diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..6fd96c1e9 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,16 @@ +# EditorConfig http://EditorConfig.org + +# top-most EditorConfig file +root = true + +# All files +[*] +charset = utf-8 +end_of_line = lf +indent_size = 2 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.sol] +indent_size = 4 diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..2e933f939 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +PRIVATE_KEY= + +ALCHEMY_KEY= + +SCAN_API_KEY= diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 000000000..6b1e7bbb1 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,13 @@ +# folders +artifacts/ +build/ +cache/ +coverage/ +dist/ +lib/ +node_modules/ +typechain/ + +# files +.solcover.js +coverage.json diff --git a/.eslintrc.yaml b/.eslintrc.yaml new file mode 100644 index 000000000..8d6890241 --- /dev/null +++ b/.eslintrc.yaml @@ -0,0 +1,21 @@ +extends: + - "eslint:recommended" + - "plugin:@typescript-eslint/eslint-recommended" + - "plugin:@typescript-eslint/recommended" + - "prettier" +parser: "@typescript-eslint/parser" +parserOptions: + project: "tsconfig.json" +plugins: + - "@typescript-eslint" +root: true +rules: + "@typescript-eslint/no-floating-promises": + - error + - ignoreIIFE: true + ignoreVoid: true + "@typescript-eslint/no-inferrable-types": "off" + "@typescript-eslint/no-unused-vars": + - warn + - argsIgnorePattern: _ + varsIgnorePattern: _ diff --git a/.gas-snapshot b/.gas-snapshot new file mode 100644 index 000000000..59fb47f8b --- /dev/null +++ b/.gas-snapshot @@ -0,0 +1,61 @@ +AABenchmarkPrepare:test_prepareBenchmarkFile() (gas: 2926370) +AccountBenchmarkTest:test_state_accountReceivesNativeTokens() (gas: 11037) +AccountBenchmarkTest:test_state_addAndWithdrawDeposit() (gas: 83332) +AccountBenchmarkTest:test_state_contractMetadata() (gas: 56507) +AccountBenchmarkTest:test_state_createAccount_viaEntrypoint() (gas: 432040) +AccountBenchmarkTest:test_state_createAccount_viaFactory() (gas: 334122) +AccountBenchmarkTest:test_state_executeBatchTransaction() (gas: 39874) +AccountBenchmarkTest:test_state_executeBatchTransaction_viaAccountSigner() (gas: 392782) +AccountBenchmarkTest:test_state_executeBatchTransaction_viaEntrypoint() (gas: 82915) +AccountBenchmarkTest:test_state_executeTransaction() (gas: 35735) +AccountBenchmarkTest:test_state_executeTransaction_viaAccountSigner() (gas: 378632) +AccountBenchmarkTest:test_state_executeTransaction_viaEntrypoint() (gas: 75593) +AccountBenchmarkTest:test_state_receiveERC1155NFT() (gas: 39343) +AccountBenchmarkTest:test_state_receiveERC721NFT() (gas: 78624) +AccountBenchmarkTest:test_state_transferOutsNativeTokens() (gas: 81713) +AirdropERC1155BenchmarkTest:test_benchmark_airdropERC1155_airdrop() (gas: 38083572) +AirdropERC20BenchmarkTest:test_benchmark_airdropERC20_airdrop() (gas: 32068413) +AirdropERC721BenchmarkTest:test_benchmark_airdropERC721_airdrop() (gas: 41912536) +DropERC1155BenchmarkTest:test_benchmark_dropERC1155_claim() (gas: 185032) +DropERC1155BenchmarkTest:test_benchmark_dropERC1155_lazyMint() (gas: 123913) +DropERC1155BenchmarkTest:test_benchmark_dropERC1155_setClaimConditions_five_conditions() (gas: 492121) +DropERC20BenchmarkTest:test_benchmark_dropERC20_claim() (gas: 230505) +DropERC20BenchmarkTest:test_benchmark_dropERC20_setClaimConditions_five_conditions() (gas: 500858) +DropERC721BenchmarkTest:test_benchmark_dropERC721_claim_five_tokens() (gas: 210967) +DropERC721BenchmarkTest:test_benchmark_dropERC721_lazyMint() (gas: 124540) +DropERC721BenchmarkTest:test_benchmark_dropERC721_lazyMint_for_delayed_reveal() (gas: 226149) +DropERC721BenchmarkTest:test_benchmark_dropERC721_reveal() (gas: 13732) +DropERC721BenchmarkTest:test_benchmark_dropERC721_setClaimConditions_five_conditions() (gas: 500494) +EditionStakeBenchmarkTest:test_benchmark_editionStake_claimRewards() (gas: 65081) +EditionStakeBenchmarkTest:test_benchmark_editionStake_stake() (gas: 185144) +EditionStakeBenchmarkTest:test_benchmark_editionStake_withdraw() (gas: 46364) +MultiwrapBenchmarkTest:test_benchmark_multiwrap_unwrap() (gas: 88950) +MultiwrapBenchmarkTest:test_benchmark_multiwrap_wrap() (gas: 473462) +NFTStakeBenchmarkTest:test_benchmark_nftStake_claimRewards() (gas: 68287) +NFTStakeBenchmarkTest:test_benchmark_nftStake_stake_five_tokens() (gas: 539145) +NFTStakeBenchmarkTest:test_benchmark_nftStake_withdraw() (gas: 38076) +PackBenchmarkTest:test_benchmark_pack_addPackContents() (gas: 219188) +PackBenchmarkTest:test_benchmark_pack_createPack() (gas: 1412868) +PackBenchmarkTest:test_benchmark_pack_openPack() (gas: 141860) +PackVRFDirectBenchmarkTest:test_benchmark_packvrf_createPack() (gas: 1379604) +PackVRFDirectBenchmarkTest:test_benchmark_packvrf_openPack() (gas: 119953) +PackVRFDirectBenchmarkTest:test_benchmark_packvrf_openPackAndClaimRewards() (gas: 3621) +SignatureDropBenchmarkTest:test_benchmark_signatureDrop_claim_five_tokens() (gas: 140517) +SignatureDropBenchmarkTest:test_benchmark_signatureDrop_lazyMint() (gas: 124311) +SignatureDropBenchmarkTest:test_benchmark_signatureDrop_lazyMint_for_delayed_reveal() (gas: 225891) +SignatureDropBenchmarkTest:test_benchmark_signatureDrop_reveal() (gas: 10647) +SignatureDropBenchmarkTest:test_benchmark_signatureDrop_setClaimConditions() (gas: 73699) +TokenERC1155BenchmarkTest:test_benchmark_tokenERC1155_burn() (gas: 5728) +TokenERC1155BenchmarkTest:test_benchmark_tokenERC1155_mintTo() (gas: 122286) +TokenERC1155BenchmarkTest:test_benchmark_tokenERC1155_mintWithSignature_pay_with_ERC20() (gas: 267175) +TokenERC1155BenchmarkTest:test_benchmark_tokenERC1155_mintWithSignature_pay_with_native_token() (gas: 296172) +TokenERC20BenchmarkTest:test_benchmark_tokenERC20_mintTo() (gas: 118586) +TokenERC20BenchmarkTest:test_benchmark_tokenERC20_mintWithSignature_pay_with_ERC20() (gas: 183032) +TokenERC20BenchmarkTest:test_benchmark_tokenERC20_mintWithSignature_pay_with_native_token() (gas: 207694) +TokenERC721BenchmarkTest:test_benchmark_tokenERC721_burn() (gas: 8954) +TokenERC721BenchmarkTest:test_benchmark_tokenERC721_mintTo() (gas: 151552) +TokenERC721BenchmarkTest:test_benchmark_tokenERC721_mintWithSignature_pay_with_ERC20() (gas: 262344) +TokenERC721BenchmarkTest:test_benchmark_tokenERC721_mintWithSignature_pay_with_native_token() (gas: 286914) +TokenStakeBenchmarkTest:test_benchmark_tokenStake_claimRewards() (gas: 67554) +TokenStakeBenchmarkTest:test_benchmark_tokenStake_stake() (gas: 177180) +TokenStakeBenchmarkTest:test_benchmark_tokenStake_withdraw() (gas: 47396) \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..7cc88f065 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.sol linguist-language=Solidity \ No newline at end of file diff --git a/.github/composite-actions/setup/action.yml b/.github/composite-actions/setup/action.yml new file mode 100644 index 000000000..a080f4495 --- /dev/null +++ b/.github/composite-actions/setup/action.yml @@ -0,0 +1,22 @@ +name: "Install" +description: "Sets up Node.js and runs install" + +runs: + using: composite + steps: + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: 18 + registry-url: "https://registry.npmjs.org" + cache: "yarn" + + - name: Install dependencies + shell: bash + run: yarn + + - name: Setup lcov + shell: bash + run: | + sudo apt update + sudo apt install -y lcov diff --git a/.github/workflows/dispatch_docs.yml b/.github/workflows/dispatch_docs.yml new file mode 100644 index 000000000..cee4a196a --- /dev/null +++ b/.github/workflows/dispatch_docs.yml @@ -0,0 +1,17 @@ +name: Dispatch Doc Generation + +on: + push: + branches: + - main + +jobs: + dispatch: + runs-on: ubuntu-latest + steps: + - name: Repository Dispatch + uses: peter-evans/repository-dispatch@v1.1.3 + with: + token: ${{ secrets.REPO_ACCESS_TOKEN }} + repository: thirdweb-dev/docs + event-type: generate-docs diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 000000000..9546895ab --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,37 @@ +# This is a basic workflow to help you get started with Actions + +name: Solhint Lint + +# Controls when the workflow will run +on: + # Triggers the workflow on push or pull request events but only for the main branch + push: + branches: [main] + pull_request: + branches: [main] + +# cancel previous runs if new commits are pushed to the branch +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +# A workflow run is made up of one or more jobs that can run sequentially or in parallel +jobs: + # This workflow contains a single job called "build" + lint: + # The type of runner that the job will run on + runs-on: ubuntu-latest + + # Steps represent a sequence of tasks that will be executed as part of the job + steps: + - name: Checkout repository + uses: actions/checkout@v3 + with: + submodules: recursive + fetch-depth: 25 + + - name: Setup Project + uses: ./.github/composite-actions/setup + + - name: Run Lint + run: yarn lint diff --git a/.github/workflows/prettier.yml b/.github/workflows/prettier.yml new file mode 100644 index 000000000..6b45fffa2 --- /dev/null +++ b/.github/workflows/prettier.yml @@ -0,0 +1,36 @@ +# This is a basic workflow to help you get started with Actions + +name: Prettier Formatting + +# Controls when the workflow will run +on: + # Triggers the workflow on push or pull request events but only for the main branch + push: + branches: [main] + pull_request: + branches: [main] + +# cancel previous runs if new commits are pushed to the branch +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + # This workflow contains a single job called "build" + lint: + # The type of runner that the job will run on + runs-on: ubuntu-latest + + # Steps represent a sequence of tasks that will be executed as part of the job + steps: + - name: Checkout repository + uses: actions/checkout@v3 + with: + submodules: recursive + fetch-depth: 25 + + - name: Setup Project + uses: ./.github/composite-actions/setup + + - name: Run Prettier + run: yarn prettier:contracts diff --git a/.github/workflows/slither.yml b/.github/workflows/slither.yml new file mode 100644 index 000000000..139eef493 --- /dev/null +++ b/.github/workflows/slither.yml @@ -0,0 +1,47 @@ +name: Slither Analysis + +on: + push: + branches: [main] + pull_request: + branches: [main] + +# cancel previous runs if new commits are pushed to the branch +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + analyze: + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + steps: + - name: Checkout repository + uses: actions/checkout@v3 + with: + submodules: recursive + fetch-depth: 25 + node-version: 18 + + - name: Setup Project + uses: ./.github/composite-actions/setup + + - name: Install Foundry + uses: onbjerg/foundry-toolchain@v1 + with: + version: nightly + + - name: Run Slither + uses: crytic/slither-action@v0.3.0 + continue-on-error: true + id: slither + with: + sarif: results.sarif + slither-args: --foundry-out-directory artifacts_forge + + - name: Upload SARIF file + uses: github/codeql-action/upload-sarif@v2 + with: + sarif_file: ${{ steps.slither.outputs.sarif }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 000000000..81bdc28dd --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,52 @@ +# This is a basic workflow to help you get started with Actions + +name: Tests + +# Controls when the workflow will run +on: + # Triggers the workflow on push or pull request events but only for the main branch + push: + branches: [main] + pull_request: + branches: [main] + +# cancel previous runs if new commits are pushed to the branch +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +# A workflow run is made up of one or more jobs that can run sequentially or in parallel +jobs: + # This workflow contains a single job called "build" + test: + # The type of runner that the job will run on + # 16 core paid runner + runs-on: ubuntu-latest-16 + + # Steps represent a sequence of tasks that will be executed as part of the job + steps: + - name: Checkout repository + uses: actions/checkout@v3 + with: + submodules: recursive + fetch-depth: 25 + node-version: 18 + + - name: Setup Project + uses: ./.github/composite-actions/setup + + - name: Install Foundry + uses: onbjerg/foundry-toolchain@v1 + with: + version: nightly + - name: Run coverage and tests + run: | + forge coverage --report lcov + lcov --remove lcov.info -o lcov.info 'src/test/**' + lcov --remove lcov.info -o lcov.info 'contracts/external-deps/**' + lcov --remove lcov.info -o lcov.info 'contracts/eip/**' + forge test + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v3 + with: + files: ./lcov.info, diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..c723a6e21 --- /dev/null +++ b/.gitignore @@ -0,0 +1,62 @@ +# folders +.coverage_artifacts/ +.coverage_cache/ +.coverage_contracts/ +artifacts/@chainlink +artifacts/@openzeppelin +artifacts/build-info +build/ +scripts/reference-scripts +cache/ +cache_hardhat-zk/ +coverage/ +dist/ +node_modules/ +typechain/ +typechain-types/ +.parcel-cache/ + +abi/ +contracts/abi/ +contracts/README.md +artifacts/ +artifacts-zk/ +artifacts_forge/ +contract_artifacts/ + +# files +*.env +*.log +*.tsbuildinfo +coverage.json +npm-debug.log* +yarn-debug.log* +yarn-error.log* +*.dbg.json +deployArgs.json + +# Dev +/relayerTest +./contracts/v2/Market.sol +/notes.txt +.yalc/ +yalc.lock + +# Forge +#/lib +/out +lcov.info + +#Build +.swc/ +out/ + +crytic-export/ +venv/ +mcore_*/ +corpus/ + +# IDES +.idea + +*.DS_Store diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..c57f0eaa8 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,51 @@ +[submodule "lib/forge-std"] + path = lib/forge-std + url = https://github.com/brockelmore/forge-std +[submodule "lib/ds-test"] + path = lib/ds-test + url = https://github.com/dapphub/ds-test +[submodule "lib/openzeppelin-contracts"] + path = lib/openzeppelin-contracts + url = https://github.com/openzeppelin/openzeppelin-contracts +[submodule "lib/openzeppelin-contracts-upgradeable"] + path = lib/openzeppelin-contracts-upgradeable + url = https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable +[submodule "lib/chainlink"] + path = lib/chainlink + url = https://github.com/smartcontractkit/chainlink +[submodule "lib/ERC721A-Upgradeable"] + path = lib/ERC721A-Upgradeable + url = https://github.com/chiru-labs/ERC721A-Upgradeable +[submodule "lib/ERC721A"] + path = lib/ERC721A + url = https://github.com/chiru-labs/ERC721A +[submodule "lib/dynamic-contracts"] + path = lib/dynamic-contracts + url = https://github.com/thirdweb-dev/dynamic-contracts +[submodule "lib/solady"] + path = lib/solady + url = https://github.com/vectorized/solady +[submodule "lib/seaport"] + path = lib/seaport + url = https://github.com/ProjectOpenSea/seaport +[submodule "lib/murky"] + path = lib/murky + url = https://github.com/dmfxyz/murky +[submodule "lib/openzeppelin-contracts"] + path = lib/openzeppelin-contracts + url = https://github.com/openzeppelin/openzeppelin-contracts +[submodule "lib/solmate"] + path = lib/solmate + url = https://github.com/transmissions11/solmate +[submodule "lib/solarray"] + path = lib/solarray + url = https://github.com/emo-eth/solarray +[submodule "lib/seaport-types"] + path = lib/seaport-types + url = https://github.com/projectopensea/seaport-types +[submodule "lib/seaport-core"] + path = lib/seaport-core + url = https://github.com/projectopensea/seaport-core +[submodule "lib/seaport-sol"] + path = lib/seaport-sol + url = https://github.com/projectopensea/seaport-sol diff --git a/.npmignore b/.npmignore new file mode 100644 index 000000000..c2658d7d1 --- /dev/null +++ b/.npmignore @@ -0,0 +1 @@ +node_modules/ diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 000000000..11631ff81 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,13 @@ +# folders +artifacts/ +artifacts_forge/ +build/ +cache/ +coverage/ +dist/ +node_modules/ +typechain/ + +# files +src/test/smart-wallet/utils/AABenchmarkArtifacts.sol +coverage.json diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 000000000..09e99ec7c --- /dev/null +++ b/.prettierrc @@ -0,0 +1,18 @@ +{ + "arrowParens": "avoid", + "bracketSpacing": true, + "endOfLine":"auto", + "printWidth": 120, + "useTabs": false, + "singleQuote": false, + "tabWidth": 2, + "trailingComma": "all", + "overrides": [ + { + "files": "*.sol", + "options": { + "tabWidth": 4 + } + } + ] +} diff --git a/.solhint.json b/.solhint.json new file mode 100644 index 000000000..aa45d9874 --- /dev/null +++ b/.solhint.json @@ -0,0 +1,38 @@ +{ + "extends": "solhint:recommended", + "plugins": ["prettier"], + "rules": { + "imports-on-top": "error", + "no-unused-vars": "error", + "code-complexity": ["error", 9], + "compiler-version": ["error", "^0.8.0"], + "const-name-snakecase": "error", + "event-name-camelcase": "error", + "constructor-syntax": "error", + "func-name-mixedcase": "off", + "func-param-name-mixedcase": "error", + "modifier-name-mixedcase": "error", + "private-vars-leading-underscore": "off", + "var-name-mixedcase": "error", + "func-visibility": ["error", { "ignoreConstructors": true }], + "not-rely-on-time": "off", + "no-empty-blocks": "off", + "contract-name-camelcase": "off", + "no-inline-assembly": "off", + "prettier/prettier": [ + "error", + { + "arrowParens": "avoid", + "bracketSpacing": true, + "endOfLine": "auto", + "printWidth": 120, + "useTabs": false, + "singleQuote": false, + "tabWidth": 4, + "trailingComma": "all", + "explicitTypes": "always" + } + ], + "reason-string": ["warn", { "maxLength": 64 }] + } +} diff --git a/.solhintignore b/.solhintignore new file mode 100644 index 000000000..05b9f2ff3 --- /dev/null +++ b/.solhintignore @@ -0,0 +1,6 @@ +# folders +.yarn/ +build/ +dist/ +node_modules/ +contracts/openzeppelin-presets/ diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 000000000..de17546a2 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,83 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at . All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of actions. + +**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, available at [https://www.contributor-covenant.org/version/2/0/code_of_conduct.html][v2.0]. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.0]: https://www.contributor-covenant.org/version/2/0/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 000000000..88e8b41ee --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2021 Non-Fungible Labs, Inc + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 000000000..8643c0471 --- /dev/null +++ b/README.md @@ -0,0 +1,144 @@ +

+
+ +
+

+

thirdweb Contracts

+

+npm version +Build Status +Join our Discord! + +

+

Collection of smart contracts deployable via the thirdweb SDK, dashboard and CLI

+
+ +## Installation + +```shell +# Forge projects +forge install https://github.com/thirdweb-dev/contracts + +# Hardhat / npm based projects +npm i @thirdweb-dev/contracts +``` + +```bash +contracts +| +|-- extension: "extensions that can be inherited by NON-upgradeable contracts" +| |-- interface: "interfaces of all extension contracts" +| |-- upgradeable: "extensions that can be inherited by upgradeable contracts" +| |-- [$prebuilt-category]: "legacy extensions written specifically for a prebuilt contract" +| +|-- base: "NON-upgradeable base contracts to build on top of" +| |-- interface: "interfaces for all base contracts" +| |-- upgradeable: "upgradeable base contracts to build on top of" +| +|-- prebuilt: "audited, ready-to-deploy thirdweb smart contracts" +| |-- interface: "interfaces for all prebuilt contracts" +| |--[$prebuilt-category]: "feature-based group of prebuilt contracts" +| |-- unaudited: "yet-to-audit thirdweb smart contracts" +| |-- [$prebuilt-category]: "feature-based group of prebuilt contracts" +| +|-- infra: "onchain infrastructure contracts" +| |-- interface: "interfaces for all infrastructure contracts" +| +|-- eip: "implementations of relevant EIP standards" +| |-- interface "all interfaces of relevant EIP standards" +| +|-- lib: "Solidity libraries" +| +|-- external-deps: "modified / copied over external dependencies" +| |-- openzeppelin: "modified / copied over openzeppelin dependencies" +| |-- chainlink: "modified / copied over chainlink dependencies" +| +|-- legacy-contracts: "maintained legacy thirdweb contracts" +``` + +## Running Tests + +1. `yarn`: install contracts dependencies +2. `forge install`: install tests dependencies +3. `forge test`: run the tests + +This repository is a [forge](https://github.com/foundry-rs/foundry/tree/master/forge) project. + +First install the relevant dependencies of the project: + +```bash +yarn + +forge install +``` + +To compile contracts, run: + +```bash +forge build +``` + +To run tests: + +```bash +forge test +``` + +## Pre-built Contracts + +Pre-built contracts are written by the thirdweb team, and cover the most common use cases for smart contracts. + +- [DropERC20](https://thirdweb.com/deployer.thirdweb.eth/DropERC20) +- [DropERC721](https://thirdweb.com/deployer.thirdweb.eth/DropERC721) +- [DropERC1155](https://thirdweb.com/deployer.thirdweb.eth/DropERC1155) +- [SignatureDrop](https://thirdweb.com/deployer.thirdweb.eth/SignatureDrop) +- [Marketplace](https://thirdweb.com/deployer.thirdweb.eth/Marketplace) +- [Multiwrap](https://thirdweb.com/deployer.thirdweb.eth/Multiwrap) +- [TokenERC20](https://thirdweb.com/deployer.thirdweb.eth/TokenERC20) +- [TokenERC721](https://thirdweb.com/deployer.thirdweb.eth/TokenERC721) +- [TokenERC1155](https://thirdweb.com/deployer.thirdweb.eth/TokenERC1155) +- [VoteERC20](https://thirdweb.com/deployer.thirdweb.eth/VoteERC20) +- [Split](https://thirdweb.com/deployer.thirdweb.eth/Split) + +[Learn more about pre-built contracts](https://portal.thirdweb.com/pre-built-contracts) + +## Extensions + +Extensions are building blocks that help enrich smart contracts with features. + +Some blocks come packaged together as Base Contracts, which come with a full set of features out of the box that you can modify and extend. These contracts are available at `contracts/base/`. + +Other (smaller) blocks are Features, which provide a way for you to pick and choose which individual pieces you want to put into your contract; with full customization of how those features work. These are available at `contracts/extension/`. + +[Learn more about extensions](https://portal.thirdweb.com/extensions) + +## Contract Audits + +- [Audit 1](audit-reports/audit-1.pdf) +- [Audit 2](audit-reports/audit-2.pdf) +- [Audit 3](audit-reports/audit-3.pdf) +- [Audit 4](audit-reports/audit-4.pdf) +- [Audit 5](audit-reports/audit-5.pdf) +- [Audit 6](audit-reports/audit-6.pdf) +- [Audit 7](audit-reports/audit-7.pdf) +- [Audit 8](audit-reports/audit-8.pdf) +- [Audit 9](audit-reports/audit-9.pdf) +- [Audit 10](audit-reports/audit-10.pdf) +- [Audit 11](audit-reports/audit-11.pdf) +- [Audit 12](audit-reports/audit-12.pdf) + +## Bug reports + +Found a security issue with our smart contracts? Send bug reports to security@thirdweb.com and we'll continue communicating with you from there. We're actively developing a bug bounty program; bug report payouts happen on a case by case basis, for now. + +## Feedback + +If you have any feedback, please reach out to us at support@thirdweb.com. + +## Authors + +- [thirdweb](https://thirdweb.com) + +## License + +[Apache 2.0](https://www.apache.org/licenses/LICENSE-2.0.txt) diff --git a/assets/claim-conditions-diagram-1.png b/assets/claim-conditions-diagram-1.png new file mode 100644 index 000000000..ede2f7e65 Binary files /dev/null and b/assets/claim-conditions-diagram-1.png differ diff --git a/assets/marketplace-1.png b/assets/marketplace-1.png new file mode 100644 index 000000000..f7b9af29f Binary files /dev/null and b/assets/marketplace-1.png differ diff --git a/assets/multiwrap-diagram.png b/assets/multiwrap-diagram.png new file mode 100644 index 000000000..e435acb27 Binary files /dev/null and b/assets/multiwrap-diagram.png differ diff --git a/assets/pack-diag-1.png b/assets/pack-diag-1.png new file mode 100644 index 000000000..99e980a4a Binary files /dev/null and b/assets/pack-diag-1.png differ diff --git a/assets/pack-diag-2.png b/assets/pack-diag-2.png new file mode 100644 index 000000000..cc3658a31 Binary files /dev/null and b/assets/pack-diag-2.png differ diff --git a/assets/pack-diag-3.png b/assets/pack-diag-3.png new file mode 100644 index 000000000..84457465c Binary files /dev/null and b/assets/pack-diag-3.png differ diff --git a/assets/signature-drop-diag.png b/assets/signature-drop-diag.png new file mode 100644 index 000000000..ca1c1a6bb Binary files /dev/null and b/assets/signature-drop-diag.png differ diff --git a/assets/signature-minting-diag-1.png b/assets/signature-minting-diag-1.png new file mode 100644 index 000000000..b7b93a3dc Binary files /dev/null and b/assets/signature-minting-diag-1.png differ diff --git a/audit-reports/audit-1.pdf b/audit-reports/audit-1.pdf new file mode 100644 index 000000000..8a459857a Binary files /dev/null and b/audit-reports/audit-1.pdf differ diff --git a/audit-reports/audit-10.pdf b/audit-reports/audit-10.pdf new file mode 100644 index 000000000..2de17d271 Binary files /dev/null and b/audit-reports/audit-10.pdf differ diff --git a/audit-reports/audit-11.pdf b/audit-reports/audit-11.pdf new file mode 100644 index 000000000..de58f2bb8 Binary files /dev/null and b/audit-reports/audit-11.pdf differ diff --git a/audit-reports/audit-12.pdf b/audit-reports/audit-12.pdf new file mode 100644 index 000000000..b3a954ef5 Binary files /dev/null and b/audit-reports/audit-12.pdf differ diff --git a/audit-reports/audit-13.pdf b/audit-reports/audit-13.pdf new file mode 100644 index 000000000..fb3ca86c7 Binary files /dev/null and b/audit-reports/audit-13.pdf differ diff --git a/audit-reports/audit-14.pdf b/audit-reports/audit-14.pdf new file mode 100644 index 000000000..35d8df4d3 Binary files /dev/null and b/audit-reports/audit-14.pdf differ diff --git a/audit-reports/audit-15.pdf b/audit-reports/audit-15.pdf new file mode 100644 index 000000000..804e33b49 Binary files /dev/null and b/audit-reports/audit-15.pdf differ diff --git a/audit-reports/audit-18.pdf b/audit-reports/audit-18.pdf new file mode 100644 index 000000000..e9a834521 Binary files /dev/null and b/audit-reports/audit-18.pdf differ diff --git a/audit-reports/audit-2.pdf b/audit-reports/audit-2.pdf new file mode 100644 index 000000000..2dd1ae41b Binary files /dev/null and b/audit-reports/audit-2.pdf differ diff --git a/audit-reports/audit-3.pdf b/audit-reports/audit-3.pdf new file mode 100644 index 000000000..5c36d9b13 Binary files /dev/null and b/audit-reports/audit-3.pdf differ diff --git a/audit-reports/audit-4.pdf b/audit-reports/audit-4.pdf new file mode 100644 index 000000000..bfe2a4a6b Binary files /dev/null and b/audit-reports/audit-4.pdf differ diff --git a/audit-reports/audit-5.pdf b/audit-reports/audit-5.pdf new file mode 100644 index 000000000..44a51b0c7 Binary files /dev/null and b/audit-reports/audit-5.pdf differ diff --git a/audit-reports/audit-6.pdf b/audit-reports/audit-6.pdf new file mode 100644 index 000000000..7c16f8262 Binary files /dev/null and b/audit-reports/audit-6.pdf differ diff --git a/audit-reports/audit-7.pdf b/audit-reports/audit-7.pdf new file mode 100644 index 000000000..418113d63 Binary files /dev/null and b/audit-reports/audit-7.pdf differ diff --git a/audit-reports/audit-8.pdf b/audit-reports/audit-8.pdf new file mode 100644 index 000000000..fc6679d73 Binary files /dev/null and b/audit-reports/audit-8.pdf differ diff --git a/audit-reports/audit-9.pdf b/audit-reports/audit-9.pdf new file mode 100644 index 000000000..27cbc5a6d Binary files /dev/null and b/audit-reports/audit-9.pdf differ diff --git a/audit-reports/preliminary-audits/airdroperc20-claimable.md b/audit-reports/preliminary-audits/airdroperc20-claimable.md new file mode 100644 index 000000000..aa22f074b --- /dev/null +++ b/audit-reports/preliminary-audits/airdroperc20-claimable.md @@ -0,0 +1,11 @@ +This document contains details on fixes / response to the preliminary audit reports added to this repository. + +## [AirdropERC20Claimable](./airdroperc20-claimable.pdf) + +### 01: Governance: TrustedForwarder can execute claims on behalf of other addresses + +- The contract doesn't add a trusted-forwarder address by default. The deployer of AirdropERC20Claimable can specify which forwarder they want to use (if any), or leave as address zero. + +### 02: Malicious users can steal the entire balance of the contract + +- This refers to the possibility of a sybil attack on open/public claims, where multiple wallets can be created to claim the quantity specified by `openClaimLimitPerWallet`. To prevent this scenario or any kind of public claiming, deployer can set `openClaimLimitPerWallet` to zero when setting claim conditions during deployment. diff --git a/audit-reports/preliminary-audits/airdroperc20-claimable.pdf b/audit-reports/preliminary-audits/airdroperc20-claimable.pdf new file mode 100644 index 000000000..64a02af70 Binary files /dev/null and b/audit-reports/preliminary-audits/airdroperc20-claimable.pdf differ diff --git a/contracts/base/ERC1155Base.sol b/contracts/base/ERC1155Base.sol new file mode 100644 index 000000000..d19da2065 --- /dev/null +++ b/contracts/base/ERC1155Base.sol @@ -0,0 +1,282 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import { ERC1155 } from "../eip/ERC1155.sol"; + +import "../extension/ContractMetadata.sol"; +import "../extension/Multicall.sol"; +import "../extension/Ownable.sol"; +import "../extension/Royalty.sol"; +import "../extension/BatchMintMetadata.sol"; + +import "../lib/Strings.sol"; + +/** + * The `ERC1155Base` smart contract implements the ERC1155 NFT standard. + * It includes the following additions to standard ERC1155 logic: + * + * - Ability to mint NFTs via the provided `mintTo` and `batchMintTo` functions. + * + * - Contract metadata for royalty support on platforms such as OpenSea that use + * off-chain information to distribute roaylties. + * + * - Ownership of the contract, with the ability to restrict certain functions to + * only be called by the contract's owner. + * + * - Multicall capability to perform multiple actions atomically + * + * - EIP 2981 compliance for royalty support on NFT marketplaces. + */ + +contract ERC1155Base is ERC1155, ContractMetadata, Ownable, Royalty, Multicall, BatchMintMetadata { + using Strings for uint256; + + /*////////////////////////////////////////////////////////////// + State variables + //////////////////////////////////////////////////////////////*/ + + /// @dev The tokenId of the next NFT to mint. + uint256 internal nextTokenIdToMint_; + + /*////////////////////////////////////////////////////////////// + Mappings + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Returns the total supply of NFTs of a given tokenId + * @dev Mapping from tokenId => total circulating supply of NFTs of that tokenId. + */ + mapping(uint256 => uint256) public totalSupply; + + /*////////////////////////////////////////////////////////////// + Constructor + //////////////////////////////////////////////////////////////*/ + + constructor( + address _defaultAdmin, + string memory _name, + string memory _symbol, + address _royaltyRecipient, + uint128 _royaltyBps + ) ERC1155(_name, _symbol) { + _setupOwner(_defaultAdmin); + _setupDefaultRoyaltyInfo(_royaltyRecipient, _royaltyBps); + } + + /*////////////////////////////////////////////////////////////// + Overriden metadata logic + //////////////////////////////////////////////////////////////*/ + + /// @notice Returns the metadata URI for the given tokenId. + /// @param _tokenId The tokenId of the token for which a URI should be returned. + /// @return The metadata URI for the given tokenId. + function uri(uint256 _tokenId) public view virtual override returns (string memory) { + string memory uriForToken = _uri[_tokenId]; + if (bytes(uriForToken).length > 0) { + return uriForToken; + } + + string memory batchUri = _getBaseURI(_tokenId); + return string(abi.encodePacked(batchUri, _tokenId.toString())); + } + + /*////////////////////////////////////////////////////////////// + Mint / burn logic + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Lets an authorized address mint NFTs to a recipient. + * @dev - The logic in the `_canMint` function determines whether the caller is authorized to mint NFTs. + * - If `_tokenId == type(uint256).max` a new NFT at tokenId `nextTokenIdToMint` is minted. If the given + * `tokenId < nextTokenIdToMint`, then additional supply of an existing NFT is being minted. + * + * @param _to The recipient of the NFTs to mint. + * @param _tokenId The tokenId of the NFT to mint. + * @param _tokenURI The full metadata URI for the NFTs minted (if a new NFT is being minted). + * @param _amount The amount of the same NFT to mint. + */ + function mintTo(address _to, uint256 _tokenId, string memory _tokenURI, uint256 _amount) public virtual { + require(_canMint(), "Not authorized to mint."); + + uint256 tokenIdToMint; + uint256 nextIdToMint = nextTokenIdToMint(); + + if (_tokenId == type(uint256).max) { + tokenIdToMint = nextIdToMint; + nextTokenIdToMint_ += 1; + _setTokenURI(nextIdToMint, _tokenURI); + } else { + require(_tokenId < nextIdToMint, "invalid id"); + tokenIdToMint = _tokenId; + } + + _mint(_to, tokenIdToMint, _amount, ""); + } + + /** + * @notice Lets an authorized address mint multiple NEW NFTs at once to a recipient. + * @dev The logic in the `_canMint` function determines whether the caller is authorized to mint NFTs. + * If `_tokenIds[i] == type(uint256).max` a new NFT at tokenId `nextTokenIdToMint` is minted. If the given + * `tokenIds[i] < nextTokenIdToMint`, then additional supply of an existing NFT is minted. + * The metadata for each new NFT is stored at `baseURI/{tokenID of NFT}` + * + * @param _to The recipient of the NFT to mint. + * @param _tokenIds The tokenIds of the NFTs to mint. + * @param _amounts The amounts of each NFT to mint. + * @param _baseURI The baseURI for the `n` number of NFTs minted. The metadata for each NFT is `baseURI/tokenId` + */ + function batchMintTo( + address _to, + uint256[] memory _tokenIds, + uint256[] memory _amounts, + string memory _baseURI + ) public virtual { + require(_canMint(), "Not authorized to mint."); + require(_amounts.length > 0, "Minting zero tokens."); + require(_tokenIds.length == _amounts.length, "Length mismatch."); + + uint256 nextIdToMint = nextTokenIdToMint(); + uint256 startNextIdToMint = nextIdToMint; + + uint256 numOfNewNFTs; + + for (uint256 i = 0; i < _tokenIds.length; i += 1) { + if (_tokenIds[i] == type(uint256).max) { + _tokenIds[i] = nextIdToMint; + + nextIdToMint += 1; + numOfNewNFTs += 1; + } else { + require(_tokenIds[i] < nextIdToMint, "invalid id"); + } + } + + if (numOfNewNFTs > 0) { + _batchMintMetadata(startNextIdToMint, numOfNewNFTs, _baseURI); + } + + nextTokenIdToMint_ = nextIdToMint; + _mintBatch(_to, _tokenIds, _amounts, ""); + } + + /** + * @notice Lets an owner or approved operator burn NFTs of the given tokenId. + * + * @param _owner The owner of the NFT to burn. + * @param _tokenId The tokenId of the NFT to burn. + * @param _amount The amount of the NFT to burn. + */ + function burn(address _owner, uint256 _tokenId, uint256 _amount) external virtual { + address caller = msg.sender; + + require(caller == _owner || isApprovedForAll[_owner][caller], "Unapproved caller"); + require(balanceOf[_owner][_tokenId] >= _amount, "Not enough tokens owned"); + + _burn(_owner, _tokenId, _amount); + } + + /** + * @notice Lets an owner or approved operator burn NFTs of the given tokenIds. + * + * @param _owner The owner of the NFTs to burn. + * @param _tokenIds The tokenIds of the NFTs to burn. + * @param _amounts The amounts of the NFTs to burn. + */ + function burnBatch(address _owner, uint256[] memory _tokenIds, uint256[] memory _amounts) external virtual { + address caller = msg.sender; + + require(caller == _owner || isApprovedForAll[_owner][caller], "Unapproved caller"); + require(_tokenIds.length == _amounts.length, "Length mismatch"); + + for (uint256 i = 0; i < _tokenIds.length; i += 1) { + require(balanceOf[_owner][_tokenIds[i]] >= _amounts[i], "Not enough tokens owned"); + } + + _burnBatch(_owner, _tokenIds, _amounts); + } + + /*////////////////////////////////////////////////////////////// + ERC165 Logic + //////////////////////////////////////////////////////////////*/ + + /** + * @dev See ERC165: https://eips.ethereum.org/EIPS/eip-165 + * @inheritdoc IERC165 + */ + function supportsInterface(bytes4 interfaceId) public view virtual override(ERC1155, IERC165) returns (bool) { + return + interfaceId == 0x01ffc9a7 || // ERC165 Interface ID for ERC165 + interfaceId == 0xd9b67a26 || // ERC165 Interface ID for ERC1155 + interfaceId == 0x0e89341c || // ERC165 Interface ID for ERC1155MetadataURI + interfaceId == type(IERC2981).interfaceId; // ERC165 ID for ERC2981 + } + + /*////////////////////////////////////////////////////////////// + View functions + //////////////////////////////////////////////////////////////*/ + + /// @notice The tokenId assigned to the next new NFT to be minted. + function nextTokenIdToMint() public view virtual returns (uint256) { + return nextTokenIdToMint_; + } + + /*////////////////////////////////////////////////////////////// + Internal (overrideable) functions + //////////////////////////////////////////////////////////////*/ + + /// @dev Returns whether contract metadata can be set in the given execution context. + /// @return Whether contract metadata can be set in the given execution context. + function _canSetContractURI() internal view virtual override returns (bool) { + return msg.sender == owner(); + } + + /// @dev Returns whether a token can be minted in the given execution context. + /// @return Whether a token can be minted in the given execution context. + function _canMint() internal view virtual returns (bool) { + return msg.sender == owner(); + } + + /// @dev Returns whether owner can be set in the given execution context. + /// @return Whether owner can be set in the given execution context. + function _canSetOwner() internal view virtual override returns (bool) { + return msg.sender == owner(); + } + + /// @dev Returns whether royalty info can be set in the given execution context. + /// @return Whether royalty info can be set in the given execution context. + function _canSetRoyaltyInfo() internal view virtual override returns (bool) { + return msg.sender == owner(); + } + + /// @dev Runs before every token transfer / mint / burn. + /// @param operator The address of the caller. + /// @param from The address of the sender. + /// @param to The address of the recipient. + /// @param ids The tokenIds of the tokens being transferred. + /// @param amounts The amounts of the tokens being transferred. + /// @param data Additional data with no specified format. + function _beforeTokenTransfer( + address operator, + address from, + address to, + uint256[] memory ids, + uint256[] memory amounts, + bytes memory data + ) internal virtual override { + super._beforeTokenTransfer(operator, from, to, ids, amounts, data); + + if (from == address(0)) { + for (uint256 i = 0; i < ids.length; ++i) { + totalSupply[ids[i]] += amounts[i]; + } + } + + if (to == address(0)) { + for (uint256 i = 0; i < ids.length; ++i) { + totalSupply[ids[i]] -= amounts[i]; + } + } + } +} diff --git a/contracts/base/ERC1155DelayedReveal.sol b/contracts/base/ERC1155DelayedReveal.sol new file mode 100644 index 000000000..3b06ef207 --- /dev/null +++ b/contracts/base/ERC1155DelayedReveal.sol @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "./ERC1155LazyMint.sol"; +import "../extension/DelayedReveal.sol"; + +/** + * BASE: ERC1155LazyMint + * EXTENSION: DelayedReveal + * + * The `ERC1155DelayedReveal` contract uses the `DelayedReveal` extension. + * + * 'Lazy minting' means defining the metadata of NFTs without minting it to an address. Regular 'minting' + * of NFTs means actually assigning an owner to an NFT. + * + * As a contract admin, this lets you prepare the metadata for NFTs that will be minted by an external party, + * without paying the gas cost for actually minting the NFTs. + * + * 'Delayed reveal' is a mechanism by which you can distribute NFTs to your audience and reveal the metadata of the distributed + * NFTs, after the fact. + * + * You can read more about how the `DelayedReveal` extension works, here: https://blog.thirdweb.com/delayed-reveal-nfts + */ + +contract ERC1155DelayedReveal is ERC1155LazyMint, DelayedReveal { + using Strings for uint256; + + /*////////////////////////////////////////////////////////////// + Constructor + //////////////////////////////////////////////////////////////*/ + + constructor( + address _defaultAdmin, + string memory _name, + string memory _symbol, + address _royaltyRecipient, + uint128 _royaltyBps + ) ERC1155LazyMint(_defaultAdmin, _name, _symbol, _royaltyRecipient, _royaltyBps) {} + + /*////////////////////////////////////////////////////////////// + Overriden Metadata logic + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Returns the metadata URI for an NFT. + * @dev See `BatchMintMetadata` for handling of metadata in this contract. + * + * @param _tokenId The tokenId of an NFT. + */ + function uri(uint256 _tokenId) public view virtual override returns (string memory) { + (uint256 batchId, ) = _getBatchId(_tokenId); + string memory batchUri = _getBaseURI(_tokenId); + + if (isEncryptedBatch(batchId)) { + return string(abi.encodePacked(batchUri, "0")); + } else { + return string(abi.encodePacked(batchUri, _tokenId.toString())); + } + } + + /*////////////////////////////////////////////////////////////// + Lazy minting logic + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Lets an authorized address lazy mint a given amount of NFTs. + * + * @param _amount The number of NFTs to lazy mint. + * @param _baseURIForTokens The placeholder base URI for the 'n' number of NFTs being lazy minted, where the + * metadata for each of those NFTs is `${baseURIForTokens}/${tokenId}`. + * @param _data The encrypted base URI + provenance hash for the batch of NFTs being lazy minted. + * @return batchId A unique integer identifier for the batch of NFTs lazy minted together. + */ + function lazyMint( + uint256 _amount, + string calldata _baseURIForTokens, + bytes calldata _data + ) public virtual override returns (uint256 batchId) { + if (_data.length > 0) { + (bytes memory encryptedURI, bytes32 provenanceHash) = abi.decode(_data, (bytes, bytes32)); + if (encryptedURI.length != 0 && provenanceHash != "") { + _setEncryptedData(nextTokenIdToLazyMint + _amount, _data); + } + } + + return super.lazyMint(_amount, _baseURIForTokens, _data); + } + + /*////////////////////////////////////////////////////////////// + Delayed reveal logic + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Lets an authorized address reveal a batch of delayed reveal NFTs. + * + * @param _index The ID for the batch of delayed-reveal NFTs to reveal. + * @param _key The key with which the base URI for the relevant batch of NFTs was encrypted. + */ + function reveal(uint256 _index, bytes calldata _key) external virtual override returns (string memory revealedURI) { + require(_canReveal(), "Not authorized"); + + uint256 batchId = getBatchIdAtIndex(_index); + revealedURI = getRevealURI(batchId, _key); + + _setEncryptedData(batchId, ""); + _setBaseURI(batchId, revealedURI); + + emit TokenURIRevealed(_index, revealedURI); + } + + /// @dev Checks whether NFTs can be revealed in the given execution context. + function _canReveal() internal view virtual returns (bool) { + return msg.sender == owner(); + } +} diff --git a/contracts/base/ERC1155Drop.sol b/contracts/base/ERC1155Drop.sol new file mode 100644 index 000000000..a85a1ba56 --- /dev/null +++ b/contracts/base/ERC1155Drop.sol @@ -0,0 +1,368 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import { ERC1155 } from "../eip/ERC1155.sol"; + +import "../extension/ContractMetadata.sol"; +import "../extension/Multicall.sol"; +import "../extension/Ownable.sol"; +import "../extension/Royalty.sol"; +import "../extension/BatchMintMetadata.sol"; +import "../extension/PrimarySale.sol"; +import "../extension/DropSinglePhase1155.sol"; +import "../extension/LazyMint.sol"; +import "../extension/DelayedReveal.sol"; + +import { CurrencyTransferLib } from "../lib/CurrencyTransferLib.sol"; +import "../lib/Strings.sol"; + +/** + * BASE: ERC1155Base + * EXTENSION: DropSinglePhase1155 + * + * The `ERC1155Base` smart contract implements the ERC1155 NFT standard. + * It includes the following additions to standard ERC1155 logic: + * + * - Contract metadata for royalty support on platforms such as OpenSea that use + * off-chain information to distribute roaylties. + * + * - Ownership of the contract, with the ability to restrict certain functions to + * only be called by the contract's owner. + * + * - Multicall capability to perform multiple actions atomically + * + * - EIP 2981 compliance for royalty support on NFT marketplaces. + * + * The `drop` mechanism in the `DropSinglePhase1155` extension is a distribution mechanism for lazy minted tokens. It lets + * you set restrictions such as a price to charge, an allowlist etc. when an address atttempts to mint lazy minted tokens. + * + * The `ERC721Drop` contract lets you lazy mint tokens, and distribute those lazy minted tokens via the drop mechanism. + */ + +contract ERC1155Drop is + ERC1155, + ContractMetadata, + Ownable, + Royalty, + Multicall, + BatchMintMetadata, + PrimarySale, + LazyMint, + DelayedReveal, + DropSinglePhase1155 +{ + using Strings for uint256; + + /*////////////////////////////////////////////////////////////// + Mappings + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Returns the total supply of NFTs of a given tokenId + * @dev Mapping from tokenId => total circulating supply of NFTs of that tokenId. + */ + mapping(uint256 => uint256) public totalSupply; + + /*/////////////////////////////////////////////////////////////// + Constructor + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Initializes the contract with the given parameters. + * + * @param _defaultAdmin The default admin for the contract. + * @param _name The name of the contract. + * @param _symbol The symbol of the contract. + * @param _royaltyRecipient The address to which royalties should be sent. + * @param _royaltyBps The royalty basis points to be charged. Max = 10000 (10000 = 100%, 1000 = 10%) + * @param _primarySaleRecipient The address to which primary sale revenue should be sent. + */ + constructor( + address _defaultAdmin, + string memory _name, + string memory _symbol, + address _royaltyRecipient, + uint128 _royaltyBps, + address _primarySaleRecipient + ) ERC1155(_name, _symbol) { + _setupOwner(_defaultAdmin); + _setupDefaultRoyaltyInfo(_royaltyRecipient, _royaltyBps); + _setupPrimarySaleRecipient(_primarySaleRecipient); + } + + /*////////////////////////////////////////////////////////////// + ERC165 Logic + //////////////////////////////////////////////////////////////*/ + + /** + * @dev See ERC165: https://eips.ethereum.org/EIPS/eip-165 + * @inheritdoc IERC165 + */ + function supportsInterface(bytes4 interfaceId) public view virtual override(ERC1155, IERC165) returns (bool) { + return + interfaceId == 0x01ffc9a7 || // ERC165 Interface ID for ERC165 + interfaceId == 0xd9b67a26 || // ERC165 Interface ID for ERC1155 + interfaceId == 0x0e89341c || // ERC165 Interface ID for ERC1155MetadataURI + interfaceId == type(IERC2981).interfaceId; // ERC165 ID for ERC2981 + } + + /*////////////////////////////////////////////////////////////// + Minting/burning logic + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Lets an owner or approved operator burn NFTs of the given tokenId. + * + * @param _owner The owner of the NFT to burn. + * @param _tokenId The tokenId of the NFT to burn. + * @param _amount The amount of the NFT to burn. + */ + function burn(address _owner, uint256 _tokenId, uint256 _amount) external virtual { + address caller = msg.sender; + + require(caller == _owner || isApprovedForAll[_owner][caller], "Unapproved caller"); + require(balanceOf[_owner][_tokenId] >= _amount, "Not enough tokens owned"); + + _burn(_owner, _tokenId, _amount); + } + + /** + * @notice Lets an owner or approved operator burn NFTs of the given tokenIds. + * + * @param _owner The owner of the NFTs to burn. + * @param _tokenIds The tokenIds of the NFTs to burn. + * @param _amounts The amounts of the NFTs to burn. + */ + function burnBatch(address _owner, uint256[] memory _tokenIds, uint256[] memory _amounts) external virtual { + address caller = msg.sender; + + require(caller == _owner || isApprovedForAll[_owner][caller], "Unapproved caller"); + require(_tokenIds.length == _amounts.length, "Length mismatch"); + + for (uint256 i = 0; i < _tokenIds.length; i += 1) { + require(balanceOf[_owner][_tokenIds[i]] >= _amounts[i], "Not enough tokens owned"); + } + + _burnBatch(_owner, _tokenIds, _amounts); + } + + /*/////////////////////////////////////////////////////////////// + Overriden metadata logic + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Returns the metadata URI for an NFT. + * @dev See `BatchMintMetadata` for handling of metadata in this contract. + * + * @param _tokenId The tokenId of an NFT. + * @return The metadata URI for the given NFT. + */ + function uri(uint256 _tokenId) public view virtual override returns (string memory) { + (uint256 batchId, ) = _getBatchId(_tokenId); + string memory batchUri = _getBaseURI(_tokenId); + + if (isEncryptedBatch(batchId)) { + return string(abi.encodePacked(batchUri, "0")); + } else { + return string(abi.encodePacked(batchUri, _tokenId.toString())); + } + } + + /*/////////////////////////////////////////////////////////////// + Delayed reveal logic + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Lets an authorized address reveal a batch of delayed reveal NFTs. + * + * @param _index The ID for the batch of delayed-reveal NFTs to reveal. + * @param _key The key with which the base URI for the relevant batch of NFTs was encrypted. + * @return revealedURI The revealed URI for the batch of NFTs. + */ + function reveal(uint256 _index, bytes calldata _key) public virtual override returns (string memory revealedURI) { + require(_canReveal(), "Not authorized"); + + uint256 batchId = getBatchIdAtIndex(_index); + revealedURI = getRevealURI(batchId, _key); + + _setEncryptedData(batchId, ""); + _setBaseURI(batchId, revealedURI); + + emit TokenURIRevealed(_index, revealedURI); + } + + /*/////////////////////////////////////////////////////////////// + Overriden lazy minting logic + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Lets an authorized address lazy mint a given amount of NFTs. + * + * @param _amount The number of NFTs to lazy mint. + * @param _baseURIForTokens The placeholder base URI for the 'n' number of NFTs being lazy minted, where the + * metadata for each of those NFTs is `${baseURIForTokens}/${tokenId}`. + * @param _data The encrypted base URI + provenance hash for the batch of NFTs being lazy minted. + * @return batchId A unique integer identifier for the batch of NFTs lazy minted together. + */ + function lazyMint( + uint256 _amount, + string calldata _baseURIForTokens, + bytes calldata _data + ) public virtual override returns (uint256 batchId) { + if (_data.length > 0) { + (bytes memory encryptedURI, bytes32 provenanceHash) = abi.decode(_data, (bytes, bytes32)); + if (encryptedURI.length != 0 && provenanceHash != "") { + _setEncryptedData(nextTokenIdToLazyMint + _amount, _data); + } + } + + return LazyMint.lazyMint(_amount, _baseURIForTokens, _data); + } + + /// @notice The tokenId assigned to the next new NFT to be lazy minted. + function nextTokenIdToMint() public view virtual returns (uint256) { + return nextTokenIdToLazyMint; + } + + /*/////////////////////////////////////////////////////////////// + Internal functions + //////////////////////////////////////////////////////////////*/ + + /** + * @dev Runs before every `claim` function call. + * + * @param _tokenId The tokenId of the NFT being claimed. + */ + function _beforeClaim( + uint256 _tokenId, + address, + uint256, + address, + uint256, + AllowlistProof calldata, + bytes memory + ) internal view virtual override { + if (_tokenId >= nextTokenIdToLazyMint) { + revert("Not enough minted tokens"); + } + } + + /** + * @dev Collects and distributes the primary sale value of NFTs being claimed. + * + * @param _primarySaleRecipient The address to which primary sale revenue should be sent. + * @param _quantityToClaim The quantity of NFTs being claimed. + * @param _currency The currency in which the NFTs are being sold. + * @param _pricePerToken The price per NFT being claimed. + */ + + function _collectPriceOnClaim( + address _primarySaleRecipient, + uint256 _quantityToClaim, + address _currency, + uint256 _pricePerToken + ) internal virtual override { + if (_pricePerToken == 0) { + require(msg.value == 0, "!Value"); + return; + } + + uint256 totalPrice = _quantityToClaim * _pricePerToken; + + bool validMsgValue; + if (_currency == CurrencyTransferLib.NATIVE_TOKEN) { + validMsgValue = msg.value == totalPrice; + } else { + validMsgValue = msg.value == 0; + } + require(validMsgValue, "Invalid msg value"); + + address saleRecipient = _primarySaleRecipient == address(0) ? primarySaleRecipient() : _primarySaleRecipient; + CurrencyTransferLib.transferCurrency(_currency, msg.sender, saleRecipient, totalPrice); + } + + /** + * @dev Transfers the NFTs being claimed. + * + * @param _to The address to which the NFTs are being transferred. + * @param _tokenId The tokenId of the NFTs being claimed. + * @param _quantityBeingClaimed The quantity of NFTs being claimed. + */ + function _transferTokensOnClaim( + address _to, + uint256 _tokenId, + uint256 _quantityBeingClaimed + ) internal virtual override { + _mint(_to, _tokenId, _quantityBeingClaimed, ""); + } + + /** + * @dev Runs before every token transfer / mint / burn. + * + * @param operator The address performing the token transfer. + * @param from The address from which the token is being transferred. + * @param to The address to which the token is being transferred. + * @param ids The tokenIds of the tokens being transferred. + * @param amounts The amounts of the tokens being transferred. + * @param data Any additional data being passed in the token transfer. + */ + function _beforeTokenTransfer( + address operator, + address from, + address to, + uint256[] memory ids, + uint256[] memory amounts, + bytes memory data + ) internal virtual override { + super._beforeTokenTransfer(operator, from, to, ids, amounts, data); + + if (from == address(0)) { + for (uint256 i = 0; i < ids.length; ++i) { + totalSupply[ids[i]] += amounts[i]; + } + } + + if (to == address(0)) { + for (uint256 i = 0; i < ids.length; ++i) { + totalSupply[ids[i]] -= amounts[i]; + } + } + } + + /// @dev Checks whether primary sale recipient can be set in the given execution context. + function _canSetPrimarySaleRecipient() internal view virtual override returns (bool) { + return msg.sender == owner(); + } + + /// @dev Checks whether owner can be set in the given execution context. + function _canSetOwner() internal view virtual override returns (bool) { + return msg.sender == owner(); + } + + /// @dev Checks whether royalty info can be set in the given execution context. + function _canSetRoyaltyInfo() internal view virtual override returns (bool) { + return msg.sender == owner(); + } + + /// @dev Checks whether contract metadata can be set in the given execution context. + function _canSetContractURI() internal view virtual override returns (bool) { + return msg.sender == owner(); + } + + /// @dev Checks whether platform fee info can be set in the given execution context. + function _canSetClaimConditions() internal view virtual override returns (bool) { + return msg.sender == owner(); + } + + /// @dev Returns whether lazy minting can be done in the given execution context. + function _canLazyMint() internal view virtual override returns (bool) { + return msg.sender == owner(); + } + + /// @dev Checks whether NFTs can be revealed in the given execution context. + function _canReveal() internal view virtual returns (bool) { + return msg.sender == owner(); + } +} diff --git a/contracts/base/ERC1155LazyMint.sol b/contracts/base/ERC1155LazyMint.sol new file mode 100644 index 000000000..93fb0c2e2 --- /dev/null +++ b/contracts/base/ERC1155LazyMint.sol @@ -0,0 +1,286 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import { ERC1155 } from "../eip/ERC1155.sol"; + +import "../extension/ContractMetadata.sol"; +import "../extension/Multicall.sol"; +import "../extension/Ownable.sol"; +import "../extension/Royalty.sol"; +import "../extension/BatchMintMetadata.sol"; +import "../extension/LazyMint.sol"; +import "../extension/interface/IClaimableERC1155.sol"; + +import "../lib/Strings.sol"; +import "../external-deps/openzeppelin/security/ReentrancyGuard.sol"; + +/** + * BASE: ERC1155Base + * EXTENSION: LazyMint + * + * The `ERC1155LazyMint` smart contract implements the ERC1155 NFT standard. + * It includes the following additions to standard ERC1155 logic: + * + * - Lazy minting + * + * - Ability to mint NFTs via the provided `mintTo` and `batchMintTo` functions. + * + * - Contract metadata for royalty support on platforms such as OpenSea that use + * off-chain information to distribute roaylties. + * + * - Ownership of the contract, with the ability to restrict certain functions to + * only be called by the contract's owner. + * + * - Multicall capability to perform multiple actions atomically + * + * - EIP 2981 compliance for royalty support on NFT marketplaces. + * + * + * The `ERC1155LazyMint` contract uses the `LazyMint` extension. + * + * 'Lazy minting' means defining the metadata of NFTs without minting it to an address. Regular 'minting' + * of NFTs means actually assigning an owner to an NFT. + * + * As a contract admin, this lets you prepare the metadata for NFTs that will be minted by an external party, + * without paying the gas cost for actually minting the NFTs. + * + */ + +contract ERC1155LazyMint is + ERC1155, + ContractMetadata, + Ownable, + Royalty, + Multicall, + BatchMintMetadata, + LazyMint, + IClaimableERC1155, + ReentrancyGuard +{ + using Strings for uint256; + + /*////////////////////////////////////////////////////////////// + Mappings + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Returns the total supply of NFTs of a given tokenId + * @dev Mapping from tokenId => total circulating supply of NFTs of that tokenId. + */ + mapping(uint256 => uint256) public totalSupply; + + /*////////////////////////////////////////////////////////////// + Constructor + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Initializes the contract during construction. + * + * @param _defaultAdmin The default admin of the contract. + * @param _name The name of the contract. + * @param _symbol The symbol of the contract. + * @param _royaltyRecipient The address to receive royalties. + * @param _royaltyBps The royalty basis points to be charged. Max = 10000 (10000 = 100%, 1000 = 10%) + */ + constructor( + address _defaultAdmin, + string memory _name, + string memory _symbol, + address _royaltyRecipient, + uint128 _royaltyBps + ) ERC1155(_name, _symbol) { + _setupOwner(_defaultAdmin); + _setupDefaultRoyaltyInfo(_royaltyRecipient, _royaltyBps); + } + + /*////////////////////////////////////////////////////////////// + Overriden metadata logic + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Returns the metadata URI for the given tokenId. + * + * @param _tokenId The tokenId of the NFT. + * @return The metadata URI for the given tokenId. + */ + function uri(uint256 _tokenId) public view virtual override returns (string memory) { + string memory batchUri = _getBaseURI(_tokenId); + return string(abi.encodePacked(batchUri, _tokenId.toString())); + } + + /*////////////////////////////////////////////////////////////// + CLAIM LOGIC + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Lets an address claim multiple lazy minted NFTs at once to a recipient. + * This function prevents any reentrant calls, and is not allowed to be overridden. + * + * Contract creators should override `verifyClaim` and `transferTokensOnClaim` + * functions to create custom logic for verification and claiming, + * for e.g. price collection, allowlist, max quantity, etc. + * + * @dev The logic in `verifyClaim` determines whether the caller is authorized to mint NFTs. + * The logic in `transferTokensOnClaim` does actual minting of tokens, + * can also be used to apply other state changes. + * + * @param _receiver The recipient of the tokens to mint. + * @param _tokenId The tokenId of the lazy minted NFT to mint. + * @param _quantity The number of tokens to mint. + */ + function claim(address _receiver, uint256 _tokenId, uint256 _quantity) public payable virtual nonReentrant { + require(_tokenId < nextTokenIdToMint(), "invalid id"); + verifyClaim(msg.sender, _tokenId, _quantity); // Add your claim verification logic by overriding this function. + + _transferTokensOnClaim(_receiver, _tokenId, _quantity); // Mints tokens. Apply any state updates by overriding this function. + emit TokensClaimed(msg.sender, _receiver, _tokenId, _quantity); + } + + /** + * @notice Override this function to add logic for claim verification, based on conditions + * such as allowlist, price, max quantity etc. + * + * @dev Checks a request to claim NFTs against a custom condition. + * + * @param _claimer Caller of the claim function. + * @param _tokenId The tokenId of the lazy minted NFT to mint. + * @param _quantity The number of NFTs being claimed. + */ + function verifyClaim(address _claimer, uint256 _tokenId, uint256 _quantity) public view virtual {} + + /** + * @notice Lets an owner or approved operator burn NFTs of the given tokenId. + * + * @param _owner The owner of the NFT to burn. + * @param _tokenId The tokenId of the NFT to burn. + * @param _amount The amount of the NFT to burn. + */ + function burn(address _owner, uint256 _tokenId, uint256 _amount) external virtual { + address caller = msg.sender; + + require(caller == _owner || isApprovedForAll[_owner][caller], "Unapproved caller"); + require(balanceOf[_owner][_tokenId] >= _amount, "Not enough tokens owned"); + + _burn(_owner, _tokenId, _amount); + } + + /** + * @notice Lets an owner or approved operator burn NFTs of the given tokenIds. + * + * @param _owner The owner of the NFTs to burn. + * @param _tokenIds The tokenIds of the NFTs to burn. + * @param _amounts The amounts of the NFTs to burn. + */ + function burnBatch(address _owner, uint256[] memory _tokenIds, uint256[] memory _amounts) external virtual { + address caller = msg.sender; + + require(caller == _owner || isApprovedForAll[_owner][caller], "Unapproved caller"); + require(_tokenIds.length == _amounts.length, "Length mismatch"); + + for (uint256 i = 0; i < _tokenIds.length; i += 1) { + require(balanceOf[_owner][_tokenIds[i]] >= _amounts[i], "Not enough tokens owned"); + } + + _burnBatch(_owner, _tokenIds, _amounts); + } + + /*////////////////////////////////////////////////////////////// + ERC165 Logic + //////////////////////////////////////////////////////////////*/ + + /** + * @dev See ERC165: https://eips.ethereum.org/EIPS/eip-165 + * @inheritdoc IERC165 + */ + function supportsInterface(bytes4 interfaceId) public view virtual override(ERC1155, IERC165) returns (bool) { + return + interfaceId == 0x01ffc9a7 || // ERC165 Interface ID for ERC165 + interfaceId == 0xd9b67a26 || // ERC165 Interface ID for ERC1155 + interfaceId == 0x0e89341c || // ERC165 Interface ID for ERC1155MetadataURI + interfaceId == type(IERC2981).interfaceId; // ERC165 ID for ERC2981 + } + + /*////////////////////////////////////////////////////////////// + View functions + //////////////////////////////////////////////////////////////*/ + + /// @notice The tokenId assigned to the next new NFT to be lazy minted. + function nextTokenIdToMint() public view virtual returns (uint256) { + return nextTokenIdToLazyMint; + } + + /*////////////////////////////////////////////////////////////// + Internal functions + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Mints tokens to receiver on claim. + * Any state changes related to `claim` must be applied + * here by overriding this function. + * + * @dev Override this function to add logic for state updation. + * When overriding, apply any state changes before `_mint`. + * + * @param _receiver The receiver of the tokens to mint. + * @param _tokenId The tokenId of the lazy minted NFT to mint. + * @param _quantity The number of tokens to mint. + */ + function _transferTokensOnClaim(address _receiver, uint256 _tokenId, uint256 _quantity) internal virtual { + _mint(_receiver, _tokenId, _quantity, ""); + } + + /// @dev Returns whether lazy minting can be done in the given execution context. + function _canLazyMint() internal view virtual override returns (bool) { + return msg.sender == owner(); + } + + /// @dev Returns whether contract metadata can be set in the given execution context. + function _canSetContractURI() internal view virtual override returns (bool) { + return msg.sender == owner(); + } + + /// @dev Returns whether owner can be set in the given execution context. + function _canSetOwner() internal view virtual override returns (bool) { + return msg.sender == owner(); + } + + /// @dev Returns whether royalty info can be set in the given execution context. + function _canSetRoyaltyInfo() internal view virtual override returns (bool) { + return msg.sender == owner(); + } + + /** + * @dev Runs before every token transfer / mint / burn. + * + * @param operator The address performing the token transfer. + * @param from The address from which the token is being transferred. + * @param to The address to which the token is being transferred. + * @param ids The tokenIds of the tokens being transferred. + * @param amounts The amounts of the tokens being transferred. + * @param data Any additional data being passed in the token transfer. + */ + function _beforeTokenTransfer( + address operator, + address from, + address to, + uint256[] memory ids, + uint256[] memory amounts, + bytes memory data + ) internal virtual override { + super._beforeTokenTransfer(operator, from, to, ids, amounts, data); + + if (from == address(0)) { + for (uint256 i = 0; i < ids.length; ++i) { + totalSupply[ids[i]] += amounts[i]; + } + } + + if (to == address(0)) { + for (uint256 i = 0; i < ids.length; ++i) { + totalSupply[ids[i]] -= amounts[i]; + } + } + } +} diff --git a/contracts/base/ERC1155SignatureMint.sol b/contracts/base/ERC1155SignatureMint.sol new file mode 100644 index 000000000..b7a74af24 --- /dev/null +++ b/contracts/base/ERC1155SignatureMint.sol @@ -0,0 +1,132 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "./ERC1155Base.sol"; + +import "../extension/PrimarySale.sol"; +import "../extension/SignatureMintERC1155.sol"; +import { ReentrancyGuard } from "../extension/upgradeable/ReentrancyGuard.sol"; +import { CurrencyTransferLib } from "../lib/CurrencyTransferLib.sol"; + +/** + * BASE: ERC1155Base + * EXTENSION: SignatureMintERC1155 + * + * The `ERC1155SignatureMint` contract uses the `ERC1155Base` contract, along with the `SignatureMintERC1155` extension. + * + * The 'signature minting' mechanism in the `SignatureMintERC1155` extension uses EIP 712, and is a way for a contract + * admin to authorize an external party's request to mint tokens on the admin's contract. At a high level, this means + * you can authorize some external party to mint tokens on your contract, and specify what exactly will be minted by + * that external party. + * + */ + +contract ERC1155SignatureMint is ERC1155Base, PrimarySale, SignatureMintERC1155, ReentrancyGuard { + /*////////////////////////////////////////////////////////////// + Constructor + //////////////////////////////////////////////////////////////*/ + + constructor( + address _defaultAdmin, + string memory _name, + string memory _symbol, + address _royaltyRecipient, + uint128 _royaltyBps, + address _primarySaleRecipient + ) ERC1155Base(_defaultAdmin, _name, _symbol, _royaltyRecipient, _royaltyBps) { + _setupPrimarySaleRecipient(_primarySaleRecipient); + } + + /*////////////////////////////////////////////////////////////// + Signature minting logic + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Mints tokens according to the provided mint request. + * + * @param _req The payload / mint request. + * @param _signature The signature produced by an account signing the mint request. + */ + function mintWithSignature( + MintRequest calldata _req, + bytes calldata _signature + ) external payable virtual override nonReentrant returns (address signer) { + require(_req.quantity > 0, "Minting zero tokens."); + + uint256 tokenIdToMint; + uint256 nextIdToMint = nextTokenIdToMint(); + + if (_req.tokenId == type(uint256).max) { + tokenIdToMint = nextIdToMint; + nextTokenIdToMint_ += 1; + } else { + require(_req.tokenId < nextIdToMint, "invalid id"); + tokenIdToMint = _req.tokenId; + } + + // Verify and process payload. + signer = _processRequest(_req, _signature); + + address receiver = _req.to; + + // Collect price + _collectPriceOnClaim(_req.primarySaleRecipient, _req.quantity, _req.currency, _req.pricePerToken); + + // Set royalties, if applicable. + if (_req.royaltyRecipient != address(0)) { + _setupRoyaltyInfoForToken(tokenIdToMint, _req.royaltyRecipient, _req.royaltyBps); + } + + // Set URI + if (_req.tokenId == type(uint256).max) { + _setTokenURI(tokenIdToMint, _req.uri); + } + + // Mint tokens. + _mint(receiver, tokenIdToMint, _req.quantity, ""); + + emit TokensMintedWithSignature(signer, receiver, tokenIdToMint, _req); + } + + /*////////////////////////////////////////////////////////////// + Internal functions + //////////////////////////////////////////////////////////////*/ + + /// @dev Returns whether a given address is authorized to sign mint requests. + function _canSignMintRequest(address _signer) internal view virtual override returns (bool) { + return _signer == owner(); + } + + /// @dev Returns whether primary sale recipient can be set in the given execution context. + function _canSetPrimarySaleRecipient() internal view virtual override returns (bool) { + return msg.sender == owner(); + } + + /// @dev Collects and distributes the primary sale value of NFTs being claimed. + function _collectPriceOnClaim( + address _primarySaleRecipient, + uint256 _quantityToClaim, + address _currency, + uint256 _pricePerToken + ) internal virtual { + if (_pricePerToken == 0) { + require(msg.value == 0, "!Value"); + return; + } + + uint256 totalPrice = _quantityToClaim * _pricePerToken; + + bool validMsgValue; + if (_currency == CurrencyTransferLib.NATIVE_TOKEN) { + validMsgValue = msg.value == totalPrice; + } else { + validMsgValue = msg.value == 0; + } + require(validMsgValue, "Invalid msg value"); + + address saleRecipient = _primarySaleRecipient == address(0) ? primarySaleRecipient() : _primarySaleRecipient; + CurrencyTransferLib.transferCurrency(_currency, msg.sender, saleRecipient, totalPrice); + } +} diff --git a/contracts/base/ERC20Base.sol b/contracts/base/ERC20Base.sol new file mode 100644 index 000000000..31d05a54d --- /dev/null +++ b/contracts/base/ERC20Base.sol @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "../external-deps/openzeppelin/token/ERC20/extensions/ERC20Permit.sol"; + +import "../extension/ContractMetadata.sol"; +import "../extension/Multicall.sol"; +import "../extension/Ownable.sol"; +import "../extension/interface/IMintableERC20.sol"; +import "../extension/interface/IBurnableERC20.sol"; + +/** + * The `ERC20Base` smart contract implements the ERC20 standard. + * It includes the following additions to standard ERC20 logic: + * + * - Ability to mint & burn tokens via the provided `mint` & `burn` functions. + * + * - Ownership of the contract, with the ability to restrict certain functions to + * only be called by the contract's owner. + * + * - Multicall capability to perform multiple actions atomically + * + * - EIP 2612 compliance: See {ERC20-permit} method, which can be used to change an account's ERC20 allowance by + * presenting a message signed by the account. + */ + +contract ERC20Base is ContractMetadata, Multicall, Ownable, ERC20Permit, IMintableERC20, IBurnableERC20 { + /*////////////////////////////////////////////////////////////// + Constructor + //////////////////////////////////////////////////////////////*/ + + constructor(address _defaultAdmin, string memory _name, string memory _symbol) ERC20Permit(_name, _symbol) { + _setupOwner(_defaultAdmin); + } + + /*////////////////////////////////////////////////////////////// + Minting logic + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Lets an authorized address mint tokens to a recipient. + * @dev The logic in the `_canMint` function determines whether the caller is authorized to mint tokens. + * + * @param _to The recipient of the tokens to mint. + * @param _amount Quantity of tokens to mint. + */ + function mintTo(address _to, uint256 _amount) public virtual { + require(_canMint(), "Not authorized to mint."); + require(_amount != 0, "Minting zero tokens."); + + _mint(_to, _amount); + } + + /** + * @notice Lets an owner a given amount of their tokens. + * @dev Caller should own the `_amount` of tokens. + * + * @param _amount The number of tokens to burn. + */ + function burn(uint256 _amount) external virtual { + require(balanceOf(msg.sender) >= _amount, "not enough balance"); + _burn(msg.sender, _amount); + } + + /** + * @notice Lets an owner burn a given amount of an account's tokens. + * @dev `_account` should own the `_amount` of tokens. + * + * @param _account The account to burn tokens from. + * @param _amount The number of tokens to burn. + */ + function burnFrom(address _account, uint256 _amount) external virtual override { + require(_canBurn(), "Not authorized to burn."); + require(balanceOf(_account) >= _amount, "not enough balance"); + uint256 decreasedAllowance = allowance(_account, msg.sender) - _amount; + _approve(_account, msg.sender, 0); + _approve(_account, msg.sender, decreasedAllowance); + _burn(_account, _amount); + } + + /*////////////////////////////////////////////////////////////// + Internal (overrideable) functions + //////////////////////////////////////////////////////////////*/ + + /// @dev Returns whether contract metadata can be set in the given execution context. + function _canSetContractURI() internal view virtual override returns (bool) { + return msg.sender == owner(); + } + + /// @dev Returns whether tokens can be minted in the given execution context. + function _canMint() internal view virtual returns (bool) { + return msg.sender == owner(); + } + + /// @dev Returns whether tokens can be burned in the given execution context. + function _canBurn() internal view virtual returns (bool) { + return msg.sender == owner(); + } + + /// @dev Returns whether owner can be set in the given execution context. + function _canSetOwner() internal view virtual override returns (bool) { + return msg.sender == owner(); + } + + /// @notice Returns the sender in the given execution context. + function _msgSender() internal view override(Multicall, Context) returns (address) { + return msg.sender; + } +} diff --git a/contracts/base/ERC20Drop.sol b/contracts/base/ERC20Drop.sol new file mode 100644 index 000000000..60c61925a --- /dev/null +++ b/contracts/base/ERC20Drop.sol @@ -0,0 +1,157 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "../external-deps/openzeppelin/token/ERC20/extensions/ERC20Permit.sol"; + +import "../extension/ContractMetadata.sol"; +import "../extension/Multicall.sol"; +import "../extension/Ownable.sol"; +import "../extension/PrimarySale.sol"; +import "../extension/DropSinglePhase.sol"; +import "../extension/interface/IBurnableERC20.sol"; + +import { CurrencyTransferLib } from "../lib/CurrencyTransferLib.sol"; + +/** + * BASE: ERC20 + * EXTENSION: DropSinglePhase + * + * The `ERC20Drop` smart contract implements the ERC20 standard. + * It includes the following additions to standard ERC20 logic: + * + * - Ownership of the contract, with the ability to restrict certain functions to + * only be called by the contract's owner. + * + * - Multicall capability to perform multiple actions atomically + * + * - EIP 2612 compliance: See {ERC20-permit} method, which can be used to change an account's ERC20 allowance by + * presenting a message signed by the account. + * + * The `drop` mechanism in the `DropSinglePhase` extension is a distribution mechanism for tokens. It lets + * you set restrictions such as a price to charge, an allowlist etc. when an address atttempts to mint tokens. + * + */ + +contract ERC20Drop is ContractMetadata, Multicall, Ownable, ERC20Permit, PrimarySale, DropSinglePhase, IBurnableERC20 { + /*////////////////////////////////////////////////////////////// + Constructor + //////////////////////////////////////////////////////////////*/ + + constructor( + address _defaultAdmin, + string memory _name, + string memory _symbol, + address _primarySaleRecipient + ) ERC20Permit(_name, _symbol) { + _setupOwner(_defaultAdmin); + _setupPrimarySaleRecipient(_primarySaleRecipient); + } + + /*////////////////////////////////////////////////////////////// + ERC20 logic + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Lets an owner a given amount of their tokens. + * @dev Caller should own the `_amount` of tokens. + * + * @param _amount The number of tokens to burn. + */ + function burn(uint256 _amount) external virtual { + require(balanceOf(msg.sender) >= _amount, "not enough balance"); + _burn(msg.sender, _amount); + } + + /** + * @notice Lets an owner burn a given amount of an account's tokens. + * @dev `_account` should own the `_amount` of tokens. + * + * @param _account The account to burn tokens from. + * @param _amount The number of tokens to burn. + */ + function burnFrom(address _account, uint256 _amount) external virtual override { + require(_canBurn(), "Not authorized to burn."); + require(balanceOf(_account) >= _amount, "not enough balance"); + uint256 decreasedAllowance = allowance(_account, msg.sender) - _amount; + _approve(_account, msg.sender, 0); + _approve(_account, msg.sender, decreasedAllowance); + _burn(_account, _amount); + } + + /*////////////////////////////////////////////////////////////// + Internal (overrideable) functions + //////////////////////////////////////////////////////////////*/ + + /// @dev Collects and distributes the primary sale value of tokens being claimed. + function _collectPriceOnClaim( + address _primarySaleRecipient, + uint256 _quantityToClaim, + address _currency, + uint256 _pricePerToken + ) internal virtual override { + if (_pricePerToken == 0) { + require(msg.value == 0, "!Value"); + return; + } + + uint256 totalPrice = (_quantityToClaim * _pricePerToken) / 1 ether; + require(totalPrice > 0, "quantity too low"); + + bool validMsgValue; + if (_currency == CurrencyTransferLib.NATIVE_TOKEN) { + validMsgValue = msg.value == totalPrice; + } else { + validMsgValue = msg.value == 0; + } + require(validMsgValue, "Invalid msg value"); + + address saleRecipient = _primarySaleRecipient == address(0) ? primarySaleRecipient() : _primarySaleRecipient; + CurrencyTransferLib.transferCurrency(_currency, msg.sender, saleRecipient, totalPrice); + } + + /// @dev Transfers the tokens being claimed. + function _transferTokensOnClaim( + address _to, + uint256 _quantityBeingClaimed + ) internal virtual override returns (uint256) { + _mint(_to, _quantityBeingClaimed); + return 0; + } + + /// @dev Checks whether platform fee info can be set in the given execution context. + function _canSetClaimConditions() internal view virtual override returns (bool) { + return msg.sender == owner(); + } + + /// @dev Returns whether contract metadata can be set in the given execution context. + function _canSetContractURI() internal view virtual override returns (bool) { + return msg.sender == owner(); + } + + /// @dev Returns whether tokens can be minted in the given execution context. + function _canMint() internal view virtual returns (bool) { + return msg.sender == owner(); + } + + /// @dev Returns whether tokens can be burned in the given execution context. + function _canBurn() internal view virtual returns (bool) { + return msg.sender == owner(); + } + + /// @dev Returns whether owner can be set in the given execution context. + function _canSetOwner() internal view virtual override returns (bool) { + return msg.sender == owner(); + } + + /// @dev Returns whether primary sale recipient can be set in the given execution context. + function _canSetPrimarySaleRecipient() internal view virtual override returns (bool) { + return msg.sender == owner(); + } + + /// @notice Returns the sender in the given execution context. + function _msgSender() internal view override(Multicall, Context) returns (address) { + return msg.sender; + } +} diff --git a/contracts/base/ERC20DropVote.sol b/contracts/base/ERC20DropVote.sol new file mode 100644 index 000000000..4150ac0e1 --- /dev/null +++ b/contracts/base/ERC20DropVote.sol @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "../external-deps/openzeppelin/token/ERC20/extensions/ERC20Votes.sol"; + +import "../extension/ContractMetadata.sol"; +import "../extension/Multicall.sol"; +import "../extension/Ownable.sol"; +import "../extension/PrimarySale.sol"; +import "../extension/DropSinglePhase.sol"; + +import { CurrencyTransferLib } from "../lib/CurrencyTransferLib.sol"; + +/** + * BASE: ERC20Votes + * EXTENSION: DropSinglePhase + * + * The `ERC20Drop` contract uses the `DropSinglePhase` extensions, along with `ERC20Votes`. + * It implements the ERC20 standard, along with the following additions to standard ERC20 logic: + * + * - Ownership of the contract, with the ability to restrict certain functions to + * only be called by the contract's owner. + * + * - Multicall capability to perform multiple actions atomically + * + * - EIP 2612 compliance: See {ERC20-permit} method, which can be used to change an account's ERC20 allowance by + * presenting a message signed by the account. + * + * The `drop` mechanism in the `DropSinglePhase` extension is a distribution mechanism tokens. It lets + * you set restrictions such as a price to charge, an allowlist etc. when an address atttempts to mint tokens. + * + */ + +contract ERC20DropVote is ContractMetadata, Multicall, Ownable, ERC20Votes, PrimarySale, DropSinglePhase { + /*////////////////////////////////////////////////////////////// + Constructor + //////////////////////////////////////////////////////////////*/ + + constructor( + address _defaultAdmin, + string memory _name, + string memory _symbol, + address _primarySaleRecipient + ) ERC20Permit(_name, _symbol) { + _setupOwner(_defaultAdmin); + _setupPrimarySaleRecipient(_primarySaleRecipient); + } + + /*////////////////////////////////////////////////////////////// + ERC20 logic + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Lets an owner a given amount of their tokens. + * @dev Caller should own the `_amount` of tokens. + * + * @param _amount The number of tokens to burn. + */ + function burn(uint256 _amount) external virtual { + require(balanceOf(msg.sender) >= _amount, "not enough balance"); + _burn(msg.sender, _amount); + } + + /*////////////////////////////////////////////////////////////// + Internal (overrideable) functions + //////////////////////////////////////////////////////////////*/ + + /// @dev Collects and distributes the primary sale value of tokens being claimed. + function _collectPriceOnClaim( + address _primarySaleRecipient, + uint256 _quantityToClaim, + address _currency, + uint256 _pricePerToken + ) internal virtual override { + if (_pricePerToken == 0) { + require(msg.value == 0, "!Value"); + return; + } + + uint256 totalPrice = (_quantityToClaim * _pricePerToken) / 1 ether; + require(totalPrice > 0, "quantity too low"); + + bool validMsgValue; + if (_currency == CurrencyTransferLib.NATIVE_TOKEN) { + validMsgValue = msg.value == totalPrice; + } else { + validMsgValue = msg.value == 0; + } + require(validMsgValue, "Invalid msg value"); + + address saleRecipient = _primarySaleRecipient == address(0) ? primarySaleRecipient() : _primarySaleRecipient; + CurrencyTransferLib.transferCurrency(_currency, msg.sender, saleRecipient, totalPrice); + } + + /// @dev Transfers the tokens being claimed. + function _transferTokensOnClaim( + address _to, + uint256 _quantityBeingClaimed + ) internal virtual override returns (uint256) { + _mint(_to, _quantityBeingClaimed); + return 0; + } + + /// @dev Checks whether platform fee info can be set in the given execution context. + function _canSetClaimConditions() internal view virtual override returns (bool) { + return msg.sender == owner(); + } + + /// @dev Returns whether contract metadata can be set in the given execution context. + function _canSetContractURI() internal view virtual override returns (bool) { + return msg.sender == owner(); + } + + /// @dev Returns whether tokens can be minted in the given execution context. + function _canMint() internal view virtual returns (bool) { + return msg.sender == owner(); + } + + /// @dev Returns whether owner can be set in the given execution context. + function _canSetOwner() internal view virtual override returns (bool) { + return msg.sender == owner(); + } + + /// @dev Returns whether primary sale recipient can be set in the given execution context. + function _canSetPrimarySaleRecipient() internal view virtual override returns (bool) { + return msg.sender == owner(); + } + + /// @notice Returns the sender in the given execution context. + function _msgSender() internal view override(Multicall, Context) returns (address) { + return msg.sender; + } +} diff --git a/contracts/base/ERC20SignatureMint.sol b/contracts/base/ERC20SignatureMint.sol new file mode 100644 index 000000000..6090aabd1 --- /dev/null +++ b/contracts/base/ERC20SignatureMint.sol @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "./ERC20Base.sol"; + +import "../extension/PrimarySale.sol"; +import { SignatureMintERC20 } from "../extension/SignatureMintERC20.sol"; +import { ReentrancyGuard } from "../extension/upgradeable/ReentrancyGuard.sol"; +import { CurrencyTransferLib } from "../lib/CurrencyTransferLib.sol"; + +/** + * BASE: ERC20 + * EXTENSION: SignatureMintERC20 + * + * The `ERC20SignatureMint` contract uses the `ERC20Base` contract, along with the `SignatureMintERC20` extension. + * + * The 'signature minting' mechanism in the `SignatureMintERC20` extension uses EIP 712, and is a way for a contract + * admin to authorize an external party's request to mint tokens on the admin's contract. At a high level, this means + * you can authorize some external party to mint tokens on your contract, and specify what exactly will be minted by + * that external party. + * + */ + +contract ERC20SignatureMint is ERC20Base, PrimarySale, SignatureMintERC20, ReentrancyGuard { + /*////////////////////////////////////////////////////////////// + Constructor + //////////////////////////////////////////////////////////////*/ + + constructor( + address _defaultAdmin, + string memory _name, + string memory _symbol, + address _primarySaleRecipient + ) ERC20Base(_defaultAdmin, _name, _symbol) { + _setupPrimarySaleRecipient(_primarySaleRecipient); + } + + /*////////////////////////////////////////////////////////////// + Signature minting logic + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Mints tokens according to the provided mint request. + * + * @param _req The payload / mint request. + * @param _signature The signature produced by an account signing the mint request. + */ + function mintWithSignature( + MintRequest calldata _req, + bytes calldata _signature + ) external payable virtual nonReentrant returns (address signer) { + require(_req.quantity > 0, "Minting zero tokens."); + + // Verify and process payload. + signer = _processRequest(_req, _signature); + + address receiver = _req.to; + + // Collect price + _collectPriceOnClaim(_req.primarySaleRecipient, _req.currency, _req.price); + + // Mint tokens. + _mint(receiver, _req.quantity); + + emit TokensMintedWithSignature(signer, receiver, _req); + } + + /*////////////////////////////////////////////////////////////// + Internal functions + //////////////////////////////////////////////////////////////*/ + + /// @dev Returns whether a given address is authorized to sign mint requests. + function _canSignMintRequest(address _signer) internal view virtual override returns (bool) { + return _signer == owner(); + } + + /// @dev Returns whether primary sale recipient can be set in the given execution context. + function _canSetPrimarySaleRecipient() internal view virtual override returns (bool) { + return msg.sender == owner(); + } + + /// @dev Collects and distributes the primary sale value of tokens being claimed. + function _collectPriceOnClaim(address _primarySaleRecipient, address _currency, uint256 _price) internal virtual { + if (_price == 0) { + require(msg.value == 0, "!Value"); + return; + } + + if (_currency == CurrencyTransferLib.NATIVE_TOKEN) { + require(msg.value == _price, "Must send total price."); + } else { + require(msg.value == 0, "msg value not zero"); + } + + address saleRecipient = _primarySaleRecipient == address(0) ? primarySaleRecipient() : _primarySaleRecipient; + CurrencyTransferLib.transferCurrency(_currency, msg.sender, saleRecipient, _price); + } +} diff --git a/contracts/base/ERC20SignatureMintVote.sol b/contracts/base/ERC20SignatureMintVote.sol new file mode 100644 index 000000000..f0d03542e --- /dev/null +++ b/contracts/base/ERC20SignatureMintVote.sol @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "./ERC20Vote.sol"; + +import "../extension/PrimarySale.sol"; +import { SignatureMintERC20 } from "../extension/SignatureMintERC20.sol"; +import { ReentrancyGuard } from "../extension/upgradeable/ReentrancyGuard.sol"; +import { CurrencyTransferLib } from "../lib/CurrencyTransferLib.sol"; + +/** + * BASE: ERC20Vote + * EXTENSION: SignatureMintERC20 + * + * The `ERC20SignatureMint` contract uses the `ERC20Vote` contract, along with the `SignatureMintERC20` extension. + * + * The 'signature minting' mechanism in the `SignatureMintERC20` extension uses EIP 712, and is a way for a contract + * admin to authorize an external party's request to mint tokens on the admin's contract. At a high level, this means + * you can authorize some external party to mint tokens on your contract, and specify what exactly will be minted by + * that external party. + * + */ + +contract ERC20SignatureMintVote is ERC20Vote, PrimarySale, SignatureMintERC20, ReentrancyGuard { + /*////////////////////////////////////////////////////////////// + Constructor + //////////////////////////////////////////////////////////////*/ + + constructor( + address _defaultAdmin, + string memory _name, + string memory _symbol, + address _primarySaleRecipient + ) ERC20Vote(_defaultAdmin, _name, _symbol) { + _setupPrimarySaleRecipient(_primarySaleRecipient); + } + + /*////////////////////////////////////////////////////////////// + Signature minting logic + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Mints tokens according to the provided mint request. + * + * @param _req The payload / mint request. + * @param _signature The signature produced by an account signing the mint request. + */ + function mintWithSignature( + MintRequest calldata _req, + bytes calldata _signature + ) external payable virtual nonReentrant returns (address signer) { + require(_req.quantity > 0, "Minting zero tokens."); + + // Verify and process payload. + signer = _processRequest(_req, _signature); + + /** + * Get receiver of tokens. + * + * Note: If `_req.to == address(0)`, a `mintWithSignature` transaction sitting in the + * mempool can be frontrun by copying the input data, since the minted tokens + * will be sent to the `_msgSender()` in this case. + */ + address receiver = _req.to == address(0) ? msg.sender : _req.to; + + // Collect price + _collectPriceOnClaim(_req.primarySaleRecipient, _req.currency, _req.price); + + // Mint tokens. + _mint(receiver, _req.quantity); + + emit TokensMintedWithSignature(signer, receiver, _req); + } + + /*////////////////////////////////////////////////////////////// + Internal functions + //////////////////////////////////////////////////////////////*/ + + /// @dev Returns whether a given address is authorized to sign mint requests. + function _canSignMintRequest(address _signer) internal view virtual override returns (bool) { + return _signer == owner(); + } + + /// @dev Returns whether primary sale recipient can be set in the given execution context. + function _canSetPrimarySaleRecipient() internal view virtual override returns (bool) { + return msg.sender == owner(); + } + + /// @dev Collects and distributes the primary sale value of tokens being claimed. + function _collectPriceOnClaim(address _primarySaleRecipient, address _currency, uint256 _price) internal virtual { + if (_price == 0) { + require(msg.value == 0, "!Value"); + return; + } + + if (_currency == CurrencyTransferLib.NATIVE_TOKEN) { + require(msg.value == _price, "Must send total price."); + } else { + require(msg.value == 0, "msg value not zero"); + } + + address saleRecipient = _primarySaleRecipient == address(0) ? primarySaleRecipient() : _primarySaleRecipient; + CurrencyTransferLib.transferCurrency(_currency, msg.sender, saleRecipient, _price); + } +} diff --git a/contracts/base/ERC20Vote.sol b/contracts/base/ERC20Vote.sol new file mode 100644 index 000000000..79626a830 --- /dev/null +++ b/contracts/base/ERC20Vote.sol @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "../external-deps/openzeppelin/token/ERC20/extensions/ERC20Votes.sol"; + +import "./ERC20Base.sol"; +import "../extension/interface/IMintableERC20.sol"; +import "../extension/interface/IBurnableERC20.sol"; + +/** + * The `ERC20Vote` smart contract implements the ERC20 standard and ERC20Votes. + * It includes the following additions to standard ERC20 logic: + * + * - Ability to mint & burn tokens via the provided `mint` & `burn` functions. + * + * - Ownership of the contract, with the ability to restrict certain functions to + * only be called by the contract's owner. + * + * - Multicall capability to perform multiple actions atomically + * + * - Extension of ERC20 to support voting and delegation. + * + * - EIP 2612 compliance: See {ERC20-permit} method, which can be used to change an account's ERC20 allowance by + * presenting a message signed by the account. + */ + +contract ERC20Vote is ContractMetadata, Multicall, Ownable, ERC20Votes, IMintableERC20, IBurnableERC20 { + /*////////////////////////////////////////////////////////////// + Constructor + //////////////////////////////////////////////////////////////*/ + + constructor(address _defaultAdmin, string memory _name, string memory _symbol) ERC20Permit(_name, _symbol) { + _setupOwner(_defaultAdmin); + } + + /*////////////////////////////////////////////////////////////// + Minting logic + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Lets an authorized address mint tokens to a recipient. + * @dev The logic in the `_canMint` function determines whether the caller is authorized to mint tokens. + * + * @param _to The recipient of the tokens to mint. + * @param _amount Quantity of tokens to mint. + */ + function mintTo(address _to, uint256 _amount) public virtual { + require(_canMint(), "Not authorized to mint."); + require(_amount != 0, "Minting zero tokens."); + + _mint(_to, _amount); + } + + /** + * @notice Lets an owner a given amount of their tokens. + * @dev Caller should own the `_amount` of tokens. + * + * @param _amount The number of tokens to burn. + */ + function burn(uint256 _amount) external virtual { + require(balanceOf(_msgSender()) >= _amount, "not enough balance"); + _burn(msg.sender, _amount); + } + + /** + * @notice Lets an owner burn a given amount of an account's tokens. + * @dev `_account` should own the `_amount` of tokens. + * + * @param _account The account to burn tokens from. + * @param _amount The number of tokens to burn. + */ + function burnFrom(address _account, uint256 _amount) external virtual override { + require(_canBurn(), "Not authorized to burn."); + require(balanceOf(_account) >= _amount, "not enough balance"); + uint256 decreasedAllowance = allowance(_account, msg.sender) - _amount; + _approve(_account, msg.sender, 0); + _approve(_account, msg.sender, decreasedAllowance); + _burn(_account, _amount); + } + + /*////////////////////////////////////////////////////////////// + Internal (overrideable) functions + //////////////////////////////////////////////////////////////*/ + + /// @dev Returns whether contract metadata can be set in the given execution context. + function _canSetContractURI() internal view virtual override returns (bool) { + return msg.sender == owner(); + } + + /// @dev Returns whether tokens can be minted in the given execution context. + function _canMint() internal view virtual returns (bool) { + return msg.sender == owner(); + } + + /// @dev Returns whether tokens can be burned in the given execution context. + function _canBurn() internal view virtual returns (bool) { + return msg.sender == owner(); + } + + /// @dev Returns whether owner can be set in the given execution context. + function _canSetOwner() internal view virtual override returns (bool) { + return msg.sender == owner(); + } + + /// @notice Returns the sender in the given execution context. + function _msgSender() internal view override(Multicall, Context) returns (address) { + return msg.sender; + } +} diff --git a/contracts/base/ERC721Base.sol b/contracts/base/ERC721Base.sol new file mode 100644 index 000000000..3d256e3f6 --- /dev/null +++ b/contracts/base/ERC721Base.sol @@ -0,0 +1,210 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "../eip/queryable/ERC721AQueryable.sol"; + +import "../extension/ContractMetadata.sol"; +import "../extension/Multicall.sol"; +import "../extension/Ownable.sol"; +import "../extension/Royalty.sol"; +import "../extension/BatchMintMetadata.sol"; + +import "../lib/Strings.sol"; + +/** + * The `ERC721Base` smart contract implements the ERC721 NFT standard, along with the ERC721A optimization to the standard. + * It includes the following additions to standard ERC721 logic: + * + * - Ability to mint NFTs via the provided `mint` function. + * + * - Contract metadata for royalty support on platforms such as OpenSea that use + * off-chain information to distribute roaylties. + * + * - Ownership of the contract, with the ability to restrict certain functions to + * only be called by the contract's owner. + * + * - Multicall capability to perform multiple actions atomically + * + * - EIP 2981 compliance for royalty support on NFT marketplaces. + */ + +contract ERC721Base is ERC721AQueryable, ContractMetadata, Multicall, Ownable, Royalty, BatchMintMetadata { + using Strings for uint256; + + /*////////////////////////////////////////////////////////////// + Mappings + //////////////////////////////////////////////////////////////*/ + + mapping(uint256 => string) private fullURI; + + /*////////////////////////////////////////////////////////////// + Constructor + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Initializes the contract during construction. + * + * @param _defaultAdmin The default admin of the contract. + * @param _name The name of the contract. + * @param _symbol The symbol of the contract. + * @param _royaltyRecipient The address to receive royalties. + * @param _royaltyBps The royalty basis points to be charged. Max = 10000 (10000 = 100%, 1000 = 10%) + */ + constructor( + address _defaultAdmin, + string memory _name, + string memory _symbol, + address _royaltyRecipient, + uint128 _royaltyBps + ) ERC721A(_name, _symbol) { + _setupOwner(_defaultAdmin); + _setupDefaultRoyaltyInfo(_royaltyRecipient, _royaltyBps); + } + + /*////////////////////////////////////////////////////////////// + ERC165 Logic + //////////////////////////////////////////////////////////////*/ + + /** + * @dev See ERC165: https://eips.ethereum.org/EIPS/eip-165 + * @inheritdoc IERC165 + */ + function supportsInterface(bytes4 interfaceId) public view virtual override(ERC721A, IERC165) returns (bool) { + return + interfaceId == 0x01ffc9a7 || // ERC165 Interface ID for ERC165 + interfaceId == 0x80ac58cd || // ERC165 Interface ID for ERC721 + interfaceId == 0x5b5e139f || // ERC165 Interface ID for ERC721Metadata + interfaceId == type(IERC2981).interfaceId; // ERC165 ID for ERC2981 + } + + /*////////////////////////////////////////////////////////////// + Overriden ERC721 logic + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Returns the metadata URI for an NFT. + * @dev See `BatchMintMetadata` for handling of metadata in this contract. + * + * @param _tokenId The tokenId of an NFT. + */ + function tokenURI(uint256 _tokenId) public view virtual override(ERC721A, IERC721Metadata) returns (string memory) { + string memory fullUriForToken = fullURI[_tokenId]; + if (bytes(fullUriForToken).length > 0) { + return fullUriForToken; + } + + string memory batchUri = _getBaseURI(_tokenId); + return string(abi.encodePacked(batchUri, _tokenId.toString())); + } + + /*////////////////////////////////////////////////////////////// + Minting logic + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Lets an authorized address mint an NFT to a recipient. + * @dev The logic in the `_canMint` function determines whether the caller is authorized to mint NFTs. + * + * @param _to The recipient of the NFT to mint. + * @param _tokenURI The full metadata URI for the NFT minted. + */ + function mintTo(address _to, string memory _tokenURI) public virtual { + require(_canMint(), "Not authorized to mint."); + _setTokenURI(nextTokenIdToMint(), _tokenURI); + _safeMint(_to, 1, ""); + } + + /** + * @notice Lets an authorized address mint multiple NFTs at once to a recipient. + * @dev The logic in the `_canMint` function determines whether the caller is authorized to mint NFTs. + * + * @param _to The recipient of the NFT to mint. + * @param _quantity The number of NFTs to mint. + * @param _baseURI The baseURI for the `n` number of NFTs minted. The metadata for each NFT is `baseURI/tokenId` + * @param _data Additional data to pass along during the minting of the NFT. + */ + function batchMintTo(address _to, uint256 _quantity, string memory _baseURI, bytes memory _data) public virtual { + require(_canMint(), "Not authorized to mint."); + _batchMintMetadata(nextTokenIdToMint(), _quantity, _baseURI); + _safeMint(_to, _quantity, _data); + } + + /** + * @notice Lets an owner or approved operator burn the NFT of the given tokenId. + * @dev ERC721A's `_burn(uint256,bool)` internally checks for token approvals. + * + * @param _tokenId The tokenId of the NFT to burn. + */ + function burn(uint256 _tokenId) external virtual { + _burn(_tokenId, true); + } + + /*////////////////////////////////////////////////////////////// + Public getters + //////////////////////////////////////////////////////////////*/ + + /// @notice The tokenId assigned to the next new NFT to be minted. + function nextTokenIdToMint() public view virtual returns (uint256) { + return _currentIndex; + } + + /** + * @notice Returns whether a given address is the owner, or approved to transfer an NFT. + * + * @param _operator The address to check. + * @param _tokenId The tokenId of the NFT to check. + * + * @return isApprovedOrOwnerOf Whether the given address is approved to transfer the given NFT. + */ + function isApprovedOrOwner( + address _operator, + uint256 _tokenId + ) public view virtual returns (bool isApprovedOrOwnerOf) { + address owner = ownerOf(_tokenId); + isApprovedOrOwnerOf = (_operator == owner || + isApprovedForAll(owner, _operator) || + getApproved(_tokenId) == _operator); + } + + /*////////////////////////////////////////////////////////////// + Internal (overrideable) functions + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Sets the metadata URI for a given tokenId. + * + * @param _tokenId The tokenId of the NFT to set the URI for. + * @param _tokenURI The URI to set for the given tokenId. + */ + function _setTokenURI(uint256 _tokenId, string memory _tokenURI) internal virtual { + require(bytes(fullURI[_tokenId]).length == 0, "URI already set"); + fullURI[_tokenId] = _tokenURI; + } + + /// @dev Returns whether contract metadata can be set in the given execution context. + function _canSetContractURI() internal view virtual override returns (bool) { + return msg.sender == owner(); + } + + /// @dev Returns whether a token can be minted in the given execution context. + function _canMint() internal view virtual returns (bool) { + return msg.sender == owner(); + } + + /// @dev Returns whether owner can be set in the given execution context. + function _canSetOwner() internal view virtual override returns (bool) { + return msg.sender == owner(); + } + + /// @dev Returns whether royalty info can be set in the given execution context. + function _canSetRoyaltyInfo() internal view virtual override returns (bool) { + return msg.sender == owner(); + } + + /// @notice Returns the sender in the given execution context. + function _msgSender() internal view override(Multicall, Context) returns (address) { + return msg.sender; + } +} diff --git a/contracts/base/ERC721DelayedReveal.sol b/contracts/base/ERC721DelayedReveal.sol new file mode 100644 index 000000000..624ec4ecc --- /dev/null +++ b/contracts/base/ERC721DelayedReveal.sol @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "./ERC721LazyMint.sol"; + +import "../extension/DelayedReveal.sol"; + +/** + * BASE: ERC721LazyMint + * EXTENSION: DelayedReveal + * + * The `ERC721DelayedReveal` contract uses the `ERC721LazyMint` contract, along with `DelayedReveal` extension. + * + * 'Lazy minting' means defining the metadata of NFTs without minting it to an address. Regular 'minting' + * of NFTs means actually assigning an owner to an NFT. + * + * As a contract admin, this lets you prepare the metadata for NFTs that will be minted by an external party, + * without paying the gas cost for actually minting the NFTs. + * + * 'Delayed reveal' is a mechanism by which you can distribute NFTs to your audience and reveal the metadata of the distributed + * NFTs, after the fact. + * + * You can read more about how the `DelayedReveal` extension works, here: https://blog.thirdweb.com/delayed-reveal-nfts + */ + +contract ERC721DelayedReveal is ERC721LazyMint, DelayedReveal { + using Strings for uint256; + + /*////////////////////////////////////////////////////////////// + Constructor + //////////////////////////////////////////////////////////////*/ + + constructor( + address _defaultAdmin, + string memory _name, + string memory _symbol, + address _royaltyRecipient, + uint128 _royaltyBps + ) ERC721LazyMint(_defaultAdmin, _name, _symbol, _royaltyRecipient, _royaltyBps) {} + + /*////////////////////////////////////////////////////////////// + Overriden ERC721 logic + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Returns the metadata URI for an NFT. + * @dev See `BatchMintMetadata` for handling of metadata in this contract. + * + * @param _tokenId The tokenId of an NFT. + */ + function tokenURI(uint256 _tokenId) public view virtual override returns (string memory) { + (uint256 batchId, ) = _getBatchId(_tokenId); + string memory batchUri = _getBaseURI(_tokenId); + + if (isEncryptedBatch(batchId)) { + return string(abi.encodePacked(batchUri, "0")); + } else { + return string(abi.encodePacked(batchUri, _tokenId.toString())); + } + } + + /*////////////////////////////////////////////////////////////// + Lazy minting logic + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Lets an authorized address lazy mint a given amount of NFTs. + * + * @param _amount The number of NFTs to lazy mint. + * @param _baseURIForTokens The placeholder base URI for the 'n' number of NFTs being lazy minted, where the + * metadata for each of those NFTs is `${baseURIForTokens}/${tokenId}`. + * @param _data The encrypted base URI + provenance hash for the batch of NFTs being lazy minted. + * @return batchId A unique integer identifier for the batch of NFTs lazy minted together. + */ + function lazyMint( + uint256 _amount, + string calldata _baseURIForTokens, + bytes calldata _data + ) public virtual override returns (uint256 batchId) { + if (_data.length > 0) { + (bytes memory encryptedURI, bytes32 provenanceHash) = abi.decode(_data, (bytes, bytes32)); + if (encryptedURI.length != 0 && provenanceHash != "") { + _setEncryptedData(nextTokenIdToLazyMint + _amount, _data); + } + } + + return super.lazyMint(_amount, _baseURIForTokens, _data); + } + + /*////////////////////////////////////////////////////////////// + Delayed reveal logic + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Lets an authorized address reveal a batch of delayed reveal NFTs. + * + * @param _index The ID for the batch of delayed-reveal NFTs to reveal. + * @param _key The key with which the base URI for the relevant batch of NFTs was encrypted. + */ + function reveal(uint256 _index, bytes calldata _key) external virtual override returns (string memory revealedURI) { + require(_canReveal(), "Not authorized"); + + uint256 batchId = getBatchIdAtIndex(_index); + revealedURI = getRevealURI(batchId, _key); + + _setEncryptedData(batchId, ""); + _setBaseURI(batchId, revealedURI); + + emit TokenURIRevealed(_index, revealedURI); + } + + /// @dev Checks whether NFTs can be revealed in the given execution context. + function _canReveal() internal view virtual returns (bool) { + return msg.sender == owner(); + } +} diff --git a/contracts/base/ERC721Drop.sol b/contracts/base/ERC721Drop.sol new file mode 100644 index 000000000..42912647a --- /dev/null +++ b/contracts/base/ERC721Drop.sol @@ -0,0 +1,312 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import { ERC721A, Context } from "../eip/ERC721AVirtualApprove.sol"; + +import "../extension/ContractMetadata.sol"; +import "../extension/Multicall.sol"; +import "../extension/Ownable.sol"; +import "../extension/Royalty.sol"; +import "../extension/BatchMintMetadata.sol"; +import "../extension/PrimarySale.sol"; +import "../extension/DropSinglePhase.sol"; +import "../extension/LazyMint.sol"; +import "../extension/DelayedReveal.sol"; + +import "../lib/Strings.sol"; +import { CurrencyTransferLib } from "../lib/CurrencyTransferLib.sol"; + +/** + * BASE: ERC721A + * EXTENSION: DropSinglePhase + * + * The `ERC721Drop` contract implements the ERC721 NFT standard, along with the ERC721A optimization to the standard. + * It includes the following additions to standard ERC721 logic: + * + * - Contract metadata for royalty support on platforms such as OpenSea that use + * off-chain information to distribute roaylties. + * + * - Ownership of the contract, with the ability to restrict certain functions to + * only be called by the contract's owner. + * + * - Multicall capability to perform multiple actions atomically + * + * - EIP 2981 compliance for royalty support on NFT marketplaces. + * + * The `drop` mechanism in the `DropSinglePhase` extension is a distribution mechanism for lazy minted tokens. It lets + * you set restrictions such as a price to charge, an allowlist etc. when an address atttempts to mint lazy minted tokens. + * + * The `ERC721Drop` contract lets you lazy mint tokens, and distribute those lazy minted tokens via the drop mechanism. + */ + +contract ERC721Drop is + ERC721A, + ContractMetadata, + Multicall, + Ownable, + Royalty, + BatchMintMetadata, + PrimarySale, + LazyMint, + DelayedReveal, + DropSinglePhase +{ + using Strings for uint256; + + /*/////////////////////////////////////////////////////////////// + Constructor + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Initializes the contract during construction. + * + * @param _defaultAdmin The default admin of the contract. + * @param _name The name of the contract. + * @param _symbol The symbol of the contract. + * @param _royaltyRecipient The address to receive royalties. + * @param _royaltyBps The royalty basis points to be charged. Max = 10000 (10000 = 100%, 1000 = 10%) + * @param _primarySaleRecipient The address to receive primary sale value. + */ + constructor( + address _defaultAdmin, + string memory _name, + string memory _symbol, + address _royaltyRecipient, + uint128 _royaltyBps, + address _primarySaleRecipient + ) ERC721A(_name, _symbol) { + _setupOwner(_defaultAdmin); + _setupDefaultRoyaltyInfo(_royaltyRecipient, _royaltyBps); + _setupPrimarySaleRecipient(_primarySaleRecipient); + } + + /*////////////////////////////////////////////////////////////// + ERC165 Logic + //////////////////////////////////////////////////////////////*/ + + /** + * @dev See ERC165: https://eips.ethereum.org/EIPS/eip-165 + * @inheritdoc IERC165 + */ + function supportsInterface(bytes4 interfaceId) public view virtual override(ERC721A, IERC165) returns (bool) { + return + interfaceId == 0x01ffc9a7 || // ERC165 Interface ID for ERC165 + interfaceId == 0x80ac58cd || // ERC165 Interface ID for ERC721 + interfaceId == 0x5b5e139f || // ERC165 Interface ID for ERC721Metadata + interfaceId == type(IERC2981).interfaceId; // ERC165 ID for ERC2981 + } + + /*/////////////////////////////////////////////////////////////// + Overriden ERC 721 logic + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Returns the metadata URI for an NFT. + * @dev See `BatchMintMetadata` for handling of metadata in this contract. + * + * @param _tokenId The tokenId of an NFT. + */ + function tokenURI(uint256 _tokenId) public view virtual override returns (string memory) { + (uint256 batchId, ) = _getBatchId(_tokenId); + string memory batchUri = _getBaseURI(_tokenId); + + if (isEncryptedBatch(batchId)) { + return string(abi.encodePacked(batchUri, "0")); + } else { + return string(abi.encodePacked(batchUri, _tokenId.toString())); + } + } + + /*/////////////////////////////////////////////////////////////// + Overriden lazy minting logic + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Lets an authorized address lazy mint a given amount of NFTs. + * + * @param _amount The number of NFTs to lazy mint. + * @param _baseURIForTokens The placeholder base URI for the 'n' number of NFTs being lazy minted, where the + * metadata for each of those NFTs is `${baseURIForTokens}/${tokenId}`. + * @param _data The encrypted base URI + provenance hash for the batch of NFTs being lazy minted. + * @return batchId A unique integer identifier for the batch of NFTs lazy minted together. + */ + function lazyMint( + uint256 _amount, + string calldata _baseURIForTokens, + bytes calldata _data + ) public virtual override returns (uint256 batchId) { + if (_data.length > 0) { + (bytes memory encryptedURI, bytes32 provenanceHash) = abi.decode(_data, (bytes, bytes32)); + if (encryptedURI.length != 0 && provenanceHash != "") { + _setEncryptedData(nextTokenIdToLazyMint + _amount, _data); + } + } + + return LazyMint.lazyMint(_amount, _baseURIForTokens, _data); + } + + /// @notice The tokenId assigned to the next new NFT to be lazy minted. + function nextTokenIdToMint() public view virtual returns (uint256) { + return nextTokenIdToLazyMint; + } + + /// @notice The tokenId assigned to the next new NFT to be claimed. + function nextTokenIdToClaim() public view virtual returns (uint256) { + return _currentIndex; + } + + /*/////////////////////////////////////////////////////////////// + Delayed reveal logic + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Lets an authorized address reveal a batch of delayed reveal NFTs. + * + * @param _index The ID for the batch of delayed-reveal NFTs to reveal. + * @param _key The key with which the base URI for the relevant batch of NFTs was encrypted. + */ + function reveal(uint256 _index, bytes calldata _key) public virtual override returns (string memory revealedURI) { + require(_canReveal(), "Not authorized"); + + uint256 batchId = getBatchIdAtIndex(_index); + revealedURI = getRevealURI(batchId, _key); + + _setEncryptedData(batchId, ""); + _setBaseURI(batchId, revealedURI); + + emit TokenURIRevealed(_index, revealedURI); + } + + /*////////////////////////////////////////////////////////////// + Minting/burning logic + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Lets an owner or approved operator burn the NFT of the given tokenId. + * @dev ERC721A's `_burn(uint256,bool)` internally checks for token approvals. + * + * @param _tokenId The tokenId of the NFT to burn. + */ + function burn(uint256 _tokenId) external virtual { + _burn(_tokenId, true); + } + + /*/////////////////////////////////////////////////////////////// + Internal functions + //////////////////////////////////////////////////////////////*/ + + /** + * @dev Runs before every `claim` function call. + * + * @param _quantity The quantity of NFTs being claimed. + */ + function _beforeClaim( + address, + uint256 _quantity, + address, + uint256, + AllowlistProof calldata, + bytes memory + ) internal view virtual override { + if (_currentIndex + _quantity > nextTokenIdToLazyMint) { + revert("Not enough minted tokens"); + } + } + + /** + * @dev Collects and distributes the primary sale value of NFTs being claimed. + * + * @param _primarySaleRecipient The address to receive the primary sale value. + * @param _quantityToClaim The quantity of NFTs being claimed. + * @param _currency The currency in which the NFTs are being claimed. + * @param _pricePerToken The price per token in the given currency. + */ + function _collectPriceOnClaim( + address _primarySaleRecipient, + uint256 _quantityToClaim, + address _currency, + uint256 _pricePerToken + ) internal virtual override { + if (_pricePerToken == 0) { + require(msg.value == 0, "!Value"); + return; + } + + uint256 totalPrice = _quantityToClaim * _pricePerToken; + + bool validMsgValue; + if (_currency == CurrencyTransferLib.NATIVE_TOKEN) { + validMsgValue = msg.value == totalPrice; + } else { + validMsgValue = msg.value == 0; + } + require(validMsgValue, "Invalid msg value"); + + address saleRecipient = _primarySaleRecipient == address(0) ? primarySaleRecipient() : _primarySaleRecipient; + CurrencyTransferLib.transferCurrency(_currency, msg.sender, saleRecipient, totalPrice); + } + + /** + * @dev Transfers the NFTs being claimed. + * + * @param _to The address to which the NFTs are being transferred. + * @param _quantityBeingClaimed The quantity of NFTs being claimed. + */ + function _transferTokensOnClaim( + address _to, + uint256 _quantityBeingClaimed + ) internal virtual override returns (uint256 startTokenId) { + startTokenId = _currentIndex; + _safeMint(_to, _quantityBeingClaimed); + } + + /// @dev Checks whether primary sale recipient can be set in the given execution context. + function _canSetPrimarySaleRecipient() internal view virtual override returns (bool) { + return msg.sender == owner(); + } + + /// @dev Checks whether owner can be set in the given execution context. + function _canSetOwner() internal view virtual override returns (bool) { + return msg.sender == owner(); + } + + /// @dev Checks whether royalty info can be set in the given execution context. + function _canSetRoyaltyInfo() internal view virtual override returns (bool) { + return msg.sender == owner(); + } + + /// @dev Checks whether contract metadata can be set in the given execution context. + function _canSetContractURI() internal view virtual override returns (bool) { + return msg.sender == owner(); + } + + /// @dev Checks whether platform fee info can be set in the given execution context. + function _canSetClaimConditions() internal view virtual override returns (bool) { + return msg.sender == owner(); + } + + /// @dev Returns whether lazy minting can be done in the given execution context. + function _canLazyMint() internal view virtual override returns (bool) { + return msg.sender == owner(); + } + + /// @dev Checks whether NFTs can be revealed in the given execution context. + function _canReveal() internal view virtual returns (bool) { + return msg.sender == owner(); + } + + /*/////////////////////////////////////////////////////////////// + Miscellaneous + //////////////////////////////////////////////////////////////*/ + + function _dropMsgSender() internal view virtual override returns (address) { + return msg.sender; + } + + /// @notice Returns the sender in the given execution context. + function _msgSender() internal view override(Multicall, Context) returns (address) { + return msg.sender; + } +} diff --git a/contracts/base/ERC721LazyMint.sol b/contracts/base/ERC721LazyMint.sol new file mode 100644 index 000000000..71929b22d --- /dev/null +++ b/contracts/base/ERC721LazyMint.sol @@ -0,0 +1,219 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import { ERC721A, Context } from "../eip/ERC721AVirtualApprove.sol"; + +import "../extension/ContractMetadata.sol"; +import "../extension/Multicall.sol"; +import "../extension/Ownable.sol"; +import "../extension/Royalty.sol"; +import "../extension/BatchMintMetadata.sol"; +import "../extension/LazyMint.sol"; +import "../extension/interface/IClaimableERC721.sol"; + +import "../lib/Strings.sol"; +import "../external-deps/openzeppelin/security/ReentrancyGuard.sol"; + +/** + * BASE: ERC721A + * EXTENSION: LazyMint + * + * The `ERC721LazyMint` smart contract implements the ERC721 NFT standard, along with the ERC721A optimization to the standard. + * It includes the following additions to standard ERC721 logic: + * + * - Lazy minting + * + * - Contract metadata for royalty support on platforms such as OpenSea that use + * off-chain information to distribute roaylties. + * + * - Ownership of the contract, with the ability to restrict certain functions to + * only be called by the contract's owner. + * + * - Multicall capability to perform multiple actions atomically + * + * - EIP 2981 compliance for royalty support on NFT marketplaces. + * + * 'Lazy minting' means defining the metadata of NFTs without minting it to an address. Regular 'minting' + * of NFTs means actually assigning an owner to an NFT. + * + * As a contract admin, this lets you prepare the metadata for NFTs that will be minted by an external party, + * without paying the gas cost for actually minting the NFTs. + */ + +contract ERC721LazyMint is + ERC721A, + ContractMetadata, + Multicall, + Ownable, + Royalty, + BatchMintMetadata, + LazyMint, + IClaimableERC721, + ReentrancyGuard +{ + using Strings for uint256; + + /*////////////////////////////////////////////////////////////// + Constructor + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Initializes the contract during construction. + * + * @param _defaultAdmin The default admin of the contract. + * @param _name The name of the contract. + * @param _symbol The symbol of the contract. + * @param _royaltyRecipient The address to receive royalties. + * @param _royaltyBps The royalty basis points to be charged. Max = 10000 (10000 = 100%, 1000 = 10%) + */ + constructor( + address _defaultAdmin, + string memory _name, + string memory _symbol, + address _royaltyRecipient, + uint128 _royaltyBps + ) ERC721A(_name, _symbol) { + _setupOwner(_defaultAdmin); + _setupDefaultRoyaltyInfo(_royaltyRecipient, _royaltyBps); + } + + /*////////////////////////////////////////////////////////////// + ERC165 Logic + //////////////////////////////////////////////////////////////*/ + + /** + * @dev See ERC165: https://eips.ethereum.org/EIPS/eip-165 + * @inheritdoc IERC165 + */ + function supportsInterface(bytes4 interfaceId) public view virtual override(ERC721A, IERC165) returns (bool) { + return + interfaceId == 0x01ffc9a7 || // ERC165 Interface ID for ERC165 + interfaceId == 0x80ac58cd || // ERC165 Interface ID for ERC721 + interfaceId == 0x5b5e139f || // ERC165 Interface ID for ERC721Metadata + interfaceId == type(IERC2981).interfaceId; // ERC165 ID for ERC2981 + } + + /*////////////////////////////////////////////////////////////// + Overriden ERC721 logic + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Returns the metadata URI for an NFT. + * @dev See `BatchMintMetadata` for handling of metadata in this contract. + * + * @param _tokenId The tokenId of an NFT. + */ + function tokenURI(uint256 _tokenId) public view virtual override returns (string memory) { + string memory batchUri = _getBaseURI(_tokenId); + return string(abi.encodePacked(batchUri, _tokenId.toString())); + } + + /*////////////////////////////////////////////////////////////// + Claiming logic + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Lets an address claim multiple lazy minted NFTs at once to a recipient. + * This function prevents any reentrant calls, and is not allowed to be overridden. + * + * @dev Contract creators should override `verifyClaim` and `transferTokensOnClaim` + * functions to create custom logic for verification and claiming, + * for e.g. price collection, allowlist, max quantity, etc. + * The logic in `verifyClaim` determines whether the caller is authorized to mint NFTs. + * The logic in `transferTokensOnClaim` does actual minting of tokens, + * can also be used to apply other state changes. + * + * @param _receiver The recipient of the NFT to mint. + * @param _quantity The number of NFTs to mint. + */ + function claim(address _receiver, uint256 _quantity) public payable virtual nonReentrant { + require(_currentIndex + _quantity <= nextTokenIdToLazyMint, "Not enough lazy minted tokens."); + verifyClaim(msg.sender, _quantity); // Add your claim verification logic by overriding this function. + + uint256 startTokenId = _transferTokensOnClaim(_receiver, _quantity); // Mints tokens. Apply any state updates by overriding this function. + emit TokensClaimed(msg.sender, _receiver, startTokenId, _quantity); + } + + /** + * @notice Checks a request to claim NFTs against a custom condition. + * + * @dev Override this function to add logic for claim verification, based on conditions + * such as allowlist, price, max quantity etc. + * + * @param _claimer Caller of the claim function. + * @param _quantity The number of NFTs being claimed. + */ + function verifyClaim(address _claimer, uint256 _quantity) public view virtual {} + + /** + * @notice Lets an owner or approved operator burn the NFT of the given tokenId. + * @dev ERC721A's `_burn(uint256,bool)` internally checks for token approvals. + * + * @param _tokenId The tokenId of the NFT to burn. + */ + function burn(uint256 _tokenId) external virtual { + _burn(_tokenId, true); + } + + /// @notice The tokenId assigned to the next new NFT to be lazy minted. + function nextTokenIdToMint() public view virtual returns (uint256) { + return nextTokenIdToLazyMint; + } + + /// @notice The tokenId assigned to the next new NFT to be claimed. + function nextTokenIdToClaim() public view virtual returns (uint256) { + return _currentIndex; + } + + /*////////////////////////////////////////////////////////////// + Internal functions + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Mints tokens to receiver on claim. + * Any state changes related to `claim` must be applied + * here by overriding this function. + * + * @dev Override this function to add logic for state updation. + * When overriding, apply any state changes before `_safeMint`. + * + * @param _receiver The recipient of the NFT to mint. + * @param _quantity The number of NFTs to mint. + * + * @return startTokenId The tokenId of the first NFT minted. + */ + function _transferTokensOnClaim( + address _receiver, + uint256 _quantity + ) internal virtual returns (uint256 startTokenId) { + startTokenId = _currentIndex; + _safeMint(_receiver, _quantity); + } + + /// @dev Returns whether lazy minting can be done in the given execution context. + function _canLazyMint() internal view virtual override returns (bool) { + return msg.sender == owner(); + } + + /// @dev Returns whether contract metadata can be set in the given execution context. + function _canSetContractURI() internal view virtual override returns (bool) { + return msg.sender == owner(); + } + + /// @dev Returns whether owner can be set in the given execution context. + function _canSetOwner() internal view virtual override returns (bool) { + return msg.sender == owner(); + } + + /// @dev Returns whether royalty info can be set in the given execution context. + function _canSetRoyaltyInfo() internal view virtual override returns (bool) { + return msg.sender == owner(); + } + + /// @notice Returns the sender in the given execution context. + function _msgSender() internal view override(Multicall, Context) returns (address) { + return msg.sender; + } +} diff --git a/contracts/base/ERC721Multiwrap.sol b/contracts/base/ERC721Multiwrap.sol new file mode 100644 index 000000000..6125b059f --- /dev/null +++ b/contracts/base/ERC721Multiwrap.sol @@ -0,0 +1,262 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import { ERC721A, Context } from "../eip/ERC721AVirtualApprove.sol"; + +import "../extension/ContractMetadata.sol"; +import "../extension/Ownable.sol"; +import "../extension/Royalty.sol"; +import "../extension/SoulboundERC721A.sol"; +import "../extension/TokenStore.sol"; +import "../extension/Multicall.sol"; +import { ReentrancyGuard } from "../extension/upgradeable/ReentrancyGuard.sol"; + +/** + * BASE: ERC721Base + * EXTENSION: TokenStore, SoulboundERC721A + * + * The `ERC721Multiwrap` contract uses the `ERC721Base` contract, along with the `TokenStore` and + * `SoulboundERC721A` extension. + * + * The `ERC721Multiwrap` contract lets you wrap arbitrary ERC20, ERC721 and ERC1155 tokens you own + * into a single wrapped token / NFT. + * + * The `SoulboundERC721A` extension lets you make your NFTs 'soulbound' i.e. non-transferrable. + * + */ + +contract ERC721Multiwrap is + Multicall, + TokenStore, + SoulboundERC721A, + ERC721A, + ContractMetadata, + Ownable, + Royalty, + ReentrancyGuard +{ + /*////////////////////////////////////////////////////////////// + Permission control roles + //////////////////////////////////////////////////////////////*/ + + /// @dev Only MINTER_ROLE holders can wrap tokens, when wrapping is restricted. + bytes32 private constant MINTER_ROLE = keccak256("MINTER_ROLE"); + /// @dev Only UNWRAP_ROLE holders can unwrap tokens, when unwrapping is restricted. + bytes32 private constant UNWRAP_ROLE = keccak256("UNWRAP_ROLE"); + /// @dev Only assets with ASSET_ROLE can be wrapped, when wrapping is restricted to particular assets. + bytes32 private constant ASSET_ROLE = keccak256("ASSET_ROLE"); + + /*////////////////////////////////////////////////////////////// + Events + //////////////////////////////////////////////////////////////*/ + + /// @dev Emitted when tokens are wrapped. + event TokensWrapped( + address indexed wrapper, + address indexed recipientOfWrappedToken, + uint256 indexed tokenIdOfWrappedToken, + Token[] wrappedContents + ); + + /// @dev Emitted when tokens are unwrapped. + event TokensUnwrapped( + address indexed unwrapper, + address indexed recipientOfWrappedContents, + uint256 indexed tokenIdOfWrappedToken + ); + + /*////////////////////////////////////////////////////////////// + Events + //////////////////////////////////////////////////////////////*/ + + /// @notice Checks whether the caller holds `role`, when restrictions for `role` are switched on. + modifier onlyRoleWithSwitch(bytes32 role) { + _checkRoleWithSwitch(role, msg.sender); + _; + } + + /*////////////////////////////////////////////////////////////// + Constructor + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Initializes the contract during construction. + * + * @param _defaultAdmin The default admin of the contract. + * @param _name The name of the contract. + * @param _symbol The symbol of the contract. + * @param _royaltyRecipient The address to receive royalties. + * @param _royaltyBps The royalty basis points to be charged. Max = 10000 (10000 = 100%, 1000 = 10%) + * @param _nativeTokenWrapper The address of the ERC20 wrapper for the native token. + */ + constructor( + address _defaultAdmin, + string memory _name, + string memory _symbol, + address _royaltyRecipient, + uint128 _royaltyBps, + address _nativeTokenWrapper + ) ERC721A(_name, _symbol) TokenStore(_nativeTokenWrapper) { + _setupOwner(_defaultAdmin); + _setupDefaultRoyaltyInfo(_royaltyRecipient, _royaltyBps); + + _setupRole(DEFAULT_ADMIN_ROLE, _defaultAdmin); + _setupRole(MINTER_ROLE, _defaultAdmin); + _setupRole(TRANSFER_ROLE, _defaultAdmin); + + _setupRole(ASSET_ROLE, address(0)); + _setupRole(UNWRAP_ROLE, address(0)); + + restrictTransfers(false); + } + + /*/////////////////////////////////////////////////////////////// + Public gette functions + //////////////////////////////////////////////////////////////*/ + + /** + * @dev See ERC165: https://eips.ethereum.org/EIPS/eip-165 + * @inheritdoc IERC165 + */ + function supportsInterface( + bytes4 interfaceId + ) public view virtual override(ERC1155Receiver, ERC721A, IERC165) returns (bool) { + return + super.supportsInterface(interfaceId) || + interfaceId == 0x01ffc9a7 || // ERC165 Interface ID for ERC165 + interfaceId == 0x80ac58cd || // ERC165 Interface ID for ERC721 + interfaceId == 0x5b5e139f || // ERC165 Interface ID for ERC721Metadata + interfaceId == type(IERC2981).interfaceId || // ERC165 ID for ERC2981 + interfaceId == type(IERC1155Receiver).interfaceId; + } + + /*////////////////////////////////////////////////////////////// + Overriden ERC721 logic + //////////////////////////////////////////////////////////////*/ + + /// @dev Returns the URI for a given tokenId. + function tokenURI(uint256 _tokenId) public view virtual override returns (string memory) { + return getUriOfBundle(_tokenId); + } + + /*/////////////////////////////////////////////////////////////// + Wrapping / Unwrapping logic + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Wrap multiple ERC1155, ERC721, ERC20 tokens into a single wrapped NFT. + * + * @param _tokensToWrap The tokens to wrap. + * @param _uriForWrappedToken The metadata URI for the wrapped NFT. + * @param _recipient The recipient of the wrapped NFT. + * + * @return tokenId The tokenId of the wrapped NFT minted. + */ + function wrap( + Token[] calldata _tokensToWrap, + string calldata _uriForWrappedToken, + address _recipient + ) public payable virtual onlyRoleWithSwitch(MINTER_ROLE) nonReentrant returns (uint256 tokenId) { + if (!hasRole(ASSET_ROLE, address(0))) { + for (uint256 i = 0; i < _tokensToWrap.length; i += 1) { + _checkRole(ASSET_ROLE, _tokensToWrap[i].assetContract); + } + } + + tokenId = nextTokenIdToMint(); + + _storeTokens(msg.sender, _tokensToWrap, _uriForWrappedToken, tokenId); + + _safeMint(_recipient, 1); + + emit TokensWrapped(msg.sender, _recipient, tokenId, _tokensToWrap); + } + + /** + * @notice Unwrap a wrapped NFT to retrieve underlying ERC1155, ERC721, ERC20 tokens. + * + * @param _tokenId The token Id of the wrapped NFT to unwrap. + * @param _recipient The recipient of the underlying ERC1155, ERC721, ERC20 tokens of the wrapped NFT. + */ + function unwrap(uint256 _tokenId, address _recipient) public virtual onlyRoleWithSwitch(UNWRAP_ROLE) nonReentrant { + require(_tokenId < nextTokenIdToMint(), "wrapped NFT DNE."); + require(isApprovedOrOwner(msg.sender, _tokenId), "caller not approved for unwrapping."); + + _burn(_tokenId); + _releaseTokens(_recipient, _tokenId); + + emit TokensUnwrapped(msg.sender, _recipient, _tokenId); + } + + /*////////////////////////////////////////////////////////////// + Public getters + //////////////////////////////////////////////////////////////*/ + + /// @notice The tokenId assigned to the next new NFT to be minted. + function nextTokenIdToMint() public view virtual returns (uint256) { + return _currentIndex; + } + + /** + * @notice Returns whether a given address is the owner, or approved to transfer an NFT. + * + * @param _operator The address to check. + * @param _tokenId The tokenId to check. + * + * @return isApprovedOrOwnerOf Whether `_operator` is approved to transfer `_tokenId`. + */ + function isApprovedOrOwner( + address _operator, + uint256 _tokenId + ) public view virtual returns (bool isApprovedOrOwnerOf) { + address owner = ownerOf(_tokenId); + isApprovedOrOwnerOf = (_operator == owner || + isApprovedForAll(owner, _operator) || + getApproved(_tokenId) == _operator); + } + + /*/////////////////////////////////////////////////////////////// + Internal functions + //////////////////////////////////////////////////////////////*/ + + /** + * @dev See {ERC721-_beforeTokenTransfer}. + * @inheritdoc ERC721A + */ + function _beforeTokenTransfers( + address from, + address to, + uint256 startTokenId, + uint256 quantity + ) internal virtual override(ERC721A, SoulboundERC721A) { + super._beforeTokenTransfers(from, to, startTokenId, quantity); + SoulboundERC721A._beforeTokenTransfers(from, to, startTokenId, quantity); + } + + /// @dev Returns whether transfers can be restricted in a given execution context. + function _canRestrictTransfers() internal view virtual override returns (bool) { + return msg.sender == owner(); + } + + /// @dev Returns whether contract metadata can be set in the given execution context. + function _canSetContractURI() internal view virtual override returns (bool) { + return msg.sender == owner(); + } + + /// @dev Returns whether owner can be set in the given execution context. + function _canSetOwner() internal view virtual override returns (bool) { + return msg.sender == owner(); + } + + /// @dev Returns whether royalty info can be set in the given execution context. + function _canSetRoyaltyInfo() internal view virtual override returns (bool) { + return msg.sender == owner(); + } + + /// @notice Returns the sender in the given execution context. + function _msgSender() internal view override(Multicall, Context) returns (address) { + return msg.sender; + } +} diff --git a/contracts/base/ERC721SignatureMint.sol b/contracts/base/ERC721SignatureMint.sol new file mode 100644 index 000000000..a3b389b00 --- /dev/null +++ b/contracts/base/ERC721SignatureMint.sol @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "./ERC721Base.sol"; + +import "../extension/PrimarySale.sol"; +import "../extension/PermissionsEnumerable.sol"; +import "../extension/SignatureMintERC721.sol"; +import { ReentrancyGuard } from "../extension/upgradeable/ReentrancyGuard.sol"; +import { CurrencyTransferLib } from "../lib/CurrencyTransferLib.sol"; + +/** + * BASE: ERC721A + * EXTENSION: SignatureMintERC721 + * + * The `ERC721SignatureMint` contract uses the `ERC721Base` contract, along with the `SignatureMintERC721` extension. + * + * The 'signature minting' mechanism in the `SignatureMintERC721` extension uses EIP 712, and is a way for a contract + * admin to authorize an external party's request to mint tokens on the admin's contract. At a high level, this means + * you can authorize some external party to mint tokens on your contract, and specify what exactly will be minted by + * that external party. + * + */ + +contract ERC721SignatureMint is ERC721Base, PrimarySale, SignatureMintERC721, ReentrancyGuard { + /*////////////////////////////////////////////////////////////// + Constructor + //////////////////////////////////////////////////////////////*/ + + constructor( + address _defaultAdmin, + string memory _name, + string memory _symbol, + address _royaltyRecipient, + uint128 _royaltyBps, + address _primarySaleRecipient + ) ERC721Base(_defaultAdmin, _name, _symbol, _royaltyRecipient, _royaltyBps) { + _setupPrimarySaleRecipient(_primarySaleRecipient); + } + + /*////////////////////////////////////////////////////////////// + Signature minting logic + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Mints tokens according to the provided mint request. + * + * @param _req The payload / mint request. + * @param _signature The signature produced by an account signing the mint request. + */ + function mintWithSignature( + MintRequest calldata _req, + bytes calldata _signature + ) external payable virtual override nonReentrant returns (address signer) { + require(_req.quantity == 1, "quantiy must be 1"); + + uint256 tokenIdToMint = nextTokenIdToMint(); + + // Verify and process payload. + signer = _processRequest(_req, _signature); + + address receiver = _req.to; + + // Collect price + _collectPriceOnClaim(_req.primarySaleRecipient, _req.quantity, _req.currency, _req.pricePerToken); + + // Set royalties, if applicable. + if (_req.royaltyRecipient != address(0) && _req.royaltyBps != 0) { + _setupRoyaltyInfoForToken(tokenIdToMint, _req.royaltyRecipient, _req.royaltyBps); + } + + // Mint tokens. + _setTokenURI(tokenIdToMint, _req.uri); + _safeMint(receiver, _req.quantity); + + emit TokensMintedWithSignature(signer, receiver, tokenIdToMint, _req); + } + + /*////////////////////////////////////////////////////////////// + Internal functions + //////////////////////////////////////////////////////////////*/ + + /// @dev Returns whether a given address is authorized to sign mint requests. + function _canSignMintRequest(address _signer) internal view virtual override returns (bool) { + return _signer == owner(); + } + + /// @dev Returns whether primary sale recipient can be set in the given execution context. + function _canSetPrimarySaleRecipient() internal view virtual override returns (bool) { + return msg.sender == owner(); + } + + /// @dev Collects and distributes the primary sale value of NFTs being claimed. + function _collectPriceOnClaim( + address _primarySaleRecipient, + uint256 _quantityToClaim, + address _currency, + uint256 _pricePerToken + ) internal virtual { + if (_pricePerToken == 0) { + require(msg.value == 0, "!Value"); + return; + } + + uint256 totalPrice = _quantityToClaim * _pricePerToken; + + bool validMsgValue; + if (_currency == CurrencyTransferLib.NATIVE_TOKEN) { + validMsgValue = msg.value == totalPrice; + } else { + validMsgValue = msg.value == 0; + } + require(validMsgValue, "Invalid msg value"); + + address saleRecipient = _primarySaleRecipient == address(0) ? primarySaleRecipient() : _primarySaleRecipient; + CurrencyTransferLib.transferCurrency(_currency, msg.sender, saleRecipient, totalPrice); + } +} diff --git a/contracts/base/Staking1155Base.sol b/contracts/base/Staking1155Base.sol new file mode 100644 index 000000000..a557d06a1 --- /dev/null +++ b/contracts/base/Staking1155Base.sol @@ -0,0 +1,189 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "../extension/ContractMetadata.sol"; +import "../extension/Multicall.sol"; +import "../extension/Ownable.sol"; +import "../extension/Staking1155.sol"; + +import "../eip/ERC165.sol"; +import "../eip/interface/IERC20.sol"; +import "../eip/interface/IERC1155Receiver.sol"; + +import { CurrencyTransferLib } from "../lib/CurrencyTransferLib.sol"; + +/** + * + * EXTENSION: Staking1155 + * + * The `Staking1155Base` smart contract implements NFT staking mechanism. + * Allows users to stake their ERC-1155 NFTs and earn rewards in form of ERC-20 tokens. + * + * Following features and implementation setup must be noted: + * + * - ERC-1155 NFTs from only one collection can be staked. + * + * - Contract admin can choose to give out rewards by either transferring or minting the rewardToken, + * which is an ERC20 token. See {_mintRewards}. + * + * - To implement custom logic for staking, reward calculation, etc. corresponding functions can be + * overridden from the extension `Staking1155`. + * + * - Ownership of the contract, with the ability to restrict certain functions to + * only be called by the contract's owner. + * + * - Multicall capability to perform multiple actions atomically. + * + */ + +/// note: This contract is provided as a base contract. +// This is to support a variety of use-cases that can be build on top of this base. +// +// Additional functionality such as deposit functions, reward-minting, etc. +// must be implemented by the deployer of this contract, as needed for their use-case. + +contract Staking1155Base is ContractMetadata, Multicall, Ownable, Staking1155, ERC165, IERC1155Receiver { + /// @dev ERC20 Reward Token address. See {_mintRewards} below. + address public immutable rewardToken; + + /// @dev The address of the native token wrapper contract. + address internal immutable nativeTokenWrapper; + + /// @dev Total amount of reward tokens in the contract. + uint256 private rewardTokenBalance; + + constructor( + uint80 _defaultTimeUnit, + address _defaultAdmin, + uint256 _defaultRewardsPerUnitTime, + address _stakingToken, + address _rewardToken, + address _nativeTokenWrapper + ) Staking1155(_stakingToken) { + _setupOwner(_defaultAdmin); + _setDefaultStakingCondition(_defaultTimeUnit, _defaultRewardsPerUnitTime); + + rewardToken = _rewardToken; + nativeTokenWrapper = _nativeTokenWrapper; + } + + /// @dev Lets the contract receive ether to unwrap native tokens. + receive() external payable virtual { + require(msg.sender == nativeTokenWrapper, "caller not native token wrapper."); + } + + /// @dev Admin deposits reward tokens. + function depositRewardTokens(uint256 _amount) external payable virtual nonReentrant { + _depositRewardTokens(_amount); // override this for custom logic. + } + + /// @dev Admin can withdraw excess reward tokens. + function withdrawRewardTokens(uint256 _amount) external virtual nonReentrant { + _withdrawRewardTokens(_amount); // override this for custom logic. + } + + /// @notice View total rewards available in the staking contract. + function getRewardTokenBalance() external view virtual override returns (uint256) { + return rewardTokenBalance; + } + + /*/////////////////////////////////////////////////////////////// + ERC 165 / 721 logic + //////////////////////////////////////////////////////////////*/ + + function onERC1155Received(address, address, uint256, uint256, bytes calldata) external view returns (bytes4) { + require(isStaking == 2, "Direct transfer"); + return this.onERC1155Received.selector; + } + + function onERC1155BatchReceived( + address operator, + address from, + uint256[] calldata ids, + uint256[] calldata values, + bytes calldata data + ) external virtual returns (bytes4) {} + + function supportsInterface(bytes4 interfaceId) public view override(ERC165, IERC165) returns (bool) { + return interfaceId == type(IERC1155Receiver).interfaceId || super.supportsInterface(interfaceId); + } + + /*////////////////////////////////////////////////////////////// + Minting logic + //////////////////////////////////////////////////////////////*/ + + /** + * @dev Mint ERC20 rewards to the staker. Override for custom logic. + * + * @param _staker Address for which to calculated rewards. + * @param _rewards Amount of tokens to be given out as reward. + * + */ + function _mintRewards(address _staker, uint256 _rewards) internal virtual override { + require(_rewards <= rewardTokenBalance, "Not enough reward tokens"); + rewardTokenBalance -= _rewards; + CurrencyTransferLib.transferCurrencyWithWrapper( + rewardToken, + address(this), + _staker, + _rewards, + nativeTokenWrapper + ); + } + + /*////////////////////////////////////////////////////////////// + Other Internal functions + //////////////////////////////////////////////////////////////*/ + + /// @dev Admin deposits reward tokens -- override for custom logic. + function _depositRewardTokens(uint256 _amount) internal virtual { + require(msg.sender == owner(), "Not authorized"); + + address _rewardToken = rewardToken == CurrencyTransferLib.NATIVE_TOKEN ? nativeTokenWrapper : rewardToken; + + uint256 balanceBefore = IERC20(_rewardToken).balanceOf(address(this)); + CurrencyTransferLib.transferCurrencyWithWrapper( + rewardToken, + msg.sender, + address(this), + _amount, + nativeTokenWrapper + ); + uint256 actualAmount = IERC20(_rewardToken).balanceOf(address(this)) - balanceBefore; + + rewardTokenBalance += actualAmount; + } + + /// @dev Admin can withdraw excess reward tokens -- override for custom logic. + function _withdrawRewardTokens(uint256 _amount) internal virtual { + require(msg.sender == owner(), "Not authorized"); + + // to prevent locking of direct-transferred tokens + rewardTokenBalance = _amount > rewardTokenBalance ? 0 : rewardTokenBalance - _amount; + + CurrencyTransferLib.transferCurrencyWithWrapper( + rewardToken, + address(this), + msg.sender, + _amount, + nativeTokenWrapper + ); + } + + /// @dev Returns whether staking restrictions can be set in given execution context. + function _canSetStakeConditions() internal view virtual override returns (bool) { + return msg.sender == owner(); + } + + /// @dev Returns whether contract metadata can be set in the given execution context. + function _canSetContractURI() internal view virtual override returns (bool) { + return msg.sender == owner(); + } + + /// @dev Returns whether owner can be set in the given execution context. + function _canSetOwner() internal view virtual override returns (bool) { + return msg.sender == owner(); + } +} diff --git a/contracts/base/Staking20Base.sol b/contracts/base/Staking20Base.sol new file mode 100644 index 000000000..6e6cbe05b --- /dev/null +++ b/contracts/base/Staking20Base.sol @@ -0,0 +1,179 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "../extension/ContractMetadata.sol"; +import "../extension/Multicall.sol"; +import "../extension/Ownable.sol"; +import "../extension/Staking20.sol"; + +import "../eip/interface/IERC20.sol"; +import "../eip/interface/IERC20Metadata.sol"; + +import { CurrencyTransferLib } from "../lib/CurrencyTransferLib.sol"; + +/** + * + * EXTENSION: Staking20 + * + * The `Staking20Base` smart contract implements Token staking mechanism. + * Allows users to stake their ERC-20 Tokens and earn rewards in form of another ERC-20 tokens. + * + * Following features and implementation setup must be noted: + * + * - ERC-20 Tokens from only one contract can be staked. + * + * - Contract admin can choose to give out rewards by either transferring or minting the rewardToken, + * which is ideally a different ERC20 token. See {_mintRewards}. + * + * - To implement custom logic for staking, reward calculation, etc. corresponding functions can be + * overridden from the extension `Staking20`. + * + * - Ownership of the contract, with the ability to restrict certain functions to + * only be called by the contract's owner. + * + * - Multicall capability to perform multiple actions atomically. + * + */ + +/// note: This contract is provided as a base contract. +// This is to support a variety of use-cases that can be build on top of this base. +// +// Additional functionality such as deposit functions, reward-minting, etc. +// must be implemented by the deployer of this contract, as needed for their use-case. + +contract Staking20Base is ContractMetadata, Multicall, Ownable, Staking20 { + /// @dev ERC20 Reward Token address. See {_mintRewards} below. + address public immutable rewardToken; + + /// @dev Total amount of reward tokens in the contract. + uint256 private rewardTokenBalance; + + constructor( + uint80 _timeUnit, + address _defaultAdmin, + uint256 _rewardRatioNumerator, + uint256 _rewardRatioDenominator, + address _stakingToken, + address _rewardToken, + address _nativeTokenWrapper + ) + Staking20( + _nativeTokenWrapper, + _stakingToken, + IERC20Metadata(_stakingToken).decimals(), + IERC20Metadata(_rewardToken).decimals() + ) + { + _setupOwner(_defaultAdmin); + _setStakingCondition(_timeUnit, _rewardRatioNumerator, _rewardRatioDenominator); + + require(_rewardToken != _stakingToken, "Reward Token and Staking Token can't be same."); + rewardToken = _rewardToken; + } + + /// @dev Lets the contract receive ether to unwrap native tokens. + receive() external payable virtual { + require(msg.sender == nativeTokenWrapper, "caller not native token wrapper."); + } + + /// @dev Admin deposits reward tokens. + function depositRewardTokens(uint256 _amount) external payable virtual nonReentrant { + _depositRewardTokens(_amount); // override this for custom logic. + } + + /// @dev Admin can withdraw excess reward tokens. + function withdrawRewardTokens(uint256 _amount) external virtual nonReentrant { + _withdrawRewardTokens(_amount); // override this for custom logic. + } + + /// @notice View total rewards available in the staking contract. + function getRewardTokenBalance() external view virtual override returns (uint256) { + return rewardTokenBalance; + } + + /*////////////////////////////////////////////////////////////// + Minting logic + //////////////////////////////////////////////////////////////*/ + + /** + * @dev Mint ERC20 rewards to the staker. Override for custom logic. + * + * @param _staker Address for which to calculated rewards. + * @param _rewards Amount of tokens to be given out as reward. + * + */ + function _mintRewards(address _staker, uint256 _rewards) internal virtual override { + require(_rewards <= rewardTokenBalance, "Not enough reward tokens"); + rewardTokenBalance -= _rewards; + CurrencyTransferLib.transferCurrencyWithWrapper( + rewardToken, + address(this), + _staker, + _rewards, + nativeTokenWrapper + ); + } + + /*////////////////////////////////////////////////////////////// + Other Internal functions + //////////////////////////////////////////////////////////////*/ + + /// @dev Admin deposits reward tokens -- override for custom logic. + function _depositRewardTokens(uint256 _amount) internal virtual { + require(msg.sender == owner(), "Not authorized"); + + address _rewardToken = rewardToken == CurrencyTransferLib.NATIVE_TOKEN ? nativeTokenWrapper : rewardToken; + + uint256 balanceBefore = IERC20(_rewardToken).balanceOf(address(this)); + CurrencyTransferLib.transferCurrencyWithWrapper( + rewardToken, + msg.sender, + address(this), + _amount, + nativeTokenWrapper + ); + uint256 actualAmount = IERC20(_rewardToken).balanceOf(address(this)) - balanceBefore; + + rewardTokenBalance += actualAmount; + } + + /// @dev Admin can withdraw excess reward tokens -- override for custom logic. + function _withdrawRewardTokens(uint256 _amount) internal virtual { + require(msg.sender == owner(), "Not authorized"); + + // to prevent locking of direct-transferred tokens + rewardTokenBalance = _amount > rewardTokenBalance ? 0 : rewardTokenBalance - _amount; + + CurrencyTransferLib.transferCurrencyWithWrapper( + rewardToken, + address(this), + msg.sender, + _amount, + nativeTokenWrapper + ); + + // The withdrawal shouldn't reduce staking token balance. `>=` accounts for any accidental transfers. + address _stakingToken = stakingToken == CurrencyTransferLib.NATIVE_TOKEN ? nativeTokenWrapper : stakingToken; + require( + IERC20(_stakingToken).balanceOf(address(this)) >= stakingTokenBalance, + "Staking token balance reduced." + ); + } + + /// @dev Returns whether staking restrictions can be set in given execution context. + function _canSetStakeConditions() internal view virtual override returns (bool) { + return msg.sender == owner(); + } + + /// @dev Returns whether contract metadata can be set in the given execution context. + function _canSetContractURI() internal view virtual override returns (bool) { + return msg.sender == owner(); + } + + /// @dev Returns whether owner can be set in the given execution context. + function _canSetOwner() internal view virtual override returns (bool) { + return msg.sender == owner(); + } +} diff --git a/contracts/base/Staking721Base.sol b/contracts/base/Staking721Base.sol new file mode 100644 index 000000000..ad1ebb19d --- /dev/null +++ b/contracts/base/Staking721Base.sol @@ -0,0 +1,186 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "../extension/ContractMetadata.sol"; +import "../extension/Multicall.sol"; +import "../extension/Ownable.sol"; +import "../extension/Staking721.sol"; + +import "../eip/ERC165.sol"; +import "../eip/interface/IERC20.sol"; +import "../eip/interface/IERC721Receiver.sol"; + +import { CurrencyTransferLib } from "../lib/CurrencyTransferLib.sol"; + +/** + * + * EXTENSION: Staking721 + * + * The `Staking721Base` smart contract implements NFT staking mechanism. + * Allows users to stake their ERC-721 NFTs and earn rewards in form of ERC-20 tokens. + * + * Following features and implementation setup must be noted: + * + * - ERC-721 NFTs from only one NFT collection can be staked. + * + * - Contract admin can choose to give out rewards by either transferring or minting the rewardToken, + * which is an ERC20 token. See {_mintRewards}. + * + * - To implement custom logic for staking, reward calculation, etc. corresponding functions can be + * overridden from the extension `Staking721`. + * + * - Ownership of the contract, with the ability to restrict certain functions to + * only be called by the contract's owner. + * + * - Multicall capability to perform multiple actions atomically. + * + */ + +/// note: This contract is provided as a base contract. +// This is to support a variety of use-cases that can be build on top of this base. +// +// Additional functionality such as deposit functions, reward-minting, etc. +// must be implemented by the deployer of this contract, as needed for their use-case. + +contract Staking721Base is ContractMetadata, Multicall, Ownable, Staking721, ERC165, IERC721Receiver { + /// @dev ERC20 Reward Token address. See {_mintRewards} below. + address public immutable rewardToken; + + /// @dev The address of the native token wrapper contract. + address public immutable nativeTokenWrapper; + + /// @dev Total amount of reward tokens in the contract. + uint256 private rewardTokenBalance; + + constructor( + address _defaultAdmin, + uint256 _timeUnit, + uint256 _rewardsPerUnitTime, + address _stakingToken, + address _rewardToken, + address _nativeTokenWrapper + ) Staking721(_stakingToken) { + _setupOwner(_defaultAdmin); + _setStakingCondition(_timeUnit, _rewardsPerUnitTime); + + rewardToken = _rewardToken; + nativeTokenWrapper = _nativeTokenWrapper; + } + + /// @dev Lets the contract receive ether to unwrap native tokens. + receive() external payable virtual { + require(msg.sender == nativeTokenWrapper, "caller not native token wrapper."); + } + + /// @dev Admin deposits reward tokens. + function depositRewardTokens(uint256 _amount) external payable virtual nonReentrant { + _depositRewardTokens(_amount); // override this for custom logic. + } + + /// @dev Admin can withdraw excess reward tokens. + function withdrawRewardTokens(uint256 _amount) external virtual nonReentrant { + _withdrawRewardTokens(_amount); // override this for custom logic. + } + + /// @notice View total rewards available in the staking contract. + function getRewardTokenBalance() external view virtual override returns (uint256) { + return rewardTokenBalance; + } + + /*/////////////////////////////////////////////////////////////// + ERC 165 / 721 logic + //////////////////////////////////////////////////////////////*/ + + function onERC721Received( + address, + address, + uint256, + bytes calldata + ) external view virtual override returns (bytes4) { + require(isStaking == 2, "Direct transfer"); + return this.onERC721Received.selector; + } + + function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) { + return interfaceId == type(IERC721Receiver).interfaceId || super.supportsInterface(interfaceId); + } + + /*////////////////////////////////////////////////////////////// + Minting logic + //////////////////////////////////////////////////////////////*/ + + /** + * @dev Mint ERC20 rewards to the staker. Override for custom logic. + * + * @param _staker Address for which to calculated rewards. + * @param _rewards Amount of tokens to be given out as reward. + * + */ + function _mintRewards(address _staker, uint256 _rewards) internal virtual override { + require(_rewards <= rewardTokenBalance, "Not enough reward tokens"); + rewardTokenBalance -= _rewards; + CurrencyTransferLib.transferCurrencyWithWrapper( + rewardToken, + address(this), + _staker, + _rewards, + nativeTokenWrapper + ); + } + + /*////////////////////////////////////////////////////////////// + Other Internal functions + //////////////////////////////////////////////////////////////*/ + + /// @dev Admin deposits reward tokens -- override for custom logic. + function _depositRewardTokens(uint256 _amount) internal virtual { + require(msg.sender == owner(), "Not authorized"); + + address _rewardToken = rewardToken == CurrencyTransferLib.NATIVE_TOKEN ? nativeTokenWrapper : rewardToken; + + uint256 balanceBefore = IERC20(_rewardToken).balanceOf(address(this)); + CurrencyTransferLib.transferCurrencyWithWrapper( + rewardToken, + msg.sender, + address(this), + _amount, + nativeTokenWrapper + ); + uint256 actualAmount = IERC20(_rewardToken).balanceOf(address(this)) - balanceBefore; + + rewardTokenBalance += actualAmount; + } + + /// @dev Admin can withdraw excess reward tokens -- override for custom logic. + function _withdrawRewardTokens(uint256 _amount) internal virtual { + require(msg.sender == owner(), "Not authorized"); + + // to prevent locking of direct-transferred tokens + rewardTokenBalance = _amount > rewardTokenBalance ? 0 : rewardTokenBalance - _amount; + + CurrencyTransferLib.transferCurrencyWithWrapper( + rewardToken, + address(this), + msg.sender, + _amount, + nativeTokenWrapper + ); + } + + /// @dev Returns whether staking restrictions can be set in given execution context. + function _canSetStakeConditions() internal view virtual override returns (bool) { + return msg.sender == owner(); + } + + /// @dev Returns whether contract metadata can be set in the given execution context. + function _canSetContractURI() internal view virtual override returns (bool) { + return msg.sender == owner(); + } + + /// @dev Returns whether owner can be set in the given execution context. + function _canSetOwner() internal view virtual override returns (bool) { + return msg.sender == owner(); + } +} diff --git a/contracts/eip/ERC1155.sol b/contracts/eip/ERC1155.sol new file mode 100644 index 000000000..5a0d5647e --- /dev/null +++ b/contracts/eip/ERC1155.sol @@ -0,0 +1,294 @@ +// SPDX-License-Identifier: Apache 2.0 +pragma solidity ^0.8.0; + +import "./interface/IERC1155.sol"; +import "./interface/IERC1155Metadata.sol"; +import "./interface/IERC1155Receiver.sol"; + +contract ERC1155 is IERC1155, IERC1155Metadata { + /*////////////////////////////////////////////////////////////// + State variables + //////////////////////////////////////////////////////////////*/ + + string public name; + string public symbol; + + /*////////////////////////////////////////////////////////////// + Mappings + //////////////////////////////////////////////////////////////*/ + + mapping(address => mapping(uint256 => uint256)) public balanceOf; + + mapping(address => mapping(address => bool)) public isApprovedForAll; + + mapping(uint256 => string) internal _uri; + + /*////////////////////////////////////////////////////////////// + Constructor + //////////////////////////////////////////////////////////////*/ + + constructor(string memory _name, string memory _symbol) { + name = _name; + symbol = _symbol; + } + + /*////////////////////////////////////////////////////////////// + View functions + //////////////////////////////////////////////////////////////*/ + + function supportsInterface(bytes4 interfaceId) public view virtual returns (bool) { + return + interfaceId == 0x01ffc9a7 || // ERC165 Interface ID for ERC165 + interfaceId == 0xd9b67a26 || // ERC165 Interface ID for ERC1155 + interfaceId == 0x0e89341c; // ERC165 Interface ID for ERC1155MetadataURI + } + + function uri(uint256 tokenId) public view virtual override returns (string memory) { + return _uri[tokenId]; + } + + function balanceOfBatch( + address[] memory accounts, + uint256[] memory ids + ) public view virtual override returns (uint256[] memory) { + require(accounts.length == ids.length, "LENGTH_MISMATCH"); + + uint256[] memory batchBalances = new uint256[](accounts.length); + + for (uint256 i = 0; i < accounts.length; ++i) { + batchBalances[i] = balanceOf[accounts[i]][ids[i]]; + } + + return batchBalances; + } + + /*////////////////////////////////////////////////////////////// + ERC1155 logic + //////////////////////////////////////////////////////////////*/ + + function setApprovalForAll(address operator, bool approved) public virtual override { + address owner = msg.sender; + require(owner != operator, "APPROVING_SELF"); + isApprovedForAll[owner][operator] = approved; + emit ApprovalForAll(owner, operator, approved); + } + + function safeTransferFrom( + address from, + address to, + uint256 id, + uint256 amount, + bytes memory data + ) public virtual override { + require(from == msg.sender || isApprovedForAll[from][msg.sender], "!OWNER_OR_APPROVED"); + _safeTransferFrom(from, to, id, amount, data); + } + + function safeBatchTransferFrom( + address from, + address to, + uint256[] memory ids, + uint256[] memory amounts, + bytes memory data + ) public virtual override { + require(from == msg.sender || isApprovedForAll[from][msg.sender], "!OWNER_OR_APPROVED"); + _safeBatchTransferFrom(from, to, ids, amounts, data); + } + + /*////////////////////////////////////////////////////////////// + Internal logic + //////////////////////////////////////////////////////////////*/ + + function _safeTransferFrom( + address from, + address to, + uint256 id, + uint256 amount, + bytes memory data + ) internal virtual { + require(to != address(0), "TO_ZERO_ADDR"); + + address operator = msg.sender; + + _beforeTokenTransfer(operator, from, to, _asSingletonArray(id), _asSingletonArray(amount), data); + + uint256 fromBalance = balanceOf[from][id]; + require(fromBalance >= amount, "INSUFFICIENT_BAL"); + unchecked { + balanceOf[from][id] = fromBalance - amount; + } + balanceOf[to][id] += amount; + + emit TransferSingle(operator, from, to, id, amount); + + _doSafeTransferAcceptanceCheck(operator, from, to, id, amount, data); + } + + function _safeBatchTransferFrom( + address from, + address to, + uint256[] memory ids, + uint256[] memory amounts, + bytes memory data + ) internal virtual { + require(ids.length == amounts.length, "LENGTH_MISMATCH"); + require(to != address(0), "TO_ZERO_ADDR"); + + address operator = msg.sender; + + _beforeTokenTransfer(operator, from, to, ids, amounts, data); + + for (uint256 i = 0; i < ids.length; ++i) { + uint256 id = ids[i]; + uint256 amount = amounts[i]; + + uint256 fromBalance = balanceOf[from][id]; + require(fromBalance >= amount, "INSUFFICIENT_BAL"); + unchecked { + balanceOf[from][id] = fromBalance - amount; + } + balanceOf[to][id] += amount; + } + + emit TransferBatch(operator, from, to, ids, amounts); + + _doSafeBatchTransferAcceptanceCheck(operator, from, to, ids, amounts, data); + } + + function _setTokenURI(uint256 tokenId, string memory newuri) internal virtual { + _uri[tokenId] = newuri; + } + + function _mint(address to, uint256 id, uint256 amount, bytes memory data) internal virtual { + require(to != address(0), "TO_ZERO_ADDR"); + + address operator = msg.sender; + + _beforeTokenTransfer(operator, address(0), to, _asSingletonArray(id), _asSingletonArray(amount), data); + + balanceOf[to][id] += amount; + emit TransferSingle(operator, address(0), to, id, amount); + + _doSafeTransferAcceptanceCheck(operator, address(0), to, id, amount, data); + } + + function _mintBatch( + address to, + uint256[] memory ids, + uint256[] memory amounts, + bytes memory data + ) internal virtual { + require(to != address(0), "TO_ZERO_ADDR"); + require(ids.length == amounts.length, "LENGTH_MISMATCH"); + + address operator = msg.sender; + + _beforeTokenTransfer(operator, address(0), to, ids, amounts, data); + + for (uint256 i = 0; i < ids.length; i++) { + balanceOf[to][ids[i]] += amounts[i]; + } + + emit TransferBatch(operator, address(0), to, ids, amounts); + + _doSafeBatchTransferAcceptanceCheck(operator, address(0), to, ids, amounts, data); + } + + function _burn(address from, uint256 id, uint256 amount) internal virtual { + require(from != address(0), "FROM_ZERO_ADDR"); + + address operator = msg.sender; + + _beforeTokenTransfer(operator, from, address(0), _asSingletonArray(id), _asSingletonArray(amount), ""); + + uint256 fromBalance = balanceOf[from][id]; + require(fromBalance >= amount, "INSUFFICIENT_BAL"); + unchecked { + balanceOf[from][id] = fromBalance - amount; + } + + emit TransferSingle(operator, from, address(0), id, amount); + } + + function _burnBatch(address from, uint256[] memory ids, uint256[] memory amounts) internal virtual { + require(from != address(0), "FROM_ZERO_ADDR"); + require(ids.length == amounts.length, "LENGTH_MISMATCH"); + + address operator = msg.sender; + + _beforeTokenTransfer(operator, from, address(0), ids, amounts, ""); + + for (uint256 i = 0; i < ids.length; i++) { + uint256 id = ids[i]; + uint256 amount = amounts[i]; + + uint256 fromBalance = balanceOf[from][id]; + require(fromBalance >= amount, "INSUFFICIENT_BAL"); + unchecked { + balanceOf[from][id] = fromBalance - amount; + } + } + + emit TransferBatch(operator, from, address(0), ids, amounts); + } + + function _beforeTokenTransfer( + address operator, + address from, + address to, + uint256[] memory ids, + uint256[] memory amounts, + bytes memory data + ) internal virtual {} + + function _doSafeTransferAcceptanceCheck( + address operator, + address from, + address to, + uint256 id, + uint256 amount, + bytes memory data + ) private { + if (to.code.length > 0) { + try IERC1155Receiver(to).onERC1155Received(operator, from, id, amount, data) returns (bytes4 response) { + if (response != IERC1155Receiver.onERC1155Received.selector) { + revert("TOKENS_REJECTED"); + } + } catch Error(string memory reason) { + revert(reason); + } catch { + revert("!ERC1155RECEIVER"); + } + } + } + + function _doSafeBatchTransferAcceptanceCheck( + address operator, + address from, + address to, + uint256[] memory ids, + uint256[] memory amounts, + bytes memory data + ) private { + if (to.code.length > 0) { + try IERC1155Receiver(to).onERC1155BatchReceived(operator, from, ids, amounts, data) returns ( + bytes4 response + ) { + if (response != IERC1155Receiver.onERC1155BatchReceived.selector) { + revert("TOKENS_REJECTED"); + } + } catch Error(string memory reason) { + revert(reason); + } catch { + revert("!ERC1155RECEIVER"); + } + } + } + + function _asSingletonArray(uint256 element) private pure returns (uint256[] memory) { + uint256[] memory array = new uint256[](1); + array[0] = element; + + return array; + } +} diff --git a/contracts/eip/ERC1271.sol b/contracts/eip/ERC1271.sol new file mode 100644 index 000000000..a3170cd45 --- /dev/null +++ b/contracts/eip/ERC1271.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: Apache 2.0 +pragma solidity ^0.8.0; + +abstract contract ERC1271 { + // bytes4(keccak256("isValidSignature(bytes32,bytes)") + bytes4 internal constant MAGICVALUE = 0x1626ba7e; + + /** + * @dev Should return whether the signature provided is valid for the provided hash + * @param _hash Hash of the data to be signed + * @param _signature Signature byte array associated with _hash + * + * MUST return the bytes4 magic value 0x1626ba7e when function passes. + * MUST NOT modify state (using STATICCALL for solc < 0.5, view modifier for solc > 0.5) + * MUST allow external calls + */ + function isValidSignature(bytes32 _hash, bytes memory _signature) public view virtual returns (bytes4 magicValue); +} diff --git a/contracts/eip/ERC165.sol b/contracts/eip/ERC165.sol new file mode 100644 index 000000000..46969ae0d --- /dev/null +++ b/contracts/eip/ERC165.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts v4.4.1 (utils/introspection/ERC165.sol) + +pragma solidity ^0.8.0; + +import "./interface/IERC165.sol"; + +/** + * @dev Implementation of the {IERC165} interface. + * + * Contracts that want to implement ERC165 should inherit from this contract and override {supportsInterface} to check + * for the additional interface id that will be supported. For example: + * + * ```solidity + * function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) { + * return interfaceId == type(MyInterface).interfaceId || super.supportsInterface(interfaceId); + * } + * ``` + * + * Alternatively, {ERC165Storage} provides an easier to use but more expensive implementation. + */ +abstract contract ERC165 is IERC165 { + /** + * @dev See {IERC165-supportsInterface}. + */ + function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) { + return interfaceId == type(IERC165).interfaceId; + } +} diff --git a/contracts/eip/ERC721A.sol b/contracts/eip/ERC721A.sol new file mode 100644 index 000000000..9ae9c99f4 --- /dev/null +++ b/contracts/eip/ERC721A.sol @@ -0,0 +1,580 @@ +// SPDX-License-Identifier: MIT +// ERC721A Contracts v3.3.0 +// Creator: Chiru Labs + +pragma solidity ^0.8.4; + +import "./interface/IERC721A.sol"; +import "../external-deps/openzeppelin/token/ERC721/IERC721Receiver.sol"; +import "../lib/Address.sol"; +import "../external-deps/openzeppelin/utils/Context.sol"; +import "../lib/Strings.sol"; +import "./ERC165.sol"; + +/** + * @dev Implementation of [ERC721](https://eips.ethereum.org/EIPS/eip-721) Non-Fungible Token Standard, including + * the Metadata extension. Built to optimize for lower gas during batch mints. + * + * Assumes serials are sequentially minted starting at _startTokenId() (defaults to 0, e.g. 0, 1, 2, 3..). + * + * Assumes that an owner cannot have more than 2^64 - 1 (max value of uint64) of supply. + * + * Assumes that the maximum token id cannot exceed 2^256 - 1 (max value of uint256). + */ +contract ERC721A is Context, ERC165, IERC721A { + using Address for address; + using Strings for uint256; + + // The tokenId of the next token to be minted. + uint256 internal _currentIndex; + + // The number of tokens burned. + uint256 internal _burnCounter; + + // Token name + string private _name; + + // Token symbol + string private _symbol; + + // Mapping from token ID to ownership details + // An empty struct value does not necessarily mean the token is unowned. See _ownershipOf implementation for details. + mapping(uint256 => TokenOwnership) internal _ownerships; + + // Mapping owner address to address data + mapping(address => AddressData) private _addressData; + + // Mapping from token ID to approved address + mapping(uint256 => address) private _tokenApprovals; + + // Mapping from owner to operator approvals + mapping(address => mapping(address => bool)) private _operatorApprovals; + + constructor(string memory name_, string memory symbol_) { + _name = name_; + _symbol = symbol_; + _currentIndex = _startTokenId(); + } + + /** + * To change the starting tokenId, please override this function. + */ + function _startTokenId() internal view virtual returns (uint256) { + return 0; + } + + /** + * @dev Burned tokens are calculated here, use _totalMinted() if you want to count just minted tokens. + */ + function totalSupply() public view override returns (uint256) { + // Counter underflow is impossible as _burnCounter cannot be incremented + // more than _currentIndex - _startTokenId() times + unchecked { + return _currentIndex - _burnCounter - _startTokenId(); + } + } + + /** + * Returns the total amount of tokens minted in the contract. + */ + function _totalMinted() internal view returns (uint256) { + // Counter underflow is impossible as _currentIndex does not decrement, + // and it is initialized to _startTokenId() + unchecked { + return _currentIndex - _startTokenId(); + } + } + + /** + * @dev See {IERC165-supportsInterface}. + */ + function supportsInterface(bytes4 interfaceId) public view virtual override(ERC165) returns (bool) { + return + interfaceId == type(IERC721).interfaceId || + interfaceId == type(IERC721Metadata).interfaceId || + super.supportsInterface(interfaceId); + } + + /** + * @dev See {IERC721-balanceOf}. + */ + function balanceOf(address owner) public view override returns (uint256) { + if (owner == address(0)) revert BalanceQueryForZeroAddress(); + return uint256(_addressData[owner].balance); + } + + /** + * Returns the number of tokens minted by `owner`. + */ + function _numberMinted(address owner) internal view returns (uint256) { + return uint256(_addressData[owner].numberMinted); + } + + /** + * Returns the number of tokens burned by or on behalf of `owner`. + */ + function _numberBurned(address owner) internal view returns (uint256) { + return uint256(_addressData[owner].numberBurned); + } + + /** + * Returns the auxillary data for `owner`. (e.g. number of whitelist mint slots used). + */ + function _getAux(address owner) internal view returns (uint64) { + return _addressData[owner].aux; + } + + /** + * Sets the auxillary data for `owner`. (e.g. number of whitelist mint slots used). + * If there are multiple variables, please pack them into a uint64. + */ + function _setAux(address owner, uint64 aux) internal { + _addressData[owner].aux = aux; + } + + /** + * Gas spent here starts off proportional to the maximum mint batch size. + * It gradually moves to O(1) as tokens get transferred around in the collection over time. + */ + function _ownershipOf(uint256 tokenId) internal view returns (TokenOwnership memory) { + uint256 curr = tokenId; + + unchecked { + if (_startTokenId() <= curr) + if (curr < _currentIndex) { + TokenOwnership memory ownership = _ownerships[curr]; + if (!ownership.burned) { + if (ownership.addr != address(0)) { + return ownership; + } + // Invariant: + // There will always be an ownership that has an address and is not burned + // before an ownership that does not have an address and is not burned. + // Hence, curr will not underflow. + while (true) { + curr--; + ownership = _ownerships[curr]; + if (ownership.addr != address(0)) { + return ownership; + } + } + } + } + } + revert OwnerQueryForNonexistentToken(); + } + + /** + * @dev See {IERC721-ownerOf}. + */ + function ownerOf(uint256 tokenId) public view override returns (address) { + return _ownershipOf(tokenId).addr; + } + + /** + * @dev See {IERC721Metadata-name}. + */ + function name() public view virtual override returns (string memory) { + return _name; + } + + /** + * @dev See {IERC721Metadata-symbol}. + */ + function symbol() public view virtual override returns (string memory) { + return _symbol; + } + + /** + * @dev See {IERC721Metadata-tokenURI}. + */ + function tokenURI(uint256 tokenId) public view virtual override returns (string memory) { + if (!_exists(tokenId)) revert URIQueryForNonexistentToken(); + + string memory baseURI = _baseURI(); + return bytes(baseURI).length != 0 ? string(abi.encodePacked(baseURI, tokenId.toString())) : ""; + } + + /** + * @dev Base URI for computing {tokenURI}. If set, the resulting URI for each + * token will be the concatenation of the `baseURI` and the `tokenId`. Empty + * by default, can be overriden in child contracts. + */ + function _baseURI() internal view virtual returns (string memory) { + return ""; + } + + /** + * @dev See {IERC721-approve}. + */ + function approve(address to, uint256 tokenId) public override { + address owner = ERC721A.ownerOf(tokenId); + if (to == owner) revert ApprovalToCurrentOwner(); + + if (_msgSender() != owner) + if (!isApprovedForAll(owner, _msgSender())) { + revert ApprovalCallerNotOwnerNorApproved(); + } + + _approve(to, tokenId, owner); + } + + /** + * @dev See {IERC721-getApproved}. + */ + function getApproved(uint256 tokenId) public view override returns (address) { + if (!_exists(tokenId)) revert ApprovalQueryForNonexistentToken(); + + return _tokenApprovals[tokenId]; + } + + /** + * @dev See {IERC721-setApprovalForAll}. + */ + function setApprovalForAll(address operator, bool approved) public virtual override { + if (operator == _msgSender()) revert ApproveToCaller(); + + _operatorApprovals[_msgSender()][operator] = approved; + emit ApprovalForAll(_msgSender(), operator, approved); + } + + /** + * @dev See {IERC721-isApprovedForAll}. + */ + function isApprovedForAll(address owner, address operator) public view virtual override returns (bool) { + return _operatorApprovals[owner][operator]; + } + + /** + * @dev See {IERC721-transferFrom}. + */ + function transferFrom(address from, address to, uint256 tokenId) public virtual override { + _transfer(from, to, tokenId); + } + + /** + * @dev See {IERC721-safeTransferFrom}. + */ + function safeTransferFrom(address from, address to, uint256 tokenId) public virtual override { + safeTransferFrom(from, to, tokenId, ""); + } + + /** + * @dev See {IERC721-safeTransferFrom}. + */ + function safeTransferFrom(address from, address to, uint256 tokenId, bytes memory _data) public virtual override { + _transfer(from, to, tokenId); + if (to.isContract()) + if (!_checkContractOnERC721Received(from, to, tokenId, _data)) { + revert TransferToNonERC721ReceiverImplementer(); + } + } + + /** + * @dev Returns whether `tokenId` exists. + * + * Tokens can be managed by their owner or approved accounts via {approve} or {setApprovalForAll}. + * + * Tokens start existing when they are minted (`_mint`), + */ + function _exists(uint256 tokenId) internal view returns (bool) { + return _startTokenId() <= tokenId && tokenId < _currentIndex && !_ownerships[tokenId].burned; + } + + /** + * @dev Equivalent to `_safeMint(to, quantity, '')`. + */ + function _safeMint(address to, uint256 quantity) internal { + _safeMint(to, quantity, ""); + } + + /** + * @dev Safely mints `quantity` tokens and transfers them to `to`. + * + * Requirements: + * + * - If `to` refers to a smart contract, it must implement + * {IERC721Receiver-onERC721Received}, which is called for each safe transfer. + * - `quantity` must be greater than 0. + * + * Emits a {Transfer} event. + */ + function _safeMint(address to, uint256 quantity, bytes memory _data) internal { + uint256 startTokenId = _currentIndex; + if (to == address(0)) revert MintToZeroAddress(); + if (quantity == 0) revert MintZeroQuantity(); + + _beforeTokenTransfers(address(0), to, startTokenId, quantity); + + // Overflows are incredibly unrealistic. + // balance or numberMinted overflow if current value of either + quantity > 1.8e19 (2**64) - 1 + // updatedIndex overflows if _currentIndex + quantity > 1.2e77 (2**256) - 1 + unchecked { + _addressData[to].balance += uint64(quantity); + _addressData[to].numberMinted += uint64(quantity); + + _ownerships[startTokenId].addr = to; + _ownerships[startTokenId].startTimestamp = uint64(block.timestamp); + + uint256 updatedIndex = startTokenId; + uint256 end = updatedIndex + quantity; + + if (to.isContract()) { + do { + emit Transfer(address(0), to, updatedIndex); + if (!_checkContractOnERC721Received(address(0), to, updatedIndex++, _data)) { + revert TransferToNonERC721ReceiverImplementer(); + } + } while (updatedIndex < end); + // Reentrancy protection + if (_currentIndex != startTokenId) revert(); + } else { + do { + emit Transfer(address(0), to, updatedIndex++); + } while (updatedIndex < end); + } + _currentIndex = updatedIndex; + } + _afterTokenTransfers(address(0), to, startTokenId, quantity); + } + + /** + * @dev Mints `quantity` tokens and transfers them to `to`. + * + * Requirements: + * + * - `to` cannot be the zero address. + * - `quantity` must be greater than 0. + * + * Emits a {Transfer} event. + */ + function _mint(address to, uint256 quantity) internal { + uint256 startTokenId = _currentIndex; + if (to == address(0)) revert MintToZeroAddress(); + if (quantity == 0) revert MintZeroQuantity(); + + _beforeTokenTransfers(address(0), to, startTokenId, quantity); + + // Overflows are incredibly unrealistic. + // balance or numberMinted overflow if current value of either + quantity > 1.8e19 (2**64) - 1 + // updatedIndex overflows if _currentIndex + quantity > 1.2e77 (2**256) - 1 + unchecked { + _addressData[to].balance += uint64(quantity); + _addressData[to].numberMinted += uint64(quantity); + + _ownerships[startTokenId].addr = to; + _ownerships[startTokenId].startTimestamp = uint64(block.timestamp); + + uint256 updatedIndex = startTokenId; + uint256 end = updatedIndex + quantity; + + do { + emit Transfer(address(0), to, updatedIndex++); + } while (updatedIndex < end); + + _currentIndex = updatedIndex; + } + _afterTokenTransfers(address(0), to, startTokenId, quantity); + } + + /** + * @dev Transfers `tokenId` from `from` to `to`. + * + * Requirements: + * + * - `to` cannot be the zero address. + * - `tokenId` token must be owned by `from`. + * + * Emits a {Transfer} event. + */ + function _transfer(address from, address to, uint256 tokenId) private { + TokenOwnership memory prevOwnership = _ownershipOf(tokenId); + + if (prevOwnership.addr != from) revert TransferFromIncorrectOwner(); + + bool isApprovedOrOwner = (_msgSender() == from || + isApprovedForAll(from, _msgSender()) || + getApproved(tokenId) == _msgSender()); + + if (!isApprovedOrOwner) revert TransferCallerNotOwnerNorApproved(); + if (to == address(0)) revert TransferToZeroAddress(); + + _beforeTokenTransfers(from, to, tokenId, 1); + + // Clear approvals from the previous owner + _approve(address(0), tokenId, from); + + // Underflow of the sender's balance is impossible because we check for + // ownership above and the recipient's balance can't realistically overflow. + // Counter overflow is incredibly unrealistic as tokenId would have to be 2**256. + unchecked { + _addressData[from].balance -= 1; + _addressData[to].balance += 1; + + TokenOwnership storage currSlot = _ownerships[tokenId]; + currSlot.addr = to; + currSlot.startTimestamp = uint64(block.timestamp); + + // If the ownership slot of tokenId+1 is not explicitly set, that means the transfer initiator owns it. + // Set the slot of tokenId+1 explicitly in storage to maintain correctness for ownerOf(tokenId+1) calls. + uint256 nextTokenId = tokenId + 1; + TokenOwnership storage nextSlot = _ownerships[nextTokenId]; + if (nextSlot.addr == address(0)) { + // This will suffice for checking _exists(nextTokenId), + // as a burned slot cannot contain the zero address. + if (nextTokenId != _currentIndex) { + nextSlot.addr = from; + nextSlot.startTimestamp = prevOwnership.startTimestamp; + } + } + } + + emit Transfer(from, to, tokenId); + _afterTokenTransfers(from, to, tokenId, 1); + } + + /** + * @dev Equivalent to `_burn(tokenId, false)`. + */ + function _burn(uint256 tokenId) internal virtual { + _burn(tokenId, false); + } + + /** + * @dev Destroys `tokenId`. + * The approval is cleared when the token is burned. + * + * Requirements: + * + * - `tokenId` must exist. + * + * Emits a {Transfer} event. + */ + function _burn(uint256 tokenId, bool approvalCheck) internal virtual { + TokenOwnership memory prevOwnership = _ownershipOf(tokenId); + + address from = prevOwnership.addr; + + if (approvalCheck) { + bool isApprovedOrOwner = (_msgSender() == from || + isApprovedForAll(from, _msgSender()) || + getApproved(tokenId) == _msgSender()); + + if (!isApprovedOrOwner) revert TransferCallerNotOwnerNorApproved(); + } + + _beforeTokenTransfers(from, address(0), tokenId, 1); + + // Clear approvals from the previous owner + _approve(address(0), tokenId, from); + + // Underflow of the sender's balance is impossible because we check for + // ownership above and the recipient's balance can't realistically overflow. + // Counter overflow is incredibly unrealistic as tokenId would have to be 2**256. + unchecked { + AddressData storage addressData = _addressData[from]; + addressData.balance -= 1; + addressData.numberBurned += 1; + + // Keep track of who burned the token, and the timestamp of burning. + TokenOwnership storage currSlot = _ownerships[tokenId]; + currSlot.addr = from; + currSlot.startTimestamp = uint64(block.timestamp); + currSlot.burned = true; + + // If the ownership slot of tokenId+1 is not explicitly set, that means the burn initiator owns it. + // Set the slot of tokenId+1 explicitly in storage to maintain correctness for ownerOf(tokenId+1) calls. + uint256 nextTokenId = tokenId + 1; + TokenOwnership storage nextSlot = _ownerships[nextTokenId]; + if (nextSlot.addr == address(0)) { + // This will suffice for checking _exists(nextTokenId), + // as a burned slot cannot contain the zero address. + if (nextTokenId != _currentIndex) { + nextSlot.addr = from; + nextSlot.startTimestamp = prevOwnership.startTimestamp; + } + } + } + + emit Transfer(from, address(0), tokenId); + _afterTokenTransfers(from, address(0), tokenId, 1); + + // Overflow not possible, as _burnCounter cannot be exceed _currentIndex times. + unchecked { + _burnCounter++; + } + } + + /** + * @dev Approve `to` to operate on `tokenId` + * + * Emits a {Approval} event. + */ + function _approve(address to, uint256 tokenId, address owner) private { + _tokenApprovals[tokenId] = to; + emit Approval(owner, to, tokenId); + } + + /** + * @dev Internal function to invoke {IERC721Receiver-onERC721Received} on a target contract. + * + * @param from address representing the previous owner of the given token ID + * @param to target address that will receive the tokens + * @param tokenId uint256 ID of the token to be transferred + * @param _data bytes optional data to send along with the call + * @return bool whether the call correctly returned the expected magic value + */ + function _checkContractOnERC721Received( + address from, + address to, + uint256 tokenId, + bytes memory _data + ) private returns (bool) { + try IERC721Receiver(to).onERC721Received(_msgSender(), from, tokenId, _data) returns (bytes4 retval) { + return retval == IERC721Receiver(to).onERC721Received.selector; + } catch (bytes memory reason) { + if (reason.length == 0) { + revert TransferToNonERC721ReceiverImplementer(); + } else { + assembly { + revert(add(32, reason), mload(reason)) + } + } + } + } + + /** + * @dev Hook that is called before a set of serially-ordered token ids are about to be transferred. This includes minting. + * And also called before burning one token. + * + * startTokenId - the first token id to be transferred + * quantity - the amount to be transferred + * + * Calling conditions: + * + * - When `from` and `to` are both non-zero, `from`'s `tokenId` will be + * transferred to `to`. + * - When `from` is zero, `tokenId` will be minted for `to`. + * - When `to` is zero, `tokenId` will be burned by `from`. + * - `from` and `to` are never both zero. + */ + function _beforeTokenTransfers(address from, address to, uint256 startTokenId, uint256 quantity) internal virtual {} + + /** + * @dev Hook that is called after a set of serially-ordered token ids have been transferred. This includes + * minting. + * And also called after one token has been burned. + * + * startTokenId - the first token id to be transferred + * quantity - the amount to be transferred + * + * Calling conditions: + * + * - When `from` and `to` are both non-zero, `from`'s `tokenId` has been + * transferred to `to`. + * - When `from` is zero, `tokenId` has been minted for `to`. + * - When `to` is zero, `tokenId` has been burned by `from`. + * - `from` and `to` are never both zero. + */ + function _afterTokenTransfers(address from, address to, uint256 startTokenId, uint256 quantity) internal virtual {} +} diff --git a/contracts/eip/ERC721AUpgradeable.sol b/contracts/eip/ERC721AUpgradeable.sol new file mode 100644 index 000000000..50fdef270 --- /dev/null +++ b/contracts/eip/ERC721AUpgradeable.sol @@ -0,0 +1,625 @@ +// SPDX-License-Identifier: MIT +// ERC721A Contracts v3.3.0 +// Creator: Chiru Labs + +pragma solidity ^0.8.4; + +////////// CHANGELOG: turn `approve` to virtual ////////// + +import "./interface/IERC721A.sol"; +import "./interface/IERC721Receiver.sol"; +import "../lib/Address.sol"; +import "../external-deps/openzeppelin/utils/Context.sol"; +import "../lib/Strings.sol"; +import "./ERC165.sol"; +import "../extension/upgradeable/Initializable.sol"; + +library ERC721AStorage { + /// @custom:storage-location erc7201:erc721.a.storage + /// @dev keccak256(abi.encode(uint256(keccak256("erc721.a.storage")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 public constant ERC721A_STORAGE_POSITION = + 0xe2efff925b8936e8a3471e86ad87942375e24de600ddfb2b841647ce1379ed00; + + struct Data { + // The tokenId of the next token to be minted. + uint256 _currentIndex; + // The number of tokens burned. + uint256 _burnCounter; + // Token name + string _name; + // Token symbol + string _symbol; + // Mapping from token ID to ownership details + // An empty struct value does not necessarily mean the token is unowned. See _ownershipOf implementation for details. + mapping(uint256 => IERC721A.TokenOwnership) _ownerships; + // Mapping owner address to address data + mapping(address => IERC721A.AddressData) _addressData; + // Mapping from token ID to approved address + mapping(uint256 => address) _tokenApprovals; + // Mapping from owner to operator approvals + mapping(address => mapping(address => bool)) _operatorApprovals; + } + + function erc721AStorage() internal pure returns (Data storage erc721AData) { + bytes32 position = ERC721A_STORAGE_POSITION; + assembly { + erc721AData.slot := position + } + } +} + +/** + * @dev Implementation of https://eips.ethereum.org/EIPS/eip-721[ERC721] Non-Fungible Token Standard, including + * the Metadata extension. Built to optimize for lower gas during batch mints. + * + * Assumes serials are sequentially minted starting at _startTokenId() (defaults to 0, e.g. 0, 1, 2, 3..). + * + * Assumes that an owner cannot have more than 2**64 - 1 (max value of uint64) of supply. + * + * Assumes that the maximum token id cannot exceed 2**256 - 1 (max value of uint256). + */ +contract ERC721AUpgradeable is Initializable, Context, ERC165, IERC721A { + using Address for address; + using Strings for uint256; + + function __ERC721A_init(string memory name_, string memory symbol_) internal onlyInitializing { + __ERC721A_init_unchained(name_, symbol_); + } + + function __ERC721A_init_unchained(string memory name_, string memory symbol_) internal onlyInitializing { + ERC721AStorage.Data storage data = ERC721AStorage.erc721AStorage(); + + data._name = name_; + data._symbol = symbol_; + data._currentIndex = _startTokenId(); + } + + /** + * To change the starting tokenId, please override this function. + */ + function _startTokenId() internal view virtual returns (uint256) { + return 0; + } + + /** + * @dev Burned tokens are calculated here, use _totalMinted() if you want to count just minted tokens. + */ + function totalSupply() public view override returns (uint256) { + ERC721AStorage.Data storage data = ERC721AStorage.erc721AStorage(); + + // Counter underflow is impossible as _burnCounter cannot be incremented + // more than _currentIndex - _startTokenId() times + unchecked { + return data._currentIndex - data._burnCounter - _startTokenId(); + } + } + + /** + * Returns the total amount of tokens minted in the contract. + */ + function _totalMinted() internal view returns (uint256) { + ERC721AStorage.Data storage data = ERC721AStorage.erc721AStorage(); + // Counter underflow is impossible as _currentIndex does not decrement, + // and it is initialized to _startTokenId() + unchecked { + return data._currentIndex - _startTokenId(); + } + } + + /** + * @dev See {IERC165-supportsInterface}. + */ + function supportsInterface(bytes4 interfaceId) public view virtual override(ERC165) returns (bool) { + return + interfaceId == type(IERC721).interfaceId || + interfaceId == type(IERC721Metadata).interfaceId || + super.supportsInterface(interfaceId); + } + + /** + * @dev See {IERC721-balanceOf}. + */ + function balanceOf(address owner) public view override returns (uint256) { + ERC721AStorage.Data storage data = ERC721AStorage.erc721AStorage(); + if (owner == address(0)) revert BalanceQueryForZeroAddress(); + return uint256(data._addressData[owner].balance); + } + + /** + * Returns the number of tokens minted by `owner`. + */ + function _numberMinted(address owner) internal view returns (uint256) { + ERC721AStorage.Data storage data = ERC721AStorage.erc721AStorage(); + return uint256(data._addressData[owner].numberMinted); + } + + /** + * Returns the number of tokens burned by or on behalf of `owner`. + */ + function _numberBurned(address owner) internal view returns (uint256) { + ERC721AStorage.Data storage data = ERC721AStorage.erc721AStorage(); + return uint256(data._addressData[owner].numberBurned); + } + + /** + * Returns the auxillary data for `owner`. (e.g. number of whitelist mint slots used). + */ + function _getAux(address owner) internal view returns (uint64) { + ERC721AStorage.Data storage data = ERC721AStorage.erc721AStorage(); + return data._addressData[owner].aux; + } + + /** + * Sets the auxillary data for `owner`. (e.g. number of whitelist mint slots used). + * If there are multiple variables, please pack them into a uint64. + */ + function _setAux(address owner, uint64 aux) internal { + ERC721AStorage.Data storage data = ERC721AStorage.erc721AStorage(); + data._addressData[owner].aux = aux; + } + + /** + * Gas spent here starts off proportional to the maximum mint batch size. + * It gradually moves to O(1) as tokens get transferred around in the collection over time. + */ + function _ownershipOf(uint256 tokenId) internal view returns (TokenOwnership memory) { + ERC721AStorage.Data storage data = ERC721AStorage.erc721AStorage(); + + uint256 curr = tokenId; + + unchecked { + if (_startTokenId() <= curr) + if (curr < data._currentIndex) { + TokenOwnership memory ownership = data._ownerships[curr]; + if (!ownership.burned) { + if (ownership.addr != address(0)) { + return ownership; + } + // Invariant: + // There will always be an ownership that has an address and is not burned + // before an ownership that does not have an address and is not burned. + // Hence, curr will not underflow. + while (true) { + curr--; + ownership = data._ownerships[curr]; + if (ownership.addr != address(0)) { + return ownership; + } + } + } + } + } + revert OwnerQueryForNonexistentToken(); + } + + /** + * @dev See {IERC721-ownerOf}. + */ + function ownerOf(uint256 tokenId) public view override returns (address) { + return _ownershipOf(tokenId).addr; + } + + /** + * @dev See {IERC721Metadata-name}. + */ + function name() public view virtual override returns (string memory) { + ERC721AStorage.Data storage data = ERC721AStorage.erc721AStorage(); + return data._name; + } + + /** + * @dev See {IERC721Metadata-symbol}. + */ + function symbol() public view virtual override returns (string memory) { + ERC721AStorage.Data storage data = ERC721AStorage.erc721AStorage(); + return data._symbol; + } + + /** + * @dev See {IERC721Metadata-tokenURI}. + */ + function tokenURI(uint256 tokenId) public view virtual override returns (string memory) { + if (!_exists(tokenId)) revert URIQueryForNonexistentToken(); + + string memory baseURI = _baseURI(); + return bytes(baseURI).length != 0 ? string(abi.encodePacked(baseURI, tokenId.toString())) : ""; + } + + /** + * @dev Base URI for computing {tokenURI}. If set, the resulting URI for each + * token will be the concatenation of the `baseURI` and the `tokenId`. Empty + * by default, can be overriden in child contracts. + */ + function _baseURI() internal view virtual returns (string memory) { + return ""; + } + + /** + * @dev See {IERC721-approve}. + */ + function approve(address to, uint256 tokenId) public virtual override { + address owner = ERC721AUpgradeable.ownerOf(tokenId); + if (to == owner) revert ApprovalToCurrentOwner(); + + if (_msgSender() != owner) + if (!isApprovedForAll(owner, _msgSender())) { + revert ApprovalCallerNotOwnerNorApproved(); + } + + _approve(to, tokenId, owner); + } + + /** + * @dev See {IERC721-getApproved}. + */ + function getApproved(uint256 tokenId) public view virtual override returns (address) { + ERC721AStorage.Data storage data = ERC721AStorage.erc721AStorage(); + + if (!_exists(tokenId)) revert ApprovalQueryForNonexistentToken(); + + return data._tokenApprovals[tokenId]; + } + + /** + * @dev See {IERC721-setApprovalForAll}. + */ + function setApprovalForAll(address operator, bool approved) public virtual override { + ERC721AStorage.Data storage data = ERC721AStorage.erc721AStorage(); + + if (operator == _msgSender()) revert ApproveToCaller(); + + data._operatorApprovals[_msgSender()][operator] = approved; + emit ApprovalForAll(_msgSender(), operator, approved); + } + + /** + * @dev See {IERC721-isApprovedForAll}. + */ + function isApprovedForAll(address owner, address operator) public view virtual override returns (bool) { + ERC721AStorage.Data storage data = ERC721AStorage.erc721AStorage(); + return data._operatorApprovals[owner][operator]; + } + + /** + * @dev See {IERC721-transferFrom}. + */ + function transferFrom(address from, address to, uint256 tokenId) public virtual override { + _transfer(from, to, tokenId); + } + + /** + * @dev See {IERC721-safeTransferFrom}. + */ + function safeTransferFrom(address from, address to, uint256 tokenId) public virtual override { + safeTransferFrom(from, to, tokenId, ""); + } + + /** + * @dev See {IERC721-safeTransferFrom}. + */ + function safeTransferFrom(address from, address to, uint256 tokenId, bytes memory _data) public virtual override { + _transfer(from, to, tokenId); + if (to.isContract()) + if (!_checkContractOnERC721Received(from, to, tokenId, _data)) { + revert TransferToNonERC721ReceiverImplementer(); + } + } + + /** + * @dev Returns whether `tokenId` exists. + * + * Tokens can be managed by their owner or approved accounts via {approve} or {setApprovalForAll}. + * + * Tokens start existing when they are minted (`_mint`), + */ + function _exists(uint256 tokenId) internal view returns (bool) { + ERC721AStorage.Data storage data = ERC721AStorage.erc721AStorage(); + return _startTokenId() <= tokenId && tokenId < data._currentIndex && !data._ownerships[tokenId].burned; + } + + /** + * @dev Equivalent to `_safeMint(to, quantity, '')`. + */ + function _safeMint(address to, uint256 quantity) internal { + _safeMint(to, quantity, ""); + } + + /** + * @dev Safely mints `quantity` tokens and transfers them to `to`. + * + * Requirements: + * + * - If `to` refers to a smart contract, it must implement + * {IERC721Receiver-onERC721Received}, which is called for each safe transfer. + * - `quantity` must be greater than 0. + * + * Emits a {Transfer} event. + */ + function _safeMint(address to, uint256 quantity, bytes memory _data) internal { + ERC721AStorage.Data storage data = ERC721AStorage.erc721AStorage(); + + uint256 startTokenId = data._currentIndex; + if (to == address(0)) revert MintToZeroAddress(); + if (quantity == 0) revert MintZeroQuantity(); + + _beforeTokenTransfers(address(0), to, startTokenId, quantity); + + // Overflows are incredibly unrealistic. + // balance or numberMinted overflow if current value of either + quantity > 1.8e19 (2**64) - 1 + // updatedIndex overflows if _currentIndex + quantity > 1.2e77 (2**256) - 1 + unchecked { + data._addressData[to].balance += uint64(quantity); + data._addressData[to].numberMinted += uint64(quantity); + + data._ownerships[startTokenId].addr = to; + data._ownerships[startTokenId].startTimestamp = uint64(block.timestamp); + + uint256 updatedIndex = startTokenId; + uint256 end = updatedIndex + quantity; + + if (to.isContract()) { + do { + emit Transfer(address(0), to, updatedIndex); + if (!_checkContractOnERC721Received(address(0), to, updatedIndex++, _data)) { + revert TransferToNonERC721ReceiverImplementer(); + } + } while (updatedIndex < end); + // Reentrancy protection + if (data._currentIndex != startTokenId) revert(); + } else { + do { + emit Transfer(address(0), to, updatedIndex++); + } while (updatedIndex < end); + } + data._currentIndex = updatedIndex; + } + _afterTokenTransfers(address(0), to, startTokenId, quantity); + } + + /** + * @dev Mints `quantity` tokens and transfers them to `to`. + * + * Requirements: + * + * - `to` cannot be the zero address. + * - `quantity` must be greater than 0. + * + * Emits a {Transfer} event. + */ + function _mint(address to, uint256 quantity) internal { + ERC721AStorage.Data storage data = ERC721AStorage.erc721AStorage(); + + uint256 startTokenId = data._currentIndex; + if (to == address(0)) revert MintToZeroAddress(); + if (quantity == 0) revert MintZeroQuantity(); + + _beforeTokenTransfers(address(0), to, startTokenId, quantity); + + // Overflows are incredibly unrealistic. + // balance or numberMinted overflow if current value of either + quantity > 1.8e19 (2**64) - 1 + // updatedIndex overflows if _currentIndex + quantity > 1.2e77 (2**256) - 1 + unchecked { + data._addressData[to].balance += uint64(quantity); + data._addressData[to].numberMinted += uint64(quantity); + + data._ownerships[startTokenId].addr = to; + data._ownerships[startTokenId].startTimestamp = uint64(block.timestamp); + + uint256 updatedIndex = startTokenId; + uint256 end = updatedIndex + quantity; + + do { + emit Transfer(address(0), to, updatedIndex++); + } while (updatedIndex < end); + + data._currentIndex = updatedIndex; + } + _afterTokenTransfers(address(0), to, startTokenId, quantity); + } + + /** + * @dev Transfers `tokenId` from `from` to `to`. + * + * Requirements: + * + * - `to` cannot be the zero address. + * - `tokenId` token must be owned by `from`. + * + * Emits a {Transfer} event. + */ + function _transfer(address from, address to, uint256 tokenId) private { + ERC721AStorage.Data storage data = ERC721AStorage.erc721AStorage(); + + TokenOwnership memory prevOwnership = _ownershipOf(tokenId); + + if (prevOwnership.addr != from) revert TransferFromIncorrectOwner(); + + bool isApprovedOrOwner = (_msgSender() == from || + isApprovedForAll(from, _msgSender()) || + getApproved(tokenId) == _msgSender()); + + if (!isApprovedOrOwner) revert TransferCallerNotOwnerNorApproved(); + if (to == address(0)) revert TransferToZeroAddress(); + + _beforeTokenTransfers(from, to, tokenId, 1); + + // Clear approvals from the previous owner + _approve(address(0), tokenId, from); + + // Underflow of the sender's balance is impossible because we check for + // ownership above and the recipient's balance can't realistically overflow. + // Counter overflow is incredibly unrealistic as tokenId would have to be 2**256. + unchecked { + data._addressData[from].balance -= 1; + data._addressData[to].balance += 1; + + TokenOwnership storage currSlot = data._ownerships[tokenId]; + currSlot.addr = to; + currSlot.startTimestamp = uint64(block.timestamp); + + // If the ownership slot of tokenId+1 is not explicitly set, that means the transfer initiator owns it. + // Set the slot of tokenId+1 explicitly in storage to maintain correctness for ownerOf(tokenId+1) calls. + uint256 nextTokenId = tokenId + 1; + TokenOwnership storage nextSlot = data._ownerships[nextTokenId]; + if (nextSlot.addr == address(0)) { + // This will suffice for checking _exists(nextTokenId), + // as a burned slot cannot contain the zero address. + if (nextTokenId != data._currentIndex) { + nextSlot.addr = from; + nextSlot.startTimestamp = prevOwnership.startTimestamp; + } + } + } + + emit Transfer(from, to, tokenId); + _afterTokenTransfers(from, to, tokenId, 1); + } + + /** + * @dev Equivalent to `_burn(tokenId, false)`. + */ + function _burn(uint256 tokenId) internal virtual { + _burn(tokenId, false); + } + + /** + * @dev Destroys `tokenId`. + * The approval is cleared when the token is burned. + * + * Requirements: + * + * - `tokenId` must exist. + * + * Emits a {Transfer} event. + */ + function _burn(uint256 tokenId, bool approvalCheck) internal virtual { + ERC721AStorage.Data storage data = ERC721AStorage.erc721AStorage(); + + TokenOwnership memory prevOwnership = _ownershipOf(tokenId); + + address from = prevOwnership.addr; + + if (approvalCheck) { + bool isApprovedOrOwner = (_msgSender() == from || + isApprovedForAll(from, _msgSender()) || + getApproved(tokenId) == _msgSender()); + + if (!isApprovedOrOwner) revert TransferCallerNotOwnerNorApproved(); + } + + _beforeTokenTransfers(from, address(0), tokenId, 1); + + // Clear approvals from the previous owner + _approve(address(0), tokenId, from); + + // Underflow of the sender's balance is impossible because we check for + // ownership above and the recipient's balance can't realistically overflow. + // Counter overflow is incredibly unrealistic as tokenId would have to be 2**256. + unchecked { + AddressData storage addressData = data._addressData[from]; + addressData.balance -= 1; + addressData.numberBurned += 1; + + // Keep track of who burned the token, and the timestamp of burning. + TokenOwnership storage currSlot = data._ownerships[tokenId]; + currSlot.addr = from; + currSlot.startTimestamp = uint64(block.timestamp); + currSlot.burned = true; + + // If the ownership slot of tokenId+1 is not explicitly set, that means the burn initiator owns it. + // Set the slot of tokenId+1 explicitly in storage to maintain correctness for ownerOf(tokenId+1) calls. + uint256 nextTokenId = tokenId + 1; + TokenOwnership storage nextSlot = data._ownerships[nextTokenId]; + if (nextSlot.addr == address(0)) { + // This will suffice for checking _exists(nextTokenId), + // as a burned slot cannot contain the zero address. + if (nextTokenId != data._currentIndex) { + nextSlot.addr = from; + nextSlot.startTimestamp = prevOwnership.startTimestamp; + } + } + } + + emit Transfer(from, address(0), tokenId); + _afterTokenTransfers(from, address(0), tokenId, 1); + + // Overflow not possible, as _burnCounter cannot be exceed _currentIndex times. + unchecked { + data._burnCounter++; + } + } + + /** + * @dev Approve `to` to operate on `tokenId` + * + * Emits a {Approval} event. + */ + function _approve(address to, uint256 tokenId, address owner) private { + ERC721AStorage.Data storage data = ERC721AStorage.erc721AStorage(); + data._tokenApprovals[tokenId] = to; + emit Approval(owner, to, tokenId); + } + + /** + * @dev Internal function to invoke {IERC721Receiver-onERC721Received} on a target contract. + * + * @param from address representing the previous owner of the given token ID + * @param to target address that will receive the tokens + * @param tokenId uint256 ID of the token to be transferred + * @param _data bytes optional data to send along with the call + * @return bool whether the call correctly returned the expected magic value + */ + function _checkContractOnERC721Received( + address from, + address to, + uint256 tokenId, + bytes memory _data + ) private returns (bool) { + try IERC721Receiver(to).onERC721Received(_msgSender(), from, tokenId, _data) returns (bytes4 retval) { + return retval == IERC721Receiver(to).onERC721Received.selector; + } catch (bytes memory reason) { + if (reason.length == 0) { + revert TransferToNonERC721ReceiverImplementer(); + } else { + assembly { + revert(add(32, reason), mload(reason)) + } + } + } + } + + /** + * @dev Hook that is called before a set of serially-ordered token ids are about to be transferred. This includes minting. + * And also called before burning one token. + * + * startTokenId - the first token id to be transferred + * quantity - the amount to be transferred + * + * Calling conditions: + * + * - When `from` and `to` are both non-zero, `from`'s `tokenId` will be + * transferred to `to`. + * - When `from` is zero, `tokenId` will be minted for `to`. + * - When `to` is zero, `tokenId` will be burned by `from`. + * - `from` and `to` are never both zero. + */ + function _beforeTokenTransfers(address from, address to, uint256 startTokenId, uint256 quantity) internal virtual {} + + /** + * @dev Hook that is called after a set of serially-ordered token ids have been transferred. This includes + * minting. + * And also called after one token has been burned. + * + * startTokenId - the first token id to be transferred + * quantity - the amount to be transferred + * + * Calling conditions: + * + * - When `from` and `to` are both non-zero, `from`'s `tokenId` has been + * transferred to `to`. + * - When `from` is zero, `tokenId` has been minted for `to`. + * - When `to` is zero, `tokenId` has been burned by `from`. + * - `from` and `to` are never both zero. + */ + function _afterTokenTransfers(address from, address to, uint256 startTokenId, uint256 quantity) internal virtual {} +} diff --git a/contracts/eip/ERC721AVirtualApprove.sol b/contracts/eip/ERC721AVirtualApprove.sol new file mode 100644 index 000000000..45c5232ce --- /dev/null +++ b/contracts/eip/ERC721AVirtualApprove.sol @@ -0,0 +1,582 @@ +// SPDX-License-Identifier: MIT +// ERC721A Contracts v3.3.0 +// Creator: Chiru Labs + +pragma solidity ^0.8.4; + +////////// CHANGELOG: turn `approve` to virtual ////////// + +import "./interface/IERC721A.sol"; +import "./interface/IERC721Receiver.sol"; +import "../lib/Address.sol"; +import "../external-deps/openzeppelin/utils/Context.sol"; +import "../lib/Strings.sol"; +import "./ERC165.sol"; + +/** + * @dev Implementation of https://eips.ethereum.org/EIPS/eip-721[ERC721] Non-Fungible Token Standard, including + * the Metadata extension. Built to optimize for lower gas during batch mints. + * + * Assumes serials are sequentially minted starting at _startTokenId() (defaults to 0, e.g. 0, 1, 2, 3..). + * + * Assumes that an owner cannot have more than 2**64 - 1 (max value of uint64) of supply. + * + * Assumes that the maximum token id cannot exceed 2**256 - 1 (max value of uint256). + */ +contract ERC721A is Context, ERC165, IERC721A { + using Address for address; + using Strings for uint256; + + // The tokenId of the next token to be minted. + uint256 internal _currentIndex; + + // The number of tokens burned. + uint256 internal _burnCounter; + + // Token name + string private _name; + + // Token symbol + string private _symbol; + + // Mapping from token ID to ownership details + // An empty struct value does not necessarily mean the token is unowned. See _ownershipOf implementation for details. + mapping(uint256 => TokenOwnership) internal _ownerships; + + // Mapping owner address to address data + mapping(address => AddressData) private _addressData; + + // Mapping from token ID to approved address + mapping(uint256 => address) private _tokenApprovals; + + // Mapping from owner to operator approvals + mapping(address => mapping(address => bool)) private _operatorApprovals; + + constructor(string memory name_, string memory symbol_) { + _name = name_; + _symbol = symbol_; + _currentIndex = _startTokenId(); + } + + /** + * To change the starting tokenId, please override this function. + */ + function _startTokenId() internal view virtual returns (uint256) { + return 0; + } + + /** + * @dev Burned tokens are calculated here, use _totalMinted() if you want to count just minted tokens. + */ + function totalSupply() public view override returns (uint256) { + // Counter underflow is impossible as _burnCounter cannot be incremented + // more than _currentIndex - _startTokenId() times + unchecked { + return _currentIndex - _burnCounter - _startTokenId(); + } + } + + /** + * Returns the total amount of tokens minted in the contract. + */ + function _totalMinted() internal view returns (uint256) { + // Counter underflow is impossible as _currentIndex does not decrement, + // and it is initialized to _startTokenId() + unchecked { + return _currentIndex - _startTokenId(); + } + } + + /** + * @dev See {IERC165-supportsInterface}. + */ + function supportsInterface(bytes4 interfaceId) public view virtual override(ERC165) returns (bool) { + return + interfaceId == type(IERC721).interfaceId || + interfaceId == type(IERC721Metadata).interfaceId || + super.supportsInterface(interfaceId); + } + + /** + * @dev See {IERC721-balanceOf}. + */ + function balanceOf(address owner) public view override returns (uint256) { + if (owner == address(0)) revert BalanceQueryForZeroAddress(); + return uint256(_addressData[owner].balance); + } + + /** + * Returns the number of tokens minted by `owner`. + */ + function _numberMinted(address owner) internal view returns (uint256) { + return uint256(_addressData[owner].numberMinted); + } + + /** + * Returns the number of tokens burned by or on behalf of `owner`. + */ + function _numberBurned(address owner) internal view returns (uint256) { + return uint256(_addressData[owner].numberBurned); + } + + /** + * Returns the auxillary data for `owner`. (e.g. number of whitelist mint slots used). + */ + function _getAux(address owner) internal view returns (uint64) { + return _addressData[owner].aux; + } + + /** + * Sets the auxillary data for `owner`. (e.g. number of whitelist mint slots used). + * If there are multiple variables, please pack them into a uint64. + */ + function _setAux(address owner, uint64 aux) internal { + _addressData[owner].aux = aux; + } + + /** + * Gas spent here starts off proportional to the maximum mint batch size. + * It gradually moves to O(1) as tokens get transferred around in the collection over time. + */ + function _ownershipOf(uint256 tokenId) internal view returns (TokenOwnership memory) { + uint256 curr = tokenId; + + unchecked { + if (_startTokenId() <= curr) + if (curr < _currentIndex) { + TokenOwnership memory ownership = _ownerships[curr]; + if (!ownership.burned) { + if (ownership.addr != address(0)) { + return ownership; + } + // Invariant: + // There will always be an ownership that has an address and is not burned + // before an ownership that does not have an address and is not burned. + // Hence, curr will not underflow. + while (true) { + curr--; + ownership = _ownerships[curr]; + if (ownership.addr != address(0)) { + return ownership; + } + } + } + } + } + revert OwnerQueryForNonexistentToken(); + } + + /** + * @dev See {IERC721-ownerOf}. + */ + function ownerOf(uint256 tokenId) public view override returns (address) { + return _ownershipOf(tokenId).addr; + } + + /** + * @dev See {IERC721Metadata-name}. + */ + function name() public view virtual override returns (string memory) { + return _name; + } + + /** + * @dev See {IERC721Metadata-symbol}. + */ + function symbol() public view virtual override returns (string memory) { + return _symbol; + } + + /** + * @dev See {IERC721Metadata-tokenURI}. + */ + function tokenURI(uint256 tokenId) public view virtual override returns (string memory) { + if (!_exists(tokenId)) revert URIQueryForNonexistentToken(); + + string memory baseURI = _baseURI(); + return bytes(baseURI).length != 0 ? string(abi.encodePacked(baseURI, tokenId.toString())) : ""; + } + + /** + * @dev Base URI for computing {tokenURI}. If set, the resulting URI for each + * token will be the concatenation of the `baseURI` and the `tokenId`. Empty + * by default, can be overriden in child contracts. + */ + function _baseURI() internal view virtual returns (string memory) { + return ""; + } + + /** + * @dev See {IERC721-approve}. + */ + function approve(address to, uint256 tokenId) public virtual override { + address owner = ERC721A.ownerOf(tokenId); + if (to == owner) revert ApprovalToCurrentOwner(); + + if (_msgSender() != owner) + if (!isApprovedForAll(owner, _msgSender())) { + revert ApprovalCallerNotOwnerNorApproved(); + } + + _approve(to, tokenId, owner); + } + + /** + * @dev See {IERC721-getApproved}. + */ + function getApproved(uint256 tokenId) public view override returns (address) { + if (!_exists(tokenId)) revert ApprovalQueryForNonexistentToken(); + + return _tokenApprovals[tokenId]; + } + + /** + * @dev See {IERC721-setApprovalForAll}. + */ + function setApprovalForAll(address operator, bool approved) public virtual override { + if (operator == _msgSender()) revert ApproveToCaller(); + + _operatorApprovals[_msgSender()][operator] = approved; + emit ApprovalForAll(_msgSender(), operator, approved); + } + + /** + * @dev See {IERC721-isApprovedForAll}. + */ + function isApprovedForAll(address owner, address operator) public view virtual override returns (bool) { + return _operatorApprovals[owner][operator]; + } + + /** + * @dev See {IERC721-transferFrom}. + */ + function transferFrom(address from, address to, uint256 tokenId) public virtual override { + _transfer(from, to, tokenId); + } + + /** + * @dev See {IERC721-safeTransferFrom}. + */ + function safeTransferFrom(address from, address to, uint256 tokenId) public virtual override { + safeTransferFrom(from, to, tokenId, ""); + } + + /** + * @dev See {IERC721-safeTransferFrom}. + */ + function safeTransferFrom(address from, address to, uint256 tokenId, bytes memory _data) public virtual override { + _transfer(from, to, tokenId); + if (to.isContract()) + if (!_checkContractOnERC721Received(from, to, tokenId, _data)) { + revert TransferToNonERC721ReceiverImplementer(); + } + } + + /** + * @dev Returns whether `tokenId` exists. + * + * Tokens can be managed by their owner or approved accounts via {approve} or {setApprovalForAll}. + * + * Tokens start existing when they are minted (`_mint`), + */ + function _exists(uint256 tokenId) internal view returns (bool) { + return _startTokenId() <= tokenId && tokenId < _currentIndex && !_ownerships[tokenId].burned; + } + + /** + * @dev Equivalent to `_safeMint(to, quantity, '')`. + */ + function _safeMint(address to, uint256 quantity) internal { + _safeMint(to, quantity, ""); + } + + /** + * @dev Safely mints `quantity` tokens and transfers them to `to`. + * + * Requirements: + * + * - If `to` refers to a smart contract, it must implement + * {IERC721Receiver-onERC721Received}, which is called for each safe transfer. + * - `quantity` must be greater than 0. + * + * Emits a {Transfer} event. + */ + function _safeMint(address to, uint256 quantity, bytes memory _data) internal { + uint256 startTokenId = _currentIndex; + if (to == address(0)) revert MintToZeroAddress(); + if (quantity == 0) revert MintZeroQuantity(); + + _beforeTokenTransfers(address(0), to, startTokenId, quantity); + + // Overflows are incredibly unrealistic. + // balance or numberMinted overflow if current value of either + quantity > 1.8e19 (2**64) - 1 + // updatedIndex overflows if _currentIndex + quantity > 1.2e77 (2**256) - 1 + unchecked { + _addressData[to].balance += uint64(quantity); + _addressData[to].numberMinted += uint64(quantity); + + _ownerships[startTokenId].addr = to; + _ownerships[startTokenId].startTimestamp = uint64(block.timestamp); + + uint256 updatedIndex = startTokenId; + uint256 end = updatedIndex + quantity; + + if (to.isContract()) { + do { + emit Transfer(address(0), to, updatedIndex); + if (!_checkContractOnERC721Received(address(0), to, updatedIndex++, _data)) { + revert TransferToNonERC721ReceiverImplementer(); + } + } while (updatedIndex < end); + // Reentrancy protection + if (_currentIndex != startTokenId) revert(); + } else { + do { + emit Transfer(address(0), to, updatedIndex++); + } while (updatedIndex < end); + } + _currentIndex = updatedIndex; + } + _afterTokenTransfers(address(0), to, startTokenId, quantity); + } + + /** + * @dev Mints `quantity` tokens and transfers them to `to`. + * + * Requirements: + * + * - `to` cannot be the zero address. + * - `quantity` must be greater than 0. + * + * Emits a {Transfer} event. + */ + function _mint(address to, uint256 quantity) internal { + uint256 startTokenId = _currentIndex; + if (to == address(0)) revert MintToZeroAddress(); + if (quantity == 0) revert MintZeroQuantity(); + + _beforeTokenTransfers(address(0), to, startTokenId, quantity); + + // Overflows are incredibly unrealistic. + // balance or numberMinted overflow if current value of either + quantity > 1.8e19 (2**64) - 1 + // updatedIndex overflows if _currentIndex + quantity > 1.2e77 (2**256) - 1 + unchecked { + _addressData[to].balance += uint64(quantity); + _addressData[to].numberMinted += uint64(quantity); + + _ownerships[startTokenId].addr = to; + _ownerships[startTokenId].startTimestamp = uint64(block.timestamp); + + uint256 updatedIndex = startTokenId; + uint256 end = updatedIndex + quantity; + + do { + emit Transfer(address(0), to, updatedIndex++); + } while (updatedIndex < end); + + _currentIndex = updatedIndex; + } + _afterTokenTransfers(address(0), to, startTokenId, quantity); + } + + /** + * @dev Transfers `tokenId` from `from` to `to`. + * + * Requirements: + * + * - `to` cannot be the zero address. + * - `tokenId` token must be owned by `from`. + * + * Emits a {Transfer} event. + */ + function _transfer(address from, address to, uint256 tokenId) private { + TokenOwnership memory prevOwnership = _ownershipOf(tokenId); + + if (prevOwnership.addr != from) revert TransferFromIncorrectOwner(); + + bool isApprovedOrOwner = (_msgSender() == from || + isApprovedForAll(from, _msgSender()) || + getApproved(tokenId) == _msgSender()); + + if (!isApprovedOrOwner) revert TransferCallerNotOwnerNorApproved(); + if (to == address(0)) revert TransferToZeroAddress(); + + _beforeTokenTransfers(from, to, tokenId, 1); + + // Clear approvals from the previous owner + _approve(address(0), tokenId, from); + + // Underflow of the sender's balance is impossible because we check for + // ownership above and the recipient's balance can't realistically overflow. + // Counter overflow is incredibly unrealistic as tokenId would have to be 2**256. + unchecked { + _addressData[from].balance -= 1; + _addressData[to].balance += 1; + + TokenOwnership storage currSlot = _ownerships[tokenId]; + currSlot.addr = to; + currSlot.startTimestamp = uint64(block.timestamp); + + // If the ownership slot of tokenId+1 is not explicitly set, that means the transfer initiator owns it. + // Set the slot of tokenId+1 explicitly in storage to maintain correctness for ownerOf(tokenId+1) calls. + uint256 nextTokenId = tokenId + 1; + TokenOwnership storage nextSlot = _ownerships[nextTokenId]; + if (nextSlot.addr == address(0)) { + // This will suffice for checking _exists(nextTokenId), + // as a burned slot cannot contain the zero address. + if (nextTokenId != _currentIndex) { + nextSlot.addr = from; + nextSlot.startTimestamp = prevOwnership.startTimestamp; + } + } + } + + emit Transfer(from, to, tokenId); + _afterTokenTransfers(from, to, tokenId, 1); + } + + /** + * @dev Equivalent to `_burn(tokenId, false)`. + */ + function _burn(uint256 tokenId) internal virtual { + _burn(tokenId, false); + } + + /** + * @dev Destroys `tokenId`. + * The approval is cleared when the token is burned. + * + * Requirements: + * + * - `tokenId` must exist. + * + * Emits a {Transfer} event. + */ + function _burn(uint256 tokenId, bool approvalCheck) internal virtual { + TokenOwnership memory prevOwnership = _ownershipOf(tokenId); + + address from = prevOwnership.addr; + + if (approvalCheck) { + bool isApprovedOrOwner = (_msgSender() == from || + isApprovedForAll(from, _msgSender()) || + getApproved(tokenId) == _msgSender()); + + if (!isApprovedOrOwner) revert TransferCallerNotOwnerNorApproved(); + } + + _beforeTokenTransfers(from, address(0), tokenId, 1); + + // Clear approvals from the previous owner + _approve(address(0), tokenId, from); + + // Underflow of the sender's balance is impossible because we check for + // ownership above and the recipient's balance can't realistically overflow. + // Counter overflow is incredibly unrealistic as tokenId would have to be 2**256. + unchecked { + AddressData storage addressData = _addressData[from]; + addressData.balance -= 1; + addressData.numberBurned += 1; + + // Keep track of who burned the token, and the timestamp of burning. + TokenOwnership storage currSlot = _ownerships[tokenId]; + currSlot.addr = from; + currSlot.startTimestamp = uint64(block.timestamp); + currSlot.burned = true; + + // If the ownership slot of tokenId+1 is not explicitly set, that means the burn initiator owns it. + // Set the slot of tokenId+1 explicitly in storage to maintain correctness for ownerOf(tokenId+1) calls. + uint256 nextTokenId = tokenId + 1; + TokenOwnership storage nextSlot = _ownerships[nextTokenId]; + if (nextSlot.addr == address(0)) { + // This will suffice for checking _exists(nextTokenId), + // as a burned slot cannot contain the zero address. + if (nextTokenId != _currentIndex) { + nextSlot.addr = from; + nextSlot.startTimestamp = prevOwnership.startTimestamp; + } + } + } + + emit Transfer(from, address(0), tokenId); + _afterTokenTransfers(from, address(0), tokenId, 1); + + // Overflow not possible, as _burnCounter cannot be exceed _currentIndex times. + unchecked { + _burnCounter++; + } + } + + /** + * @dev Approve `to` to operate on `tokenId` + * + * Emits a {Approval} event. + */ + function _approve(address to, uint256 tokenId, address owner) private { + _tokenApprovals[tokenId] = to; + emit Approval(owner, to, tokenId); + } + + /** + * @dev Internal function to invoke {IERC721Receiver-onERC721Received} on a target contract. + * + * @param from address representing the previous owner of the given token ID + * @param to target address that will receive the tokens + * @param tokenId uint256 ID of the token to be transferred + * @param _data bytes optional data to send along with the call + * @return bool whether the call correctly returned the expected magic value + */ + function _checkContractOnERC721Received( + address from, + address to, + uint256 tokenId, + bytes memory _data + ) private returns (bool) { + try IERC721Receiver(to).onERC721Received(_msgSender(), from, tokenId, _data) returns (bytes4 retval) { + return retval == IERC721Receiver(to).onERC721Received.selector; + } catch (bytes memory reason) { + if (reason.length == 0) { + revert TransferToNonERC721ReceiverImplementer(); + } else { + assembly { + revert(add(32, reason), mload(reason)) + } + } + } + } + + /** + * @dev Hook that is called before a set of serially-ordered token ids are about to be transferred. This includes minting. + * And also called before burning one token. + * + * startTokenId - the first token id to be transferred + * quantity - the amount to be transferred + * + * Calling conditions: + * + * - When `from` and `to` are both non-zero, `from`'s `tokenId` will be + * transferred to `to`. + * - When `from` is zero, `tokenId` will be minted for `to`. + * - When `to` is zero, `tokenId` will be burned by `from`. + * - `from` and `to` are never both zero. + */ + function _beforeTokenTransfers(address from, address to, uint256 startTokenId, uint256 quantity) internal virtual {} + + /** + * @dev Hook that is called after a set of serially-ordered token ids have been transferred. This includes + * minting. + * And also called after one token has been burned. + * + * startTokenId - the first token id to be transferred + * quantity - the amount to be transferred + * + * Calling conditions: + * + * - When `from` and `to` are both non-zero, `from`'s `tokenId` has been + * transferred to `to`. + * - When `from` is zero, `tokenId` has been minted for `to`. + * - When `to` is zero, `tokenId` has been burned by `from`. + * - `from` and `to` are never both zero. + */ + function _afterTokenTransfers(address from, address to, uint256 startTokenId, uint256 quantity) internal virtual {} +} diff --git a/contracts/eip/ERC721AVirtualApproveUpgradeable.sol b/contracts/eip/ERC721AVirtualApproveUpgradeable.sol new file mode 100644 index 000000000..55d233bbc --- /dev/null +++ b/contracts/eip/ERC721AVirtualApproveUpgradeable.sol @@ -0,0 +1,598 @@ +// SPDX-License-Identifier: MIT +// ERC721A Contracts v3.3.0 +// Creator: Chiru Labs + +////////// CHANGELOG: turn `approve` to virtual ////////// + +pragma solidity ^0.8.4; + +import "erc721a-upgradeable/contracts/IERC721AUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/token/ERC721/IERC721ReceiverUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/utils/AddressUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/utils/ContextUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/utils/StringsUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/utils/introspection/ERC165Upgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; + +/** + * @dev Implementation of https://eips.ethereum.org/EIPS/eip-721[ERC721] Non-Fungible Token Standard, including + * the Metadata extension. Built to optimize for lower gas during batch mints. + * + * Assumes serials are sequentially minted starting at _startTokenId() (defaults to 0, e.g. 0, 1, 2, 3..). + * + * Assumes that an owner cannot have more than 2**64 - 1 (max value of uint64) of supply. + * + * Assumes that the maximum token id cannot exceed 2**256 - 1 (max value of uint256). + */ +contract ERC721AUpgradeable is Initializable, ContextUpgradeable, ERC165Upgradeable, IERC721AUpgradeable { + using AddressUpgradeable for address; + using StringsUpgradeable for uint256; + + // The tokenId of the next token to be minted. + uint256 internal _currentIndex; + + // The number of tokens burned. + uint256 internal _burnCounter; + + // Token name + string private _name; + + // Token symbol + string private _symbol; + + // Mapping from token ID to ownership details + // An empty struct value does not necessarily mean the token is unowned. See _ownershipOf implementation for details. + mapping(uint256 => TokenOwnership) internal _ownerships; + + // Mapping owner address to address data + mapping(address => AddressData) private _addressData; + + // Mapping from token ID to approved address + mapping(uint256 => address) private _tokenApprovals; + + // Mapping from owner to operator approvals + mapping(address => mapping(address => bool)) private _operatorApprovals; + + function __ERC721A_init(string memory name_, string memory symbol_) internal onlyInitializing { + __ERC721A_init_unchained(name_, symbol_); + } + + function __ERC721A_init_unchained(string memory name_, string memory symbol_) internal onlyInitializing { + _name = name_; + _symbol = symbol_; + _currentIndex = _startTokenId(); + } + + /** + * To change the starting tokenId, please override this function. + */ + function _startTokenId() internal view virtual returns (uint256) { + return 0; + } + + /** + * @dev Burned tokens are calculated here, use _totalMinted() if you want to count just minted tokens. + */ + function totalSupply() public view override returns (uint256) { + // Counter underflow is impossible as _burnCounter cannot be incremented + // more than _currentIndex - _startTokenId() times + unchecked { + return _currentIndex - _burnCounter - _startTokenId(); + } + } + + /** + * Returns the total amount of tokens minted in the contract. + */ + function _totalMinted() internal view returns (uint256) { + // Counter underflow is impossible as _currentIndex does not decrement, + // and it is initialized to _startTokenId() + unchecked { + return _currentIndex - _startTokenId(); + } + } + + /** + * @dev See {IERC165-supportsInterface}. + */ + function supportsInterface( + bytes4 interfaceId + ) public view virtual override(ERC165Upgradeable, IERC165Upgradeable) returns (bool) { + return + interfaceId == type(IERC721Upgradeable).interfaceId || + interfaceId == type(IERC721MetadataUpgradeable).interfaceId || + super.supportsInterface(interfaceId); + } + + /** + * @dev See {IERC721-balanceOf}. + */ + function balanceOf(address owner) public view override returns (uint256) { + if (owner == address(0)) revert BalanceQueryForZeroAddress(); + return uint256(_addressData[owner].balance); + } + + /** + * Returns the number of tokens minted by `owner`. + */ + function _numberMinted(address owner) internal view returns (uint256) { + return uint256(_addressData[owner].numberMinted); + } + + /** + * Returns the number of tokens burned by or on behalf of `owner`. + */ + function _numberBurned(address owner) internal view returns (uint256) { + return uint256(_addressData[owner].numberBurned); + } + + /** + * Returns the auxillary data for `owner`. (e.g. number of whitelist mint slots used). + */ + function _getAux(address owner) internal view returns (uint64) { + return _addressData[owner].aux; + } + + /** + * Sets the auxillary data for `owner`. (e.g. number of whitelist mint slots used). + * If there are multiple variables, please pack them into a uint64. + */ + function _setAux(address owner, uint64 aux) internal { + _addressData[owner].aux = aux; + } + + /** + * Gas spent here starts off proportional to the maximum mint batch size. + * It gradually moves to O(1) as tokens get transferred around in the collection over time. + */ + function _ownershipOf(uint256 tokenId) internal view returns (TokenOwnership memory) { + uint256 curr = tokenId; + + unchecked { + if (_startTokenId() <= curr) + if (curr < _currentIndex) { + TokenOwnership memory ownership = _ownerships[curr]; + if (!ownership.burned) { + if (ownership.addr != address(0)) { + return ownership; + } + // Invariant: + // There will always be an ownership that has an address and is not burned + // before an ownership that does not have an address and is not burned. + // Hence, curr will not underflow. + while (true) { + curr--; + ownership = _ownerships[curr]; + if (ownership.addr != address(0)) { + return ownership; + } + } + } + } + } + revert OwnerQueryForNonexistentToken(); + } + + /** + * @dev See {IERC721-ownerOf}. + */ + function ownerOf(uint256 tokenId) public view override returns (address) { + return _ownershipOf(tokenId).addr; + } + + /** + * @dev See {IERC721Metadata-name}. + */ + function name() public view virtual override returns (string memory) { + return _name; + } + + /** + * @dev See {IERC721Metadata-symbol}. + */ + function symbol() public view virtual override returns (string memory) { + return _symbol; + } + + /** + * @dev See {IERC721Metadata-tokenURI}. + */ + function tokenURI(uint256 tokenId) public view virtual override returns (string memory) { + if (!_exists(tokenId)) revert URIQueryForNonexistentToken(); + + string memory baseURI = _baseURI(); + return bytes(baseURI).length != 0 ? string(abi.encodePacked(baseURI, tokenId.toString())) : ""; + } + + /** + * @dev Base URI for computing {tokenURI}. If set, the resulting URI for each + * token will be the concatenation of the `baseURI` and the `tokenId`. Empty + * by default, can be overriden in child contracts. + */ + function _baseURI() internal view virtual returns (string memory) { + return ""; + } + + /** + * @dev See {IERC721-approve}. + */ + function approve(address to, uint256 tokenId) public virtual override { + address owner = ERC721AUpgradeable.ownerOf(tokenId); + if (to == owner) revert ApprovalToCurrentOwner(); + + if (_msgSender() != owner) + if (!isApprovedForAll(owner, _msgSender())) { + revert ApprovalCallerNotOwnerNorApproved(); + } + + _approve(to, tokenId, owner); + } + + /** + * @dev See {IERC721-getApproved}. + */ + function getApproved(uint256 tokenId) public view override returns (address) { + if (!_exists(tokenId)) revert ApprovalQueryForNonexistentToken(); + + return _tokenApprovals[tokenId]; + } + + /** + * @dev See {IERC721-setApprovalForAll}. + */ + function setApprovalForAll(address operator, bool approved) public virtual override { + if (operator == _msgSender()) revert ApproveToCaller(); + + _operatorApprovals[_msgSender()][operator] = approved; + emit ApprovalForAll(_msgSender(), operator, approved); + } + + /** + * @dev See {IERC721-isApprovedForAll}. + */ + function isApprovedForAll(address owner, address operator) public view virtual override returns (bool) { + return _operatorApprovals[owner][operator]; + } + + /** + * @dev See {IERC721-transferFrom}. + */ + function transferFrom(address from, address to, uint256 tokenId) public virtual override { + _transfer(from, to, tokenId); + } + + /** + * @dev See {IERC721-safeTransferFrom}. + */ + function safeTransferFrom(address from, address to, uint256 tokenId) public virtual override { + safeTransferFrom(from, to, tokenId, ""); + } + + /** + * @dev See {IERC721-safeTransferFrom}. + */ + function safeTransferFrom(address from, address to, uint256 tokenId, bytes memory _data) public virtual override { + _transfer(from, to, tokenId); + if (to.isContract()) + if (!_checkContractOnERC721Received(from, to, tokenId, _data)) { + revert TransferToNonERC721ReceiverImplementer(); + } + } + + /** + * @dev Returns whether `tokenId` exists. + * + * Tokens can be managed by their owner or approved accounts via {approve} or {setApprovalForAll}. + * + * Tokens start existing when they are minted (`_mint`), + */ + function _exists(uint256 tokenId) internal view returns (bool) { + return _startTokenId() <= tokenId && tokenId < _currentIndex && !_ownerships[tokenId].burned; + } + + /** + * @dev Equivalent to `_safeMint(to, quantity, '')`. + */ + function _safeMint(address to, uint256 quantity) internal { + _safeMint(to, quantity, ""); + } + + /** + * @dev Safely mints `quantity` tokens and transfers them to `to`. + * + * Requirements: + * + * - If `to` refers to a smart contract, it must implement + * {IERC721Receiver-onERC721Received}, which is called for each safe transfer. + * - `quantity` must be greater than 0. + * + * Emits a {Transfer} event. + */ + function _safeMint(address to, uint256 quantity, bytes memory _data) internal { + uint256 startTokenId = _currentIndex; + if (to == address(0)) revert MintToZeroAddress(); + if (quantity == 0) revert MintZeroQuantity(); + + _beforeTokenTransfers(address(0), to, startTokenId, quantity); + + // Overflows are incredibly unrealistic. + // balance or numberMinted overflow if current value of either + quantity > 1.8e19 (2**64) - 1 + // updatedIndex overflows if _currentIndex + quantity > 1.2e77 (2**256) - 1 + unchecked { + _addressData[to].balance += uint64(quantity); + _addressData[to].numberMinted += uint64(quantity); + + _ownerships[startTokenId].addr = to; + _ownerships[startTokenId].startTimestamp = uint64(block.timestamp); + + uint256 updatedIndex = startTokenId; + uint256 end = updatedIndex + quantity; + + if (to.isContract()) { + do { + emit Transfer(address(0), to, updatedIndex); + if (!_checkContractOnERC721Received(address(0), to, updatedIndex++, _data)) { + revert TransferToNonERC721ReceiverImplementer(); + } + } while (updatedIndex < end); + // Reentrancy protection + if (_currentIndex != startTokenId) revert(); + } else { + do { + emit Transfer(address(0), to, updatedIndex++); + } while (updatedIndex < end); + } + _currentIndex = updatedIndex; + } + _afterTokenTransfers(address(0), to, startTokenId, quantity); + } + + /** + * @dev Mints `quantity` tokens and transfers them to `to`. + * + * Requirements: + * + * - `to` cannot be the zero address. + * - `quantity` must be greater than 0. + * + * Emits a {Transfer} event. + */ + function _mint(address to, uint256 quantity) internal { + uint256 startTokenId = _currentIndex; + if (to == address(0)) revert MintToZeroAddress(); + if (quantity == 0) revert MintZeroQuantity(); + + _beforeTokenTransfers(address(0), to, startTokenId, quantity); + + // Overflows are incredibly unrealistic. + // balance or numberMinted overflow if current value of either + quantity > 1.8e19 (2**64) - 1 + // updatedIndex overflows if _currentIndex + quantity > 1.2e77 (2**256) - 1 + unchecked { + _addressData[to].balance += uint64(quantity); + _addressData[to].numberMinted += uint64(quantity); + + _ownerships[startTokenId].addr = to; + _ownerships[startTokenId].startTimestamp = uint64(block.timestamp); + + uint256 updatedIndex = startTokenId; + uint256 end = updatedIndex + quantity; + + do { + emit Transfer(address(0), to, updatedIndex++); + } while (updatedIndex < end); + + _currentIndex = updatedIndex; + } + _afterTokenTransfers(address(0), to, startTokenId, quantity); + } + + /** + * @dev Transfers `tokenId` from `from` to `to`. + * + * Requirements: + * + * - `to` cannot be the zero address. + * - `tokenId` token must be owned by `from`. + * + * Emits a {Transfer} event. + */ + function _transfer(address from, address to, uint256 tokenId) private { + TokenOwnership memory prevOwnership = _ownershipOf(tokenId); + + if (prevOwnership.addr != from) revert TransferFromIncorrectOwner(); + + bool isApprovedOrOwner = (_msgSender() == from || + isApprovedForAll(from, _msgSender()) || + getApproved(tokenId) == _msgSender()); + + if (!isApprovedOrOwner) revert TransferCallerNotOwnerNorApproved(); + if (to == address(0)) revert TransferToZeroAddress(); + + _beforeTokenTransfers(from, to, tokenId, 1); + + // Clear approvals from the previous owner + _approve(address(0), tokenId, from); + + // Underflow of the sender's balance is impossible because we check for + // ownership above and the recipient's balance can't realistically overflow. + // Counter overflow is incredibly unrealistic as tokenId would have to be 2**256. + unchecked { + _addressData[from].balance -= 1; + _addressData[to].balance += 1; + + TokenOwnership storage currSlot = _ownerships[tokenId]; + currSlot.addr = to; + currSlot.startTimestamp = uint64(block.timestamp); + + // If the ownership slot of tokenId+1 is not explicitly set, that means the transfer initiator owns it. + // Set the slot of tokenId+1 explicitly in storage to maintain correctness for ownerOf(tokenId+1) calls. + uint256 nextTokenId = tokenId + 1; + TokenOwnership storage nextSlot = _ownerships[nextTokenId]; + if (nextSlot.addr == address(0)) { + // This will suffice for checking _exists(nextTokenId), + // as a burned slot cannot contain the zero address. + if (nextTokenId != _currentIndex) { + nextSlot.addr = from; + nextSlot.startTimestamp = prevOwnership.startTimestamp; + } + } + } + + emit Transfer(from, to, tokenId); + _afterTokenTransfers(from, to, tokenId, 1); + } + + /** + * @dev Equivalent to `_burn(tokenId, false)`. + */ + function _burn(uint256 tokenId) internal virtual { + _burn(tokenId, false); + } + + /** + * @dev Destroys `tokenId`. + * The approval is cleared when the token is burned. + * + * Requirements: + * + * - `tokenId` must exist. + * + * Emits a {Transfer} event. + */ + function _burn(uint256 tokenId, bool approvalCheck) internal virtual { + TokenOwnership memory prevOwnership = _ownershipOf(tokenId); + + address from = prevOwnership.addr; + + if (approvalCheck) { + bool isApprovedOrOwner = (_msgSender() == from || + isApprovedForAll(from, _msgSender()) || + getApproved(tokenId) == _msgSender()); + + if (!isApprovedOrOwner) revert TransferCallerNotOwnerNorApproved(); + } + + _beforeTokenTransfers(from, address(0), tokenId, 1); + + // Clear approvals from the previous owner + _approve(address(0), tokenId, from); + + // Underflow of the sender's balance is impossible because we check for + // ownership above and the recipient's balance can't realistically overflow. + // Counter overflow is incredibly unrealistic as tokenId would have to be 2**256. + unchecked { + AddressData storage addressData = _addressData[from]; + addressData.balance -= 1; + addressData.numberBurned += 1; + + // Keep track of who burned the token, and the timestamp of burning. + TokenOwnership storage currSlot = _ownerships[tokenId]; + currSlot.addr = from; + currSlot.startTimestamp = uint64(block.timestamp); + currSlot.burned = true; + + // If the ownership slot of tokenId+1 is not explicitly set, that means the burn initiator owns it. + // Set the slot of tokenId+1 explicitly in storage to maintain correctness for ownerOf(tokenId+1) calls. + uint256 nextTokenId = tokenId + 1; + TokenOwnership storage nextSlot = _ownerships[nextTokenId]; + if (nextSlot.addr == address(0)) { + // This will suffice for checking _exists(nextTokenId), + // as a burned slot cannot contain the zero address. + if (nextTokenId != _currentIndex) { + nextSlot.addr = from; + nextSlot.startTimestamp = prevOwnership.startTimestamp; + } + } + } + + emit Transfer(from, address(0), tokenId); + _afterTokenTransfers(from, address(0), tokenId, 1); + + // Overflow not possible, as _burnCounter cannot be exceed _currentIndex times. + unchecked { + _burnCounter++; + } + } + + /** + * @dev Approve `to` to operate on `tokenId` + * + * Emits a {Approval} event. + */ + function _approve(address to, uint256 tokenId, address owner) private { + _tokenApprovals[tokenId] = to; + emit Approval(owner, to, tokenId); + } + + /** + * @dev Internal function to invoke {IERC721Receiver-onERC721Received} on a target contract. + * + * @param from address representing the previous owner of the given token ID + * @param to target address that will receive the tokens + * @param tokenId uint256 ID of the token to be transferred + * @param _data bytes optional data to send along with the call + * @return bool whether the call correctly returned the expected magic value + */ + function _checkContractOnERC721Received( + address from, + address to, + uint256 tokenId, + bytes memory _data + ) private returns (bool) { + try IERC721ReceiverUpgradeable(to).onERC721Received(_msgSender(), from, tokenId, _data) returns ( + bytes4 retval + ) { + return retval == IERC721ReceiverUpgradeable(to).onERC721Received.selector; + } catch (bytes memory reason) { + if (reason.length == 0) { + revert TransferToNonERC721ReceiverImplementer(); + } else { + assembly { + revert(add(32, reason), mload(reason)) + } + } + } + } + + /** + * @dev Hook that is called before a set of serially-ordered token ids are about to be transferred. This includes minting. + * And also called before burning one token. + * + * startTokenId - the first token id to be transferred + * quantity - the amount to be transferred + * + * Calling conditions: + * + * - When `from` and `to` are both non-zero, `from`'s `tokenId` will be + * transferred to `to`. + * - When `from` is zero, `tokenId` will be minted for `to`. + * - When `to` is zero, `tokenId` will be burned by `from`. + * - `from` and `to` are never both zero. + */ + function _beforeTokenTransfers(address from, address to, uint256 startTokenId, uint256 quantity) internal virtual {} + + /** + * @dev Hook that is called after a set of serially-ordered token ids have been transferred. This includes + * minting. + * And also called after one token has been burned. + * + * startTokenId - the first token id to be transferred + * quantity - the amount to be transferred + * + * Calling conditions: + * + * - When `from` and `to` are both non-zero, `from`'s `tokenId` has been + * transferred to `to`. + * - When `from` is zero, `tokenId` has been minted for `to`. + * - When `to` is zero, `tokenId` has been burned by `from`. + * - `from` and `to` are never both zero. + */ + function _afterTokenTransfers(address from, address to, uint256 startTokenId, uint256 quantity) internal virtual {} + + /** + * @dev This empty reserved space is put in place to allow future versions to add new + * variables without shifting down storage in the inheritance chain. + * See https://docs.openzeppelin.com/contracts/4.x/upgradeable#storage_gaps + */ + uint256[42] private __gap; +} diff --git a/contracts/eip/interface/IERC1155.sol b/contracts/eip/interface/IERC1155.sol new file mode 100644 index 000000000..1f2d9e919 --- /dev/null +++ b/contracts/eip/interface/IERC1155.sol @@ -0,0 +1,132 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/** + @title ERC-1155 Multi Token Standard + @dev See https://github.com/ethereum/EIPs/blob/master/EIPS/eip-1155.md + Note: The ERC-165 identifier for this interface is 0xd9b67a26. + */ +interface IERC1155 { + /** + @dev Either `TransferSingle` or `TransferBatch` MUST emit when tokens are transferred, including zero value transfers as well as minting or burning (see "Safe Transfer Rules" section of the standard). + The `_operator` argument MUST be msg.sender. + The `_from` argument MUST be the address of the holder whose balance is decreased. + The `_to` argument MUST be the address of the recipient whose balance is increased. + The `_id` argument MUST be the token type being transferred. + The `_value` argument MUST be the number of tokens the holder balance is decreased by and match what the recipient balance is increased by. + When minting/creating tokens, the `_from` argument MUST be set to `0x0` (i.e. zero address). + When burning/destroying tokens, the `_to` argument MUST be set to `0x0` (i.e. zero address). + */ + event TransferSingle( + address indexed _operator, + address indexed _from, + address indexed _to, + uint256 _id, + uint256 _value + ); + + /** + @dev Either `TransferSingle` or `TransferBatch` MUST emit when tokens are transferred, including zero value transfers as well as minting or burning (see "Safe Transfer Rules" section of the standard). + The `_operator` argument MUST be msg.sender. + The `_from` argument MUST be the address of the holder whose balance is decreased. + The `_to` argument MUST be the address of the recipient whose balance is increased. + The `_ids` argument MUST be the list of tokens being transferred. + The `_values` argument MUST be the list of number of tokens (matching the list and order of tokens specified in _ids) the holder balance is decreased by and match what the recipient balance is increased by. + When minting/creating tokens, the `_from` argument MUST be set to `0x0` (i.e. zero address). + When burning/destroying tokens, the `_to` argument MUST be set to `0x0` (i.e. zero address). + */ + event TransferBatch( + address indexed _operator, + address indexed _from, + address indexed _to, + uint256[] _ids, + uint256[] _values + ); + + /** + @dev MUST emit when approval for a second party/operator address to manage all tokens for an owner address is enabled or disabled (absense of an event assumes disabled). + */ + event ApprovalForAll(address indexed _owner, address indexed _operator, bool _approved); + + /** + @dev MUST emit when the URI is updated for a token ID. + URIs are defined in RFC 3986. + The URI MUST point a JSON file that conforms to the "ERC-1155 Metadata URI JSON Schema". + */ + event URI(string _value, uint256 indexed _id); + + /** + @notice Transfers `_value` amount of an `_id` from the `_from` address to the `_to` address specified (with safety call). + @dev Caller must be approved to manage the tokens being transferred out of the `_from` account (see "Approval" section of the standard). + MUST revert if `_to` is the zero address. + MUST revert if balance of holder for token `_id` is lower than the `_value` sent. + MUST revert on any other error. + MUST emit the `TransferSingle` event to reflect the balance change (see "Safe Transfer Rules" section of the standard). + After the above conditions are met, this function MUST check if `_to` is a smart contract (e.g. code size > 0). If so, it MUST call `onERC1155Received` on `_to` and act appropriately (see "Safe Transfer Rules" section of the standard). + @param _from Source address + @param _to Target address + @param _id ID of the token type + @param _value Transfer amount + @param _data Additional data with no specified format, MUST be sent unaltered in call to `onERC1155Received` on `_to` + */ + function safeTransferFrom(address _from, address _to, uint256 _id, uint256 _value, bytes calldata _data) external; + + /** + @notice Transfers `_values` amount(s) of `_ids` from the `_from` address to the `_to` address specified (with safety call). + @dev Caller must be approved to manage the tokens being transferred out of the `_from` account (see "Approval" section of the standard). + MUST revert if `_to` is the zero address. + MUST revert if length of `_ids` is not the same as length of `_values`. + MUST revert if any of the balance(s) of the holder(s) for token(s) in `_ids` is lower than the respective amount(s) in `_values` sent to the recipient. + MUST revert on any other error. + MUST emit `TransferSingle` or `TransferBatch` event(s) such that all the balance changes are reflected (see "Safe Transfer Rules" section of the standard). + Balance changes and events MUST follow the ordering of the arrays (_ids[0]/_values[0] before _ids[1]/_values[1], etc). + After the above conditions for the transfer(s) in the batch are met, this function MUST check if `_to` is a smart contract (e.g. code size > 0). If so, it MUST call the relevant `ERC1155TokenReceiver` hook(s) on `_to` and act appropriately (see "Safe Transfer Rules" section of the standard). + @param _from Source address + @param _to Target address + @param _ids IDs of each token type (order and length must match _values array) + @param _values Transfer amounts per token type (order and length must match _ids array) + @param _data Additional data with no specified format, MUST be sent unaltered in call to the `ERC1155TokenReceiver` hook(s) on `_to` + */ + function safeBatchTransferFrom( + address _from, + address _to, + uint256[] calldata _ids, + uint256[] calldata _values, + bytes calldata _data + ) external; + + /** + @notice Get the balance of an account's Tokens. + @param _owner The address of the token holder + @param _id ID of the Token + @return The _owner's balance of the Token type requested + */ + function balanceOf(address _owner, uint256 _id) external view returns (uint256); + + /** + @notice Get the balance of multiple account/token pairs + @param _owners The addresses of the token holders + @param _ids ID of the Tokens + @return The _owner's balance of the Token types requested (i.e. balance for each (owner, id) pair) + */ + function balanceOfBatch( + address[] calldata _owners, + uint256[] calldata _ids + ) external view returns (uint256[] memory); + + /** + @notice Enable or disable approval for a third party ("operator") to manage all of the caller's tokens. + @dev MUST emit the ApprovalForAll event on success. + @param _operator Address to add to the set of authorized operators + @param _approved True if the operator is approved, false to revoke approval + */ + function setApprovalForAll(address _operator, bool _approved) external; + + /** + @notice Queries the approval status of an operator for a given owner. + @param _owner The owner of the Tokens + @param _operator Address of authorized operator + @return True if the operator is approved, false if not + */ + function isApprovedForAll(address _owner, address _operator) external view returns (bool); +} diff --git a/contracts/eip/interface/IERC1155Enumerable.sol b/contracts/eip/interface/IERC1155Enumerable.sol new file mode 100644 index 000000000..8d0c22dba --- /dev/null +++ b/contracts/eip/interface/IERC1155Enumerable.sol @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @title ERC1155 Non-Fungible Token Standard, optional enumeration extension +/// @dev See https://eips.ethereum.org/EIPS/eip-1155 +interface IERC1155Enumerable { + /// @notice Returns the next token ID available for minting + /// @return The token identifier for the `_index`th NFT, + /// (sort order not specified) + function nextTokenIdToMint() external view returns (uint256); +} diff --git a/contracts/eip/interface/IERC1155Metadata.sol b/contracts/eip/interface/IERC1155Metadata.sol new file mode 100644 index 000000000..d735d851a --- /dev/null +++ b/contracts/eip/interface/IERC1155Metadata.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/** + Note: The ERC-165 identifier for this interface is 0x0e89341c. +*/ +interface IERC1155Metadata { + /** + @notice A distinct Uniform Resource Identifier (URI) for a given token. + @dev URIs are defined in RFC 3986. + The URI may point to a JSON file that conforms to the "ERC-1155 Metadata URI JSON Schema". + @return URI string + */ + function uri(uint256 _id) external view returns (string memory); +} diff --git a/contracts/eip/interface/IERC1155Receiver.sol b/contracts/eip/interface/IERC1155Receiver.sol new file mode 100644 index 000000000..8a38c31e8 --- /dev/null +++ b/contracts/eip/interface/IERC1155Receiver.sol @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts (last updated v4.5.0) (token/ERC1155/IERC1155Receiver.sol) + +pragma solidity ^0.8.0; + +import "./IERC165.sol"; + +/** + * @dev _Available since v3.1._ + */ +interface IERC1155Receiver is IERC165 { + /** + * @dev Handles the receipt of a single ERC1155 token type. This function is + * called at the end of a `safeTransferFrom` after the balance has been updated. + * + * NOTE: To accept the transfer, this must return + * `bytes4(keccak256("onERC1155Received(address,address,uint256,uint256,bytes)"))` + * (i.e. 0xf23a6e61, or its own function selector). + * + * @param operator The address which initiated the transfer (i.e. msg.sender) + * @param from The address which previously owned the token + * @param id The ID of the token being transferred + * @param value The amount of tokens being transferred + * @param data Additional data with no specified format + * @return `bytes4(keccak256("onERC1155Received(address,address,uint256,uint256,bytes)"))` if transfer is allowed + */ + function onERC1155Received( + address operator, + address from, + uint256 id, + uint256 value, + bytes calldata data + ) external returns (bytes4); + + /** + * @dev Handles the receipt of a multiple ERC1155 token types. This function + * is called at the end of a `safeBatchTransferFrom` after the balances have + * been updated. + * + * NOTE: To accept the transfer(s), this must return + * `bytes4(keccak256("onERC1155BatchReceived(address,address,uint256[],uint256[],bytes)"))` + * (i.e. 0xbc197c81, or its own function selector). + * + * @param operator The address which initiated the batch transfer (i.e. msg.sender) + * @param from The address which previously owned the token + * @param ids An array containing ids of each token being transferred (order and length must match values array) + * @param values An array containing amounts of each token being transferred (order and length must match ids array) + * @param data Additional data with no specified format + * @return `bytes4(keccak256("onERC1155BatchReceived(address,address,uint256[],uint256[],bytes)"))` if transfer is allowed + */ + function onERC1155BatchReceived( + address operator, + address from, + uint256[] calldata ids, + uint256[] calldata values, + bytes calldata data + ) external returns (bytes4); +} diff --git a/contracts/eip/interface/IERC1155Supply.sol b/contracts/eip/interface/IERC1155Supply.sol new file mode 100644 index 000000000..53d6d3f52 --- /dev/null +++ b/contracts/eip/interface/IERC1155Supply.sol @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @title ERC1155S Non-Fungible Token Standard, optional supply extension +/// @dev See https://eips.ethereum.org/EIPS/eip-1155 +interface IERC1155Supply { + /// @notice Count NFTs tracked by this contract + /// @return A count of valid NFTs tracked by this contract, where each one of + /// them has an assigned and queryable owner not equal to the zero address + function totalSupply(uint256 id) external view returns (uint256); +} diff --git a/contracts/eip/interface/IERC165.sol b/contracts/eip/interface/IERC165.sol new file mode 100644 index 000000000..281c3f2db --- /dev/null +++ b/contracts/eip/interface/IERC165.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts v4.4.1 (utils/introspection/IERC165.sol) + +pragma solidity ^0.8.0; + +/** + * @dev Interface of the ERC165 standard, as defined in the + * [EIP](https://eips.ethereum.org/EIPS/eip-165). + * + * Implementers can declare support of contract interfaces, which can then be + * queried by others ({ERC165Checker}). + * + * For an implementation, see {ERC165}. + */ +interface IERC165 { + /** + * @dev Returns true if this contract implements the interface defined by + * `interfaceId`. See the corresponding + * [EIP section](https://eips.ethereum.org/EIPS/eip-165#how-interfaces-are-identified) + * to learn more about how these ids are created. + * + * This function call must use less than 30 000 gas. + */ + function supportsInterface(bytes4 interfaceId) external view returns (bool); +} diff --git a/contracts/eip/interface/IERC20.sol b/contracts/eip/interface/IERC20.sol new file mode 100644 index 000000000..246af8424 --- /dev/null +++ b/contracts/eip/interface/IERC20.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/** + * @title ERC20 interface + * @dev see https://github.com/ethereum/EIPs/issues/20 + */ +interface IERC20 { + function totalSupply() external view returns (uint256); + + function balanceOf(address who) external view returns (uint256); + + function allowance(address owner, address spender) external view returns (uint256); + + function transfer(address to, uint256 value) external returns (bool); + + function approve(address spender, uint256 value) external returns (bool); + + function transferFrom(address from, address to, uint256 value) external returns (bool); + + event Transfer(address indexed from, address indexed to, uint256 value); + + event Approval(address indexed owner, address indexed spender, uint256 value); +} diff --git a/contracts/eip/interface/IERC20Metadata.sol b/contracts/eip/interface/IERC20Metadata.sol new file mode 100644 index 000000000..31961cd99 --- /dev/null +++ b/contracts/eip/interface/IERC20Metadata.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/** + * @title ERC20Metadata interface + * @dev see https://github.com/ethereum/EIPs/issues/20 + */ +interface IERC20Metadata { + function name() external view returns (string memory); + + function symbol() external view returns (string memory); + + function decimals() external view returns (uint8); +} diff --git a/contracts/eip/interface/IERC20Permit.sol b/contracts/eip/interface/IERC20Permit.sol new file mode 100644 index 000000000..6363b1408 --- /dev/null +++ b/contracts/eip/interface/IERC20Permit.sol @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts v4.4.1 (token/ERC20/extensions/draft-IERC20Permit.sol) + +pragma solidity ^0.8.0; + +/** + * @dev Interface of the ERC20 Permit extension allowing approvals to be made via signatures, as defined in + * https://eips.ethereum.org/EIPS/eip-2612[EIP-2612]. + * + * Adds the {permit} method, which can be used to change an account's ERC20 allowance (see {IERC20-allowance}) by + * presenting a message signed by the account. By not relying on {IERC20-approve}, the token holder account doesn't + * need to send a transaction, and thus is not required to hold Ether at all. + */ +interface IERC20Permit { + /** + * @dev Sets `value` as the allowance of `spender` over ``owner``'s tokens, + * given ``owner``'s signed approval. + * + * IMPORTANT: The same issues {IERC20-approve} has related to transaction + * ordering also apply here. + * + * Emits an {Approval} event. + * + * Requirements: + * + * - `spender` cannot be the zero address. + * - `deadline` must be a timestamp in the future. + * - `v`, `r` and `s` must be a valid `secp256k1` signature from `owner` + * over the EIP712-formatted function arguments. + * - the signature must use ``owner``'s current nonce (see {nonces}). + * + * For more information on the signature format, see the + * https://eips.ethereum.org/EIPS/eip-2612#specification[relevant EIP + * section]. + */ + function permit( + address owner, + address spender, + uint256 value, + uint256 deadline, + uint8 v, + bytes32 r, + bytes32 s + ) external; + + /** + * @dev Returns the current nonce for `owner`. This value must be + * included whenever a signature is generated for {permit}. + * + * Every successful call to {permit} increases ``owner``'s nonce by one. This + * prevents a signature from being used multiple times. + */ + function nonces(address owner) external view returns (uint256); + + /** + * @dev Returns the domain separator used in the encoding of the signature for {permit}, as defined by {EIP712}. + */ + // solhint-disable-next-line func-name-mixedcase + function DOMAIN_SEPARATOR() external view returns (bytes32); +} diff --git a/contracts/eip/interface/IERC2981.sol b/contracts/eip/interface/IERC2981.sol new file mode 100644 index 000000000..e25265b33 --- /dev/null +++ b/contracts/eip/interface/IERC2981.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: Apache 2.0 +pragma solidity ^0.8.0; + +import "./IERC165.sol"; + +/** + * @dev Interface for the NFT Royalty Standard. + * + * A standardized way to retrieve royalty payment information for non-fungible tokens (NFTs) to enable universal + * support for royalty payments across all NFT marketplaces and ecosystem participants. + * + * _Available since v4.5._ + */ +interface IERC2981 is IERC165 { + /** + * @dev Returns how much royalty is owed and to whom, based on a sale price that may be denominated in any unit of + * exchange. The royalty amount is denominated and should be payed in that same unit of exchange. + */ + function royaltyInfo( + uint256 tokenId, + uint256 salePrice + ) external view returns (address receiver, uint256 royaltyAmount); +} diff --git a/contracts/eip/interface/IERC4906.sol b/contracts/eip/interface/IERC4906.sol new file mode 100644 index 000000000..d52537eff --- /dev/null +++ b/contracts/eip/interface/IERC4906.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "./IERC165.sol"; +import "./IERC721.sol"; + +interface IERC4906 is IERC165 { + /// @dev This event emits when the metadata of a token is changed. + /// So that the third-party platforms such as NFT market could + /// timely update the images and related attributes of the NFT. + event MetadataUpdate(uint256 _tokenId); + + /// @dev This event emits when the metadata of a range of tokens is changed. + /// So that the third-party platforms such as NFT market could + /// timely update the images and related attributes of the NFTs. + event BatchMetadataUpdate(uint256 _fromTokenId, uint256 _toTokenId); +} diff --git a/contracts/eip/interface/IERC721.sol b/contracts/eip/interface/IERC721.sol new file mode 100644 index 000000000..6bc14e70d --- /dev/null +++ b/contracts/eip/interface/IERC721.sol @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts v4.4.1 (token/ERC721/IERC721.sol) + +pragma solidity ^0.8.0; + +/** + * @dev Required interface of an ERC721 compliant contract. + */ +interface IERC721 { + /** + * @dev Emitted when `tokenId` token is transferred from `from` to `to`. + */ + event Transfer(address indexed from, address indexed to, uint256 indexed tokenId); + + /** + * @dev Emitted when `owner` enables `approved` to manage the `tokenId` token. + */ + event Approval(address indexed owner, address indexed approved, uint256 indexed tokenId); + + /** + * @dev Emitted when `owner` enables or disables (`approved`) `operator` to manage all of its assets. + */ + event ApprovalForAll(address indexed owner, address indexed operator, bool approved); + + /** + * @dev Returns the number of tokens in ``owner``'s account. + */ + function balanceOf(address owner) external view returns (uint256); + + /** + * @dev Returns the owner of the `tokenId` token. + * + * Requirements: + * + * - `tokenId` must exist. + */ + function ownerOf(uint256 tokenId) external view returns (address); + + /** + * @dev Safely transfers `tokenId` token from `from` to `to`, checking first that contract recipients + * are aware of the ERC721 protocol to prevent tokens from being forever locked. + * + * Requirements: + * + * - `from` cannot be the zero address. + * - `to` cannot be the zero address. + * - `tokenId` token must exist and be owned by `from`. + * - If the caller is not `from`, it must be have been allowed to move this token by either {approve} or {setApprovalForAll}. + * - If `to` refers to a smart contract, it must implement {IERC721Receiver-onERC721Received}, which is called upon a safe transfer. + * + * Emits a {Transfer} event. + */ + function safeTransferFrom(address from, address to, uint256 tokenId) external; + + /** + * @dev Transfers `tokenId` token from `from` to `to`. + * + * WARNING: Usage of this method is discouraged, use {safeTransferFrom} whenever possible. + * + * Requirements: + * + * - `from` cannot be the zero address. + * - `to` cannot be the zero address. + * - `tokenId` token must be owned by `from`. + * - If the caller is not `from`, it must be approved to move this token by either {approve} or {setApprovalForAll}. + * + * Emits a {Transfer} event. + */ + function transferFrom(address from, address to, uint256 tokenId) external; + + /** + * @dev Gives permission to `to` to transfer `tokenId` token to another account. + * The approval is cleared when the token is transferred. + * + * Only a single account can be approved at a time, so approving the zero address clears previous approvals. + * + * Requirements: + * + * - The caller must own the token or be an approved operator. + * - `tokenId` must exist. + * + * Emits an {Approval} event. + */ + function approve(address to, uint256 tokenId) external; + + /** + * @dev Returns the account approved for `tokenId` token. + * + * Requirements: + * + * - `tokenId` must exist. + */ + function getApproved(uint256 tokenId) external view returns (address); + + /** + * @dev Approve or remove `operator` as an operator for the caller. + * Operators can call {transferFrom} or {safeTransferFrom} for any token owned by the caller. + * + * Requirements: + * + * - The `operator` cannot be the caller. + * + * Emits an {ApprovalForAll} event. + */ + function setApprovalForAll(address operator, bool _approved) external; + + /** + * @dev Returns if the `operator` is allowed to manage all of the assets of `owner`. + * + * See {setApprovalForAll} + */ + function isApprovedForAll(address owner, address operator) external view returns (bool); + + /** + * @dev Safely transfers `tokenId` token from `from` to `to`. + * + * Requirements: + * + * - `from` cannot be the zero address. + * - `to` cannot be the zero address. + * - `tokenId` token must exist and be owned by `from`. + * - If the caller is not `from`, it must be approved to move this token by either {approve} or {setApprovalForAll}. + * - If `to` refers to a smart contract, it must implement {IERC721Receiver-onERC721Received}, which is called upon a safe transfer. + * + * Emits a {Transfer} event. + */ + function safeTransferFrom(address from, address to, uint256 tokenId, bytes calldata data) external; +} diff --git a/contracts/eip/interface/IERC721A.sol b/contracts/eip/interface/IERC721A.sol new file mode 100644 index 000000000..2b788245e --- /dev/null +++ b/contracts/eip/interface/IERC721A.sol @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: MIT +// ERC721A Contracts v3.3.0 +// Creator: Chiru Labs + +pragma solidity ^0.8.4; + +import "./IERC721.sol"; +import "./IERC721Metadata.sol"; + +/** + * @dev Interface of an ERC721A compliant contract. + */ +interface IERC721A is IERC721, IERC721Metadata { + /** + * The caller must own the token or be an approved operator. + */ + error ApprovalCallerNotOwnerNorApproved(); + + /** + * The token does not exist. + */ + error ApprovalQueryForNonexistentToken(); + + /** + * The caller cannot approve to their own address. + */ + error ApproveToCaller(); + + /** + * The caller cannot approve to the current owner. + */ + error ApprovalToCurrentOwner(); + + /** + * Cannot query the balance for the zero address. + */ + error BalanceQueryForZeroAddress(); + + /** + * Cannot mint to the zero address. + */ + error MintToZeroAddress(); + + /** + * The quantity of tokens minted must be more than zero. + */ + error MintZeroQuantity(); + + /** + * The token does not exist. + */ + error OwnerQueryForNonexistentToken(); + + /** + * The caller must own the token or be an approved operator. + */ + error TransferCallerNotOwnerNorApproved(); + + /** + * The token must be owned by `from`. + */ + error TransferFromIncorrectOwner(); + + /** + * Cannot safely transfer to a contract that does not implement the ERC721Receiver interface. + */ + error TransferToNonERC721ReceiverImplementer(); + + /** + * Cannot transfer to the zero address. + */ + error TransferToZeroAddress(); + + /** + * The token does not exist. + */ + error URIQueryForNonexistentToken(); + + // Compiler will pack this into a single 256bit word. + struct TokenOwnership { + // The address of the owner. + address addr; + // Keeps track of the start time of ownership with minimal overhead for tokenomics. + uint64 startTimestamp; + // Whether the token has been burned. + bool burned; + } + + // Compiler will pack this into a single 256bit word. + struct AddressData { + // Realistically, 2**64-1 is more than enough. + uint64 balance; + // Keeps track of mint count with minimal overhead for tokenomics. + uint64 numberMinted; + // Keeps track of burn count with minimal overhead for tokenomics. + uint64 numberBurned; + // For miscellaneous variable(s) pertaining to the address + // (e.g. number of whitelist mint slots used). + // If there are multiple variables, please pack them into a uint64. + uint64 aux; + } + + /** + * @dev Returns the total amount of tokens stored by the contract. + * + * Burned tokens are calculated here, use `_totalMinted()` if you want to count just minted tokens. + */ + function totalSupply() external view returns (uint256); +} diff --git a/contracts/eip/interface/IERC721Enumerable.sol b/contracts/eip/interface/IERC721Enumerable.sol new file mode 100644 index 000000000..0112a7f9e --- /dev/null +++ b/contracts/eip/interface/IERC721Enumerable.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @title ERC-721 Non-Fungible Token Standard, optional enumeration extension +/// @dev See https://eips.ethereum.org/EIPS/eip-721 +/// Note: the ERC-165 identifier for this interface is 0x780e9d63. +/* is ERC721 */ +interface IERC721Enumerable { + /// @notice Enumerate valid NFTs + /// @dev Throws if `_index` >= `totalSupply()`. + /// @param _index A counter less than `totalSupply()` + /// @return The token identifier for the `_index`th NFT, + /// (sort order not specified) + function tokenByIndex(uint256 _index) external view returns (uint256); + + /// @notice Enumerate NFTs assigned to an owner + /// @dev Throws if `_index` >= `balanceOf(_owner)` or if + /// `_owner` is the zero address, representing invalid NFTs. + /// @param _owner An address where we are interested in NFTs owned by them + /// @param _index A counter less than `balanceOf(_owner)` + /// @return The token identifier for the `_index`th NFT assigned to `_owner`, + /// (sort order not specified) + function tokenOfOwnerByIndex(address _owner, uint256 _index) external view returns (uint256); +} diff --git a/contracts/eip/interface/IERC721Metadata.sol b/contracts/eip/interface/IERC721Metadata.sol new file mode 100644 index 000000000..cca364443 --- /dev/null +++ b/contracts/eip/interface/IERC721Metadata.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @title ERC-721 Non-Fungible Token Standard, optional metadata extension +/// @dev See https://eips.ethereum.org/EIPS/eip-721 +/// Note: the ERC-165 identifier for this interface is 0x5b5e139f. +/* is ERC721 */ +interface IERC721Metadata { + /// @notice A descriptive name for a collection of NFTs in this contract + function name() external view returns (string memory); + + /// @notice An abbreviated name for NFTs in this contract + function symbol() external view returns (string memory); + + /// @notice A distinct Uniform Resource Identifier (URI) for a given asset. + /// @dev Throws if `_tokenId` is not a valid NFT. URIs are defined in RFC + /// 3986. The URI may point to a JSON file that conforms to the "ERC721 + /// Metadata JSON Schema". + function tokenURI(uint256 _tokenId) external view returns (string memory); +} diff --git a/contracts/eip/interface/IERC721Receiver.sol b/contracts/eip/interface/IERC721Receiver.sol new file mode 100644 index 000000000..a42cb52ff --- /dev/null +++ b/contracts/eip/interface/IERC721Receiver.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts v4.4.1 (token/ERC721/IERC721Receiver.sol) + +pragma solidity ^0.8.0; + +/** + * @title ERC721 token receiver interface + * @dev Interface for any contract that wants to support safeTransfers + * from ERC721 asset contracts. + */ +interface IERC721Receiver { + /** + * @dev Whenever an {IERC721} `tokenId` token is transferred to this contract via {IERC721-safeTransferFrom} + * by `operator` from `from`, this function is called. + * + * It must return its Solidity selector to confirm the token transfer. + * If any other value is returned or the interface is not implemented by the recipient, the transfer will be reverted. + * + * The selector can be obtained in Solidity with `IERC721.onERC721Received.selector`. + */ + function onERC721Received( + address operator, + address from, + uint256 tokenId, + bytes calldata data + ) external returns (bytes4); +} diff --git a/contracts/eip/interface/IERC721Supply.sol b/contracts/eip/interface/IERC721Supply.sol new file mode 100644 index 000000000..c640242df --- /dev/null +++ b/contracts/eip/interface/IERC721Supply.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @title ERC-721 Non-Fungible Token Standard, optional supplu extension +/// @dev See https://eips.ethereum.org/EIPS/eip-721 +/// Note: the ERC-165 identifier for this interface is 0x780e9d63. +/* is ERC721 */ +interface IERC721Supply { + /// @notice Count NFTs tracked by this contract + /// @return A count of valid NFTs tracked by this contract, where each one of + /// them has an assigned and queryable owner not equal to the zero address + function totalSupply() external view returns (uint256); +} diff --git a/contracts/eip/queryable/ERC721AQueryable.sol b/contracts/eip/queryable/ERC721AQueryable.sol new file mode 100644 index 000000000..a5026b487 --- /dev/null +++ b/contracts/eip/queryable/ERC721AQueryable.sol @@ -0,0 +1,168 @@ +// SPDX-License-Identifier: MIT +// ERC721A Contracts v3.3.0 +// Creator: Chiru Labs + +pragma solidity ^0.8.4; + +import "./IERC721AQueryable.sol"; +import "../ERC721AVirtualApprove.sol"; + +/** + * @title ERC721A Queryable + * @dev ERC721A subclass with convenience query functions. + */ +abstract contract ERC721AQueryable is ERC721A, IERC721AQueryable { + /** + * @dev Returns the `TokenOwnership` struct at `tokenId` without reverting. + * + * If the `tokenId` is out of bounds: + * - `addr` = `address(0)` + * - `startTimestamp` = `0` + * - `burned` = `false` + * + * If the `tokenId` is burned: + * - `addr` = `
` + * - `startTimestamp` = `` + * - `burned = `true` + * + * Otherwise: + * - `addr` = `
` + * - `startTimestamp` = `` + * - `burned = `false` + */ + function explicitOwnershipOf(uint256 tokenId) public view override returns (TokenOwnership memory) { + TokenOwnership memory ownership; + if (tokenId < _startTokenId() || tokenId >= _currentIndex) { + return ownership; + } + ownership = _ownerships[tokenId]; + if (ownership.burned) { + return ownership; + } + return _ownershipOf(tokenId); + } + + /** + * @dev Returns an array of `TokenOwnership` structs at `tokenIds` in order. + * See {ERC721AQueryable-explicitOwnershipOf} + */ + function explicitOwnershipsOf(uint256[] memory tokenIds) external view override returns (TokenOwnership[] memory) { + unchecked { + uint256 tokenIdsLength = tokenIds.length; + TokenOwnership[] memory ownerships = new TokenOwnership[](tokenIdsLength); + for (uint256 i; i != tokenIdsLength; ++i) { + ownerships[i] = explicitOwnershipOf(tokenIds[i]); + } + return ownerships; + } + } + + /** + * @dev Returns an array of token IDs owned by `owner`, + * in the range [`start`, `stop`) + * (i.e. `start <= tokenId < stop`). + * + * This function allows for tokens to be queried if the collection + * grows too big for a single call of {ERC721AQueryable-tokensOfOwner}. + * + * Requirements: + * + * - `start` < `stop` + */ + /* solhint-disable*/ + function tokensOfOwnerIn( + address owner, + uint256 start, + uint256 stop + ) external view override returns (uint256[] memory) { + unchecked { + if (start >= stop) revert InvalidQueryRange(); + uint256 tokenIdsIdx; + uint256 stopLimit = _currentIndex; + // Set `start = max(start, _startTokenId())`. + if (start < _startTokenId()) { + start = _startTokenId(); + } + // Set `stop = min(stop, _currentIndex)`. + if (stop > stopLimit) { + stop = stopLimit; + } + uint256 tokenIdsMaxLength = balanceOf(owner); + // Set `tokenIdsMaxLength = min(balanceOf(owner), stop - start)`, + // to cater for cases where `balanceOf(owner)` is too big. + if (start < stop) { + uint256 rangeLength = stop - start; + if (rangeLength < tokenIdsMaxLength) { + tokenIdsMaxLength = rangeLength; + } + } else { + tokenIdsMaxLength = 0; + } + uint256[] memory tokenIds = new uint256[](tokenIdsMaxLength); + if (tokenIdsMaxLength == 0) { + return tokenIds; + } + // We need to call `explicitOwnershipOf(start)`, + // because the slot at `start` may not be initialized. + TokenOwnership memory ownership = explicitOwnershipOf(start); + address currOwnershipAddr; + // If the starting slot exists (i.e. not burned), initialize `currOwnershipAddr`. + // `ownership.address` will not be zero, as `start` is clamped to the valid token ID range. + if (!ownership.burned) { + currOwnershipAddr = ownership.addr; + } + for (uint256 i = start; i != stop && tokenIdsIdx != tokenIdsMaxLength; ++i) { + ownership = _ownerships[i]; + if (ownership.burned) { + continue; + } + if (ownership.addr != address(0)) { + currOwnershipAddr = ownership.addr; + } + if (currOwnershipAddr == owner) { + tokenIds[tokenIdsIdx++] = i; + } + } + // Downsize the array to fit. + assembly { + mstore(tokenIds, tokenIdsIdx) + } + return tokenIds; + } + } + + /* solhint-enable */ + + /** + * @dev Returns an array of token IDs owned by `owner`. + * + * This function scans the ownership mapping and is O(totalSupply) in complexity. + * It is meant to be called off-chain. + * + * See {ERC721AQueryable-tokensOfOwnerIn} for splitting the scan into + * multiple smaller scans if the collection is large enough to cause + * an out-of-gas error (10K pfp collections should be fine). + */ + function tokensOfOwner(address owner) external view override returns (uint256[] memory) { + unchecked { + uint256 tokenIdsIdx; + address currOwnershipAddr; + uint256 tokenIdsLength = balanceOf(owner); + uint256[] memory tokenIds = new uint256[](tokenIdsLength); + TokenOwnership memory ownership; + for (uint256 i = _startTokenId(); tokenIdsIdx != tokenIdsLength; ++i) { + ownership = _ownerships[i]; + if (ownership.burned) { + continue; + } + if (ownership.addr != address(0)) { + currOwnershipAddr = ownership.addr; + } + if (currOwnershipAddr == owner) { + tokenIds[tokenIdsIdx++] = i; + } + } + return tokenIds; + } + } +} diff --git a/contracts/eip/queryable/ERC721AQueryableUpgradeable.sol b/contracts/eip/queryable/ERC721AQueryableUpgradeable.sol new file mode 100644 index 000000000..9eec8f7b6 --- /dev/null +++ b/contracts/eip/queryable/ERC721AQueryableUpgradeable.sol @@ -0,0 +1,193 @@ +// SPDX-License-Identifier: MIT +// ERC721A Contracts v4.2.3 +// Creator: Chiru Labs + +pragma solidity ^0.8.4; + +import "./IERC721AQueryableUpgradeable.sol"; +import "./ERC721AUpgradeable.sol"; +import "./ERC721A__Initializable.sol"; + +/** + * @title ERC721AQueryable. + * + * @dev ERC721A subclass with convenience query functions. + */ +abstract contract ERC721AQueryableUpgradeable is + ERC721A__Initializable, + ERC721AUpgradeable, + IERC721AQueryableUpgradeable +{ + function __ERC721AQueryable_init() internal onlyInitializingERC721A { + __ERC721AQueryable_init_unchained(); + } + + function __ERC721AQueryable_init_unchained() internal onlyInitializingERC721A {} + + /** + * @dev Returns the `TokenOwnership` struct at `tokenId` without reverting. + * + * If the `tokenId` is out of bounds: + * + * - `addr = address(0)` + * - `startTimestamp = 0` + * - `burned = false` + * - `extraData = 0` + * + * If the `tokenId` is burned: + * + * - `addr =
` + * - `startTimestamp = ` + * - `burned = true` + * - `extraData = ` + * + * Otherwise: + * + * - `addr =
` + * - `startTimestamp = ` + * - `burned = false` + * - `extraData = ` + */ + function explicitOwnershipOf( + uint256 tokenId + ) public view virtual override returns (TokenOwnership memory ownership) { + unchecked { + if (tokenId >= _startTokenId()) { + if (tokenId < _nextTokenId()) { + // If the `tokenId` is within bounds, + // scan backwards for the initialized ownership slot. + while (!_ownershipIsInitialized(tokenId)) --tokenId; + return _ownershipAt(tokenId); + } + } + } + } + + /** + * @dev Returns an array of token IDs owned by `owner`, + * in the range [`start`, `stop`) + * (i.e. `start <= tokenId < stop`). + * + * This function allows for tokens to be queried if the collection + * grows too big for a single call of {ERC721AQueryable-tokensOfOwner}. + * + * Requirements: + * + * - `start < stop` + */ + function tokensOfOwnerIn( + address owner, + uint256 start, + uint256 stop + ) external view virtual override returns (uint256[] memory) { + return _tokensOfOwnerIn(owner, start, stop); + } + + /** + * @dev Returns an array of token IDs owned by `owner`. + * + * This function scans the ownership mapping and is O(`totalSupply`) in complexity. + * It is meant to be called off-chain. + * + * See {ERC721AQueryable-tokensOfOwnerIn} for splitting the scan into + * multiple smaller scans if the collection is large enough to cause + * an out-of-gas error (10K collections should be fine). + */ + function tokensOfOwner(address owner) external view virtual override returns (uint256[] memory) { + uint256 start = _startTokenId(); + uint256 stop = _nextTokenId(); + uint256[] memory tokenIds; + if (start != stop) tokenIds = _tokensOfOwnerIn(owner, start, stop); + return tokenIds; + } + + /** + * @dev Helper function for returning an array of token IDs owned by `owner`. + * + * Note that this function is optimized for smaller bytecode size over runtime gas, + * since it is meant to be called off-chain. + */ + function _tokensOfOwnerIn(address owner, uint256 start, uint256 stop) private view returns (uint256[] memory) { + unchecked { + if (start >= stop) _revert(InvalidQueryRange.selector); + // Set `start = max(start, _startTokenId())`. + if (start < _startTokenId()) { + start = _startTokenId(); + } + uint256 stopLimit = _nextTokenId(); + // Set `stop = min(stop, stopLimit)`. + if (stop >= stopLimit) { + stop = stopLimit; + } + uint256[] memory tokenIds; + uint256 tokenIdsMaxLength = balanceOf(owner); + bool startLtStop = start < stop; + assembly { + // Set `tokenIdsMaxLength` to zero if `start` is less than `stop`. + tokenIdsMaxLength := mul(tokenIdsMaxLength, startLtStop) + } + if (tokenIdsMaxLength != 0) { + // Set `tokenIdsMaxLength = min(balanceOf(owner), stop - start)`, + // to cater for cases where `balanceOf(owner)` is too big. + if (stop - start <= tokenIdsMaxLength) { + tokenIdsMaxLength = stop - start; + } + assembly { + // Grab the free memory pointer. + tokenIds := mload(0x40) + // Allocate one word for the length, and `tokenIdsMaxLength` words + // for the data. `shl(5, x)` is equivalent to `mul(32, x)`. + mstore(0x40, add(tokenIds, shl(5, add(tokenIdsMaxLength, 1)))) + } + // We need to call `explicitOwnershipOf(start)`, + // because the slot at `start` may not be initialized. + TokenOwnership memory ownership = explicitOwnershipOf(start); + address currOwnershipAddr; + // If the starting slot exists (i.e. not burned), + // initialize `currOwnershipAddr`. + // `ownership.address` will not be zero, + // as `start` is clamped to the valid token ID range. + if (!ownership.burned) { + currOwnershipAddr = ownership.addr; + } + uint256 tokenIdsIdx; + // Use a do-while, which is slightly more efficient for this case, + // as the array will at least contain one element. + do { + ownership = _ownershipAt(start); + assembly { + switch mload(add(ownership, 0x40)) + // if `ownership.burned == false`. + case 0 { + // if `ownership.addr != address(0)`. + // The `addr` already has it's upper 96 bits clearned, + // since it is written to memory with regular Solidity. + if mload(ownership) { + currOwnershipAddr := mload(ownership) + } + // if `currOwnershipAddr == owner`. + // The `shl(96, x)` is to make the comparison agnostic to any + // dirty upper 96 bits in `owner`. + if iszero(shl(96, xor(currOwnershipAddr, owner))) { + tokenIdsIdx := add(tokenIdsIdx, 1) + mstore(add(tokenIds, shl(5, tokenIdsIdx)), start) + } + } + // Otherwise, reset `currOwnershipAddr`. + // This handles the case of batch burned tokens + // (burned bit of first slot set, remaining slots left uninitialized). + default { + currOwnershipAddr := 0 + } + start := add(start, 1) + } + } while (!(start == stop || tokenIdsIdx == tokenIdsMaxLength)); + // Store the length of the array. + assembly { + mstore(tokenIds, tokenIdsIdx) + } + } + return tokenIds; + } + } +} diff --git a/contracts/eip/queryable/ERC721AStorage.sol b/contracts/eip/queryable/ERC721AStorage.sol new file mode 100644 index 000000000..d9f5c12cd --- /dev/null +++ b/contracts/eip/queryable/ERC721AStorage.sol @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +library ERC721AStorage { + // Bypass for a `--via-ir` bug (https://github.com/chiru-labs/ERC721A/pull/364). + struct TokenApprovalRef { + address value; + } + + struct Layout { + // ============================================================= + // STORAGE + // ============================================================= + + // The next token ID to be minted. + uint256 _currentIndex; + // The number of tokens burned. + uint256 _burnCounter; + // Token name + string _name; + // Token symbol + string _symbol; + // Mapping from token ID to ownership details + // An empty struct value does not necessarily mean the token is unowned. + // See {_packedOwnershipOf} implementation for details. + // + // Bits Layout: + // - [0..159] `addr` + // - [160..223] `startTimestamp` + // - [224] `burned` + // - [225] `nextInitialized` + // - [232..255] `extraData` + mapping(uint256 => uint256) _packedOwnerships; + // Mapping owner address to address data. + // + // Bits Layout: + // - [0..63] `balance` + // - [64..127] `numberMinted` + // - [128..191] `numberBurned` + // - [192..255] `aux` + mapping(address => uint256) _packedAddressData; + // Mapping from token ID to approved address. + mapping(uint256 => ERC721AStorage.TokenApprovalRef) _tokenApprovals; + // Mapping from owner to operator approvals + mapping(address => mapping(address => bool)) _operatorApprovals; + } + + bytes32 internal constant STORAGE_SLOT = keccak256("ERC721A.contracts.storage.ERC721A"); + + function layout() internal pure returns (Layout storage l) { + bytes32 slot = STORAGE_SLOT; + assembly { + l.slot := slot + } + } +} diff --git a/contracts/eip/queryable/ERC721AUpgradeable.sol b/contracts/eip/queryable/ERC721AUpgradeable.sol new file mode 100644 index 000000000..af8bb4204 --- /dev/null +++ b/contracts/eip/queryable/ERC721AUpgradeable.sol @@ -0,0 +1,1075 @@ +// SPDX-License-Identifier: MIT +// ERC721A Contracts v4.2.3 +// Creator: Chiru Labs + +pragma solidity ^0.8.4; + +import "./IERC721AUpgradeable.sol"; +import { ERC721AStorage } from "./ERC721AStorage.sol"; +import "./ERC721A__Initializable.sol"; + +/** + * @dev Interface of ERC721 token receiver. + */ +interface ERC721A__IERC721ReceiverUpgradeable { + function onERC721Received( + address operator, + address from, + uint256 tokenId, + bytes calldata data + ) external returns (bytes4); +} + +/** + * @title ERC721A + * + * @dev Implementation of the [ERC721](https://eips.ethereum.org/EIPS/eip-721) + * Non-Fungible Token Standard, including the Metadata extension. + * Optimized for lower gas during batch mints. + * + * Token IDs are minted in sequential order (e.g. 0, 1, 2, 3, ...) + * starting from `_startTokenId()`. + * + * Assumptions: + * + * - An owner cannot have more than 2**64 - 1 (max value of uint64) of supply. + * - The maximum token ID cannot exceed 2**256 - 1 (max value of uint256). + */ +contract ERC721AUpgradeable is ERC721A__Initializable, IERC721AUpgradeable { + using ERC721AStorage for ERC721AStorage.Layout; + + // ============================================================= + // CONSTANTS + // ============================================================= + + // Mask of an entry in packed address data. + uint256 private constant _BITMASK_ADDRESS_DATA_ENTRY = (1 << 64) - 1; + + // The bit position of `numberMinted` in packed address data. + uint256 private constant _BITPOS_NUMBER_MINTED = 64; + + // The bit position of `numberBurned` in packed address data. + uint256 private constant _BITPOS_NUMBER_BURNED = 128; + + // The bit position of `aux` in packed address data. + uint256 private constant _BITPOS_AUX = 192; + + // Mask of all 256 bits in packed address data except the 64 bits for `aux`. + uint256 private constant _BITMASK_AUX_COMPLEMENT = (1 << 192) - 1; + + // The bit position of `startTimestamp` in packed ownership. + uint256 private constant _BITPOS_START_TIMESTAMP = 160; + + // The bit mask of the `burned` bit in packed ownership. + uint256 private constant _BITMASK_BURNED = 1 << 224; + + // The bit position of the `nextInitialized` bit in packed ownership. + uint256 private constant _BITPOS_NEXT_INITIALIZED = 225; + + // The bit mask of the `nextInitialized` bit in packed ownership. + uint256 private constant _BITMASK_NEXT_INITIALIZED = 1 << 225; + + // The bit position of `extraData` in packed ownership. + uint256 private constant _BITPOS_EXTRA_DATA = 232; + + // Mask of all 256 bits in a packed ownership except the 24 bits for `extraData`. + uint256 private constant _BITMASK_EXTRA_DATA_COMPLEMENT = (1 << 232) - 1; + + // The mask of the lower 160 bits for addresses. + uint256 private constant _BITMASK_ADDRESS = (1 << 160) - 1; + + // The maximum `quantity` that can be minted with {_mintERC2309}. + // This limit is to prevent overflows on the address data entries. + // For a limit of 5000, a total of 3.689e15 calls to {_mintERC2309} + // is required to cause an overflow, which is unrealistic. + uint256 private constant _MAX_MINT_ERC2309_QUANTITY_LIMIT = 5000; + + // The `Transfer` event signature is given by: + // `keccak256(bytes("Transfer(address,address,uint256)"))`. + bytes32 private constant _TRANSFER_EVENT_SIGNATURE = + 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef; + + // ============================================================= + // CONSTRUCTOR + // ============================================================= + + function __ERC721A_init(string memory name_, string memory symbol_) internal onlyInitializingERC721A { + __ERC721A_init_unchained(name_, symbol_); + } + + function __ERC721A_init_unchained(string memory name_, string memory symbol_) internal onlyInitializingERC721A { + ERC721AStorage.layout()._name = name_; + ERC721AStorage.layout()._symbol = symbol_; + ERC721AStorage.layout()._currentIndex = _startTokenId(); + } + + // ============================================================= + // TOKEN COUNTING OPERATIONS + // ============================================================= + + /** + * @dev Returns the starting token ID. + * To change the starting token ID, please override this function. + */ + function _startTokenId() internal view virtual returns (uint256) { + return 0; + } + + /** + * @dev Returns the next token ID to be minted. + */ + function _nextTokenId() internal view virtual returns (uint256) { + return ERC721AStorage.layout()._currentIndex; + } + + /** + * @dev Returns the total number of tokens in existence. + * Burned tokens will reduce the count. + * To get the total number of tokens minted, please see {_totalMinted}. + */ + function totalSupply() public view virtual override returns (uint256) { + // Counter underflow is impossible as _burnCounter cannot be incremented + // more than `_currentIndex - _startTokenId()` times. + unchecked { + return ERC721AStorage.layout()._currentIndex - ERC721AStorage.layout()._burnCounter - _startTokenId(); + } + } + + /** + * @dev Returns the total amount of tokens minted in the contract. + */ + function _totalMinted() internal view virtual returns (uint256) { + // Counter underflow is impossible as `_currentIndex` does not decrement, + // and it is initialized to `_startTokenId()`. + unchecked { + return ERC721AStorage.layout()._currentIndex - _startTokenId(); + } + } + + /** + * @dev Returns the total number of tokens burned. + */ + function _totalBurned() internal view virtual returns (uint256) { + return ERC721AStorage.layout()._burnCounter; + } + + // ============================================================= + // ADDRESS DATA OPERATIONS + // ============================================================= + + /** + * @dev Returns the number of tokens in `owner`'s account. + */ + function balanceOf(address owner) public view virtual override returns (uint256) { + if (owner == address(0)) _revert(BalanceQueryForZeroAddress.selector); + return ERC721AStorage.layout()._packedAddressData[owner] & _BITMASK_ADDRESS_DATA_ENTRY; + } + + /** + * Returns the number of tokens minted by `owner`. + */ + function _numberMinted(address owner) internal view returns (uint256) { + return + (ERC721AStorage.layout()._packedAddressData[owner] >> _BITPOS_NUMBER_MINTED) & _BITMASK_ADDRESS_DATA_ENTRY; + } + + /** + * Returns the number of tokens burned by or on behalf of `owner`. + */ + function _numberBurned(address owner) internal view returns (uint256) { + return + (ERC721AStorage.layout()._packedAddressData[owner] >> _BITPOS_NUMBER_BURNED) & _BITMASK_ADDRESS_DATA_ENTRY; + } + + /** + * Returns the auxiliary data for `owner`. (e.g. number of whitelist mint slots used). + */ + function _getAux(address owner) internal view returns (uint64) { + return uint64(ERC721AStorage.layout()._packedAddressData[owner] >> _BITPOS_AUX); + } + + /** + * Sets the auxiliary data for `owner`. (e.g. number of whitelist mint slots used). + * If there are multiple variables, please pack them into a uint64. + */ + function _setAux(address owner, uint64 aux) internal virtual { + uint256 packed = ERC721AStorage.layout()._packedAddressData[owner]; + uint256 auxCasted; + // Cast `aux` with assembly to avoid redundant masking. + assembly { + auxCasted := aux + } + packed = (packed & _BITMASK_AUX_COMPLEMENT) | (auxCasted << _BITPOS_AUX); + ERC721AStorage.layout()._packedAddressData[owner] = packed; + } + + // ============================================================= + // IERC165 + // ============================================================= + + /** + * @dev Returns true if this contract implements the interface defined by + * `interfaceId`. See the corresponding + * [EIP section](https://eips.ethereum.org/EIPS/eip-165#how-interfaces-are-identified) + * to learn more about how these ids are created. + * + * This function call must use less than 30000 gas. + */ + function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) { + // The interface IDs are constants representing the first 4 bytes + // of the XOR of all function selectors in the interface. + // See: [ERC165](https://eips.ethereum.org/EIPS/eip-165) + // (e.g. `bytes4(i.functionA.selector ^ i.functionB.selector ^ ...)`) + return + interfaceId == 0x01ffc9a7 || // ERC165 interface ID for ERC165. + interfaceId == 0x80ac58cd || // ERC165 interface ID for ERC721. + interfaceId == 0x5b5e139f; // ERC165 interface ID for ERC721Metadata. + } + + // ============================================================= + // IERC721Metadata + // ============================================================= + + /** + * @dev Returns the token collection name. + */ + function name() public view virtual override returns (string memory) { + return ERC721AStorage.layout()._name; + } + + /** + * @dev Returns the token collection symbol. + */ + function symbol() public view virtual override returns (string memory) { + return ERC721AStorage.layout()._symbol; + } + + /** + * @dev Returns the Uniform Resource Identifier (URI) for `tokenId` token. + */ + function tokenURI(uint256 tokenId) public view virtual override returns (string memory) { + if (!_exists(tokenId)) _revert(URIQueryForNonexistentToken.selector); + + string memory baseURI = _baseURI(); + return bytes(baseURI).length != 0 ? string(abi.encodePacked(baseURI, _toString(tokenId))) : ""; + } + + /** + * @dev Base URI for computing {tokenURI}. If set, the resulting URI for each + * token will be the concatenation of the `baseURI` and the `tokenId`. Empty + * by default, it can be overridden in child contracts. + */ + function _baseURI() internal view virtual returns (string memory) { + return ""; + } + + // ============================================================= + // OWNERSHIPS OPERATIONS + // ============================================================= + + /** + * @dev Returns the owner of the `tokenId` token. + * + * Requirements: + * + * - `tokenId` must exist. + */ + function ownerOf(uint256 tokenId) public view virtual override returns (address) { + return address(uint160(_packedOwnershipOf(tokenId))); + } + + /** + * @dev Gas spent here starts off proportional to the maximum mint batch size. + * It gradually moves to O(1) as tokens get transferred around over time. + */ + function _ownershipOf(uint256 tokenId) internal view virtual returns (TokenOwnership memory) { + return _unpackedOwnership(_packedOwnershipOf(tokenId)); + } + + /** + * @dev Returns the unpacked `TokenOwnership` struct at `index`. + */ + function _ownershipAt(uint256 index) internal view virtual returns (TokenOwnership memory) { + return _unpackedOwnership(ERC721AStorage.layout()._packedOwnerships[index]); + } + + /** + * @dev Returns whether the ownership slot at `index` is initialized. + * An uninitialized slot does not necessarily mean that the slot has no owner. + */ + function _ownershipIsInitialized(uint256 index) internal view virtual returns (bool) { + return ERC721AStorage.layout()._packedOwnerships[index] != 0; + } + + /** + * @dev Initializes the ownership slot minted at `index` for efficiency purposes. + */ + function _initializeOwnershipAt(uint256 index) internal virtual { + if (ERC721AStorage.layout()._packedOwnerships[index] == 0) { + ERC721AStorage.layout()._packedOwnerships[index] = _packedOwnershipOf(index); + } + } + + /** + * Returns the packed ownership data of `tokenId`. + */ + function _packedOwnershipOf(uint256 tokenId) private view returns (uint256 packed) { + if (_startTokenId() <= tokenId) { + packed = ERC721AStorage.layout()._packedOwnerships[tokenId]; + // If the data at the starting slot does not exist, start the scan. + if (packed == 0) { + if (tokenId >= ERC721AStorage.layout()._currentIndex) _revert(OwnerQueryForNonexistentToken.selector); + // Invariant: + // There will always be an initialized ownership slot + // (i.e. `ownership.addr != address(0) && ownership.burned == false`) + // before an unintialized ownership slot + // (i.e. `ownership.addr == address(0) && ownership.burned == false`) + // Hence, `tokenId` will not underflow. + // + // We can directly compare the packed value. + // If the address is zero, packed will be zero. + for (;;) { + unchecked { + packed = ERC721AStorage.layout()._packedOwnerships[--tokenId]; + } + if (packed == 0) continue; + if (packed & _BITMASK_BURNED == 0) return packed; + // Otherwise, the token is burned, and we must revert. + // This handles the case of batch burned tokens, where only the burned bit + // of the starting slot is set, and remaining slots are left uninitialized. + _revert(OwnerQueryForNonexistentToken.selector); + } + } + // Otherwise, the data exists and we can skip the scan. + // This is possible because we have already achieved the target condition. + // This saves 2143 gas on transfers of initialized tokens. + // If the token is not burned, return `packed`. Otherwise, revert. + if (packed & _BITMASK_BURNED == 0) return packed; + } + _revert(OwnerQueryForNonexistentToken.selector); + } + + /** + * @dev Returns the unpacked `TokenOwnership` struct from `packed`. + */ + function _unpackedOwnership(uint256 packed) private pure returns (TokenOwnership memory ownership) { + ownership.addr = address(uint160(packed)); + ownership.startTimestamp = uint64(packed >> _BITPOS_START_TIMESTAMP); + ownership.burned = packed & _BITMASK_BURNED != 0; + ownership.extraData = uint24(packed >> _BITPOS_EXTRA_DATA); + } + + /** + * @dev Packs ownership data into a single uint256. + */ + function _packOwnershipData(address owner, uint256 flags) private view returns (uint256 result) { + assembly { + // Mask `owner` to the lower 160 bits, in case the upper bits somehow aren't clean. + owner := and(owner, _BITMASK_ADDRESS) + // `owner | (block.timestamp << _BITPOS_START_TIMESTAMP) | flags`. + result := or(owner, or(shl(_BITPOS_START_TIMESTAMP, timestamp()), flags)) + } + } + + /** + * @dev Returns the `nextInitialized` flag set if `quantity` equals 1. + */ + function _nextInitializedFlag(uint256 quantity) private pure returns (uint256 result) { + // For branchless setting of the `nextInitialized` flag. + assembly { + // `(quantity == 1) << _BITPOS_NEXT_INITIALIZED`. + result := shl(_BITPOS_NEXT_INITIALIZED, eq(quantity, 1)) + } + } + + // ============================================================= + // APPROVAL OPERATIONS + // ============================================================= + + /** + * @dev Gives permission to `to` to transfer `tokenId` token to another account. See {ERC721A-_approve}. + * + * Requirements: + * + * - The caller must own the token or be an approved operator. + */ + function approve(address to, uint256 tokenId) public payable virtual override { + _approve(to, tokenId, true); + } + + /** + * @dev Returns the account approved for `tokenId` token. + * + * Requirements: + * + * - `tokenId` must exist. + */ + function getApproved(uint256 tokenId) public view virtual override returns (address) { + if (!_exists(tokenId)) _revert(ApprovalQueryForNonexistentToken.selector); + + return ERC721AStorage.layout()._tokenApprovals[tokenId].value; + } + + /** + * @dev Approve or remove `operator` as an operator for the caller. + * Operators can call {transferFrom} or {safeTransferFrom} + * for any token owned by the caller. + * + * Requirements: + * + * - The `operator` cannot be the caller. + * + * Emits an {ApprovalForAll} event. + */ + function setApprovalForAll(address operator, bool approved) public virtual override { + ERC721AStorage.layout()._operatorApprovals[_msgSenderERC721A()][operator] = approved; + emit ApprovalForAll(_msgSenderERC721A(), operator, approved); + } + + /** + * @dev Returns if the `operator` is allowed to manage all of the assets of `owner`. + * + * See {setApprovalForAll}. + */ + function isApprovedForAll(address owner, address operator) public view virtual override returns (bool) { + return ERC721AStorage.layout()._operatorApprovals[owner][operator]; + } + + /** + * @dev Returns whether `tokenId` exists. + * + * Tokens can be managed by their owner or approved accounts via {approve} or {setApprovalForAll}. + * + * Tokens start existing when they are minted. See {_mint}. + */ + function _exists(uint256 tokenId) internal view virtual returns (bool result) { + if (_startTokenId() <= tokenId) { + if (tokenId < ERC721AStorage.layout()._currentIndex) { + uint256 packed; + while ((packed = ERC721AStorage.layout()._packedOwnerships[tokenId]) == 0) --tokenId; + result = packed & _BITMASK_BURNED == 0; + } + } + } + + /** + * @dev Returns whether `msgSender` is equal to `approvedAddress` or `owner`. + */ + function _isSenderApprovedOrOwner( + address approvedAddress, + address owner, + address msgSender + ) private pure returns (bool result) { + assembly { + // Mask `owner` to the lower 160 bits, in case the upper bits somehow aren't clean. + owner := and(owner, _BITMASK_ADDRESS) + // Mask `msgSender` to the lower 160 bits, in case the upper bits somehow aren't clean. + msgSender := and(msgSender, _BITMASK_ADDRESS) + // `msgSender == owner || msgSender == approvedAddress`. + result := or(eq(msgSender, owner), eq(msgSender, approvedAddress)) + } + } + + /** + * @dev Returns the storage slot and value for the approved address of `tokenId`. + */ + function _getApprovedSlotAndAddress( + uint256 tokenId + ) private view returns (uint256 approvedAddressSlot, address approvedAddress) { + ERC721AStorage.TokenApprovalRef storage tokenApproval = ERC721AStorage.layout()._tokenApprovals[tokenId]; + // The following is equivalent to `approvedAddress = _tokenApprovals[tokenId].value`. + assembly { + approvedAddressSlot := tokenApproval.slot + approvedAddress := sload(approvedAddressSlot) + } + } + + // ============================================================= + // TRANSFER OPERATIONS + // ============================================================= + + /** + * @dev Transfers `tokenId` from `from` to `to`. + * + * Requirements: + * + * - `from` cannot be the zero address. + * - `to` cannot be the zero address. + * - `tokenId` token must be owned by `from`. + * - If the caller is not `from`, it must be approved to move this token + * by either {approve} or {setApprovalForAll}. + * + * Emits a {Transfer} event. + */ + function transferFrom(address from, address to, uint256 tokenId) public payable virtual override { + uint256 prevOwnershipPacked = _packedOwnershipOf(tokenId); + + // Mask `from` to the lower 160 bits, in case the upper bits somehow aren't clean. + from = address(uint160(uint256(uint160(from)) & _BITMASK_ADDRESS)); + + if (address(uint160(prevOwnershipPacked)) != from) _revert(TransferFromIncorrectOwner.selector); + + (uint256 approvedAddressSlot, address approvedAddress) = _getApprovedSlotAndAddress(tokenId); + + // The nested ifs save around 20+ gas over a compound boolean condition. + if (!_isSenderApprovedOrOwner(approvedAddress, from, _msgSenderERC721A())) + if (!isApprovedForAll(from, _msgSenderERC721A())) _revert(TransferCallerNotOwnerNorApproved.selector); + + _beforeTokenTransfers(from, to, tokenId, 1); + + // Clear approvals from the previous owner. + assembly { + if approvedAddress { + // This is equivalent to `delete _tokenApprovals[tokenId]`. + sstore(approvedAddressSlot, 0) + } + } + + // Underflow of the sender's balance is impossible because we check for + // ownership above and the recipient's balance can't realistically overflow. + // Counter overflow is incredibly unrealistic as `tokenId` would have to be 2**256. + unchecked { + // We can directly increment and decrement the balances. + --ERC721AStorage.layout()._packedAddressData[from]; // Updates: `balance -= 1`. + ++ERC721AStorage.layout()._packedAddressData[to]; // Updates: `balance += 1`. + + // Updates: + // - `address` to the next owner. + // - `startTimestamp` to the timestamp of transfering. + // - `burned` to `false`. + // - `nextInitialized` to `true`. + ERC721AStorage.layout()._packedOwnerships[tokenId] = _packOwnershipData( + to, + _BITMASK_NEXT_INITIALIZED | _nextExtraData(from, to, prevOwnershipPacked) + ); + + // If the next slot may not have been initialized (i.e. `nextInitialized == false`) . + if (prevOwnershipPacked & _BITMASK_NEXT_INITIALIZED == 0) { + uint256 nextTokenId = tokenId + 1; + // If the next slot's address is zero and not burned (i.e. packed value is zero). + if (ERC721AStorage.layout()._packedOwnerships[nextTokenId] == 0) { + // If the next slot is within bounds. + if (nextTokenId != ERC721AStorage.layout()._currentIndex) { + // Initialize the next slot to maintain correctness for `ownerOf(tokenId + 1)`. + ERC721AStorage.layout()._packedOwnerships[nextTokenId] = prevOwnershipPacked; + } + } + } + } + + // Mask `to` to the lower 160 bits, in case the upper bits somehow aren't clean. + uint256 toMasked = uint256(uint160(to)) & _BITMASK_ADDRESS; + assembly { + // Emit the `Transfer` event. + log4( + 0, // Start of data (0, since no data). + 0, // End of data (0, since no data). + _TRANSFER_EVENT_SIGNATURE, // Signature. + from, // `from`. + toMasked, // `to`. + tokenId // `tokenId`. + ) + } + if (toMasked == 0) _revert(TransferToZeroAddress.selector); + + _afterTokenTransfers(from, to, tokenId, 1); + } + + /** + * @dev Equivalent to `safeTransferFrom(from, to, tokenId, '')`. + */ + function safeTransferFrom(address from, address to, uint256 tokenId) public payable virtual override { + safeTransferFrom(from, to, tokenId, ""); + } + + /** + * @dev Safely transfers `tokenId` token from `from` to `to`. + * + * Requirements: + * + * - `from` cannot be the zero address. + * - `to` cannot be the zero address. + * - `tokenId` token must exist and be owned by `from`. + * - If the caller is not `from`, it must be approved to move this token + * by either {approve} or {setApprovalForAll}. + * - If `to` refers to a smart contract, it must implement + * {IERC721Receiver-onERC721Received}, which is called upon a safe transfer. + * + * Emits a {Transfer} event. + */ + function safeTransferFrom( + address from, + address to, + uint256 tokenId, + bytes memory _data + ) public payable virtual override { + transferFrom(from, to, tokenId); + if (to.code.length != 0) + if (!_checkContractOnERC721Received(from, to, tokenId, _data)) { + _revert(TransferToNonERC721ReceiverImplementer.selector); + } + } + + /** + * @dev Hook that is called before a set of serially-ordered token IDs + * are about to be transferred. This includes minting. + * And also called before burning one token. + * + * `startTokenId` - the first token ID to be transferred. + * `quantity` - the amount to be transferred. + * + * Calling conditions: + * + * - When `from` and `to` are both non-zero, `from`'s `tokenId` will be + * transferred to `to`. + * - When `from` is zero, `tokenId` will be minted for `to`. + * - When `to` is zero, `tokenId` will be burned by `from`. + * - `from` and `to` are never both zero. + */ + function _beforeTokenTransfers(address from, address to, uint256 startTokenId, uint256 quantity) internal virtual {} + + /** + * @dev Hook that is called after a set of serially-ordered token IDs + * have been transferred. This includes minting. + * And also called after one token has been burned. + * + * `startTokenId` - the first token ID to be transferred. + * `quantity` - the amount to be transferred. + * + * Calling conditions: + * + * - When `from` and `to` are both non-zero, `from`'s `tokenId` has been + * transferred to `to`. + * - When `from` is zero, `tokenId` has been minted for `to`. + * - When `to` is zero, `tokenId` has been burned by `from`. + * - `from` and `to` are never both zero. + */ + function _afterTokenTransfers(address from, address to, uint256 startTokenId, uint256 quantity) internal virtual {} + + /** + * @dev Private function to invoke {IERC721Receiver-onERC721Received} on a target contract. + * + * `from` - Previous owner of the given token ID. + * `to` - Target address that will receive the token. + * `tokenId` - Token ID to be transferred. + * `_data` - Optional data to send along with the call. + * + * Returns whether the call correctly returned the expected magic value. + */ + function _checkContractOnERC721Received( + address from, + address to, + uint256 tokenId, + bytes memory _data + ) private returns (bool) { + try + ERC721A__IERC721ReceiverUpgradeable(to).onERC721Received(_msgSenderERC721A(), from, tokenId, _data) + returns (bytes4 retval) { + return retval == ERC721A__IERC721ReceiverUpgradeable(to).onERC721Received.selector; + } catch (bytes memory reason) { + if (reason.length == 0) { + _revert(TransferToNonERC721ReceiverImplementer.selector); + } + assembly { + revert(add(32, reason), mload(reason)) + } + } + } + + // ============================================================= + // MINT OPERATIONS + // ============================================================= + + /** + * @dev Mints `quantity` tokens and transfers them to `to`. + * + * Requirements: + * + * - `to` cannot be the zero address. + * - `quantity` must be greater than 0. + * + * Emits a {Transfer} event for each mint. + */ + function _mint(address to, uint256 quantity) internal virtual { + uint256 startTokenId = ERC721AStorage.layout()._currentIndex; + if (quantity == 0) _revert(MintZeroQuantity.selector); + + _beforeTokenTransfers(address(0), to, startTokenId, quantity); + + // Overflows are incredibly unrealistic. + // `balance` and `numberMinted` have a maximum limit of 2**64. + // `tokenId` has a maximum limit of 2**256. + unchecked { + // Updates: + // - `address` to the owner. + // - `startTimestamp` to the timestamp of minting. + // - `burned` to `false`. + // - `nextInitialized` to `quantity == 1`. + ERC721AStorage.layout()._packedOwnerships[startTokenId] = _packOwnershipData( + to, + _nextInitializedFlag(quantity) | _nextExtraData(address(0), to, 0) + ); + + // Updates: + // - `balance += quantity`. + // - `numberMinted += quantity`. + // + // We can directly add to the `balance` and `numberMinted`. + ERC721AStorage.layout()._packedAddressData[to] += quantity * ((1 << _BITPOS_NUMBER_MINTED) | 1); + + // Mask `to` to the lower 160 bits, in case the upper bits somehow aren't clean. + uint256 toMasked = uint256(uint160(to)) & _BITMASK_ADDRESS; + + if (toMasked == 0) _revert(MintToZeroAddress.selector); + + uint256 end = startTokenId + quantity; + uint256 tokenId = startTokenId; + + do { + assembly { + // Emit the `Transfer` event. + log4( + 0, // Start of data (0, since no data). + 0, // End of data (0, since no data). + _TRANSFER_EVENT_SIGNATURE, // Signature. + 0, // `address(0)`. + toMasked, // `to`. + tokenId // `tokenId`. + ) + } + // The `!=` check ensures that large values of `quantity` + // that overflows uint256 will make the loop run out of gas. + } while (++tokenId != end); + + ERC721AStorage.layout()._currentIndex = end; + } + _afterTokenTransfers(address(0), to, startTokenId, quantity); + } + + /** + * @dev Mints `quantity` tokens and transfers them to `to`. + * + * This function is intended for efficient minting only during contract creation. + * + * It emits only one {ConsecutiveTransfer} as defined in + * [ERC2309](https://eips.ethereum.org/EIPS/eip-2309), + * instead of a sequence of {Transfer} event(s). + * + * Calling this function outside of contract creation WILL make your contract + * non-compliant with the ERC721 standard. + * For full ERC721 compliance, substituting ERC721 {Transfer} event(s) with the ERC2309 + * {ConsecutiveTransfer} event is only permissible during contract creation. + * + * Requirements: + * + * - `to` cannot be the zero address. + * - `quantity` must be greater than 0. + * + * Emits a {ConsecutiveTransfer} event. + */ + function _mintERC2309(address to, uint256 quantity) internal virtual { + uint256 startTokenId = ERC721AStorage.layout()._currentIndex; + if (to == address(0)) _revert(MintToZeroAddress.selector); + if (quantity == 0) _revert(MintZeroQuantity.selector); + if (quantity > _MAX_MINT_ERC2309_QUANTITY_LIMIT) _revert(MintERC2309QuantityExceedsLimit.selector); + + _beforeTokenTransfers(address(0), to, startTokenId, quantity); + + // Overflows are unrealistic due to the above check for `quantity` to be below the limit. + unchecked { + // Updates: + // - `balance += quantity`. + // - `numberMinted += quantity`. + // + // We can directly add to the `balance` and `numberMinted`. + ERC721AStorage.layout()._packedAddressData[to] += quantity * ((1 << _BITPOS_NUMBER_MINTED) | 1); + + // Updates: + // - `address` to the owner. + // - `startTimestamp` to the timestamp of minting. + // - `burned` to `false`. + // - `nextInitialized` to `quantity == 1`. + ERC721AStorage.layout()._packedOwnerships[startTokenId] = _packOwnershipData( + to, + _nextInitializedFlag(quantity) | _nextExtraData(address(0), to, 0) + ); + + emit ConsecutiveTransfer(startTokenId, startTokenId + quantity - 1, address(0), to); + + ERC721AStorage.layout()._currentIndex = startTokenId + quantity; + } + _afterTokenTransfers(address(0), to, startTokenId, quantity); + } + + /** + * @dev Safely mints `quantity` tokens and transfers them to `to`. + * + * Requirements: + * + * - If `to` refers to a smart contract, it must implement + * {IERC721Receiver-onERC721Received}, which is called for each safe transfer. + * - `quantity` must be greater than 0. + * + * See {_mint}. + * + * Emits a {Transfer} event for each mint. + */ + function _safeMint(address to, uint256 quantity, bytes memory _data) internal virtual { + _mint(to, quantity); + + unchecked { + if (to.code.length != 0) { + uint256 end = ERC721AStorage.layout()._currentIndex; + uint256 index = end - quantity; + do { + if (!_checkContractOnERC721Received(address(0), to, index++, _data)) { + _revert(TransferToNonERC721ReceiverImplementer.selector); + } + } while (index < end); + // Reentrancy protection. + if (ERC721AStorage.layout()._currentIndex != end) _revert(bytes4(0)); + } + } + } + + /** + * @dev Equivalent to `_safeMint(to, quantity, '')`. + */ + function _safeMint(address to, uint256 quantity) internal virtual { + _safeMint(to, quantity, ""); + } + + // ============================================================= + // APPROVAL OPERATIONS + // ============================================================= + + /** + * @dev Equivalent to `_approve(to, tokenId, false)`. + */ + function _approve(address to, uint256 tokenId) internal virtual { + _approve(to, tokenId, false); + } + + /** + * @dev Gives permission to `to` to transfer `tokenId` token to another account. + * The approval is cleared when the token is transferred. + * + * Only a single account can be approved at a time, so approving the + * zero address clears previous approvals. + * + * Requirements: + * + * - `tokenId` must exist. + * + * Emits an {Approval} event. + */ + function _approve(address to, uint256 tokenId, bool approvalCheck) internal virtual { + address owner = ownerOf(tokenId); + + if (approvalCheck && _msgSenderERC721A() != owner) + if (!isApprovedForAll(owner, _msgSenderERC721A())) { + _revert(ApprovalCallerNotOwnerNorApproved.selector); + } + + ERC721AStorage.layout()._tokenApprovals[tokenId].value = to; + emit Approval(owner, to, tokenId); + } + + // ============================================================= + // BURN OPERATIONS + // ============================================================= + + /** + * @dev Equivalent to `_burn(tokenId, false)`. + */ + function _burn(uint256 tokenId) internal virtual { + _burn(tokenId, false); + } + + /** + * @dev Destroys `tokenId`. + * The approval is cleared when the token is burned. + * + * Requirements: + * + * - `tokenId` must exist. + * + * Emits a {Transfer} event. + */ + function _burn(uint256 tokenId, bool approvalCheck) internal virtual { + uint256 prevOwnershipPacked = _packedOwnershipOf(tokenId); + + address from = address(uint160(prevOwnershipPacked)); + + (uint256 approvedAddressSlot, address approvedAddress) = _getApprovedSlotAndAddress(tokenId); + + if (approvalCheck) { + // The nested ifs save around 20+ gas over a compound boolean condition. + if (!_isSenderApprovedOrOwner(approvedAddress, from, _msgSenderERC721A())) + if (!isApprovedForAll(from, _msgSenderERC721A())) _revert(TransferCallerNotOwnerNorApproved.selector); + } + + _beforeTokenTransfers(from, address(0), tokenId, 1); + + // Clear approvals from the previous owner. + assembly { + if approvedAddress { + // This is equivalent to `delete _tokenApprovals[tokenId]`. + sstore(approvedAddressSlot, 0) + } + } + + // Underflow of the sender's balance is impossible because we check for + // ownership above and the recipient's balance can't realistically overflow. + // Counter overflow is incredibly unrealistic as `tokenId` would have to be 2**256. + unchecked { + // Updates: + // - `balance -= 1`. + // - `numberBurned += 1`. + // + // We can directly decrement the balance, and increment the number burned. + // This is equivalent to `packed -= 1; packed += 1 << _BITPOS_NUMBER_BURNED;`. + ERC721AStorage.layout()._packedAddressData[from] += (1 << _BITPOS_NUMBER_BURNED) - 1; + + // Updates: + // - `address` to the last owner. + // - `startTimestamp` to the timestamp of burning. + // - `burned` to `true`. + // - `nextInitialized` to `true`. + ERC721AStorage.layout()._packedOwnerships[tokenId] = _packOwnershipData( + from, + (_BITMASK_BURNED | _BITMASK_NEXT_INITIALIZED) | _nextExtraData(from, address(0), prevOwnershipPacked) + ); + + // If the next slot may not have been initialized (i.e. `nextInitialized == false`) . + if (prevOwnershipPacked & _BITMASK_NEXT_INITIALIZED == 0) { + uint256 nextTokenId = tokenId + 1; + // If the next slot's address is zero and not burned (i.e. packed value is zero). + if (ERC721AStorage.layout()._packedOwnerships[nextTokenId] == 0) { + // If the next slot is within bounds. + if (nextTokenId != ERC721AStorage.layout()._currentIndex) { + // Initialize the next slot to maintain correctness for `ownerOf(tokenId + 1)`. + ERC721AStorage.layout()._packedOwnerships[nextTokenId] = prevOwnershipPacked; + } + } + } + } + + emit Transfer(from, address(0), tokenId); + _afterTokenTransfers(from, address(0), tokenId, 1); + + // Overflow not possible, as _burnCounter cannot be exceed _currentIndex times. + unchecked { + ERC721AStorage.layout()._burnCounter++; + } + } + + // ============================================================= + // EXTRA DATA OPERATIONS + // ============================================================= + + /** + * @dev Directly sets the extra data for the ownership data `index`. + */ + function _setExtraDataAt(uint256 index, uint24 extraData) internal virtual { + uint256 packed = ERC721AStorage.layout()._packedOwnerships[index]; + if (packed == 0) _revert(OwnershipNotInitializedForExtraData.selector); + uint256 extraDataCasted; + // Cast `extraData` with assembly to avoid redundant masking. + assembly { + extraDataCasted := extraData + } + packed = (packed & _BITMASK_EXTRA_DATA_COMPLEMENT) | (extraDataCasted << _BITPOS_EXTRA_DATA); + ERC721AStorage.layout()._packedOwnerships[index] = packed; + } + + /** + * @dev Called during each token transfer to set the 24bit `extraData` field. + * Intended to be overridden by the cosumer contract. + * + * `previousExtraData` - the value of `extraData` before transfer. + * + * Calling conditions: + * + * - When `from` and `to` are both non-zero, `from`'s `tokenId` will be + * transferred to `to`. + * - When `from` is zero, `tokenId` will be minted for `to`. + * - When `to` is zero, `tokenId` will be burned by `from`. + * - `from` and `to` are never both zero. + */ + function _extraData(address from, address to, uint24 previousExtraData) internal view virtual returns (uint24) {} + + /** + * @dev Returns the next extra data for the packed ownership data. + * The returned result is shifted into position. + */ + function _nextExtraData(address from, address to, uint256 prevOwnershipPacked) private view returns (uint256) { + uint24 extraData = uint24(prevOwnershipPacked >> _BITPOS_EXTRA_DATA); + return uint256(_extraData(from, to, extraData)) << _BITPOS_EXTRA_DATA; + } + + // ============================================================= + // OTHER OPERATIONS + // ============================================================= + + /** + * @dev Returns the message sender (defaults to `msg.sender`). + * + * If you are writing GSN compatible contracts, you need to override this function. + */ + function _msgSenderERC721A() internal view virtual returns (address) { + return msg.sender; + } + + /** + * @dev Converts a uint256 to its ASCII string decimal representation. + */ + function _toString(uint256 value) internal pure virtual returns (string memory str) { + assembly { + // The maximum value of a uint256 contains 78 digits (1 byte per digit), but + // we allocate 0xa0 bytes to keep the free memory pointer 32-byte word aligned. + // We will need 1 word for the trailing zeros padding, 1 word for the length, + // and 3 words for a maximum of 78 digits. Total: 5 * 0x20 = 0xa0. + let m := add(mload(0x40), 0xa0) + // Update the free memory pointer to allocate. + mstore(0x40, m) + // Assign the `str` to the end. + str := sub(m, 0x20) + // Zeroize the slot after the string. + mstore(str, 0) + + // Cache the end of the memory to calculate the length later. + let end := str + + // We write the string from rightmost digit to leftmost digit. + // The following is essentially a do-while loop that also handles the zero case. + // prettier-ignore + for { let temp := value } 1 {} { + str := sub(str, 1) + // Write the character to the pointer. + // The ASCII index of the '0' character is 48. + mstore8(str, add(48, mod(temp, 10))) + // Keep dividing `temp` until zero. + temp := div(temp, 10) + // prettier-ignore + if iszero(temp) { break } + } + + let length := sub(end, str) + // Move the pointer 32 bytes leftwards to make room for the length. + str := sub(str, 0x20) + // Store the length. + mstore(str, length) + } + } + + /** + * @dev For more efficient reverts. + */ + function _revert(bytes4 errorSelector) internal pure { + assembly { + mstore(0x00, errorSelector) + revert(0x00, 0x04) + } + } +} diff --git a/contracts/eip/queryable/ERC721A__Initializable.sol b/contracts/eip/queryable/ERC721A__Initializable.sol new file mode 100644 index 000000000..feba56ea7 --- /dev/null +++ b/contracts/eip/queryable/ERC721A__Initializable.sol @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +/** + * @dev This is a base contract to aid in writing upgradeable diamond facet contracts, or any kind of contract that will be deployed + * behind a proxy. Since proxied contracts do not make use of a constructor, it's common to move constructor logic to an + * external initializer function, usually called `initialize`. It then becomes necessary to protect this initializer + * function so it can only be called once. The {initializer} modifier provided by this contract will have this effect. + * + * TIP: To avoid leaving the proxy in an uninitialized state, the initializer function should be called as early as + * possible by providing the encoded function call as the `_data` argument to {ERC1967Proxy-constructor}. + * + * CAUTION: When used with inheritance, manual care must be taken to not invoke a parent initializer twice, or to ensure + * that all initializers are idempotent. This is not verified automatically as constructors are by Solidity. + */ + +import { ERC721A__InitializableStorage } from "./ERC721A__InitializableStorage.sol"; + +abstract contract ERC721A__Initializable { + using ERC721A__InitializableStorage for ERC721A__InitializableStorage.Layout; + + /** + * @dev Modifier to protect an initializer function from being invoked twice. + */ + modifier initializerERC721A() { + // If the contract is initializing we ignore whether _initialized is set in order to support multiple + // inheritance patterns, but we only do this in the context of a constructor, because in other contexts the + // contract may have been reentered. + require( + ERC721A__InitializableStorage.layout()._initializing + ? _isConstructor() + : !ERC721A__InitializableStorage.layout()._initialized, + "ERC721A__Initializable: contract is already initialized" + ); + + bool isTopLevelCall = !ERC721A__InitializableStorage.layout()._initializing; + if (isTopLevelCall) { + ERC721A__InitializableStorage.layout()._initializing = true; + ERC721A__InitializableStorage.layout()._initialized = true; + } + + _; + + if (isTopLevelCall) { + ERC721A__InitializableStorage.layout()._initializing = false; + } + } + + /** + * @dev Modifier to protect an initialization function so that it can only be invoked by functions with the + * {initializer} modifier, directly or indirectly. + */ + modifier onlyInitializingERC721A() { + require( + ERC721A__InitializableStorage.layout()._initializing, + "ERC721A__Initializable: contract is not initializing" + ); + _; + } + + /// @dev Returns true if and only if the function is running in the constructor + function _isConstructor() private view returns (bool) { + // extcodesize checks the size of the code stored in an address, and + // address returns the current address. Since the code is still not + // deployed when running a constructor, any checks on its code size will + // yield zero, making it an effective way to detect if a contract is + // under construction or not. + address self = address(this); + uint256 cs; + assembly { + cs := extcodesize(self) + } + return cs == 0; + } +} diff --git a/contracts/eip/queryable/ERC721A__InitializableStorage.sol b/contracts/eip/queryable/ERC721A__InitializableStorage.sol new file mode 100644 index 000000000..6b649b7b1 --- /dev/null +++ b/contracts/eip/queryable/ERC721A__InitializableStorage.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +/** + * @dev This is a base storage for the initialization function for upgradeable diamond facet contracts + **/ + +library ERC721A__InitializableStorage { + struct Layout { + /* + * Indicates that the contract has been initialized. + */ + bool _initialized; + /* + * Indicates that the contract is in the process of being initialized. + */ + bool _initializing; + } + + bytes32 internal constant STORAGE_SLOT = keccak256("ERC721A.contracts.storage.initializable.facet"); + + function layout() internal pure returns (Layout storage l) { + bytes32 slot = STORAGE_SLOT; + assembly { + l.slot := slot + } + } +} diff --git a/contracts/eip/queryable/IERC721AQueryable.sol b/contracts/eip/queryable/IERC721AQueryable.sol new file mode 100644 index 000000000..06fa1ca21 --- /dev/null +++ b/contracts/eip/queryable/IERC721AQueryable.sol @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: MIT +// ERC721A Contracts v3.3.0 +// Creator: Chiru Labs + +pragma solidity ^0.8.4; + +import "../interface/IERC721A.sol"; + +/** + * @dev Interface of an ERC721AQueryable compliant contract. + */ +interface IERC721AQueryable is IERC721A { + /** + * Invalid query range (`start` >= `stop`). + */ + error InvalidQueryRange(); + + /** + * @dev Returns the `TokenOwnership` struct at `tokenId` without reverting. + * + * If the `tokenId` is out of bounds: + * - `addr` = `address(0)` + * - `startTimestamp` = `0` + * - `burned` = `false` + * + * If the `tokenId` is burned: + * - `addr` = `
` + * - `startTimestamp` = `` + * - `burned = `true` + * + * Otherwise: + * - `addr` = `
` + * - `startTimestamp` = `` + * - `burned = `false` + */ + function explicitOwnershipOf(uint256 tokenId) external view returns (TokenOwnership memory); + + /** + * @dev Returns an array of `TokenOwnership` structs at `tokenIds` in order. + * See {ERC721AQueryable-explicitOwnershipOf} + */ + function explicitOwnershipsOf(uint256[] memory tokenIds) external view returns (TokenOwnership[] memory); + + /** + * @dev Returns an array of token IDs owned by `owner`, + * in the range [`start`, `stop`) + * (i.e. `start <= tokenId < stop`). + * + * This function allows for tokens to be queried if the collection + * grows too big for a single call of {ERC721AQueryable-tokensOfOwner}. + * + * Requirements: + * + * - `start` < `stop` + */ + function tokensOfOwnerIn(address owner, uint256 start, uint256 stop) external view returns (uint256[] memory); + + /** + * @dev Returns an array of token IDs owned by `owner`. + * + * This function scans the ownership mapping and is O(totalSupply) in complexity. + * It is meant to be called off-chain. + * + * See {ERC721AQueryable-tokensOfOwnerIn} for splitting the scan into + * multiple smaller scans if the collection is large enough to cause + * an out-of-gas error (10K pfp collections should be fine). + */ + function tokensOfOwner(address owner) external view returns (uint256[] memory); +} diff --git a/contracts/eip/queryable/IERC721AQueryableUpgradeable.sol b/contracts/eip/queryable/IERC721AQueryableUpgradeable.sol new file mode 100644 index 000000000..ac52fb68c --- /dev/null +++ b/contracts/eip/queryable/IERC721AQueryableUpgradeable.sol @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: MIT +// ERC721A Contracts v4.2.3 +// Creator: Chiru Labs + +pragma solidity ^0.8.4; + +import "./IERC721AUpgradeable.sol"; + +/** + * @dev Interface of ERC721AQueryable. + */ +interface IERC721AQueryableUpgradeable is IERC721AUpgradeable { + /** + * Invalid query range (`start` >= `stop`). + */ + error InvalidQueryRange(); + + /** + * @dev Returns the `TokenOwnership` struct at `tokenId` without reverting. + * + * If the `tokenId` is out of bounds: + * + * - `addr = address(0)` + * - `startTimestamp = 0` + * - `burned = false` + * - `extraData = 0` + * + * If the `tokenId` is burned: + * + * - `addr =
` + * - `startTimestamp = ` + * - `burned = true` + * - `extraData = ` + * + * Otherwise: + * + * - `addr =
` + * - `startTimestamp = ` + * - `burned = false` + * - `extraData = ` + */ + function explicitOwnershipOf(uint256 tokenId) external view returns (TokenOwnership memory); + + /** + * @dev Returns an array of token IDs owned by `owner`, + * in the range [`start`, `stop`) + * (i.e. `start <= tokenId < stop`). + * + * This function allows for tokens to be queried if the collection + * grows too big for a single call of {ERC721AQueryable-tokensOfOwner}. + * + * Requirements: + * + * - `start < stop` + */ + function tokensOfOwnerIn(address owner, uint256 start, uint256 stop) external view returns (uint256[] memory); + + /** + * @dev Returns an array of token IDs owned by `owner`. + * + * This function scans the ownership mapping and is O(`totalSupply`) in complexity. + * It is meant to be called off-chain. + * + * See {ERC721AQueryable-tokensOfOwnerIn} for splitting the scan into + * multiple smaller scans if the collection is large enough to cause + * an out-of-gas error (10K collections should be fine). + */ + function tokensOfOwner(address owner) external view returns (uint256[] memory); +} diff --git a/contracts/eip/queryable/IERC721AUpgradeable.sol b/contracts/eip/queryable/IERC721AUpgradeable.sol new file mode 100644 index 000000000..a2159f064 --- /dev/null +++ b/contracts/eip/queryable/IERC721AUpgradeable.sol @@ -0,0 +1,269 @@ +// SPDX-License-Identifier: MIT +// ERC721A Contracts v4.2.3 +// Creator: Chiru Labs + +pragma solidity ^0.8.4; + +/** + * @dev Interface of ERC721A. + */ +interface IERC721AUpgradeable { + /** + * The caller must own the token or be an approved operator. + */ + error ApprovalCallerNotOwnerNorApproved(); + + /** + * The token does not exist. + */ + error ApprovalQueryForNonexistentToken(); + + /** + * Cannot query the balance for the zero address. + */ + error BalanceQueryForZeroAddress(); + + /** + * Cannot mint to the zero address. + */ + error MintToZeroAddress(); + + /** + * The quantity of tokens minted must be more than zero. + */ + error MintZeroQuantity(); + + /** + * The token does not exist. + */ + error OwnerQueryForNonexistentToken(); + + /** + * The caller must own the token or be an approved operator. + */ + error TransferCallerNotOwnerNorApproved(); + + /** + * The token must be owned by `from`. + */ + error TransferFromIncorrectOwner(); + + /** + * Cannot safely transfer to a contract that does not implement the + * ERC721Receiver interface. + */ + error TransferToNonERC721ReceiverImplementer(); + + /** + * Cannot transfer to the zero address. + */ + error TransferToZeroAddress(); + + /** + * The token does not exist. + */ + error URIQueryForNonexistentToken(); + + /** + * The `quantity` minted with ERC2309 exceeds the safety limit. + */ + error MintERC2309QuantityExceedsLimit(); + + /** + * The `extraData` cannot be set on an unintialized ownership slot. + */ + error OwnershipNotInitializedForExtraData(); + + // ============================================================= + // STRUCTS + // ============================================================= + + struct TokenOwnership { + // The address of the owner. + address addr; + // Stores the start time of ownership with minimal overhead for tokenomics. + uint64 startTimestamp; + // Whether the token has been burned. + bool burned; + // Arbitrary data similar to `startTimestamp` that can be set via {_extraData}. + uint24 extraData; + } + + // ============================================================= + // TOKEN COUNTERS + // ============================================================= + + /** + * @dev Returns the total number of tokens in existence. + * Burned tokens will reduce the count. + * To get the total number of tokens minted, please see {_totalMinted}. + */ + function totalSupply() external view returns (uint256); + + // ============================================================= + // IERC165 + // ============================================================= + + /** + * @dev Returns true if this contract implements the interface defined by + * `interfaceId`. See the corresponding + * [EIP section](https://eips.ethereum.org/EIPS/eip-165#how-interfaces-are-identified) + * to learn more about how these ids are created. + * + * This function call must use less than 30000 gas. + */ + function supportsInterface(bytes4 interfaceId) external view returns (bool); + + // ============================================================= + // IERC721 + // ============================================================= + + /** + * @dev Emitted when `tokenId` token is transferred from `from` to `to`. + */ + event Transfer(address indexed from, address indexed to, uint256 indexed tokenId); + + /** + * @dev Emitted when `owner` enables `approved` to manage the `tokenId` token. + */ + event Approval(address indexed owner, address indexed approved, uint256 indexed tokenId); + + /** + * @dev Emitted when `owner` enables or disables + * (`approved`) `operator` to manage all of its assets. + */ + event ApprovalForAll(address indexed owner, address indexed operator, bool approved); + + /** + * @dev Returns the number of tokens in `owner`'s account. + */ + function balanceOf(address owner) external view returns (uint256 balance); + + /** + * @dev Returns the owner of the `tokenId` token. + * + * Requirements: + * + * - `tokenId` must exist. + */ + function ownerOf(uint256 tokenId) external view returns (address owner); + + /** + * @dev Safely transfers `tokenId` token from `from` to `to`, + * checking first that contract recipients are aware of the ERC721 protocol + * to prevent tokens from being forever locked. + * + * Requirements: + * + * - `from` cannot be the zero address. + * - `to` cannot be the zero address. + * - `tokenId` token must exist and be owned by `from`. + * - If the caller is not `from`, it must be have been allowed to move + * this token by either {approve} or {setApprovalForAll}. + * - If `to` refers to a smart contract, it must implement + * {IERC721Receiver-onERC721Received}, which is called upon a safe transfer. + * + * Emits a {Transfer} event. + */ + function safeTransferFrom(address from, address to, uint256 tokenId, bytes calldata data) external payable; + + /** + * @dev Equivalent to `safeTransferFrom(from, to, tokenId, '')`. + */ + function safeTransferFrom(address from, address to, uint256 tokenId) external payable; + + /** + * @dev Transfers `tokenId` from `from` to `to`. + * + * WARNING: Usage of this method is discouraged, use {safeTransferFrom} + * whenever possible. + * + * Requirements: + * + * - `from` cannot be the zero address. + * - `to` cannot be the zero address. + * - `tokenId` token must be owned by `from`. + * - If the caller is not `from`, it must be approved to move this token + * by either {approve} or {setApprovalForAll}. + * + * Emits a {Transfer} event. + */ + function transferFrom(address from, address to, uint256 tokenId) external payable; + + /** + * @dev Gives permission to `to` to transfer `tokenId` token to another account. + * The approval is cleared when the token is transferred. + * + * Only a single account can be approved at a time, so approving the + * zero address clears previous approvals. + * + * Requirements: + * + * - The caller must own the token or be an approved operator. + * - `tokenId` must exist. + * + * Emits an {Approval} event. + */ + function approve(address to, uint256 tokenId) external payable; + + /** + * @dev Approve or remove `operator` as an operator for the caller. + * Operators can call {transferFrom} or {safeTransferFrom} + * for any token owned by the caller. + * + * Requirements: + * + * - The `operator` cannot be the caller. + * + * Emits an {ApprovalForAll} event. + */ + function setApprovalForAll(address operator, bool _approved) external; + + /** + * @dev Returns the account approved for `tokenId` token. + * + * Requirements: + * + * - `tokenId` must exist. + */ + function getApproved(uint256 tokenId) external view returns (address operator); + + /** + * @dev Returns if the `operator` is allowed to manage all of the assets of `owner`. + * + * See {setApprovalForAll}. + */ + function isApprovedForAll(address owner, address operator) external view returns (bool); + + // ============================================================= + // IERC721Metadata + // ============================================================= + + /** + * @dev Returns the token collection name. + */ + function name() external view returns (string memory); + + /** + * @dev Returns the token collection symbol. + */ + function symbol() external view returns (string memory); + + /** + * @dev Returns the Uniform Resource Identifier (URI) for `tokenId` token. + */ + function tokenURI(uint256 tokenId) external view returns (string memory); + + // ============================================================= + // IERC2309 + // ============================================================= + + /** + * @dev Emitted when tokens in `fromTokenId` to `toTokenId` + * (inclusive) is transferred from `from` to `to`, as defined in the + * [ERC2309](https://eips.ethereum.org/EIPS/eip-2309) standard. + * + * See {_mintERC2309} for more details. + */ + event ConsecutiveTransfer(uint256 indexed fromTokenId, uint256 toTokenId, address indexed from, address indexed to); +} diff --git a/contracts/extension/AppURI.sol b/contracts/extension/AppURI.sol new file mode 100644 index 000000000..99c92dd56 --- /dev/null +++ b/contracts/extension/AppURI.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "./interface/IAppURI.sol"; + +/** + * Thirdweb's `AppURI` is a contract extension for any contract + * that wants to add an official App URI that follows the appUri spec + * + */ + +abstract contract AppURI is IAppURI { + /// @dev appURI + string public override appURI; + + /// @dev Lets a contract admin set the URI for app metadata. + function setAppURI(string memory _uri) public override { + if (!_canSetAppURI()) { + revert("Not authorized"); + } + + _setupAppURI(_uri); + } + + /// @dev Lets a contract admin set the URI for app metadata. + function _setupAppURI(string memory _uri) internal { + string memory prevURI = appURI; + appURI = _uri; + + emit AppURIUpdated(prevURI, _uri); + } + + /// @dev Returns whether appUri can be set in the given execution context. + function _canSetAppURI() internal view virtual returns (bool); +} diff --git a/contracts/extension/BatchMintMetadata.sol b/contracts/extension/BatchMintMetadata.sol new file mode 100644 index 000000000..108d43a63 --- /dev/null +++ b/contracts/extension/BatchMintMetadata.sol @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +/** + * @title Batch-mint Metadata + * @notice The `BatchMintMetadata` is a contract extension for any base NFT contract. It lets the smart contract + * using this extension set metadata for `n` number of NFTs all at once. This is enabled by storing a single + * base URI for a batch of `n` NFTs, where the metadata for each NFT in a relevant batch is `baseURI/tokenId`. + */ + +contract BatchMintMetadata { + /// @dev Invalid index for batch + error BatchMintInvalidBatchId(uint256 index); + + /// @dev Invalid token + error BatchMintInvalidTokenId(uint256 tokenId); + + /// @dev Metadata frozen + error BatchMintMetadataFrozen(uint256 batchId); + + /// @dev Largest tokenId of each batch of tokens with the same baseURI + 1 {ex: batchId 100 at position 0 includes tokens 0-99} + uint256[] private batchIds; + + /// @dev Mapping from id of a batch of tokens => to base URI for the respective batch of tokens. + mapping(uint256 => string) private baseURI; + + /// @dev Mapping from id of a batch of tokens => to whether the base URI for the respective batch of tokens is frozen. + mapping(uint256 => bool) public batchFrozen; + + /// @dev This event emits when the metadata of all tokens are frozen. + /// While not currently supported by marketplaces, this event allows + /// future indexing if desired. + event MetadataFrozen(); + + // @dev This event emits when the metadata of a range of tokens is updated. + /// So that the third-party platforms such as NFT market could + /// timely update the images and related attributes of the NFTs. + event BatchMetadataUpdate(uint256 _fromTokenId, uint256 _toTokenId); + + /** + * @notice Returns the count of batches of NFTs. + * @dev Each batch of tokens has an in ID and an associated `baseURI`. + * See {batchIds}. + */ + function getBaseURICount() public view returns (uint256) { + return batchIds.length; + } + + /** + * @notice Returns the ID for the batch of tokens at the given index. + * @dev See {getBaseURICount}. + * @param _index Index of the desired batch in batchIds array. + */ + function getBatchIdAtIndex(uint256 _index) public view returns (uint256) { + if (_index >= getBaseURICount()) { + revert BatchMintInvalidBatchId(_index); + } + return batchIds[_index]; + } + + /// @dev Returns the id for the batch of tokens the given tokenId belongs to. + function _getBatchId(uint256 _tokenId) internal view returns (uint256 batchId, uint256 index) { + uint256 numOfTokenBatches = getBaseURICount(); + uint256[] memory indices = batchIds; + + for (uint256 i = 0; i < numOfTokenBatches; i += 1) { + if (_tokenId < indices[i]) { + index = i; + batchId = indices[i]; + + return (batchId, index); + } + } + + revert BatchMintInvalidTokenId(_tokenId); + } + + /// @dev Returns the baseURI for a token. The intended metadata URI for the token is baseURI + tokenId. + function _getBaseURI(uint256 _tokenId) internal view returns (string memory) { + uint256 numOfTokenBatches = getBaseURICount(); + uint256[] memory indices = batchIds; + + for (uint256 i = 0; i < numOfTokenBatches; i += 1) { + if (_tokenId < indices[i]) { + return baseURI[indices[i]]; + } + } + + revert BatchMintInvalidTokenId(_tokenId); + } + + /// @dev returns the starting tokenId of a given batchId. + function _getBatchStartId(uint256 _batchID) internal view returns (uint256) { + uint256 numOfTokenBatches = getBaseURICount(); + uint256[] memory indices = batchIds; + + for (uint256 i = 0; i < numOfTokenBatches; i++) { + if (_batchID == indices[i]) { + if (i > 0) { + return indices[i - 1]; + } + return 0; + } + } + + revert BatchMintInvalidBatchId(_batchID); + } + + /// @dev Sets the base URI for the batch of tokens with the given batchId. + function _setBaseURI(uint256 _batchId, string memory _baseURI) internal { + if (batchFrozen[_batchId]) { + revert BatchMintMetadataFrozen(_batchId); + } + baseURI[_batchId] = _baseURI; + emit BatchMetadataUpdate(_getBatchStartId(_batchId), _batchId); + } + + /// @dev Freezes the base URI for the batch of tokens with the given batchId. + function _freezeBaseURI(uint256 _batchId) internal { + string memory baseURIForBatch = baseURI[_batchId]; + if (bytes(baseURIForBatch).length == 0) { + revert BatchMintInvalidBatchId(_batchId); + } + batchFrozen[_batchId] = true; + emit MetadataFrozen(); + } + + /// @dev Mints a batch of tokenIds and associates a common baseURI to all those Ids. + function _batchMintMetadata( + uint256 _startId, + uint256 _amountToMint, + string memory _baseURIForTokens + ) internal returns (uint256 nextTokenIdToMint, uint256 batchId) { + batchId = _startId + _amountToMint; + nextTokenIdToMint = batchId; + + batchIds.push(batchId); + + baseURI[batchId] = _baseURIForTokens; + } +} diff --git a/contracts/extension/BurnToClaim.sol b/contracts/extension/BurnToClaim.sol new file mode 100644 index 000000000..c9e6c1a21 --- /dev/null +++ b/contracts/extension/BurnToClaim.sol @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import { ERC1155Burnable } from "@openzeppelin/contracts/token/ERC1155/extensions/ERC1155Burnable.sol"; +import { ERC721Burnable } from "@openzeppelin/contracts/token/ERC721/extensions/ERC721Burnable.sol"; + +import "../eip/interface/IERC1155.sol"; +import "../eip/interface/IERC721.sol"; + +import "../external-deps/openzeppelin/utils/Context.sol"; +import "./interface/IBurnToClaim.sol"; + +abstract contract BurnToClaim is IBurnToClaim { + BurnToClaimInfo internal burnToClaimInfo; + + function getBurnToClaimInfo() public view returns (BurnToClaimInfo memory) { + return burnToClaimInfo; + } + + function setBurnToClaimInfo(BurnToClaimInfo calldata _burnToClaimInfo) external virtual { + require(_canSetBurnToClaim(), "Not authorized."); + require(_burnToClaimInfo.originContractAddress != address(0), "Origin contract not set."); + require(_burnToClaimInfo.currency != address(0), "Currency not set."); + + burnToClaimInfo = _burnToClaimInfo; + } + + function verifyBurnToClaim(address _tokenOwner, uint256 _tokenId, uint256 _quantity) public view virtual { + BurnToClaimInfo memory _burnToClaimInfo = burnToClaimInfo; + + if (_burnToClaimInfo.tokenType == IBurnToClaim.TokenType.ERC721) { + require(_quantity == 1, "Invalid amount"); + require(IERC721(_burnToClaimInfo.originContractAddress).ownerOf(_tokenId) == _tokenOwner, "!Owner"); + } else if (_burnToClaimInfo.tokenType == IBurnToClaim.TokenType.ERC1155) { + uint256 _eligible1155TokenId = _burnToClaimInfo.tokenId; + + require(_tokenId == _eligible1155TokenId, "Invalid token Id"); + require( + IERC1155(_burnToClaimInfo.originContractAddress).balanceOf(_tokenOwner, _tokenId) >= _quantity, + "!Balance" + ); + } + + // TODO: check if additional verification steps are required / override in main contract + } + + function _burnTokensOnOrigin(address _tokenOwner, uint256 _tokenId, uint256 _quantity) internal virtual { + BurnToClaimInfo memory _burnToClaimInfo = burnToClaimInfo; + if (_burnToClaimInfo.tokenType == IBurnToClaim.TokenType.ERC721) { + ERC721Burnable(_burnToClaimInfo.originContractAddress).burn(_tokenId); + } else if (_burnToClaimInfo.tokenType == IBurnToClaim.TokenType.ERC1155) { + ERC1155Burnable(_burnToClaimInfo.originContractAddress).burn(_tokenOwner, _tokenId, _quantity); + } + // TODO: check if additional migration steps are required / override in main contract + } + + function _canSetBurnToClaim() internal view virtual returns (bool); +} diff --git a/contracts/extension/ContractMetadata.sol b/contracts/extension/ContractMetadata.sol new file mode 100644 index 000000000..ec968f2b6 --- /dev/null +++ b/contracts/extension/ContractMetadata.sol @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "./interface/IContractMetadata.sol"; + +/** + * @title Contract Metadata + * @notice Thirdweb's `ContractMetadata` is a contract extension for any base contracts. It lets you set a metadata URI + * for you contract. + * Additionally, `ContractMetadata` is necessary for NFT contracts that want royalties to get distributed on OpenSea. + */ + +abstract contract ContractMetadata is IContractMetadata { + /// @dev The sender is not authorized to perform the action + error ContractMetadataUnauthorized(); + + /// @notice Returns the contract metadata URI. + string public override contractURI; + + /** + * @notice Lets a contract admin set the URI for contract-level metadata. + * @dev Caller should be authorized to setup contractURI, e.g. contract admin. + * See {_canSetContractURI}. + * Emits {ContractURIUpdated Event}. + * + * @param _uri keccak256 hash of the role. e.g. keccak256("TRANSFER_ROLE") + */ + function setContractURI(string memory _uri) external override { + if (!_canSetContractURI()) { + revert ContractMetadataUnauthorized(); + } + + _setupContractURI(_uri); + } + + /// @dev Lets a contract admin set the URI for contract-level metadata. + function _setupContractURI(string memory _uri) internal { + string memory prevURI = contractURI; + contractURI = _uri; + + emit ContractURIUpdated(prevURI, _uri); + } + + /// @dev Returns whether contract metadata can be set in the given execution context. + function _canSetContractURI() internal view virtual returns (bool); +} diff --git a/contracts/extension/DelayedReveal.sol b/contracts/extension/DelayedReveal.sol new file mode 100644 index 000000000..bc0d09d22 --- /dev/null +++ b/contracts/extension/DelayedReveal.sol @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "./interface/IDelayedReveal.sol"; + +/** + * @title Delayed Reveal + * @notice Thirdweb's `DelayedReveal` is a contract extension for base NFT contracts. It lets you create batches of + * 'delayed-reveal' NFTs. You can learn more about the usage of delayed reveal NFTs here - https://blog.thirdweb.com/delayed-reveal-nfts + */ + +abstract contract DelayedReveal is IDelayedReveal { + /// @dev The contract doesn't have any url to be delayed revealed + error DelayedRevealNothingToReveal(); + + /// @dev The result of the returned an incorrect hash + error DelayedRevealIncorrectResultHash(bytes32 expected, bytes32 actual); + + /// @dev Mapping from tokenId of a batch of tokens => to delayed reveal data. + mapping(uint256 => bytes) public encryptedData; + + /// @dev Sets the delayed reveal data for a batchId. + function _setEncryptedData(uint256 _batchId, bytes memory _encryptedData) internal { + encryptedData[_batchId] = _encryptedData; + } + + /** + * @notice Returns revealed URI for a batch of NFTs. + * @dev Reveal encrypted base URI for `_batchId` with caller/admin's `_key` used for encryption. + * Reverts if there's no encrypted URI for `_batchId`. + * See {encryptDecrypt}. + * + * @param _batchId ID of the batch for which URI is being revealed. + * @param _key Secure key used by caller/admin for encryption of baseURI. + * + * @return revealedURI Decrypted base URI. + */ + function getRevealURI(uint256 _batchId, bytes calldata _key) public view returns (string memory revealedURI) { + bytes memory data = encryptedData[_batchId]; + if (data.length == 0) { + revert DelayedRevealNothingToReveal(); + } + + (bytes memory encryptedURI, bytes32 provenanceHash) = abi.decode(data, (bytes, bytes32)); + + revealedURI = string(encryptDecrypt(encryptedURI, _key)); + + if (keccak256(abi.encodePacked(revealedURI, _key, block.chainid)) != provenanceHash) { + revert DelayedRevealIncorrectResultHash( + provenanceHash, + keccak256(abi.encodePacked(revealedURI, _key, block.chainid)) + ); + } + } + + /** + * @notice Encrypt/decrypt data on chain. + * @dev Encrypt/decrypt given `data` with `key`. Uses inline assembly. + * See: https://ethereum.stackexchange.com/questions/69825/decrypt-message-on-chain + * + * @param data Bytes of data to encrypt/decrypt. + * @param key Secure key used by caller for encryption/decryption. + * + * @return result Output after encryption/decryption of given data. + */ + function encryptDecrypt(bytes memory data, bytes calldata key) public pure override returns (bytes memory result) { + // Store data length on stack for later use + uint256 length = data.length; + + // solhint-disable-next-line no-inline-assembly + assembly { + // Set result to free memory pointer + result := mload(0x40) + // Increase free memory pointer by lenght + 32 + mstore(0x40, add(add(result, length), 32)) + // Set result length + mstore(result, length) + } + + // Iterate over the data stepping by 32 bytes + for (uint256 i = 0; i < length; i += 32) { + // Generate hash of the key and offset + bytes32 hash = keccak256(abi.encodePacked(key, i)); + + bytes32 chunk; + // solhint-disable-next-line no-inline-assembly + assembly { + // Read 32-bytes data chunk + chunk := mload(add(data, add(i, 32))) + } + // XOR the chunk with hash + chunk ^= hash; + // solhint-disable-next-line no-inline-assembly + assembly { + // Write 32-byte encrypted chunk + mstore(add(result, add(i, 32)), chunk) + } + } + } + + /** + * @notice Returns whether the relvant batch of NFTs is subject to a delayed reveal. + * @dev Returns `true` if `_batchId`'s base URI is encrypted. + * @param _batchId ID of a batch of NFTs. + */ + function isEncryptedBatch(uint256 _batchId) public view returns (bool) { + return encryptedData[_batchId].length > 0; + } +} diff --git a/contracts/extension/Drop.sol b/contracts/extension/Drop.sol new file mode 100644 index 000000000..5a1ee1a62 --- /dev/null +++ b/contracts/extension/Drop.sol @@ -0,0 +1,284 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "./interface/IDrop.sol"; +import "../lib/MerkleProof.sol"; + +abstract contract Drop is IDrop { + /// @dev The sender is not authorized to perform the action + error DropUnauthorized(); + + /// @dev Exceeded the max token total supply + error DropExceedMaxSupply(); + + /// @dev No active claim condition + error DropNoActiveCondition(); + + /// @dev Claim condition invalid currency or price + error DropClaimInvalidTokenPrice( + address expectedCurrency, + uint256 expectedPricePerToken, + address actualCurrency, + uint256 actualExpectedPricePerToken + ); + + /// @dev Claim condition exceeded limit + error DropClaimExceedLimit(uint256 expected, uint256 actual); + + /// @dev Claim condition exceeded max supply + error DropClaimExceedMaxSupply(uint256 expected, uint256 actual); + + /// @dev Claim condition not started yet + error DropClaimNotStarted(uint256 expected, uint256 actual); + + /*/////////////////////////////////////////////////////////////// + State variables + //////////////////////////////////////////////////////////////*/ + + /// @dev The active conditions for claiming tokens. + ClaimConditionList public claimCondition; + + /*/////////////////////////////////////////////////////////////// + Drop logic + //////////////////////////////////////////////////////////////*/ + + /// @dev Lets an account claim tokens. + function claim( + address _receiver, + uint256 _quantity, + address _currency, + uint256 _pricePerToken, + AllowlistProof calldata _allowlistProof, + bytes memory _data + ) public payable virtual override { + _beforeClaim(_receiver, _quantity, _currency, _pricePerToken, _allowlistProof, _data); + + uint256 activeConditionId = getActiveClaimConditionId(); + + verifyClaim(activeConditionId, _dropMsgSender(), _quantity, _currency, _pricePerToken, _allowlistProof); + + // Update contract state. + claimCondition.conditions[activeConditionId].supplyClaimed += _quantity; + claimCondition.supplyClaimedByWallet[activeConditionId][_dropMsgSender()] += _quantity; + + // If there's a price, collect price. + _collectPriceOnClaim(address(0), _quantity, _currency, _pricePerToken); + + // Mint the relevant tokens to claimer. + uint256 startTokenId = _transferTokensOnClaim(_receiver, _quantity); + + emit TokensClaimed(activeConditionId, _dropMsgSender(), _receiver, startTokenId, _quantity); + + _afterClaim(_receiver, _quantity, _currency, _pricePerToken, _allowlistProof, _data); + } + + /// @dev Lets a contract admin set claim conditions. + function setClaimConditions( + ClaimCondition[] calldata _conditions, + bool _resetClaimEligibility + ) external virtual override { + if (!_canSetClaimConditions()) { + revert DropUnauthorized(); + } + + uint256 existingStartIndex = claimCondition.currentStartId; + uint256 existingPhaseCount = claimCondition.count; + + /** + * The mapping `supplyClaimedByWallet` uses a claim condition's UID as a key. + * + * If `_resetClaimEligibility == true`, we assign completely new UIDs to the claim + * conditions in `_conditions`, effectively resetting the restrictions on claims expressed + * by `supplyClaimedByWallet`. + */ + uint256 newStartIndex = existingStartIndex; + if (_resetClaimEligibility) { + newStartIndex = existingStartIndex + existingPhaseCount; + } + + claimCondition.count = _conditions.length; + claimCondition.currentStartId = newStartIndex; + + uint256 lastConditionStartTimestamp; + for (uint256 i = 0; i < _conditions.length; i++) { + require(i == 0 || lastConditionStartTimestamp < _conditions[i].startTimestamp, "ST"); + + uint256 supplyClaimedAlready = claimCondition.conditions[newStartIndex + i].supplyClaimed; + if (supplyClaimedAlready > _conditions[i].maxClaimableSupply) { + revert DropExceedMaxSupply(); + } + + claimCondition.conditions[newStartIndex + i] = _conditions[i]; + claimCondition.conditions[newStartIndex + i].supplyClaimed = supplyClaimedAlready; + + lastConditionStartTimestamp = _conditions[i].startTimestamp; + } + + /** + * Gas refunds (as much as possible) + * + * If `_resetClaimEligibility == true`, we assign completely new UIDs to the claim + * conditions in `_conditions`. So, we delete claim conditions with UID < `newStartIndex`. + * + * If `_resetClaimEligibility == false`, and there are more existing claim conditions + * than in `_conditions`, we delete the existing claim conditions that don't get replaced + * by the conditions in `_conditions`. + */ + if (_resetClaimEligibility) { + for (uint256 i = existingStartIndex; i < newStartIndex; i++) { + delete claimCondition.conditions[i]; + } + } else { + if (existingPhaseCount > _conditions.length) { + for (uint256 i = _conditions.length; i < existingPhaseCount; i++) { + delete claimCondition.conditions[newStartIndex + i]; + } + } + } + + emit ClaimConditionsUpdated(_conditions, _resetClaimEligibility); + } + + /// @dev Checks a request to claim NFTs against the active claim condition's criteria. + function verifyClaim( + uint256 _conditionId, + address _claimer, + uint256 _quantity, + address _currency, + uint256 _pricePerToken, + AllowlistProof calldata _allowlistProof + ) public view virtual returns (bool isOverride) { + ClaimCondition memory currentClaimPhase = claimCondition.conditions[_conditionId]; + uint256 claimLimit = currentClaimPhase.quantityLimitPerWallet; + uint256 claimPrice = currentClaimPhase.pricePerToken; + address claimCurrency = currentClaimPhase.currency; + + /* + * Here `isOverride` implies that if the merkle proof verification fails, + * the claimer would claim through open claim limit instead of allowlisted limit. + */ + if (currentClaimPhase.merkleRoot != bytes32(0)) { + (isOverride, ) = MerkleProof.verify( + _allowlistProof.proof, + currentClaimPhase.merkleRoot, + keccak256( + abi.encodePacked( + _claimer, + _allowlistProof.quantityLimitPerWallet, + _allowlistProof.pricePerToken, + _allowlistProof.currency + ) + ) + ); + } + + if (isOverride) { + claimLimit = _allowlistProof.quantityLimitPerWallet != 0 + ? _allowlistProof.quantityLimitPerWallet + : claimLimit; + claimPrice = _allowlistProof.pricePerToken != type(uint256).max + ? _allowlistProof.pricePerToken + : claimPrice; + claimCurrency = _allowlistProof.pricePerToken != type(uint256).max && _allowlistProof.currency != address(0) + ? _allowlistProof.currency + : claimCurrency; + } + + uint256 supplyClaimedByWallet = claimCondition.supplyClaimedByWallet[_conditionId][_claimer]; + + if (_currency != claimCurrency || _pricePerToken != claimPrice) { + revert DropClaimInvalidTokenPrice(_currency, _pricePerToken, claimCurrency, claimPrice); + } + + if (_quantity == 0 || (_quantity + supplyClaimedByWallet > claimLimit)) { + revert DropClaimExceedLimit(claimLimit, _quantity + supplyClaimedByWallet); + } + + if (currentClaimPhase.supplyClaimed + _quantity > currentClaimPhase.maxClaimableSupply) { + revert DropClaimExceedMaxSupply( + currentClaimPhase.maxClaimableSupply, + currentClaimPhase.supplyClaimed + _quantity + ); + } + + if (currentClaimPhase.startTimestamp > block.timestamp) { + revert DropClaimNotStarted(currentClaimPhase.startTimestamp, block.timestamp); + } + } + + /// @dev At any given moment, returns the uid for the active claim condition. + function getActiveClaimConditionId() public view returns (uint256) { + for (uint256 i = claimCondition.currentStartId + claimCondition.count; i > claimCondition.currentStartId; i--) { + if (block.timestamp >= claimCondition.conditions[i - 1].startTimestamp) { + return i - 1; + } + } + + revert DropNoActiveCondition(); + } + + /// @dev Returns the claim condition at the given uid. + function getClaimConditionById(uint256 _conditionId) external view returns (ClaimCondition memory condition) { + condition = claimCondition.conditions[_conditionId]; + } + + /// @dev Returns the supply claimed by claimer for a given conditionId. + function getSupplyClaimedByWallet( + uint256 _conditionId, + address _claimer + ) public view returns (uint256 supplyClaimedByWallet) { + supplyClaimedByWallet = claimCondition.supplyClaimedByWallet[_conditionId][_claimer]; + } + + /*//////////////////////////////////////////////////////////////////// + Optional hooks that can be implemented in the derived contract + ///////////////////////////////////////////////////////////////////*/ + + /// @dev Exposes the ability to override the msg sender. + function _dropMsgSender() internal virtual returns (address) { + return msg.sender; + } + + /// @dev Runs before every `claim` function call. + function _beforeClaim( + address _receiver, + uint256 _quantity, + address _currency, + uint256 _pricePerToken, + AllowlistProof calldata _allowlistProof, + bytes memory _data + ) internal virtual {} + + /// @dev Runs after every `claim` function call. + function _afterClaim( + address _receiver, + uint256 _quantity, + address _currency, + uint256 _pricePerToken, + AllowlistProof calldata _allowlistProof, + bytes memory _data + ) internal virtual {} + + /*/////////////////////////////////////////////////////////////// + Virtual functions: to be implemented in derived contract + //////////////////////////////////////////////////////////////*/ + + /// @dev Collects and distributes the primary sale value of NFTs being claimed. + function _collectPriceOnClaim( + address _primarySaleRecipient, + uint256 _quantityToClaim, + address _currency, + uint256 _pricePerToken + ) internal virtual; + + /// @dev Transfers the NFTs being claimed. + function _transferTokensOnClaim( + address _to, + uint256 _quantityBeingClaimed + ) internal virtual returns (uint256 startTokenId); + + /// @dev Determine what wallet can update claim conditions + function _canSetClaimConditions() internal view virtual returns (bool); +} diff --git a/contracts/extension/Drop1155.sol b/contracts/extension/Drop1155.sol new file mode 100644 index 000000000..07631e32d --- /dev/null +++ b/contracts/extension/Drop1155.sol @@ -0,0 +1,300 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "./interface/IDrop1155.sol"; +import "../lib/MerkleProof.sol"; + +abstract contract Drop1155 is IDrop1155 { + /// @dev The sender is not authorized to perform the action + error DropUnauthorized(); + + /// @dev Exceeded the max token total supply + error DropExceedMaxSupply(); + + /// @dev No active claim condition + error DropNoActiveCondition(); + + /// @dev Claim condition invalid currency or price + error DropClaimInvalidTokenPrice( + address expectedCurrency, + uint256 expectedPricePerToken, + address actualCurrency, + uint256 actualExpectedPricePerToken + ); + + /// @dev Claim condition exceeded limit + error DropClaimExceedLimit(uint256 expected, uint256 actual); + + /// @dev Claim condition exceeded max supply + error DropClaimExceedMaxSupply(uint256 expected, uint256 actual); + + /// @dev Claim condition not started yet + error DropClaimNotStarted(uint256 expected, uint256 actual); + + /*/////////////////////////////////////////////////////////////// + State variables + //////////////////////////////////////////////////////////////*/ + + /// @dev Mapping from token ID => the set of all claim conditions, at any given moment, for tokens of the token ID. + mapping(uint256 => ClaimConditionList) public claimCondition; + + /*/////////////////////////////////////////////////////////////// + Drop logic + //////////////////////////////////////////////////////////////*/ + + /// @dev Lets an account claim tokens. + function claim( + address _receiver, + uint256 _tokenId, + uint256 _quantity, + address _currency, + uint256 _pricePerToken, + AllowlistProof calldata _allowlistProof, + bytes memory _data + ) public payable virtual override { + _beforeClaim(_tokenId, _receiver, _quantity, _currency, _pricePerToken, _allowlistProof, _data); + + uint256 activeConditionId = getActiveClaimConditionId(_tokenId); + + verifyClaim( + activeConditionId, + _dropMsgSender(), + _tokenId, + _quantity, + _currency, + _pricePerToken, + _allowlistProof + ); + + // Update contract state. + claimCondition[_tokenId].conditions[activeConditionId].supplyClaimed += _quantity; + claimCondition[_tokenId].supplyClaimedByWallet[activeConditionId][_dropMsgSender()] += _quantity; + + // If there's a price, collect price. + collectPriceOnClaim(_tokenId, address(0), _quantity, _currency, _pricePerToken); + + // Mint the relevant NFTs to claimer. + transferTokensOnClaim(_receiver, _tokenId, _quantity); + + emit TokensClaimed(activeConditionId, _dropMsgSender(), _receiver, _tokenId, _quantity); + + _afterClaim(_tokenId, _receiver, _quantity, _currency, _pricePerToken, _allowlistProof, _data); + } + + /// @dev Lets a contract admin set claim conditions. + function setClaimConditions( + uint256 _tokenId, + ClaimCondition[] calldata _conditions, + bool _resetClaimEligibility + ) external virtual override { + if (!_canSetClaimConditions()) { + revert DropUnauthorized(); + } + ClaimConditionList storage conditionList = claimCondition[_tokenId]; + uint256 existingStartIndex = conditionList.currentStartId; + uint256 existingPhaseCount = conditionList.count; + + /** + * The mapping `supplyClaimedByWallet` uses a claim condition's UID as a key. + * + * If `_resetClaimEligibility == true`, we assign completely new UIDs to the claim + * conditions in `_conditions`, effectively resetting the restrictions on claims expressed + * by `supplyClaimedByWallet`. + */ + uint256 newStartIndex = existingStartIndex; + if (_resetClaimEligibility) { + newStartIndex = existingStartIndex + existingPhaseCount; + } + + conditionList.count = _conditions.length; + conditionList.currentStartId = newStartIndex; + + uint256 lastConditionStartTimestamp; + for (uint256 i = 0; i < _conditions.length; i++) { + require(i == 0 || lastConditionStartTimestamp < _conditions[i].startTimestamp, "ST"); + + uint256 supplyClaimedAlready = conditionList.conditions[newStartIndex + i].supplyClaimed; + if (supplyClaimedAlready > _conditions[i].maxClaimableSupply) { + revert DropExceedMaxSupply(); + } + + conditionList.conditions[newStartIndex + i] = _conditions[i]; + conditionList.conditions[newStartIndex + i].supplyClaimed = supplyClaimedAlready; + + lastConditionStartTimestamp = _conditions[i].startTimestamp; + } + + /** + * Gas refunds (as much as possible) + * + * If `_resetClaimEligibility == true`, we assign completely new UIDs to the claim + * conditions in `_conditions`. So, we delete claim conditions with UID < `newStartIndex`. + * + * If `_resetClaimEligibility == false`, and there are more existing claim conditions + * than in `_conditions`, we delete the existing claim conditions that don't get replaced + * by the conditions in `_conditions`. + */ + if (_resetClaimEligibility) { + for (uint256 i = existingStartIndex; i < newStartIndex; i++) { + delete conditionList.conditions[i]; + } + } else { + if (existingPhaseCount > _conditions.length) { + for (uint256 i = _conditions.length; i < existingPhaseCount; i++) { + delete conditionList.conditions[newStartIndex + i]; + } + } + } + + emit ClaimConditionsUpdated(_tokenId, _conditions, _resetClaimEligibility); + } + + /// @dev Checks a request to claim NFTs against the active claim condition's criteria. + function verifyClaim( + uint256 _conditionId, + address _claimer, + uint256 _tokenId, + uint256 _quantity, + address _currency, + uint256 _pricePerToken, + AllowlistProof calldata _allowlistProof + ) public view virtual returns (bool isOverride) { + ClaimCondition memory currentClaimPhase = claimCondition[_tokenId].conditions[_conditionId]; + uint256 claimLimit = currentClaimPhase.quantityLimitPerWallet; + uint256 claimPrice = currentClaimPhase.pricePerToken; + address claimCurrency = currentClaimPhase.currency; + + /* + * Here `isOverride` implies that if the merkle proof verification fails, + * the claimer would claim through open claim limit instead of allowlisted limit. + */ + if (currentClaimPhase.merkleRoot != bytes32(0)) { + (isOverride, ) = MerkleProof.verify( + _allowlistProof.proof, + currentClaimPhase.merkleRoot, + keccak256( + abi.encodePacked( + _claimer, + _allowlistProof.quantityLimitPerWallet, + _allowlistProof.pricePerToken, + _allowlistProof.currency + ) + ) + ); + } + + if (isOverride) { + claimLimit = _allowlistProof.quantityLimitPerWallet != 0 + ? _allowlistProof.quantityLimitPerWallet + : claimLimit; + claimPrice = _allowlistProof.pricePerToken != type(uint256).max + ? _allowlistProof.pricePerToken + : claimPrice; + claimCurrency = _allowlistProof.pricePerToken != type(uint256).max && _allowlistProof.currency != address(0) + ? _allowlistProof.currency + : claimCurrency; + } + + uint256 supplyClaimedByWallet = claimCondition[_tokenId].supplyClaimedByWallet[_conditionId][_claimer]; + + if (_currency != claimCurrency || _pricePerToken != claimPrice) { + revert DropClaimInvalidTokenPrice(_currency, _pricePerToken, claimCurrency, claimPrice); + } + + if (_quantity == 0 || (_quantity + supplyClaimedByWallet > claimLimit)) { + revert DropClaimExceedLimit(claimLimit, _quantity + supplyClaimedByWallet); + } + + if (currentClaimPhase.supplyClaimed + _quantity > currentClaimPhase.maxClaimableSupply) { + revert DropClaimExceedMaxSupply( + currentClaimPhase.maxClaimableSupply, + currentClaimPhase.supplyClaimed + _quantity + ); + } + + if (currentClaimPhase.startTimestamp > block.timestamp) { + revert DropClaimNotStarted(currentClaimPhase.startTimestamp, block.timestamp); + } + } + + /// @dev At any given moment, returns the uid for the active claim condition. + function getActiveClaimConditionId(uint256 _tokenId) public view returns (uint256) { + ClaimConditionList storage conditionList = claimCondition[_tokenId]; + for (uint256 i = conditionList.currentStartId + conditionList.count; i > conditionList.currentStartId; i--) { + if (block.timestamp >= conditionList.conditions[i - 1].startTimestamp) { + return i - 1; + } + } + + revert DropNoActiveCondition(); + } + + /// @dev Returns the claim condition at the given uid. + function getClaimConditionById( + uint256 _tokenId, + uint256 _conditionId + ) external view returns (ClaimCondition memory condition) { + condition = claimCondition[_tokenId].conditions[_conditionId]; + } + + /// @dev Returns the supply claimed by claimer for a given conditionId. + function getSupplyClaimedByWallet( + uint256 _tokenId, + uint256 _conditionId, + address _claimer + ) public view returns (uint256 supplyClaimedByWallet) { + supplyClaimedByWallet = claimCondition[_tokenId].supplyClaimedByWallet[_conditionId][_claimer]; + } + + /*//////////////////////////////////////////////////////////////////// + Optional hooks that can be implemented in the derived contract + ///////////////////////////////////////////////////////////////////*/ + + /// @dev Exposes the ability to override the msg sender. + function _dropMsgSender() internal virtual returns (address) { + return msg.sender; + } + + /// @dev Runs before every `claim` function call. + function _beforeClaim( + uint256 _tokenId, + address _receiver, + uint256 _quantity, + address _currency, + uint256 _pricePerToken, + AllowlistProof calldata _allowlistProof, + bytes memory _data + ) internal virtual {} + + /// @dev Runs after every `claim` function call. + function _afterClaim( + uint256 _tokenId, + address _receiver, + uint256 _quantity, + address _currency, + uint256 _pricePerToken, + AllowlistProof calldata _allowlistProof, + bytes memory _data + ) internal virtual {} + + /*/////////////////////////////////////////////////////////////// + Virtual functions: to be implemented in derived contract + //////////////////////////////////////////////////////////////*/ + + /// @dev Collects and distributes the primary sale value of NFTs being claimed. + function collectPriceOnClaim( + uint256 _tokenId, + address _primarySaleRecipient, + uint256 _quantityToClaim, + address _currency, + uint256 _pricePerToken + ) internal virtual; + + /// @dev Transfers the NFTs being claimed. + function transferTokensOnClaim(address _to, uint256 _tokenId, uint256 _quantityBeingClaimed) internal virtual; + + /// @dev Determine what wallet can update claim conditions + function _canSetClaimConditions() internal view virtual returns (bool); +} diff --git a/contracts/extension/DropSinglePhase.sol b/contracts/extension/DropSinglePhase.sol new file mode 100644 index 000000000..e7b7cf29a --- /dev/null +++ b/contracts/extension/DropSinglePhase.sol @@ -0,0 +1,237 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "./interface/IDropSinglePhase.sol"; +import "../lib/MerkleProof.sol"; + +abstract contract DropSinglePhase is IDropSinglePhase { + /// @dev The sender is not authorized to perform the action + error DropUnauthorized(); + + /// @dev Exceeded the max token total supply + error DropExceedMaxSupply(); + + /// @dev No active claim condition + error DropNoActiveCondition(); + + /// @dev Claim condition invalid currency or price + error DropClaimInvalidTokenPrice( + address expectedCurrency, + uint256 expectedPricePerToken, + address actualCurrency, + uint256 actualExpectedPricePerToken + ); + + /// @dev Claim condition exceeded limit + error DropClaimExceedLimit(uint256 expected, uint256 actual); + + /// @dev Claim condition exceeded max supply + error DropClaimExceedMaxSupply(uint256 expected, uint256 actual); + + /// @dev Claim condition not started yet + error DropClaimNotStarted(uint256 expected, uint256 actual); + + /*/////////////////////////////////////////////////////////////// + State variables + //////////////////////////////////////////////////////////////*/ + + /// @dev The active conditions for claiming tokens. + ClaimCondition public claimCondition; + + /// @dev The ID for the active claim condition. + bytes32 private conditionId; + + /*/////////////////////////////////////////////////////////////// + Mappings + //////////////////////////////////////////////////////////////*/ + + /** + * @dev Map from a claim condition uid and account to supply claimed by account. + */ + mapping(bytes32 => mapping(address => uint256)) private supplyClaimedByWallet; + + /*/////////////////////////////////////////////////////////////// + Drop logic + //////////////////////////////////////////////////////////////*/ + + /// @dev Lets an account claim tokens. + function claim( + address _receiver, + uint256 _quantity, + address _currency, + uint256 _pricePerToken, + AllowlistProof calldata _allowlistProof, + bytes memory _data + ) public payable virtual override { + _beforeClaim(_receiver, _quantity, _currency, _pricePerToken, _allowlistProof, _data); + + bytes32 activeConditionId = conditionId; + + verifyClaim(_dropMsgSender(), _quantity, _currency, _pricePerToken, _allowlistProof); + + // Update contract state. + claimCondition.supplyClaimed += _quantity; + supplyClaimedByWallet[activeConditionId][_dropMsgSender()] += _quantity; + + // If there's a price, collect price. + _collectPriceOnClaim(address(0), _quantity, _currency, _pricePerToken); + + // Mint the relevant NFTs to claimer. + uint256 startTokenId = _transferTokensOnClaim(_receiver, _quantity); + + emit TokensClaimed(_dropMsgSender(), _receiver, startTokenId, _quantity); + + _afterClaim(_receiver, _quantity, _currency, _pricePerToken, _allowlistProof, _data); + } + + /// @dev Lets a contract admin set claim conditions. + function setClaimConditions(ClaimCondition calldata _condition, bool _resetClaimEligibility) external override { + if (!_canSetClaimConditions()) { + revert DropUnauthorized(); + } + + bytes32 targetConditionId = conditionId; + uint256 supplyClaimedAlready = claimCondition.supplyClaimed; + + if (_resetClaimEligibility) { + supplyClaimedAlready = 0; + targetConditionId = keccak256(abi.encodePacked(_dropMsgSender(), block.number)); + } + + if (supplyClaimedAlready > _condition.maxClaimableSupply) { + revert DropExceedMaxSupply(); + } + + claimCondition = ClaimCondition({ + startTimestamp: _condition.startTimestamp, + maxClaimableSupply: _condition.maxClaimableSupply, + supplyClaimed: supplyClaimedAlready, + quantityLimitPerWallet: _condition.quantityLimitPerWallet, + merkleRoot: _condition.merkleRoot, + pricePerToken: _condition.pricePerToken, + currency: _condition.currency, + metadata: _condition.metadata + }); + conditionId = targetConditionId; + + emit ClaimConditionUpdated(_condition, _resetClaimEligibility); + } + + /// @dev Checks a request to claim NFTs against the active claim condition's criteria. + function verifyClaim( + address _claimer, + uint256 _quantity, + address _currency, + uint256 _pricePerToken, + AllowlistProof calldata _allowlistProof + ) public view virtual returns (bool isOverride) { + ClaimCondition memory currentClaimPhase = claimCondition; + uint256 claimLimit = currentClaimPhase.quantityLimitPerWallet; + uint256 claimPrice = currentClaimPhase.pricePerToken; + address claimCurrency = currentClaimPhase.currency; + + /* + * Here `isOverride` implies that if the merkle proof verification fails, + * the claimer would claim through open claim limit instead of allowlisted limit. + */ + if (currentClaimPhase.merkleRoot != bytes32(0)) { + (isOverride, ) = MerkleProof.verify( + _allowlistProof.proof, + currentClaimPhase.merkleRoot, + keccak256( + abi.encodePacked( + _claimer, + _allowlistProof.quantityLimitPerWallet, + _allowlistProof.pricePerToken, + _allowlistProof.currency + ) + ) + ); + } + + if (isOverride) { + claimLimit = _allowlistProof.quantityLimitPerWallet != 0 + ? _allowlistProof.quantityLimitPerWallet + : claimLimit; + claimPrice = _allowlistProof.pricePerToken != type(uint256).max + ? _allowlistProof.pricePerToken + : claimPrice; + claimCurrency = _allowlistProof.pricePerToken != type(uint256).max && _allowlistProof.currency != address(0) + ? _allowlistProof.currency + : claimCurrency; + } + + uint256 _supplyClaimedByWallet = supplyClaimedByWallet[conditionId][_claimer]; + + if (_currency != claimCurrency || _pricePerToken != claimPrice) { + revert DropClaimInvalidTokenPrice(_currency, _pricePerToken, claimCurrency, claimPrice); + } + + if (_quantity == 0 || (_quantity + _supplyClaimedByWallet > claimLimit)) { + revert DropClaimExceedLimit(claimLimit, _quantity + _supplyClaimedByWallet); + } + + if (currentClaimPhase.supplyClaimed + _quantity > currentClaimPhase.maxClaimableSupply) { + revert DropClaimExceedMaxSupply( + currentClaimPhase.maxClaimableSupply, + currentClaimPhase.supplyClaimed + _quantity + ); + } + + if (currentClaimPhase.startTimestamp > block.timestamp) { + revert DropClaimNotStarted(currentClaimPhase.startTimestamp, block.timestamp); + } + } + + /// @dev Returns the supply claimed by claimer for active conditionId. + function getSupplyClaimedByWallet(address _claimer) public view returns (uint256) { + return supplyClaimedByWallet[conditionId][_claimer]; + } + + /*//////////////////////////////////////////////////////////////////// + Optional hooks that can be implemented in the derived contract + ///////////////////////////////////////////////////////////////////*/ + + /// @dev Exposes the ability to override the msg sender. + function _dropMsgSender() internal virtual returns (address) { + return msg.sender; + } + + /// @dev Runs before every `claim` function call. + function _beforeClaim( + address _receiver, + uint256 _quantity, + address _currency, + uint256 _pricePerToken, + AllowlistProof calldata _allowlistProof, + bytes memory _data + ) internal virtual {} + + /// @dev Runs after every `claim` function call. + function _afterClaim( + address _receiver, + uint256 _quantity, + address _currency, + uint256 _pricePerToken, + AllowlistProof calldata _allowlistProof, + bytes memory _data + ) internal virtual {} + + /// @dev Collects and distributes the primary sale value of NFTs being claimed. + function _collectPriceOnClaim( + address _primarySaleRecipient, + uint256 _quantityToClaim, + address _currency, + uint256 _pricePerToken + ) internal virtual; + + /// @dev Transfers the NFTs being claimed. + function _transferTokensOnClaim( + address _to, + uint256 _quantityBeingClaimed + ) internal virtual returns (uint256 startTokenId); + + function _canSetClaimConditions() internal view virtual returns (bool); +} diff --git a/contracts/extension/DropSinglePhase1155.sol b/contracts/extension/DropSinglePhase1155.sol new file mode 100644 index 000000000..2950ed485 --- /dev/null +++ b/contracts/extension/DropSinglePhase1155.sol @@ -0,0 +1,215 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "./interface/IDropSinglePhase1155.sol"; +import "../lib/MerkleProof.sol"; + +abstract contract DropSinglePhase1155 is IDropSinglePhase1155 { + /*/////////////////////////////////////////////////////////////// + Mappings + //////////////////////////////////////////////////////////////*/ + + /// @dev Mapping from tokenId => active claim condition for the tokenId. + mapping(uint256 => ClaimCondition) public claimCondition; + + /// @dev Mapping from tokenId => active claim condition's UID. + mapping(uint256 => bytes32) private conditionId; + + /** + * @dev Map from a claim condition uid and account to supply claimed by account. + */ + mapping(bytes32 => mapping(address => uint256)) private supplyClaimedByWallet; + + /*/////////////////////////////////////////////////////////////// + Drop logic + //////////////////////////////////////////////////////////////*/ + + /// @dev Lets an account claim tokens. + function claim( + address _receiver, + uint256 _tokenId, + uint256 _quantity, + address _currency, + uint256 _pricePerToken, + AllowlistProof calldata _allowlistProof, + bytes memory _data + ) public payable virtual override { + _beforeClaim(_tokenId, _receiver, _quantity, _currency, _pricePerToken, _allowlistProof, _data); + + ClaimCondition memory condition = claimCondition[_tokenId]; + bytes32 activeConditionId = conditionId[_tokenId]; + + verifyClaim(_tokenId, _dropMsgSender(), _quantity, _currency, _pricePerToken, _allowlistProof); + + // Update contract state. + condition.supplyClaimed += _quantity; + supplyClaimedByWallet[activeConditionId][_dropMsgSender()] += _quantity; + claimCondition[_tokenId] = condition; + + // If there's a price, collect price. + _collectPriceOnClaim(address(0), _quantity, _currency, _pricePerToken); + + // Mint the relevant NFTs to claimer. + _transferTokensOnClaim(_receiver, _tokenId, _quantity); + + emit TokensClaimed(_dropMsgSender(), _receiver, _tokenId, _quantity); + + _afterClaim(_tokenId, _receiver, _quantity, _currency, _pricePerToken, _allowlistProof, _data); + } + + /// @dev Lets a contract admin set claim conditions. + function setClaimConditions( + uint256 _tokenId, + ClaimCondition calldata _condition, + bool _resetClaimEligibility + ) external override { + if (!_canSetClaimConditions()) { + revert("Not authorized"); + } + + ClaimCondition memory condition = claimCondition[_tokenId]; + bytes32 targetConditionId = conditionId[_tokenId]; + + uint256 supplyClaimedAlready = condition.supplyClaimed; + + if (targetConditionId == bytes32(0) || _resetClaimEligibility) { + supplyClaimedAlready = 0; + targetConditionId = keccak256(abi.encodePacked(_dropMsgSender(), block.number, _tokenId)); + } + + if (supplyClaimedAlready > _condition.maxClaimableSupply) { + revert("max supply claimed"); + } + + ClaimCondition memory updatedCondition = ClaimCondition({ + startTimestamp: _condition.startTimestamp, + maxClaimableSupply: _condition.maxClaimableSupply, + supplyClaimed: supplyClaimedAlready, + quantityLimitPerWallet: _condition.quantityLimitPerWallet, + merkleRoot: _condition.merkleRoot, + pricePerToken: _condition.pricePerToken, + currency: _condition.currency, + metadata: _condition.metadata + }); + + claimCondition[_tokenId] = updatedCondition; + conditionId[_tokenId] = targetConditionId; + + emit ClaimConditionUpdated(_tokenId, _condition, _resetClaimEligibility); + } + + /// @dev Checks a request to claim NFTs against the active claim condition's criteria. + function verifyClaim( + uint256 _tokenId, + address _claimer, + uint256 _quantity, + address _currency, + uint256 _pricePerToken, + AllowlistProof calldata _allowlistProof + ) public view virtual returns (bool isOverride) { + ClaimCondition memory currentClaimPhase = claimCondition[_tokenId]; + uint256 claimLimit = currentClaimPhase.quantityLimitPerWallet; + uint256 claimPrice = currentClaimPhase.pricePerToken; + address claimCurrency = currentClaimPhase.currency; + + /* + * Here `isOverride` implies that if the merkle proof verification fails, + * the claimer would claim through open claim limit instead of allowlisted limit. + */ + if (currentClaimPhase.merkleRoot != bytes32(0)) { + (isOverride, ) = MerkleProof.verify( + _allowlistProof.proof, + currentClaimPhase.merkleRoot, + keccak256( + abi.encodePacked( + _claimer, + _allowlistProof.quantityLimitPerWallet, + _allowlistProof.pricePerToken, + _allowlistProof.currency + ) + ) + ); + } + + if (isOverride) { + claimLimit = _allowlistProof.quantityLimitPerWallet != 0 + ? _allowlistProof.quantityLimitPerWallet + : claimLimit; + claimPrice = _allowlistProof.pricePerToken != type(uint256).max + ? _allowlistProof.pricePerToken + : claimPrice; + claimCurrency = _allowlistProof.pricePerToken != type(uint256).max && _allowlistProof.currency != address(0) + ? _allowlistProof.currency + : claimCurrency; + } + + uint256 _supplyClaimedByWallet = supplyClaimedByWallet[conditionId[_tokenId]][_claimer]; + + if (_currency != claimCurrency || _pricePerToken != claimPrice) { + revert("!PriceOrCurrency"); + } + + if (_quantity == 0 || (_quantity + _supplyClaimedByWallet > claimLimit)) { + revert("!Qty"); + } + + if (currentClaimPhase.supplyClaimed + _quantity > currentClaimPhase.maxClaimableSupply) { + revert("!MaxSupply"); + } + + if (currentClaimPhase.startTimestamp > block.timestamp) { + revert("cant claim yet"); + } + } + + /// @dev Returns the supply claimed by claimer for active conditionId. + function getSupplyClaimedByWallet(uint256 _tokenId, address _claimer) public view returns (uint256) { + return supplyClaimedByWallet[conditionId[_tokenId]][_claimer]; + } + + /*//////////////////////////////////////////////////////////////////// + Optional hooks that can be implemented in the derived contract + ///////////////////////////////////////////////////////////////////*/ + + /// @dev Exposes the ability to override the msg sender. + function _dropMsgSender() internal virtual returns (address) { + return msg.sender; + } + + /// @dev Runs before every `claim` function call. + function _beforeClaim( + uint256 _tokenId, + address _receiver, + uint256 _quantity, + address _currency, + uint256 _pricePerToken, + AllowlistProof calldata _allowlistProof, + bytes memory _data + ) internal virtual {} + + /// @dev Runs after every `claim` function call. + function _afterClaim( + uint256 _tokenId, + address _receiver, + uint256 _quantity, + address _currency, + uint256 _pricePerToken, + AllowlistProof calldata _allowlistProof, + bytes memory _data + ) internal virtual {} + + /// @dev Collects and distributes the primary sale value of NFTs being claimed. + function _collectPriceOnClaim( + address _primarySaleRecipient, + uint256 _quantityToClaim, + address _currency, + uint256 _pricePerToken + ) internal virtual; + + /// @dev Transfers the NFTs being claimed. + function _transferTokensOnClaim(address _to, uint256 _tokenId, uint256 _quantityBeingClaimed) internal virtual; + + function _canSetClaimConditions() internal view virtual returns (bool); +} diff --git a/contracts/extension/Initializable.sol b/contracts/extension/Initializable.sol new file mode 100644 index 000000000..b91ab4621 --- /dev/null +++ b/contracts/extension/Initializable.sol @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: Apache 2.0 +pragma solidity ^0.8.0; + +import "../lib/Address.sol"; + +/** + * @dev This is a base contract to aid in writing upgradeable contracts, or any kind of contract that will be deployed + * behind a proxy. Since proxied contracts do not make use of a constructor, it's common to move constructor logic to an + * external initializer function, usually called `initialize`. It then becomes necessary to protect this initializer + * function so it can only be called once. The {initializer} modifier provided by this contract will have this effect. + * + * The initialization functions use a version number. Once a version number is used, it is consumed and cannot be + * reused. This mechanism prevents re-execution of each "step" but allows the creation of new initialization steps in + * case an upgrade adds a module that needs to be initialized. + * + * For example: + * + * [.hljs-theme-light.nopadding] + * ``` + * contract MyToken is ERC20Upgradeable { + * function initialize() initializer public { + * __ERC20_init("MyToken", "MTK"); + * } + * } + * contract MyTokenV2 is MyToken, ERC20PermitUpgradeable { + * function initializeV2() reinitializer(2) public { + * __ERC20Permit_init("MyToken"); + * } + * } + * ``` + * + * TIP: To avoid leaving the proxy in an uninitialized state, the initializer function should be called as early as + * possible by providing the encoded function call as the `_data` argument to {ERC1967Proxy-constructor}. + * + * CAUTION: When used with inheritance, manual care must be taken to not invoke a parent initializer twice, or to ensure + * that all initializers are idempotent. This is not verified automatically as constructors are by Solidity. + * + * [CAUTION] + * ==== + * Avoid leaving a contract uninitialized. + * + * An uninitialized contract can be taken over by an attacker. This applies to both a proxy and its implementation + * contract, which may impact the proxy. To prevent the implementation contract from being used, you should invoke + * the {_disableInitializers} function in the constructor to automatically lock it when it is deployed: + * + * [.hljs-theme-light.nopadding] + * ``` + * /// @custom:oz-upgrades-unsafe-allow constructor + * constructor() { + * _disableInitializers(); + * } + * ``` + * ==== + */ +abstract contract Initializable { + /** + * @dev Indicates that the contract has been initialized. + * @custom:oz-retyped-from bool + */ + uint8 private _initialized; + + /** + * @dev Indicates that the contract is in the process of being initialized. + */ + bool private _initializing; + + /** + * @dev Triggered when the contract has been initialized or reinitialized. + */ + event Initialized(uint8 version); + + /** + * @dev A modifier that defines a protected initializer function that can be invoked at most once. In its scope, + * `onlyInitializing` functions can be used to initialize parent contracts. Equivalent to `reinitializer(1)`. + */ + modifier initializer() { + bool isTopLevelCall = !_initializing; + require( + (isTopLevelCall && _initialized < 1) || (!Address.isContract(address(this)) && _initialized == 1), + "Initializable: contract is already initialized" + ); + _initialized = 1; + if (isTopLevelCall) { + _initializing = true; + } + _; + if (isTopLevelCall) { + _initializing = false; + emit Initialized(1); + } + } + + /** + * @dev A modifier that defines a protected reinitializer function that can be invoked at most once, and only if the + * contract hasn't been initialized to a greater version before. In its scope, `onlyInitializing` functions can be + * used to initialize parent contracts. + * + * `initializer` is equivalent to `reinitializer(1)`, so a reinitializer may be used after the original + * initialization step. This is essential to configure modules that are added through upgrades and that require + * initialization. + * + * Note that versions can jump in increments greater than 1; this implies that if multiple reinitializers coexist in + * a contract, executing them in the right order is up to the developer or operator. + */ + modifier reinitializer(uint8 version) { + require(!_initializing && _initialized < version, "Initializable: contract is already initialized"); + _initialized = version; + _initializing = true; + _; + _initializing = false; + emit Initialized(version); + } + + /** + * @dev Modifier to protect an initialization function so that it can only be invoked by functions with the + * {initializer} and {reinitializer} modifiers, directly or indirectly. + */ + modifier onlyInitializing() { + require(_initializing, "Initializable: contract is not initializing"); + _; + } + + /** + * @dev Locks the contract, preventing any future reinitialization. This cannot be part of an initializer call. + * Calling this in the constructor of a contract will prevent that contract from being initialized or reinitialized + * to any version. It is recommended to use this to lock implementation contracts that are designed to be called + * through proxies. + */ + function _disableInitializers() internal virtual { + require(!_initializing, "Initializable: contract is initializing"); + if (_initialized < type(uint8).max) { + _initialized = type(uint8).max; + emit Initialized(type(uint8).max); + } + } +} diff --git a/contracts/extension/LazyMint.sol b/contracts/extension/LazyMint.sol new file mode 100644 index 000000000..ee59abc17 --- /dev/null +++ b/contracts/extension/LazyMint.sol @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "./interface/ILazyMint.sol"; +import "./BatchMintMetadata.sol"; + +/** + * The `LazyMint` is a contract extension for any base NFT contract. It lets you 'lazy mint' any number of NFTs + * at once. Here, 'lazy mint' means defining the metadata for particular tokenIds of your NFT contract, without actually + * minting a non-zero balance of NFTs of those tokenIds. + */ + +abstract contract LazyMint is ILazyMint, BatchMintMetadata { + /// @dev The sender is not authorized to perform the action + error LazyMintUnauthorized(); + error LazyMintInvalidAmount(); + + /// @notice The tokenId assigned to the next new NFT to be lazy minted. + uint256 internal nextTokenIdToLazyMint; + + /** + * @notice Lets an authorized address lazy mint a given amount of NFTs. + * + * @param _amount The number of NFTs to lazy mint. + * @param _baseURIForTokens The base URI for the 'n' number of NFTs being lazy minted, where the metadata for each + * of those NFTs is `${baseURIForTokens}/${tokenId}`. + * @param _data Additional bytes data to be used at the discretion of the consumer of the contract. + * @return batchId A unique integer identifier for the batch of NFTs lazy minted together. + */ + function lazyMint( + uint256 _amount, + string calldata _baseURIForTokens, + bytes calldata _data + ) public virtual override returns (uint256 batchId) { + if (!_canLazyMint()) { + revert LazyMintUnauthorized(); + } + + if (_amount == 0) { + revert LazyMintInvalidAmount(); + } + + uint256 startId = nextTokenIdToLazyMint; + + (nextTokenIdToLazyMint, batchId) = _batchMintMetadata(startId, _amount, _baseURIForTokens); + + emit TokensLazyMinted(startId, startId + _amount - 1, _baseURIForTokens, _data); + + return batchId; + } + + /// @dev Returns whether lazy minting can be performed in the given execution context. + function _canLazyMint() internal view virtual returns (bool); +} diff --git a/contracts/extension/LazyMintWithTier.sol b/contracts/extension/LazyMintWithTier.sol new file mode 100644 index 000000000..50758b55b --- /dev/null +++ b/contracts/extension/LazyMintWithTier.sol @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "./interface/ILazyMintWithTier.sol"; +import "../extension/BatchMintMetadata.sol"; + +/** + * The `LazyMint` is a contract extension for any base NFT contract. It lets you 'lazy mint' any number of NFTs + * at once. Here, 'lazy mint' means defining the metadata for particular tokenIds of your NFT contract, without actually + * minting a non-zero balance of NFTs of those tokenIds. + */ + +abstract contract LazyMintWithTier is ILazyMintWithTier, BatchMintMetadata { + struct TokenRange { + uint256 startIdInclusive; + uint256 endIdNonInclusive; + } + + struct TierMetadata { + string tier; + TokenRange[] ranges; + string[] baseURIs; + } + + /// @notice The tokenId assigned to the next new NFT to be lazy minted. + uint256 internal nextTokenIdToLazyMint; + + /// @notice Mapping from a tier -> the token IDs grouped under that tier. + mapping(string => TokenRange[]) internal tokensInTier; + + /// @notice A list of tiers used in this contract. + string[] private tiers; + + /** + * @notice Lets an authorized address lazy mint a given amount of NFTs. + * + * @param _amount The number of NFTs to lazy mint. + * @param _baseURIForTokens The base URI for the 'n' number of NFTs being lazy minted, where the metadata for each + * of those NFTs is `${baseURIForTokens}/${tokenId}`. + * @param _data Additional bytes data to be used at the discretion of the consumer of the contract. + * @return batchId A unique integer identifier for the batch of NFTs lazy minted together. + */ + function lazyMint( + uint256 _amount, + string calldata _baseURIForTokens, + string calldata _tier, + bytes calldata _data + ) public virtual override returns (uint256 batchId) { + if (!_canLazyMint()) { + revert("Not authorized"); + } + + if (_amount == 0) { + revert("0 amt"); + } + + uint256 startId = nextTokenIdToLazyMint; + + (nextTokenIdToLazyMint, batchId) = _batchMintMetadata(startId, _amount, _baseURIForTokens); + + // Handle tier info. + if (!(tokensInTier[_tier].length > 0)) { + tiers.push(_tier); + } + tokensInTier[_tier].push(TokenRange(startId, batchId)); + + emit TokensLazyMinted(_tier, startId, startId + _amount - 1, _baseURIForTokens, _data); + + return batchId; + } + + /// @notice Returns all metadata lazy minted for the given tier. + function _getMetadataInTier( + string memory _tier + ) private view returns (TokenRange[] memory tokens, string[] memory baseURIs) { + tokens = tokensInTier[_tier]; + + uint256 len = tokens.length; + baseURIs = new string[](len); + + for (uint256 i = 0; i < len; i += 1) { + baseURIs[i] = _getBaseURI(tokens[i].startIdInclusive); + } + } + + /// @notice Returns all metadata for all tiers created on the contract. + function getMetadataForAllTiers() external view returns (TierMetadata[] memory metadataForAllTiers) { + string[] memory allTiers = tiers; + uint256 len = allTiers.length; + + metadataForAllTiers = new TierMetadata[](len); + + for (uint256 i = 0; i < len; i += 1) { + (TokenRange[] memory tokens, string[] memory baseURIs) = _getMetadataInTier(allTiers[i]); + metadataForAllTiers[i] = TierMetadata(allTiers[i], tokens, baseURIs); + } + } + + /** + * @notice Returns whether any metadata is lazy minted for the given tier. + * + * @param _tier We check whether this given tier is empty. + */ + function isTierEmpty(string memory _tier) internal view returns (bool) { + return tokensInTier[_tier].length == 0; + } + + /// @dev Returns whether lazy minting can be performed in the given execution context. + function _canLazyMint() internal view virtual returns (bool); +} diff --git a/contracts/extension/Multicall.sol b/contracts/extension/Multicall.sol new file mode 100644 index 000000000..043d6c3c0 --- /dev/null +++ b/contracts/extension/Multicall.sol @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: Apache 2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "../lib/Address.sol"; +import "./interface/IMulticall.sol"; + +/** + * @dev Provides a function to batch together multiple calls in a single external call. + * + * _Available since v4.1._ + */ +contract Multicall is IMulticall { + /** + * @notice Receives and executes a batch of function calls on this contract. + * @dev Receives and executes a batch of function calls on this contract. + * + * @param data The bytes data that makes up the batch of function calls to execute. + * @return results The bytes data that makes up the result of the batch of function calls executed. + */ + function multicall(bytes[] calldata data) external returns (bytes[] memory results) { + results = new bytes[](data.length); + address sender = _msgSender(); + bool isForwarder = msg.sender != sender; + for (uint256 i = 0; i < data.length; i++) { + if (isForwarder) { + results[i] = Address.functionDelegateCall(address(this), abi.encodePacked(data[i], sender)); + } else { + results[i] = Address.functionDelegateCall(address(this), data[i]); + } + } + return results; + } + + /// @notice Returns the sender in the given execution context. + function _msgSender() internal view virtual returns (address) { + return msg.sender; + } +} diff --git a/contracts/extension/NFTMetadata.sol b/contracts/extension/NFTMetadata.sol new file mode 100644 index 000000000..3a0ded1e1 --- /dev/null +++ b/contracts/extension/NFTMetadata.sol @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "./interface/INFTMetadata.sol"; + +abstract contract NFTMetadata is INFTMetadata { + /// @dev The sender is not authorized to perform the action + error NFTMetadataUnauthorized(); + + /// @dev Invalid token metadata url + error NFTMetadataInvalidUrl(); + + /// @dev the nft metadata is frozen + error NFTMetadataFrozen(uint256 tokenId); + + bool public uriFrozen; + + mapping(uint256 => string) internal _tokenURI; + + /// @notice Returns the metadata URI for a given NFT. + function _getTokenURI(uint256 _tokenId) internal view virtual returns (string memory) { + return _tokenURI[_tokenId]; + } + + /// @notice Sets the metadata URI for a given NFT. + function _setTokenURI(uint256 _tokenId, string memory _uri) internal virtual { + if (bytes(_uri).length == 0) { + revert NFTMetadataInvalidUrl(); + } + _tokenURI[_tokenId] = _uri; + + emit MetadataUpdate(_tokenId); + } + + /// @notice Sets the metadata URI for a given NFT. + function setTokenURI(uint256 _tokenId, string memory _uri) public virtual { + if (!_canSetMetadata()) { + revert NFTMetadataUnauthorized(); + } + if (uriFrozen) { + revert NFTMetadataFrozen(_tokenId); + } + _setTokenURI(_tokenId, _uri); + } + + function freezeMetadata() public virtual { + if (!_canFreezeMetadata()) { + revert NFTMetadataUnauthorized(); + } + uriFrozen = true; + emit MetadataFrozen(); + } + + /// @dev Returns whether metadata can be set in the given execution context. + function _canSetMetadata() internal view virtual returns (bool); + + function _canFreezeMetadata() internal view virtual returns (bool); +} diff --git a/contracts/extension/OperatorFilterToggle.sol b/contracts/extension/OperatorFilterToggle.sol new file mode 100644 index 000000000..7094026e8 --- /dev/null +++ b/contracts/extension/OperatorFilterToggle.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: Apache 2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "./interface/IOperatorFilterToggle.sol"; + +abstract contract OperatorFilterToggle is IOperatorFilterToggle { + bool public operatorRestriction; + + function setOperatorRestriction(bool _restriction) external { + require(_canSetOperatorRestriction(), "Not authorized to set operator restriction."); + _setOperatorRestriction(_restriction); + } + + function _setOperatorRestriction(bool _restriction) internal { + operatorRestriction = _restriction; + emit OperatorRestriction(_restriction); + } + + function _canSetOperatorRestriction() internal virtual returns (bool); +} diff --git a/contracts/extension/OperatorFilterer.sol b/contracts/extension/OperatorFilterer.sol new file mode 100644 index 000000000..57b3554d2 --- /dev/null +++ b/contracts/extension/OperatorFilterer.sol @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: Apache 2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "./interface/IOperatorFilterRegistry.sol"; +import "./OperatorFilterToggle.sol"; + +/** + * @title OperatorFilterer + * @notice Abstract contract whose constructor automatically registers and optionally subscribes to or copies another + * registrant's entries in the OperatorFilterRegistry. + * @dev This smart contract is meant to be inherited by token contracts so they can use the following: + * - `onlyAllowedOperator` modifier for `transferFrom` and `safeTransferFrom` methods. + * - `onlyAllowedOperatorApproval` modifier for `approve` and `setApprovalForAll` methods. + */ + +abstract contract OperatorFilterer is OperatorFilterToggle { + error OperatorNotAllowed(address operator); + + IOperatorFilterRegistry public constant OPERATOR_FILTER_REGISTRY = + IOperatorFilterRegistry(0x000000000000AAeB6D7670E522A718067333cd4E); + + constructor(address subscriptionOrRegistrantToCopy, bool subscribe) { + // If an inheriting token contract is deployed to a network without the registry deployed, the modifier + // will not revert, but the contract will need to be registered with the registry once it is deployed in + // order for the modifier to filter addresses. + _register(subscriptionOrRegistrantToCopy, subscribe); + } + + modifier onlyAllowedOperator(address from) virtual { + // Allow spending tokens from addresses with balance + // Note that this still allows listings and marketplaces with escrow to transfer tokens if transferred + // from an EOA. + if (from != msg.sender) { + _checkFilterOperator(msg.sender); + } + _; + } + + modifier onlyAllowedOperatorApproval(address operator) virtual { + _checkFilterOperator(operator); + _; + } + + function _checkFilterOperator(address operator) internal view virtual { + // Check registry code length to facilitate testing in environments without a deployed registry. + if (operatorRestriction) { + if (address(OPERATOR_FILTER_REGISTRY).code.length > 0) { + if (!OPERATOR_FILTER_REGISTRY.isOperatorAllowed(address(this), operator)) { + revert OperatorNotAllowed(operator); + } + } + } + } + + function _register(address subscriptionOrRegistrantToCopy, bool subscribe) internal { + // Is the registry deployed? + if (address(OPERATOR_FILTER_REGISTRY).code.length > 0) { + // Is the subscription contract deployed? + if (address(subscriptionOrRegistrantToCopy).code.length > 0) { + // Do we want to subscribe? + if (subscribe) { + OPERATOR_FILTER_REGISTRY.registerAndSubscribe(address(this), subscriptionOrRegistrantToCopy); + } else { + OPERATOR_FILTER_REGISTRY.registerAndCopyEntries(address(this), subscriptionOrRegistrantToCopy); + } + } else { + OPERATOR_FILTER_REGISTRY.register(address(this)); + } + } + } +} diff --git a/contracts/extension/OperatorFiltererUpgradeable.sol b/contracts/extension/OperatorFiltererUpgradeable.sol new file mode 100644 index 000000000..a656d46ce --- /dev/null +++ b/contracts/extension/OperatorFiltererUpgradeable.sol @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: Apache 2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "./interface/IOperatorFilterRegistry.sol"; +import "./OperatorFilterToggle.sol"; + +abstract contract OperatorFiltererUpgradeable is OperatorFilterToggle { + error OperatorNotAllowed(address operator); + + IOperatorFilterRegistry constant OPERATOR_FILTER_REGISTRY = + IOperatorFilterRegistry(0x000000000000AAeB6D7670E522A718067333cd4E); + + function __OperatorFilterer_init(address subscriptionOrRegistrantToCopy, bool subscribe) internal { + // If an inheriting token contract is deployed to a network without the registry deployed, the modifier + // will not revert, but the contract will need to be registered with the registry once it is deployed in + // order for the modifier to filter addresses. + _register(subscriptionOrRegistrantToCopy, subscribe); + } + + modifier onlyAllowedOperator(address from) virtual { + // Check registry code length to facilitate testing in environments without a deployed registry. + if (operatorRestriction) { + if (address(OPERATOR_FILTER_REGISTRY).code.length > 0) { + // Allow spending tokens from addresses with balance + // Note that this still allows listings and marketplaces with escrow to transfer tokens if transferred + // from an EOA. + if (from == msg.sender) { + _; + return; + } + if (!OPERATOR_FILTER_REGISTRY.isOperatorAllowed(address(this), msg.sender)) { + revert OperatorNotAllowed(msg.sender); + } + } + } + _; + } + + modifier onlyAllowedOperatorApproval(address operator) virtual { + // Check registry code length to facilitate testing in environments without a deployed registry. + if (operatorRestriction) { + if (address(OPERATOR_FILTER_REGISTRY).code.length > 0) { + if (!OPERATOR_FILTER_REGISTRY.isOperatorAllowed(address(this), operator)) { + revert OperatorNotAllowed(operator); + } + } + } + _; + } + + function _register(address subscriptionOrRegistrantToCopy, bool subscribe) internal { + // Is the registry deployed? + if (address(OPERATOR_FILTER_REGISTRY).code.length > 0) { + // Is the subscription contract deployed? + if (address(subscriptionOrRegistrantToCopy).code.length > 0) { + // Do we want to subscribe? + if (subscribe) { + OPERATOR_FILTER_REGISTRY.registerAndSubscribe(address(this), subscriptionOrRegistrantToCopy); + } else { + OPERATOR_FILTER_REGISTRY.registerAndCopyEntries(address(this), subscriptionOrRegistrantToCopy); + } + } else { + OPERATOR_FILTER_REGISTRY.register(address(this)); + } + } + } +} diff --git a/contracts/extension/Ownable.sol b/contracts/extension/Ownable.sol new file mode 100644 index 000000000..bdfb98d47 --- /dev/null +++ b/contracts/extension/Ownable.sol @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "./interface/IOwnable.sol"; + +/** + * @title Ownable + * @notice Thirdweb's `Ownable` is a contract extension to be used with any base contract. It exposes functions for setting and reading + * who the 'owner' of the inheriting smart contract is, and lets the inheriting contract perform conditional logic that uses + * information about who the contract's owner is. + */ + +abstract contract Ownable is IOwnable { + /// @dev The sender is not authorized to perform the action + error OwnableUnauthorized(); + + /// @dev Owner of the contract (purpose: OpenSea compatibility) + address private _owner; + + /// @dev Reverts if caller is not the owner. + modifier onlyOwner() { + if (msg.sender != _owner) { + revert OwnableUnauthorized(); + } + _; + } + + /** + * @notice Returns the owner of the contract. + */ + function owner() public view override returns (address) { + return _owner; + } + + /** + * @notice Lets an authorized wallet set a new owner for the contract. + * @param _newOwner The address to set as the new owner of the contract. + */ + function setOwner(address _newOwner) external override { + if (!_canSetOwner()) { + revert OwnableUnauthorized(); + } + _setupOwner(_newOwner); + } + + /// @dev Lets a contract admin set a new owner for the contract. The new owner must be a contract admin. + function _setupOwner(address _newOwner) internal { + address _prevOwner = _owner; + _owner = _newOwner; + + emit OwnerUpdated(_prevOwner, _newOwner); + } + + /// @dev Returns whether owner can be set in the given execution context. + function _canSetOwner() internal view virtual returns (bool); +} diff --git a/contracts/extension/Permissions.sol b/contracts/extension/Permissions.sol new file mode 100644 index 000000000..29362ddcf --- /dev/null +++ b/contracts/extension/Permissions.sol @@ -0,0 +1,159 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "./interface/IPermissions.sol"; +import "../lib/Strings.sol"; + +/** + * @title Permissions + * @dev This contracts provides extending-contracts with role-based access control mechanisms + */ +contract Permissions is IPermissions { + /// @dev The `account` is missing a role. + error PermissionsUnauthorizedAccount(address account, bytes32 neededRole); + + /// @dev The `account` already is a holder of `role` + error PermissionsAlreadyGranted(address account, bytes32 role); + + /// @dev Invalid priviledge to revoke + error PermissionsInvalidPermission(address expected, address actual); + + /// @dev Map from keccak256 hash of a role => a map from address => whether address has role. + mapping(bytes32 => mapping(address => bool)) private _hasRole; + + /// @dev Map from keccak256 hash of a role to role admin. See {getRoleAdmin}. + mapping(bytes32 => bytes32) private _getRoleAdmin; + + /// @dev Default admin role for all roles. Only accounts with this role can grant/revoke other roles. + bytes32 public constant DEFAULT_ADMIN_ROLE = 0x00; + + /// @dev Modifier that checks if an account has the specified role; reverts otherwise. + modifier onlyRole(bytes32 role) { + _checkRole(role, msg.sender); + _; + } + + /** + * @notice Checks whether an account has a particular role. + * @dev Returns `true` if `account` has been granted `role`. + * + * @param role keccak256 hash of the role. e.g. keccak256("TRANSFER_ROLE") + * @param account Address of the account for which the role is being checked. + */ + function hasRole(bytes32 role, address account) public view override returns (bool) { + return _hasRole[role][account]; + } + + /** + * @notice Checks whether an account has a particular role; + * role restrictions can be swtiched on and off. + * + * @dev Returns `true` if `account` has been granted `role`. + * Role restrictions can be swtiched on and off: + * - If address(0) has ROLE, then the ROLE restrictions + * don't apply. + * - If address(0) does not have ROLE, then the ROLE + * restrictions will apply. + * + * @param role keccak256 hash of the role. e.g. keccak256("TRANSFER_ROLE") + * @param account Address of the account for which the role is being checked. + */ + function hasRoleWithSwitch(bytes32 role, address account) public view returns (bool) { + if (!_hasRole[role][address(0)]) { + return _hasRole[role][account]; + } + + return true; + } + + /** + * @notice Returns the admin role that controls the specified role. + * @dev See {grantRole} and {revokeRole}. + * To change a role's admin, use {_setRoleAdmin}. + * + * @param role keccak256 hash of the role. e.g. keccak256("TRANSFER_ROLE") + */ + function getRoleAdmin(bytes32 role) external view override returns (bytes32) { + return _getRoleAdmin[role]; + } + + /** + * @notice Grants a role to an account, if not previously granted. + * @dev Caller must have admin role for the `role`. + * Emits {RoleGranted Event}. + * + * @param role keccak256 hash of the role. e.g. keccak256("TRANSFER_ROLE") + * @param account Address of the account to which the role is being granted. + */ + function grantRole(bytes32 role, address account) public virtual override { + _checkRole(_getRoleAdmin[role], msg.sender); + if (_hasRole[role][account]) { + revert PermissionsAlreadyGranted(account, role); + } + _setupRole(role, account); + } + + /** + * @notice Revokes role from an account. + * @dev Caller must have admin role for the `role`. + * Emits {RoleRevoked Event}. + * + * @param role keccak256 hash of the role. e.g. keccak256("TRANSFER_ROLE") + * @param account Address of the account from which the role is being revoked. + */ + function revokeRole(bytes32 role, address account) public virtual override { + _checkRole(_getRoleAdmin[role], msg.sender); + _revokeRole(role, account); + } + + /** + * @notice Revokes role from the account. + * @dev Caller must have the `role`, with caller being the same as `account`. + * Emits {RoleRevoked Event}. + * + * @param role keccak256 hash of the role. e.g. keccak256("TRANSFER_ROLE") + * @param account Address of the account from which the role is being revoked. + */ + function renounceRole(bytes32 role, address account) public virtual override { + if (msg.sender != account) { + revert PermissionsInvalidPermission(msg.sender, account); + } + _revokeRole(role, account); + } + + /// @dev Sets `adminRole` as `role`'s admin role. + function _setRoleAdmin(bytes32 role, bytes32 adminRole) internal virtual { + bytes32 previousAdminRole = _getRoleAdmin[role]; + _getRoleAdmin[role] = adminRole; + emit RoleAdminChanged(role, previousAdminRole, adminRole); + } + + /// @dev Sets up `role` for `account` + function _setupRole(bytes32 role, address account) internal virtual { + _hasRole[role][account] = true; + emit RoleGranted(role, account, msg.sender); + } + + /// @dev Revokes `role` from `account` + function _revokeRole(bytes32 role, address account) internal virtual { + _checkRole(role, account); + delete _hasRole[role][account]; + emit RoleRevoked(role, account, msg.sender); + } + + /// @dev Checks `role` for `account`. Reverts with a message including the required role. + function _checkRole(bytes32 role, address account) internal view virtual { + if (!_hasRole[role][account]) { + revert PermissionsUnauthorizedAccount(account, role); + } + } + + /// @dev Checks `role` for `account`. Reverts with a message including the required role. + function _checkRoleWithSwitch(bytes32 role, address account) internal view virtual { + if (!hasRoleWithSwitch(role, account)) { + revert PermissionsUnauthorizedAccount(account, role); + } + } +} diff --git a/contracts/extension/PermissionsEnumerable.sol b/contracts/extension/PermissionsEnumerable.sol new file mode 100644 index 000000000..f5480c600 --- /dev/null +++ b/contracts/extension/PermissionsEnumerable.sol @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "./interface/IPermissionsEnumerable.sol"; +import "./Permissions.sol"; + +/** + * @title PermissionsEnumerable + * @dev This contracts provides extending-contracts with role-based access control mechanisms. + * Also provides interfaces to view all members with a given role, and total count of members. + */ +contract PermissionsEnumerable is IPermissionsEnumerable, Permissions { + /** + * @notice A data structure to store data of members for a given role. + * + * @param index Current index in the list of accounts that have a role. + * @param members map from index => address of account that has a role + * @param indexOf map from address => index which the account has. + */ + struct RoleMembers { + uint256 index; + mapping(uint256 => address) members; + mapping(address => uint256) indexOf; + } + + /// @dev map from keccak256 hash of a role to its members' data. See {RoleMembers}. + mapping(bytes32 => RoleMembers) private roleMembers; + + /** + * @notice Returns the role-member from a list of members for a role, + * at a given index. + * @dev Returns `member` who has `role`, at `index` of role-members list. + * See struct {RoleMembers}, and mapping {roleMembers} + * + * @param role keccak256 hash of the role. e.g. keccak256("TRANSFER_ROLE") + * @param index Index in list of current members for the role. + * + * @return member Address of account that has `role` + */ + function getRoleMember(bytes32 role, uint256 index) external view override returns (address member) { + uint256 currentIndex = roleMembers[role].index; + uint256 check; + + for (uint256 i = 0; i < currentIndex; i += 1) { + if (roleMembers[role].members[i] != address(0)) { + if (check == index) { + member = roleMembers[role].members[i]; + return member; + } + check += 1; + } else if (hasRole(role, address(0)) && i == roleMembers[role].indexOf[address(0)]) { + check += 1; + } + } + } + + /** + * @notice Returns total number of accounts that have a role. + * @dev Returns `count` of accounts that have `role`. + * See struct {RoleMembers}, and mapping {roleMembers} + * + * @param role keccak256 hash of the role. e.g. keccak256("TRANSFER_ROLE") + * + * @return count Total number of accounts that have `role` + */ + function getRoleMemberCount(bytes32 role) external view override returns (uint256 count) { + uint256 currentIndex = roleMembers[role].index; + + for (uint256 i = 0; i < currentIndex; i += 1) { + if (roleMembers[role].members[i] != address(0)) { + count += 1; + } + } + if (hasRole(role, address(0))) { + count += 1; + } + } + + /// @dev Revokes `role` from `account`, and removes `account` from {roleMembers} + /// See {_removeMember} + function _revokeRole(bytes32 role, address account) internal override { + super._revokeRole(role, account); + _removeMember(role, account); + } + + /// @dev Grants `role` to `account`, and adds `account` to {roleMembers} + /// See {_addMember} + function _setupRole(bytes32 role, address account) internal override { + super._setupRole(role, account); + _addMember(role, account); + } + + /// @dev adds `account` to {roleMembers}, for `role` + function _addMember(bytes32 role, address account) internal { + uint256 idx = roleMembers[role].index; + roleMembers[role].index += 1; + + roleMembers[role].members[idx] = account; + roleMembers[role].indexOf[account] = idx; + } + + /// @dev removes `account` from {roleMembers}, for `role` + function _removeMember(bytes32 role, address account) internal { + uint256 idx = roleMembers[role].indexOf[account]; + + delete roleMembers[role].members[idx]; + delete roleMembers[role].indexOf[account]; + } +} diff --git a/contracts/extension/PlatformFee.sol b/contracts/extension/PlatformFee.sol new file mode 100644 index 000000000..4c92c20cb --- /dev/null +++ b/contracts/extension/PlatformFee.sol @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "./interface/IPlatformFee.sol"; + +/** + * @title Platform Fee + * @notice Thirdweb's `PlatformFee` is a contract extension to be used with any base contract. It exposes functions for setting and reading + * the recipient of platform fee and the platform fee basis points, and lets the inheriting contract perform conditional logic + * that uses information about platform fees, if desired. + */ + +abstract contract PlatformFee is IPlatformFee { + /// @dev The sender is not authorized to perform the action + error PlatformFeeUnauthorized(); + + /// @dev The recipient is invalid + error PlatformFeeInvalidRecipient(address recipient); + + /// @dev The fee bps exceeded the max value + error PlatformFeeExceededMaxFeeBps(uint256 max, uint256 actual); + + /// @dev The address that receives all platform fees from all sales. + address private platformFeeRecipient; + + /// @dev The % of primary sales collected as platform fees. + uint16 private platformFeeBps; + + /// @dev Fee type variants: percentage fee and flat fee + PlatformFeeType private platformFeeType; + + /// @dev The flat amount collected by the contract as fees on primary sales. + uint256 private flatPlatformFee; + + /// @dev Returns the platform fee recipient and bps. + function getPlatformFeeInfo() public view override returns (address, uint16) { + return (platformFeeRecipient, uint16(platformFeeBps)); + } + + /// @dev Returns the platform fee bps and recipient. + function getFlatPlatformFeeInfo() public view returns (address, uint256) { + return (platformFeeRecipient, flatPlatformFee); + } + + /// @dev Returns the platform fee type. + function getPlatformFeeType() public view returns (PlatformFeeType) { + return platformFeeType; + } + + /** + * @notice Updates the platform fee recipient and bps. + * @dev Caller should be authorized to set platform fee info. + * See {_canSetPlatformFeeInfo}. + * Emits {PlatformFeeInfoUpdated Event}; See {_setupPlatformFeeInfo}. + * + * @param _platformFeeRecipient Address to be set as new platformFeeRecipient. + * @param _platformFeeBps Updated platformFeeBps. + */ + function setPlatformFeeInfo(address _platformFeeRecipient, uint256 _platformFeeBps) external override { + if (!_canSetPlatformFeeInfo()) { + revert PlatformFeeUnauthorized(); + } + _setupPlatformFeeInfo(_platformFeeRecipient, _platformFeeBps); + } + + /// @dev Sets the platform fee recipient and bps + function _setupPlatformFeeInfo(address _platformFeeRecipient, uint256 _platformFeeBps) internal { + if (_platformFeeBps > 10_000) { + revert PlatformFeeExceededMaxFeeBps(10_000, _platformFeeBps); + } + if (_platformFeeRecipient == address(0)) { + revert PlatformFeeInvalidRecipient(_platformFeeRecipient); + } + + platformFeeBps = uint16(_platformFeeBps); + platformFeeRecipient = _platformFeeRecipient; + + emit PlatformFeeInfoUpdated(_platformFeeRecipient, _platformFeeBps); + } + + /// @notice Lets a module admin set a flat fee on primary sales. + function setFlatPlatformFeeInfo(address _platformFeeRecipient, uint256 _flatFee) external { + if (!_canSetPlatformFeeInfo()) { + revert PlatformFeeUnauthorized(); + } + + _setupFlatPlatformFeeInfo(_platformFeeRecipient, _flatFee); + } + + /// @dev Sets a flat fee on primary sales. + function _setupFlatPlatformFeeInfo(address _platformFeeRecipient, uint256 _flatFee) internal { + flatPlatformFee = _flatFee; + platformFeeRecipient = _platformFeeRecipient; + + emit FlatPlatformFeeUpdated(_platformFeeRecipient, _flatFee); + } + + /// @notice Lets a module admin set platform fee type. + function setPlatformFeeType(PlatformFeeType _feeType) external { + if (!_canSetPlatformFeeInfo()) { + revert PlatformFeeUnauthorized(); + } + _setupPlatformFeeType(_feeType); + } + + /// @dev Sets platform fee type. + function _setupPlatformFeeType(PlatformFeeType _feeType) internal { + platformFeeType = _feeType; + + emit PlatformFeeTypeUpdated(_feeType); + } + + /// @dev Returns whether platform fee info can be set in the given execution context. + function _canSetPlatformFeeInfo() internal view virtual returns (bool); +} diff --git a/contracts/extension/PrimarySale.sol b/contracts/extension/PrimarySale.sol new file mode 100644 index 000000000..ca3588edd --- /dev/null +++ b/contracts/extension/PrimarySale.sol @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "./interface/IPrimarySale.sol"; + +/** + * @title Primary Sale + * @notice Thirdweb's `PrimarySale` is a contract extension to be used with any base contract. It exposes functions for setting and reading + * the recipient of primary sales, and lets the inheriting contract perform conditional logic that uses information about + * primary sales, if desired. + */ + +abstract contract PrimarySale is IPrimarySale { + /// @dev The sender is not authorized to perform the action + error PrimarySaleUnauthorized(); + + /// @dev The recipient is invalid + error PrimarySaleInvalidRecipient(address recipient); + + /// @dev The address that receives all primary sales value. + address private recipient; + + /// @dev Returns primary sale recipient address. + function primarySaleRecipient() public view override returns (address) { + return recipient; + } + + /** + * @notice Updates primary sale recipient. + * @dev Caller should be authorized to set primary sales info. + * See {_canSetPrimarySaleRecipient}. + * Emits {PrimarySaleRecipientUpdated Event}; See {_setupPrimarySaleRecipient}. + * + * @param _saleRecipient Address to be set as new recipient of primary sales. + */ + function setPrimarySaleRecipient(address _saleRecipient) external override { + if (!_canSetPrimarySaleRecipient()) { + revert PrimarySaleUnauthorized(); + } + _setupPrimarySaleRecipient(_saleRecipient); + } + + /// @dev Lets a contract admin set the recipient for all primary sales. + function _setupPrimarySaleRecipient(address _saleRecipient) internal { + if (_saleRecipient == address(0)) { + revert PrimarySaleInvalidRecipient(_saleRecipient); + } + + recipient = _saleRecipient; + emit PrimarySaleRecipientUpdated(_saleRecipient); + } + + /// @dev Returns whether primary sale recipient can be set in the given execution context. + function _canSetPrimarySaleRecipient() internal view virtual returns (bool); +} diff --git a/contracts/extension/Proxy.sol b/contracts/extension/Proxy.sol new file mode 100644 index 000000000..bb632e93d --- /dev/null +++ b/contracts/extension/Proxy.sol @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: Apache 2.0 +pragma solidity ^0.8.0; + +/** + * @dev This abstract contract provides a fallback function that delegates all calls to another contract using the EVM + * instruction `delegatecall`. We refer to the second contract as the _implementation_ behind the proxy, and it has to + * be specified by overriding the virtual {_implementation} function. + * + * Additionally, delegation to the implementation can be triggered manually through the {_fallback} function, or to a + * different contract through the {_delegate} function. + * + * The success and return data of the delegated call will be returned back to the caller of the proxy. + */ +abstract contract Proxy { + /** + * @dev Delegates the current call to `implementation`. + * + * This function does not return to its internal call site, it will return directly to the external caller. + */ + function _delegate(address implementation) internal virtual { + assembly { + // Copy msg.data. We take full control of memory in this inline assembly + // block because it will not return to Solidity code. We overwrite the + // Solidity scratch pad at memory position 0. + calldatacopy(0, 0, calldatasize()) + + // Call the implementation. + // out and outsize are 0 because we don't know the size yet. + let result := delegatecall(gas(), implementation, 0, calldatasize(), 0, 0) + + // Copy the returned data. + returndatacopy(0, 0, returndatasize()) + + switch result + // delegatecall returns 0 on error. + case 0 { + revert(0, returndatasize()) + } + default { + return(0, returndatasize()) + } + } + } + + /** + * @dev This is a virtual function that should be overridden so it returns the address to which the fallback function + * and {_fallback} should delegate. + */ + function _implementation() internal view virtual returns (address); + + /** + * @dev Delegates the current call to the address returned by `_implementation()`. + * + * This function does not return to its internal call site, it will return directly to the external caller. + */ + function _fallback() internal virtual { + _beforeFallback(); + _delegate(_implementation()); + } + + /** + * @dev Fallback function that delegates calls to the address returned by `_implementation()`. Will run if no other + * function in the contract matches the call data. + */ + fallback() external payable virtual { + _fallback(); + } + + /** + * @dev Fallback function that delegates calls to the address returned by `_implementation()`. Will run if call data + * is empty. + */ + receive() external payable virtual { + _fallback(); + } + + /** + * @dev Hook that is called before falling back to the implementation. Can happen as part of a manual `_fallback` + * call, or as part of the Solidity `fallback` or `receive` functions. + * + * If overridden should call `super._beforeFallback()`. + */ + function _beforeFallback() internal virtual {} +} diff --git a/contracts/extension/ProxyForUpgradeable.sol b/contracts/extension/ProxyForUpgradeable.sol new file mode 100644 index 000000000..dcbf4043e --- /dev/null +++ b/contracts/extension/ProxyForUpgradeable.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: Apache 2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "./Proxy.sol"; +import "../external-deps/openzeppelin/proxy/ERC1967/ERC1967Upgrade.sol"; + +/** + * @dev This contract implements an upgradeable proxy. It is upgradeable because calls are delegated to an + * implementation address that can be changed. This address is stored in storage in the location specified by + * https://eips.ethereum.org/EIPS/eip-1967[EIP1967], so that it doesn't conflict with the storage layout of the + * implementation behind the proxy. + */ +contract ProxyForUpgradeable is Proxy, ERC1967Upgrade { + /** + * @dev Initializes the upgradeable proxy with an initial implementation specified by `_logic`. + * + * If `_data` is nonempty, it's used as data in a delegate call to `_logic`. This will typically be an encoded + * function call, and allows initializing the storage of the proxy like a Solidity constructor. + */ + constructor(address _logic, bytes memory _data) payable { + _upgradeToAndCall(_logic, _data, false); + } + + /** + * @dev Returns the current implementation address. + */ + function _implementation() internal view virtual override returns (address impl) { + return ERC1967Upgrade._getImplementation(); + } +} diff --git a/contracts/extension/Royalty.sol b/contracts/extension/Royalty.sol new file mode 100644 index 000000000..765fcd08f --- /dev/null +++ b/contracts/extension/Royalty.sol @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "./interface/IRoyalty.sol"; + +/** + * @title Royalty + * @notice Thirdweb's `Royalty` is a contract extension to be used with any base contract. It exposes functions for setting and reading + * the recipient of royalty fee and the royalty fee basis points, and lets the inheriting contract perform conditional logic + * that uses information about royalty fees, if desired. + * + * @dev The `Royalty` contract is ERC2981 compliant. + */ + +abstract contract Royalty is IRoyalty { + /// @dev The sender is not authorized to perform the action + error RoyaltyUnauthorized(); + + /// @dev The recipient is invalid + error RoyaltyInvalidRecipient(address recipient); + + /// @dev The fee bps exceeded the max value + error RoyaltyExceededMaxFeeBps(uint256 max, uint256 actual); + + /// @dev The (default) address that receives all royalty value. + address private royaltyRecipient; + + /// @dev The (default) % of a sale to take as royalty (in basis points). + uint16 private royaltyBps; + + /// @dev Token ID => royalty recipient and bps for token + mapping(uint256 => RoyaltyInfo) private royaltyInfoForToken; + + /** + * @notice View royalty info for a given token and sale price. + * @dev Returns royalty amount and recipient for `tokenId` and `salePrice`. + * @param tokenId The tokenID of the NFT for which to query royalty info. + * @param salePrice Sale price of the token. + * + * @return receiver Address of royalty recipient account. + * @return royaltyAmount Royalty amount calculated at current royaltyBps value. + */ + function royaltyInfo( + uint256 tokenId, + uint256 salePrice + ) external view virtual override returns (address receiver, uint256 royaltyAmount) { + (address recipient, uint256 bps) = getRoyaltyInfoForToken(tokenId); + receiver = recipient; + royaltyAmount = (salePrice * bps) / 10_000; + } + + /** + * @notice View royalty info for a given token. + * @dev Returns royalty recipient and bps for `_tokenId`. + * @param _tokenId The tokenID of the NFT for which to query royalty info. + */ + function getRoyaltyInfoForToken(uint256 _tokenId) public view override returns (address, uint16) { + RoyaltyInfo memory royaltyForToken = royaltyInfoForToken[_tokenId]; + + return + royaltyForToken.recipient == address(0) + ? (royaltyRecipient, uint16(royaltyBps)) + : (royaltyForToken.recipient, uint16(royaltyForToken.bps)); + } + + /** + * @notice Returns the defualt royalty recipient and BPS for this contract's NFTs. + */ + function getDefaultRoyaltyInfo() external view override returns (address, uint16) { + return (royaltyRecipient, uint16(royaltyBps)); + } + + /** + * @notice Updates default royalty recipient and bps. + * @dev Caller should be authorized to set royalty info. + * See {_canSetRoyaltyInfo}. + * Emits {DefaultRoyalty Event}; See {_setupDefaultRoyaltyInfo}. + * + * @param _royaltyRecipient Address to be set as default royalty recipient. + * @param _royaltyBps Updated royalty bps. + */ + function setDefaultRoyaltyInfo(address _royaltyRecipient, uint256 _royaltyBps) external override { + if (!_canSetRoyaltyInfo()) { + revert RoyaltyUnauthorized(); + } + + _setupDefaultRoyaltyInfo(_royaltyRecipient, _royaltyBps); + } + + /// @dev Lets a contract admin update the default royalty recipient and bps. + function _setupDefaultRoyaltyInfo(address _royaltyRecipient, uint256 _royaltyBps) internal { + if (_royaltyBps > 10_000) { + revert RoyaltyExceededMaxFeeBps(10_000, _royaltyBps); + } + + royaltyRecipient = _royaltyRecipient; + royaltyBps = uint16(_royaltyBps); + + emit DefaultRoyalty(_royaltyRecipient, _royaltyBps); + } + + /** + * @notice Updates default royalty recipient and bps for a particular token. + * @dev Sets royalty info for `_tokenId`. Caller should be authorized to set royalty info. + * See {_canSetRoyaltyInfo}. + * Emits {RoyaltyForToken Event}; See {_setupRoyaltyInfoForToken}. + * + * @param _recipient Address to be set as royalty recipient for given token Id. + * @param _bps Updated royalty bps for the token Id. + */ + function setRoyaltyInfoForToken(uint256 _tokenId, address _recipient, uint256 _bps) external override { + if (!_canSetRoyaltyInfo()) { + revert RoyaltyUnauthorized(); + } + + _setupRoyaltyInfoForToken(_tokenId, _recipient, _bps); + } + + /// @dev Lets a contract admin set the royalty recipient and bps for a particular token Id. + function _setupRoyaltyInfoForToken(uint256 _tokenId, address _recipient, uint256 _bps) internal { + if (_bps > 10_000) { + revert RoyaltyExceededMaxFeeBps(10_000, _bps); + } + + royaltyInfoForToken[_tokenId] = RoyaltyInfo({ recipient: _recipient, bps: _bps }); + + emit RoyaltyForToken(_tokenId, _recipient, _bps); + } + + /// @dev Returns whether royalty info can be set in the given execution context. + function _canSetRoyaltyInfo() internal view virtual returns (bool); +} diff --git a/contracts/extension/SeaportEIP1271.sol b/contracts/extension/SeaportEIP1271.sol new file mode 100644 index 000000000..4705e898a --- /dev/null +++ b/contracts/extension/SeaportEIP1271.sol @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import { ECDSA } from "solady/src/utils/ECDSA.sol"; +import { SeaportOrderParser } from "./SeaportOrderParser.sol"; +import { OrderParameters } from "seaport-types/src/lib/ConsiderationStructs.sol"; + +abstract contract SeaportEIP1271 is SeaportOrderParser { + using ECDSA for bytes32; + + bytes32 private constant ACCOUNT_MESSAGE_TYPEHASH = keccak256("AccountMessage(bytes message)"); + bytes32 private constant EIP712_TYPEHASH = + keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"); + bytes32 private immutable HASHED_NAME; + bytes32 private immutable HASHED_VERSION; + + /// @notice The function selector of EIP1271.isValidSignature to be returned on sucessful signature verification. + bytes4 public constant MAGICVALUE = 0x1626ba7e; + + constructor(string memory _name, string memory _version) { + HASHED_NAME = keccak256(bytes(_name)); + HASHED_VERSION = keccak256(bytes(_version)); + } + + /// @notice See EIP-1271: https://eips.ethereum.org/EIPS/eip-1271 + function isValidSignature( + bytes32 _message, + bytes memory _signature + ) public view virtual returns (bytes4 magicValue) { + bytes32 targetDigest; + bytes memory targetSig; + + // Handle OpenSea bulk order signatures that are >65 bytes in length. + if (_signature.length > 65) { + // Decode packed signature and order parameters. + (bytes memory extractedPackedSig, OrderParameters memory orderParameters, uint256 counter) = abi.decode( + _signature, + (bytes, OrderParameters, uint256) + ); + + // Verify that the original digest matches the digest built with order parameters. + bytes32 domainSeparator = _buildSeaportDomainSeparator(msg.sender); + bytes32 orderHash = _deriveOrderHash(orderParameters, counter); + + require( + _deriveEIP712Digest(domainSeparator, orderHash) == _message, + "Seaport: order hash does not match the provided message." + ); + + // Build bulk signature digest + targetDigest = _deriveEIP712Digest(domainSeparator, _computeBulkOrderProof(extractedPackedSig, orderHash)); + + // Extract the signature, which is the first 65 bytes + targetSig = new bytes(65); + for (uint256 i = 0; i < 65; i++) { + targetSig[i] = extractedPackedSig[i]; + } + } else { + targetDigest = getMessageHash(_message); + targetSig = _signature; + } + + address signer = targetDigest.recover(targetSig); + + if (_isAuthorizedSigner(signer)) { + magicValue = MAGICVALUE; + } + } + + /** + * @notice Returns the hash of message that should be signed for EIP1271 verification. + * @param _hash The message hash pre replay protection. + * @return messageHash The digest (with replay protection) to sign for EIP-1271 verification. + */ + function getMessageHash(bytes32 _hash) public view returns (bytes32) { + bytes32 messageHash = keccak256(abi.encode(_hash)); + bytes32 typedDataHash = keccak256(abi.encode(ACCOUNT_MESSAGE_TYPEHASH, messageHash)); + return keccak256(abi.encodePacked("\x19\x01", _accountDomainSeparator(), typedDataHash)); + } + + /// @notice Returns the EIP712 domain separator for the contract. + function _accountDomainSeparator() private view returns (bytes32) { + return keccak256(abi.encode(EIP712_TYPEHASH, HASHED_NAME, HASHED_VERSION, block.chainid, address(this))); + } + + /// @notice Returns whether a given signer is an authorized signer for the contract. + function _isAuthorizedSigner(address _signer) internal view virtual returns (bool); +} diff --git a/contracts/extension/SeaportOrderParser.sol b/contracts/extension/SeaportOrderParser.sol new file mode 100644 index 000000000..f76e3e337 --- /dev/null +++ b/contracts/extension/SeaportOrderParser.sol @@ -0,0 +1,550 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/* solhint-disable */ + +import { OrderParameters } from "seaport-types/src/lib/ConsiderationStructs.sol"; +import { EIP_712_PREFIX, EIP712_ConsiderationItem_size, EIP712_DigestPayload_size, EIP712_DomainSeparator_offset, EIP712_OfferItem_size, EIP712_Order_size, EIP712_OrderHash_offset, OneWord, OneWordShift, OrderParameters_consideration_head_offset, OrderParameters_counter_offset, OrderParameters_offer_head_offset, TwoWords, BulkOrderProof_keyShift, BulkOrderProof_keySize, BulkOrder_Typehash_Height_One, BulkOrder_Typehash_Height_Two, BulkOrder_Typehash_Height_Three, BulkOrder_Typehash_Height_Four, BulkOrder_Typehash_Height_Five, BulkOrder_Typehash_Height_Six, BulkOrder_Typehash_Height_Seven, BulkOrder_Typehash_Height_Eight, BulkOrder_Typehash_Height_Nine, BulkOrder_Typehash_Height_Ten, BulkOrder_Typehash_Height_Eleven, BulkOrder_Typehash_Height_Twelve, BulkOrder_Typehash_Height_Thirteen, BulkOrder_Typehash_Height_Fourteen, BulkOrder_Typehash_Height_Fifteen, BulkOrder_Typehash_Height_Sixteen, BulkOrder_Typehash_Height_Seventeen, BulkOrder_Typehash_Height_Eighteen, BulkOrder_Typehash_Height_Nineteen, BulkOrder_Typehash_Height_Twenty, BulkOrder_Typehash_Height_TwentyOne, BulkOrder_Typehash_Height_TwentyTwo, BulkOrder_Typehash_Height_TwentyThree, BulkOrder_Typehash_Height_TwentyFour, FreeMemoryPointerSlot } from "seaport-types/src/lib/ConsiderationConstants.sol"; + +contract SeaportOrderParser { + uint256 constant ECDSA_MAXLENGTH = 65; + + bytes32 private immutable _NAME_HASH; + bytes32 private immutable _VERSION_HASH; + bytes32 private immutable _EIP_712_DOMAIN_TYPEHASH; + bytes32 private immutable _OFFER_ITEM_TYPEHASH; + bytes32 private immutable _CONSIDERATION_ITEM_TYPEHASH; + bytes32 private immutable _ORDER_TYPEHASH; + + constructor() { + ( + _NAME_HASH, + _VERSION_HASH, + _EIP_712_DOMAIN_TYPEHASH, + _OFFER_ITEM_TYPEHASH, + _CONSIDERATION_ITEM_TYPEHASH, + _ORDER_TYPEHASH + ) = _deriveTypehashes(); + } + + function _nameString() internal pure virtual returns (string memory) { + // Return the name of the contract. + return "Seaport"; + } + + function _buildSeaportDomainSeparator(address _domainAddress) internal view returns (bytes32) { + return + keccak256(abi.encode(_EIP_712_DOMAIN_TYPEHASH, _NAME_HASH, _VERSION_HASH, block.chainid, _domainAddress)); + } + + function _deriveOrderHash( + OrderParameters memory orderParameters, + uint256 counter + ) internal view returns (bytes32 orderHash) { + // Get length of original consideration array and place it on the stack. + uint256 originalConsiderationLength = (orderParameters.totalOriginalConsiderationItems); + + /* + * Memory layout for an array of structs (dynamic or not) is similar + * to ABI encoding of dynamic types, with a head segment followed by + * a data segment. The main difference is that the head of an element + * is a memory pointer rather than an offset. + */ + + // Declare a variable for the derived hash of the offer array. + bytes32 offerHash; + + // Read offer item EIP-712 typehash from runtime code & place on stack. + bytes32 typeHash = _OFFER_ITEM_TYPEHASH; + + // Utilize assembly so that memory regions can be reused across hashes. + assembly { + // Retrieve the free memory pointer and place on the stack. + let hashArrPtr := mload(FreeMemoryPointerSlot) + + // Get the pointer to the offers array. + let offerArrPtr := mload(add(orderParameters, OrderParameters_offer_head_offset)) + + // Load the length. + let offerLength := mload(offerArrPtr) + + // Set the pointer to the first offer's head. + offerArrPtr := add(offerArrPtr, OneWord) + + // Iterate over the offer items. + for { + let i := 0 + } lt(i, offerLength) { + i := add(i, 1) + } { + // Read the pointer to the offer data and subtract one word + // to get typeHash pointer. + let ptr := sub(mload(offerArrPtr), OneWord) + + // Read the current value before the offer data. + let value := mload(ptr) + + // Write the type hash to the previous word. + mstore(ptr, typeHash) + + // Take the EIP712 hash and store it in the hash array. + mstore(hashArrPtr, keccak256(ptr, EIP712_OfferItem_size)) + + // Restore the previous word. + mstore(ptr, value) + + // Increment the array pointers by one word. + offerArrPtr := add(offerArrPtr, OneWord) + hashArrPtr := add(hashArrPtr, OneWord) + } + + // Derive the offer hash using the hashes of each item. + offerHash := keccak256(mload(FreeMemoryPointerSlot), shl(OneWordShift, offerLength)) + } + + // Declare a variable for the derived hash of the consideration array. + bytes32 considerationHash; + + // Read consideration item typehash from runtime code & place on stack. + typeHash = _CONSIDERATION_ITEM_TYPEHASH; + + // Utilize assembly so that memory regions can be reused across hashes. + assembly { + // Retrieve the free memory pointer and place on the stack. + let hashArrPtr := mload(FreeMemoryPointerSlot) + + // Get the pointer to the consideration array. + let considerationArrPtr := add( + mload(add(orderParameters, OrderParameters_consideration_head_offset)), + OneWord + ) + + // Iterate over the consideration items (not including tips). + for { + let i := 0 + } lt(i, originalConsiderationLength) { + i := add(i, 1) + } { + // Read the pointer to the consideration data and subtract one + // word to get typeHash pointer. + let ptr := sub(mload(considerationArrPtr), OneWord) + + // Read the current value before the consideration data. + let value := mload(ptr) + + // Write the type hash to the previous word. + mstore(ptr, typeHash) + + // Take the EIP712 hash and store it in the hash array. + mstore(hashArrPtr, keccak256(ptr, EIP712_ConsiderationItem_size)) + + // Restore the previous word. + mstore(ptr, value) + + // Increment the array pointers by one word. + considerationArrPtr := add(considerationArrPtr, OneWord) + hashArrPtr := add(hashArrPtr, OneWord) + } + + // Derive the consideration hash using the hashes of each item. + considerationHash := keccak256(mload(FreeMemoryPointerSlot), shl(OneWordShift, originalConsiderationLength)) + } + + // Read order item EIP-712 typehash from runtime code & place on stack. + typeHash = _ORDER_TYPEHASH; + + // Utilize assembly to access derived hashes & other arguments directly. + assembly { + // Retrieve pointer to the region located just behind parameters. + let typeHashPtr := sub(orderParameters, OneWord) + + // Store the value at that pointer location to restore later. + let previousValue := mload(typeHashPtr) + + // Store the order item EIP-712 typehash at the typehash location. + mstore(typeHashPtr, typeHash) + + // Retrieve the pointer for the offer array head. + let offerHeadPtr := add(orderParameters, OrderParameters_offer_head_offset) + + // Retrieve the data pointer referenced by the offer head. + let offerDataPtr := mload(offerHeadPtr) + + // Store the offer hash at the retrieved memory location. + mstore(offerHeadPtr, offerHash) + + // Retrieve the pointer for the consideration array head. + let considerationHeadPtr := add(orderParameters, OrderParameters_consideration_head_offset) + + // Retrieve the data pointer referenced by the consideration head. + let considerationDataPtr := mload(considerationHeadPtr) + + // Store the consideration hash at the retrieved memory location. + mstore(considerationHeadPtr, considerationHash) + + // Retrieve the pointer for the counter. + let counterPtr := add(orderParameters, OrderParameters_counter_offset) + + // Store the counter at the retrieved memory location. + mstore(counterPtr, counter) + + // Derive the order hash using the full range of order parameters. + orderHash := keccak256(typeHashPtr, EIP712_Order_size) + + // Restore the value previously held at typehash pointer location. + mstore(typeHashPtr, previousValue) + + // Restore offer data pointer at the offer head pointer location. + mstore(offerHeadPtr, offerDataPtr) + + // Restore consideration data pointer at the consideration head ptr. + mstore(considerationHeadPtr, considerationDataPtr) + + // Restore consideration item length at the counter pointer. + mstore(counterPtr, originalConsiderationLength) + } + } + + function _deriveTypehashes() + internal + pure + returns ( + bytes32 nameHash, + bytes32 versionHash, + bytes32 eip712DomainTypehash, + bytes32 offerItemTypehash, + bytes32 considerationItemTypehash, + bytes32 orderTypehash + ) + { + // Derive hash of the name of the contract. + nameHash = keccak256(bytes(_nameString())); + + // Derive hash of the version string of the contract. + versionHash = keccak256(bytes("1.5")); + + // Construct the OfferItem type string. + bytes memory offerItemTypeString = bytes( + "OfferItem(" + "uint8 itemType," + "address token," + "uint256 identifierOrCriteria," + "uint256 startAmount," + "uint256 endAmount" + ")" + ); + + // Construct the ConsiderationItem type string. + bytes memory considerationItemTypeString = bytes( + "ConsiderationItem(" + "uint8 itemType," + "address token," + "uint256 identifierOrCriteria," + "uint256 startAmount," + "uint256 endAmount," + "address recipient" + ")" + ); + + // Construct the OrderComponents type string, not including the above. + bytes memory orderComponentsPartialTypeString = bytes( + "OrderComponents(" + "address offerer," + "address zone," + "OfferItem[] offer," + "ConsiderationItem[] consideration," + "uint8 orderType," + "uint256 startTime," + "uint256 endTime," + "bytes32 zoneHash," + "uint256 salt," + "bytes32 conduitKey," + "uint256 counter" + ")" + ); + + // Construct the primary EIP-712 domain type string. + eip712DomainTypehash = keccak256( + bytes( + "EIP712Domain(" + "string name," + "string version," + "uint256 chainId," + "address verifyingContract" + ")" + ) + ); + + // Derive the OfferItem type hash using the corresponding type string. + offerItemTypehash = keccak256(offerItemTypeString); + + // Derive ConsiderationItem type hash using corresponding type string. + considerationItemTypehash = keccak256(considerationItemTypeString); + + bytes memory orderTypeString = bytes.concat( + orderComponentsPartialTypeString, + considerationItemTypeString, + offerItemTypeString + ); + + // Derive OrderItem type hash via combination of relevant type strings. + orderTypehash = keccak256(orderTypeString); + } + + function _computeBulkOrderProof( + bytes memory proofAndSignature, + bytes32 leaf + ) internal pure returns (bytes32 bulkOrderHash) { + // Declare arguments for the root hash and the height of the proof. + bytes32 root; + uint256 height; + + // Utilize assembly to efficiently derive the root hash using the proof. + assembly { + // Retrieve the length of the proof, key, and signature combined. + let fullLength := mload(proofAndSignature) + + // If proofAndSignature has odd length, it is a compact signature + // with 64 bytes. + let signatureLength := sub(ECDSA_MAXLENGTH, and(fullLength, 1)) + + // Derive height (or depth of tree) with signature and proof length. + height := shr(OneWordShift, sub(fullLength, signatureLength)) + + // Update the length in memory to only include the signature. + mstore(proofAndSignature, signatureLength) + + // Derive the pointer for the key using the signature length. + let keyPtr := add(proofAndSignature, add(OneWord, signatureLength)) + + // Retrieve the three-byte key using the derived pointer. + let key := shr(BulkOrderProof_keyShift, mload(keyPtr)) + + /// Retrieve pointer to first proof element by applying a constant + // for the key size to the derived key pointer. + let proof := add(keyPtr, BulkOrderProof_keySize) + + // Compute level 1. + let scratchPtr1 := shl(OneWordShift, and(key, 1)) + mstore(scratchPtr1, leaf) + mstore(xor(scratchPtr1, OneWord), mload(proof)) + + // Compute remaining proofs. + for { + let i := 1 + } lt(i, height) { + i := add(i, 1) + } { + proof := add(proof, OneWord) + let scratchPtr := shl(OneWordShift, and(shr(i, key), 1)) + mstore(scratchPtr, keccak256(0, TwoWords)) + mstore(xor(scratchPtr, OneWord), mload(proof)) + } + + // Compute root hash. + root := keccak256(0, TwoWords) + } + + // Retrieve appropriate typehash constant based on height. + bytes32 rootTypeHash = _lookupBulkOrderTypehash(height); + + // Use the typehash and the root hash to derive final bulk order hash. + assembly { + mstore(0, rootTypeHash) + mstore(OneWord, root) + bulkOrderHash := keccak256(0, TwoWords) + } + } + + function _lookupBulkOrderTypehash(uint256 _treeHeight) internal pure returns (bytes32 _typeHash) { + // Utilize assembly to efficiently retrieve correct bulk order typehash. + assembly { + // Use a Yul function to enable use of the `leave` keyword + // to stop searching once the appropriate type hash is found. + function lookupTypeHash(treeHeight) -> typeHash { + // Handle tree heights one through eight. + if lt(treeHeight, 9) { + // Handle tree heights one through four. + if lt(treeHeight, 5) { + // Handle tree heights one and two. + if lt(treeHeight, 3) { + // Utilize branchless logic to determine typehash. + typeHash := ternary( + eq(treeHeight, 1), + BulkOrder_Typehash_Height_One, + BulkOrder_Typehash_Height_Two + ) + + // Exit the function once typehash has been located. + leave + } + + // Handle height three and four via branchless logic. + typeHash := ternary( + eq(treeHeight, 3), + BulkOrder_Typehash_Height_Three, + BulkOrder_Typehash_Height_Four + ) + + // Exit the function once typehash has been located. + leave + } + + // Handle tree height five and six. + if lt(treeHeight, 7) { + // Utilize branchless logic to determine typehash. + typeHash := ternary( + eq(treeHeight, 5), + BulkOrder_Typehash_Height_Five, + BulkOrder_Typehash_Height_Six + ) + + // Exit the function once typehash has been located. + leave + } + + // Handle height seven and eight via branchless logic. + typeHash := ternary( + eq(treeHeight, 7), + BulkOrder_Typehash_Height_Seven, + BulkOrder_Typehash_Height_Eight + ) + + // Exit the function once typehash has been located. + leave + } + + // Handle tree height nine through sixteen. + if lt(treeHeight, 17) { + // Handle tree height nine through twelve. + if lt(treeHeight, 13) { + // Handle tree height nine and ten. + if lt(treeHeight, 11) { + // Utilize branchless logic to determine typehash. + typeHash := ternary( + eq(treeHeight, 9), + BulkOrder_Typehash_Height_Nine, + BulkOrder_Typehash_Height_Ten + ) + + // Exit the function once typehash has been located. + leave + } + + // Handle height eleven and twelve via branchless logic. + typeHash := ternary( + eq(treeHeight, 11), + BulkOrder_Typehash_Height_Eleven, + BulkOrder_Typehash_Height_Twelve + ) + + // Exit the function once typehash has been located. + leave + } + + // Handle tree height thirteen and fourteen. + if lt(treeHeight, 15) { + // Utilize branchless logic to determine typehash. + typeHash := ternary( + eq(treeHeight, 13), + BulkOrder_Typehash_Height_Thirteen, + BulkOrder_Typehash_Height_Fourteen + ) + + // Exit the function once typehash has been located. + leave + } + // Handle height fifteen and sixteen via branchless logic. + typeHash := ternary( + eq(treeHeight, 15), + BulkOrder_Typehash_Height_Fifteen, + BulkOrder_Typehash_Height_Sixteen + ) + + // Exit the function once typehash has been located. + leave + } + + // Handle tree height seventeen through twenty. + if lt(treeHeight, 21) { + // Handle tree height seventeen and eighteen. + if lt(treeHeight, 19) { + // Utilize branchless logic to determine typehash. + typeHash := ternary( + eq(treeHeight, 17), + BulkOrder_Typehash_Height_Seventeen, + BulkOrder_Typehash_Height_Eighteen + ) + + // Exit the function once typehash has been located. + leave + } + + // Handle height nineteen and twenty via branchless logic. + typeHash := ternary( + eq(treeHeight, 19), + BulkOrder_Typehash_Height_Nineteen, + BulkOrder_Typehash_Height_Twenty + ) + + // Exit the function once typehash has been located. + leave + } + + // Handle tree height twenty-one and twenty-two. + if lt(treeHeight, 23) { + // Utilize branchless logic to determine typehash. + typeHash := ternary( + eq(treeHeight, 21), + BulkOrder_Typehash_Height_TwentyOne, + BulkOrder_Typehash_Height_TwentyTwo + ) + + // Exit the function once typehash has been located. + leave + } + + // Handle height twenty-three & twenty-four w/ branchless logic. + typeHash := ternary( + eq(treeHeight, 23), + BulkOrder_Typehash_Height_TwentyThree, + BulkOrder_Typehash_Height_TwentyFour + ) + + // Exit the function once typehash has been located. + leave + } + + // Implement ternary conditional using branchless logic. + function ternary(cond, ifTrue, ifFalse) -> c { + c := xor(ifFalse, mul(cond, xor(ifFalse, ifTrue))) + } + + // Look up the typehash using the supplied tree height. + _typeHash := lookupTypeHash(_treeHeight) + } + } + + function _deriveEIP712Digest(bytes32 domainSeparator, bytes32 orderHash) internal pure returns (bytes32 value) { + // Leverage scratch space to perform an efficient hash. + assembly { + // Place the EIP-712 prefix at the start of scratch space. + mstore(0, EIP_712_PREFIX) + + // Place the domain separator in the next region of scratch space. + mstore(EIP712_DomainSeparator_offset, domainSeparator) + + // Place the order hash in scratch space, spilling into the first + // two bytes of the free memory pointer — this should never be set + // as memory cannot be expanded to that size, and will be zeroed out + // after the hash is performed. + mstore(EIP712_OrderHash_offset, orderHash) + + // Hash the relevant region (65 bytes). + value := keccak256(0, EIP712_DigestPayload_size) + + // Clear out the dirtied bits in the memory pointer. + mstore(EIP712_OrderHash_offset, 0) + } + } +} diff --git a/contracts/extension/SharedMetadata.sol b/contracts/extension/SharedMetadata.sol new file mode 100644 index 000000000..72d244625 --- /dev/null +++ b/contracts/extension/SharedMetadata.sol @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: Apache 2.0 +pragma solidity ^0.8.10; + +/// @author thirdweb + +import "../lib/NFTMetadataRenderer.sol"; +import "./interface/ISharedMetadata.sol"; +import "../eip/interface/IERC4906.sol"; + +abstract contract SharedMetadata is ISharedMetadata, IERC4906 { + /// @notice Token metadata information + SharedMetadataInfo public sharedMetadata; + + /// @notice Set shared metadata for NFTs + function setSharedMetadata(SharedMetadataInfo calldata _metadata) external virtual { + if (!_canSetSharedMetadata()) { + revert("Not authorized"); + } + _setSharedMetadata(_metadata); + } + + /** + * @dev Sets shared metadata for NFTs. + * @param _metadata common metadata for all tokens + */ + function _setSharedMetadata(SharedMetadataInfo calldata _metadata) internal { + sharedMetadata = SharedMetadataInfo({ + name: _metadata.name, + description: _metadata.description, + imageURI: _metadata.imageURI, + animationURI: _metadata.animationURI + }); + + emit BatchMetadataUpdate(0, type(uint256).max); + + emit SharedMetadataUpdated({ + name: _metadata.name, + description: _metadata.description, + imageURI: _metadata.imageURI, + animationURI: _metadata.animationURI + }); + } + + /** + * @dev Token URI information getter + * @param tokenId Token ID to get URI for + */ + function _getURIFromSharedMetadata(uint256 tokenId) internal view returns (string memory) { + SharedMetadataInfo memory info = sharedMetadata; + + return + NFTMetadataRenderer.createMetadataEdition({ + name: info.name, + description: info.description, + imageURI: info.imageURI, + animationURI: info.animationURI, + tokenOfEdition: tokenId + }); + } + + /// @dev Returns whether shared metadata can be set in the given execution context. + function _canSetSharedMetadata() internal view virtual returns (bool); +} diff --git a/contracts/extension/SignatureAction.sol b/contracts/extension/SignatureAction.sol new file mode 100644 index 000000000..4e857e870 --- /dev/null +++ b/contracts/extension/SignatureAction.sol @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "./interface/ISignatureAction.sol"; +import "../external-deps/openzeppelin/utils/cryptography/EIP712.sol"; + +abstract contract SignatureAction is EIP712, ISignatureAction { + using ECDSA for bytes32; + + bytes32 private constant TYPEHASH = + keccak256("GenericRequest(uint128 validityStartTimestamp,uint128 validityEndTimestamp,bytes32 uid,bytes data)"); + + /// @dev Mapping from a signed request UID => whether the request is processed. + mapping(bytes32 => bool) private executed; + + constructor() EIP712("SignatureAction", "1") {} + + /// @dev Verifies that a request is signed by an authorized account. + function verify( + GenericRequest calldata _req, + bytes calldata _signature + ) public view override returns (bool success, address signer) { + signer = _recoverAddress(_req, _signature); + success = !executed[_req.uid] && _isAuthorizedSigner(signer); + } + + /// @dev Returns whether a given address is authorized to sign requests. + function _isAuthorizedSigner(address _signer) internal view virtual returns (bool); + + /// @dev Verifies a request and marks the request as processed. + function _processRequest( + GenericRequest calldata _req, + bytes calldata _signature + ) internal returns (address signer) { + bool success; + (success, signer) = verify(_req, _signature); + + if (!success) { + revert("Invalid req"); + } + + if (_req.validityStartTimestamp > block.timestamp || block.timestamp > _req.validityEndTimestamp) { + revert("Req expired"); + } + + executed[_req.uid] = true; + } + + /// @dev Returns the address of the signer of the request. + function _recoverAddress(GenericRequest calldata _req, bytes calldata _signature) internal view returns (address) { + return _hashTypedDataV4(keccak256(_encodeRequest(_req))).recover(_signature); + } + + /// @dev Encodes a request for recovery of the signer in `recoverAddress`. + function _encodeRequest(GenericRequest calldata _req) internal pure returns (bytes memory) { + return + abi.encode( + TYPEHASH, + _req.validityStartTimestamp, + _req.validityEndTimestamp, + _req.uid, + keccak256(_req.data) + ); + } +} diff --git a/contracts/extension/SignatureActionUpgradeable.sol b/contracts/extension/SignatureActionUpgradeable.sol new file mode 100644 index 000000000..18b791151 --- /dev/null +++ b/contracts/extension/SignatureActionUpgradeable.sol @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "./interface/ISignatureAction.sol"; +import "@openzeppelin/contracts-upgradeable/utils/cryptography/draft-EIP712Upgradeable.sol"; + +abstract contract SignatureActionUpgradeable is EIP712Upgradeable, ISignatureAction { + using ECDSAUpgradeable for bytes32; + + bytes32 private constant TYPEHASH = + keccak256("GenericRequest(uint128 validityStartTimestamp,uint128 validityEndTimestamp,bytes32 uid,bytes data)"); + + /// @dev Mapping from a signed request UID => whether the request is processed. + mapping(bytes32 => bool) private executed; + + function __SignatureAction_init() internal onlyInitializing { + __EIP712_init("SignatureAction", "1"); + } + + function __SignatureAction_init_unchained() internal onlyInitializing {} + + /// @dev Verifies that a request is signed by an authorized account. + function verify( + GenericRequest calldata _req, + bytes calldata _signature + ) public view override returns (bool success, address signer) { + signer = _recoverAddress(_req, _signature); + success = !executed[_req.uid] && _isAuthorizedSigner(signer); + } + + /// @dev Returns whether a given address is authorized to sign requests. + function _isAuthorizedSigner(address _signer) internal view virtual returns (bool); + + /// @dev Verifies a request and marks the request as processed. + function _processRequest( + GenericRequest calldata _req, + bytes calldata _signature + ) internal returns (address signer) { + bool success; + (success, signer) = verify(_req, _signature); + + if (!success) { + revert("Invalid req"); + } + + if (_req.validityStartTimestamp > block.timestamp || block.timestamp > _req.validityEndTimestamp) { + revert("Req expired"); + } + + executed[_req.uid] = true; + } + + /// @dev Returns the address of the signer of the request. + function _recoverAddress(GenericRequest calldata _req, bytes calldata _signature) internal view returns (address) { + return _hashTypedDataV4(keccak256(_encodeRequest(_req))).recover(_signature); + } + + /// @dev Encodes a request for recovery of the signer in `recoverAddress`. + function _encodeRequest(GenericRequest calldata _req) internal pure returns (bytes memory) { + return + abi.encode( + TYPEHASH, + _req.validityStartTimestamp, + _req.validityEndTimestamp, + _req.uid, + keccak256(_req.data) + ); + } +} diff --git a/contracts/extension/SignatureMintERC1155.sol b/contracts/extension/SignatureMintERC1155.sol new file mode 100644 index 000000000..0d1c7aaf3 --- /dev/null +++ b/contracts/extension/SignatureMintERC1155.sol @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "./interface/ISignatureMintERC1155.sol"; +import "../external-deps/openzeppelin/utils/cryptography/EIP712.sol"; + +abstract contract SignatureMintERC1155 is EIP712, ISignatureMintERC1155 { + using ECDSA for bytes32; + + bytes32 internal constant TYPEHASH = + keccak256( + "MintRequest(address to,address royaltyRecipient,uint256 royaltyBps,address primarySaleRecipient,uint256 tokenId,string uri,uint256 quantity,uint256 pricePerToken,address currency,uint128 validityStartTimestamp,uint128 validityEndTimestamp,bytes32 uid)" + ); + + /// @dev Mapping from mint request UID => whether the mint request is processed. + mapping(bytes32 => bool) private minted; + + constructor() EIP712("SignatureMintERC1155", "1") {} + + /// @dev Verifies that a mint request is signed by an account holding MINTER_ROLE (at the time of the function call). + function verify( + MintRequest calldata _req, + bytes calldata _signature + ) public view override returns (bool success, address signer) { + signer = _recoverAddress(_req, _signature); + success = !minted[_req.uid] && _canSignMintRequest(signer); + } + + /// @dev Returns whether a given address is authorized to sign mint requests. + function _canSignMintRequest(address _signer) internal view virtual returns (bool); + + /// @dev Verifies a mint request and marks the request as minted. + function _processRequest(MintRequest calldata _req, bytes calldata _signature) internal returns (address signer) { + bool success; + (success, signer) = verify(_req, _signature); + + require(success, "Invalid request"); + require( + _req.validityStartTimestamp <= block.timestamp && block.timestamp <= _req.validityEndTimestamp, + "Request expired" + ); + require(_req.to != address(0), "recipient undefined"); + require(_req.quantity > 0, "0 qty"); + + minted[_req.uid] = true; + } + + /// @dev Returns the address of the signer of the mint request. + function _recoverAddress(MintRequest calldata _req, bytes calldata _signature) internal view returns (address) { + return _hashTypedDataV4(keccak256(_encodeRequest(_req))).recover(_signature); + } + + /// @dev Resolves 'stack too deep' error in `recoverAddress`. + function _encodeRequest(MintRequest calldata _req) internal pure returns (bytes memory) { + return + bytes.concat( + abi.encode( + TYPEHASH, + _req.to, + _req.royaltyRecipient, + _req.royaltyBps, + _req.primarySaleRecipient, + _req.tokenId, + keccak256(bytes(_req.uri)) + ), + abi.encode( + _req.quantity, + _req.pricePerToken, + _req.currency, + _req.validityStartTimestamp, + _req.validityEndTimestamp, + _req.uid + ) + ); + } +} diff --git a/contracts/extension/SignatureMintERC1155Upgradeable.sol b/contracts/extension/SignatureMintERC1155Upgradeable.sol new file mode 100644 index 000000000..f4e2891b5 --- /dev/null +++ b/contracts/extension/SignatureMintERC1155Upgradeable.sol @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "./interface/ISignatureMintERC1155.sol"; + +import "@openzeppelin/contracts-upgradeable/utils/cryptography/ECDSAUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/utils/cryptography/draft-EIP712Upgradeable.sol"; + +abstract contract SignatureMintERC1155Upgradeable is Initializable, EIP712Upgradeable, ISignatureMintERC1155 { + using ECDSAUpgradeable for bytes32; + + bytes32 internal constant TYPEHASH = + keccak256( + "MintRequest(address to,address royaltyRecipient,uint256 royaltyBps,address primarySaleRecipient,uint256 tokenId,string uri,uint256 quantity,uint256 pricePerToken,address currency,uint128 validityStartTimestamp,uint128 validityEndTimestamp,bytes32 uid)" + ); + + /// @dev Mapping from mint request UID => whether the mint request is processed. + mapping(bytes32 => bool) private minted; + + function __SignatureMintERC1155_init() internal onlyInitializing { + __EIP712_init("SignatureMintERC1155", "1"); + } + + function __SignatureMintERC1155_init_unchained() internal onlyInitializing {} + + /// @dev Verifies that a mint request is signed by an account holding MINTER_ROLE (at the time of the function call). + function verify( + MintRequest calldata _req, + bytes calldata _signature + ) public view override returns (bool success, address signer) { + signer = _recoverAddress(_req, _signature); + success = !minted[_req.uid] && _isAuthorizedSigner(signer); + } + + /// @dev Returns whether a given address is authorized to sign mint requests. + function _isAuthorizedSigner(address _signer) internal view virtual returns (bool); + + /// @dev Verifies a mint request and marks the request as minted. + function _processRequest(MintRequest calldata _req, bytes calldata _signature) internal returns (address signer) { + bool success; + (success, signer) = verify(_req, _signature); + + require(success, "Invalid request"); + require( + _req.validityStartTimestamp <= block.timestamp && block.timestamp <= _req.validityEndTimestamp, + "Request expired" + ); + require(_req.to != address(0), "recipient undefined"); + require(_req.quantity > 0, "0 qty"); + + minted[_req.uid] = true; + } + + /// @dev Returns the address of the signer of the mint request. + function _recoverAddress(MintRequest calldata _req, bytes calldata _signature) internal view returns (address) { + return _hashTypedDataV4(keccak256(_encodeRequest(_req))).recover(_signature); + } + + /// @dev Resolves 'stack too deep' error in `recoverAddress`. + function _encodeRequest(MintRequest calldata _req) internal pure returns (bytes memory) { + return + bytes.concat( + abi.encode( + TYPEHASH, + _req.to, + _req.royaltyRecipient, + _req.royaltyBps, + _req.primarySaleRecipient, + _req.tokenId, + keccak256(bytes(_req.uri)) + ), + abi.encode( + _req.quantity, + _req.pricePerToken, + _req.currency, + _req.validityStartTimestamp, + _req.validityEndTimestamp, + _req.uid + ) + ); + } +} diff --git a/contracts/extension/SignatureMintERC20.sol b/contracts/extension/SignatureMintERC20.sol new file mode 100644 index 000000000..fb561b8b3 --- /dev/null +++ b/contracts/extension/SignatureMintERC20.sol @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "./interface/ISignatureMintERC20.sol"; +import "../external-deps/openzeppelin/utils/cryptography/EIP712.sol"; + +abstract contract SignatureMintERC20 is EIP712, ISignatureMintERC20 { + using ECDSA for bytes32; + + bytes32 private constant TYPEHASH = + keccak256( + "MintRequest(address to,address primarySaleRecipient,uint256 quantity,uint256 price,address currency,uint128 validityStartTimestamp,uint128 validityEndTimestamp,bytes32 uid)" + ); + + /// @dev Mapping from mint request UID => whether the mint request is processed. + mapping(bytes32 => bool) private minted; + + constructor() EIP712("SignatureMintERC20", "1") {} + + /// @dev Verifies that a mint request is signed by an account holding MINTER_ROLE (at the time of the function call). + function verify( + MintRequest calldata _req, + bytes calldata _signature + ) public view override returns (bool success, address signer) { + signer = _recoverAddress(_req, _signature); + success = !minted[_req.uid] && _canSignMintRequest(signer); + } + + /// @dev Returns whether a given address is authorized to sign mint requests. + function _canSignMintRequest(address _signer) internal view virtual returns (bool); + + /// @dev Verifies a mint request and marks the request as minted. + function _processRequest(MintRequest calldata _req, bytes calldata _signature) internal returns (address signer) { + bool success; + (success, signer) = verify(_req, _signature); + + require(success, "Invalid request"); + require( + _req.validityStartTimestamp <= block.timestamp && block.timestamp <= _req.validityEndTimestamp, + "Request expired" + ); + require(_req.to != address(0), "recipient undefined"); + require(_req.quantity > 0, "0 qty"); + + minted[_req.uid] = true; + } + + /// @dev Returns the address of the signer of the mint request. + function _recoverAddress(MintRequest calldata _req, bytes calldata _signature) internal view returns (address) { + return _hashTypedDataV4(keccak256(_encodeRequest(_req))).recover(_signature); + } + + /// @dev Resolves 'stack too deep' error in `recoverAddress`. + function _encodeRequest(MintRequest calldata _req) internal pure returns (bytes memory) { + return + abi.encode( + TYPEHASH, + _req.to, + _req.primarySaleRecipient, + _req.quantity, + _req.price, + _req.currency, + _req.validityStartTimestamp, + _req.validityEndTimestamp, + _req.uid + ); + } +} diff --git a/contracts/extension/SignatureMintERC20Upgradeable.sol b/contracts/extension/SignatureMintERC20Upgradeable.sol new file mode 100644 index 000000000..d67b1eac5 --- /dev/null +++ b/contracts/extension/SignatureMintERC20Upgradeable.sol @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "./interface/ISignatureMintERC20.sol"; + +import "@openzeppelin/contracts-upgradeable/utils/cryptography/ECDSAUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/utils/cryptography/draft-EIP712Upgradeable.sol"; + +abstract contract SignatureMintERC20Upgradeable is Initializable, EIP712Upgradeable, ISignatureMintERC20 { + using ECDSAUpgradeable for bytes32; + + bytes32 private constant TYPEHASH = + keccak256( + "MintRequest(address to,address primarySaleRecipient,uint256 quantity,uint256 price,address currency,uint128 validityStartTimestamp,uint128 validityEndTimestamp,bytes32 uid)" + ); + + /// @dev Mapping from mint request UID => whether the mint request is processed. + mapping(bytes32 => bool) private minted; + + function __SignatureMintERC20_init(string memory _name) internal onlyInitializing { + __EIP712_init(_name, "1"); + __SignatureMintERC20_init_unchained(_name); + } + + function __SignatureMintERC20_init_unchained(string memory) internal onlyInitializing {} + + /// @dev Verifies that a mint request is signed by an account holding MINTER_ROLE (at the time of the function call). + function verify( + MintRequest calldata _req, + bytes calldata _signature + ) public view override returns (bool success, address signer) { + signer = _recoverAddress(_req, _signature); + success = !minted[_req.uid] && _isAuthorizedSigner(signer); + } + + /// @dev Returns whether a given address is authorized to sign mint requests. + function _isAuthorizedSigner(address _signer) internal view virtual returns (bool); + + /// @dev Verifies a mint request and marks the request as minted. + function _processRequest(MintRequest calldata _req, bytes calldata _signature) internal returns (address signer) { + bool success; + (success, signer) = verify(_req, _signature); + + require(success, "Invalid request"); + require( + _req.validityStartTimestamp <= block.timestamp && block.timestamp <= _req.validityEndTimestamp, + "Request expired" + ); + require(_req.to != address(0), "recipient undefined"); + require(_req.quantity > 0, "Minting zero qty"); + + minted[_req.uid] = true; + } + + /// @dev Returns the address of the signer of the mint request. + function _recoverAddress(MintRequest calldata _req, bytes calldata _signature) internal view returns (address) { + return _hashTypedDataV4(keccak256(_encodeRequest(_req))).recover(_signature); + } + + /// @dev Resolves 'stack too deep' error in `recoverAddress`. + function _encodeRequest(MintRequest calldata _req) internal pure returns (bytes memory) { + return + abi.encode( + TYPEHASH, + _req.to, + _req.primarySaleRecipient, + _req.quantity, + _req.price, + _req.currency, + _req.validityStartTimestamp, + _req.validityEndTimestamp, + _req.uid + ); + } +} diff --git a/contracts/extension/SignatureMintERC721.sol b/contracts/extension/SignatureMintERC721.sol new file mode 100644 index 000000000..2db421f8f --- /dev/null +++ b/contracts/extension/SignatureMintERC721.sol @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "./interface/ISignatureMintERC721.sol"; +import "../external-deps/openzeppelin/utils/cryptography/EIP712.sol"; + +abstract contract SignatureMintERC721 is EIP712, ISignatureMintERC721 { + /// @dev The sender is not authorized to perform the action + error SignatureMintUnauthorized(); + + /// @dev The signer is not authorized to perform the signing action + error SignatureMintInvalidSigner(); + + /// @dev The signature is either expired or not ready to be claimed yet + error SignatureMintInvalidTime(uint256 startTime, uint256 endTime, uint256 actualTime); + + /// @dev Invalid mint recipient + error SignatureMintInvalidRecipient(); + + /// @dev Invalid mint quantity + error SignatureMintInvalidQuantity(); + + using ECDSA for bytes32; + + bytes32 private constant TYPEHASH = + keccak256( + "MintRequest(address to,address royaltyRecipient,uint256 royaltyBps,address primarySaleRecipient,string uri,uint256 quantity,uint256 pricePerToken,address currency,uint128 validityStartTimestamp,uint128 validityEndTimestamp,bytes32 uid)" + ); + + /// @dev Mapping from mint request UID => whether the mint request is processed. + mapping(bytes32 => bool) private minted; + + constructor() EIP712("SignatureMintERC721", "1") {} + + /// @dev Verifies that a mint request is signed by an authorized account. + function verify( + MintRequest calldata _req, + bytes calldata _signature + ) public view override returns (bool success, address signer) { + signer = _recoverAddress(_req, _signature); + success = !minted[_req.uid] && _canSignMintRequest(signer); + } + + /// @dev Returns whether a given address is authorized to sign mint requests. + function _canSignMintRequest(address _signer) internal view virtual returns (bool); + + /// @dev Verifies a mint request and marks the request as minted. + function _processRequest(MintRequest calldata _req, bytes calldata _signature) internal returns (address signer) { + bool success; + (success, signer) = verify(_req, _signature); + + if (!success) { + revert SignatureMintInvalidSigner(); + } + + if (_req.validityStartTimestamp > block.timestamp || block.timestamp > _req.validityEndTimestamp) { + revert SignatureMintInvalidTime(_req.validityStartTimestamp, _req.validityEndTimestamp, block.timestamp); + } + + if (_req.to == address(0)) { + revert SignatureMintInvalidRecipient(); + } + + if (_req.quantity == 0) { + revert SignatureMintInvalidQuantity(); + } + + minted[_req.uid] = true; + } + + /// @dev Returns the address of the signer of the mint request. + function _recoverAddress(MintRequest calldata _req, bytes calldata _signature) internal view returns (address) { + return _hashTypedDataV4(keccak256(_encodeRequest(_req))).recover(_signature); + } + + /// @dev Resolves 'stack too deep' error in `recoverAddress`. + function _encodeRequest(MintRequest calldata _req) internal pure returns (bytes memory) { + return + abi.encode( + TYPEHASH, + _req.to, + _req.royaltyRecipient, + _req.royaltyBps, + _req.primarySaleRecipient, + keccak256(bytes(_req.uri)), + _req.quantity, + _req.pricePerToken, + _req.currency, + _req.validityStartTimestamp, + _req.validityEndTimestamp, + _req.uid + ); + } +} diff --git a/contracts/extension/SignatureMintERC721Upgradeable.sol b/contracts/extension/SignatureMintERC721Upgradeable.sol new file mode 100644 index 000000000..935cd749a --- /dev/null +++ b/contracts/extension/SignatureMintERC721Upgradeable.sol @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "./interface/ISignatureMintERC721.sol"; + +import "@openzeppelin/contracts-upgradeable/utils/cryptography/ECDSAUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/utils/cryptography/draft-EIP712Upgradeable.sol"; + +abstract contract SignatureMintERC721Upgradeable is Initializable, EIP712Upgradeable, ISignatureMintERC721 { + /// @dev The sender is not authorized to perform the action + error SignatureMintUnauthorized(); + + /// @dev The signer is not authorized to perform the signing action + error SignatureMintInvalidSigner(); + + /// @dev The signature is either expired or not ready to be claimed yet + error SignatureMintInvalidTime(uint256 startTime, uint256 endTime, uint256 actualTime); + + /// @dev Invalid mint recipient + error SignatureMintInvalidRecipient(); + + /// @dev Invalid mint quantity + error SignatureMintInvalidQuantity(); + + using ECDSAUpgradeable for bytes32; + + bytes32 private constant TYPEHASH = + keccak256( + "MintRequest(address to,address royaltyRecipient,uint256 royaltyBps,address primarySaleRecipient,string uri,uint256 quantity,uint256 pricePerToken,address currency,uint128 validityStartTimestamp,uint128 validityEndTimestamp,bytes32 uid)" + ); + + /// @dev Mapping from mint request UID => whether the mint request is processed. + mapping(bytes32 => bool) private minted; + + function __SignatureMintERC721_init() internal onlyInitializing { + __EIP712_init("SignatureMintERC721", "1"); + } + + function __SignatureMintERC721_init_unchained() internal onlyInitializing {} + + /// @dev Verifies that a mint request is signed by an account holding MINTER_ROLE (at the time of the function call). + function verify( + MintRequest calldata _req, + bytes calldata _signature + ) public view override returns (bool success, address signer) { + signer = _recoverAddress(_req, _signature); + success = !minted[_req.uid] && _isAuthorizedSigner(signer); + } + + /// @dev Returns whether a given address is authorized to sign mint requests. + function _isAuthorizedSigner(address _signer) internal view virtual returns (bool); + + /// @dev Verifies a mint request and marks the request as minted. + function _processRequest(MintRequest calldata _req, bytes calldata _signature) internal returns (address signer) { + bool success; + (success, signer) = verify(_req, _signature); + + if (!success) { + revert SignatureMintInvalidSigner(); + } + + if (_req.validityStartTimestamp > block.timestamp || block.timestamp > _req.validityEndTimestamp) { + revert SignatureMintInvalidTime(_req.validityStartTimestamp, _req.validityEndTimestamp, block.timestamp); + } + + if (_req.to == address(0)) { + revert SignatureMintInvalidRecipient(); + } + + if (_req.quantity == 0) { + revert SignatureMintInvalidQuantity(); + } + + minted[_req.uid] = true; + } + + /// @dev Returns the address of the signer of the mint request. + function _recoverAddress(MintRequest calldata _req, bytes calldata _signature) internal view returns (address) { + return _hashTypedDataV4(keccak256(_encodeRequest(_req))).recover(_signature); + } + + /// @dev Resolves 'stack too deep' error in `recoverAddress`. + function _encodeRequest(MintRequest calldata _req) internal pure returns (bytes memory) { + return + abi.encode( + TYPEHASH, + _req.to, + _req.royaltyRecipient, + _req.royaltyBps, + _req.primarySaleRecipient, + keccak256(bytes(_req.uri)), + _req.quantity, + _req.pricePerToken, + _req.currency, + _req.validityStartTimestamp, + _req.validityEndTimestamp, + _req.uid + ); + } +} diff --git a/contracts/extension/SoulboundERC721A.sol b/contracts/extension/SoulboundERC721A.sol new file mode 100644 index 000000000..7c8eba553 --- /dev/null +++ b/contracts/extension/SoulboundERC721A.sol @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "./PermissionsEnumerable.sol"; + +/** + * The `SoulboundERC721A` extension smart contract is meant to be used with ERC721A contracts as its base. It + * provides the appropriate `before transfer` hook for ERC721A, where it checks whether a given transfer is + * valid to go through or not. + * + * This contract uses the `Permissions` extension, and creates a role 'TRANSFER_ROLE'. + * - If `address(0)` holds the transfer role, then all transfers go through. + * - Else, a transfer goes through only if either the sender or recipient holds the transfe role. + */ + +abstract contract SoulboundERC721A is PermissionsEnumerable { + /// @dev Only transfers to or from TRANSFER_ROLE holders are valid, when transfers are restricted. + bytes32 public constant TRANSFER_ROLE = keccak256("TRANSFER_ROLE"); + + event TransfersRestricted(bool isRestricted); + + /** + * @notice Restrict transfers of NFTs. + * @dev Restricting transfers means revoking the TRANSFER_ROLE from address(0). Making + * transfers unrestricted means granting the TRANSFER_ROLE to address(0). + * + * @param _toRestrict Whether to restrict transfers or not. + */ + function restrictTransfers(bool _toRestrict) public virtual { + if (_toRestrict) { + _revokeRole(TRANSFER_ROLE, address(0)); + } else { + _setupRole(TRANSFER_ROLE, address(0)); + } + } + + /// @dev Returns whether transfers can be restricted in a given execution context. + function _canRestrictTransfers() internal view virtual returns (bool); + + /// @dev See {ERC721A-_beforeTokenTransfers}. + function _beforeTokenTransfers(address from, address to, uint256, uint256) internal virtual { + // If transfers are restricted on the contract, we still want to allow burning and minting. + if (!hasRole(TRANSFER_ROLE, address(0)) && from != address(0) && to != address(0)) { + if (!hasRole(TRANSFER_ROLE, from) && !hasRole(TRANSFER_ROLE, to)) { + revert("!TRANSFER_ROLE"); + } + } + } +} diff --git a/contracts/extension/Staking1155.sol b/contracts/extension/Staking1155.sol new file mode 100644 index 000000000..d30885b12 --- /dev/null +++ b/contracts/extension/Staking1155.sol @@ -0,0 +1,516 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/// @author thirdweb + +import "../external-deps/openzeppelin/security/ReentrancyGuard.sol"; +import "../external-deps/openzeppelin/utils/math/SafeMath.sol"; +import "../eip/interface/IERC1155.sol"; + +import "./interface/IStaking1155.sol"; + +abstract contract Staking1155 is ReentrancyGuard, IStaking1155 { + /*/////////////////////////////////////////////////////////////// + State variables / Mappings + //////////////////////////////////////////////////////////////*/ + + ///@dev Address of ERC1155 contract -- staked tokens belong to this contract. + address public immutable stakingToken; + + /// @dev Flag to check direct transfers of staking tokens. + uint8 internal isStaking = 1; + + ///@dev Next staking condition Id. Tracks number of conditon updates so far. + uint64 private nextDefaultConditionId; + + ///@dev List of token-ids ever staked. + uint256[] public indexedTokens; + + ///@dev Mapping from token-id to whether it is indexed or not. + mapping(uint256 => bool) public isIndexed; + + ///@dev Mapping from default condition-id to default condition. + mapping(uint64 => StakingCondition) private defaultCondition; + + ///@dev Mapping from token-id to next staking condition Id for the token. Tracks number of conditon updates so far. + mapping(uint256 => uint64) private nextConditionId; + + ///@dev Mapping from token-id and staker address to Staker struct. See {struct IStaking1155.Staker}. + mapping(uint256 => mapping(address => Staker)) public stakers; + + ///@dev Mapping from token-id and condition Id to staking condition. See {struct IStaking1155.StakingCondition} + mapping(uint256 => mapping(uint64 => StakingCondition)) private stakingConditions; + + /// @dev Mapping from token-id to list of accounts that have staked that token-id. + mapping(uint256 => address[]) public stakersArray; + + constructor(address _stakingToken) ReentrancyGuard() { + require(address(_stakingToken) != address(0), "address 0"); + stakingToken = _stakingToken; + } + + /*/////////////////////////////////////////////////////////////// + External/Public Functions + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Stake ERC721 Tokens. + * + * @dev See {_stake}. Override that to implement custom logic. + * + * @param _tokenId ERC1155 token-id to stake. + * @param _amount Amount to stake. + */ + function stake(uint256 _tokenId, uint64 _amount) external nonReentrant { + _stake(_tokenId, _amount); + } + + /** + * @notice Withdraw staked tokens. + * + * @dev See {_withdraw}. Override that to implement custom logic. + * + * @param _tokenId ERC1155 token-id to withdraw. + * @param _amount Amount to withdraw. + */ + function withdraw(uint256 _tokenId, uint64 _amount) external nonReentrant { + _withdraw(_tokenId, _amount); + } + + /** + * @notice Claim accumulated rewards. + * + * @dev See {_claimRewards}. Override that to implement custom logic. + * See {_calculateRewards} for reward-calculation logic. + * + * @param _tokenId Staked token Id. + */ + function claimRewards(uint256 _tokenId) external nonReentrant { + _claimRewards(_tokenId); + } + + /** + * @notice Set time unit. Set as a number of seconds. + * Could be specified as -- x * 1 hours, x * 1 days, etc. + * + * @dev Only admin/authorized-account can call it. + * + * + * @param _tokenId ERC1155 token Id. + * @param _timeUnit New time unit. + */ + function setTimeUnit(uint256 _tokenId, uint80 _timeUnit) external virtual { + if (!_canSetStakeConditions()) { + revert("Not authorized"); + } + + uint64 _nextConditionId = nextConditionId[_tokenId]; + StakingCondition memory condition = _nextConditionId == 0 + ? defaultCondition[nextDefaultConditionId - 1] + : stakingConditions[_tokenId][_nextConditionId - 1]; + require(_timeUnit != condition.timeUnit, "Time-unit unchanged."); + + _setStakingCondition(_tokenId, _timeUnit, condition.rewardsPerUnitTime); + + emit UpdatedTimeUnit(_tokenId, condition.timeUnit, _timeUnit); + } + + /** + * @notice Set rewards per unit of time. + * Interpreted as x rewards per second/per day/etc based on time-unit. + * + * @dev Only admin/authorized-account can call it. + * + * + * @param _tokenId ERC1155 token Id. + * @param _rewardsPerUnitTime New rewards per unit time. + */ + function setRewardsPerUnitTime(uint256 _tokenId, uint256 _rewardsPerUnitTime) external virtual { + if (!_canSetStakeConditions()) { + revert("Not authorized"); + } + + uint64 _nextConditionId = nextConditionId[_tokenId]; + StakingCondition memory condition = _nextConditionId == 0 + ? defaultCondition[nextDefaultConditionId - 1] + : stakingConditions[_tokenId][_nextConditionId - 1]; + require(_rewardsPerUnitTime != condition.rewardsPerUnitTime, "Reward unchanged."); + + _setStakingCondition(_tokenId, condition.timeUnit, _rewardsPerUnitTime); + + emit UpdatedRewardsPerUnitTime(_tokenId, condition.rewardsPerUnitTime, _rewardsPerUnitTime); + } + + /** + * @notice Set time unit. Set as a number of seconds. + * Could be specified as -- x * 1 hours, x * 1 days, etc. + * + * @dev Only admin/authorized-account can call it. + * + * @param _defaultTimeUnit New time unit. + */ + function setDefaultTimeUnit(uint80 _defaultTimeUnit) external virtual { + if (!_canSetStakeConditions()) { + revert("Not authorized"); + } + + StakingCondition memory _defaultCondition = defaultCondition[nextDefaultConditionId - 1]; + require(_defaultTimeUnit != _defaultCondition.timeUnit, "Default time-unit unchanged."); + + _setDefaultStakingCondition(_defaultTimeUnit, _defaultCondition.rewardsPerUnitTime); + + emit UpdatedDefaultTimeUnit(_defaultCondition.timeUnit, _defaultTimeUnit); + } + + /** + * @notice Set rewards per unit of time. + * Interpreted as x rewards per second/per day/etc based on time-unit. + * + * @dev Only admin/authorized-account can call it. + * + * @param _defaultRewardsPerUnitTime New rewards per unit time. + */ + function setDefaultRewardsPerUnitTime(uint256 _defaultRewardsPerUnitTime) external virtual { + if (!_canSetStakeConditions()) { + revert("Not authorized"); + } + + StakingCondition memory _defaultCondition = defaultCondition[nextDefaultConditionId - 1]; + require(_defaultRewardsPerUnitTime != _defaultCondition.rewardsPerUnitTime, "Default reward unchanged."); + + _setDefaultStakingCondition(_defaultCondition.timeUnit, _defaultRewardsPerUnitTime); + + emit UpdatedDefaultRewardsPerUnitTime(_defaultCondition.rewardsPerUnitTime, _defaultRewardsPerUnitTime); + } + + /** + * @notice View amount staked and rewards for a user, for a given token-id. + * + * @param _staker Address for which to calculated rewards. + * @return _tokensStaked Amount of tokens staked for given token-id. + * @return _rewards Available reward amount. + */ + function getStakeInfoForToken( + uint256 _tokenId, + address _staker + ) external view virtual returns (uint256 _tokensStaked, uint256 _rewards) { + _tokensStaked = stakers[_tokenId][_staker].amountStaked; + _rewards = _availableRewards(_tokenId, _staker); + } + + /** + * @notice View all tokens staked and total rewards for a user. + * + * @param _staker Address for which to calculated rewards. + * @return _tokensStaked List of token-ids staked. + * @return _tokenAmounts Amount of each token-id staked. + * @return _totalRewards Total rewards available. + */ + function getStakeInfo( + address _staker + ) + external + view + virtual + returns (uint256[] memory _tokensStaked, uint256[] memory _tokenAmounts, uint256 _totalRewards) + { + uint256[] memory _indexedTokens = indexedTokens; + uint256[] memory _stakedAmounts = new uint256[](_indexedTokens.length); + uint256 indexedTokenCount = _indexedTokens.length; + uint256 stakerTokenCount = 0; + + for (uint256 i = 0; i < indexedTokenCount; i++) { + _stakedAmounts[i] = stakers[_indexedTokens[i]][_staker].amountStaked; + if (_stakedAmounts[i] > 0) stakerTokenCount += 1; + } + + _tokensStaked = new uint256[](stakerTokenCount); + _tokenAmounts = new uint256[](stakerTokenCount); + uint256 count = 0; + for (uint256 i = 0; i < indexedTokenCount; i++) { + if (_stakedAmounts[i] > 0) { + _tokensStaked[count] = _indexedTokens[i]; + _tokenAmounts[count] = _stakedAmounts[i]; + _totalRewards += _availableRewards(_indexedTokens[i], _staker); + count += 1; + } + } + } + + function getTimeUnit(uint256 _tokenId) public view returns (uint256 _timeUnit) { + uint64 _nextConditionId = nextConditionId[_tokenId]; + require(_nextConditionId != 0, "Time unit not set. Check default time unit."); + _timeUnit = stakingConditions[_tokenId][_nextConditionId - 1].timeUnit; + } + + function getRewardsPerUnitTime(uint256 _tokenId) public view returns (uint256 _rewardsPerUnitTime) { + uint64 _nextConditionId = nextConditionId[_tokenId]; + require(_nextConditionId != 0, "Rewards not set. Check default rewards."); + _rewardsPerUnitTime = stakingConditions[_tokenId][_nextConditionId - 1].rewardsPerUnitTime; + } + + function getDefaultTimeUnit() public view returns (uint256 _timeUnit) { + _timeUnit = defaultCondition[nextDefaultConditionId - 1].timeUnit; + } + + function getDefaultRewardsPerUnitTime() public view returns (uint256 _rewardsPerUnitTime) { + _rewardsPerUnitTime = defaultCondition[nextDefaultConditionId - 1].rewardsPerUnitTime; + } + + /*/////////////////////////////////////////////////////////////// + Internal Functions + //////////////////////////////////////////////////////////////*/ + + /// @dev Staking logic. Override to add custom logic. + function _stake(uint256 _tokenId, uint64 _amount) internal virtual { + require(_amount != 0, "Staking 0 tokens"); + + if (stakers[_tokenId][_stakeMsgSender()].amountStaked > 0) { + _updateUnclaimedRewardsForStaker(_tokenId, _stakeMsgSender()); + } else { + stakersArray[_tokenId].push(_stakeMsgSender()); + stakers[_tokenId][_stakeMsgSender()].timeOfLastUpdate = uint80(block.timestamp); + + uint64 _conditionId = nextConditionId[_tokenId]; + unchecked { + stakers[_tokenId][_stakeMsgSender()].conditionIdOflastUpdate = _conditionId == 0 + ? nextDefaultConditionId - 1 + : _conditionId - 1; + } + } + + isStaking = 2; + IERC1155(stakingToken).safeTransferFrom(_stakeMsgSender(), address(this), _tokenId, _amount, ""); + isStaking = 1; + // stakerAddress[_tokenIds[i]] = _stakeMsgSender(); + stakers[_tokenId][_stakeMsgSender()].amountStaked += _amount; + + if (!isIndexed[_tokenId]) { + isIndexed[_tokenId] = true; + indexedTokens.push(_tokenId); + } + + emit TokensStaked(_stakeMsgSender(), _tokenId, _amount); + } + + /// @dev Withdraw logic. Override to add custom logic. + function _withdraw(uint256 _tokenId, uint64 _amount) internal virtual { + uint256 _amountStaked = stakers[_tokenId][_stakeMsgSender()].amountStaked; + require(_amount != 0, "Withdrawing 0 tokens"); + require(_amountStaked >= _amount, "Withdrawing more than staked"); + + _updateUnclaimedRewardsForStaker(_tokenId, _stakeMsgSender()); + + if (_amountStaked == _amount) { + address[] memory _stakersArray = stakersArray[_tokenId]; + for (uint256 i = 0; i < _stakersArray.length; ++i) { + if (_stakersArray[i] == _stakeMsgSender()) { + stakersArray[_tokenId][i] = _stakersArray[_stakersArray.length - 1]; + stakersArray[_tokenId].pop(); + break; + } + } + } + stakers[_tokenId][_stakeMsgSender()].amountStaked -= _amount; + + IERC1155(stakingToken).safeTransferFrom(address(this), _stakeMsgSender(), _tokenId, _amount, ""); + + emit TokensWithdrawn(_stakeMsgSender(), _tokenId, _amount); + } + + /// @dev Logic for claiming rewards. Override to add custom logic. + function _claimRewards(uint256 _tokenId) internal virtual { + uint256 rewards = stakers[_tokenId][_stakeMsgSender()].unclaimedRewards + + _calculateRewards(_tokenId, _stakeMsgSender()); + + require(rewards != 0, "No rewards"); + + stakers[_tokenId][_stakeMsgSender()].timeOfLastUpdate = uint80(block.timestamp); + stakers[_tokenId][_stakeMsgSender()].unclaimedRewards = 0; + + uint64 _conditionId = nextConditionId[_tokenId]; + + unchecked { + stakers[_tokenId][_stakeMsgSender()].conditionIdOflastUpdate = _conditionId == 0 + ? nextDefaultConditionId - 1 + : _conditionId - 1; + } + + _mintRewards(_stakeMsgSender(), rewards); + + emit RewardsClaimed(_stakeMsgSender(), rewards); + } + + /// @dev View available rewards for a user. + function _availableRewards(uint256 _tokenId, address _user) internal view virtual returns (uint256 _rewards) { + if (stakers[_tokenId][_user].amountStaked == 0) { + _rewards = stakers[_tokenId][_user].unclaimedRewards; + } else { + _rewards = stakers[_tokenId][_user].unclaimedRewards + _calculateRewards(_tokenId, _user); + } + } + + /// @dev Update unclaimed rewards for a users. Called for every state change for a user. + function _updateUnclaimedRewardsForStaker(uint256 _tokenId, address _staker) internal virtual { + uint256 rewards = _calculateRewards(_tokenId, _staker); + stakers[_tokenId][_staker].unclaimedRewards += rewards; + stakers[_tokenId][_staker].timeOfLastUpdate = uint80(block.timestamp); + + uint64 _conditionId = nextConditionId[_tokenId]; + unchecked { + stakers[_tokenId][_staker].conditionIdOflastUpdate = _conditionId == 0 + ? nextDefaultConditionId - 1 + : _conditionId - 1; + } + } + + /// @dev Set staking conditions, for a token-Id. + function _setStakingCondition(uint256 _tokenId, uint80 _timeUnit, uint256 _rewardsPerUnitTime) internal virtual { + require(_timeUnit != 0, "time-unit can't be 0"); + uint64 conditionId = nextConditionId[_tokenId]; + + if (conditionId == 0) { + uint64 _nextDefaultConditionId = nextDefaultConditionId; + for (; conditionId < _nextDefaultConditionId; conditionId += 1) { + StakingCondition memory _defaultCondition = defaultCondition[conditionId]; + + stakingConditions[_tokenId][conditionId] = StakingCondition({ + timeUnit: _defaultCondition.timeUnit, + rewardsPerUnitTime: _defaultCondition.rewardsPerUnitTime, + startTimestamp: _defaultCondition.startTimestamp, + endTimestamp: _defaultCondition.endTimestamp + }); + } + } + + stakingConditions[_tokenId][conditionId - 1].endTimestamp = uint80(block.timestamp); + + stakingConditions[_tokenId][conditionId] = StakingCondition({ + timeUnit: _timeUnit, + rewardsPerUnitTime: _rewardsPerUnitTime, + startTimestamp: uint80(block.timestamp), + endTimestamp: 0 + }); + + nextConditionId[_tokenId] = conditionId + 1; + } + + /// @dev Set default staking conditions. + function _setDefaultStakingCondition(uint80 _timeUnit, uint256 _rewardsPerUnitTime) internal virtual { + require(_timeUnit != 0, "time-unit can't be 0"); + uint64 conditionId = nextDefaultConditionId; + nextDefaultConditionId += 1; + + defaultCondition[conditionId] = StakingCondition({ + timeUnit: _timeUnit, + rewardsPerUnitTime: _rewardsPerUnitTime, + startTimestamp: uint80(block.timestamp), + endTimestamp: 0 + }); + + if (conditionId > 0) { + defaultCondition[conditionId - 1].endTimestamp = uint80(block.timestamp); + } + } + + /// @dev Reward calculation logic. Override to implement custom logic. + function _calculateRewards(uint256 _tokenId, address _staker) internal view virtual returns (uint256 _rewards) { + Staker memory staker = stakers[_tokenId][_staker]; + uint64 _stakerConditionId = staker.conditionIdOflastUpdate; + uint64 _nextConditionId = nextConditionId[_tokenId]; + + if (_nextConditionId == 0) { + _nextConditionId = nextDefaultConditionId; + + for (uint64 i = _stakerConditionId; i < _nextConditionId; i += 1) { + StakingCondition memory condition = defaultCondition[i]; + + uint256 startTime = i != _stakerConditionId ? condition.startTimestamp : staker.timeOfLastUpdate; + uint256 endTime = condition.endTimestamp != 0 ? condition.endTimestamp : block.timestamp; + + (bool noOverflowProduct, uint256 rewardsProduct) = SafeMath.tryMul( + (endTime - startTime) * staker.amountStaked, + condition.rewardsPerUnitTime + ); + (bool noOverflowSum, uint256 rewardsSum) = SafeMath.tryAdd( + _rewards, + rewardsProduct / condition.timeUnit + ); + + _rewards = noOverflowProduct && noOverflowSum ? rewardsSum : _rewards; + } + } else { + for (uint64 i = _stakerConditionId; i < _nextConditionId; i += 1) { + StakingCondition memory condition = stakingConditions[_tokenId][i]; + + uint256 startTime = i != _stakerConditionId ? condition.startTimestamp : staker.timeOfLastUpdate; + uint256 endTime = condition.endTimestamp != 0 ? condition.endTimestamp : block.timestamp; + + (bool noOverflowProduct, uint256 rewardsProduct) = SafeMath.tryMul( + (endTime - startTime) * staker.amountStaked, + condition.rewardsPerUnitTime + ); + (bool noOverflowSum, uint256 rewardsSum) = SafeMath.tryAdd( + _rewards, + rewardsProduct / condition.timeUnit + ); + + _rewards = noOverflowProduct && noOverflowSum ? rewardsSum : _rewards; + } + } + } + + /*//////////////////////////////////////////////////////////////////// + Optional hooks that can be implemented in the derived contract + ///////////////////////////////////////////////////////////////////*/ + + /// @dev Exposes the ability to override the msg sender -- support ERC2771. + function _stakeMsgSender() internal virtual returns (address) { + return msg.sender; + } + + /*/////////////////////////////////////////////////////////////// + Virtual functions to be implemented in derived contract + //////////////////////////////////////////////////////////////*/ + + /** + * @notice View total rewards available in the staking contract. + * + */ + function getRewardTokenBalance() external view virtual returns (uint256 _rewardsAvailableInContract); + + /** + * @dev Mint/Transfer ERC20 rewards to the staker. Must override. + * + * @param _staker Address for which to calculated rewards. + * @param _rewards Amount of tokens to be given out as reward. + * + * For example, override as below to mint ERC20 rewards: + * + * ``` + * function _mintRewards(address _staker, uint256 _rewards) internal override { + * + * TokenERC20(rewardTokenAddress).mintTo(_staker, _rewards); + * + * } + * ``` + */ + function _mintRewards(address _staker, uint256 _rewards) internal virtual; + + /** + * @dev Returns whether staking restrictions can be set in given execution context. + * Must override. + * + * + * For example, override as below to restrict access to admin: + * + * ``` + * function _canSetStakeConditions() internal override { + * + * return msg.sender == adminAddress; + * + * } + * ``` + */ + function _canSetStakeConditions() internal view virtual returns (bool); +} diff --git a/contracts/extension/Staking1155Upgradeable.sol b/contracts/extension/Staking1155Upgradeable.sol new file mode 100644 index 000000000..31d967efa --- /dev/null +++ b/contracts/extension/Staking1155Upgradeable.sol @@ -0,0 +1,516 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/// @author thirdweb + +import "@openzeppelin/contracts-upgradeable/security/ReentrancyGuardUpgradeable.sol"; +import "../external-deps/openzeppelin/utils/math/SafeMath.sol"; +import "../eip/interface/IERC1155.sol"; + +import "./interface/IStaking1155.sol"; + +abstract contract Staking1155Upgradeable is ReentrancyGuardUpgradeable, IStaking1155 { + /*/////////////////////////////////////////////////////////////// + State variables / Mappings + //////////////////////////////////////////////////////////////*/ + + ///@dev Address of ERC1155 contract -- staked tokens belong to this contract. + address public stakingToken; + + /// @dev Flag to check direct transfers of staking tokens. + uint8 internal isStaking = 1; + + ///@dev Next staking condition Id. Tracks number of conditon updates so far. + uint64 private nextDefaultConditionId; + + ///@dev List of token-ids ever staked. + uint256[] public indexedTokens; + + ///@dev Mapping from token-id to whether it is indexed or not. + mapping(uint256 => bool) public isIndexed; + + ///@dev Mapping from default condition-id to default condition. + mapping(uint64 => StakingCondition) private defaultCondition; + + ///@dev Mapping from token-id to next staking condition Id for the token. Tracks number of conditon updates so far. + mapping(uint256 => uint64) private nextConditionId; + + ///@dev Mapping from token-id and staker address to Staker struct. See {struct IStaking1155.Staker}. + mapping(uint256 => mapping(address => Staker)) public stakers; + + ///@dev Mapping from token-id and condition Id to staking condition. See {struct IStaking1155.StakingCondition} + mapping(uint256 => mapping(uint64 => StakingCondition)) private stakingConditions; + + /// @dev Mapping from token-id to list of accounts that have staked that token-id. + mapping(uint256 => address[]) public stakersArray; + + function __Staking1155_init(address _stakingToken) internal onlyInitializing { + __ReentrancyGuard_init(); + + require(address(_stakingToken) != address(0), "address 0"); + stakingToken = _stakingToken; + } + + /*/////////////////////////////////////////////////////////////// + External/Public Functions + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Stake ERC721 Tokens. + * + * @dev See {_stake}. Override that to implement custom logic. + * + * @param _tokenId ERC1155 token-id to stake. + * @param _amount Amount to stake. + */ + function stake(uint256 _tokenId, uint64 _amount) external nonReentrant { + _stake(_tokenId, _amount); + } + + /** + * @notice Withdraw staked tokens. + * + * @dev See {_withdraw}. Override that to implement custom logic. + * + * @param _tokenId ERC1155 token-id to withdraw. + * @param _amount Amount to withdraw. + */ + function withdraw(uint256 _tokenId, uint64 _amount) external nonReentrant { + _withdraw(_tokenId, _amount); + } + + /** + * @notice Claim accumulated rewards. + * + * @dev See {_claimRewards}. Override that to implement custom logic. + * See {_calculateRewards} for reward-calculation logic. + * + * @param _tokenId Staked token Id. + */ + function claimRewards(uint256 _tokenId) external nonReentrant { + _claimRewards(_tokenId); + } + + /** + * @notice Set time unit. Set as a number of seconds. + * Could be specified as -- x * 1 hours, x * 1 days, etc. + * + * @dev Only admin/authorized-account can call it. + * + * + * @param _tokenId ERC1155 token Id. + * @param _timeUnit New time unit. + */ + function setTimeUnit(uint256 _tokenId, uint80 _timeUnit) external virtual { + if (!_canSetStakeConditions()) { + revert("Not authorized"); + } + + uint64 _nextConditionId = nextConditionId[_tokenId]; + StakingCondition memory condition = _nextConditionId == 0 + ? defaultCondition[nextDefaultConditionId - 1] + : stakingConditions[_tokenId][_nextConditionId - 1]; + require(_timeUnit != condition.timeUnit, "Time-unit unchanged."); + + _setStakingCondition(_tokenId, _timeUnit, condition.rewardsPerUnitTime); + + emit UpdatedTimeUnit(_tokenId, condition.timeUnit, _timeUnit); + } + + /** + * @notice Set rewards per unit of time. + * Interpreted as x rewards per second/per day/etc based on time-unit. + * + * @dev Only admin/authorized-account can call it. + * + * + * @param _tokenId ERC1155 token Id. + * @param _rewardsPerUnitTime New rewards per unit time. + */ + function setRewardsPerUnitTime(uint256 _tokenId, uint256 _rewardsPerUnitTime) external virtual { + if (!_canSetStakeConditions()) { + revert("Not authorized"); + } + + uint64 _nextConditionId = nextConditionId[_tokenId]; + StakingCondition memory condition = _nextConditionId == 0 + ? defaultCondition[nextDefaultConditionId - 1] + : stakingConditions[_tokenId][_nextConditionId - 1]; + require(_rewardsPerUnitTime != condition.rewardsPerUnitTime, "Reward unchanged."); + + _setStakingCondition(_tokenId, condition.timeUnit, _rewardsPerUnitTime); + + emit UpdatedRewardsPerUnitTime(_tokenId, condition.rewardsPerUnitTime, _rewardsPerUnitTime); + } + + /** + * @notice Set time unit. Set as a number of seconds. + * Could be specified as -- x * 1 hours, x * 1 days, etc. + * + * @dev Only admin/authorized-account can call it. + * + * @param _defaultTimeUnit New time unit. + */ + function setDefaultTimeUnit(uint80 _defaultTimeUnit) external virtual { + if (!_canSetStakeConditions()) { + revert("Not authorized"); + } + + StakingCondition memory _defaultCondition = defaultCondition[nextDefaultConditionId - 1]; + require(_defaultTimeUnit != _defaultCondition.timeUnit, "Default time-unit unchanged."); + + _setDefaultStakingCondition(_defaultTimeUnit, _defaultCondition.rewardsPerUnitTime); + + emit UpdatedDefaultTimeUnit(_defaultCondition.timeUnit, _defaultTimeUnit); + } + + /** + * @notice Set rewards per unit of time. + * Interpreted as x rewards per second/per day/etc based on time-unit. + * + * @dev Only admin/authorized-account can call it. + * + * @param _defaultRewardsPerUnitTime New rewards per unit time. + */ + function setDefaultRewardsPerUnitTime(uint256 _defaultRewardsPerUnitTime) external virtual { + if (!_canSetStakeConditions()) { + revert("Not authorized"); + } + + StakingCondition memory _defaultCondition = defaultCondition[nextDefaultConditionId - 1]; + require(_defaultRewardsPerUnitTime != _defaultCondition.rewardsPerUnitTime, "Default reward unchanged."); + + _setDefaultStakingCondition(_defaultCondition.timeUnit, _defaultRewardsPerUnitTime); + + emit UpdatedDefaultRewardsPerUnitTime(_defaultCondition.rewardsPerUnitTime, _defaultRewardsPerUnitTime); + } + + /** + * @notice View amount staked and rewards for a user, for a given token-id. + * + * @param _staker Address for which to calculated rewards. + * @return _tokensStaked Amount of tokens staked for given token-id. + * @return _rewards Available reward amount. + */ + function getStakeInfoForToken( + uint256 _tokenId, + address _staker + ) external view virtual returns (uint256 _tokensStaked, uint256 _rewards) { + _tokensStaked = stakers[_tokenId][_staker].amountStaked; + _rewards = _availableRewards(_tokenId, _staker); + } + + /** + * @notice View all tokens staked and total rewards for a user. + * + * @param _staker Address for which to calculated rewards. + * @return _tokensStaked List of token-ids staked. + * @return _tokenAmounts Amount of each token-id staked. + * @return _totalRewards Total rewards available. + */ + function getStakeInfo( + address _staker + ) + external + view + virtual + returns (uint256[] memory _tokensStaked, uint256[] memory _tokenAmounts, uint256 _totalRewards) + { + uint256[] memory _indexedTokens = indexedTokens; + uint256[] memory _stakedAmounts = new uint256[](_indexedTokens.length); + uint256 indexedTokenCount = _indexedTokens.length; + uint256 stakerTokenCount = 0; + + for (uint256 i = 0; i < indexedTokenCount; i++) { + _stakedAmounts[i] = stakers[_indexedTokens[i]][_staker].amountStaked; + if (_stakedAmounts[i] > 0) stakerTokenCount += 1; + } + + _tokensStaked = new uint256[](stakerTokenCount); + _tokenAmounts = new uint256[](stakerTokenCount); + uint256 count = 0; + for (uint256 i = 0; i < indexedTokenCount; i++) { + if (_stakedAmounts[i] > 0) { + _tokensStaked[count] = _indexedTokens[i]; + _tokenAmounts[count] = _stakedAmounts[i]; + _totalRewards += _availableRewards(_indexedTokens[i], _staker); + count += 1; + } + } + } + + function getTimeUnit(uint256 _tokenId) public view returns (uint256 _timeUnit) { + uint64 _nextConditionId = nextConditionId[_tokenId]; + require(_nextConditionId != 0, "Time unit not set. Check default time unit."); + _timeUnit = stakingConditions[_tokenId][_nextConditionId - 1].timeUnit; + } + + function getRewardsPerUnitTime(uint256 _tokenId) public view returns (uint256 _rewardsPerUnitTime) { + uint64 _nextConditionId = nextConditionId[_tokenId]; + require(_nextConditionId != 0, "Rewards not set. Check default rewards."); + _rewardsPerUnitTime = stakingConditions[_tokenId][_nextConditionId - 1].rewardsPerUnitTime; + } + + function getDefaultTimeUnit() public view returns (uint256 _timeUnit) { + _timeUnit = defaultCondition[nextDefaultConditionId - 1].timeUnit; + } + + function getDefaultRewardsPerUnitTime() public view returns (uint256 _rewardsPerUnitTime) { + _rewardsPerUnitTime = defaultCondition[nextDefaultConditionId - 1].rewardsPerUnitTime; + } + + /*/////////////////////////////////////////////////////////////// + Internal Functions + //////////////////////////////////////////////////////////////*/ + + /// @dev Staking logic. Override to add custom logic. + function _stake(uint256 _tokenId, uint64 _amount) internal virtual { + require(_amount != 0, "Staking 0 tokens"); + + if (stakers[_tokenId][_stakeMsgSender()].amountStaked > 0) { + _updateUnclaimedRewardsForStaker(_tokenId, _stakeMsgSender()); + } else { + stakersArray[_tokenId].push(_stakeMsgSender()); + stakers[_tokenId][_stakeMsgSender()].timeOfLastUpdate = uint80(block.timestamp); + + uint64 _conditionId = nextConditionId[_tokenId]; + stakers[_tokenId][_stakeMsgSender()].conditionIdOflastUpdate = _conditionId == 0 + ? nextDefaultConditionId - 1 + : _conditionId - 1; + } + + isStaking = 2; + IERC1155(stakingToken).safeTransferFrom(_stakeMsgSender(), address(this), _tokenId, _amount, ""); + isStaking = 1; + // stakerAddress[_tokenIds[i]] = _stakeMsgSender(); + stakers[_tokenId][_stakeMsgSender()].amountStaked += _amount; + + if (!isIndexed[_tokenId]) { + isIndexed[_tokenId] = true; + indexedTokens.push(_tokenId); + } + + emit TokensStaked(_stakeMsgSender(), _tokenId, _amount); + } + + /// @dev Withdraw logic. Override to add custom logic. + function _withdraw(uint256 _tokenId, uint64 _amount) internal virtual { + uint256 _amountStaked = stakers[_tokenId][_stakeMsgSender()].amountStaked; + require(_amount != 0, "Withdrawing 0 tokens"); + require(_amountStaked >= _amount, "Withdrawing more than staked"); + + _updateUnclaimedRewardsForStaker(_tokenId, _stakeMsgSender()); + + if (_amountStaked == _amount) { + address[] memory _stakersArray = stakersArray[_tokenId]; + for (uint256 i = 0; i < _stakersArray.length; ++i) { + if (_stakersArray[i] == _stakeMsgSender()) { + stakersArray[_tokenId][i] = _stakersArray[_stakersArray.length - 1]; + stakersArray[_tokenId].pop(); + break; + } + } + } + + stakers[_tokenId][_stakeMsgSender()].amountStaked -= _amount; + + IERC1155(stakingToken).safeTransferFrom(address(this), _stakeMsgSender(), _tokenId, _amount, ""); + + emit TokensWithdrawn(_stakeMsgSender(), _tokenId, _amount); + } + + /// @dev Logic for claiming rewards. Override to add custom logic. + function _claimRewards(uint256 _tokenId) internal virtual { + uint256 rewards = stakers[_tokenId][_stakeMsgSender()].unclaimedRewards + + _calculateRewards(_tokenId, _stakeMsgSender()); + + require(rewards != 0, "No rewards"); + + stakers[_tokenId][_stakeMsgSender()].timeOfLastUpdate = uint80(block.timestamp); + stakers[_tokenId][_stakeMsgSender()].unclaimedRewards = 0; + + uint64 _conditionId = nextConditionId[_tokenId]; + unchecked { + stakers[_tokenId][_stakeMsgSender()].conditionIdOflastUpdate = _conditionId == 0 + ? nextDefaultConditionId - 1 + : _conditionId - 1; + } + + _mintRewards(_stakeMsgSender(), rewards); + + emit RewardsClaimed(_stakeMsgSender(), rewards); + } + + /// @dev View available rewards for a user. + function _availableRewards(uint256 _tokenId, address _user) internal view virtual returns (uint256 _rewards) { + if (stakers[_tokenId][_user].amountStaked == 0) { + _rewards = stakers[_tokenId][_user].unclaimedRewards; + } else { + _rewards = stakers[_tokenId][_user].unclaimedRewards + _calculateRewards(_tokenId, _user); + } + } + + /// @dev Update unclaimed rewards for a users. Called for every state change for a user. + function _updateUnclaimedRewardsForStaker(uint256 _tokenId, address _staker) internal virtual { + uint256 rewards = _calculateRewards(_tokenId, _staker); + stakers[_tokenId][_staker].unclaimedRewards += rewards; + stakers[_tokenId][_staker].timeOfLastUpdate = uint80(block.timestamp); + + uint64 _conditionId = nextConditionId[_tokenId]; + unchecked { + stakers[_tokenId][_staker].conditionIdOflastUpdate = _conditionId == 0 + ? nextDefaultConditionId - 1 + : _conditionId - 1; + } + } + + /// @dev Set staking conditions, for a token-Id. + function _setStakingCondition(uint256 _tokenId, uint80 _timeUnit, uint256 _rewardsPerUnitTime) internal virtual { + require(_timeUnit != 0, "time-unit can't be 0"); + uint64 conditionId = nextConditionId[_tokenId]; + + if (conditionId == 0) { + uint256 _nextDefaultConditionId = nextDefaultConditionId; + for (; conditionId < _nextDefaultConditionId; conditionId += 1) { + StakingCondition memory _defaultCondition = defaultCondition[conditionId]; + + stakingConditions[_tokenId][conditionId] = StakingCondition({ + timeUnit: _defaultCondition.timeUnit, + rewardsPerUnitTime: _defaultCondition.rewardsPerUnitTime, + startTimestamp: _defaultCondition.startTimestamp, + endTimestamp: _defaultCondition.endTimestamp + }); + } + } + + stakingConditions[_tokenId][conditionId - 1].endTimestamp = uint80(block.timestamp); + + stakingConditions[_tokenId][conditionId] = StakingCondition({ + timeUnit: _timeUnit, + rewardsPerUnitTime: _rewardsPerUnitTime, + startTimestamp: uint80(block.timestamp), + endTimestamp: 0 + }); + + nextConditionId[_tokenId] = conditionId + 1; + } + + /// @dev Set default staking conditions. + function _setDefaultStakingCondition(uint80 _timeUnit, uint256 _rewardsPerUnitTime) internal virtual { + require(_timeUnit != 0, "time-unit can't be 0"); + uint64 conditionId = nextDefaultConditionId; + nextDefaultConditionId += 1; + + defaultCondition[conditionId] = StakingCondition({ + timeUnit: _timeUnit, + rewardsPerUnitTime: _rewardsPerUnitTime, + startTimestamp: uint80(block.timestamp), + endTimestamp: 0 + }); + + if (conditionId > 0) { + defaultCondition[conditionId - 1].endTimestamp = uint80(block.timestamp); + } + } + + /// @dev Reward calculation logic. Override to implement custom logic. + function _calculateRewards(uint256 _tokenId, address _staker) internal view virtual returns (uint256 _rewards) { + Staker memory staker = stakers[_tokenId][_staker]; + uint64 _stakerConditionId = staker.conditionIdOflastUpdate; + uint64 _nextConditionId = nextConditionId[_tokenId]; + + if (_nextConditionId == 0) { + _nextConditionId = nextDefaultConditionId; + + for (uint64 i = _stakerConditionId; i < _nextConditionId; i += 1) { + StakingCondition memory condition = defaultCondition[i]; + + uint256 startTime = i != _stakerConditionId ? condition.startTimestamp : staker.timeOfLastUpdate; + uint256 endTime = condition.endTimestamp != 0 ? condition.endTimestamp : block.timestamp; + + (bool noOverflowProduct, uint256 rewardsProduct) = SafeMath.tryMul( + (endTime - startTime) * staker.amountStaked, + condition.rewardsPerUnitTime + ); + (bool noOverflowSum, uint256 rewardsSum) = SafeMath.tryAdd( + _rewards, + rewardsProduct / condition.timeUnit + ); + + _rewards = noOverflowProduct && noOverflowSum ? rewardsSum : _rewards; + } + } else { + for (uint64 i = _stakerConditionId; i < _nextConditionId; i += 1) { + StakingCondition memory condition = stakingConditions[_tokenId][i]; + + uint256 startTime = i != _stakerConditionId ? condition.startTimestamp : staker.timeOfLastUpdate; + uint256 endTime = condition.endTimestamp != 0 ? condition.endTimestamp : block.timestamp; + + (bool noOverflowProduct, uint256 rewardsProduct) = SafeMath.tryMul( + (endTime - startTime) * staker.amountStaked, + condition.rewardsPerUnitTime + ); + (bool noOverflowSum, uint256 rewardsSum) = SafeMath.tryAdd( + _rewards, + rewardsProduct / condition.timeUnit + ); + + _rewards = noOverflowProduct && noOverflowSum ? rewardsSum : _rewards; + } + } + } + + /*//////////////////////////////////////////////////////////////////// + Optional hooks that can be implemented in the derived contract + ///////////////////////////////////////////////////////////////////*/ + + /// @dev Exposes the ability to override the msg sender -- support ERC2771. + function _stakeMsgSender() internal virtual returns (address) { + return msg.sender; + } + + /*/////////////////////////////////////////////////////////////// + Virtual functions to be implemented in derived contract + //////////////////////////////////////////////////////////////*/ + + /** + * @notice View total rewards available in the staking contract. + * + */ + function getRewardTokenBalance() external view virtual returns (uint256 _rewardsAvailableInContract); + + /** + * @dev Mint/Transfer ERC20 rewards to the staker. Must override. + * + * @param _staker Address for which to calculated rewards. + * @param _rewards Amount of tokens to be given out as reward. + * + * For example, override as below to mint ERC20 rewards: + * + * ``` + * function _mintRewards(address _staker, uint256 _rewards) internal override { + * + * TokenERC20(rewardTokenAddress).mintTo(_staker, _rewards); + * + * } + * ``` + */ + function _mintRewards(address _staker, uint256 _rewards) internal virtual; + + /** + * @dev Returns whether staking restrictions can be set in given execution context. + * Must override. + * + * + * For example, override as below to restrict access to admin: + * + * ``` + * function _canSetStakeConditions() internal override { + * + * return msg.sender == adminAddress; + * + * } + * ``` + */ + function _canSetStakeConditions() internal view virtual returns (bool); +} diff --git a/contracts/extension/Staking20.sol b/contracts/extension/Staking20.sol new file mode 100644 index 000000000..ab766b7f4 --- /dev/null +++ b/contracts/extension/Staking20.sol @@ -0,0 +1,376 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/// @author thirdweb + +import "../external-deps/openzeppelin/security/ReentrancyGuard.sol"; +import "../external-deps/openzeppelin/utils/math/SafeMath.sol"; +import "../eip/interface/IERC20.sol"; +import { CurrencyTransferLib } from "../lib/CurrencyTransferLib.sol"; + +import "./interface/IStaking20.sol"; + +abstract contract Staking20 is ReentrancyGuard, IStaking20 { + /*/////////////////////////////////////////////////////////////// + State variables / Mappings + //////////////////////////////////////////////////////////////*/ + + /// @dev The address of the native token wrapper contract. + address internal immutable nativeTokenWrapper; + + ///@dev Address of ERC20 contract -- staked tokens belong to this contract. + address public immutable stakingToken; + + /// @dev Decimals of staking token. + uint16 public immutable stakingTokenDecimals; + + /// @dev Decimals of reward token. + uint16 public immutable rewardTokenDecimals; + + ///@dev Next staking condition Id. Tracks number of conditon updates so far. + uint64 private nextConditionId; + + /// @dev Total amount of tokens staked in the contract. + uint256 public stakingTokenBalance; + + /// @dev List of accounts that have staked that token-id. + address[] public stakersArray; + + ///@dev Mapping staker address to Staker struct. See {struct IStaking20.Staker}. + mapping(address => Staker) public stakers; + + ///@dev Mapping from condition Id to staking condition. See {struct IStaking721.StakingCondition} + mapping(uint256 => StakingCondition) private stakingConditions; + + constructor( + address _nativeTokenWrapper, + address _stakingToken, + uint16 _stakingTokenDecimals, + uint16 _rewardTokenDecimals + ) ReentrancyGuard() { + require(_stakingToken != address(0) && _nativeTokenWrapper != address(0), "address 0"); + require(_stakingTokenDecimals != 0 && _rewardTokenDecimals != 0, "decimals 0"); + + nativeTokenWrapper = _nativeTokenWrapper; + stakingToken = _stakingToken; + stakingTokenDecimals = _stakingTokenDecimals; + rewardTokenDecimals = _rewardTokenDecimals; + } + + /*/////////////////////////////////////////////////////////////// + External/Public Functions + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Stake ERC20 Tokens. + * + * @dev See {_stake}. Override that to implement custom logic. + * + * @param _amount Amount to stake. + */ + function stake(uint256 _amount) external payable nonReentrant { + _stake(_amount); + } + + /** + * @notice Withdraw staked ERC20 tokens. + * + * @dev See {_withdraw}. Override that to implement custom logic. + * + * @param _amount Amount to withdraw. + */ + function withdraw(uint256 _amount) external nonReentrant { + _withdraw(_amount); + } + + /** + * @notice Claim accumulated rewards. + * + * @dev See {_claimRewards}. Override that to implement custom logic. + * See {_calculateRewards} for reward-calculation logic. + */ + function claimRewards() external nonReentrant { + _claimRewards(); + } + + /** + * @notice Set time unit. Set as a number of seconds. + * Could be specified as -- x * 1 hours, x * 1 days, etc. + * + * @dev Only admin/authorized-account can call it. + * + * @param _timeUnit New time unit. + */ + function setTimeUnit(uint80 _timeUnit) external virtual { + if (!_canSetStakeConditions()) { + revert("Not authorized"); + } + + StakingCondition memory condition = stakingConditions[nextConditionId - 1]; + require(_timeUnit != condition.timeUnit, "Time-unit unchanged."); + + _setStakingCondition(_timeUnit, condition.rewardRatioNumerator, condition.rewardRatioDenominator); + + emit UpdatedTimeUnit(condition.timeUnit, _timeUnit); + } + + /** + * @notice Set rewards per unit of time. + * Interpreted as (numerator/denominator) rewards per second/per day/etc based on time-unit. + * + * For e.g., ratio of 1/20 would mean 1 reward token for every 20 tokens staked. + * + * @dev Only admin/authorized-account can call it. + * + * @param _numerator Reward ratio numerator. + * @param _denominator Reward ratio denominator. + */ + function setRewardRatio(uint256 _numerator, uint256 _denominator) external virtual { + if (!_canSetStakeConditions()) { + revert("Not authorized"); + } + + StakingCondition memory condition = stakingConditions[nextConditionId - 1]; + require( + _numerator != condition.rewardRatioNumerator || _denominator != condition.rewardRatioDenominator, + "Reward ratio unchanged." + ); + _setStakingCondition(condition.timeUnit, _numerator, _denominator); + + emit UpdatedRewardRatio( + condition.rewardRatioNumerator, + _numerator, + condition.rewardRatioDenominator, + _denominator + ); + } + + /** + * @notice View amount staked and rewards for a user. + * + * @param _staker Address for which to calculated rewards. + * @return _tokensStaked Amount of tokens staked. + * @return _rewards Available reward amount. + */ + function getStakeInfo(address _staker) external view virtual returns (uint256 _tokensStaked, uint256 _rewards) { + _tokensStaked = stakers[_staker].amountStaked; + _rewards = _availableRewards(_staker); + } + + function getTimeUnit() public view returns (uint256 _timeUnit) { + _timeUnit = stakingConditions[nextConditionId - 1].timeUnit; + } + + function getRewardRatio() public view returns (uint256 _numerator, uint256 _denominator) { + _numerator = stakingConditions[nextConditionId - 1].rewardRatioNumerator; + _denominator = stakingConditions[nextConditionId - 1].rewardRatioDenominator; + } + + /*/////////////////////////////////////////////////////////////// + Internal Functions + //////////////////////////////////////////////////////////////*/ + + /// @dev Staking logic. Override to add custom logic. + function _stake(uint256 _amount) internal virtual { + require(_amount != 0, "Staking 0 tokens"); + + address _stakingToken; + if (stakingToken == CurrencyTransferLib.NATIVE_TOKEN) { + _stakingToken = nativeTokenWrapper; + } else { + require(msg.value == 0, "Value not 0"); + _stakingToken = stakingToken; + } + + if (stakers[_stakeMsgSender()].amountStaked > 0) { + _updateUnclaimedRewardsForStaker(_stakeMsgSender()); + } else { + stakersArray.push(_stakeMsgSender()); + stakers[_stakeMsgSender()].timeOfLastUpdate = uint80(block.timestamp); + stakers[_stakeMsgSender()].conditionIdOflastUpdate = nextConditionId - 1; + } + + uint256 balanceBefore = IERC20(_stakingToken).balanceOf(address(this)); + CurrencyTransferLib.transferCurrencyWithWrapper( + stakingToken, + _stakeMsgSender(), + address(this), + _amount, + nativeTokenWrapper + ); + uint256 actualAmount = IERC20(_stakingToken).balanceOf(address(this)) - balanceBefore; + + stakers[_stakeMsgSender()].amountStaked += actualAmount; + stakingTokenBalance += actualAmount; + + emit TokensStaked(_stakeMsgSender(), actualAmount); + } + + /// @dev Withdraw logic. Override to add custom logic. + function _withdraw(uint256 _amount) internal virtual { + uint256 _amountStaked = stakers[_stakeMsgSender()].amountStaked; + require(_amount != 0, "Withdrawing 0 tokens"); + require(_amountStaked >= _amount, "Withdrawing more than staked"); + + _updateUnclaimedRewardsForStaker(_stakeMsgSender()); + + if (_amountStaked == _amount) { + address[] memory _stakersArray = stakersArray; + for (uint256 i = 0; i < _stakersArray.length; ++i) { + if (_stakersArray[i] == _stakeMsgSender()) { + stakersArray[i] = _stakersArray[_stakersArray.length - 1]; + stakersArray.pop(); + break; + } + } + } + stakers[_stakeMsgSender()].amountStaked -= _amount; + stakingTokenBalance -= _amount; + + CurrencyTransferLib.transferCurrencyWithWrapper( + stakingToken, + address(this), + _stakeMsgSender(), + _amount, + nativeTokenWrapper + ); + + emit TokensWithdrawn(_stakeMsgSender(), _amount); + } + + /// @dev Logic for claiming rewards. Override to add custom logic. + function _claimRewards() internal virtual { + uint256 rewards = stakers[_stakeMsgSender()].unclaimedRewards + _calculateRewards(_stakeMsgSender()); + + require(rewards != 0, "No rewards"); + + stakers[_stakeMsgSender()].timeOfLastUpdate = uint80(block.timestamp); + stakers[_stakeMsgSender()].unclaimedRewards = 0; + stakers[_stakeMsgSender()].conditionIdOflastUpdate = nextConditionId - 1; + + _mintRewards(_stakeMsgSender(), rewards); + + emit RewardsClaimed(_stakeMsgSender(), rewards); + } + + /// @dev View available rewards for a user. + function _availableRewards(address _staker) internal view virtual returns (uint256 _rewards) { + if (stakers[_staker].amountStaked == 0) { + _rewards = stakers[_staker].unclaimedRewards; + } else { + _rewards = stakers[_staker].unclaimedRewards + _calculateRewards(_staker); + } + } + + /// @dev Update unclaimed rewards for a users. Called for every state change for a user. + function _updateUnclaimedRewardsForStaker(address _staker) internal virtual { + uint256 rewards = _calculateRewards(_staker); + stakers[_staker].unclaimedRewards += rewards; + stakers[_staker].timeOfLastUpdate = uint80(block.timestamp); + stakers[_staker].conditionIdOflastUpdate = nextConditionId - 1; + } + + /// @dev Set staking conditions. + function _setStakingCondition(uint80 _timeUnit, uint256 _numerator, uint256 _denominator) internal virtual { + require(_denominator != 0, "divide by 0"); + require(_timeUnit != 0, "time-unit can't be 0"); + uint256 conditionId = nextConditionId; + nextConditionId += 1; + + stakingConditions[conditionId] = StakingCondition({ + timeUnit: _timeUnit, + rewardRatioNumerator: _numerator, + rewardRatioDenominator: _denominator, + startTimestamp: uint80(block.timestamp), + endTimestamp: 0 + }); + + if (conditionId > 0) { + stakingConditions[conditionId - 1].endTimestamp = uint80(block.timestamp); + } + } + + /// @dev Calculate rewards for a staker. + function _calculateRewards(address _staker) internal view virtual returns (uint256 _rewards) { + Staker memory staker = stakers[_staker]; + + uint256 _stakerConditionId = staker.conditionIdOflastUpdate; + uint256 _nextConditionId = nextConditionId; + + for (uint256 i = _stakerConditionId; i < _nextConditionId; i += 1) { + StakingCondition memory condition = stakingConditions[i]; + + uint256 startTime = i != _stakerConditionId ? condition.startTimestamp : staker.timeOfLastUpdate; + uint256 endTime = condition.endTimestamp != 0 ? condition.endTimestamp : block.timestamp; + + (bool noOverflowProduct, uint256 rewardsProduct) = SafeMath.tryMul( + (endTime - startTime) * staker.amountStaked, + condition.rewardRatioNumerator + ); + (bool noOverflowSum, uint256 rewardsSum) = SafeMath.tryAdd( + _rewards, + (rewardsProduct / condition.timeUnit) / condition.rewardRatioDenominator + ); + + _rewards = noOverflowProduct && noOverflowSum ? rewardsSum : _rewards; + } + + (, _rewards) = SafeMath.tryMul(_rewards, 10 ** rewardTokenDecimals); + + _rewards /= (10 ** stakingTokenDecimals); + } + + /*//////////////////////////////////////////////////////////////////// + Optional hooks that can be implemented in the derived contract + ///////////////////////////////////////////////////////////////////*/ + + /// @dev Exposes the ability to override the msg sender -- support ERC2771. + function _stakeMsgSender() internal virtual returns (address) { + return msg.sender; + } + + /*/////////////////////////////////////////////////////////////// + Virtual functions to be implemented in derived contract + //////////////////////////////////////////////////////////////*/ + + /** + * @notice View total rewards available in the staking contract. + * + */ + function getRewardTokenBalance() external view virtual returns (uint256 _rewardsAvailableInContract); + + /** + * @dev Mint/Transfer ERC20 rewards to the staker. Must override. + * + * @param _staker Address for which to calculated rewards. + * @param _rewards Amount of tokens to be given out as reward. + * + * For example, override as below to mint ERC20 rewards: + * + * ``` + * function _mintRewards(address _staker, uint256 _rewards) internal override { + * + * TokenERC20(rewardTokenAddress).mintTo(_staker, _rewards); + * + * } + * ``` + */ + function _mintRewards(address _staker, uint256 _rewards) internal virtual; + + /** + * @dev Returns whether staking restrictions can be set in given execution context. + * Must override. + * + * + * For example, override as below to restrict access to admin: + * + * ``` + * function _canSetStakeConditions() internal override { + * + * return msg.sender == adminAddress; + * + * } + * ``` + */ + function _canSetStakeConditions() internal view virtual returns (bool); +} diff --git a/contracts/extension/Staking20Upgradeable.sol b/contracts/extension/Staking20Upgradeable.sol new file mode 100644 index 000000000..c83c16b1a --- /dev/null +++ b/contracts/extension/Staking20Upgradeable.sol @@ -0,0 +1,383 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/// @author thirdweb + +import "@openzeppelin/contracts-upgradeable/security/ReentrancyGuardUpgradeable.sol"; +import "../external-deps/openzeppelin/utils/math/SafeMath.sol"; +import "../eip/interface/IERC20.sol"; +import { CurrencyTransferLib } from "../lib/CurrencyTransferLib.sol"; + +import "./interface/IStaking20.sol"; + +abstract contract Staking20Upgradeable is ReentrancyGuardUpgradeable, IStaking20 { + /*/////////////////////////////////////////////////////////////// + State variables / Mappings + //////////////////////////////////////////////////////////////*/ + + /// @dev The address of the native token wrapper contract. + address internal immutable nativeTokenWrapper; + + ///@dev Address of ERC20 contract -- staked tokens belong to this contract. + address public stakingToken; + + /// @dev Decimals of staking token. + uint16 public stakingTokenDecimals; + + /// @dev Decimals of reward token. + uint16 public rewardTokenDecimals; + + ///@dev Next staking condition Id. Tracks number of conditon updates so far. + uint64 private nextConditionId; + + /// @dev Total amount of tokens staked in the contract. + uint256 public stakingTokenBalance; + + /// @dev List of accounts that have staked that token-id. + address[] public stakersArray; + + ///@dev Mapping staker address to Staker struct. See {struct IStaking20.Staker}. + mapping(address => Staker) public stakers; + + ///@dev Mapping from condition Id to staking condition. See {struct IStaking721.StakingCondition} + mapping(uint256 => StakingCondition) private stakingConditions; + + constructor(address _nativeTokenWrapper) { + require(_nativeTokenWrapper != address(0), "address 0"); + + nativeTokenWrapper = _nativeTokenWrapper; + } + + function __Staking20_init( + address _stakingToken, + uint16 _stakingTokenDecimals, + uint16 _rewardTokenDecimals + ) internal onlyInitializing { + __ReentrancyGuard_init(); + + require(address(_stakingToken) != address(0), "token address 0"); + require(_stakingTokenDecimals != 0 && _rewardTokenDecimals != 0, "decimals 0"); + + stakingToken = _stakingToken; + stakingTokenDecimals = _stakingTokenDecimals; + rewardTokenDecimals = _rewardTokenDecimals; + } + + /*/////////////////////////////////////////////////////////////// + External/Public Functions + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Stake ERC20 Tokens. + * + * @dev See {_stake}. Override that to implement custom logic. + * + * @param _amount Amount to stake. + */ + function stake(uint256 _amount) external payable nonReentrant { + _stake(_amount); + } + + /** + * @notice Withdraw staked ERC20 tokens. + * + * @dev See {_withdraw}. Override that to implement custom logic. + * + * @param _amount Amount to withdraw. + */ + function withdraw(uint256 _amount) external nonReentrant { + _withdraw(_amount); + } + + /** + * @notice Claim accumulated rewards. + * + * @dev See {_claimRewards}. Override that to implement custom logic. + * See {_calculateRewards} for reward-calculation logic. + */ + function claimRewards() external nonReentrant { + _claimRewards(); + } + + /** + * @notice Set time unit. Set as a number of seconds. + * Could be specified as -- x * 1 hours, x * 1 days, etc. + * + * @dev Only admin/authorized-account can call it. + * + * @param _timeUnit New time unit. + */ + function setTimeUnit(uint80 _timeUnit) external virtual { + if (!_canSetStakeConditions()) { + revert("Not authorized"); + } + + StakingCondition memory condition = stakingConditions[nextConditionId - 1]; + require(_timeUnit != condition.timeUnit, "Time-unit unchanged."); + + _setStakingCondition(_timeUnit, condition.rewardRatioNumerator, condition.rewardRatioDenominator); + + emit UpdatedTimeUnit(condition.timeUnit, _timeUnit); + } + + /** + * @notice Set rewards per unit of time. + * Interpreted as (numerator/denominator) rewards per second/per day/etc based on time-unit. + * + * For e.g., ratio of 1/20 would mean 1 reward token for every 20 tokens staked. + * + * @dev Only admin/authorized-account can call it. + * + * @param _numerator Reward ratio numerator. + * @param _denominator Reward ratio denominator. + */ + function setRewardRatio(uint256 _numerator, uint256 _denominator) external virtual { + if (!_canSetStakeConditions()) { + revert("Not authorized"); + } + + StakingCondition memory condition = stakingConditions[nextConditionId - 1]; + require( + _numerator != condition.rewardRatioNumerator || _denominator != condition.rewardRatioDenominator, + "Reward ratio unchanged." + ); + _setStakingCondition(condition.timeUnit, _numerator, _denominator); + + emit UpdatedRewardRatio( + condition.rewardRatioNumerator, + _numerator, + condition.rewardRatioDenominator, + _denominator + ); + } + + /** + * @notice View amount staked and rewards for a user. + * + * @param _staker Address for which to calculated rewards. + * @return _tokensStaked Amount of tokens staked. + * @return _rewards Available reward amount. + */ + function getStakeInfo(address _staker) external view virtual returns (uint256 _tokensStaked, uint256 _rewards) { + _tokensStaked = stakers[_staker].amountStaked; + _rewards = _availableRewards(_staker); + } + + function getTimeUnit() public view returns (uint80 _timeUnit) { + _timeUnit = stakingConditions[nextConditionId - 1].timeUnit; + } + + function getRewardRatio() public view returns (uint256 _numerator, uint256 _denominator) { + _numerator = stakingConditions[nextConditionId - 1].rewardRatioNumerator; + _denominator = stakingConditions[nextConditionId - 1].rewardRatioDenominator; + } + + /*/////////////////////////////////////////////////////////////// + Internal Functions + //////////////////////////////////////////////////////////////*/ + + /// @dev Staking logic. Override to add custom logic. + function _stake(uint256 _amount) internal virtual { + require(_amount != 0, "Staking 0 tokens"); + + address _stakingToken; + if (stakingToken == CurrencyTransferLib.NATIVE_TOKEN) { + _stakingToken = nativeTokenWrapper; + } else { + require(msg.value == 0, "Value not 0"); + _stakingToken = stakingToken; + } + + if (stakers[_stakeMsgSender()].amountStaked > 0) { + _updateUnclaimedRewardsForStaker(_stakeMsgSender()); + } else { + stakersArray.push(_stakeMsgSender()); + stakers[_stakeMsgSender()].timeOfLastUpdate = uint80(block.timestamp); + stakers[_stakeMsgSender()].conditionIdOflastUpdate = nextConditionId - 1; + } + + uint256 balanceBefore = IERC20(_stakingToken).balanceOf(address(this)); + CurrencyTransferLib.transferCurrencyWithWrapper( + stakingToken, + _stakeMsgSender(), + address(this), + _amount, + nativeTokenWrapper + ); + uint256 actualAmount = IERC20(_stakingToken).balanceOf(address(this)) - balanceBefore; + + stakers[_stakeMsgSender()].amountStaked += actualAmount; + stakingTokenBalance += actualAmount; + + emit TokensStaked(_stakeMsgSender(), actualAmount); + } + + /// @dev Withdraw logic. Override to add custom logic. + function _withdraw(uint256 _amount) internal virtual { + uint256 _amountStaked = stakers[_stakeMsgSender()].amountStaked; + require(_amount != 0, "Withdrawing 0 tokens"); + require(_amountStaked >= _amount, "Withdrawing more than staked"); + + _updateUnclaimedRewardsForStaker(_stakeMsgSender()); + + if (_amountStaked == _amount) { + address[] memory _stakersArray = stakersArray; + for (uint256 i = 0; i < _stakersArray.length; ++i) { + if (_stakersArray[i] == _stakeMsgSender()) { + stakersArray[i] = _stakersArray[_stakersArray.length - 1]; + stakersArray.pop(); + break; + } + } + } + + stakers[_stakeMsgSender()].amountStaked -= _amount; + stakingTokenBalance -= _amount; + + CurrencyTransferLib.transferCurrencyWithWrapper( + stakingToken, + address(this), + _stakeMsgSender(), + _amount, + nativeTokenWrapper + ); + + emit TokensWithdrawn(_stakeMsgSender(), _amount); + } + + /// @dev Logic for claiming rewards. Override to add custom logic. + function _claimRewards() internal virtual { + uint256 rewards = stakers[_stakeMsgSender()].unclaimedRewards + _calculateRewards(_stakeMsgSender()); + + require(rewards != 0, "No rewards"); + + stakers[_stakeMsgSender()].timeOfLastUpdate = uint80(block.timestamp); + stakers[_stakeMsgSender()].unclaimedRewards = 0; + stakers[_stakeMsgSender()].conditionIdOflastUpdate = nextConditionId - 1; + + _mintRewards(_stakeMsgSender(), rewards); + + emit RewardsClaimed(_stakeMsgSender(), rewards); + } + + /// @dev View available rewards for a user. + function _availableRewards(address _staker) internal view virtual returns (uint256 _rewards) { + if (stakers[_staker].amountStaked == 0) { + _rewards = stakers[_staker].unclaimedRewards; + } else { + _rewards = stakers[_staker].unclaimedRewards + _calculateRewards(_staker); + } + } + + /// @dev Update unclaimed rewards for a users. Called for every state change for a user. + function _updateUnclaimedRewardsForStaker(address _staker) internal virtual { + uint256 rewards = _calculateRewards(_staker); + stakers[_staker].unclaimedRewards += rewards; + stakers[_staker].timeOfLastUpdate = uint80(block.timestamp); + stakers[_staker].conditionIdOflastUpdate = nextConditionId - 1; + } + + /// @dev Set staking conditions. + function _setStakingCondition(uint80 _timeUnit, uint256 _numerator, uint256 _denominator) internal virtual { + require(_denominator != 0, "divide by 0"); + require(_timeUnit != 0, "time-unit can't be 0"); + uint256 conditionId = nextConditionId; + nextConditionId += 1; + + stakingConditions[conditionId] = StakingCondition({ + timeUnit: _timeUnit, + rewardRatioNumerator: _numerator, + rewardRatioDenominator: _denominator, + startTimestamp: uint80(block.timestamp), + endTimestamp: 0 + }); + + if (conditionId > 0) { + stakingConditions[conditionId - 1].endTimestamp = uint80(block.timestamp); + } + } + + /// @dev Calculate rewards for a staker. + function _calculateRewards(address _staker) internal view virtual returns (uint256 _rewards) { + Staker memory staker = stakers[_staker]; + + uint256 _stakerConditionId = staker.conditionIdOflastUpdate; + uint256 _nextConditionId = nextConditionId; + + for (uint256 i = _stakerConditionId; i < _nextConditionId; i += 1) { + StakingCondition memory condition = stakingConditions[i]; + + uint256 startTime = i != _stakerConditionId ? condition.startTimestamp : staker.timeOfLastUpdate; + uint256 endTime = condition.endTimestamp != 0 ? condition.endTimestamp : block.timestamp; + + (bool noOverflowProduct, uint256 rewardsProduct) = SafeMath.tryMul( + (endTime - startTime) * staker.amountStaked, + condition.rewardRatioNumerator + ); + (bool noOverflowSum, uint256 rewardsSum) = SafeMath.tryAdd( + _rewards, + (rewardsProduct / condition.timeUnit) / condition.rewardRatioDenominator + ); + + _rewards = noOverflowProduct && noOverflowSum ? rewardsSum : _rewards; + } + + (, _rewards) = SafeMath.tryMul(_rewards, 10 ** rewardTokenDecimals); + + _rewards /= (10 ** stakingTokenDecimals); + } + + /*//////////////////////////////////////////////////////////////////// + Optional hooks that can be implemented in the derived contract + ///////////////////////////////////////////////////////////////////*/ + + /// @dev Exposes the ability to override the msg sender -- support ERC2771. + function _stakeMsgSender() internal virtual returns (address) { + return msg.sender; + } + + /*/////////////////////////////////////////////////////////////// + Virtual functions to be implemented in derived contract + //////////////////////////////////////////////////////////////*/ + + /** + * @notice View total rewards available in the staking contract. + * + */ + function getRewardTokenBalance() external view virtual returns (uint256 _rewardsAvailableInContract); + + /** + * @dev Mint/Transfer ERC20 rewards to the staker. Must override. + * + * @param _staker Address for which to calculated rewards. + * @param _rewards Amount of tokens to be given out as reward. + * + * For example, override as below to mint ERC20 rewards: + * + * ``` + * function _mintRewards(address _staker, uint256 _rewards) internal override { + * + * TokenERC20(rewardTokenAddress).mintTo(_staker, _rewards); + * + * } + * ``` + */ + function _mintRewards(address _staker, uint256 _rewards) internal virtual; + + /** + * @dev Returns whether staking restrictions can be set in given execution context. + * Must override. + * + * + * For example, override as below to restrict access to admin: + * + * ``` + * function _canSetStakeConditions() internal override { + * + * return msg.sender == adminAddress; + * + * } + * ``` + */ + function _canSetStakeConditions() internal view virtual returns (bool); +} diff --git a/contracts/extension/Staking721.sol b/contracts/extension/Staking721.sol new file mode 100644 index 000000000..9d18144c5 --- /dev/null +++ b/contracts/extension/Staking721.sol @@ -0,0 +1,362 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/// @author thirdweb + +import "../external-deps/openzeppelin/security/ReentrancyGuard.sol"; +import "../external-deps/openzeppelin/utils/math/SafeMath.sol"; +import "../eip/interface/IERC721.sol"; + +import "./interface/IStaking721.sol"; + +abstract contract Staking721 is ReentrancyGuard, IStaking721 { + /*/////////////////////////////////////////////////////////////// + State variables / Mappings + //////////////////////////////////////////////////////////////*/ + + ///@dev Address of ERC721 NFT contract -- staked tokens belong to this contract. + address public immutable stakingToken; + + /// @dev Flag to check direct transfers of staking tokens. + uint8 internal isStaking = 1; + + ///@dev Next staking condition Id. Tracks number of conditon updates so far. + uint64 private nextConditionId; + + ///@dev List of token-ids ever staked. + uint256[] public indexedTokens; + + /// @dev List of accounts that have staked their NFTs. + address[] public stakersArray; + + ///@dev Mapping from token-id to whether it is indexed or not. + mapping(uint256 => bool) public isIndexed; + + ///@dev Mapping from staker address to Staker struct. See {struct IStaking721.Staker}. + mapping(address => Staker) public stakers; + + /// @dev Mapping from staked token-id to staker address. + mapping(uint256 => address) public stakerAddress; + + ///@dev Mapping from condition Id to staking condition. See {struct IStaking721.StakingCondition} + mapping(uint256 => StakingCondition) private stakingConditions; + + constructor(address _stakingToken) ReentrancyGuard() { + require(address(_stakingToken) != address(0), "collection address 0"); + stakingToken = _stakingToken; + } + + /*/////////////////////////////////////////////////////////////// + External/Public Functions + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Stake ERC721 Tokens. + * + * @dev See {_stake}. Override that to implement custom logic. + * + * @param _tokenIds List of tokens to stake. + */ + function stake(uint256[] calldata _tokenIds) external nonReentrant { + _stake(_tokenIds); + } + + /** + * @notice Withdraw staked tokens. + * + * @dev See {_withdraw}. Override that to implement custom logic. + * + * @param _tokenIds List of tokens to withdraw. + */ + function withdraw(uint256[] calldata _tokenIds) external nonReentrant { + _withdraw(_tokenIds); + } + + /** + * @notice Claim accumulated rewards. + * + * @dev See {_claimRewards}. Override that to implement custom logic. + * See {_calculateRewards} for reward-calculation logic. + */ + function claimRewards() external nonReentrant { + _claimRewards(); + } + + /** + * @notice Set time unit. Set as a number of seconds. + * Could be specified as -- x * 1 hours, x * 1 days, etc. + * + * @dev Only admin/authorized-account can call it. + * + * + * @param _timeUnit New time unit. + */ + function setTimeUnit(uint256 _timeUnit) external virtual { + if (!_canSetStakeConditions()) { + revert("Not authorized"); + } + + StakingCondition memory condition = stakingConditions[nextConditionId - 1]; + require(_timeUnit != condition.timeUnit, "Time-unit unchanged."); + + _setStakingCondition(_timeUnit, condition.rewardsPerUnitTime); + + emit UpdatedTimeUnit(condition.timeUnit, _timeUnit); + } + + /** + * @notice Set rewards per unit of time. + * Interpreted as x rewards per second/per day/etc based on time-unit. + * + * @dev Only admin/authorized-account can call it. + * + * + * @param _rewardsPerUnitTime New rewards per unit time. + */ + function setRewardsPerUnitTime(uint256 _rewardsPerUnitTime) external virtual { + if (!_canSetStakeConditions()) { + revert("Not authorized"); + } + + StakingCondition memory condition = stakingConditions[nextConditionId - 1]; + require(_rewardsPerUnitTime != condition.rewardsPerUnitTime, "Reward unchanged."); + + _setStakingCondition(condition.timeUnit, _rewardsPerUnitTime); + + emit UpdatedRewardsPerUnitTime(condition.rewardsPerUnitTime, _rewardsPerUnitTime); + } + + /** + * @notice View amount staked and total rewards for a user. + * + * @param _staker Address for which to calculated rewards. + * @return _tokensStaked List of token-ids staked by staker. + * @return _rewards Available reward amount. + */ + function getStakeInfo( + address _staker + ) external view virtual returns (uint256[] memory _tokensStaked, uint256 _rewards) { + uint256[] memory _indexedTokens = indexedTokens; + bool[] memory _isStakerToken = new bool[](_indexedTokens.length); + uint256 indexedTokenCount = _indexedTokens.length; + uint256 stakerTokenCount = 0; + + for (uint256 i = 0; i < indexedTokenCount; i++) { + _isStakerToken[i] = stakerAddress[_indexedTokens[i]] == _staker; + if (_isStakerToken[i]) stakerTokenCount += 1; + } + + _tokensStaked = new uint256[](stakerTokenCount); + uint256 count = 0; + for (uint256 i = 0; i < indexedTokenCount; i++) { + if (_isStakerToken[i]) { + _tokensStaked[count] = _indexedTokens[i]; + count += 1; + } + } + + _rewards = _availableRewards(_staker); + } + + function getTimeUnit() public view returns (uint256 _timeUnit) { + _timeUnit = stakingConditions[nextConditionId - 1].timeUnit; + } + + function getRewardsPerUnitTime() public view returns (uint256 _rewardsPerUnitTime) { + _rewardsPerUnitTime = stakingConditions[nextConditionId - 1].rewardsPerUnitTime; + } + + /*/////////////////////////////////////////////////////////////// + Internal Functions + //////////////////////////////////////////////////////////////*/ + + /// @dev Staking logic. Override to add custom logic. + function _stake(uint256[] calldata _tokenIds) internal virtual { + uint64 len = uint64(_tokenIds.length); + require(len != 0, "Staking 0 tokens"); + + address _stakingToken = stakingToken; + + if (stakers[_stakeMsgSender()].amountStaked > 0) { + _updateUnclaimedRewardsForStaker(_stakeMsgSender()); + } else { + stakersArray.push(_stakeMsgSender()); + stakers[_stakeMsgSender()].timeOfLastUpdate = uint128(block.timestamp); + stakers[_stakeMsgSender()].conditionIdOflastUpdate = nextConditionId - 1; + } + for (uint256 i = 0; i < len; ++i) { + isStaking = 2; + IERC721(_stakingToken).safeTransferFrom(_stakeMsgSender(), address(this), _tokenIds[i]); + isStaking = 1; + + stakerAddress[_tokenIds[i]] = _stakeMsgSender(); + + if (!isIndexed[_tokenIds[i]]) { + isIndexed[_tokenIds[i]] = true; + indexedTokens.push(_tokenIds[i]); + } + } + stakers[_stakeMsgSender()].amountStaked += len; + + emit TokensStaked(_stakeMsgSender(), _tokenIds); + } + + /// @dev Withdraw logic. Override to add custom logic. + function _withdraw(uint256[] calldata _tokenIds) internal virtual { + uint256 _amountStaked = stakers[_stakeMsgSender()].amountStaked; + uint64 len = uint64(_tokenIds.length); + require(len != 0, "Withdrawing 0 tokens"); + require(_amountStaked >= len, "Withdrawing more than staked"); + + address _stakingToken = stakingToken; + + _updateUnclaimedRewardsForStaker(_stakeMsgSender()); + + if (_amountStaked == len) { + address[] memory _stakersArray = stakersArray; + for (uint256 i = 0; i < _stakersArray.length; ++i) { + if (_stakersArray[i] == _stakeMsgSender()) { + stakersArray[i] = _stakersArray[_stakersArray.length - 1]; + stakersArray.pop(); + break; + } + } + } + stakers[_stakeMsgSender()].amountStaked -= len; + + for (uint256 i = 0; i < len; ++i) { + require(stakerAddress[_tokenIds[i]] == _stakeMsgSender(), "Not staker"); + stakerAddress[_tokenIds[i]] = address(0); + IERC721(_stakingToken).safeTransferFrom(address(this), _stakeMsgSender(), _tokenIds[i]); + } + + emit TokensWithdrawn(_stakeMsgSender(), _tokenIds); + } + + /// @dev Logic for claiming rewards. Override to add custom logic. + function _claimRewards() internal virtual { + uint256 rewards = stakers[_stakeMsgSender()].unclaimedRewards + _calculateRewards(_stakeMsgSender()); + + require(rewards != 0, "No rewards"); + + stakers[_stakeMsgSender()].timeOfLastUpdate = uint128(block.timestamp); + stakers[_stakeMsgSender()].unclaimedRewards = 0; + stakers[_stakeMsgSender()].conditionIdOflastUpdate = nextConditionId - 1; + + _mintRewards(_stakeMsgSender(), rewards); + + emit RewardsClaimed(_stakeMsgSender(), rewards); + } + + /// @dev View available rewards for a user. + function _availableRewards(address _user) internal view virtual returns (uint256 _rewards) { + if (stakers[_user].amountStaked == 0) { + _rewards = stakers[_user].unclaimedRewards; + } else { + _rewards = stakers[_user].unclaimedRewards + _calculateRewards(_user); + } + } + + /// @dev Update unclaimed rewards for a users. Called for every state change for a user. + function _updateUnclaimedRewardsForStaker(address _staker) internal virtual { + uint256 rewards = _calculateRewards(_staker); + stakers[_staker].unclaimedRewards += rewards; + stakers[_staker].timeOfLastUpdate = uint128(block.timestamp); + stakers[_staker].conditionIdOflastUpdate = nextConditionId - 1; + } + + /// @dev Set staking conditions. + function _setStakingCondition(uint256 _timeUnit, uint256 _rewardsPerUnitTime) internal virtual { + require(_timeUnit != 0, "time-unit can't be 0"); + uint256 conditionId = nextConditionId; + nextConditionId += 1; + + stakingConditions[conditionId] = StakingCondition({ + timeUnit: _timeUnit, + rewardsPerUnitTime: _rewardsPerUnitTime, + startTimestamp: block.timestamp, + endTimestamp: 0 + }); + + if (conditionId > 0) { + stakingConditions[conditionId - 1].endTimestamp = block.timestamp; + } + } + + /// @dev Calculate rewards for a staker. + function _calculateRewards(address _staker) internal view virtual returns (uint256 _rewards) { + Staker memory staker = stakers[_staker]; + + uint256 _stakerConditionId = staker.conditionIdOflastUpdate; + uint256 _nextConditionId = nextConditionId; + + for (uint256 i = _stakerConditionId; i < _nextConditionId; i += 1) { + StakingCondition memory condition = stakingConditions[i]; + + uint256 startTime = i != _stakerConditionId ? condition.startTimestamp : staker.timeOfLastUpdate; + uint256 endTime = condition.endTimestamp != 0 ? condition.endTimestamp : block.timestamp; + + (bool noOverflowProduct, uint256 rewardsProduct) = SafeMath.tryMul( + (endTime - startTime) * staker.amountStaked, + condition.rewardsPerUnitTime + ); + (bool noOverflowSum, uint256 rewardsSum) = SafeMath.tryAdd(_rewards, rewardsProduct / condition.timeUnit); + + _rewards = noOverflowProduct && noOverflowSum ? rewardsSum : _rewards; + } + } + + /*//////////////////////////////////////////////////////////////////// + Optional hooks that can be implemented in the derived contract + ///////////////////////////////////////////////////////////////////*/ + + /// @dev Exposes the ability to override the msg sender -- support ERC2771. + function _stakeMsgSender() internal virtual returns (address) { + return msg.sender; + } + + /*/////////////////////////////////////////////////////////////// + Virtual functions to be implemented in derived contract + //////////////////////////////////////////////////////////////*/ + + /** + * @notice View total rewards available in the staking contract. + * + */ + function getRewardTokenBalance() external view virtual returns (uint256 _rewardsAvailableInContract); + + /** + * @dev Mint/Transfer ERC20 rewards to the staker. Must override. + * + * @param _staker Address for which to calculated rewards. + * @param _rewards Amount of tokens to be given out as reward. + * + * For example, override as below to mint ERC20 rewards: + * + * ``` + * function _mintRewards(address _staker, uint256 _rewards) internal override { + * + * TokenERC20(rewardTokenAddress).mintTo(_staker, _rewards); + * + * } + * ``` + */ + function _mintRewards(address _staker, uint256 _rewards) internal virtual; + + /** + * @dev Returns whether staking restrictions can be set in given execution context. + * Must override. + * + * + * For example, override as below to restrict access to admin: + * + * ``` + * function _canSetStakeConditions() internal override { + * + * return msg.sender == adminAddress; + * + * } + * ``` + */ + function _canSetStakeConditions() internal view virtual returns (bool); +} diff --git a/contracts/extension/Staking721Upgradeable.sol b/contracts/extension/Staking721Upgradeable.sol new file mode 100644 index 000000000..a5719e641 --- /dev/null +++ b/contracts/extension/Staking721Upgradeable.sol @@ -0,0 +1,364 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/// @author thirdweb + +import "@openzeppelin/contracts-upgradeable/security/ReentrancyGuardUpgradeable.sol"; +import "../external-deps/openzeppelin/utils/math/SafeMath.sol"; +import "../eip/interface/IERC721.sol"; + +import "./interface/IStaking721.sol"; + +abstract contract Staking721Upgradeable is ReentrancyGuardUpgradeable, IStaking721 { + /*/////////////////////////////////////////////////////////////// + State variables / Mappings + //////////////////////////////////////////////////////////////*/ + + ///@dev Address of ERC721 NFT contract -- staked tokens belong to this contract. + address public stakingToken; + + /// @dev Flag to check direct transfers of staking tokens. + uint8 internal isStaking = 1; + + ///@dev Next staking condition Id. Tracks number of conditon updates so far. + uint64 private nextConditionId; + + ///@dev List of token-ids ever staked. + uint256[] public indexedTokens; + + /// @dev List of accounts that have staked their NFTs. + address[] public stakersArray; + + ///@dev Mapping from token-id to whether it is indexed or not. + mapping(uint256 => bool) public isIndexed; + + ///@dev Mapping from staker address to Staker struct. See {struct IStaking721.Staker}. + mapping(address => Staker) public stakers; + + /// @dev Mapping from staked token-id to staker address. + mapping(uint256 => address) public stakerAddress; + + ///@dev Mapping from condition Id to staking condition. See {struct IStaking721.StakingCondition} + mapping(uint256 => StakingCondition) private stakingConditions; + + function __Staking721_init(address _stakingToken) internal onlyInitializing { + __ReentrancyGuard_init(); + + require(address(_stakingToken) != address(0), "collection address 0"); + stakingToken = _stakingToken; + } + + /*/////////////////////////////////////////////////////////////// + External/Public Functions + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Stake ERC721 Tokens. + * + * @dev See {_stake}. Override that to implement custom logic. + * + * @param _tokenIds List of tokens to stake. + */ + function stake(uint256[] calldata _tokenIds) external nonReentrant { + _stake(_tokenIds); + } + + /** + * @notice Withdraw staked tokens. + * + * @dev See {_withdraw}. Override that to implement custom logic. + * + * @param _tokenIds List of tokens to withdraw. + */ + function withdraw(uint256[] calldata _tokenIds) external nonReentrant { + _withdraw(_tokenIds); + } + + /** + * @notice Claim accumulated rewards. + * + * @dev See {_claimRewards}. Override that to implement custom logic. + * See {_calculateRewards} for reward-calculation logic. + */ + function claimRewards() external nonReentrant { + _claimRewards(); + } + + /** + * @notice Set time unit. Set as a number of seconds. + * Could be specified as -- x * 1 hours, x * 1 days, etc. + * + * @dev Only admin/authorized-account can call it. + * + * + * @param _timeUnit New time unit. + */ + function setTimeUnit(uint256 _timeUnit) external virtual { + if (!_canSetStakeConditions()) { + revert("Not authorized"); + } + + StakingCondition memory condition = stakingConditions[nextConditionId - 1]; + require(_timeUnit != condition.timeUnit, "Time-unit unchanged."); + + _setStakingCondition(_timeUnit, condition.rewardsPerUnitTime); + + emit UpdatedTimeUnit(condition.timeUnit, _timeUnit); + } + + /** + * @notice Set rewards per unit of time. + * Interpreted as x rewards per second/per day/etc based on time-unit. + * + * @dev Only admin/authorized-account can call it. + * + * + * @param _rewardsPerUnitTime New rewards per unit time. + */ + function setRewardsPerUnitTime(uint256 _rewardsPerUnitTime) external virtual { + if (!_canSetStakeConditions()) { + revert("Not authorized"); + } + + StakingCondition memory condition = stakingConditions[nextConditionId - 1]; + require(_rewardsPerUnitTime != condition.rewardsPerUnitTime, "Reward unchanged."); + + _setStakingCondition(condition.timeUnit, _rewardsPerUnitTime); + + emit UpdatedRewardsPerUnitTime(condition.rewardsPerUnitTime, _rewardsPerUnitTime); + } + + /** + * @notice View amount staked and total rewards for a user. + * + * @param _staker Address for which to calculated rewards. + * @return _tokensStaked List of token-ids staked by staker. + * @return _rewards Available reward amount. + */ + function getStakeInfo( + address _staker + ) external view virtual returns (uint256[] memory _tokensStaked, uint256 _rewards) { + uint256[] memory _indexedTokens = indexedTokens; + bool[] memory _isStakerToken = new bool[](_indexedTokens.length); + uint256 indexedTokenCount = _indexedTokens.length; + uint256 stakerTokenCount = 0; + + for (uint256 i = 0; i < indexedTokenCount; i++) { + _isStakerToken[i] = stakerAddress[_indexedTokens[i]] == _staker; + if (_isStakerToken[i]) stakerTokenCount += 1; + } + + _tokensStaked = new uint256[](stakerTokenCount); + uint256 count = 0; + for (uint256 i = 0; i < indexedTokenCount; i++) { + if (_isStakerToken[i]) { + _tokensStaked[count] = _indexedTokens[i]; + count += 1; + } + } + + _rewards = _availableRewards(_staker); + } + + function getTimeUnit() public view returns (uint256 _timeUnit) { + _timeUnit = stakingConditions[nextConditionId - 1].timeUnit; + } + + function getRewardsPerUnitTime() public view returns (uint256 _rewardsPerUnitTime) { + _rewardsPerUnitTime = stakingConditions[nextConditionId - 1].rewardsPerUnitTime; + } + + /*/////////////////////////////////////////////////////////////// + Internal Functions + //////////////////////////////////////////////////////////////*/ + + /// @dev Staking logic. Override to add custom logic. + function _stake(uint256[] calldata _tokenIds) internal virtual { + uint64 len = uint64(_tokenIds.length); + require(len != 0, "Staking 0 tokens"); + + address _stakingToken = stakingToken; + + if (stakers[_stakeMsgSender()].amountStaked > 0) { + _updateUnclaimedRewardsForStaker(_stakeMsgSender()); + } else { + stakersArray.push(_stakeMsgSender()); + stakers[_stakeMsgSender()].timeOfLastUpdate = uint128(block.timestamp); + stakers[_stakeMsgSender()].conditionIdOflastUpdate = nextConditionId - 1; + } + for (uint256 i = 0; i < len; ++i) { + isStaking = 2; + IERC721(_stakingToken).safeTransferFrom(_stakeMsgSender(), address(this), _tokenIds[i]); + isStaking = 1; + + stakerAddress[_tokenIds[i]] = _stakeMsgSender(); + + if (!isIndexed[_tokenIds[i]]) { + isIndexed[_tokenIds[i]] = true; + indexedTokens.push(_tokenIds[i]); + } + } + stakers[_stakeMsgSender()].amountStaked += len; + + emit TokensStaked(_stakeMsgSender(), _tokenIds); + } + + /// @dev Withdraw logic. Override to add custom logic. + function _withdraw(uint256[] calldata _tokenIds) internal virtual { + uint256 _amountStaked = stakers[_stakeMsgSender()].amountStaked; + uint64 len = uint64(_tokenIds.length); + require(len != 0, "Withdrawing 0 tokens"); + require(_amountStaked >= len, "Withdrawing more than staked"); + + address _stakingToken = stakingToken; + + _updateUnclaimedRewardsForStaker(_stakeMsgSender()); + + if (_amountStaked == len) { + address[] memory _stakersArray = stakersArray; + for (uint256 i = 0; i < _stakersArray.length; ++i) { + if (_stakersArray[i] == _stakeMsgSender()) { + stakersArray[i] = _stakersArray[_stakersArray.length - 1]; + stakersArray.pop(); + break; + } + } + } + stakers[_stakeMsgSender()].amountStaked -= len; + + for (uint256 i = 0; i < len; ++i) { + require(stakerAddress[_tokenIds[i]] == _stakeMsgSender(), "Not staker"); + stakerAddress[_tokenIds[i]] = address(0); + IERC721(_stakingToken).safeTransferFrom(address(this), _stakeMsgSender(), _tokenIds[i]); + } + + emit TokensWithdrawn(_stakeMsgSender(), _tokenIds); + } + + /// @dev Logic for claiming rewards. Override to add custom logic. + function _claimRewards() internal virtual { + uint256 rewards = stakers[_stakeMsgSender()].unclaimedRewards + _calculateRewards(_stakeMsgSender()); + + require(rewards != 0, "No rewards"); + + stakers[_stakeMsgSender()].timeOfLastUpdate = uint128(block.timestamp); + stakers[_stakeMsgSender()].unclaimedRewards = 0; + stakers[_stakeMsgSender()].conditionIdOflastUpdate = nextConditionId - 1; + + _mintRewards(_stakeMsgSender(), rewards); + + emit RewardsClaimed(_stakeMsgSender(), rewards); + } + + /// @dev View available rewards for a user. + function _availableRewards(address _user) internal view virtual returns (uint256 _rewards) { + if (stakers[_user].amountStaked == 0) { + _rewards = stakers[_user].unclaimedRewards; + } else { + _rewards = stakers[_user].unclaimedRewards + _calculateRewards(_user); + } + } + + /// @dev Update unclaimed rewards for a users. Called for every state change for a user. + function _updateUnclaimedRewardsForStaker(address _staker) internal virtual { + uint256 rewards = _calculateRewards(_staker); + stakers[_staker].unclaimedRewards += rewards; + stakers[_staker].timeOfLastUpdate = uint128(block.timestamp); + stakers[_staker].conditionIdOflastUpdate = nextConditionId - 1; + } + + /// @dev Set staking conditions. + function _setStakingCondition(uint256 _timeUnit, uint256 _rewardsPerUnitTime) internal virtual { + require(_timeUnit != 0, "time-unit can't be 0"); + uint256 conditionId = nextConditionId; + nextConditionId += 1; + + stakingConditions[conditionId] = StakingCondition({ + timeUnit: _timeUnit, + rewardsPerUnitTime: _rewardsPerUnitTime, + startTimestamp: block.timestamp, + endTimestamp: 0 + }); + + if (conditionId > 0) { + stakingConditions[conditionId - 1].endTimestamp = block.timestamp; + } + } + + /// @dev Calculate rewards for a staker. + function _calculateRewards(address _staker) internal view virtual returns (uint256 _rewards) { + Staker memory staker = stakers[_staker]; + + uint256 _stakerConditionId = staker.conditionIdOflastUpdate; + uint256 _nextConditionId = nextConditionId; + + for (uint256 i = _stakerConditionId; i < _nextConditionId; i += 1) { + StakingCondition memory condition = stakingConditions[i]; + + uint256 startTime = i != _stakerConditionId ? condition.startTimestamp : staker.timeOfLastUpdate; + uint256 endTime = condition.endTimestamp != 0 ? condition.endTimestamp : block.timestamp; + + (bool noOverflowProduct, uint256 rewardsProduct) = SafeMath.tryMul( + (endTime - startTime) * staker.amountStaked, + condition.rewardsPerUnitTime + ); + (bool noOverflowSum, uint256 rewardsSum) = SafeMath.tryAdd(_rewards, rewardsProduct / condition.timeUnit); + + _rewards = noOverflowProduct && noOverflowSum ? rewardsSum : _rewards; + } + } + + /*//////////////////////////////////////////////////////////////////// + Optional hooks that can be implemented in the derived contract + ///////////////////////////////////////////////////////////////////*/ + + /// @dev Exposes the ability to override the msg sender -- support ERC2771. + function _stakeMsgSender() internal virtual returns (address) { + return msg.sender; + } + + /*/////////////////////////////////////////////////////////////// + Virtual functions to be implemented in derived contract + //////////////////////////////////////////////////////////////*/ + + /** + * @notice View total rewards available in the staking contract. + * + */ + function getRewardTokenBalance() external view virtual returns (uint256 _rewardsAvailableInContract); + + /** + * @dev Mint/Transfer ERC20 rewards to the staker. Must override. + * + * @param _staker Address for which to calculated rewards. + * @param _rewards Amount of tokens to be given out as reward. + * + * For example, override as below to mint ERC20 rewards: + * + * ``` + * function _mintRewards(address _staker, uint256 _rewards) internal override { + * + * TokenERC20(rewardTokenAddress).mintTo(_staker, _rewards); + * + * } + * ``` + */ + function _mintRewards(address _staker, uint256 _rewards) internal virtual; + + /** + * @dev Returns whether staking restrictions can be set in given execution context. + * Must override. + * + * + * For example, override as below to restrict access to admin: + * + * ``` + * function _canSetStakeConditions() internal override { + * + * return msg.sender == adminAddress; + * + * } + * ``` + */ + function _canSetStakeConditions() internal view virtual returns (bool); +} diff --git a/contracts/extension/TokenBundle.sol b/contracts/extension/TokenBundle.sol new file mode 100644 index 000000000..76d773fca --- /dev/null +++ b/contracts/extension/TokenBundle.sol @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "./interface/ITokenBundle.sol"; +import { CurrencyTransferLib } from "../lib/CurrencyTransferLib.sol"; + +interface IERC165 { + function supportsInterface(bytes4 interfaceId) external view returns (bool); +} + +/** + * @title Token Bundle + * @notice `TokenBundle` contract extension allows bundling-up of ERC20/ERC721/ERC1155 and native-tokan assets + * in a data structure, and provides logic for setting/getting IDs and URIs for created bundles. + * @dev See {ITokenBundle} + */ + +abstract contract TokenBundle is ITokenBundle { + /// @dev Mapping from bundle UID => bundle info. + mapping(uint256 => BundleInfo) private bundle; + + /// @dev Returns the total number of assets in a particular bundle. + function getTokenCountOfBundle(uint256 _bundleId) public view returns (uint256) { + return bundle[_bundleId].count; + } + + /// @dev Returns an asset contained in a particular bundle, at a particular index. + function getTokenOfBundle(uint256 _bundleId, uint256 index) public view returns (Token memory) { + return bundle[_bundleId].tokens[index]; + } + + /// @dev Returns the uri of a particular bundle. + function getUriOfBundle(uint256 _bundleId) public view returns (string memory) { + return bundle[_bundleId].uri; + } + + /// @dev Lets the calling contract create a bundle, by passing in a list of tokens and a unique id. + function _createBundle(Token[] calldata _tokensToBind, uint256 _bundleId) internal { + uint256 targetCount = _tokensToBind.length; + + require(targetCount > 0, "!Tokens"); + require(bundle[_bundleId].count == 0, "id exists"); + + for (uint256 i = 0; i < targetCount; i += 1) { + _checkTokenType(_tokensToBind[i]); + bundle[_bundleId].tokens[i] = _tokensToBind[i]; + } + + bundle[_bundleId].count = targetCount; + } + + /// @dev Lets the calling contract update a bundle, by passing in a list of tokens and a unique id. + function _updateBundle(Token[] memory _tokensToBind, uint256 _bundleId) internal { + require(_tokensToBind.length > 0, "!Tokens"); + + uint256 currentCount = bundle[_bundleId].count; + uint256 targetCount = _tokensToBind.length; + uint256 check = currentCount > targetCount ? currentCount : targetCount; + + for (uint256 i = 0; i < check; i += 1) { + if (i < targetCount) { + _checkTokenType(_tokensToBind[i]); + bundle[_bundleId].tokens[i] = _tokensToBind[i]; + } else if (i < currentCount) { + delete bundle[_bundleId].tokens[i]; + } + } + + bundle[_bundleId].count = targetCount; + } + + /// @dev Lets the calling contract add a token to a bundle for a unique bundle id and index. + function _addTokenInBundle(Token memory _tokenToBind, uint256 _bundleId) internal { + _checkTokenType(_tokenToBind); + uint256 id = bundle[_bundleId].count; + + bundle[_bundleId].tokens[id] = _tokenToBind; + bundle[_bundleId].count += 1; + } + + /// @dev Lets the calling contract update a token in a bundle for a unique bundle id and index. + function _updateTokenInBundle(Token memory _tokenToBind, uint256 _bundleId, uint256 _index) internal { + require(_index < bundle[_bundleId].count, "index DNE"); + _checkTokenType(_tokenToBind); + bundle[_bundleId].tokens[_index] = _tokenToBind; + } + + /// @dev Checks if the type of asset-contract is same as the TokenType specified. + function _checkTokenType(Token memory _token) internal view { + if (_token.tokenType == TokenType.ERC721) { + try IERC165(_token.assetContract).supportsInterface(0x80ac58cd) returns (bool supported721) { + require(supported721, "!TokenType"); + } catch { + revert("!TokenType"); + } + } else if (_token.tokenType == TokenType.ERC1155) { + try IERC165(_token.assetContract).supportsInterface(0xd9b67a26) returns (bool supported1155) { + require(supported1155, "!TokenType"); + } catch { + revert("!TokenType"); + } + } else if (_token.tokenType == TokenType.ERC20) { + if (_token.assetContract != CurrencyTransferLib.NATIVE_TOKEN) { + // 0x36372b07 + try IERC165(_token.assetContract).supportsInterface(0x80ac58cd) returns (bool supported721) { + require(!supported721, "!TokenType"); + + try IERC165(_token.assetContract).supportsInterface(0xd9b67a26) returns (bool supported1155) { + require(!supported1155, "!TokenType"); + } catch Error(string memory) {} catch {} + } catch Error(string memory) {} catch {} + } + } + } + + /// @dev Lets the calling contract set/update the uri of a particular bundle. + function _setUriOfBundle(string memory _uri, uint256 _bundleId) internal { + bundle[_bundleId].uri = _uri; + } + + /// @dev Lets the calling contract delete a particular bundle. + function _deleteBundle(uint256 _bundleId) internal { + for (uint256 i = 0; i < bundle[_bundleId].count; i += 1) { + delete bundle[_bundleId].tokens[i]; + } + bundle[_bundleId].count = 0; + } +} diff --git a/contracts/extension/TokenStore.sol b/contracts/extension/TokenStore.sol new file mode 100644 index 000000000..14b2042ba --- /dev/null +++ b/contracts/extension/TokenStore.sol @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +// ========== External imports ========== + +import "../eip/interface/IERC1155.sol"; +import "../eip/interface/IERC721.sol"; + +import "../external-deps/openzeppelin/utils/ERC1155/ERC1155Holder.sol"; +import "../external-deps/openzeppelin/utils/ERC721/ERC721Holder.sol"; + +// ========== Internal imports ========== + +import { TokenBundle, ITokenBundle } from "./TokenBundle.sol"; +import { CurrencyTransferLib } from "../lib/CurrencyTransferLib.sol"; + +/** + * @title Token Store + * @notice `TokenStore` contract extension allows bundling-up of ERC20/ERC721/ERC1155 and native-tokan assets + * and provides logic for storing, releasing, and transferring them from the extending contract. + * @dev See {CurrencyTransferLib} + */ + +contract TokenStore is TokenBundle, ERC721Holder, ERC1155Holder { + /// @dev The address of the native token wrapper contract. + address internal immutable nativeTokenWrapper; + + constructor(address _nativeTokenWrapper) { + nativeTokenWrapper = _nativeTokenWrapper; + } + + /// @dev Store / escrow multiple ERC1155, ERC721, ERC20 tokens. + function _storeTokens( + address _tokenOwner, + Token[] calldata _tokens, + string memory _uriForTokens, + uint256 _idForTokens + ) internal { + _createBundle(_tokens, _idForTokens); + _setUriOfBundle(_uriForTokens, _idForTokens); + _transferTokenBatch(_tokenOwner, address(this), _tokens); + } + + /// @dev Release stored / escrowed ERC1155, ERC721, ERC20 tokens. + function _releaseTokens(address _recipient, uint256 _idForContent) internal { + uint256 count = getTokenCountOfBundle(_idForContent); + Token[] memory tokensToRelease = new Token[](count); + + for (uint256 i = 0; i < count; i += 1) { + tokensToRelease[i] = getTokenOfBundle(_idForContent, i); + } + + _deleteBundle(_idForContent); + + _transferTokenBatch(address(this), _recipient, tokensToRelease); + } + + /// @dev Transfers an arbitrary ERC20 / ERC721 / ERC1155 token. + function _transferToken(address _from, address _to, Token memory _token) internal { + if (_token.tokenType == TokenType.ERC20) { + CurrencyTransferLib.transferCurrencyWithWrapper( + _token.assetContract, + _from, + _to, + _token.totalAmount, + nativeTokenWrapper + ); + } else if (_token.tokenType == TokenType.ERC721) { + IERC721(_token.assetContract).safeTransferFrom(_from, _to, _token.tokenId); + } else if (_token.tokenType == TokenType.ERC1155) { + IERC1155(_token.assetContract).safeTransferFrom(_from, _to, _token.tokenId, _token.totalAmount, ""); + } + } + + /// @dev Transfers multiple arbitrary ERC20 / ERC721 / ERC1155 tokens. + function _transferTokenBatch(address _from, address _to, Token[] memory _tokens) internal { + uint256 nativeTokenValue; + for (uint256 i = 0; i < _tokens.length; i += 1) { + if (_tokens[i].assetContract == CurrencyTransferLib.NATIVE_TOKEN && _to == address(this)) { + nativeTokenValue += _tokens[i].totalAmount; + } else { + _transferToken(_from, _to, _tokens[i]); + } + } + if (nativeTokenValue != 0) { + Token memory _nativeToken = Token({ + assetContract: CurrencyTransferLib.NATIVE_TOKEN, + tokenType: ITokenBundle.TokenType.ERC20, + tokenId: 0, + totalAmount: nativeTokenValue + }); + _transferToken(_from, _to, _nativeToken); + } + } +} diff --git a/contracts/extension/Upgradeable.sol b/contracts/extension/Upgradeable.sol new file mode 100644 index 000000000..7f81db60b --- /dev/null +++ b/contracts/extension/Upgradeable.sol @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: Apache 2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "../external-deps/openzeppelin/proxy/IERC1822Proxiable.sol"; +import "../external-deps/openzeppelin/proxy/ERC1967/ERC1967Upgrade.sol"; + +/** + * @dev An upgradeability mechanism designed for UUPS proxies. The functions included here can perform an upgrade of an + * {ERC1967Proxy}, when this contract is set as the implementation behind such a proxy. + * + * A security mechanism ensures that an upgrade does not turn off upgradeability accidentally, although this risk is + * reinstated if the upgrade retains upgradeability but removes the security mechanism, e.g. by replacing + * `UUPSUpgradeable` with a custom implementation of upgrades. + * + * The {_authorizeUpgrade} function must be overridden to include access restriction to the upgrade mechanism. + * + * _Available since v4.1._ + */ +abstract contract Upgradeable is IERC1822Proxiable, ERC1967Upgrade { + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable state-variable-assignment + address private immutable __self = address(this); + + /** + * @dev Check that the execution is being performed through a delegatecall call and that the execution context is + * a proxy contract with an implementation (as defined in ERC1967) pointing to self. This should only be the case + * for UUPS and transparent proxies that are using the current contract as their implementation. Execution of a + * function through ERC1167 minimal proxies (clones) would not normally pass this test, but is not guaranteed to + * fail. + */ + modifier onlyProxy() { + require(address(this) != __self, "Function must be called through delegatecall"); + require(_getImplementation() == __self, "Function must be called through active proxy"); + _; + } + + /** + * @dev Check that the execution is not being performed through a delegate call. This allows a function to be + * callable on the implementing contract but not through proxies. + */ + modifier notDelegated() { + require(address(this) == __self, "UUPSUpgradeable: must not be called through delegatecall"); + _; + } + + /** + * @dev Implementation of the ERC1822 {proxiableUUID} function. This returns the storage slot used by the + * implementation. It is used to validate that the this implementation remains valid after an upgrade. + * + * IMPORTANT: A proxy pointing at a proxiable contract should not be considered proxiable itself, because this risks + * bricking a proxy that upgrades to it, by delegating to itself until out of gas. Thus it is critical that this + * function revert if invoked through a proxy. This is guaranteed by the `notDelegated` modifier. + */ + function proxiableUUID() external view virtual override notDelegated returns (bytes32) { + return _IMPLEMENTATION_SLOT; + } + + /** + * @dev Upgrade the implementation of the proxy to `newImplementation`. + * + * Calls {_authorizeUpgrade}. + * + * Emits an {Upgraded} event. + */ + function upgradeTo(address newImplementation) external virtual onlyProxy { + _authorizeUpgrade(newImplementation); + _upgradeToAndCallUUPS(newImplementation, new bytes(0), false); + } + + /** + * @dev Upgrade the implementation of the proxy to `newImplementation`, and subsequently execute the function call + * encoded in `data`. + * + * Calls {_authorizeUpgrade}. + * + * Emits an {Upgraded} event. + */ + function upgradeToAndCall(address newImplementation, bytes memory data) external payable virtual onlyProxy { + _authorizeUpgrade(newImplementation); + _upgradeToAndCallUUPS(newImplementation, data, true); + } + + /** + * @dev Function that should revert when `msg.sender` is not authorized to upgrade the contract. Called by + * {upgradeTo} and {upgradeToAndCall}. + * + * Normally, this function will use an xref:access.adoc[access control] modifier such as {Ownable-onlyOwner}. + * + * ```solidity + * function _authorizeUpgrade(address) internal override onlyOwner {} + * ``` + */ + function _authorizeUpgrade(address newImplementation) internal virtual; +} diff --git a/contracts/extension/interface/IAccountPermissions.sol b/contracts/extension/interface/IAccountPermissions.sol new file mode 100644 index 000000000..0685a8301 --- /dev/null +++ b/contracts/extension/interface/IAccountPermissions.sol @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +interface IAccountPermissions { + /*/////////////////////////////////////////////////////////////// + Types + //////////////////////////////////////////////////////////////*/ + + /** + * @notice The payload that must be signed by an authorized wallet to set permissions for a signer to use the smart wallet. + * + * @param signer The addres of the signer to give permissions. + * @param approvedTargets The list of approved targets that a role holder can call using the smart wallet. + * @param nativeTokenLimitPerTransaction The maximum value that can be transferred by a role holder in a single transaction. + * @param permissionStartTimestamp The UNIX timestamp at and after which a signer has permission to use the smart wallet. + * @param permissionEndTimestamp The UNIX timestamp at and after which a signer no longer has permission to use the smart wallet. + * @param reqValidityStartTimestamp The UNIX timestamp at and after which a signature is valid. + * @param reqValidityEndTimestamp The UNIX timestamp at and after which a signature is invalid/expired. + * @param uid A unique non-repeatable ID for the payload. + * @param isAdmin Whether the signer should be an admin. + */ + struct SignerPermissionRequest { + address signer; + uint8 isAdmin; + address[] approvedTargets; + uint256 nativeTokenLimitPerTransaction; + uint128 permissionStartTimestamp; + uint128 permissionEndTimestamp; + uint128 reqValidityStartTimestamp; + uint128 reqValidityEndTimestamp; + bytes32 uid; + } + + /** + * @notice The permissions that a signer has to use the smart wallet. + * + * @param signer The address of the signer. + * @param approvedTargets The list of approved targets that a role holder can call using the smart wallet. + * @param nativeTokenLimitPerTransaction The maximum value that can be transferred by a role holder in a single transaction. + * @param startTimestamp The UNIX timestamp at and after which a signer has permission to use the smart wallet. + * @param endTimestamp The UNIX timestamp at and after which a signer no longer has permission to use the smart wallet. + */ + struct SignerPermissions { + address signer; + address[] approvedTargets; + uint256 nativeTokenLimitPerTransaction; + uint128 startTimestamp; + uint128 endTimestamp; + } + + /** + * @notice Internal struct for storing permissions for a signer (without approved targets). + * + * @param nativeTokenLimitPerTransaction The maximum value that can be transferred by a role holder in a single transaction. + * @param startTimestamp The UNIX timestamp at and after which a signer has permission to use the smart wallet. + * @param endTimestamp The UNIX timestamp at and after which a signer no longer has permission to use the smart wallet. + */ + struct SignerPermissionsStatic { + uint256 nativeTokenLimitPerTransaction; + uint128 startTimestamp; + uint128 endTimestamp; + } + + /*/////////////////////////////////////////////////////////////// + Events + //////////////////////////////////////////////////////////////*/ + + /// @notice Emitted when permissions for a signer are updated. + event SignerPermissionsUpdated( + address indexed authorizingSigner, + address indexed targetSigner, + SignerPermissionRequest permissions + ); + + /// @notice Emitted when an admin is set or removed. + event AdminUpdated(address indexed signer, bool isAdmin); + + /*/////////////////////////////////////////////////////////////// + View functions + //////////////////////////////////////////////////////////////*/ + + /// @notice Returns whether the given account is an admin. + function isAdmin(address signer) external view returns (bool); + + /// @notice Returns whether the given account is an active signer on the account. + function isActiveSigner(address signer) external view returns (bool); + + /// @notice Returns the restrictions under which a signer can use the smart wallet. + function getPermissionsForSigner(address signer) external view returns (SignerPermissions memory permissions); + + /// @notice Returns all active and inactive signers of the account. + function getAllSigners() external view returns (SignerPermissions[] memory signers); + + /// @notice Returns all signers with active permissions to use the account. + function getAllActiveSigners() external view returns (SignerPermissions[] memory signers); + + /// @notice Returns all admins of the account. + function getAllAdmins() external view returns (address[] memory admins); + + /// @dev Verifies that a request is signed by an authorized account. + function verifySignerPermissionRequest( + SignerPermissionRequest calldata req, + bytes calldata signature + ) external view returns (bool success, address signer); + + /*/////////////////////////////////////////////////////////////// + External functions + //////////////////////////////////////////////////////////////*/ + + /// @notice Sets the permissions for a given signer. + function setPermissionsForSigner(SignerPermissionRequest calldata req, bytes calldata signature) external; +} diff --git a/contracts/extension/interface/IAppURI.sol b/contracts/extension/interface/IAppURI.sol new file mode 100644 index 000000000..12875f990 --- /dev/null +++ b/contracts/extension/interface/IAppURI.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +/** + * Thirdweb's `AppURI` is a contract extension for any base contracts. It lets you set a metadata URI + * for you contract. + * + */ + +interface IAppURI { + /// @dev Returns the metadata URI of the contract. + function appURI() external view returns (string memory); + + /** + * @dev Sets contract URI for the storefront-level metadata of the contract. + * Only module admin can call this function. + */ + function setAppURI(string calldata _uri) external; + + /// @dev Emitted when the contract URI is updated. + event AppURIUpdated(string prevURI, string newURI); +} diff --git a/contracts/extension/interface/IBurnToClaim.sol b/contracts/extension/interface/IBurnToClaim.sol new file mode 100644 index 000000000..caf583d39 --- /dev/null +++ b/contracts/extension/interface/IBurnToClaim.sol @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +interface IBurnToClaim { + /// @notice The type of assets that can be burned. + enum TokenType { + ERC721, + ERC1155 + } + + /** + * @notice Configuration for burning tokens to claim new tokens. + * + * @param originContractAddress The address of the contract that the tokens are burned from. + * @param tokenType The type of token to burn. + * @param tokenId The token ID of the token to burn. Only used if tokenType is ERC1155. + * @param mintPriceForNewToken The price to mint a new token. + * @param currency The currency to pay the mint price in. + */ + struct BurnToClaimInfo { + address originContractAddress; + TokenType tokenType; + uint256 tokenId; // used only if tokenType is ERC1155 + uint256 mintPriceForNewToken; + address currency; + } + + /// @notice Emitted when tokens are burned to claim new tokens + event TokensBurnedAndClaimed( + address indexed originContract, + address indexed tokenOwner, + uint256 indexed burnTokenId, + uint256 quantity + ); + + /** + * @notice Sets the configuration for burning tokens to claim new tokens. + * @param burnToClaimInfo The configuration for burning tokens to claim new tokens. + */ + function setBurnToClaimInfo(BurnToClaimInfo calldata burnToClaimInfo) external; +} diff --git a/contracts/extension/interface/IBurnableERC1155.sol b/contracts/extension/interface/IBurnableERC1155.sol new file mode 100644 index 000000000..b4170c105 --- /dev/null +++ b/contracts/extension/interface/IBurnableERC1155.sol @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +/** + * `SignatureMint1155` is an ERC 1155 contract. It lets anyone mint NFTs by producing a mint request + * and a signature (produced by an account with MINTER_ROLE, signing the mint request). + */ +interface IBurnableERC1155 { + /// @dev Lets a token owner burn the tokens they own (i.e. destroy for good) + function burn(address account, uint256 id, uint256 value) external; + + /// @dev Lets a token owner burn multiple tokens they own at once (i.e. destroy for good) + function burnBatch(address account, uint256[] memory ids, uint256[] memory values) external; +} diff --git a/contracts/extension/interface/IBurnableERC20.sol b/contracts/extension/interface/IBurnableERC20.sol new file mode 100644 index 000000000..f6064f535 --- /dev/null +++ b/contracts/extension/interface/IBurnableERC20.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +interface IBurnableERC20 { + /** + * @dev Destroys `amount` tokens from the caller. + * + * See {ERC20-_burn}. + */ + function burn(uint256 amount) external; + + /** + * @dev Destroys `amount` tokens from `account`, deducting from the caller's + * allowance. + * + * See {ERC20-_burn} and {ERC20-allowance}. + * + * Requirements: + * + * - the caller must have allowance for ``accounts``'s tokens of at least + * `amount`. + */ + function burnFrom(address account, uint256 amount) external; +} diff --git a/contracts/extension/interface/IBurnableERC721.sol b/contracts/extension/interface/IBurnableERC721.sol new file mode 100644 index 000000000..e7d92abd1 --- /dev/null +++ b/contracts/extension/interface/IBurnableERC721.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +interface IBurnableERC721 { + /** + * @dev Burns `tokenId`. See {ERC721-_burn}. + * + * Requirements: + * + * - The caller must own `tokenId` or be an approved operator. + */ + function burn(uint256 tokenId) external; +} diff --git a/contracts/extension/interface/IClaimCondition.sol b/contracts/extension/interface/IClaimCondition.sol new file mode 100644 index 000000000..3e1f96c02 --- /dev/null +++ b/contracts/extension/interface/IClaimCondition.sol @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +/** + * The interface `IClaimCondition` is written for thirdweb's 'Drop' contracts, which are distribution mechanisms for tokens. + * + * A claim condition defines criteria under which accounts can mint tokens. Claim conditions can be overwritten + * or added to by the contract admin. At any moment, there is only one active claim condition. + */ + +interface IClaimCondition { + /** + * @notice The criteria that make up a claim condition. + * + * @param startTimestamp The unix timestamp after which the claim condition applies. + * The same claim condition applies until the `startTimestamp` + * of the next claim condition. + * + * @param maxClaimableSupply The maximum total number of tokens that can be claimed under + * the claim condition. + * + * @param supplyClaimed At any given point, the number of tokens that have been claimed + * under the claim condition. + * + * @param quantityLimitPerWallet The maximum number of tokens that can be claimed by a wallet. + * + * @param merkleRoot The allowlist of addresses that can claim tokens under the claim + * condition. + * + * @param pricePerToken The price required to pay per token claimed. + * + * @param currency The currency in which the `pricePerToken` must be paid. + * + * @param metadata Claim condition metadata. + */ + struct ClaimCondition { + uint256 startTimestamp; + uint256 maxClaimableSupply; + uint256 supplyClaimed; + uint256 quantityLimitPerWallet; + bytes32 merkleRoot; + uint256 pricePerToken; + address currency; + string metadata; + } +} diff --git a/contracts/extension/interface/IClaimConditionMultiPhase.sol b/contracts/extension/interface/IClaimConditionMultiPhase.sol new file mode 100644 index 000000000..df0d424e3 --- /dev/null +++ b/contracts/extension/interface/IClaimConditionMultiPhase.sol @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "./IClaimCondition.sol"; + +/** + * The interface `IClaimConditionMultiPhase` is written for thirdweb's 'Drop' contracts, which are distribution mechanisms for tokens. + * + * An authorized wallet can set a series of claim conditions, ordered by their respective `startTimestamp`. + * A claim condition defines criteria under which accounts can mint tokens. Claim conditions can be overwritten + * or added to by the contract admin. At any moment, there is only one active claim condition. + */ + +interface IClaimConditionMultiPhase is IClaimCondition { + /** + * @notice The set of all claim conditions, at any given moment. + * Claim Phase ID = [currentStartId, currentStartId + length - 1]; + * + * @param currentStartId The uid for the first claim condition amongst the current set of + * claim conditions. The uid for each next claim condition is one + * more than the previous claim condition's uid. + * + * @param count The total number of phases / claim conditions in the list + * of claim conditions. + * + * @param conditions The claim conditions at a given uid. Claim conditions + * are ordered in an ascending order by their `startTimestamp`. + * + * @param supplyClaimedByWallet Map from a claim condition uid and account to supply claimed by account. + */ + struct ClaimConditionList { + uint256 currentStartId; + uint256 count; + mapping(uint256 => ClaimCondition) conditions; + mapping(uint256 => mapping(address => uint256)) supplyClaimedByWallet; + } +} diff --git a/contracts/extension/interface/IClaimConditionsSinglePhase.sol b/contracts/extension/interface/IClaimConditionsSinglePhase.sol new file mode 100644 index 000000000..3b3d6d1ae --- /dev/null +++ b/contracts/extension/interface/IClaimConditionsSinglePhase.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "../../lib/BitMaps.sol"; +import "./IClaimCondition.sol"; + +/** + * Thirdweb's 'Drop' contracts are distribution mechanisms for tokens. + * + * A contract admin (i.e. a holder of `DEFAULT_ADMIN_ROLE`) can set a series of claim conditions, + * ordered by their respective `startTimestamp`. A claim condition defines criteria under which + * accounts can mint tokens. Claim conditions can be overwritten or added to by the contract admin. + * At any moment, there is only one active claim condition. + */ + +interface IClaimConditionsSinglePhase is IClaimCondition { + event ClaimConditionUpdated(ClaimCondition claimConditions, bool resetClaimEligibility); + + /** + * @notice Lets a contract admin (account with `DEFAULT_ADMIN_ROLE`) set claim conditions. + * + * @param phase Claim conditions in ascending order by `startTimestamp`. + * + * @param resetClaimEligibility Whether to reset `limitLastClaimTimestamp` and `limitMerkleProofClaim` values when setting new + * claim conditions. + * + */ + function setClaimConditions(ClaimCondition calldata phase, bool resetClaimEligibility) external; +} diff --git a/contracts/extension/interface/IClaimableERC1155.sol b/contracts/extension/interface/IClaimableERC1155.sol new file mode 100644 index 000000000..2afb26e29 --- /dev/null +++ b/contracts/extension/interface/IClaimableERC1155.sol @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +interface IClaimableERC1155 { + /// @dev Emitted when tokens are claimed + event TokensClaimed( + address indexed claimer, + address indexed receiver, + uint256 indexed tokenId, + uint256 quantityClaimed + ); + + /** + * @notice Lets an address claim multiple lazy minted NFTs at once to a recipient. + * Contract creators should override this function to create custom logic for claiming, + * for e.g. price collection, allowlist, max quantity, etc. + * + * @dev The logic in the `verifyClaim` function determines whether the caller is authorized to mint NFTs. + * + * @param _receiver The recipient of the tokens to mint. + * @param _tokenId The tokenId of the lazy minted NFT to mint. + * @param _quantity The number of tokens to mint. + */ + function claim(address _receiver, uint256 _tokenId, uint256 _quantity) external payable; + + /** + * @notice Override this function to add logic for claim verification, based on conditions + * such as allowlist, price, max quantity etc. + * + * @dev Checks a request to claim NFTs against a custom condition. + * + * @param _claimer Caller of the claim function. + * @param _tokenId The tokenId of the lazy minted NFT to mint. + * @param _quantity The number of NFTs being claimed. + */ + function verifyClaim(address _claimer, uint256 _tokenId, uint256 _quantity) external view; +} diff --git a/contracts/extension/interface/IClaimableERC721.sol b/contracts/extension/interface/IClaimableERC721.sol new file mode 100644 index 000000000..d92918c71 --- /dev/null +++ b/contracts/extension/interface/IClaimableERC721.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +interface IClaimableERC721 { + /// @dev Emitted when tokens are claimed + event TokensClaimed( + address indexed claimer, + address indexed receiver, + uint256 indexed startTokenId, + uint256 quantityClaimed + ); + + /** + * @notice Lets an address claim multiple lazy minted NFTs at once to a recipient. + * Contract creators should override this function to create custom logic for claiming, + * for e.g. price collection, allowlist, max quantity, etc. + * + * @dev The logic in the `verifyClaim` function determines whether the caller is authorized to mint NFTs. + * + * @param _receiver The recipient of the NFT to mint. + * @param _quantity The number of NFTs to mint. + */ + function claim(address _receiver, uint256 _quantity) external payable; + + /** + * @notice Override this function to add logic for claim verification, based on conditions + * such as allowlist, price, max quantity etc. + * + * @dev Checks a request to claim NFTs against a custom condition. + * + * @param _claimer Caller of the claim function. + * @param _quantity The number of NFTs being claimed. + */ + function verifyClaim(address _claimer, uint256 _quantity) external view; +} diff --git a/contracts/extension/interface/IContractFactory.sol b/contracts/extension/interface/IContractFactory.sol new file mode 100644 index 000000000..7d4bcea78 --- /dev/null +++ b/contracts/extension/interface/IContractFactory.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +interface IContractFactory { + /** + * @notice Deploys a proxy that points to that points to the given implementation. + * + * @param implementation Address of the implementation to point to. + * + * @param data Additional data to pass to the proxy constructor or any other data useful during deployement. + * @param salt Salt to use for the deterministic address generation. + */ + function deployProxyByImplementation( + address implementation, + bytes memory data, + bytes32 salt + ) external returns (address); +} diff --git a/contracts/extension/interface/IContractMetadata.sol b/contracts/extension/interface/IContractMetadata.sol new file mode 100644 index 000000000..b865001fd --- /dev/null +++ b/contracts/extension/interface/IContractMetadata.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +/** + * Thirdweb's `ContractMetadata` is a contract extension for any base contracts. It lets you set a metadata URI + * for you contract. + * + * Additionally, `ContractMetadata` is necessary for NFT contracts that want royalties to get distributed on OpenSea. + */ + +interface IContractMetadata { + /// @dev Returns the metadata URI of the contract. + function contractURI() external view returns (string memory); + + /** + * @dev Sets contract URI for the storefront-level metadata of the contract. + * Only module admin can call this function. + */ + function setContractURI(string calldata _uri) external; + + /// @dev Emitted when the contract URI is updated. + event ContractURIUpdated(string prevURI, string newURI); +} diff --git a/contracts/extension/interface/IDelayedReveal.sol b/contracts/extension/interface/IDelayedReveal.sol new file mode 100644 index 000000000..74708761d --- /dev/null +++ b/contracts/extension/interface/IDelayedReveal.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +/** + * Thirdweb's `DelayedReveal` is a contract extension for base NFT contracts. It lets you create batches of + * 'delayed-reveal' NFTs. You can learn more about the usage of delayed reveal NFTs here - https://blog.thirdweb.com/delayed-reveal-nfts + */ + +interface IDelayedReveal { + /// @dev Emitted when tokens are revealed. + event TokenURIRevealed(uint256 indexed index, string revealedURI); + + /** + * @notice Reveals a batch of delayed reveal NFTs. + * + * @param identifier The ID for the batch of delayed-reveal NFTs to reveal. + * + * @param key The key with which the base URI for the relevant batch of NFTs was encrypted. + */ + function reveal(uint256 identifier, bytes calldata key) external returns (string memory revealedURI); + + /** + * @notice Performs XOR encryption/decryption. + * + * @param data The data to encrypt. In the case of delayed-reveal NFTs, this is the "revealed" state + * base URI of the relevant batch of NFTs. + * + * @param key The key with which to encrypt data + */ + function encryptDecrypt(bytes memory data, bytes calldata key) external pure returns (bytes memory result); +} diff --git a/contracts/extension/interface/IDelayedRevealDeprecated.sol b/contracts/extension/interface/IDelayedRevealDeprecated.sol new file mode 100644 index 000000000..34650ae90 --- /dev/null +++ b/contracts/extension/interface/IDelayedRevealDeprecated.sol @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +// [ DEPRECATED CONTRACT: use `contracts/extension/interface/IDelayedReveal.sol` instead ] + +/** + * Thirdweb's `DelayedReveal` is a contract extension for base NFT contracts. It lets you create batches of + * 'delayed-reveal' NFTs. You can learn more about the usage of delayed reveal NFTs here - https://blog.thirdweb.com/delayed-reveal-nfts + */ + +interface IDelayedRevealDeprecated { + /// @dev Emitted when tokens are revealed. + event TokenURIRevealed(uint256 indexed index, string revealedURI); + + /// @dev Returns the encrypted base URI associated with the given identifier. + function encryptedBaseURI(uint256 identifier) external view returns (bytes memory); + + /** + * @notice Reveals a batch of delayed reveal NFTs. + * + * @param identifier The ID for the batch of delayed-reveal NFTs to reveal. + * + * @param key The key with which the base URI for the relevant batch of NFTs was encrypted. + */ + function reveal(uint256 identifier, bytes calldata key) external returns (string memory revealedURI); + + /** + * @notice Performs XOR encryption/decryption. + * + * @param data The data to encrypt. In the case of delayed-reveal NFTs, this is the "revealed" state + * base URI of the relevant batch of NFTs. + * + * @param key The key with which to encrypt data + */ + function encryptDecrypt(bytes memory data, bytes calldata key) external pure returns (bytes memory result); +} diff --git a/contracts/extension/interface/IDrop.sol b/contracts/extension/interface/IDrop.sol new file mode 100644 index 000000000..4e466e6c3 --- /dev/null +++ b/contracts/extension/interface/IDrop.sol @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "./IClaimConditionMultiPhase.sol"; + +/** + * The interface `IDrop` is written for thirdweb's 'Drop' contracts, which are distribution mechanisms for tokens. + * + * An authorized wallet can set a series of claim conditions, ordered by their respective `startTimestamp`. + * A claim condition defines criteria under which accounts can mint tokens. Claim conditions can be overwritten + * or added to by the contract admin. At any moment, there is only one active claim condition. + */ + +interface IDrop is IClaimConditionMultiPhase { + /** + * @param proof Proof of concerned wallet's inclusion in an allowlist. + * @param quantityLimitPerWallet The total quantity of tokens the allowlisted wallet is eligible to claim over time. + * @param pricePerToken The price per token the allowlisted wallet must pay to claim tokens. + * @param currency The currency in which the allowlisted wallet must pay the price for claiming tokens. + */ + struct AllowlistProof { + bytes32[] proof; + uint256 quantityLimitPerWallet; + uint256 pricePerToken; + address currency; + } + + /// @notice Emitted when tokens are claimed via `claim`. + event TokensClaimed( + uint256 indexed claimConditionIndex, + address indexed claimer, + address indexed receiver, + uint256 startTokenId, + uint256 quantityClaimed + ); + + /// @notice Emitted when the contract's claim conditions are updated. + event ClaimConditionsUpdated(ClaimCondition[] claimConditions, bool resetEligibility); + + /** + * @notice Lets an account claim a given quantity of NFTs. + * + * @param receiver The receiver of the NFTs to claim. + * @param quantity The quantity of NFTs to claim. + * @param currency The currency in which to pay for the claim. + * @param pricePerToken The price per token to pay for the claim. + * @param allowlistProof The proof of the claimer's inclusion in the merkle root allowlist + * of the claim conditions that apply. + * @param data Arbitrary bytes data that can be leveraged in the implementation of this interface. + */ + function claim( + address receiver, + uint256 quantity, + address currency, + uint256 pricePerToken, + AllowlistProof calldata allowlistProof, + bytes memory data + ) external payable; + + /** + * @notice Lets a contract admin (account with `DEFAULT_ADMIN_ROLE`) set claim conditions. + * + * @param phases Claim conditions in ascending order by `startTimestamp`. + * + * @param resetClaimEligibility Whether to honor the restrictions applied to wallets who have claimed tokens in the current conditions, + * in the new claim conditions being set. + * + */ + function setClaimConditions(ClaimCondition[] calldata phases, bool resetClaimEligibility) external; +} diff --git a/contracts/extension/interface/IDrop1155.sol b/contracts/extension/interface/IDrop1155.sol new file mode 100644 index 000000000..4fe60232e --- /dev/null +++ b/contracts/extension/interface/IDrop1155.sol @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "./IClaimConditionMultiPhase.sol"; + +/** + * The interface `IDrop1155` is written for thirdweb's 'Drop' contracts, which are distribution mechanisms for tokens. + * + * An authorized wallet can set a series of claim conditions, ordered by their respective `startTimestamp`. + * A claim condition defines criteria under which accounts can mint tokens. Claim conditions can be overwritten + * or added to by the contract admin. At any moment, there is only one active claim condition. + */ + +interface IDrop1155 is IClaimConditionMultiPhase { + /** + * @param proof Proof of concerned wallet's inclusion in an allowlist. + * @param quantityLimitPerWallet The total quantity of tokens the allowlisted wallet is eligible to claim over time. + * @param pricePerToken The price per token the allowlisted wallet must pay to claim tokens. + * @param currency The currency in which the allowlisted wallet must pay the price for claiming tokens. + */ + struct AllowlistProof { + bytes32[] proof; + uint256 quantityLimitPerWallet; + uint256 pricePerToken; + address currency; + } + + /// @notice Emitted when tokens are claimed. + event TokensClaimed( + uint256 indexed claimConditionIndex, + address indexed claimer, + address indexed receiver, + uint256 tokenId, + uint256 quantityClaimed + ); + + /// @notice Emitted when the contract's claim conditions are updated. + event ClaimConditionsUpdated(uint256 indexed tokenId, ClaimCondition[] claimConditions, bool resetEligibility); + + /** + * @notice Lets an account claim a given quantity of NFTs. + * + * @param receiver The receiver of the NFTs to claim. + * @param tokenId The tokenId of the NFT to claim. + * @param quantity The quantity of NFTs to claim. + * @param currency The currency in which to pay for the claim. + * @param pricePerToken The price per token to pay for the claim. + * @param allowlistProof The proof of the claimer's inclusion in the merkle root allowlist + * of the claim conditions that apply. + * @param data Arbitrary bytes data that can be leveraged in the implementation of this interface. + */ + function claim( + address receiver, + uint256 tokenId, + uint256 quantity, + address currency, + uint256 pricePerToken, + AllowlistProof calldata allowlistProof, + bytes memory data + ) external payable; + + /** + * @notice Lets a contract admin (account with `DEFAULT_ADMIN_ROLE`) set claim conditions. + * + * @param tokenId The token ID for which to set mint conditions. + * @param phases Claim conditions in ascending order by `startTimestamp`. + * + * @param resetClaimEligibility Whether to honor the restrictions applied to wallets who have claimed tokens in the current conditions, + * in the new claim conditions being set. + * + */ + function setClaimConditions(uint256 tokenId, ClaimCondition[] calldata phases, bool resetClaimEligibility) external; +} diff --git a/contracts/extension/interface/IDropSinglePhase.sol b/contracts/extension/interface/IDropSinglePhase.sol new file mode 100644 index 000000000..92717755c --- /dev/null +++ b/contracts/extension/interface/IDropSinglePhase.sol @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "./IClaimCondition.sol"; + +/** + * The interface `IDropSinglePhase` is written for thirdweb's 'DropSinglePhase' contracts, which are distribution mechanisms for tokens. + * + * An authorized wallet can set a claim condition for the distribution of the contract's tokens. + * A claim condition defines criteria under which accounts can mint tokens. Claim conditions can be overwritten + * or added to by the contract admin. At any moment, there is only one active claim condition. + */ + +interface IDropSinglePhase is IClaimCondition { + /** + * @param proof Proof of concerned wallet's inclusion in an allowlist. + * @param quantityLimitPerWallet The total quantity of tokens the allowlisted wallet is eligible to claim over time. + * @param pricePerToken The price per token the allowlisted wallet must pay to claim tokens. + * @param currency The currency in which the allowlisted wallet must pay the price for claiming tokens. + */ + struct AllowlistProof { + bytes32[] proof; + uint256 quantityLimitPerWallet; + uint256 pricePerToken; + address currency; + } + + /// @notice Emitted when tokens are claimed via `claim`. + event TokensClaimed( + address indexed claimer, + address indexed receiver, + uint256 indexed startTokenId, + uint256 quantityClaimed + ); + + /// @notice Emitted when the contract's claim conditions are updated. + event ClaimConditionUpdated(ClaimCondition condition, bool resetEligibility); + + /** + * @notice Lets an account claim a given quantity of NFTs. + * + * @param receiver The receiver of the NFTs to claim. + * @param quantity The quantity of NFTs to claim. + * @param currency The currency in which to pay for the claim. + * @param pricePerToken The price per token to pay for the claim. + * @param allowlistProof The proof of the claimer's inclusion in the merkle root allowlist + * of the claim conditions that apply. + * @param data Arbitrary bytes data that can be leveraged in the implementation of this interface. + */ + function claim( + address receiver, + uint256 quantity, + address currency, + uint256 pricePerToken, + AllowlistProof calldata allowlistProof, + bytes memory data + ) external payable; + + /** + * @notice Lets a contract admin (account with `DEFAULT_ADMIN_ROLE`) set claim conditions. + * + * @param phase Claim condition to set. + * + * @param resetClaimEligibility Whether to honor the restrictions applied to wallets who have claimed tokens in the current conditions, + * in the new claim conditions being set. + */ + function setClaimConditions(ClaimCondition calldata phase, bool resetClaimEligibility) external; +} diff --git a/contracts/extension/interface/IDropSinglePhase1155.sol b/contracts/extension/interface/IDropSinglePhase1155.sol new file mode 100644 index 000000000..8c6321095 --- /dev/null +++ b/contracts/extension/interface/IDropSinglePhase1155.sol @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "./IClaimCondition.sol"; + +/** + * The interface `IDropSinglePhase1155` is written for thirdweb's 'DropSinglePhase' contracts, which are distribution mechanisms for tokens. + * + * An authorized wallet can set a claim condition for the distribution of the contract's tokens. + * A claim condition defines criteria under which accounts can mint tokens. Claim conditions can be overwritten + * or added to by the contract admin. At any moment, there is only one active claim condition. + */ + +interface IDropSinglePhase1155 is IClaimCondition { + /** + * @param proof Proof of concerned wallet's inclusion in an allowlist. + * @param quantityLimitPerWallet The total quantity of tokens the allowlisted wallet is eligible to claim over time. + * @param pricePerToken The price per token the allowlisted wallet must pay to claim tokens. + * @param currency The currency in which the allowlisted wallet must pay the price for claiming tokens. + */ + struct AllowlistProof { + bytes32[] proof; + uint256 quantityLimitPerWallet; + uint256 pricePerToken; + address currency; + } + + /// @notice Emitted when tokens are claimed via `claim`. + event TokensClaimed( + address indexed claimer, + address indexed receiver, + uint256 indexed tokenId, + uint256 quantityClaimed + ); + + /// @notice Emitted when the contract's claim conditions are updated. + event ClaimConditionUpdated(uint256 indexed tokenId, ClaimCondition condition, bool resetEligibility); + + /** + * @notice Lets an account claim a given quantity of NFTs. + * + * @param receiver The receiver of the NFT to claim. + * @param tokenId The tokenId of the NFT to claim. + * @param quantity The quantity of the NFT to claim. + * @param currency The currency in which to pay for the claim. + * @param pricePerToken The price per token to pay for the claim. + * @param allowlistProof The proof of the claimer's inclusion in the merkle root allowlist + * of the claim conditions that apply. + * @param data Arbitrary bytes data that can be leveraged in the implementation of this interface. + */ + function claim( + address receiver, + uint256 tokenId, + uint256 quantity, + address currency, + uint256 pricePerToken, + AllowlistProof calldata allowlistProof, + bytes memory data + ) external payable; + + /** + * @notice Lets a contract admin (account with `DEFAULT_ADMIN_ROLE`) set claim conditions. + * + * @param phase Claim condition to set. + * + * @param resetClaimEligibility Whether to honor the restrictions applied to wallets who have claimed tokens in the current conditions, + * in the new claim conditions being set. + * + * @param tokenId The tokenId for which to set the relevant claim condition. + */ + function setClaimConditions(uint256 tokenId, ClaimCondition calldata phase, bool resetClaimEligibility) external; +} diff --git a/contracts/extension/interface/IERC2771Context.sol b/contracts/extension/interface/IERC2771Context.sol new file mode 100644 index 000000000..b943d3cc8 --- /dev/null +++ b/contracts/extension/interface/IERC2771Context.sol @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +interface IERC2771Context { + function isTrustedForwarder(address forwarder) external view returns (bool); +} diff --git a/contracts/extension/interface/ILazyMint.sol b/contracts/extension/interface/ILazyMint.sol new file mode 100644 index 000000000..8a71f74ed --- /dev/null +++ b/contracts/extension/interface/ILazyMint.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +/** + * Thirdweb's `LazyMint` is a contract extension for any base NFT contract. It lets you 'lazy mint' any number of NFTs + * at once. Here, 'lazy mint' means defining the metadata for particular tokenIds of your NFT contract, without actually + * minting a non-zero balance of NFTs of those tokenIds. + */ + +interface ILazyMint { + /// @dev Emitted when tokens are lazy minted. + event TokensLazyMinted(uint256 indexed startTokenId, uint256 endTokenId, string baseURI, bytes encryptedBaseURI); + + /** + * @notice Lazy mints a given amount of NFTs. + * + * @param amount The number of NFTs to lazy mint. + * + * @param baseURIForTokens The base URI for the 'n' number of NFTs being lazy minted, where the metadata for each + * of those NFTs is `${baseURIForTokens}/${tokenId}`. + * + * @param extraData Additional bytes data to be used at the discretion of the consumer of the contract. + * + * @return batchId A unique integer identifier for the batch of NFTs lazy minted together. + */ + function lazyMint( + uint256 amount, + string calldata baseURIForTokens, + bytes calldata extraData + ) external returns (uint256 batchId); +} diff --git a/contracts/extension/interface/ILazyMintWithTier.sol b/contracts/extension/interface/ILazyMintWithTier.sol new file mode 100644 index 000000000..e3a4caa6f --- /dev/null +++ b/contracts/extension/interface/ILazyMintWithTier.sol @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +/** + * Thirdweb's `LazyMintWithTier` is a contract extension for any base NFT contract. It lets you 'lazy mint' any number of NFTs + * at once, for a particular tier. Here, 'lazy mint' means defining the metadata for particular tokenIds of your NFT contract, + * without actually minting a non-zero balance of NFTs of those tokenIds. + */ + +interface ILazyMintWithTier { + /// @dev Emitted when tokens are lazy minted. + event TokensLazyMinted( + string indexed tier, + uint256 indexed startTokenId, + uint256 endTokenId, + string baseURI, + bytes encryptedBaseURI + ); + + /** + * @notice Lazy mints a given amount of NFTs. + * + * @param amount The number of NFTs to lazy mint. + * + * @param baseURIForTokens The base URI for the 'n' number of NFTs being lazy minted, where the metadata for each + * of those NFTs is `${baseURIForTokens}/${tokenId}`. + * + * @param tier The tier for which these tokens are being lazy mitned. Here, `tier` is a unique string label + * that is used to group together different batches of lazy minted tokens under a common category. + * + * @param extraData Additional bytes data to be used at the discretion of the consumer of the contract. + * + * @return batchId A unique integer identifier for the batch of NFTs lazy minted together. + */ + function lazyMint( + uint256 amount, + string calldata baseURIForTokens, + string calldata tier, + bytes calldata extraData + ) external returns (uint256 batchId); +} diff --git a/contracts/extension/interface/IMintableERC1155.sol b/contracts/extension/interface/IMintableERC1155.sol new file mode 100644 index 000000000..7b45769f6 --- /dev/null +++ b/contracts/extension/interface/IMintableERC1155.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +/** + * `SignatureMint1155` is an ERC 1155 contract. It lets anyone mint NFTs by producing a mint request + * and a signature (produced by an account with MINTER_ROLE, signing the mint request). + */ +interface IMintableERC1155 { + /// @dev Emitted when an account with MINTER_ROLE mints an NFT. + event TokensMinted(address indexed mintedTo, uint256 indexed tokenIdMinted, string uri, uint256 quantityMinted); + + /** + * @notice Lets an account with MINTER_ROLE mint an NFT. + * + * @param to The address to mint the NFT to. + * @param tokenId The tokenId of the NFTs to mint + * @param uri The URI to assign to the NFT. + * @param amount The number of copies of the NFT to mint. + * + */ + function mintTo(address to, uint256 tokenId, string calldata uri, uint256 amount) external; +} diff --git a/contracts/extension/interface/IMintableERC20.sol b/contracts/extension/interface/IMintableERC20.sol new file mode 100644 index 000000000..9d42a04dc --- /dev/null +++ b/contracts/extension/interface/IMintableERC20.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +interface IMintableERC20 { + /// @dev Emitted when tokens are minted with `mintTo` + event TokensMinted(address indexed mintedTo, uint256 quantityMinted); + + /** + * @dev Creates `amount` new tokens for `to`. + * + * See {ERC20-_mint}. + * + * Requirements: + * + * - the caller must have the `MINTER_ROLE`. + */ + function mintTo(address to, uint256 amount) external; +} diff --git a/contracts/extension/interface/IMintableERC721.sol b/contracts/extension/interface/IMintableERC721.sol new file mode 100644 index 000000000..b0316c7b9 --- /dev/null +++ b/contracts/extension/interface/IMintableERC721.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +interface IMintableERC721 { + /// @dev Emitted when tokens are minted via `mintTo` + event TokensMinted(address indexed mintedTo, uint256 indexed tokenIdMinted, string uri); + + /** + * @notice Lets an account mint an NFT. + * + * @param to The address to mint the NFT to. + * @param uri The URI to assign to the NFT. + * + * @return tokenId of the NFT minted. + */ + function mintTo(address to, string calldata uri) external returns (uint256); +} diff --git a/contracts/extension/interface/IMulticall.sol b/contracts/extension/interface/IMulticall.sol new file mode 100644 index 000000000..e96e0b85e --- /dev/null +++ b/contracts/extension/interface/IMulticall.sol @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +/// @author thirdweb + +/** + * @dev Provides a function to batch together multiple calls in a single external call. + * + * _Available since v4.1._ + */ +interface IMulticall { + /** + * @dev Receives and executes a batch of function calls on this contract. + */ + function multicall(bytes[] calldata data) external returns (bytes[] memory results); +} diff --git a/contracts/extension/interface/INFTMetadata.sol b/contracts/extension/interface/INFTMetadata.sol new file mode 100644 index 000000000..fb8dbdfc9 --- /dev/null +++ b/contracts/extension/interface/INFTMetadata.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "../../eip/interface/IERC4906.sol"; + +interface INFTMetadata is IERC4906 { + /// @dev This event emits when the metadata of all tokens are frozen. + /// While not currently supported by marketplaces, this event allows + /// future indexing if desired. + event MetadataFrozen(); + + /// @notice Sets the metadata URI for a given NFT. + function setTokenURI(uint256 _tokenId, string memory _uri) external; + + /// @notice Freezes the metadata URI for a given NFT. + function freezeMetadata() external; +} diff --git a/contracts/extension/interface/IOperatorFilterRegistry.sol b/contracts/extension/interface/IOperatorFilterRegistry.sol new file mode 100644 index 000000000..4b756a17c --- /dev/null +++ b/contracts/extension/interface/IOperatorFilterRegistry.sol @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: Apache 2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +interface IOperatorFilterRegistry { + function isOperatorAllowed(address registrant, address operator) external view returns (bool); + + function register(address registrant) external; + + function registerAndSubscribe(address registrant, address subscription) external; + + function registerAndCopyEntries(address registrant, address registrantToCopy) external; + + function unregister(address addr) external; + + function updateOperator(address registrant, address operator, bool filtered) external; + + function updateOperators(address registrant, address[] calldata operators, bool filtered) external; + + function updateCodeHash(address registrant, bytes32 codehash, bool filtered) external; + + function updateCodeHashes(address registrant, bytes32[] calldata codeHashes, bool filtered) external; + + function subscribe(address registrant, address registrantToSubscribe) external; + + function unsubscribe(address registrant, bool copyExistingEntries) external; + + function subscriptionOf(address addr) external returns (address registrant); + + function subscribers(address registrant) external returns (address[] memory); + + function subscriberAt(address registrant, uint256 index) external returns (address); + + function copyEntriesOf(address registrant, address registrantToCopy) external; + + function isOperatorFiltered(address registrant, address operator) external returns (bool); + + function isCodeHashOfFiltered(address registrant, address operatorWithCode) external returns (bool); + + function isCodeHashFiltered(address registrant, bytes32 codeHash) external returns (bool); + + function filteredOperators(address addr) external returns (address[] memory); + + function filteredCodeHashes(address addr) external returns (bytes32[] memory); + + function filteredOperatorAt(address registrant, uint256 index) external returns (address); + + function filteredCodeHashAt(address registrant, uint256 index) external returns (bytes32); + + function isRegistered(address addr) external returns (bool); + + function codeHashOf(address addr) external returns (bytes32); +} diff --git a/contracts/extension/interface/IOperatorFilterToggle.sol b/contracts/extension/interface/IOperatorFilterToggle.sol new file mode 100644 index 000000000..65f7e23a1 --- /dev/null +++ b/contracts/extension/interface/IOperatorFilterToggle.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: Apache 2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +interface IOperatorFilterToggle { + event OperatorRestriction(bool restriction); + + function operatorRestriction() external view returns (bool); + + function setOperatorRestriction(bool restriction) external; +} diff --git a/contracts/extension/interface/IOwnable.sol b/contracts/extension/interface/IOwnable.sol new file mode 100644 index 000000000..e96008a07 --- /dev/null +++ b/contracts/extension/interface/IOwnable.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +/** + * Thirdweb's `Ownable` is a contract extension to be used with any base contract. It exposes functions for setting and reading + * who the 'owner' of the inheriting smart contract is, and lets the inheriting contract perform conditional logic that uses + * information about who the contract's owner is. + */ + +interface IOwnable { + /// @dev Returns the owner of the contract. + function owner() external view returns (address); + + /// @dev Lets a module admin set a new owner for the contract. The new owner must be a module admin. + function setOwner(address _newOwner) external; + + /// @dev Emitted when a new Owner is set. + event OwnerUpdated(address indexed prevOwner, address indexed newOwner); +} diff --git a/contracts/extension/interface/IPermissions.sol b/contracts/extension/interface/IPermissions.sol new file mode 100644 index 000000000..7bd6e8c8b --- /dev/null +++ b/contracts/extension/interface/IPermissions.sol @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +/** + * @dev External interface of AccessControl declared to support ERC165 detection. + */ +interface IPermissions { + /** + * @dev Emitted when `newAdminRole` is set as ``role``'s admin role, replacing `previousAdminRole` + * + * `DEFAULT_ADMIN_ROLE` is the starting admin for all roles, despite + * {RoleAdminChanged} not being emitted signaling this. + * + * _Available since v3.1._ + */ + event RoleAdminChanged(bytes32 indexed role, bytes32 indexed previousAdminRole, bytes32 indexed newAdminRole); + + /** + * @dev Emitted when `account` is granted `role`. + * + * `sender` is the account that originated the contract call, an admin role + * bearer except when using {AccessControl-_setupRole}. + */ + event RoleGranted(bytes32 indexed role, address indexed account, address indexed sender); + + /** + * @dev Emitted when `account` is revoked `role`. + * + * `sender` is the account that originated the contract call: + * - if using `revokeRole`, it is the admin role bearer + * - if using `renounceRole`, it is the role bearer (i.e. `account`) + */ + event RoleRevoked(bytes32 indexed role, address indexed account, address indexed sender); + + /** + * @dev Returns `true` if `account` has been granted `role`. + */ + function hasRole(bytes32 role, address account) external view returns (bool); + + /** + * @dev Returns the admin role that controls `role`. See {grantRole} and + * {revokeRole}. + * + * To change a role's admin, use {AccessControl-_setRoleAdmin}. + */ + function getRoleAdmin(bytes32 role) external view returns (bytes32); + + /** + * @dev Grants `role` to `account`. + * + * If `account` had not been already granted `role`, emits a {RoleGranted} + * event. + * + * Requirements: + * + * - the caller must have ``role``'s admin role. + */ + function grantRole(bytes32 role, address account) external; + + /** + * @dev Revokes `role` from `account`. + * + * If `account` had been granted `role`, emits a {RoleRevoked} event. + * + * Requirements: + * + * - the caller must have ``role``'s admin role. + */ + function revokeRole(bytes32 role, address account) external; + + /** + * @dev Revokes `role` from the calling account. + * + * Roles are often managed via {grantRole} and {revokeRole}: this function's + * purpose is to provide a mechanism for accounts to lose their privileges + * if they are compromised (such as when a trusted device is misplaced). + * + * If the calling account had been granted `role`, emits a {RoleRevoked} + * event. + * + * Requirements: + * + * - the caller must be `account`. + */ + function renounceRole(bytes32 role, address account) external; +} diff --git a/contracts/extension/interface/IPermissionsEnumerable.sol b/contracts/extension/interface/IPermissionsEnumerable.sol new file mode 100644 index 000000000..977bce6d5 --- /dev/null +++ b/contracts/extension/interface/IPermissionsEnumerable.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "./IPermissions.sol"; + +/** + * @dev External interface of AccessControlEnumerable declared to support ERC165 detection. + */ +interface IPermissionsEnumerable is IPermissions { + /** + * @dev Returns one of the accounts that have `role`. `index` must be a + * value between 0 and {getRoleMemberCount}, non-inclusive. + * + * Role bearers are not sorted in any particular way, and their ordering may + * change at any point. + * + * WARNING: When using {getRoleMember} and {getRoleMemberCount}, make sure + * you perform all queries on the same block. See the following + * [forum post](https://forum.openzeppelin.com/t/iterating-over-elements-on-enumerableset-in-openzeppelin-contracts/2296) + * for more information. + */ + function getRoleMember(bytes32 role, uint256 index) external view returns (address); + + /** + * @dev Returns the number of accounts that have `role`. Can be used + * together with {getRoleMember} to enumerate all bearers of a role. + */ + function getRoleMemberCount(bytes32 role) external view returns (uint256); +} diff --git a/contracts/extension/interface/IPlatformFee.sol b/contracts/extension/interface/IPlatformFee.sol new file mode 100644 index 000000000..1a1fc778a --- /dev/null +++ b/contracts/extension/interface/IPlatformFee.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +/** + * Thirdweb's `PlatformFee` is a contract extension to be used with any base contract. It exposes functions for setting and reading + * the recipient of platform fee and the platform fee basis points, and lets the inheriting contract perform conditional logic + * that uses information about platform fees, if desired. + */ + +interface IPlatformFee { + /// @dev Fee type variants: percentage fee and flat fee + enum PlatformFeeType { + Bps, + Flat + } + + /// @dev Returns the platform fee bps and recipient. + function getPlatformFeeInfo() external view returns (address, uint16); + + /// @dev Lets a module admin update the fees on primary sales. + function setPlatformFeeInfo(address _platformFeeRecipient, uint256 _platformFeeBps) external; + + /// @dev Emitted when fee on primary sales is updated. + event PlatformFeeInfoUpdated(address indexed platformFeeRecipient, uint256 platformFeeBps); + + /// @dev Emitted when the flat platform fee is updated. + event FlatPlatformFeeUpdated(address platformFeeRecipient, uint256 flatFee); + + /// @dev Emitted when the platform fee type is updated. + event PlatformFeeTypeUpdated(PlatformFeeType feeType); +} diff --git a/contracts/extension/interface/IPrimarySale.sol b/contracts/extension/interface/IPrimarySale.sol new file mode 100644 index 000000000..6ca726842 --- /dev/null +++ b/contracts/extension/interface/IPrimarySale.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +/** + * Thirdweb's `Primary` is a contract extension to be used with any base contract. It exposes functions for setting and reading + * the recipient of primary sales, and lets the inheriting contract perform conditional logic that uses information about + * primary sales, if desired. + */ + +interface IPrimarySale { + /// @dev The adress that receives all primary sales value. + function primarySaleRecipient() external view returns (address); + + /// @dev Lets a module admin set the default recipient of all primary sales. + function setPrimarySaleRecipient(address _saleRecipient) external; + + /// @dev Emitted when a new sale recipient is set. + event PrimarySaleRecipientUpdated(address indexed recipient); +} diff --git a/contracts/extension/interface/IRoyalty.sol b/contracts/extension/interface/IRoyalty.sol new file mode 100644 index 000000000..f87cdff9c --- /dev/null +++ b/contracts/extension/interface/IRoyalty.sol @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "../../eip/interface/IERC2981.sol"; + +/** + * Thirdweb's `Royalty` is a contract extension to be used with any base contract. It exposes functions for setting and reading + * the recipient of royalty fee and the royalty fee basis points, and lets the inheriting contract perform conditional logic + * that uses information about royalty fees, if desired. + * + * The `Royalty` contract is ERC2981 compliant. + */ + +interface IRoyalty is IERC2981 { + struct RoyaltyInfo { + address recipient; + uint256 bps; + } + + /// @dev Returns the royalty recipient and fee bps. + function getDefaultRoyaltyInfo() external view returns (address, uint16); + + /// @dev Lets a module admin update the royalty bps and recipient. + function setDefaultRoyaltyInfo(address _royaltyRecipient, uint256 _royaltyBps) external; + + /// @dev Lets a module admin set the royalty recipient for a particular token Id. + function setRoyaltyInfoForToken(uint256 tokenId, address recipient, uint256 bps) external; + + /// @dev Returns the royalty recipient for a particular token Id. + function getRoyaltyInfoForToken(uint256 tokenId) external view returns (address, uint16); + + /// @dev Emitted when royalty info is updated. + event DefaultRoyalty(address indexed newRoyaltyRecipient, uint256 newRoyaltyBps); + + /// @dev Emitted when royalty recipient for tokenId is set + event RoyaltyForToken(uint256 indexed tokenId, address indexed royaltyRecipient, uint256 royaltyBps); +} diff --git a/contracts/extension/interface/IRoyaltyEngineV1.sol b/contracts/extension/interface/IRoyaltyEngineV1.sol new file mode 100644 index 000000000..819427c5d --- /dev/null +++ b/contracts/extension/interface/IRoyaltyEngineV1.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +/// @author: manifold.xyz + +import "@openzeppelin/contracts/utils/introspection/IERC165.sol"; + +/** + * @dev Lookup engine interface + */ +interface IRoyaltyEngineV1 is IERC165 { + /** + * Get the royalty for a given token (address, id) and value amount. Does not cache the bps/amounts. Caches the spec for a given token address + * + * @param tokenAddress - The address of the token + * @param tokenId - The id of the token + * @param value - The value you wish to get the royalty of + * + * returns Two arrays of equal length, royalty recipients and the corresponding amount each recipient should get + */ + function getRoyalty( + address tokenAddress, + uint256 tokenId, + uint256 value + ) external returns (address payable[] memory recipients, uint256[] memory amounts); + + /** + * View only version of getRoyalty + * + * @param tokenAddress - The address of the token + * @param tokenId - The id of the token + * @param value - The value you wish to get the royalty of + * + * returns Two arrays of equal length, royalty recipients and the corresponding amount each recipient should get + */ + function getRoyaltyView( + address tokenAddress, + uint256 tokenId, + uint256 value + ) external view returns (address payable[] memory recipients, uint256[] memory amounts); +} diff --git a/contracts/extension/interface/IRoyaltyPayments.sol b/contracts/extension/interface/IRoyaltyPayments.sol new file mode 100644 index 000000000..cb7982c05 --- /dev/null +++ b/contracts/extension/interface/IRoyaltyPayments.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "@openzeppelin/contracts/utils/introspection/IERC165.sol"; + +/** + * @dev Read royalty info for a token. + * Supports RoyaltyEngineV1 and RoyaltyRegistry by manifold.xyz. + */ +interface IRoyaltyPayments is IERC165 { + /// @dev Emitted when the address of RoyaltyEngine is set or updated. + event RoyaltyEngineUpdated(address indexed previousAddress, address indexed newAddress); + + /** + * Get the royalty for a given token (address, id) and value amount. + * + * @param tokenAddress - The address of the token + * @param tokenId - The id of the token + * @param value - The value you wish to get the royalty of + * + * returns Two arrays of equal length, royalty recipients and the corresponding amount each recipient should get + */ + function getRoyalty( + address tokenAddress, + uint256 tokenId, + uint256 value + ) external returns (address payable[] memory recipients, uint256[] memory amounts); + + /** + * Set or override RoyaltyEngine address + * + * @param _royaltyEngineAddress - RoyaltyEngineV1 address + */ + function setRoyaltyEngine(address _royaltyEngineAddress) external; +} diff --git a/contracts/extension/interface/IRulesEngine.sol b/contracts/extension/interface/IRulesEngine.sol new file mode 100644 index 000000000..3c19c6558 --- /dev/null +++ b/contracts/extension/interface/IRulesEngine.sol @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +interface IRulesEngine { + enum TokenType { + ERC20, + ERC721, + ERC1155 + } + + enum RuleType { + Threshold, + Multiplicative + } + + struct RuleTypeThreshold { + address token; + TokenType tokenType; + uint256 tokenId; + uint256 balance; + uint256 score; + } + + struct RuleTypeMultiplicative { + address token; + TokenType tokenType; + uint256 tokenId; + uint256 scorePerOwnedToken; + } + + struct RuleWithId { + bytes32 ruleId; + address token; + TokenType tokenType; + uint256 tokenId; + uint256 balance; + uint256 score; + RuleType ruleType; + } + + event RuleCreated(bytes32 indexed ruleId, RuleWithId rule); + event RuleDeleted(bytes32 indexed ruleId); + event RulesEngineOverriden(address indexed newRulesEngine); + + function getScore(address _tokenOwner) external view returns (uint256 score); + + function getAllRules() external view returns (RuleWithId[] memory rules); + + function getRulesEngineOverride() external view returns (address rulesEngineAddress); + + function createRuleMultiplicative(RuleTypeMultiplicative memory rule) external returns (bytes32 ruleId); + + function createRuleThreshold(RuleTypeThreshold memory rule) external returns (bytes32 ruleId); + + function deleteRule(bytes32 ruleId) external; + + function setRulesEngineOverride(address _rulesEngineAddress) external; +} diff --git a/contracts/extension/interface/ISharedMetadata.sol b/contracts/extension/interface/ISharedMetadata.sol new file mode 100644 index 000000000..5b816d2a1 --- /dev/null +++ b/contracts/extension/interface/ISharedMetadata.sol @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: Apache 2.0 +pragma solidity ^0.8.10; + +/// @author thirdweb + +interface ISharedMetadata { + /// @notice Emitted when shared metadata is lazy minted. + event SharedMetadataUpdated(string name, string description, string imageURI, string animationURI); + + /** + * @notice Structure for metadata shared across all tokens + * + * @param name Shared name of NFT in metadata + * @param description Shared description of NFT in metadata + * @param imageURI Shared URI of image to render for NFTs + * @param animationURI Shared URI of animation to render for NFTs + */ + struct SharedMetadataInfo { + string name; + string description; + string imageURI; + string animationURI; + } + + /** + * @notice Set shared metadata for NFTs + * @param _metadata common metadata for all tokens + */ + function setSharedMetadata(SharedMetadataInfo calldata _metadata) external; +} diff --git a/contracts/extension/interface/ISharedMetadataBatch.sol b/contracts/extension/interface/ISharedMetadataBatch.sol new file mode 100644 index 000000000..e6bc915c0 --- /dev/null +++ b/contracts/extension/interface/ISharedMetadataBatch.sol @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: Apache 2.0 +pragma solidity ^0.8.10; + +/// @author thirdweb + +interface ISharedMetadataBatch { + /// @notice Emitted when shared metadata is lazy minted. + event SharedMetadataUpdated( + bytes32 indexed id, + string name, + string description, + string imageURI, + string animationURI + ); + + /// @notice Emitted when shared metadata is deleted. + event SharedMetadataDeleted(bytes32 indexed id); + + /** + * @notice Structure for metadata shared across all tokens + * + * @param name Shared name of NFT in metadata + * @param description Shared description of NFT in metadata + * @param imageURI Shared URI of image to render for NFTs + * @param animationURI Shared URI of animation to render for NFTs + */ + struct SharedMetadataInfo { + string name; + string description; + string imageURI; + string animationURI; + } + + struct SharedMetadataWithId { + bytes32 id; + SharedMetadataInfo metadata; + } + + /** + * @notice Set shared metadata for NFTs + * @param metadata common metadata for all tokens + * @param id UID for the metadata + */ + function setSharedMetadata(SharedMetadataInfo calldata metadata, bytes32 id) external; + + /** + * @notice Delete shared metadata for NFTs + * @param id UID for the metadata + */ + function deleteSharedMetadata(bytes32 id) external; + + /** + * @notice Get all shared metadata + * @return metadata array of all shared metadata + */ + function getAllSharedMetadata() external view returns (SharedMetadataWithId[] memory metadata); +} diff --git a/contracts/extension/interface/ISignatureAction.sol b/contracts/extension/interface/ISignatureAction.sol new file mode 100644 index 000000000..dd98f5d49 --- /dev/null +++ b/contracts/extension/interface/ISignatureAction.sol @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +/** + * thirdweb's `SignatureAction` extension smart contract can be used with any base smart contract. It provides a generic + * payload struct that can be signed by an authorized wallet and verified by the contract. The bytes `data` field provided + * in the payload can be abi encoded <-> decoded to use `SignatureContract` for any authorized signature action. + */ + +interface ISignatureAction { + /** + * @notice The payload that must be signed by an authorized wallet. + * + * @param validityStartTimestamp The UNIX timestamp at and after which a signature is valid. + * @param validityEndTimestamp The UNIX timestamp at and after which a signature is invalid/expired. + * @param uid A unique non-repeatable ID for the payload. + * @param data Arbitrary bytes data to be used at the discretion of the contract. + */ + struct GenericRequest { + uint128 validityStartTimestamp; + uint128 validityEndTimestamp; + bytes32 uid; + bytes data; + } + + /// @notice Emitted when a payload is verified and executed. + event RequestExecuted(address indexed user, address indexed signer, GenericRequest _req); + + /** + * @notice Verfies that a payload is signed by an authorized wallet. + * + * @param req The payload signed by the authorized wallet. + * @param signature The signature produced by the authorized wallet signing the given payload. + * + * @return success Whether the payload is signed by the authorized wallet. + * @return signer The address of the signer. + */ + function verify( + GenericRequest calldata req, + bytes calldata signature + ) external view returns (bool success, address signer); +} diff --git a/contracts/extension/interface/ISignatureMintERC1155.sol b/contracts/extension/interface/ISignatureMintERC1155.sol new file mode 100644 index 000000000..9f283eff0 --- /dev/null +++ b/contracts/extension/interface/ISignatureMintERC1155.sol @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +/** + * The 'signature minting' mechanism used in thirdweb Token smart contracts is a way for a contract admin to authorize an external party's + * request to mint tokens on the admin's contract. + * + * At a high level, this means you can authorize some external party to mint tokens on your contract, and specify what exactly will be + * minted by that external party. + */ +interface ISignatureMintERC1155 { + /** + * @notice The body of a request to mint tokens. + * + * @param to The receiver of the tokens to mint. + * @param royaltyRecipient The recipient of the minted token's secondary sales royalties. (Not applicable for ERC20 tokens) + * @param royaltyBps The percentage of the minted token's secondary sales to take as royalties. (Not applicable for ERC20 tokens) + * @param primarySaleRecipient The recipient of the minted token's primary sales proceeds. + * @param tokenId The tokenId of the token to mint. (Only applicable for ERC1155 tokens) + * @param uri The metadata URI of the token to mint. (Not applicable for ERC20 tokens) + * @param quantity The quantity of tokens to mint. + * @param pricePerToken The price to pay per quantity of tokens minted. + * @param currency The currency in which to pay the price per token minted. + * @param validityStartTimestamp The unix timestamp after which the payload is valid. + * @param validityEndTimestamp The unix timestamp at which the payload expires. + * @param uid A unique identifier for the payload. + */ + struct MintRequest { + address to; + address royaltyRecipient; + uint256 royaltyBps; + address primarySaleRecipient; + uint256 tokenId; + string uri; + uint256 quantity; + uint256 pricePerToken; + address currency; + uint128 validityStartTimestamp; + uint128 validityEndTimestamp; + bytes32 uid; + } + + /// @dev Emitted when tokens are minted. + event TokensMintedWithSignature( + address indexed signer, + address indexed mintedTo, + uint256 indexed tokenIdMinted, + MintRequest mintRequest + ); + + /** + * @notice Verifies that a mint request is signed by an account holding + * MINTER_ROLE (at the time of the function call). + * + * @param req The payload / mint request. + * @param signature The signature produced by an account signing the mint request. + * + * returns (success, signer) Result of verification and the recovered address. + */ + function verify( + MintRequest calldata req, + bytes calldata signature + ) external view returns (bool success, address signer); + + /** + * @notice Mints tokens according to the provided mint request. + * + * @param req The payload / mint request. + * @param signature The signature produced by an account signing the mint request. + */ + function mintWithSignature( + MintRequest calldata req, + bytes calldata signature + ) external payable returns (address signer); +} diff --git a/contracts/extension/interface/ISignatureMintERC20.sol b/contracts/extension/interface/ISignatureMintERC20.sol new file mode 100644 index 000000000..63aabbc33 --- /dev/null +++ b/contracts/extension/interface/ISignatureMintERC20.sol @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +/** + * The 'signature minting' mechanism used in thirdweb Token smart contracts is a way for a contract admin to authorize an external party's + * request to mint tokens on the admin's contract. + * + * At a high level, this means you can authorize some external party to mint tokens on your contract, and specify what exactly will be + * minted by that external party. + */ +interface ISignatureMintERC20 { + /** + * @notice The body of a request to mint tokens. + * + * @param to The receiver of the tokens to mint. + * @param primarySaleRecipient The recipient of the minted token's primary sales proceeds. + * @param quantity The quantity of tokens to mint. + * @param pricePerToken The price to pay per quantity of tokens minted. + * @param currency The currency in which to pay the price per token minted. + * @param validityStartTimestamp The unix timestamp after which the payload is valid. + * @param validityEndTimestamp The unix timestamp at which the payload expires. + * @param uid A unique identifier for the payload. + */ + struct MintRequest { + address to; + address primarySaleRecipient; + uint256 quantity; + uint256 price; + address currency; + uint128 validityStartTimestamp; + uint128 validityEndTimestamp; + bytes32 uid; + } + + /// @dev Emitted when tokens are minted. + event TokensMintedWithSignature(address indexed signer, address indexed mintedTo, MintRequest mintRequest); + + /** + * @notice Verifies that a mint request is signed by an account holding + * MINTER_ROLE (at the time of the function call). + * + * @param req The payload / mint request. + * @param signature The signature produced by an account signing the mint request. + * + * returns (success, signer) Result of verification and the recovered address. + */ + function verify( + MintRequest calldata req, + bytes calldata signature + ) external view returns (bool success, address signer); + + /** + * @notice Mints tokens according to the provided mint request. + * + * @param req The payload / mint request. + * @param signature The signature produced by an account signing the mint request. + */ + function mintWithSignature( + MintRequest calldata req, + bytes calldata signature + ) external payable returns (address signer); +} diff --git a/contracts/extension/interface/ISignatureMintERC721.sol b/contracts/extension/interface/ISignatureMintERC721.sol new file mode 100644 index 000000000..0478adae1 --- /dev/null +++ b/contracts/extension/interface/ISignatureMintERC721.sol @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +/** + * The 'signature minting' mechanism used in thirdweb Token smart contracts is a way for a contract admin to authorize an external party's + * request to mint tokens on the admin's contract. + * + * At a high level, this means you can authorize some external party to mint tokens on your contract, and specify what exactly will be + * minted by that external party. + */ +interface ISignatureMintERC721 { + /** + * @notice The body of a request to mint tokens. + * + * @param to The receiver of the tokens to mint. + * @param royaltyRecipient The recipient of the minted token's secondary sales royalties. (Not applicable for ERC20 tokens) + * @param royaltyBps The percentage of the minted token's secondary sales to take as royalties. (Not applicable for ERC20 tokens) + * @param primarySaleRecipient The recipient of the minted token's primary sales proceeds. + * @param uri The metadata URI of the token to mint. (Not applicable for ERC20 tokens) + * @param quantity The quantity of tokens to mint. + * @param pricePerToken The price to pay per quantity of tokens minted. + * @param currency The currency in which to pay the price per token minted. + * @param validityStartTimestamp The unix timestamp after which the payload is valid. + * @param validityEndTimestamp The unix timestamp at which the payload expires. + * @param uid A unique identifier for the payload. + */ + struct MintRequest { + address to; + address royaltyRecipient; + uint256 royaltyBps; + address primarySaleRecipient; + string uri; + uint256 quantity; + uint256 pricePerToken; + address currency; + uint128 validityStartTimestamp; + uint128 validityEndTimestamp; + bytes32 uid; + } + + /// @dev Emitted when tokens are minted. + event TokensMintedWithSignature( + address indexed signer, + address indexed mintedTo, + uint256 indexed tokenIdMinted, + MintRequest mintRequest + ); + + /** + * @notice Verifies that a mint request is signed by an account holding + * MINTER_ROLE (at the time of the function call). + * + * @param req The payload / mint request. + * @param signature The signature produced by an account signing the mint request. + * + * returns (success, signer) Result of verification and the recovered address. + */ + function verify( + MintRequest calldata req, + bytes calldata signature + ) external view returns (bool success, address signer); + + /** + * @notice Mints tokens according to the provided mint request. + * + * @param req The payload / mint request. + * @param signature The signature produced by an account signing the mint request. + */ + function mintWithSignature( + MintRequest calldata req, + bytes calldata signature + ) external payable returns (address signer); +} diff --git a/contracts/extension/interface/IStaking1155.sol b/contracts/extension/interface/IStaking1155.sol new file mode 100644 index 000000000..27351e332 --- /dev/null +++ b/contracts/extension/interface/IStaking1155.sol @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/// @author thirdweb + +interface IStaking1155 { + /// @dev Emitted when tokens are staked. + event TokensStaked(address indexed staker, uint256 indexed tokenId, uint256 amount); + + /// @dev Emitted when a set of staked token-ids are withdrawn. + event TokensWithdrawn(address indexed staker, uint256 indexed tokenId, uint256 amount); + + /// @dev Emitted when a staker claims staking rewards. + event RewardsClaimed(address indexed staker, uint256 rewardAmount); + + /// @dev Emitted when contract admin updates timeUnit. + event UpdatedTimeUnit(uint256 indexed _tokenId, uint256 oldTimeUnit, uint256 newTimeUnit); + + /// @dev Emitted when contract admin updates rewardsPerUnitTime. + event UpdatedRewardsPerUnitTime( + uint256 indexed _tokenId, + uint256 oldRewardsPerUnitTime, + uint256 newRewardsPerUnitTime + ); + + /// @dev Emitted when contract admin updates timeUnit. + event UpdatedDefaultTimeUnit(uint256 oldTimeUnit, uint256 newTimeUnit); + + /// @dev Emitted when contract admin updates rewardsPerUnitTime. + event UpdatedDefaultRewardsPerUnitTime(uint256 oldRewardsPerUnitTime, uint256 newRewardsPerUnitTime); + + /** + * @notice Staker Info. + * + * @param amountStaked Total number of tokens staked by the staker. + * + * @param timeOfLastUpdate Last reward-update timestamp. + * + * @param unclaimedRewards Rewards accumulated but not claimed by user yet. + * + * @param conditionIdOflastUpdate Condition-Id when rewards were last updated for user. + */ + struct Staker { + uint64 conditionIdOflastUpdate; + uint64 amountStaked; + uint128 timeOfLastUpdate; + uint256 unclaimedRewards; + } + + /** + * @notice Staking Condition. + * + * @param timeUnit Unit of time specified in number of seconds. Can be set as 1 seconds, 1 days, 1 hours, etc. + * + * @param rewardsPerUnitTime Rewards accumulated per unit of time. + * + * @param startTimestamp Condition start timestamp. + * + * @param endTimestamp Condition end timestamp. + */ + struct StakingCondition { + uint80 timeUnit; + uint80 startTimestamp; + uint80 endTimestamp; + uint256 rewardsPerUnitTime; + } + + /** + * @notice Stake ERC721 Tokens. + * + * @param tokenId ERC1155 token-id to stake. + * @param amount Amount to stake. + */ + function stake(uint256 tokenId, uint64 amount) external; + + /** + * @notice Withdraw staked tokens. + * + * @param tokenId ERC1155 token-id to withdraw. + * @param amount Amount to withdraw. + */ + function withdraw(uint256 tokenId, uint64 amount) external; + + /** + * @notice Claim accumulated rewards. + * + * @param tokenId Staked token Id. + */ + function claimRewards(uint256 tokenId) external; + + /** + * @notice View amount staked and total rewards for a user. + * + * @param tokenId Staked token Id. + * @param staker Address for which to calculated rewards. + */ + function getStakeInfoForToken( + uint256 tokenId, + address staker + ) external view returns (uint256 _tokensStaked, uint256 _rewards); + + /** + * @notice View amount staked and total rewards for a user. + * + * @param staker Address for which to calculated rewards. + */ + function getStakeInfo( + address staker + ) external view returns (uint256[] memory _tokensStaked, uint256[] memory _tokenAmounts, uint256 _totalRewards); +} diff --git a/contracts/extension/interface/IStaking20.sol b/contracts/extension/interface/IStaking20.sol new file mode 100644 index 000000000..494a0d0a5 --- /dev/null +++ b/contracts/extension/interface/IStaking20.sol @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/// @author thirdweb + +interface IStaking20 { + /// @dev Emitted when tokens are staked. + event TokensStaked(address indexed staker, uint256 amount); + + /// @dev Emitted when a tokens are withdrawn. + event TokensWithdrawn(address indexed staker, uint256 amount); + + /// @dev Emitted when a staker claims staking rewards. + event RewardsClaimed(address indexed staker, uint256 rewardAmount); + + /// @dev Emitted when contract admin updates timeUnit. + event UpdatedTimeUnit(uint256 oldTimeUnit, uint256 newTimeUnit); + + /// @dev Emitted when contract admin updates rewardsPerUnitTime. + event UpdatedRewardRatio( + uint256 oldNumerator, + uint256 newNumerator, + uint256 oldDenominator, + uint256 newDenominator + ); + + /// @dev Emitted when contract admin updates minimum staking amount. + event UpdatedMinStakeAmount(uint256 oldAmount, uint256 newAmount); + + /** + * @notice Staker Info. + * + * @param amountStaked Total number of tokens staked by the staker. + * + * @param timeOfLastUpdate Last reward-update timestamp. + * + * @param unclaimedRewards Rewards accumulated but not claimed by user yet. + * + * @param conditionIdOflastUpdate Condition-Id when rewards were last updated for user. + */ + struct Staker { + uint128 timeOfLastUpdate; + uint64 conditionIdOflastUpdate; + uint256 amountStaked; + uint256 unclaimedRewards; + } + + /** + * @notice Staking Condition. + * + * @param timeUnit Unit of time specified in number of seconds. Can be set as 1 seconds, 1 days, 1 hours, etc. + * + * @param rewardRatioNumerator Rewards ratio is the number of reward tokens for a number of staked tokens, + * per unit of time. + * + * @param rewardRatioDenominator Rewards ratio is the number of reward tokens for a number of staked tokens, + * per unit of time. + * + * @param startTimestamp Condition start timestamp. + * + * @param endTimestamp Condition end timestamp. + */ + struct StakingCondition { + uint80 timeUnit; + uint80 startTimestamp; + uint80 endTimestamp; + uint256 rewardRatioNumerator; + uint256 rewardRatioDenominator; + } + + /** + * @notice Stake ERC721 Tokens. + * + * @param amount Amount to stake. + */ + function stake(uint256 amount) external payable; + + /** + * @notice Withdraw staked tokens. + * + * @param amount Amount to withdraw. + */ + function withdraw(uint256 amount) external; + + /** + * @notice Claim accumulated rewards. + * + */ + function claimRewards() external; + + /** + * @notice View amount staked and total rewards for a user. + * + * @param staker Address for which to calculated rewards. + */ + function getStakeInfo(address staker) external view returns (uint256 _tokensStaked, uint256 _rewards); +} diff --git a/contracts/extension/interface/IStaking721.sol b/contracts/extension/interface/IStaking721.sol new file mode 100644 index 000000000..21ef88b38 --- /dev/null +++ b/contracts/extension/interface/IStaking721.sol @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/// @author thirdweb + +interface IStaking721 { + /// @dev Emitted when a set of token-ids are staked. + event TokensStaked(address indexed staker, uint256[] indexed tokenIds); + + /// @dev Emitted when a set of staked token-ids are withdrawn. + event TokensWithdrawn(address indexed staker, uint256[] indexed tokenIds); + + /// @dev Emitted when a staker claims staking rewards. + event RewardsClaimed(address indexed staker, uint256 rewardAmount); + + /// @dev Emitted when contract admin updates timeUnit. + event UpdatedTimeUnit(uint256 oldTimeUnit, uint256 newTimeUnit); + + /// @dev Emitted when contract admin updates rewardsPerUnitTime. + event UpdatedRewardsPerUnitTime(uint256 oldRewardsPerUnitTime, uint256 newRewardsPerUnitTime); + + /** + * @notice Staker Info. + * + * @param amountStaked Total number of tokens staked by the staker. + * + * @param timeOfLastUpdate Last reward-update timestamp. + * + * @param unclaimedRewards Rewards accumulated but not claimed by user yet. + * + * @param conditionIdOflastUpdate Condition-Id when rewards were last updated for user. + */ + struct Staker { + uint64 amountStaked; + uint64 conditionIdOflastUpdate; + uint128 timeOfLastUpdate; + uint256 unclaimedRewards; + } + + /** + * @notice Staking Condition. + * + * @param timeUnit Unit of time specified in number of seconds. Can be set as 1 seconds, 1 days, 1 hours, etc. + * + * @param rewardsPerUnitTime Rewards accumulated per unit of time. + * + * @param startTimestamp Condition start timestamp. + * + * @param endTimestamp Condition end timestamp. + */ + struct StakingCondition { + uint256 timeUnit; + uint256 rewardsPerUnitTime; + uint256 startTimestamp; + uint256 endTimestamp; + } + + /** + * @notice Stake ERC721 Tokens. + * + * @param tokenIds List of tokens to stake. + */ + function stake(uint256[] calldata tokenIds) external; + + /** + * @notice Withdraw staked tokens. + * + * @param tokenIds List of tokens to withdraw. + */ + function withdraw(uint256[] calldata tokenIds) external; + + /** + * @notice Claim accumulated rewards. + */ + function claimRewards() external; + + /** + * @notice View amount staked and total rewards for a user. + * + * @param staker Address for which to calculated rewards. + */ + function getStakeInfo(address staker) external view returns (uint256[] memory _tokensStaked, uint256 _rewards); +} diff --git a/contracts/extension/interface/ITokenBundle.sol b/contracts/extension/interface/ITokenBundle.sol new file mode 100644 index 000000000..ee63551f5 --- /dev/null +++ b/contracts/extension/interface/ITokenBundle.sol @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +/** + * Group together arbitrary ERC20, ERC721 and ERC1155 tokens into a single bundle. + * + * The `Token` struct is a generic type that can describe any ERC20, ERC721 or ERC1155 token. + * The `Bundle` struct is a data structure to track a group/bundle of multiple assets i.e. ERC20, + * ERC721 and ERC1155 tokens, each described as a `Token`. + * + * Expressing tokens as the `Token` type, and grouping them as a `Bundle` allows for writing generic + * logic to handle any ERC20, ERC721 or ERC1155 tokens. + */ + +interface ITokenBundle { + /// @notice The type of assets that can be wrapped. + enum TokenType { + ERC20, + ERC721, + ERC1155 + } + + /** + * @notice A generic interface to describe any ERC20, ERC721 or ERC1155 token. + * + * @param assetContract The contract address of the asset. + * @param tokenType The token type (ERC20 / ERC721 / ERC1155) of the asset. + * @param tokenId The token Id of the asset, if the asset is an ERC721 / ERC1155 NFT. + * @param totalAmount The amount of the asset, if the asset is an ERC20 / ERC1155 fungible token. + */ + struct Token { + address assetContract; + TokenType tokenType; + uint256 tokenId; + uint256 totalAmount; + } + + /** + * @notice An internal data structure to track a group / bundle of multiple assets i.e. `Token`s. + * + * @param count The total number of assets i.e. `Token` in a bundle. + * @param uri The (metadata) URI assigned to the bundle created + * @param tokens Mapping from a UID -> to a unique asset i.e. `Token` in the bundle. + */ + struct BundleInfo { + uint256 count; + string uri; + mapping(uint256 => Token) tokens; + } +} diff --git a/contracts/extension/interface/plugin/IContext.sol b/contracts/extension/interface/plugin/IContext.sol new file mode 100644 index 000000000..96da340a1 --- /dev/null +++ b/contracts/extension/interface/plugin/IContext.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +interface IContext { + function _msgSender() external view returns (address sender); + + function _msgData() external view returns (bytes calldata); +} diff --git a/contracts/extension/interface/plugin/IPluginMap.sol b/contracts/extension/interface/plugin/IPluginMap.sol new file mode 100644 index 000000000..9053a06e6 --- /dev/null +++ b/contracts/extension/interface/plugin/IPluginMap.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/// @author thirdweb + +interface IPluginMap { + /** + * @notice An interface to describe a plug-in. + * + * @param functionSelector 4-byte function selector. + * @param functionSignature Function representation as a string. E.g. "transfer(address,address,uint256)" + * @param pluginAddress Address of the contract containing the function. + */ + struct Plugin { + bytes4 functionSelector; + string functionSignature; + address pluginAddress; + } + + /// @dev Emitted when a function selector is mapped to a particular plug-in smart contract, during construction of Map. + event PluginSet(bytes4 indexed functionSelector, string indexed functionSignature, address indexed pluginAddress); + + /// @dev Returns the plug-in contract for a given function. + function getPluginForFunction(bytes4 functionSelector) external view returns (address); + + /// @dev Returns all functions that are mapped to the given plug-in contract. + function getAllFunctionsOfPlugin(address pluginAddress) external view returns (bytes4[] memory); + + /// @dev Returns all plug-ins known by Map. + function getAllPlugins() external view returns (Plugin[] memory); +} diff --git a/contracts/extension/interface/plugin/IRouter.sol b/contracts/extension/interface/plugin/IRouter.sol new file mode 100644 index 000000000..0fe7a5670 --- /dev/null +++ b/contracts/extension/interface/plugin/IRouter.sol @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/// @author thirdweb + +import "./IPluginMap.sol"; + +interface IRouter is IPluginMap { + /// @dev Emitted when a functionality is added, or plugged-in. + event PluginAdded(bytes4 indexed functionSelector, address indexed pluginAddress); + + /// @dev Emitted when a functionality is updated or overridden. + event PluginUpdated( + bytes4 indexed functionSelector, + address indexed oldPluginAddress, + address indexed newPluginAddress + ); + + /// @dev Emitted when a functionality is removed. + event PluginRemoved(bytes4 indexed functionSelector, address indexed pluginAddress); + + /// @dev Add a new plugin to the contract. + function addPlugin(Plugin memory plugin) external; + + /// @dev Update / override an existing plugin. + function updatePlugin(Plugin memory plugin) external; + + /// @dev Remove an existing plugin from the contract. + function removePlugin(bytes4 functionSelector) external; +} diff --git a/contracts/extension/plugin/ContractMetadataLogic.sol b/contracts/extension/plugin/ContractMetadataLogic.sol new file mode 100644 index 000000000..724aeb936 --- /dev/null +++ b/contracts/extension/plugin/ContractMetadataLogic.sol @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "./ContractMetadataStorage.sol"; +import "../interface/IContractMetadata.sol"; + +/** + * @author thirdweb.com + * + * @title Contract Metadata + * @notice Thirdweb's `ContractMetadata` is a contract extension for any base contracts. It lets you set a metadata URI + * for you contract. + * Additionally, `ContractMetadata` is necessary for NFT contracts that want royalties to get distributed on OpenSea. + */ + +abstract contract ContractMetadataLogic is IContractMetadata { + /// @dev Returns the metadata URI of the contract. + function contractURI() public view returns (string memory) { + ContractMetadataStorage.Data storage data = ContractMetadataStorage.contractMetadataStorage(); + return data.contractURI; + } + + /** + * @notice Lets a contract admin set the URI for contract-level metadata. + * @dev Caller should be authorized to setup contractURI, e.g. contract admin. + * See {_canSetContractURI}. + * Emits {ContractURIUpdated Event}. + * + * @param _uri keccak256 hash of the role. e.g. keccak256("TRANSFER_ROLE") + */ + function setContractURI(string memory _uri) external override { + if (!_canSetContractURI()) { + revert("Not authorized"); + } + + _setupContractURI(_uri); + } + + /// @dev Lets a contract admin set the URI for contract-level metadata. + function _setupContractURI(string memory _uri) internal { + ContractMetadataStorage.Data storage data = ContractMetadataStorage.contractMetadataStorage(); + string memory prevURI = data.contractURI; + data.contractURI = _uri; + + emit ContractURIUpdated(prevURI, _uri); + } + + /// @dev Returns whether contract metadata can be set in the given execution context. + function _canSetContractURI() internal view virtual returns (bool); +} diff --git a/contracts/extension/plugin/ContractMetadataStorage.sol b/contracts/extension/plugin/ContractMetadataStorage.sol new file mode 100644 index 000000000..c6d9bfb85 --- /dev/null +++ b/contracts/extension/plugin/ContractMetadataStorage.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +/** + * @author thirdweb.com + */ +library ContractMetadataStorage { + /// @custom:storage-location erc7201:contract.metadata.storage + /// @dev keccak256(abi.encode(uint256(keccak256("contract.metadata.storage")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 public constant CONTRACT_METADATA_STORAGE_POSITION = + 0x4bc804ba64359c0e35e5ed5d90ee596ecaa49a3a930ddcb1470ea0dd625da900; + + struct Data { + string contractURI; + } + + function contractMetadataStorage() internal pure returns (Data storage contractMetadataData) { + bytes32 position = CONTRACT_METADATA_STORAGE_POSITION; + assembly { + contractMetadataData.slot := position + } + } +} diff --git a/contracts/extension/plugin/ERC2771ContextConsumer.sol b/contracts/extension/plugin/ERC2771ContextConsumer.sol new file mode 100644 index 000000000..6e9c236c5 --- /dev/null +++ b/contracts/extension/plugin/ERC2771ContextConsumer.sol @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "./ERC2771ContextLogic.sol"; + +interface IERC2771Context { + function isTrustedForwarder(address forwarder) external view returns (bool); +} + +/** + * @dev Context variant with ERC2771 support. + */ +abstract contract ERC2771ContextConsumer { + function _msgSender() public view virtual returns (address sender) { + if (IERC2771Context(address(this)).isTrustedForwarder(msg.sender)) { + // The assembly code is more direct than the Solidity version using `abi.decode`. + assembly { + sender := shr(96, calldataload(sub(calldatasize(), 20))) + } + } else { + return msg.sender; + } + } + + function _msgData() public view virtual returns (bytes calldata) { + if (IERC2771Context(address(this)).isTrustedForwarder(msg.sender)) { + return msg.data[:msg.data.length - 20]; + } else { + return msg.data; + } + } +} diff --git a/contracts/extension/plugin/ERC2771ContextLogic.sol b/contracts/extension/plugin/ERC2771ContextLogic.sol new file mode 100644 index 000000000..c87d1bd6a --- /dev/null +++ b/contracts/extension/plugin/ERC2771ContextLogic.sol @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "./ERC2771ContextStorage.sol"; + +/** + * @dev Context variant with ERC2771 support. + */ +abstract contract ERC2771ContextLogic { + constructor(address[] memory trustedForwarder) { + ERC2771ContextStorage.Data storage data = ERC2771ContextStorage.erc2771ContextStorage(); + + for (uint256 i = 0; i < trustedForwarder.length; i++) { + data._trustedForwarder[trustedForwarder[i]] = true; + } + } + + function isTrustedForwarder(address forwarder) public view virtual returns (bool) { + ERC2771ContextStorage.Data storage data = ERC2771ContextStorage.erc2771ContextStorage(); + return data._trustedForwarder[forwarder]; + } + + function _msgSender() internal view virtual returns (address sender) { + if (isTrustedForwarder(msg.sender)) { + // The assembly code is more direct than the Solidity version using `abi.decode`. + assembly { + sender := shr(96, calldataload(sub(calldatasize(), 20))) + } + } else { + return msg.sender; + } + } + + function _msgData() internal view virtual returns (bytes calldata) { + if (isTrustedForwarder(msg.sender)) { + return msg.data[:msg.data.length - 20]; + } else { + return msg.data; + } + } +} diff --git a/contracts/extension/plugin/ERC2771ContextStorage.sol b/contracts/extension/plugin/ERC2771ContextStorage.sol new file mode 100644 index 000000000..884d8e728 --- /dev/null +++ b/contracts/extension/plugin/ERC2771ContextStorage.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +library ERC2771ContextStorage { + /// @custom:storage-location erc7201:erc2771.context.storage + /// @dev keccak256(abi.encode(uint256(keccak256("erc2771.context.storage")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 public constant ERC2771_CONTEXT_STORAGE_POSITION = + 0x82aadcdf5bea62fd30615b6c0754b644e71b6c1e8c55b71bb927ad005b504f00; + + struct Data { + mapping(address => bool) _trustedForwarder; + } + + function erc2771ContextStorage() internal pure returns (Data storage erc2771ContextData) { + bytes32 position = ERC2771_CONTEXT_STORAGE_POSITION; + assembly { + erc2771ContextData.slot := position + } + } +} diff --git a/contracts/extension/plugin/ERC2771ContextUpgradeableLogic.sol b/contracts/extension/plugin/ERC2771ContextUpgradeableLogic.sol new file mode 100644 index 000000000..f07deb38d --- /dev/null +++ b/contracts/extension/plugin/ERC2771ContextUpgradeableLogic.sol @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "./ERC2771ContextStorage.sol"; + +/** + * @dev Context variant with ERC2771 support. + */ +abstract contract ERC2771ContextUpgradeableLogic { + function __ERC2771Context_init(address[] memory trustedForwarder) internal { + __ERC2771Context_init_unchained(trustedForwarder); + } + + function __ERC2771Context_init_unchained(address[] memory trustedForwarder) internal { + ERC2771ContextStorage.Data storage data = ERC2771ContextStorage.erc2771ContextStorage(); + + for (uint256 i = 0; i < trustedForwarder.length; i++) { + data._trustedForwarder[trustedForwarder[i]] = true; + } + } + + function isTrustedForwarder(address forwarder) public view virtual returns (bool) { + ERC2771ContextStorage.Data storage data = ERC2771ContextStorage.erc2771ContextStorage(); + return data._trustedForwarder[forwarder]; + } + + function _msgSender() internal view virtual returns (address sender) { + if (isTrustedForwarder(msg.sender)) { + // The assembly code is more direct than the Solidity version using `abi.decode`. + assembly { + sender := shr(96, calldataload(sub(calldatasize(), 20))) + } + } else { + return msg.sender; + } + } + + function _msgData() internal view virtual returns (bytes calldata) { + if (isTrustedForwarder(msg.sender)) { + return msg.data[:msg.data.length - 20]; + } else { + return msg.data; + } + } +} diff --git a/contracts/extension/plugin/ERC2771ContextUpgradeableStorage.sol b/contracts/extension/plugin/ERC2771ContextUpgradeableStorage.sol new file mode 100644 index 000000000..f77bc4df8 --- /dev/null +++ b/contracts/extension/plugin/ERC2771ContextUpgradeableStorage.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +library ERC2771ContextUpgradeableStorage { + bytes32 public constant ERC2771_CONTEXT_UPGRADEABLE_STORAGE_POSITION = + keccak256("erc2771.context.upgradeable.storage"); + + struct Data { + mapping(address => bool) _trustedForwarder; + } + + function erc2771ContextUpgradeableStorage() internal pure returns (Data storage erc2771ContextData) { + bytes32 position = ERC2771_CONTEXT_UPGRADEABLE_STORAGE_POSITION; + assembly { + erc2771ContextData.slot := position + } + } +} diff --git a/contracts/extension/plugin/PermissionsEnumerableLogic.sol b/contracts/extension/plugin/PermissionsEnumerableLogic.sol new file mode 100644 index 000000000..c307d5c8a --- /dev/null +++ b/contracts/extension/plugin/PermissionsEnumerableLogic.sol @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "./PermissionsEnumerableStorage.sol"; +import "./PermissionsLogic.sol"; + +/** + * @author thirdweb.com + * + * @title PermissionsEnumerable + * @dev This contracts provides extending-contracts with role-based access control mechanisms. + * Also provides interfaces to view all members with a given role, and total count of members. + */ +contract PermissionsEnumerableLogic is IPermissionsEnumerable, PermissionsLogic { + /** + * @notice Returns the role-member from a list of members for a role, + * at a given index. + * @dev Returns `member` who has `role`, at `index` of role-members list. + * See struct {RoleMembers}, and mapping {roleMembers} + * + * @param role keccak256 hash of the role. e.g. keccak256("TRANSFER_ROLE") + * @param index Index in list of current members for the role. + * + * @return member Address of account that has `role` + */ + function getRoleMember(bytes32 role, uint256 index) external view override returns (address member) { + PermissionsEnumerableStorage.Data storage data = PermissionsEnumerableStorage.permissionsEnumerableStorage(); + uint256 currentIndex = data.roleMembers[role].index; + uint256 check; + + for (uint256 i = 0; i < currentIndex; i += 1) { + if (data.roleMembers[role].members[i] != address(0)) { + if (check == index) { + member = data.roleMembers[role].members[i]; + return member; + } + check += 1; + } else if (hasRole(role, address(0)) && i == data.roleMembers[role].indexOf[address(0)]) { + check += 1; + } + } + } + + /** + * @notice Returns total number of accounts that have a role. + * @dev Returns `count` of accounts that have `role`. + * See struct {RoleMembers}, and mapping {roleMembers} + * + * @param role keccak256 hash of the role. e.g. keccak256("TRANSFER_ROLE") + * + * @return count Total number of accounts that have `role` + */ + function getRoleMemberCount(bytes32 role) external view override returns (uint256 count) { + PermissionsEnumerableStorage.Data storage data = PermissionsEnumerableStorage.permissionsEnumerableStorage(); + uint256 currentIndex = data.roleMembers[role].index; + + for (uint256 i = 0; i < currentIndex; i += 1) { + if (data.roleMembers[role].members[i] != address(0)) { + count += 1; + } + } + if (hasRole(role, address(0))) { + count += 1; + } + } + + /// @dev Revokes `role` from `account`, and removes `account` from {roleMembers} + /// See {_removeMember} + function _revokeRole(bytes32 role, address account) internal override { + super._revokeRole(role, account); + _removeMember(role, account); + } + + /// @dev Grants `role` to `account`, and adds `account` to {roleMembers} + /// See {_addMember} + function _setupRole(bytes32 role, address account) internal override { + super._setupRole(role, account); + _addMember(role, account); + } + + /// @dev adds `account` to {roleMembers}, for `role` + function _addMember(bytes32 role, address account) internal { + PermissionsEnumerableStorage.Data storage data = PermissionsEnumerableStorage.permissionsEnumerableStorage(); + uint256 idx = data.roleMembers[role].index; + data.roleMembers[role].index += 1; + + data.roleMembers[role].members[idx] = account; + data.roleMembers[role].indexOf[account] = idx; + } + + /// @dev removes `account` from {roleMembers}, for `role` + function _removeMember(bytes32 role, address account) internal { + PermissionsEnumerableStorage.Data storage data = PermissionsEnumerableStorage.permissionsEnumerableStorage(); + uint256 idx = data.roleMembers[role].indexOf[account]; + + delete data.roleMembers[role].members[idx]; + delete data.roleMembers[role].indexOf[account]; + } +} diff --git a/contracts/extension/plugin/PermissionsEnumerableStorage.sol b/contracts/extension/plugin/PermissionsEnumerableStorage.sol new file mode 100644 index 000000000..e8c889e3d --- /dev/null +++ b/contracts/extension/plugin/PermissionsEnumerableStorage.sol @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "../interface/IPermissionsEnumerable.sol"; + +/** + * @author thirdweb.com + */ +library PermissionsEnumerableStorage { + /// @custom:storage-location erc7201:permissions.enumerable.storage + /// @dev keccak256(abi.encode(uint256(keccak256("permissions.enumerable.storage")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 public constant PERMISSIONS_ENUMERABLE_STORAGE_POSITION = + 0x1ea2ed6cf13bfad376ba49bede85b663fef0b40eac197c5ac8e6f92ec4076100; + + /** + * @notice A data structure to store data of members for a given role. + * + * @param index Current index in the list of accounts that have a role. + * @param members map from index => address of account that has a role + * @param indexOf map from address => index which the account has. + */ + struct RoleMembers { + uint256 index; + mapping(uint256 => address) members; + mapping(address => uint256) indexOf; + } + + struct Data { + /// @dev map from keccak256 hash of a role to its members' data. See {RoleMembers}. + mapping(bytes32 => RoleMembers) roleMembers; + } + + function permissionsEnumerableStorage() internal pure returns (Data storage permissionsEnumerableData) { + bytes32 position = PERMISSIONS_ENUMERABLE_STORAGE_POSITION; + assembly { + permissionsEnumerableData.slot := position + } + } +} diff --git a/contracts/extension/plugin/PermissionsLogic.sol b/contracts/extension/plugin/PermissionsLogic.sol new file mode 100644 index 000000000..9206870bd --- /dev/null +++ b/contracts/extension/plugin/PermissionsLogic.sol @@ -0,0 +1,182 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "../interface/IPermissions.sol"; +import "./PermissionsStorage.sol"; +import "../../lib/Strings.sol"; + +/** + * @author thirdweb.com + * + * @title Permissions + * @dev This contracts provides extending-contracts with role-based access control mechanisms + */ +contract PermissionsLogic is IPermissions { + /// @dev Default admin role for all roles. Only accounts with this role can grant/revoke other roles. + bytes32 public constant DEFAULT_ADMIN_ROLE = 0x00; + + /// @dev Modifier that checks if an account has the specified role; reverts otherwise. + modifier onlyRole(bytes32 role) { + _checkRole(role, _msgSender()); + _; + } + + /** + * @notice Checks whether an account has a particular role. + * @dev Returns `true` if `account` has been granted `role`. + * + * @param role keccak256 hash of the role. e.g. keccak256("TRANSFER_ROLE") + * @param account Address of the account for which the role is being checked. + */ + function hasRole(bytes32 role, address account) public view override returns (bool) { + PermissionsStorage.Data storage data = PermissionsStorage.permissionsStorage(); + return data._hasRole[role][account]; + } + + /** + * @notice Checks whether an account has a particular role; + * role restrictions can be swtiched on and off. + * + * @dev Returns `true` if `account` has been granted `role`. + * Role restrictions can be swtiched on and off: + * - If address(0) has ROLE, then the ROLE restrictions + * don't apply. + * - If address(0) does not have ROLE, then the ROLE + * restrictions will apply. + * + * @param role keccak256 hash of the role. e.g. keccak256("TRANSFER_ROLE") + * @param account Address of the account for which the role is being checked. + */ + function hasRoleWithSwitch(bytes32 role, address account) public view returns (bool) { + PermissionsStorage.Data storage data = PermissionsStorage.permissionsStorage(); + if (!data._hasRole[role][address(0)]) { + return data._hasRole[role][account]; + } + + return true; + } + + /** + * @notice Returns the admin role that controls the specified role. + * @dev See {grantRole} and {revokeRole}. + * To change a role's admin, use {_setRoleAdmin}. + * + * @param role keccak256 hash of the role. e.g. keccak256("TRANSFER_ROLE") + */ + function getRoleAdmin(bytes32 role) external view override returns (bytes32) { + PermissionsStorage.Data storage data = PermissionsStorage.permissionsStorage(); + return data._getRoleAdmin[role]; + } + + /** + * @notice Grants a role to an account, if not previously granted. + * @dev Caller must have admin role for the `role`. + * Emits {RoleGranted Event}. + * + * @param role keccak256 hash of the role. e.g. keccak256("TRANSFER_ROLE") + * @param account Address of the account to which the role is being granted. + */ + function grantRole(bytes32 role, address account) public virtual override { + PermissionsStorage.Data storage data = PermissionsStorage.permissionsStorage(); + _checkRole(data._getRoleAdmin[role], _msgSender()); + if (data._hasRole[role][account]) { + revert("Can only grant to non holders"); + } + _setupRole(role, account); + } + + /** + * @notice Revokes role from an account. + * @dev Caller must have admin role for the `role`. + * Emits {RoleRevoked Event}. + * + * @param role keccak256 hash of the role. e.g. keccak256("TRANSFER_ROLE") + * @param account Address of the account from which the role is being revoked. + */ + function revokeRole(bytes32 role, address account) public virtual override { + PermissionsStorage.Data storage data = PermissionsStorage.permissionsStorage(); + _checkRole(data._getRoleAdmin[role], _msgSender()); + _revokeRole(role, account); + } + + /** + * @notice Revokes role from the account. + * @dev Caller must have the `role`, with caller being the same as `account`. + * Emits {RoleRevoked Event}. + * + * @param role keccak256 hash of the role. e.g. keccak256("TRANSFER_ROLE") + * @param account Address of the account from which the role is being revoked. + */ + function renounceRole(bytes32 role, address account) public virtual override { + if (_msgSender() != account) { + revert("Can only renounce for self"); + } + _revokeRole(role, account); + } + + /// @dev Sets `adminRole` as `role`'s admin role. + function _setRoleAdmin(bytes32 role, bytes32 adminRole) internal virtual { + PermissionsStorage.Data storage data = PermissionsStorage.permissionsStorage(); + bytes32 previousAdminRole = data._getRoleAdmin[role]; + data._getRoleAdmin[role] = adminRole; + emit RoleAdminChanged(role, previousAdminRole, adminRole); + } + + /// @dev Sets up `role` for `account` + function _setupRole(bytes32 role, address account) internal virtual { + PermissionsStorage.Data storage data = PermissionsStorage.permissionsStorage(); + data._hasRole[role][account] = true; + emit RoleGranted(role, account, _msgSender()); + } + + /// @dev Revokes `role` from `account` + function _revokeRole(bytes32 role, address account) internal virtual { + PermissionsStorage.Data storage data = PermissionsStorage.permissionsStorage(); + _checkRole(role, account); + delete data._hasRole[role][account]; + emit RoleRevoked(role, account, _msgSender()); + } + + /// @dev Checks `role` for `account`. Reverts with a message including the required role. + function _checkRole(bytes32 role, address account) internal view virtual { + PermissionsStorage.Data storage data = PermissionsStorage.permissionsStorage(); + if (!data._hasRole[role][account]) { + revert( + string( + abi.encodePacked( + "Permissions: account ", + Strings.toHexString(uint160(account), 20), + " is missing role ", + Strings.toHexString(uint256(role), 32) + ) + ) + ); + } + } + + /// @dev Checks `role` for `account`. Reverts with a message including the required role. + function _checkRoleWithSwitch(bytes32 role, address account) internal view virtual { + if (!hasRoleWithSwitch(role, account)) { + revert( + string( + abi.encodePacked( + "Permissions: account ", + Strings.toHexString(uint160(account), 20), + " is missing role ", + Strings.toHexString(uint256(role), 32) + ) + ) + ); + } + } + + function _msgSender() internal view virtual returns (address sender) { + return msg.sender; + } + + function _msgData() internal view virtual returns (bytes calldata) { + return msg.data; + } +} diff --git a/contracts/extension/plugin/PermissionsStorage.sol b/contracts/extension/plugin/PermissionsStorage.sol new file mode 100644 index 000000000..bedc341c0 --- /dev/null +++ b/contracts/extension/plugin/PermissionsStorage.sol @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +/** + * @author thirdweb.com + */ +library PermissionsStorage { + /// @custom:storage-location erc7201:permissions.storage + /// @dev keccak256(abi.encode(uint256(keccak256("permissions.storage")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 public constant PERMISSIONS_STORAGE_POSITION = + 0x0a7b0f5c59907924802379ebe98cdc23e2ee7820f63d30126e10b3752010e500; + + struct Data { + /// @dev Map from keccak256 hash of a role => a map from address => whether address has role. + mapping(bytes32 => mapping(address => bool)) _hasRole; + /// @dev Map from keccak256 hash of a role to role admin. See {getRoleAdmin}. + mapping(bytes32 => bytes32) _getRoleAdmin; + } + + function permissionsStorage() internal pure returns (Data storage permissionsData) { + bytes32 position = PERMISSIONS_STORAGE_POSITION; + assembly { + permissionsData.slot := position + } + } +} diff --git a/contracts/extension/plugin/PlatformFeeLogic.sol b/contracts/extension/plugin/PlatformFeeLogic.sol new file mode 100644 index 000000000..99dfe9aef --- /dev/null +++ b/contracts/extension/plugin/PlatformFeeLogic.sol @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "./PlatformFeeStorage.sol"; +import "../interface/IPlatformFee.sol"; + +/** + * @author thirdweb.com + * + * @title Platform Fee + * @notice Thirdweb's `PlatformFee` is a contract extension to be used with any base contract. It exposes functions for setting and reading + * the recipient of platform fee and the platform fee basis points, and lets the inheriting contract perform conditional logic + * that uses information about platform fees, if desired. + */ + +abstract contract PlatformFeeLogic is IPlatformFee { + /// @dev Returns the platform fee recipient and bps. + function getPlatformFeeInfo() public view override returns (address, uint16) { + PlatformFeeStorage.Data storage data = PlatformFeeStorage.platformFeeStorage(); + return (data.platformFeeRecipient, uint16(data.platformFeeBps)); + } + + /** + * @notice Updates the platform fee recipient and bps. + * @dev Caller should be authorized to set platform fee info. + * See {_canSetPlatformFeeInfo}. + * Emits {PlatformFeeInfoUpdated Event}; See {_setupPlatformFeeInfo}. + * + * @param _platformFeeRecipient Address to be set as new platformFeeRecipient. + * @param _platformFeeBps Updated platformFeeBps. + */ + function setPlatformFeeInfo(address _platformFeeRecipient, uint256 _platformFeeBps) external override { + if (!_canSetPlatformFeeInfo()) { + revert("Not authorized"); + } + _setupPlatformFeeInfo(_platformFeeRecipient, _platformFeeBps); + } + + /// @dev Lets a contract admin update the platform fee recipient and bps + function _setupPlatformFeeInfo(address _platformFeeRecipient, uint256 _platformFeeBps) internal { + PlatformFeeStorage.Data storage data = PlatformFeeStorage.platformFeeStorage(); + if (_platformFeeBps > 10_000) { + revert("Exceeds max bps"); + } + + data.platformFeeBps = uint16(_platformFeeBps); + data.platformFeeRecipient = _platformFeeRecipient; + + emit PlatformFeeInfoUpdated(_platformFeeRecipient, _platformFeeBps); + } + + /// @dev Returns whether platform fee info can be set in the given execution context. + function _canSetPlatformFeeInfo() internal view virtual returns (bool); +} diff --git a/contracts/extension/plugin/PlatformFeeStorage.sol b/contracts/extension/plugin/PlatformFeeStorage.sol new file mode 100644 index 000000000..93395a481 --- /dev/null +++ b/contracts/extension/plugin/PlatformFeeStorage.sol @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +/** + * @author thirdweb.com + */ +library PlatformFeeStorage { + /// @custom:storage-location erc7201:platform.fee.storage + /// @dev keccak256(abi.encode(uint256(keccak256("platform.fee.storage")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 public constant PLATFORM_FEE_STORAGE_POSITION = + 0xc0c34308b4a2f4c5ee9af8ba82541cfb3c33b076d1fd05c65f9ce7060c64c400; + + struct Data { + /// @dev The address that receives all platform fees from all sales. + address platformFeeRecipient; + /// @dev The % of primary sales collected as platform fees. + uint16 platformFeeBps; + } + + function platformFeeStorage() internal pure returns (Data storage platformFeeData) { + bytes32 position = PLATFORM_FEE_STORAGE_POSITION; + assembly { + platformFeeData.slot := position + } + } +} diff --git a/contracts/extension/plugin/PluginMap.sol b/contracts/extension/plugin/PluginMap.sol new file mode 100644 index 000000000..d4b89be3b --- /dev/null +++ b/contracts/extension/plugin/PluginMap.sol @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "../interface/plugin/IPluginMap.sol"; +import "../../external-deps/openzeppelin/utils/EnumerableSet.sol"; + +/** + * @author thirdweb.com + */ +contract PluginMap is IPluginMap { + using EnumerableSet for EnumerableSet.Bytes32Set; + + EnumerableSet.Bytes32Set private allSelectors; + + mapping(address => EnumerableSet.Bytes32Set) private selectorsForPlugin; + mapping(bytes4 => Plugin) private pluginForSelector; + + /*/////////////////////////////////////////////////////////////// + Constructor + initializer logic + //////////////////////////////////////////////////////////////*/ + + constructor(Plugin[] memory _pluginsToAdd) { + uint256 len = _pluginsToAdd.length; + for (uint256 i = 0; i < len; i += 1) { + _setPlugin(_pluginsToAdd[i]); + } + } + + /*/////////////////////////////////////////////////////////////// + View functions + //////////////////////////////////////////////////////////////*/ + + /// @dev View address of the plugged-in functionality contract for a given function signature. + function getPluginForFunction(bytes4 _selector) public view returns (address) { + address _pluginAddress = pluginForSelector[_selector].pluginAddress; + require(_pluginAddress != address(0), "Map: No plugin available for selector"); + + return _pluginAddress; + } + + /// @dev View all funtionality as list of function signatures. + function getAllFunctionsOfPlugin(address _pluginAddress) external view returns (bytes4[] memory registered) { + uint256 len = selectorsForPlugin[_pluginAddress].length(); + registered = new bytes4[](len); + + for (uint256 i = 0; i < len; i += 1) { + registered[i] = bytes4(selectorsForPlugin[_pluginAddress].at(i)); + } + } + + /// @dev View all funtionality existing on the contract. + function getAllPlugins() external view returns (Plugin[] memory _plugins) { + uint256 len = allSelectors.length(); + _plugins = new Plugin[](len); + + for (uint256 i = 0; i < len; i += 1) { + bytes4 selector = bytes4(allSelectors.at(i)); + _plugins[i] = pluginForSelector[selector]; + } + } + + /*/////////////////////////////////////////////////////////////// + Internal functions + //////////////////////////////////////////////////////////////*/ + + /// @dev Add functionality to the contract. + function _setPlugin(Plugin memory _plugin) internal { + require(allSelectors.add(bytes32(_plugin.functionSelector)), "Map: Selector exists"); + require( + _plugin.functionSelector == bytes4(keccak256(abi.encodePacked(_plugin.functionSignature))), + "Map: Incorrect selector" + ); + + pluginForSelector[_plugin.functionSelector] = _plugin; + selectorsForPlugin[_plugin.pluginAddress].add(bytes32(_plugin.functionSelector)); + + emit PluginSet(_plugin.functionSelector, _plugin.functionSignature, _plugin.pluginAddress); + } +} diff --git a/contracts/extension/plugin/ReentrancyGuardLogic.sol b/contracts/extension/plugin/ReentrancyGuardLogic.sol new file mode 100644 index 000000000..a0936c67a --- /dev/null +++ b/contracts/extension/plugin/ReentrancyGuardLogic.sol @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: Apache 2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "./ReentrancyGuardStorage.sol"; + +/** + * @dev Contract module that helps prevent reentrant calls to a function. + * + * Inheriting from `ReentrancyGuard` will make the {nonReentrant} modifier + * available, which can be applied to functions to make sure there are no nested + * (reentrant) calls to them. + * + * Note that because there is a single `nonReentrant` guard, functions marked as + * `nonReentrant` may not call one another. This can be worked around by making + * those functions `private`, and then adding `external` `nonReentrant` entry + * points to them. + * + * TIP: If you would like to learn more about reentrancy and alternative ways + * to protect against it, check out our blog post + * https://blog.openzeppelin.com/reentrancy-after-istanbul/[Reentrancy After Istanbul]. + */ +abstract contract ReentrancyGuardLogic { + // Booleans are more expensive than uint256 or any type that takes up a full + // word because each write operation emits an extra SLOAD to first read the + // slot's contents, replace the bits taken up by the boolean, and then write + // back. This is the compiler's defense against contract upgrades and + // pointer aliasing, and it cannot be disabled. + + // The values being non-zero value makes deployment a bit more expensive, + // but in exchange the refund on every call to nonReentrant will be lower in + // amount. Since refunds are capped to a percentage of the total + // transaction's gas, it is best to keep them low in cases like this one, to + // increase the likelihood of the full refund coming into effect. + uint256 private constant _NOT_ENTERED = 1; + uint256 private constant _ENTERED = 2; + + function __ReentrancyGuard_init() internal { + __ReentrancyGuard_init_unchained(); + } + + function __ReentrancyGuard_init_unchained() internal { + ReentrancyGuardStorage.Data storage data = ReentrancyGuardStorage.reentrancyGuardStorage(); + data._status = _NOT_ENTERED; + } + + /** + * @dev Prevents a contract from calling itself, directly or indirectly. + * Calling a `nonReentrant` function from another `nonReentrant` + * function is not supported. It is possible to prevent this from happening + * by making the `nonReentrant` function external, and making it call a + * `private` function that does the actual work. + */ + modifier nonReentrant() { + ReentrancyGuardStorage.Data storage data = ReentrancyGuardStorage.reentrancyGuardStorage(); + // On the first call to nonReentrant, _notEntered will be true + require(data._status != _ENTERED, "ReentrancyGuard: reentrant call"); + + // Any calls to nonReentrant after this point will fail + data._status = _ENTERED; + + _; + + // By storing the original value once again, a refund is triggered (see + // https://eips.ethereum.org/EIPS/eip-2200) + data._status = _NOT_ENTERED; + } +} diff --git a/contracts/extension/plugin/ReentrancyGuardStorage.sol b/contracts/extension/plugin/ReentrancyGuardStorage.sol new file mode 100644 index 000000000..866747c05 --- /dev/null +++ b/contracts/extension/plugin/ReentrancyGuardStorage.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +library ReentrancyGuardStorage { + /// @custom:storage-location erc7201:reentrancy.guard.storage + /// @dev keccak256(abi.encode(uint256(keccak256("reentrancy.guard.storage")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 public constant REENTRANCY_GUARD_STORAGE_POSITION = + 0x1d281c488dae143b6ea4122e80c65059929950b9c32f17fc57be22089d9c3b00; + + struct Data { + uint256 _status; + } + + function reentrancyGuardStorage() internal pure returns (Data storage reentrancyGuardData) { + bytes32 position = REENTRANCY_GUARD_STORAGE_POSITION; + assembly { + reentrancyGuardData.slot := position + } + } +} diff --git a/contracts/extension/plugin/Router.sol b/contracts/extension/plugin/Router.sol new file mode 100644 index 000000000..cf8b9d911 --- /dev/null +++ b/contracts/extension/plugin/Router.sol @@ -0,0 +1,270 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "../interface/plugin/IRouter.sol"; +import "../Multicall.sol"; +import "../../eip/ERC165.sol"; +import "../../external-deps/openzeppelin/utils/EnumerableSet.sol"; + +/** + * @author thirdweb.com + */ +library RouterStorage { + /// @custom:storage-location erc7201:router.storage + /// @dev keccak256(abi.encode(uint256(keccak256("router.storage")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 public constant ROUTER_STORAGE_POSITION = + 0x012ef321094c8c682aa635dfdfcd754624a7473f08ad6ac415bb7f35eb12a100; + + struct Data { + EnumerableSet.Bytes32Set allSelectors; + mapping(address => EnumerableSet.Bytes32Set) selectorsForPlugin; + mapping(bytes4 => IPluginMap.Plugin) pluginForSelector; + } + + function routerStorage() internal pure returns (Data storage routerData) { + bytes32 position = ROUTER_STORAGE_POSITION; + assembly { + routerData.slot := position + } + } +} + +abstract contract Router is Multicall, ERC165, IRouter { + using EnumerableSet for EnumerableSet.Bytes32Set; + /*/////////////////////////////////////////////////////////////// + State variables + //////////////////////////////////////////////////////////////*/ + + address public immutable pluginMap; + + /*/////////////////////////////////////////////////////////////// + Constructor + initializer logic + //////////////////////////////////////////////////////////////*/ + + constructor(address _pluginMap) { + pluginMap = _pluginMap; + } + + /*/////////////////////////////////////////////////////////////// + ERC 165 + //////////////////////////////////////////////////////////////*/ + + /** + * @dev See {IERC165-supportsInterface}. + */ + function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) { + return interfaceId == type(IRouter).interfaceId || super.supportsInterface(interfaceId); + } + + /*/////////////////////////////////////////////////////////////// + Generic contract logic + //////////////////////////////////////////////////////////////*/ + + fallback() external payable virtual { + address _pluginAddress = _getPluginForFunction(msg.sig); + if (_pluginAddress == address(0)) { + _pluginAddress = IPluginMap(pluginMap).getPluginForFunction(msg.sig); + } + _delegate(_pluginAddress); + } + + receive() external payable virtual {} + + function _delegate(address implementation) internal virtual { + assembly { + // Copy msg.data. We take full control of memory in this inline assembly + // block because it will not return to Solidity code. We overwrite the + // Solidity scratch pad at memory position 0. + calldatacopy(0, 0, calldatasize()) + + // Call the implementation. + // out and outsize are 0 because we don't know the size yet. + let result := delegatecall(gas(), implementation, 0, calldatasize(), 0, 0) + + // Copy the returned data. + returndatacopy(0, 0, returndatasize()) + + switch result + // delegatecall returns 0 on error. + case 0 { + revert(0, returndatasize()) + } + default { + return(0, returndatasize()) + } + } + } + + /*/////////////////////////////////////////////////////////////// + External functions + //////////////////////////////////////////////////////////////*/ + + /// @dev Add functionality to the contract. + function addPlugin(Plugin memory _plugin) external { + require(_canSetPlugin(), "Router: Not authorized"); + + _addPlugin(_plugin); + } + + /// @dev Update or override existing functionality. + function updatePlugin(Plugin memory _plugin) external { + require(_canSetPlugin(), "Map: Not authorized"); + + _updatePlugin(_plugin); + } + + /// @dev Remove existing functionality from the contract. + function removePlugin(bytes4 _selector) external { + require(_canSetPlugin(), "Map: Not authorized"); + + _removePlugin(_selector); + } + + /*/////////////////////////////////////////////////////////////// + View functions + //////////////////////////////////////////////////////////////*/ + + /// @dev View address of the plugged-in functionality contract for a given function signature. + function getPluginForFunction(bytes4 _selector) public view returns (address) { + address pluginAddress = _getPluginForFunction(_selector); + + return pluginAddress != address(0) ? pluginAddress : IPluginMap(pluginMap).getPluginForFunction(_selector); + } + + /// @dev View all funtionality as list of function signatures. + function getAllFunctionsOfPlugin(address _pluginAddress) external view returns (bytes4[] memory registered) { + RouterStorage.Data storage data = RouterStorage.routerStorage(); + + EnumerableSet.Bytes32Set storage selectorsForPlugin = data.selectorsForPlugin[_pluginAddress]; + bytes4[] memory defaultSelectors = IPluginMap(pluginMap).getAllFunctionsOfPlugin(_pluginAddress); + + uint256 len = defaultSelectors.length; + uint256 count = selectorsForPlugin.length() + defaultSelectors.length; + + for (uint256 i = 0; i < len; i += 1) { + if (selectorsForPlugin.contains(defaultSelectors[i])) { + count -= 1; + defaultSelectors[i] = bytes4(0); + } + } + + registered = new bytes4[](count); + uint256 index; + + for (uint256 i = 0; i < len; i += 1) { + if (defaultSelectors[i] != bytes4(0)) { + registered[index++] = defaultSelectors[i]; + } + } + + len = selectorsForPlugin.length(); + for (uint256 i = 0; i < len; i += 1) { + registered[index++] = bytes4(data.selectorsForPlugin[_pluginAddress].at(i)); + } + } + + /// @dev View all funtionality existing on the contract. + function getAllPlugins() external view returns (Plugin[] memory registered) { + RouterStorage.Data storage data = RouterStorage.routerStorage(); + + EnumerableSet.Bytes32Set storage overrideSelectors = data.allSelectors; + Plugin[] memory defaultPlugins = IPluginMap(pluginMap).getAllPlugins(); + + uint256 overrideSelectorsLen = overrideSelectors.length(); + uint256 defaultPluginsLen = defaultPlugins.length; + + uint256 totalCount = overrideSelectorsLen + defaultPluginsLen; + + for (uint256 i = 0; i < overrideSelectorsLen; i += 1) { + for (uint256 j = 0; j < defaultPluginsLen; j += 1) { + if (bytes4(overrideSelectors.at(i)) == defaultPlugins[j].functionSelector) { + totalCount -= 1; + defaultPlugins[j].functionSelector = bytes4(0); + } + } + } + + registered = new Plugin[](totalCount); + uint256 index; + + for (uint256 i = 0; i < defaultPluginsLen; i += 1) { + if (defaultPlugins[i].functionSelector != bytes4(0)) { + registered[index] = defaultPlugins[i]; + index += 1; + } + } + + for (uint256 i = 0; i < overrideSelectorsLen; i += 1) { + registered[index] = data.pluginForSelector[bytes4(overrideSelectors.at(i))]; + index += 1; + } + } + + /*/////////////////////////////////////////////////////////////// + Internal functions + //////////////////////////////////////////////////////////////*/ + + /// @dev View address of the plugged-in functionality contract for a given function signature. + function _getPluginForFunction(bytes4 _selector) public view returns (address) { + RouterStorage.Data storage data = RouterStorage.routerStorage(); + address _pluginAddress = data.pluginForSelector[_selector].pluginAddress; + + return _pluginAddress; + } + + /// @dev Add functionality to the contract. + function _addPlugin(Plugin memory _plugin) internal { + RouterStorage.Data storage data = RouterStorage.routerStorage(); + + // Revert: default plugin exists for function; use updatePlugin instead. + try IPluginMap(pluginMap).getPluginForFunction(_plugin.functionSelector) returns (address) { + revert("Router: default plugin exists for function."); + } catch { + require(data.allSelectors.add(bytes32(_plugin.functionSelector)), "Router: plugin exists for function."); + } + + require( + _plugin.functionSelector == bytes4(keccak256(abi.encodePacked(_plugin.functionSignature))), + "Router: fn selector and signature mismatch." + ); + + data.pluginForSelector[_plugin.functionSelector] = _plugin; + data.selectorsForPlugin[_plugin.pluginAddress].add(bytes32(_plugin.functionSelector)); + + emit PluginAdded(_plugin.functionSelector, _plugin.pluginAddress); + } + + /// @dev Update or override existing functionality. + function _updatePlugin(Plugin memory _plugin) internal { + address currentPlugin = getPluginForFunction(_plugin.functionSelector); + require( + _plugin.functionSelector == bytes4(keccak256(abi.encodePacked(_plugin.functionSignature))), + "Router: fn selector and signature mismatch." + ); + + RouterStorage.Data storage data = RouterStorage.routerStorage(); + data.allSelectors.add(bytes32(_plugin.functionSelector)); + data.pluginForSelector[_plugin.functionSelector] = _plugin; + data.selectorsForPlugin[currentPlugin].remove(bytes32(_plugin.functionSelector)); + data.selectorsForPlugin[_plugin.pluginAddress].add(bytes32(_plugin.functionSelector)); + + emit PluginUpdated(_plugin.functionSelector, currentPlugin, _plugin.pluginAddress); + } + + /// @dev Remove existing functionality from the contract. + function _removePlugin(bytes4 _selector) internal { + RouterStorage.Data storage data = RouterStorage.routerStorage(); + address currentPlugin = _getPluginForFunction(_selector); + require(currentPlugin != address(0), "Router: No plugin available for selector"); + + delete data.pluginForSelector[_selector]; + data.allSelectors.remove(_selector); + data.selectorsForPlugin[currentPlugin].remove(bytes32(_selector)); + + emit PluginRemoved(_selector, currentPlugin); + } + + function _canSetPlugin() internal view virtual returns (bool); +} diff --git a/contracts/extension/plugin/RouterImmutable.sol b/contracts/extension/plugin/RouterImmutable.sol new file mode 100644 index 000000000..b2e326eeb --- /dev/null +++ b/contracts/extension/plugin/RouterImmutable.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "./Router.sol"; + +/** + * @author thirdweb.com + */ +contract RouterImmutable is Router { + /*/////////////////////////////////////////////////////////////// + Constructor + initializer logic + //////////////////////////////////////////////////////////////*/ + + constructor(address _pluginMap) Router(_pluginMap) {} + + /*/////////////////////////////////////////////////////////////// + Internal functions + //////////////////////////////////////////////////////////////*/ + + /// @dev Returns whether plug-in can be set in the given execution context. + function _canSetPlugin() internal pure override returns (bool) { + return false; + } +} diff --git a/contracts/extension/plugin/RoyaltyPayments.sol b/contracts/extension/plugin/RoyaltyPayments.sol new file mode 100644 index 000000000..fc353376c --- /dev/null +++ b/contracts/extension/plugin/RoyaltyPayments.sol @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "../interface/IRoyaltyPayments.sol"; +import "../interface/IRoyaltyEngineV1.sol"; +import { IERC2981 } from "../../eip/interface/IERC2981.sol"; + +library RoyaltyPaymentsStorage { + /// @custom:storage-location erc7201:royalty.payments.storage + /// @dev keccak256(abi.encode(uint256(keccak256("royalty.payments.storage")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 public constant ROYALTY_PAYMENTS_STORAGE_POSITION = + 0xc802b338f3fb784853cf3c808df5ff08335200e394ea2c687d12571a91045000; + + struct Data { + /// @dev The address of RoyaltyEngineV1, replacing the one set during construction. + address royaltyEngineAddressOverride; + } + + function royaltyPaymentsStorage() internal pure returns (Data storage royaltyPaymentsData) { + bytes32 position = ROYALTY_PAYMENTS_STORAGE_POSITION; + assembly { + royaltyPaymentsData.slot := position + } + } +} + +/** + * @author thirdweb.com + * + * @title Royalty Payments + * @notice Thirdweb's `RoyaltyPayments` is a contract extension to be used with a marketplace contract. + * It exposes functions for fetching royalty settings for a token. + * It Supports RoyaltyEngineV1 and RoyaltyRegistry by manifold.xyz. + */ + +abstract contract RoyaltyPaymentsLogic is IRoyaltyPayments { + // solhint-disable-next-line var-name-mixedcase + address immutable ROYALTY_ENGINE_ADDRESS; + + constructor(address _royaltyEngineAddress) { + // allow address(0) in case RoyaltyEngineV1 not present on a network + require( + _royaltyEngineAddress == address(0) || + IERC165(_royaltyEngineAddress).supportsInterface(type(IRoyaltyEngineV1).interfaceId), + "Doesn't support IRoyaltyEngineV1 interface" + ); + + ROYALTY_ENGINE_ADDRESS = _royaltyEngineAddress; + } + + /** + * Get the royalty for a given token (address, id) and value amount. Does not cache the bps/amounts. Caches the spec for a given token address + * + * @param tokenAddress - The address of the token + * @param tokenId - The id of the token + * @param value - The value you wish to get the royalty of + * + * returns Two arrays of equal length, royalty recipients and the corresponding amount each recipient should get + */ + function getRoyalty( + address tokenAddress, + uint256 tokenId, + uint256 value + ) external returns (address payable[] memory recipients, uint256[] memory amounts) { + address royaltyEngineAddress = getRoyaltyEngineAddress(); + + if (royaltyEngineAddress == address(0)) { + try IERC2981(tokenAddress).royaltyInfo(tokenId, value) returns (address recipient, uint256 amount) { + require(amount < value, "Invalid royalty amount"); + + recipients = new address payable[](1); + amounts = new uint256[](1); + recipients[0] = payable(recipient); + amounts[0] = amount; + } catch {} + } else { + (recipients, amounts) = IRoyaltyEngineV1(royaltyEngineAddress).getRoyalty(tokenAddress, tokenId, value); + } + } + + /** + * Set or override RoyaltyEngine address + * + * @param _royaltyEngineAddress - RoyaltyEngineV1 address + */ + function setRoyaltyEngine(address _royaltyEngineAddress) external { + if (!_canSetRoyaltyEngine()) { + revert("Not authorized"); + } + + require( + _royaltyEngineAddress != address(0) && + IERC165(_royaltyEngineAddress).supportsInterface(type(IRoyaltyEngineV1).interfaceId), + "Doesn't support IRoyaltyEngineV1 interface" + ); + + _setupRoyaltyEngine(_royaltyEngineAddress); + } + + /// @dev Returns original or overridden address for RoyaltyEngineV1 + function getRoyaltyEngineAddress() public view returns (address royaltyEngineAddress) { + RoyaltyPaymentsStorage.Data storage data = RoyaltyPaymentsStorage.royaltyPaymentsStorage(); + address royaltyEngineOverride = data.royaltyEngineAddressOverride; + royaltyEngineAddress = royaltyEngineOverride != address(0) ? royaltyEngineOverride : ROYALTY_ENGINE_ADDRESS; + } + + /// @dev Lets a contract admin update the royalty engine address + function _setupRoyaltyEngine(address _royaltyEngineAddress) internal { + RoyaltyPaymentsStorage.Data storage data = RoyaltyPaymentsStorage.royaltyPaymentsStorage(); + address currentAddress = data.royaltyEngineAddressOverride; + + data.royaltyEngineAddressOverride = _royaltyEngineAddress; + + emit RoyaltyEngineUpdated(currentAddress, _royaltyEngineAddress); + } + + /// @dev Returns whether royalty engine address can be set in the given execution context. + function _canSetRoyaltyEngine() internal view virtual returns (bool); +} diff --git a/contracts/extension/upgradeable/AccountPermissions.sol b/contracts/extension/upgradeable/AccountPermissions.sol new file mode 100644 index 000000000..63332489c --- /dev/null +++ b/contracts/extension/upgradeable/AccountPermissions.sol @@ -0,0 +1,253 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "../interface/IAccountPermissions.sol"; +import "../../external-deps/openzeppelin/utils/cryptography/EIP712.sol"; +import "../../external-deps/openzeppelin/utils/structs/EnumerableSet.sol"; + +library AccountPermissionsStorage { + /// @custom:storage-location erc7201:account.permissions.storage + /// @dev keccak256(abi.encode(uint256(keccak256("account.permissions.storage")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 public constant ACCOUNT_PERMISSIONS_STORAGE_POSITION = + 0x3181e78fc1b109bc611fd2406150bf06e33faa75f71cba12c3e1fd670f2def00; + + struct Data { + /// @dev The set of all admins of the wallet. + EnumerableSet.AddressSet allAdmins; + /// @dev The set of all signers with permission to use the account. + EnumerableSet.AddressSet allSigners; + /// @dev Map from address => whether the address is an admin. + mapping(address => bool) isAdmin; + /// @dev Map from signer address => active restrictions for that signer. + mapping(address => IAccountPermissions.SignerPermissionsStatic) signerPermissions; + /// @dev Map from signer address => approved target the signer can call using the account contract. + mapping(address => EnumerableSet.AddressSet) approvedTargets; + /// @dev Mapping from a signed request UID => whether the request is processed. + mapping(bytes32 => bool) executed; + } + + function data() internal pure returns (Data storage data_) { + bytes32 position = ACCOUNT_PERMISSIONS_STORAGE_POSITION; + assembly { + data_.slot := position + } + } +} + +abstract contract AccountPermissions is IAccountPermissions, EIP712 { + using ECDSA for bytes32; + using EnumerableSet for EnumerableSet.AddressSet; + + bytes32 private constant TYPEHASH = + keccak256( + "SignerPermissionRequest(address signer,uint8 isAdmin,address[] approvedTargets,uint256 nativeTokenLimitPerTransaction,uint128 permissionStartTimestamp,uint128 permissionEndTimestamp,uint128 reqValidityStartTimestamp,uint128 reqValidityEndTimestamp,bytes32 uid)" + ); + + function _onlyAdmin() internal virtual { + require(isAdmin(msg.sender), "!admin"); + } + + /*/////////////////////////////////////////////////////////////// + External functions + //////////////////////////////////////////////////////////////*/ + + /// @notice Sets the permissions for a given signer. + function setPermissionsForSigner(SignerPermissionRequest calldata _req, bytes calldata _signature) external { + address targetSigner = _req.signer; + + require( + _req.reqValidityStartTimestamp <= block.timestamp && block.timestamp < _req.reqValidityEndTimestamp, + "!period" + ); + + (bool success, address signer) = verifySignerPermissionRequest(_req, _signature); + require(success, "!sig"); + + _accountPermissionsStorage().executed[_req.uid] = true; + + //isAdmin > 0, set admin or remove admin + if (_req.isAdmin > 0) { + //isAdmin = 1, set admin + //isAdmin > 1, remove admin + bool _isAdmin = _req.isAdmin == 1; + + _setAdmin(targetSigner, _isAdmin); + return; + } + + require(!isAdmin(targetSigner), "admin"); + + _accountPermissionsStorage().allSigners.add(targetSigner); + + _accountPermissionsStorage().signerPermissions[targetSigner] = SignerPermissionsStatic( + _req.nativeTokenLimitPerTransaction, + _req.permissionStartTimestamp, + _req.permissionEndTimestamp + ); + + address[] memory currentTargets = _accountPermissionsStorage().approvedTargets[targetSigner].values(); + uint256 len = currentTargets.length; + + for (uint256 i = 0; i < len; i += 1) { + _accountPermissionsStorage().approvedTargets[targetSigner].remove(currentTargets[i]); + } + + len = _req.approvedTargets.length; + for (uint256 i = 0; i < len; i += 1) { + _accountPermissionsStorage().approvedTargets[targetSigner].add(_req.approvedTargets[i]); + } + + _afterSignerPermissionsUpdate(_req); + + emit SignerPermissionsUpdated(signer, targetSigner, _req); + } + + /*/////////////////////////////////////////////////////////////// + View functions + //////////////////////////////////////////////////////////////*/ + + /// @notice Returns whether the given account is an admin. + function isAdmin(address _account) public view virtual returns (bool) { + return _accountPermissionsStorage().isAdmin[_account]; + } + + /// @notice Returns whether the given account is an active signer on the account. + function isActiveSigner(address signer) public view returns (bool) { + SignerPermissionsStatic memory permissions = _accountPermissionsStorage().signerPermissions[signer]; + + return + permissions.startTimestamp <= block.timestamp && + block.timestamp < permissions.endTimestamp && + _accountPermissionsStorage().approvedTargets[signer].length() > 0; + } + + /// @notice Returns the restrictions under which a signer can use the smart wallet. + function getPermissionsForSigner(address signer) external view returns (SignerPermissions memory) { + SignerPermissionsStatic memory permissions = _accountPermissionsStorage().signerPermissions[signer]; + + return + SignerPermissions( + signer, + _accountPermissionsStorage().approvedTargets[signer].values(), + permissions.nativeTokenLimitPerTransaction, + permissions.startTimestamp, + permissions.endTimestamp + ); + } + + /// @dev Verifies that a request is signed by an authorized account. + function verifySignerPermissionRequest( + SignerPermissionRequest calldata req, + bytes calldata signature + ) public view virtual returns (bool success, address signer) { + signer = _recoverAddress(_encodeRequest(req), signature); + success = !_accountPermissionsStorage().executed[req.uid] && isAdmin(signer); + } + + /// @notice Returns all active and inactive signers of the account. + function getAllSigners() external view returns (SignerPermissions[] memory signers) { + address[] memory allSigners = _accountPermissionsStorage().allSigners.values(); + + uint256 len = allSigners.length; + signers = new SignerPermissions[](len); + for (uint256 i = 0; i < len; i += 1) { + address signer = allSigners[i]; + SignerPermissionsStatic memory permissions = _accountPermissionsStorage().signerPermissions[signer]; + + signers[i] = SignerPermissions( + signer, + _accountPermissionsStorage().approvedTargets[signer].values(), + permissions.nativeTokenLimitPerTransaction, + permissions.startTimestamp, + permissions.endTimestamp + ); + } + } + + /// @notice Returns all signers with active permissions to use the account. + function getAllActiveSigners() external view returns (SignerPermissions[] memory signers) { + address[] memory allSigners = _accountPermissionsStorage().allSigners.values(); + + uint256 len = allSigners.length; + uint256 numOfActiveSigners = 0; + + for (uint256 i = 0; i < len; i += 1) { + if (isActiveSigner(allSigners[i])) { + numOfActiveSigners++; + } else { + allSigners[i] = address(0); + } + } + + signers = new SignerPermissions[](numOfActiveSigners); + uint256 index = 0; + for (uint256 i = 0; i < len; i += 1) { + if (allSigners[i] != address(0)) { + address signer = allSigners[i]; + SignerPermissionsStatic memory permissions = _accountPermissionsStorage().signerPermissions[signer]; + + signers[index++] = SignerPermissions( + signer, + _accountPermissionsStorage().approvedTargets[signer].values(), + permissions.nativeTokenLimitPerTransaction, + permissions.startTimestamp, + permissions.endTimestamp + ); + } + } + } + + /// @notice Returns all admins of the account. + function getAllAdmins() external view returns (address[] memory) { + return _accountPermissionsStorage().allAdmins.values(); + } + + /*/////////////////////////////////////////////////////////////// + Internal functions + //////////////////////////////////////////////////////////////*/ + + /// @notice Runs after every `changeRole` run. + function _afterSignerPermissionsUpdate(SignerPermissionRequest calldata _req) internal virtual; + + /// @notice Makes the given account an admin. + function _setAdmin(address _account, bool _isAdmin) internal virtual { + _accountPermissionsStorage().isAdmin[_account] = _isAdmin; + + if (_isAdmin) { + _accountPermissionsStorage().allAdmins.add(_account); + } else { + _accountPermissionsStorage().allAdmins.remove(_account); + } + + emit AdminUpdated(_account, _isAdmin); + } + + /// @dev Returns the address of the signer of the request. + function _recoverAddress(bytes memory _encoded, bytes calldata _signature) internal view virtual returns (address) { + return _hashTypedDataV4(keccak256(_encoded)).recover(_signature); + } + + /// @dev Encodes a request for recovery of the signer in `recoverAddress`. + function _encodeRequest(SignerPermissionRequest calldata _req) internal pure virtual returns (bytes memory) { + return + abi.encode( + TYPEHASH, + _req.signer, + _req.isAdmin, + keccak256(abi.encodePacked(_req.approvedTargets)), + _req.nativeTokenLimitPerTransaction, + _req.permissionStartTimestamp, + _req.permissionEndTimestamp, + _req.reqValidityStartTimestamp, + _req.reqValidityEndTimestamp, + _req.uid + ); + } + + /// @dev Returns the AccountPermissions storage. + function _accountPermissionsStorage() internal pure returns (AccountPermissionsStorage.Data storage data) { + data = AccountPermissionsStorage.data(); + } +} diff --git a/contracts/extension/upgradeable/BatchMintMetadata.sol b/contracts/extension/upgradeable/BatchMintMetadata.sol new file mode 100644 index 000000000..8d4546033 --- /dev/null +++ b/contracts/extension/upgradeable/BatchMintMetadata.sol @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +library BatchMintMetadataStorage { + /// @custom:storage-location erc7201:batch.mint.metadata.storage + /// @dev keccak256(abi.encode(uint256(keccak256("batch.mint.metadata.storage")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 public constant BATCH_MINT_METADATA_STORAGE_POSITION = + 0xf5b99f0648d517803cfbd359284c3fd81ac54e1c89b4874d917ae042d05e8500; + + struct Data { + /// @dev Largest tokenId of each batch of tokens with the same baseURI. + uint256[] batchIds; + /// @dev Mapping from id of a batch of tokens => to base URI for the respective batch of tokens. + mapping(uint256 => string) baseURI; + /// @dev Mapping from id of a batch of tokens => to whether the base URI for the respective batch of tokens is frozen. + mapping(uint256 => bool) batchFrozen; + } + + function data() internal pure returns (Data storage data_) { + bytes32 position = BATCH_MINT_METADATA_STORAGE_POSITION; + assembly { + data_.slot := position + } + } +} + +/** + * @title Batch-mint Metadata + * @notice The `BatchMintMetadata` is a contract extension for any base NFT contract. It lets the smart contract + * using this extension set metadata for `n` number of NFTs all at once. This is enabled by storing a single + * base URI for a batch of `n` NFTs, where the metadata for each NFT in a relevant batch is `baseURI/tokenId`. + */ + +contract BatchMintMetadata { + /// @dev This event emits when the metadata of all tokens are frozen. + /// While not currently supported by marketplaces, this event allows + /// future indexing if desired. + event MetadataFrozen(); + + // @dev This event emits when the metadata of a range of tokens is updated. + /// So that the third-party platforms such as NFT market could + /// timely update the images and related attributes of the NFTs. + event BatchMetadataUpdate(uint256 _fromTokenId, uint256 _toTokenId); + + /** + * @notice Returns the count of batches of NFTs. + * @dev Each batch of tokens has an in ID and an associated `baseURI`. + * See {batchIds}. + */ + function getBaseURICount() public view returns (uint256) { + return _batchMintMetadataStorage().batchIds.length; + } + + /** + * @notice Returns the ID for the batch of tokens the given tokenId belongs to. + * @dev See {getBaseURICount}. + * @param _index ID of a token. + */ + function getBatchIdAtIndex(uint256 _index) public view returns (uint256) { + if (_index >= getBaseURICount()) { + revert("Invalid index"); + } + return _batchMintMetadataStorage().batchIds[_index]; + } + + /// @dev Returns the id for the batch of tokens the given tokenId belongs to. + function _getBatchId(uint256 _tokenId) internal view returns (uint256 batchId, uint256 index) { + uint256 numOfTokenBatches = getBaseURICount(); + uint256[] memory indices = _batchMintMetadataStorage().batchIds; + + for (uint256 i = 0; i < numOfTokenBatches; i += 1) { + if (_tokenId < indices[i]) { + index = i; + batchId = indices[i]; + + return (batchId, index); + } + } + + revert("Invalid tokenId"); + } + + /// @dev Returns the baseURI for a token. The intended metadata URI for the token is baseURI + tokenId. + function _getBaseURI(uint256 _tokenId) internal view returns (string memory) { + uint256 numOfTokenBatches = getBaseURICount(); + uint256[] memory indices = _batchMintMetadataStorage().batchIds; + + for (uint256 i = 0; i < numOfTokenBatches; i += 1) { + if (_tokenId < indices[i]) { + return _batchMintMetadataStorage().baseURI[indices[i]]; + } + } + revert("Invalid tokenId"); + } + + /// @dev returns the starting tokenId of a given batchId. + function _getBatchStartId(uint256 _batchID) internal view returns (uint256) { + uint256 numOfTokenBatches = getBaseURICount(); + uint256[] memory indices = _batchMintMetadataStorage().batchIds; + + for (uint256 i = 0; i < numOfTokenBatches; i++) { + if (_batchID == indices[i]) { + if (i > 0) { + return indices[i - 1]; + } + return 0; + } + } + revert("Invalid batchId"); + } + + /// @dev Sets the base URI for the batch of tokens with the given batchId. + function _setBaseURI(uint256 _batchId, string memory _baseURI) internal { + require(!_batchMintMetadataStorage().batchFrozen[_batchId], "Batch frozen"); + _batchMintMetadataStorage().baseURI[_batchId] = _baseURI; + emit BatchMetadataUpdate(_getBatchStartId(_batchId), _batchId); + } + + /// @dev Freezes the base URI for the batch of tokens with the given batchId. + function _freezeBaseURI(uint256 _batchId) internal { + string memory baseURIForBatch = _batchMintMetadataStorage().baseURI[_batchId]; + require(bytes(baseURIForBatch).length > 0, "Invalid batch"); + _batchMintMetadataStorage().batchFrozen[_batchId] = true; + emit MetadataFrozen(); + } + + /// @dev Mints a batch of tokenIds and associates a common baseURI to all those Ids. + function _batchMintMetadata( + uint256 _startId, + uint256 _amountToMint, + string memory _baseURIForTokens + ) internal returns (uint256 nextTokenIdToMint, uint256 batchId) { + batchId = _startId + _amountToMint; + nextTokenIdToMint = batchId; + + _batchMintMetadataStorage().batchIds.push(batchId); + _batchMintMetadataStorage().baseURI[batchId] = _baseURIForTokens; + } + + /// @dev Returns the BatchMintMetadata storage. + function _batchMintMetadataStorage() internal pure returns (BatchMintMetadataStorage.Data storage data) { + data = BatchMintMetadataStorage.data(); + } +} diff --git a/contracts/extension/upgradeable/BurnToClaim.sol b/contracts/extension/upgradeable/BurnToClaim.sol new file mode 100644 index 000000000..8d3736062 --- /dev/null +++ b/contracts/extension/upgradeable/BurnToClaim.sol @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import { ERC1155Burnable } from "@openzeppelin/contracts/token/ERC1155/extensions/ERC1155Burnable.sol"; +import { ERC721Burnable } from "@openzeppelin/contracts/token/ERC721/extensions/ERC721Burnable.sol"; + +import "../../eip/interface/IERC1155.sol"; +import "../../eip/interface/IERC721.sol"; + +import "../interface/IBurnToClaim.sol"; + +library BurnToClaimStorage { + /// @custom:storage-location erc7201:burn.to.claim.storage + /// @dev keccak256(abi.encode(uint256(keccak256("burn.to.claim.storage")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 public constant BURN_TO_CLAIM_STORAGE_POSITION = + 0x6f0d20bed2d5528732497d5a17ac45087a6175b2a140eebe2a39ab447d7ad400; + + struct Data { + IBurnToClaim.BurnToClaimInfo burnToClaimInfo; + } + + function data() internal pure returns (Data storage data_) { + bytes32 position = BURN_TO_CLAIM_STORAGE_POSITION; + assembly { + data_.slot := position + } + } +} + +abstract contract BurnToClaim is IBurnToClaim { + /// @notice Returns the confugration for burning tokens to claim new tokens. + function getBurnToClaimInfo() public view returns (BurnToClaimInfo memory) { + return _burnToClaimStorage().burnToClaimInfo; + } + + /// @notice Sets the configuration for burning tokens to claim new tokens. + function setBurnToClaimInfo(BurnToClaimInfo calldata _burnToClaimInfo) external virtual { + require(_canSetBurnToClaim(), "Not authorized."); + require(_burnToClaimInfo.originContractAddress != address(0), "Origin contract not set."); + require(_burnToClaimInfo.currency != address(0), "Currency not set."); + + _burnToClaimStorage().burnToClaimInfo = _burnToClaimInfo; + } + + /// @notice Verifies an attempt to burn tokens to claim new tokens. + function verifyBurnToClaim(address _tokenOwner, uint256 _tokenId, uint256 _quantity) public view virtual { + BurnToClaimInfo memory _burnToClaimInfo = getBurnToClaimInfo(); + + if (_burnToClaimInfo.tokenType == IBurnToClaim.TokenType.ERC721) { + require(_quantity == 1, "Invalid amount"); + require(IERC721(_burnToClaimInfo.originContractAddress).ownerOf(_tokenId) == _tokenOwner, "!Owner"); + } else if (_burnToClaimInfo.tokenType == IBurnToClaim.TokenType.ERC1155) { + uint256 _eligible1155TokenId = _burnToClaimInfo.tokenId; + + require(_tokenId == _eligible1155TokenId, "Invalid token Id"); + require( + IERC1155(_burnToClaimInfo.originContractAddress).balanceOf(_tokenOwner, _tokenId) >= _quantity, + "!Balance" + ); + } + } + + /// @dev Burns tokens to claim new tokens. + function _burnTokensOnOrigin(address _tokenOwner, uint256 _tokenId, uint256 _quantity) internal virtual { + BurnToClaimInfo memory _burnToClaimInfo = getBurnToClaimInfo(); + + if (_burnToClaimInfo.tokenType == IBurnToClaim.TokenType.ERC721) { + ERC721Burnable(_burnToClaimInfo.originContractAddress).burn(_tokenId); + } else if (_burnToClaimInfo.tokenType == IBurnToClaim.TokenType.ERC1155) { + ERC1155Burnable(_burnToClaimInfo.originContractAddress).burn(_tokenOwner, _tokenId, _quantity); + } + } + + /// @dev Returns the BurnToClaimStorage storage. + function _burnToClaimStorage() internal pure returns (BurnToClaimStorage.Data storage data) { + data = BurnToClaimStorage.data(); + } + + /// @dev Returns whether the caller can set the burn to claim configuration. + function _canSetBurnToClaim() internal view virtual returns (bool); +} diff --git a/contracts/extension/upgradeable/ContractMetadata.sol b/contracts/extension/upgradeable/ContractMetadata.sol new file mode 100644 index 000000000..09ef5cb08 --- /dev/null +++ b/contracts/extension/upgradeable/ContractMetadata.sol @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "../interface/IContractMetadata.sol"; + +/** + * @author thirdweb.com + * + * @title Contract Metadata + * @notice Thirdweb's `ContractMetadata` is a contract extension for any base contracts. It lets you set a metadata URI + * for you contract. + * Additionally, `ContractMetadata` is necessary for NFT contracts that want royalties to get distributed on OpenSea. + */ + +library ContractMetadataStorage { + /// @custom:storage-location erc7201:contract.metadata.storage + /// @dev keccak256(abi.encode(uint256(keccak256("contract.metadata.storage")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 public constant CONTRACT_METADATA_STORAGE_POSITION = + 0x4bc804ba64359c0e35e5ed5d90ee596ecaa49a3a930ddcb1470ea0dd625da900; + + struct Data { + /// @notice Returns the contract metadata URI. + string contractURI; + } + + function data() internal pure returns (Data storage data_) { + bytes32 position = CONTRACT_METADATA_STORAGE_POSITION; + assembly { + data_.slot := position + } + } +} + +abstract contract ContractMetadata is IContractMetadata { + /** + * @notice Lets a contract admin set the URI for contract-level metadata. + * @dev Caller should be authorized to setup contractURI, e.g. contract admin. + * See {_canSetContractURI}. + * Emits {ContractURIUpdated Event}. + * + * @param _uri keccak256 hash of the role. e.g. keccak256("TRANSFER_ROLE") + */ + function setContractURI(string memory _uri) external override { + if (!_canSetContractURI()) { + revert("Not authorized"); + } + + _setupContractURI(_uri); + } + + /// @dev Lets a contract admin set the URI for contract-level metadata. + function _setupContractURI(string memory _uri) internal { + string memory prevURI = _contractMetadataStorage().contractURI; + _contractMetadataStorage().contractURI = _uri; + + emit ContractURIUpdated(prevURI, _uri); + } + + /// @notice Returns the contract metadata URI. + function contractURI() public view virtual override returns (string memory) { + return _contractMetadataStorage().contractURI; + } + + /// @dev Returns the AccountPermissions storage. + function _contractMetadataStorage() internal pure returns (ContractMetadataStorage.Data storage data) { + data = ContractMetadataStorage.data(); + } + + /// @dev Returns whether contract metadata can be set in the given execution context. + function _canSetContractURI() internal view virtual returns (bool); +} diff --git a/contracts/extension/upgradeable/DelayedReveal.sol b/contracts/extension/upgradeable/DelayedReveal.sol new file mode 100644 index 000000000..d36a4922c --- /dev/null +++ b/contracts/extension/upgradeable/DelayedReveal.sol @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "../interface/IDelayedReveal.sol"; + +library DelayedRevealStorage { + /// @custom:storage-location erc7201:delayed.reveal.storage + ///@dev keccak256(abi.encode(uint256(keccak256("delayed.reveal.storage")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 public constant DELAYED_REVEAL_STORAGE_POSITION = + 0x29cbb6a3768b42f407b01945994a37861bf5a2179c5dea5be7e378415e755100; + + struct Data { + /// @dev Mapping from tokenId of a batch of tokens => to delayed reveal data. + mapping(uint256 => bytes) encryptedData; + } + + function data() internal pure returns (Data storage data_) { + bytes32 position = DELAYED_REVEAL_STORAGE_POSITION; + assembly { + data_.slot := position + } + } +} + +/** + * @title Delayed Reveal + * @notice Thirdweb's `DelayedReveal` is a contract extension for base NFT contracts. It lets you create batches of + * 'delayed-reveal' NFTs. You can learn more about the usage of delayed reveal NFTs here - https://blog.thirdweb.com/delayed-reveal-nfts + */ + +abstract contract DelayedReveal is IDelayedReveal { + /// @dev Mapping from tokenId of a batch of tokens => to delayed reveal data. + function encryptedData(uint256 _tokenId) public view returns (bytes memory) { + return _delayedRevealStorage().encryptedData[_tokenId]; + } + + /// @dev Sets the delayed reveal data for a batchId. + function _setEncryptedData(uint256 _batchId, bytes memory _encryptedData) internal { + _delayedRevealStorage().encryptedData[_batchId] = _encryptedData; + } + + /** + * @notice Returns revealed URI for a batch of NFTs. + * @dev Reveal encrypted base URI for `_batchId` with caller/admin's `_key` used for encryption. + * Reverts if there's no encrypted URI for `_batchId`. + * See {encryptDecrypt}. + * + * @param _batchId ID of the batch for which URI is being revealed. + * @param _key Secure key used by caller/admin for encryption of baseURI. + * + * @return revealedURI Decrypted base URI. + */ + function getRevealURI(uint256 _batchId, bytes calldata _key) public view returns (string memory revealedURI) { + bytes memory dataForBatch = _delayedRevealStorage().encryptedData[_batchId]; + if (dataForBatch.length == 0) { + revert("Nothing to reveal"); + } + + (bytes memory encryptedURI, bytes32 provenanceHash) = abi.decode(dataForBatch, (bytes, bytes32)); + + revealedURI = string(encryptDecrypt(encryptedURI, _key)); + + require(keccak256(abi.encodePacked(revealedURI, _key, block.chainid)) == provenanceHash, "Incorrect key"); + } + + /** + * @notice Encrypt/decrypt data on chain. + * @dev Encrypt/decrypt given `data` with `key`. Uses inline assembly. + * See: https://ethereum.stackexchange.com/questions/69825/decrypt-message-on-chain + * + * @param data Bytes of data to encrypt/decrypt. + * @param key Secure key used by caller for encryption/decryption. + * + * @return result Output after encryption/decryption of given data. + */ + function encryptDecrypt(bytes memory data, bytes calldata key) public pure override returns (bytes memory result) { + // Store data length on stack for later use + uint256 length = data.length; + + // solhint-disable-next-line no-inline-assembly + assembly { + // Set result to free memory pointer + result := mload(0x40) + // Increase free memory pointer by lenght + 32 + mstore(0x40, add(add(result, length), 32)) + // Set result length + mstore(result, length) + } + + // Iterate over the data stepping by 32 bytes + for (uint256 i = 0; i < length; i += 32) { + // Generate hash of the key and offset + bytes32 hash = keccak256(abi.encodePacked(key, i)); + + bytes32 chunk; + // solhint-disable-next-line no-inline-assembly + assembly { + // Read 32-bytes data chunk + chunk := mload(add(data, add(i, 32))) + } + // XOR the chunk with hash + chunk ^= hash; + // solhint-disable-next-line no-inline-assembly + assembly { + // Write 32-byte encrypted chunk + mstore(add(result, add(i, 32)), chunk) + } + } + } + + /** + * @notice Returns whether the relvant batch of NFTs is subject to a delayed reveal. + * @dev Returns `true` if `_batchId`'s base URI is encrypted. + * @param _batchId ID of a batch of NFTs. + */ + function isEncryptedBatch(uint256 _batchId) public view returns (bool) { + return _delayedRevealStorage().encryptedData[_batchId].length > 0; + } + + /// @dev Returns the DelayedReveal storage. + function _delayedRevealStorage() internal pure returns (DelayedRevealStorage.Data storage data) { + data = DelayedRevealStorage.data(); + } +} diff --git a/contracts/extension/upgradeable/Drop.sol b/contracts/extension/upgradeable/Drop.sol new file mode 100644 index 000000000..4d701a204 --- /dev/null +++ b/contracts/extension/upgradeable/Drop.sol @@ -0,0 +1,278 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "../interface/IDrop.sol"; +import "../../lib/MerkleProof.sol"; + +library DropStorage { + /// @custom:storage-location erc7201:extension.manager.storage + bytes32 public constant DROP_STORAGE_POSITION = + keccak256(abi.encode(uint256(keccak256("drop.storage")) - 1)) & ~bytes32(uint256(0xff)); + + struct Data { + /// @dev The active conditions for claiming tokens. + IDrop.ClaimConditionList claimCondition; + } + + function data() internal pure returns (Data storage data_) { + bytes32 position = DROP_STORAGE_POSITION; + assembly { + data_.slot := position + } + } +} + +abstract contract Drop is IDrop { + function claimCondition() public view returns (uint256, uint256) { + return (_dropStorage().claimCondition.currentStartId, _dropStorage().claimCondition.count); + } + + /*/////////////////////////////////////////////////////////////// + Drop logic + //////////////////////////////////////////////////////////////*/ + + /// @dev Lets an account claim tokens. + function claim( + address _receiver, + uint256 _quantity, + address _currency, + uint256 _pricePerToken, + AllowlistProof calldata _allowlistProof, + bytes memory _data + ) public payable virtual override { + _beforeClaim(_receiver, _quantity, _currency, _pricePerToken, _allowlistProof, _data); + + uint256 activeConditionId = getActiveClaimConditionId(); + + verifyClaim(activeConditionId, _dropMsgSender(), _quantity, _currency, _pricePerToken, _allowlistProof); + + // Update contract state. + _dropStorage().claimCondition.conditions[activeConditionId].supplyClaimed += _quantity; + _dropStorage().claimCondition.supplyClaimedByWallet[activeConditionId][_dropMsgSender()] += _quantity; + + // If there's a price, collect price. + _collectPriceOnClaim(address(0), _quantity, _currency, _pricePerToken); + + // Mint the relevant tokens to claimer. + uint256 startTokenId = _transferTokensOnClaim(_receiver, _quantity); + + emit TokensClaimed(activeConditionId, _dropMsgSender(), _receiver, startTokenId, _quantity); + + _afterClaim(_receiver, _quantity, _currency, _pricePerToken, _allowlistProof, _data); + } + + /// @dev Lets a contract admin set claim conditions. + function setClaimConditions( + ClaimCondition[] calldata _conditions, + bool _resetClaimEligibility + ) external virtual override { + if (!_canSetClaimConditions()) { + revert("Not authorized"); + } + + uint256 existingStartIndex = _dropStorage().claimCondition.currentStartId; + uint256 existingPhaseCount = _dropStorage().claimCondition.count; + + /** + * The mapping `supplyClaimedByWallet` uses a claim condition's UID as a key. + * + * If `_resetClaimEligibility == true`, we assign completely new UIDs to the claim + * conditions in `_conditions`, effectively resetting the restrictions on claims expressed + * by `supplyClaimedByWallet`. + */ + uint256 newStartIndex = existingStartIndex; + if (_resetClaimEligibility) { + newStartIndex = existingStartIndex + existingPhaseCount; + } + + _dropStorage().claimCondition.count = _conditions.length; + _dropStorage().claimCondition.currentStartId = newStartIndex; + + uint256 lastConditionStartTimestamp; + for (uint256 i = 0; i < _conditions.length; i++) { + require(i == 0 || lastConditionStartTimestamp < _conditions[i].startTimestamp, "ST"); + + uint256 supplyClaimedAlready = _dropStorage().claimCondition.conditions[newStartIndex + i].supplyClaimed; + if (supplyClaimedAlready > _conditions[i].maxClaimableSupply) { + revert("max supply claimed"); + } + + _dropStorage().claimCondition.conditions[newStartIndex + i] = _conditions[i]; + _dropStorage().claimCondition.conditions[newStartIndex + i].supplyClaimed = supplyClaimedAlready; + + lastConditionStartTimestamp = _conditions[i].startTimestamp; + } + + /** + * Gas refunds (as much as possible) + * + * If `_resetClaimEligibility == true`, we assign completely new UIDs to the claim + * conditions in `_conditions`. So, we delete claim conditions with UID < `newStartIndex`. + * + * If `_resetClaimEligibility == false`, and there are more existing claim conditions + * than in `_conditions`, we delete the existing claim conditions that don't get replaced + * by the conditions in `_conditions`. + */ + if (_resetClaimEligibility) { + for (uint256 i = existingStartIndex; i < newStartIndex; i++) { + delete _dropStorage().claimCondition.conditions[i]; + } + } else { + if (existingPhaseCount > _conditions.length) { + for (uint256 i = _conditions.length; i < existingPhaseCount; i++) { + delete _dropStorage().claimCondition.conditions[newStartIndex + i]; + } + } + } + + emit ClaimConditionsUpdated(_conditions, _resetClaimEligibility); + } + + /// @dev Checks a request to claim NFTs against the active claim condition's criteria. + function verifyClaim( + uint256 _conditionId, + address _claimer, + uint256 _quantity, + address _currency, + uint256 _pricePerToken, + AllowlistProof calldata _allowlistProof + ) public view virtual returns (bool isOverride) { + ClaimCondition memory currentClaimPhase = _dropStorage().claimCondition.conditions[_conditionId]; + uint256 claimLimit = currentClaimPhase.quantityLimitPerWallet; + uint256 claimPrice = currentClaimPhase.pricePerToken; + address claimCurrency = currentClaimPhase.currency; + + /* + * Here `isOverride` implies that if the merkle proof verification fails, + * the claimer would claim through open claim limit instead of allowlisted limit. + */ + if (currentClaimPhase.merkleRoot != bytes32(0)) { + (isOverride, ) = MerkleProof.verify( + _allowlistProof.proof, + currentClaimPhase.merkleRoot, + keccak256( + abi.encodePacked( + _claimer, + _allowlistProof.quantityLimitPerWallet, + _allowlistProof.pricePerToken, + _allowlistProof.currency + ) + ) + ); + } + + if (isOverride) { + claimLimit = _allowlistProof.quantityLimitPerWallet != 0 + ? _allowlistProof.quantityLimitPerWallet + : claimLimit; + claimPrice = _allowlistProof.pricePerToken != type(uint256).max + ? _allowlistProof.pricePerToken + : claimPrice; + claimCurrency = _allowlistProof.pricePerToken != type(uint256).max && _allowlistProof.currency != address(0) + ? _allowlistProof.currency + : claimCurrency; + } + + uint256 supplyClaimedByWallet = _dropStorage().claimCondition.supplyClaimedByWallet[_conditionId][_claimer]; + + if (_currency != claimCurrency || _pricePerToken != claimPrice) { + revert("!PriceOrCurrency"); + } + + if (_quantity == 0 || (_quantity + supplyClaimedByWallet > claimLimit)) { + revert("!Qty"); + } + if (currentClaimPhase.supplyClaimed + _quantity > currentClaimPhase.maxClaimableSupply) { + revert("!MaxSupply"); + } + + if (currentClaimPhase.startTimestamp > block.timestamp) { + revert("cant claim yet"); + } + } + + /// @dev At any given moment, returns the uid for the active claim condition. + function getActiveClaimConditionId() public view returns (uint256) { + for ( + uint256 i = _dropStorage().claimCondition.currentStartId + _dropStorage().claimCondition.count; + i > _dropStorage().claimCondition.currentStartId; + i-- + ) { + if (block.timestamp >= _dropStorage().claimCondition.conditions[i - 1].startTimestamp) { + return i - 1; + } + } + + revert("!CONDITION."); + } + + /// @dev Returns the claim condition at the given uid. + function getClaimConditionById(uint256 _conditionId) external view returns (ClaimCondition memory condition) { + condition = _dropStorage().claimCondition.conditions[_conditionId]; + } + + /// @dev Returns the supply claimed by claimer for a given conditionId. + function getSupplyClaimedByWallet( + uint256 _conditionId, + address _claimer + ) public view returns (uint256 supplyClaimedByWallet) { + supplyClaimedByWallet = _dropStorage().claimCondition.supplyClaimedByWallet[_conditionId][_claimer]; + } + + /*//////////////////////////////////////////////////////////////////// + Optional hooks that can be implemented in the derived contract + ///////////////////////////////////////////////////////////////////*/ + + /// @dev Exposes the ability to override the msg sender. + function _dropMsgSender() internal virtual returns (address) { + return msg.sender; + } + + /// @dev Runs before every `claim` function call. + function _beforeClaim( + address _receiver, + uint256 _quantity, + address _currency, + uint256 _pricePerToken, + AllowlistProof calldata _allowlistProof, + bytes memory _data + ) internal virtual {} + + /// @dev Runs after every `claim` function call. + function _afterClaim( + address _receiver, + uint256 _quantity, + address _currency, + uint256 _pricePerToken, + AllowlistProof calldata _allowlistProof, + bytes memory _data + ) internal virtual {} + + /// @dev Returns the DropStorage storage. + function _dropStorage() internal pure returns (DropStorage.Data storage data) { + data = DropStorage.data(); + } + + /*/////////////////////////////////////////////////////////////// + Virtual functions: to be implemented in derived contract + //////////////////////////////////////////////////////////////*/ + + /// @dev Collects and distributes the primary sale value of NFTs being claimed. + function _collectPriceOnClaim( + address _primarySaleRecipient, + uint256 _quantityToClaim, + address _currency, + uint256 _pricePerToken + ) internal virtual; + + /// @dev Transfers the NFTs being claimed. + function _transferTokensOnClaim( + address _to, + uint256 _quantityBeingClaimed + ) internal virtual returns (uint256 startTokenId); + + /// @dev Determine what wallet can update claim conditions + function _canSetClaimConditions() internal view virtual returns (bool); +} diff --git a/contracts/extension/upgradeable/ERC2771Context.sol b/contracts/extension/upgradeable/ERC2771Context.sol new file mode 100644 index 000000000..1898a876a --- /dev/null +++ b/contracts/extension/upgradeable/ERC2771Context.sol @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: Apache 2.0 +pragma solidity ^0.8.0; + +import "../interface/IERC2771Context.sol"; + +/** + * @dev Context variant with ERC2771 support. + */ + +library ERC2771ContextStorage { + /// @custom:storage-location erc7201:erc2771.context.storage + /// @dev keccak256(abi.encode(uint256(keccak256("erc2771.context.storage")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 public constant ERC2771_CONTEXT_STORAGE_POSITION = + 0x82aadcdf5bea62fd30615b6c0754b644e71b6c1e8c55b71bb927ad005b504f00; + + struct Data { + mapping(address => bool) trustedForwarder; + } + + function data() internal pure returns (Data storage data_) { + bytes32 position = ERC2771_CONTEXT_STORAGE_POSITION; + assembly { + data_.slot := position + } + } +} + +contract ERC2771Context is IERC2771Context { + constructor(address[] memory trustedForwarder) { + for (uint256 i = 0; i < trustedForwarder.length; i++) { + _erc2771ContextStorage().trustedForwarder[trustedForwarder[i]] = true; + } + } + + function isTrustedForwarder(address forwarder) public view virtual returns (bool) { + return _erc2771ContextStorage().trustedForwarder[forwarder]; + } + + function _msgSender() internal view virtual returns (address sender) { + if (isTrustedForwarder(msg.sender)) { + // The assembly code is more direct than the Solidity version using `abi.decode`. + assembly { + sender := shr(96, calldataload(sub(calldatasize(), 20))) + } + } else { + return msg.sender; + } + } + + function _msgData() internal view virtual returns (bytes calldata) { + if (isTrustedForwarder(msg.sender)) { + return msg.data[:msg.data.length - 20]; + } else { + return msg.data; + } + } + + /// @dev Returns the ERC2771ContextStorage storage. + function _erc2771ContextStorage() internal pure returns (ERC2771ContextStorage.Data storage data) { + data = ERC2771ContextStorage.data(); + } + + uint256[49] private __gap; +} diff --git a/contracts/extension/upgradeable/ERC2771ContextConsumer.sol b/contracts/extension/upgradeable/ERC2771ContextConsumer.sol new file mode 100644 index 000000000..1dde5ad5e --- /dev/null +++ b/contracts/extension/upgradeable/ERC2771ContextConsumer.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +interface IERC2771Context { + function isTrustedForwarder(address forwarder) external view returns (bool); +} + +/** + * @dev Context variant with ERC2771 support. + */ +abstract contract ERC2771ContextConsumer { + function _msgSender() public view virtual returns (address sender) { + if (IERC2771Context(address(this)).isTrustedForwarder(msg.sender)) { + // The assembly code is more direct than the Solidity version using `abi.decode`. + assembly { + sender := shr(96, calldataload(sub(calldatasize(), 20))) + } + } else { + return msg.sender; + } + } + + function _msgData() public view virtual returns (bytes calldata) { + if (IERC2771Context(address(this)).isTrustedForwarder(msg.sender)) { + return msg.data[:msg.data.length - 20]; + } else { + return msg.data; + } + } +} diff --git a/contracts/extension/upgradeable/ERC2771ContextUpgradeable.sol b/contracts/extension/upgradeable/ERC2771ContextUpgradeable.sol new file mode 100644 index 000000000..7ca71a972 --- /dev/null +++ b/contracts/extension/upgradeable/ERC2771ContextUpgradeable.sol @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: Apache 2.0 +pragma solidity ^0.8.0; + +import "../interface/IERC2771Context.sol"; +import "./Initializable.sol"; + +/** + * @dev Context variant with ERC2771 support. + */ + +library ERC2771ContextStorage { + /// @custom:storage-location erc7201:erc2771.context.storage + /// @dev keccak256(abi.encode(uint256(keccak256("erc2771.context.storage")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 public constant ERC2771_CONTEXT_STORAGE_POSITION = + 0x82aadcdf5bea62fd30615b6c0754b644e71b6c1e8c55b71bb927ad005b504f00; + + struct Data { + mapping(address => bool) trustedForwarder; + } + + function data() internal pure returns (Data storage data_) { + bytes32 position = ERC2771_CONTEXT_STORAGE_POSITION; + assembly { + data_.slot := position + } + } +} + +/** + * @dev Context variant with ERC2771 support. + */ +abstract contract ERC2771ContextUpgradeable is Initializable { + function __ERC2771Context_init(address[] memory trustedForwarder) internal onlyInitializing { + __ERC2771Context_init_unchained(trustedForwarder); + } + + function __ERC2771Context_init_unchained(address[] memory trustedForwarder) internal onlyInitializing { + for (uint256 i = 0; i < trustedForwarder.length; i++) { + _erc2771ContextStorage().trustedForwarder[trustedForwarder[i]] = true; + } + } + + function isTrustedForwarder(address forwarder) public view virtual returns (bool) { + return _erc2771ContextStorage().trustedForwarder[forwarder]; + } + + function _msgSender() internal view virtual returns (address sender) { + if (isTrustedForwarder(msg.sender)) { + // The assembly code is more direct than the Solidity version using `abi.decode`. + assembly { + sender := shr(96, calldataload(sub(calldatasize(), 20))) + } + } else { + return msg.sender; + } + } + + function _msgData() internal view virtual returns (bytes calldata) { + if (isTrustedForwarder(msg.sender)) { + return msg.data[:msg.data.length - 20]; + } else { + return msg.data; + } + } + + /// @dev Returns the ERC2771ContextStorage storage. + function _erc2771ContextStorage() internal pure returns (ERC2771ContextStorage.Data storage data) { + data = ERC2771ContextStorage.data(); + } + + uint256[49] private __gap; +} diff --git a/contracts/extension/upgradeable/Initializable.sol b/contracts/extension/upgradeable/Initializable.sol new file mode 100644 index 000000000..dfa3f4bcd --- /dev/null +++ b/contracts/extension/upgradeable/Initializable.sol @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: Apache 2.0 +pragma solidity ^0.8.0; + +import "../../lib/Address.sol"; + +library InitStorage { + /// @custom:storage-location erc7201:init.storage + /// @dev keccak256(abi.encode(uint256(keccak256("init.storage")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 constant INIT_STORAGE_POSITION = 0x322cf19c484104d3b1a9c2982ebae869ede3fa5f6c4703ca41b9a48c76ee0300; + + /// @dev Layout of the entrypoint contract's storage. + struct Data { + uint8 initialized; + bool initializing; + } + + /// @dev Returns the entrypoint contract's data at the relevant storage location. + function data() internal pure returns (Data storage data_) { + bytes32 position = INIT_STORAGE_POSITION; + assembly { + data_.slot := position + } + } +} + +abstract contract Initializable { + /** + * @dev Triggered when the contract has been initialized or reinitialized. + */ + event Initialized(uint8 version); + + /** + * @dev A modifier that defines a protected initializer function that can be invoked at most once. In its scope, + * `onlyInitializing` functions can be used to initialize parent contracts. Equivalent to `reinitializer(1)`. + */ + modifier initializer() { + uint8 _initialized = _initStorage().initialized; + bool _initializing = _initStorage().initializing; + + bool isTopLevelCall = !_initializing; + require( + (isTopLevelCall && _initialized < 1) || (!Address.isContract(address(this)) && _initialized == 1), + "Initializable: contract is already initialized" + ); + _initStorage().initialized = 1; + if (isTopLevelCall) { + _initStorage().initializing = true; + } + _; + if (isTopLevelCall) { + _initStorage().initializing = false; + emit Initialized(1); + } + } + + /** + * @dev A modifier that defines a protected reinitializer function that can be invoked at most once, and only if the + * contract hasn't been initialized to a greater version before. In its scope, `onlyInitializing` functions can be + * used to initialize parent contracts. + * + * `initializer` is equivalent to `reinitializer(1)`, so a reinitializer may be used after the original + * initialization step. This is essential to configure modules that are added through upgrades and that require + * initialization. + * + * Note that versions can jump in increments greater than 1; this implies that if multiple reinitializers coexist in + * a contract, executing them in the right order is up to the developer or operator. + */ + modifier reinitializer(uint8 version) { + uint8 _initialized = _initStorage().initialized; + bool _initializing = _initStorage().initializing; + + require(!_initializing && _initialized < version, "Initializable: contract is already initialized"); + _initStorage().initialized = version; + _initStorage().initializing = true; + _; + _initStorage().initializing = false; + emit Initialized(version); + } + + /** + * @dev Modifier to protect an initialization function so that it can only be invoked by functions with the + * {initializer} and {reinitializer} modifiers, directly or indirectly. + */ + modifier onlyInitializing() { + require(_initStorage().initializing, "Initializable: contract is not initializing"); + _; + } + + /** + * @dev Locks the contract, preventing any future reinitialization. This cannot be part of an initializer call. + * Calling this in the constructor of a contract will prevent that contract from being initialized or reinitialized + * to any version. It is recommended to use this to lock implementation contracts that are designed to be called + * through proxies. + */ + function _disableInitializers() internal virtual { + uint8 _initialized = _initStorage().initialized; + bool _initializing = _initStorage().initializing; + + require(!_initializing, "Initializable: contract is initializing"); + if (_initialized < type(uint8).max) { + _initStorage().initialized = type(uint8).max; + emit Initialized(type(uint8).max); + } + } + + /// @dev Returns the InitStorage storage. + function _initStorage() internal pure returns (InitStorage.Data storage data) { + data = InitStorage.data(); + } +} diff --git a/contracts/extension/upgradeable/LazyMint.sol b/contracts/extension/upgradeable/LazyMint.sol new file mode 100644 index 000000000..e4213836f --- /dev/null +++ b/contracts/extension/upgradeable/LazyMint.sol @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "../interface/ILazyMint.sol"; +import "./BatchMintMetadata.sol"; + +library LazyMintStorage { + /// @custom:storage-location erc7201:lazy.mint.storage + /// @dev keccak256(abi.encode(uint256(keccak256("lazy.mint.storage")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 public constant LAZY_MINT_STORAGE_POSITION = + 0xb9d1563179e0b515350da446a9b78048cef890c6aaa6e34cdf88122d970b5c00; + + struct Data { + /// @notice The tokenId assigned to the next new NFT to be lazy minted. + uint256 nextTokenIdToLazyMint; + } + + function data() internal pure returns (Data storage data_) { + bytes32 position = LAZY_MINT_STORAGE_POSITION; + assembly { + data_.slot := position + } + } +} + +/** + * The `LazyMint` is a contract extension for any base NFT contract. It lets you 'lazy mint' any number of NFTs + * at once. Here, 'lazy mint' means defining the metadata for particular tokenIds of your NFT contract, without actually + * minting a non-zero balance of NFTs of those tokenIds. + */ + +abstract contract LazyMint is ILazyMint, BatchMintMetadata { + function nextTokenIdToLazyMint() internal view returns (uint256) { + return _lazyMintStorage().nextTokenIdToLazyMint; + } + + /** + * @notice Lets an authorized address lazy mint a given amount of NFTs. + * + * @param _amount The number of NFTs to lazy mint. + * @param _baseURIForTokens The base URI for the 'n' number of NFTs being lazy minted, where the metadata for each + * of those NFTs is `${baseURIForTokens}/${tokenId}`. + * @param _data Additional bytes data to be used at the discretion of the consumer of the contract. + * @return batchId A unique integer identifier for the batch of NFTs lazy minted together. + */ + function lazyMint( + uint256 _amount, + string calldata _baseURIForTokens, + bytes calldata _data + ) public virtual override returns (uint256 batchId) { + if (!_canLazyMint()) { + revert("Not authorized"); + } + + if (_amount == 0) { + revert("0 amt"); + } + + uint256 startId = _lazyMintStorage().nextTokenIdToLazyMint; + + (_lazyMintStorage().nextTokenIdToLazyMint, batchId) = _batchMintMetadata(startId, _amount, _baseURIForTokens); + + emit TokensLazyMinted(startId, startId + _amount - 1, _baseURIForTokens, _data); + + return batchId; + } + + /// @dev Returns the LazyMintStorage storage. + function _lazyMintStorage() internal pure returns (LazyMintStorage.Data storage data) { + data = LazyMintStorage.data(); + } + + /// @dev Returns whether lazy minting can be performed in the given execution context. + function _canLazyMint() internal view virtual returns (bool); +} diff --git a/contracts/extension/upgradeable/OperatorFilterToggle.sol b/contracts/extension/upgradeable/OperatorFilterToggle.sol new file mode 100644 index 000000000..deb1707c1 --- /dev/null +++ b/contracts/extension/upgradeable/OperatorFilterToggle.sol @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: Apache 2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "../interface/IOperatorFilterToggle.sol"; + +library OperatorFilterToggleStorage { + /// @custom:storage-location erc7201:operator.filter.toggle.storage + /// @dev keccak256(abi.encode(uint256(keccak256("operator.filter.toggle.storage")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 public constant OPERATOR_FILTER_TOGGLE_STORAGE_POSITION = + 0xc9c6c05578224a00a593ea5c05021a182582a08fc1143a677c61a8fe56c23800; + + struct Data { + bool operatorRestriction; + } + + function data() internal pure returns (Data storage data_) { + bytes32 position = OPERATOR_FILTER_TOGGLE_STORAGE_POSITION; + assembly { + data_.slot := position + } + } +} + +abstract contract OperatorFilterToggle is IOperatorFilterToggle { + function operatorRestriction() external view override returns (bool) { + return _operatorFilterToggleStorage().operatorRestriction; + } + + function setOperatorRestriction(bool _restriction) external { + require(_canSetOperatorRestriction(), "Not authorized to set operator restriction."); + _setOperatorRestriction(_restriction); + } + + function _setOperatorRestriction(bool _restriction) internal { + _operatorFilterToggleStorage().operatorRestriction = _restriction; + emit OperatorRestriction(_restriction); + } + + /// @dev Returns the OperatorFilterToggle storage. + function _operatorFilterToggleStorage() internal pure returns (OperatorFilterToggleStorage.Data storage data) { + data = OperatorFilterToggleStorage.data(); + } + + function _canSetOperatorRestriction() internal virtual returns (bool); +} diff --git a/contracts/extension/upgradeable/OperatorFiltererUpgradeable.sol b/contracts/extension/upgradeable/OperatorFiltererUpgradeable.sol new file mode 100644 index 000000000..dd5a2c04c --- /dev/null +++ b/contracts/extension/upgradeable/OperatorFiltererUpgradeable.sol @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: Apache 2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "../interface/IOperatorFilterRegistry.sol"; +import "./OperatorFilterToggle.sol"; + +abstract contract OperatorFiltererUpgradeable is OperatorFilterToggle { + IOperatorFilterRegistry constant OPERATOR_FILTER_REGISTRY = + IOperatorFilterRegistry(0x000000000000AAeB6D7670E522A718067333cd4E); + + function __OperatorFilterer_init(address subscriptionOrRegistrantToCopy, bool subscribe) internal { + // If an inheriting token contract is deployed to a network without the registry deployed, the modifier + // will not revert, but the contract will need to be registered with the registry once it is deployed in + // order for the modifier to filter addresses. + if (address(OPERATOR_FILTER_REGISTRY).code.length > 0) { + if (!OPERATOR_FILTER_REGISTRY.isRegistered(address(this))) { + if (subscribe) { + OPERATOR_FILTER_REGISTRY.registerAndSubscribe(address(this), subscriptionOrRegistrantToCopy); + } else { + if (subscriptionOrRegistrantToCopy != address(0)) { + OPERATOR_FILTER_REGISTRY.registerAndCopyEntries(address(this), subscriptionOrRegistrantToCopy); + } else { + OPERATOR_FILTER_REGISTRY.register(address(this)); + } + } + } + } + } + + modifier onlyAllowedOperator(address from) virtual { + // Check registry code length to facilitate testing in environments without a deployed registry. + if (_operatorFilterToggleStorage().operatorRestriction) { + if (address(OPERATOR_FILTER_REGISTRY).code.length > 0) { + // Allow spending tokens from addresses with balance + // Note that this still allows listings and marketplaces with escrow to transfer tokens if transferred + // from an EOA. + if (from == msg.sender) { + _; + return; + } + OPERATOR_FILTER_REGISTRY.isOperatorAllowed(address(this), msg.sender); + } + } + _; + } + + modifier onlyAllowedOperatorApproval(address operator) virtual { + // Check registry code length to facilitate testing in environments without a deployed registry. + if (_operatorFilterToggleStorage().operatorRestriction) { + if (address(OPERATOR_FILTER_REGISTRY).code.length > 0) { + OPERATOR_FILTER_REGISTRY.isOperatorAllowed(address(this), msg.sender); + } + } + _; + } +} diff --git a/contracts/extension/upgradeable/Ownable.sol b/contracts/extension/upgradeable/Ownable.sol new file mode 100644 index 000000000..db048dba1 --- /dev/null +++ b/contracts/extension/upgradeable/Ownable.sol @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "../interface/IOwnable.sol"; + +/** + * @title Ownable + * @notice Thirdweb's `Ownable` is a contract extension to be used with any base contract. It exposes functions for setting and reading + * who the 'owner' of the inheriting smart contract is, and lets the inheriting contract perform conditional logic that uses + * information about who the contract's owner is. + */ + +library OwnableStorage { + /// @custom:storage-location erc7201:extension.manager.storage + bytes32 public constant OWNABLE_STORAGE_POSITION = + keccak256(abi.encode(uint256(keccak256("ownable.storage")) - 1)) & ~bytes32(uint256(0xff)); + + struct Data { + /// @dev Owner of the contract (purpose: OpenSea compatibility) + address _owner; + } + + function data() internal pure returns (Data storage data_) { + bytes32 position = OWNABLE_STORAGE_POSITION; + assembly { + data_.slot := position + } + } +} + +abstract contract Ownable is IOwnable { + /// @dev Reverts if caller is not the owner. + modifier onlyOwner() { + if (msg.sender != _ownableStorage()._owner) { + revert("Not authorized"); + } + _; + } + + /** + * @notice Returns the owner of the contract. + */ + function owner() public view override returns (address) { + return _ownableStorage()._owner; + } + + /** + * @notice Lets an authorized wallet set a new owner for the contract. + * @param _newOwner The address to set as the new owner of the contract. + */ + function setOwner(address _newOwner) external override { + if (!_canSetOwner()) { + revert("Not authorized"); + } + _setupOwner(_newOwner); + } + + /// @dev Lets a contract admin set a new owner for the contract. The new owner must be a contract admin. + function _setupOwner(address _newOwner) internal { + address _prevOwner = _ownableStorage()._owner; + _ownableStorage()._owner = _newOwner; + + emit OwnerUpdated(_prevOwner, _newOwner); + } + + /// @dev Returns the Ownable storage. + function _ownableStorage() internal pure returns (OwnableStorage.Data storage data) { + data = OwnableStorage.data(); + } + + /// @dev Returns whether owner can be set in the given execution context. + function _canSetOwner() internal view virtual returns (bool); +} diff --git a/contracts/extension/upgradeable/Permissions.sol b/contracts/extension/upgradeable/Permissions.sol new file mode 100644 index 000000000..c1fa7925e --- /dev/null +++ b/contracts/extension/upgradeable/Permissions.sol @@ -0,0 +1,197 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "../interface/IPermissions.sol"; +import "../../lib/Strings.sol"; + +/** + * @title Permissions + * @dev This contracts provides extending-contracts with role-based access control mechanisms + */ + +library PermissionsStorage { + /// @custom:storage-location erc7201:permissions.storage + /// @dev keccak256(abi.encode(uint256(keccak256("permissions.storage")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 public constant PERMISSIONS_STORAGE_POSITION = + 0x0a7b0f5c59907924802379ebe98cdc23e2ee7820f63d30126e10b3752010e500; + + struct Data { + /// @dev Map from keccak256 hash of a role => a map from address => whether address has role. + mapping(bytes32 => mapping(address => bool)) _hasRole; + /// @dev Map from keccak256 hash of a role to role admin. See {getRoleAdmin}. + mapping(bytes32 => bytes32) _getRoleAdmin; + } + + function data() internal pure returns (Data storage data_) { + bytes32 position = PERMISSIONS_STORAGE_POSITION; + assembly { + data_.slot := position + } + } +} + +contract Permissions is IPermissions { + /// @dev Default admin role for all roles. Only accounts with this role can grant/revoke other roles. + bytes32 public constant DEFAULT_ADMIN_ROLE = 0x00; + + /// @dev Modifier that checks if an account has the specified role; reverts otherwise. + modifier onlyRole(bytes32 role) { + _checkRole(role, _msgSender()); + _; + } + + /** + * @notice Checks whether an account has a particular role. + * @dev Returns `true` if `account` has been granted `role`. + * + * @param role keccak256 hash of the role. e.g. keccak256("TRANSFER_ROLE") + * @param account Address of the account for which the role is being checked. + */ + function hasRole(bytes32 role, address account) public view override returns (bool) { + return _permissionsStorage()._hasRole[role][account]; + } + + /** + * @notice Checks whether an account has a particular role; + * role restrictions can be swtiched on and off. + * + * @dev Returns `true` if `account` has been granted `role`. + * Role restrictions can be swtiched on and off: + * - If address(0) has ROLE, then the ROLE restrictions + * don't apply. + * - If address(0) does not have ROLE, then the ROLE + * restrictions will apply. + * + * @param role keccak256 hash of the role. e.g. keccak256("TRANSFER_ROLE") + * @param account Address of the account for which the role is being checked. + */ + function hasRoleWithSwitch(bytes32 role, address account) public view returns (bool) { + if (!_permissionsStorage()._hasRole[role][address(0)]) { + return _permissionsStorage()._hasRole[role][account]; + } + + return true; + } + + /** + * @notice Returns the admin role that controls the specified role. + * @dev See {grantRole} and {revokeRole}. + * To change a role's admin, use {_setRoleAdmin}. + * + * @param role keccak256 hash of the role. e.g. keccak256("TRANSFER_ROLE") + */ + function getRoleAdmin(bytes32 role) external view override returns (bytes32) { + return _permissionsStorage()._getRoleAdmin[role]; + } + + /** + * @notice Grants a role to an account, if not previously granted. + * @dev Caller must have admin role for the `role`. + * Emits {RoleGranted Event}. + * + * @param role keccak256 hash of the role. e.g. keccak256("TRANSFER_ROLE") + * @param account Address of the account to which the role is being granted. + */ + function grantRole(bytes32 role, address account) public virtual override { + _checkRole(_permissionsStorage()._getRoleAdmin[role], _msgSender()); + if (_permissionsStorage()._hasRole[role][account]) { + revert("Can only grant to non holders"); + } + _setupRole(role, account); + } + + /** + * @notice Revokes role from an account. + * @dev Caller must have admin role for the `role`. + * Emits {RoleRevoked Event}. + * + * @param role keccak256 hash of the role. e.g. keccak256("TRANSFER_ROLE") + * @param account Address of the account from which the role is being revoked. + */ + function revokeRole(bytes32 role, address account) public virtual override { + _checkRole(_permissionsStorage()._getRoleAdmin[role], _msgSender()); + _revokeRole(role, account); + } + + /** + * @notice Revokes role from the account. + * @dev Caller must have the `role`, with caller being the same as `account`. + * Emits {RoleRevoked Event}. + * + * @param role keccak256 hash of the role. e.g. keccak256("TRANSFER_ROLE") + * @param account Address of the account from which the role is being revoked. + */ + function renounceRole(bytes32 role, address account) public virtual override { + if (_msgSender() != account) { + revert("Can only renounce for self"); + } + _revokeRole(role, account); + } + + /// @dev Sets `adminRole` as `role`'s admin role. + function _setRoleAdmin(bytes32 role, bytes32 adminRole) internal virtual { + bytes32 previousAdminRole = _permissionsStorage()._getRoleAdmin[role]; + _permissionsStorage()._getRoleAdmin[role] = adminRole; + emit RoleAdminChanged(role, previousAdminRole, adminRole); + } + + /// @dev Sets up `role` for `account` + function _setupRole(bytes32 role, address account) internal virtual { + _permissionsStorage()._hasRole[role][account] = true; + emit RoleGranted(role, account, _msgSender()); + } + + /// @dev Revokes `role` from `account` + function _revokeRole(bytes32 role, address account) internal virtual { + _checkRole(role, account); + delete _permissionsStorage()._hasRole[role][account]; + emit RoleRevoked(role, account, _msgSender()); + } + + /// @dev Checks `role` for `account`. Reverts with a message including the required role. + function _checkRole(bytes32 role, address account) internal view virtual { + if (!_permissionsStorage()._hasRole[role][account]) { + revert( + string( + abi.encodePacked( + "Permissions: account ", + Strings.toHexString(uint160(account), 20), + " is missing role ", + Strings.toHexString(uint256(role), 32) + ) + ) + ); + } + } + + /// @dev Checks `role` for `account`. Reverts with a message including the required role. + function _checkRoleWithSwitch(bytes32 role, address account) internal view virtual { + if (!hasRoleWithSwitch(role, account)) { + revert( + string( + abi.encodePacked( + "Permissions: account ", + Strings.toHexString(uint160(account), 20), + " is missing role ", + Strings.toHexString(uint256(role), 32) + ) + ) + ); + } + } + + function _msgSender() internal view virtual returns (address sender) { + return msg.sender; + } + + function _msgData() internal view virtual returns (bytes calldata) { + return msg.data; + } + + /// @dev Returns the Permissions storage. + function _permissionsStorage() internal pure returns (PermissionsStorage.Data storage data) { + data = PermissionsStorage.data(); + } +} diff --git a/contracts/extension/upgradeable/PermissionsEnumerable.sol b/contracts/extension/upgradeable/PermissionsEnumerable.sol new file mode 100644 index 000000000..5307b7e54 --- /dev/null +++ b/contracts/extension/upgradeable/PermissionsEnumerable.sol @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "../interface/IPermissionsEnumerable.sol"; +import "./Permissions.sol"; + +/** + * @title PermissionsEnumerable + * @dev This contracts provides extending-contracts with role-based access control mechanisms. + * Also provides interfaces to view all members with a given role, and total count of members. + */ + +library PermissionsEnumerableStorage { + /// @custom:storage-location erc7201:extension.manager.storage + bytes32 public constant PERMISSIONS_ENUMERABLE_STORAGE_POSITION = + keccak256(abi.encode(uint256(keccak256("permissions.enumerable.storage")) - 1)) & ~bytes32(uint256(0xff)); + + /** + * @notice A data structure to store data of members for a given role. + * + * @param index Current index in the list of accounts that have a role. + * @param members map from index => address of account that has a role + * @param indexOf map from address => index which the account has. + */ + struct RoleMembers { + uint256 index; + mapping(uint256 => address) members; + mapping(address => uint256) indexOf; + } + + struct Data { + /// @dev map from keccak256 hash of a role to its members' data. See {RoleMembers}. + mapping(bytes32 => RoleMembers) roleMembers; + } + + function data() internal pure returns (Data storage data_) { + bytes32 position = PERMISSIONS_ENUMERABLE_STORAGE_POSITION; + assembly { + data_.slot := position + } + } +} + +contract PermissionsEnumerable is IPermissionsEnumerable, Permissions { + /** + * @notice Returns the role-member from a list of members for a role, + * at a given index. + * @dev Returns `member` who has `role`, at `index` of role-members list. + * See struct {RoleMembers}, and mapping {roleMembers} + * + * @param role keccak256 hash of the role. e.g. keccak256("TRANSFER_ROLE") + * @param index Index in list of current members for the role. + * + * @return member Address of account that has `role` + */ + function getRoleMember(bytes32 role, uint256 index) external view override returns (address member) { + uint256 currentIndex = _permissionsEnumerableStorage().roleMembers[role].index; + uint256 check; + + for (uint256 i = 0; i < currentIndex; i += 1) { + if (_permissionsEnumerableStorage().roleMembers[role].members[i] != address(0)) { + if (check == index) { + member = _permissionsEnumerableStorage().roleMembers[role].members[i]; + return member; + } + check += 1; + } else if ( + hasRole(role, address(0)) && i == _permissionsEnumerableStorage().roleMembers[role].indexOf[address(0)] + ) { + check += 1; + } + } + } + + /** + * @notice Returns total number of accounts that have a role. + * @dev Returns `count` of accounts that have `role`. + * See struct {RoleMembers}, and mapping {roleMembers} + * + * @param role keccak256 hash of the role. e.g. keccak256("TRANSFER_ROLE") + * + * @return count Total number of accounts that have `role` + */ + function getRoleMemberCount(bytes32 role) external view override returns (uint256 count) { + uint256 currentIndex = _permissionsEnumerableStorage().roleMembers[role].index; + + for (uint256 i = 0; i < currentIndex; i += 1) { + if (_permissionsEnumerableStorage().roleMembers[role].members[i] != address(0)) { + count += 1; + } + } + if (hasRole(role, address(0))) { + count += 1; + } + } + + /// @dev Revokes `role` from `account`, and removes `account` from {roleMembers} + /// See {_removeMember} + function _revokeRole(bytes32 role, address account) internal virtual override { + super._revokeRole(role, account); + _removeMember(role, account); + } + + /// @dev Grants `role` to `account`, and adds `account` to {roleMembers} + /// See {_addMember} + function _setupRole(bytes32 role, address account) internal virtual override { + super._setupRole(role, account); + _addMember(role, account); + } + + /// @dev adds `account` to {roleMembers}, for `role` + function _addMember(bytes32 role, address account) internal { + uint256 idx = _permissionsEnumerableStorage().roleMembers[role].index; + _permissionsEnumerableStorage().roleMembers[role].index += 1; + + _permissionsEnumerableStorage().roleMembers[role].members[idx] = account; + _permissionsEnumerableStorage().roleMembers[role].indexOf[account] = idx; + } + + /// @dev removes `account` from {roleMembers}, for `role` + function _removeMember(bytes32 role, address account) internal { + uint256 idx = _permissionsEnumerableStorage().roleMembers[role].indexOf[account]; + + delete _permissionsEnumerableStorage().roleMembers[role].members[idx]; + delete _permissionsEnumerableStorage().roleMembers[role].indexOf[account]; + } + + /// @dev Returns the PermissionsEnumerable storage. + function _permissionsEnumerableStorage() internal pure returns (PermissionsEnumerableStorage.Data storage data) { + data = PermissionsEnumerableStorage.data(); + } +} diff --git a/contracts/extension/upgradeable/PlatformFee.sol b/contracts/extension/upgradeable/PlatformFee.sol new file mode 100644 index 000000000..6b9e9ef21 --- /dev/null +++ b/contracts/extension/upgradeable/PlatformFee.sol @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "../interface/IPlatformFee.sol"; + +/** + * @author thirdweb.com + */ +library PlatformFeeStorage { + /// @custom:storage-location erc7201:platform.fee.storage + /// @dev keccak256(abi.encode(uint256(keccak256("platform.fee.storage")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 public constant PLATFORM_FEE_STORAGE_POSITION = + 0xc0c34308b4a2f4c5ee9af8ba82541cfb3c33b076d1fd05c65f9ce7060c64c400; + + struct Data { + /// @dev The address that receives all platform fees from all sales. + address platformFeeRecipient; + /// @dev The % of primary sales collected as platform fees. + uint16 platformFeeBps; + /// @dev Fee type variants: percentage fee and flat fee + IPlatformFee.PlatformFeeType platformFeeType; + /// @dev The flat amount collected by the contract as fees on primary sales. + uint256 flatPlatformFee; + } + + function data() internal pure returns (Data storage data_) { + bytes32 position = PLATFORM_FEE_STORAGE_POSITION; + assembly { + data_.slot := position + } + } +} + +/** + * @author thirdweb.com + * + * @title Platform Fee + * @notice Thirdweb's `PlatformFee` is a contract extension to be used with any base contract. It exposes functions for setting and reading + * the recipient of platform fee and the platform fee basis points, and lets the inheriting contract perform conditional logic + * that uses information about platform fees, if desired. + */ + +abstract contract PlatformFee is IPlatformFee { + /// @dev Returns the platform fee recipient and bps. + function getPlatformFeeInfo() public view override returns (address, uint16) { + return (_platformFeeStorage().platformFeeRecipient, uint16(_platformFeeStorage().platformFeeBps)); + } + + /// @dev Returns the platform fee bps and recipient. + function getFlatPlatformFeeInfo() public view returns (address, uint256) { + return (_platformFeeStorage().platformFeeRecipient, _platformFeeStorage().flatPlatformFee); + } + + /// @dev Returns the platform fee type. + function getPlatformFeeType() public view returns (PlatformFeeType) { + return _platformFeeStorage().platformFeeType; + } + + /** + * @notice Updates the platform fee recipient and bps. + * @dev Caller should be authorized to set platform fee info. + * See {_canSetPlatformFeeInfo}. + * Emits {PlatformFeeInfoUpdated Event}; See {_setupPlatformFeeInfo}. + * + * @param _platformFeeRecipient Address to be set as new platformFeeRecipient. + * @param _platformFeeBps Updated platformFeeBps. + */ + function setPlatformFeeInfo(address _platformFeeRecipient, uint256 _platformFeeBps) external override { + if (!_canSetPlatformFeeInfo()) { + revert("Not authorized"); + } + _setupPlatformFeeInfo(_platformFeeRecipient, _platformFeeBps); + } + + /// @dev Lets a contract admin update the platform fee recipient and bps + function _setupPlatformFeeInfo(address _platformFeeRecipient, uint256 _platformFeeBps) internal { + if (_platformFeeBps > 10_000) { + revert("Exceeds max bps"); + } + if (_platformFeeRecipient == address(0)) { + revert("Invalid recipient"); + } + + _platformFeeStorage().platformFeeBps = uint16(_platformFeeBps); + _platformFeeStorage().platformFeeRecipient = _platformFeeRecipient; + + emit PlatformFeeInfoUpdated(_platformFeeRecipient, _platformFeeBps); + } + + /// @notice Lets a module admin set a flat fee on primary sales. + function setFlatPlatformFeeInfo(address _platformFeeRecipient, uint256 _flatFee) external { + if (!_canSetPlatformFeeInfo()) { + revert("Not authorized"); + } + + _setupFlatPlatformFeeInfo(_platformFeeRecipient, _flatFee); + } + + /// @dev Sets a flat fee on primary sales. + function _setupFlatPlatformFeeInfo(address _platformFeeRecipient, uint256 _flatFee) internal { + _platformFeeStorage().flatPlatformFee = _flatFee; + _platformFeeStorage().platformFeeRecipient = _platformFeeRecipient; + + emit FlatPlatformFeeUpdated(_platformFeeRecipient, _flatFee); + } + + /// @notice Lets a module admin set platform fee type. + function setPlatformFeeType(PlatformFeeType _feeType) external { + if (!_canSetPlatformFeeInfo()) { + revert("Not authorized"); + } + _setupPlatformFeeType(_feeType); + } + + /// @dev Sets platform fee type. + function _setupPlatformFeeType(PlatformFeeType _feeType) internal { + _platformFeeStorage().platformFeeType = _feeType; + + emit PlatformFeeTypeUpdated(_feeType); + } + + /// @dev Returns the PlatformFee storage. + function _platformFeeStorage() internal pure returns (PlatformFeeStorage.Data storage data) { + data = PlatformFeeStorage.data(); + } + + /// @dev Returns whether platform fee info can be set in the given execution context. + function _canSetPlatformFeeInfo() internal view virtual returns (bool); +} diff --git a/contracts/extension/upgradeable/PrimarySale.sol b/contracts/extension/upgradeable/PrimarySale.sol new file mode 100644 index 000000000..6280f6d15 --- /dev/null +++ b/contracts/extension/upgradeable/PrimarySale.sol @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "../interface/IPrimarySale.sol"; + +library PrimarySaleStorage { + /// @custom:storage-location erc7201:extension.manager.storage + bytes32 public constant PRIMARY_SALE_STORAGE_POSITION = + keccak256(abi.encode(uint256(keccak256("primary.sale.storage")) - 1)) & ~bytes32(uint256(0xff)); + + struct Data { + address recipient; + } + + function data() internal pure returns (Data storage data_) { + bytes32 position = PRIMARY_SALE_STORAGE_POSITION; + assembly { + data_.slot := position + } + } +} + +/** + * @title Primary Sale + * @notice Thirdweb's `PrimarySale` is a contract extension to be used with any base contract. It exposes functions for setting and reading + * the recipient of primary sales, and lets the inheriting contract perform conditional logic that uses information about + * primary sales, if desired. + */ + +abstract contract PrimarySale is IPrimarySale { + /// @dev Returns primary sale recipient address. + function primarySaleRecipient() public view override returns (address) { + return _primarySaleStorage().recipient; + } + + /** + * @notice Updates primary sale recipient. + * @dev Caller should be authorized to set primary sales info. + * See {_canSetPrimarySaleRecipient}. + * Emits {PrimarySaleRecipientUpdated Event}; See {_setupPrimarySaleRecipient}. + * + * @param _saleRecipient Address to be set as new recipient of primary sales. + */ + function setPrimarySaleRecipient(address _saleRecipient) external override { + if (!_canSetPrimarySaleRecipient()) { + revert("Not authorized"); + } + _setupPrimarySaleRecipient(_saleRecipient); + } + + /// @dev Lets a contract admin set the recipient for all primary sales. + function _setupPrimarySaleRecipient(address _saleRecipient) internal { + if (_saleRecipient == address(0)) { + revert("Invalid recipient"); + } + _primarySaleStorage().recipient = _saleRecipient; + emit PrimarySaleRecipientUpdated(_saleRecipient); + } + + /// @dev Returns the PrimarySale storage. + function _primarySaleStorage() internal pure returns (PrimarySaleStorage.Data storage data) { + data = PrimarySaleStorage.data(); + } + + /// @dev Returns whether primary sale recipient can be set in the given execution context. + function _canSetPrimarySaleRecipient() internal view virtual returns (bool); +} diff --git a/contracts/extension/upgradeable/ReentrancyGuard.sol b/contracts/extension/upgradeable/ReentrancyGuard.sol new file mode 100644 index 000000000..c815dac97 --- /dev/null +++ b/contracts/extension/upgradeable/ReentrancyGuard.sol @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts v4.4.1 (security/ReentrancyGuard.sol) + +pragma solidity ^0.8.0; + +library ReentrancyGuardStorage { + /// @custom:storage-location erc7201:reentrancy.guard.storage + /// @dev keccak256(abi.encode(uint256(keccak256("reentrancy.guard.storage")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 public constant REENTRANCY_GUARD_STORAGE_POSITION = + 0x1d281c488dae143b6ea4122e80c65059929950b9c32f17fc57be22089d9c3b00; + + struct Data { + uint256 _status; + } + + function data() internal pure returns (Data storage data_) { + bytes32 position = REENTRANCY_GUARD_STORAGE_POSITION; + assembly { + data_.slot := position + } + } +} + +abstract contract ReentrancyGuard { + uint256 private constant _NOT_ENTERED = 1; + uint256 private constant _ENTERED = 2; + + constructor() { + _reentrancyGuardStorage()._status = _NOT_ENTERED; + } + + /** + * @dev Prevents a contract from calling itself, directly or indirectly. + */ + modifier nonReentrant() { + // On the first call to nonReentrant, _notEntered will be true + require(_reentrancyGuardStorage()._status != _ENTERED, "ReentrancyGuard: reentrant call"); + + // Any calls to nonReentrant after this point will fail + _reentrancyGuardStorage()._status = _ENTERED; + + _; + + // By storing the original value once again, a refund is triggered (see + // https://eips.ethereum.org/EIPS/eip-2200) + _reentrancyGuardStorage()._status = _NOT_ENTERED; + } + + /// @dev Returns the ReentrancyGuard storage. + function _reentrancyGuardStorage() internal pure returns (ReentrancyGuardStorage.Data storage data) { + data = ReentrancyGuardStorage.data(); + } +} diff --git a/contracts/extension/upgradeable/Royalty.sol b/contracts/extension/upgradeable/Royalty.sol new file mode 100644 index 000000000..b8cadcd02 --- /dev/null +++ b/contracts/extension/upgradeable/Royalty.sol @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "../interface/IRoyalty.sol"; + +library RoyaltyStorage { + /// @custom:storage-location erc7201:royalty.storage + /// @dev keccak256(abi.encode(uint256(keccak256("royalty.storage")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 public constant ROYALTY_STORAGE_POSITION = + 0x8116a128b135962baae86382f90f26a5e28c4bb803b8888f92fd98e3bbbc6d00; + + struct Data { + /// @dev The (default) address that receives all royalty value. + address royaltyRecipient; + /// @dev The (default) % of a sale to take as royalty (in basis points). + uint16 royaltyBps; + /// @dev Token ID => royalty recipient and bps for token + mapping(uint256 => IRoyalty.RoyaltyInfo) royaltyInfoForToken; + } + + function data() internal pure returns (Data storage data_) { + bytes32 position = ROYALTY_STORAGE_POSITION; + assembly { + data_.slot := position + } + } +} + +/** + * @title Royalty + * @notice Thirdweb's `Royalty` is a contract extension to be used with any base contract. It exposes functions for setting and reading + * the recipient of royalty fee and the royalty fee basis points, and lets the inheriting contract perform conditional logic + * that uses information about royalty fees, if desired. + * + * @dev The `Royalty` contract is ERC2981 compliant. + */ + +abstract contract Royalty is IRoyalty { + /** + * @notice View royalty info for a given token and sale price. + * @dev Returns royalty amount and recipient for `tokenId` and `salePrice`. + * @param tokenId The tokenID of the NFT for which to query royalty info. + * @param salePrice Sale price of the token. + * + * @return receiver Address of royalty recipient account. + * @return royaltyAmount Royalty amount calculated at current royaltyBps value. + */ + function royaltyInfo( + uint256 tokenId, + uint256 salePrice + ) external view virtual override returns (address receiver, uint256 royaltyAmount) { + (address recipient, uint256 bps) = getRoyaltyInfoForToken(tokenId); + receiver = recipient; + royaltyAmount = (salePrice * bps) / 10_000; + } + + /** + * @notice View royalty info for a given token. + * @dev Returns royalty recipient and bps for `_tokenId`. + * @param _tokenId The tokenID of the NFT for which to query royalty info. + */ + function getRoyaltyInfoForToken(uint256 _tokenId) public view override returns (address, uint16) { + RoyaltyInfo memory royaltyForToken = _royaltyStorage().royaltyInfoForToken[_tokenId]; + + return + royaltyForToken.recipient == address(0) + ? (_royaltyStorage().royaltyRecipient, uint16(_royaltyStorage().royaltyBps)) + : (royaltyForToken.recipient, uint16(royaltyForToken.bps)); + } + + /** + * @notice Returns the defualt royalty recipient and BPS for this contract's NFTs. + */ + function getDefaultRoyaltyInfo() external view override returns (address, uint16) { + return (_royaltyStorage().royaltyRecipient, uint16(_royaltyStorage().royaltyBps)); + } + + /** + * @notice Updates default royalty recipient and bps. + * @dev Caller should be authorized to set royalty info. + * See {_canSetRoyaltyInfo}. + * Emits {DefaultRoyalty Event}; See {_setupDefaultRoyaltyInfo}. + * + * @param _royaltyRecipient Address to be set as default royalty recipient. + * @param _royaltyBps Updated royalty bps. + */ + function setDefaultRoyaltyInfo(address _royaltyRecipient, uint256 _royaltyBps) external override { + if (!_canSetRoyaltyInfo()) { + revert("Not authorized"); + } + + _setupDefaultRoyaltyInfo(_royaltyRecipient, _royaltyBps); + } + + /// @dev Lets a contract admin update the default royalty recipient and bps. + function _setupDefaultRoyaltyInfo(address _royaltyRecipient, uint256 _royaltyBps) internal { + if (_royaltyBps > 10_000) { + revert("Exceeds max bps"); + } + + _royaltyStorage().royaltyRecipient = _royaltyRecipient; + _royaltyStorage().royaltyBps = uint16(_royaltyBps); + + emit DefaultRoyalty(_royaltyRecipient, _royaltyBps); + } + + /** + * @notice Updates default royalty recipient and bps for a particular token. + * @dev Sets royalty info for `_tokenId`. Caller should be authorized to set royalty info. + * See {_canSetRoyaltyInfo}. + * Emits {RoyaltyForToken Event}; See {_setupRoyaltyInfoForToken}. + * + * @param _recipient Address to be set as royalty recipient for given token Id. + * @param _bps Updated royalty bps for the token Id. + */ + function setRoyaltyInfoForToken(uint256 _tokenId, address _recipient, uint256 _bps) external override { + if (!_canSetRoyaltyInfo()) { + revert("Not authorized"); + } + + _setupRoyaltyInfoForToken(_tokenId, _recipient, _bps); + } + + /// @dev Lets a contract admin set the royalty recipient and bps for a particular token Id. + function _setupRoyaltyInfoForToken(uint256 _tokenId, address _recipient, uint256 _bps) internal { + if (_bps > 10_000) { + revert("Exceeds max bps"); + } + + _royaltyStorage().royaltyInfoForToken[_tokenId] = RoyaltyInfo({ recipient: _recipient, bps: _bps }); + + emit RoyaltyForToken(_tokenId, _recipient, _bps); + } + + /// @dev Returns the Royalty storage. + function _royaltyStorage() internal pure returns (RoyaltyStorage.Data storage data) { + data = RoyaltyStorage.data(); + } + + /// @dev Returns whether royalty info can be set in the given execution context. + function _canSetRoyaltyInfo() internal view virtual returns (bool); +} diff --git a/contracts/extension/upgradeable/RoyaltyPayments.sol b/contracts/extension/upgradeable/RoyaltyPayments.sol new file mode 100644 index 000000000..2b5f504be --- /dev/null +++ b/contracts/extension/upgradeable/RoyaltyPayments.sol @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "../interface/IRoyaltyPayments.sol"; +import "../interface/IRoyaltyEngineV1.sol"; +import { IERC2981 } from "../../eip/interface/IERC2981.sol"; + +library RoyaltyPaymentsStorage { + /// @custom:storage-location erc7201:royalty.payments.storage + /// @dev keccak256(abi.encode(uint256(keccak256("royalty.payments.storage")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 public constant ROYALTY_PAYMENTS_STORAGE_POSITION = + 0xc802b338f3fb784853cf3c808df5ff08335200e394ea2c687d12571a91045000; + + struct Data { + /// @dev The address of RoyaltyEngineV1, replacing the one set during construction. + address royaltyEngineAddressOverride; + } + + function royaltyPaymentsStorage() internal pure returns (Data storage royaltyPaymentsData) { + bytes32 position = ROYALTY_PAYMENTS_STORAGE_POSITION; + assembly { + royaltyPaymentsData.slot := position + } + } +} + +/** + * @author thirdweb.com + * + * @title Royalty Payments + * @notice Thirdweb's `RoyaltyPayments` is a contract extension to be used with a marketplace contract. + * It exposes functions for fetching royalty settings for a token. + * It Supports RoyaltyEngineV1 and RoyaltyRegistry by manifold.xyz. + */ + +abstract contract RoyaltyPaymentsLogic is IRoyaltyPayments { + // solhint-disable-next-line var-name-mixedcase + address immutable ROYALTY_ENGINE_ADDRESS; + + constructor(address _royaltyEngineAddress) { + // allow address(0) in case RoyaltyEngineV1 not present on a network + require( + _royaltyEngineAddress == address(0) || + IERC165(_royaltyEngineAddress).supportsInterface(type(IRoyaltyEngineV1).interfaceId), + "Doesn't support IRoyaltyEngineV1 interface" + ); + + ROYALTY_ENGINE_ADDRESS = _royaltyEngineAddress; + } + + /** + * Get the royalty for a given token (address, id) and value amount. Does not cache the bps/amounts. Caches the spec for a given token address + * + * @param tokenAddress - The address of the token + * @param tokenId - The id of the token + * @param value - The value you wish to get the royalty of + * + * returns Two arrays of equal length, royalty recipients and the corresponding amount each recipient should get + */ + function getRoyalty( + address tokenAddress, + uint256 tokenId, + uint256 value + ) external returns (address payable[] memory recipients, uint256[] memory amounts) { + address royaltyEngineAddress = getRoyaltyEngineAddress(); + + if (royaltyEngineAddress == address(0)) { + try IERC2981(tokenAddress).royaltyInfo(tokenId, value) returns (address recipient, uint256 amount) { + require(amount <= value, "Invalid royalty amount"); + + recipients = new address payable[](1); + amounts = new uint256[](1); + recipients[0] = payable(recipient); + amounts[0] = amount; + } catch {} + } else { + (recipients, amounts) = IRoyaltyEngineV1(royaltyEngineAddress).getRoyalty(tokenAddress, tokenId, value); + } + } + + /** + * Set or override RoyaltyEngine address + * + * @param _royaltyEngineAddress - RoyaltyEngineV1 address + */ + function setRoyaltyEngine(address _royaltyEngineAddress) external { + if (!_canSetRoyaltyEngine()) { + revert("Not authorized"); + } + + require( + _royaltyEngineAddress != address(0) && + IERC165(_royaltyEngineAddress).supportsInterface(type(IRoyaltyEngineV1).interfaceId), + "Doesn't support IRoyaltyEngineV1 interface" + ); + + _setupRoyaltyEngine(_royaltyEngineAddress); + } + + /// @dev Returns original or overridden address for RoyaltyEngineV1 + function getRoyaltyEngineAddress() public view returns (address royaltyEngineAddress) { + RoyaltyPaymentsStorage.Data storage data = RoyaltyPaymentsStorage.royaltyPaymentsStorage(); + address royaltyEngineOverride = data.royaltyEngineAddressOverride; + royaltyEngineAddress = royaltyEngineOverride != address(0) ? royaltyEngineOverride : ROYALTY_ENGINE_ADDRESS; + } + + /// @dev Lets a contract admin update the royalty engine address + function _setupRoyaltyEngine(address _royaltyEngineAddress) internal { + RoyaltyPaymentsStorage.Data storage data = RoyaltyPaymentsStorage.royaltyPaymentsStorage(); + address currentAddress = data.royaltyEngineAddressOverride; + + data.royaltyEngineAddressOverride = _royaltyEngineAddress; + + emit RoyaltyEngineUpdated(currentAddress, _royaltyEngineAddress); + } + + /// @dev Returns whether royalty engine address can be set in the given execution context. + function _canSetRoyaltyEngine() internal view virtual returns (bool); +} diff --git a/contracts/extension/upgradeable/RulesEngine.sol b/contracts/extension/upgradeable/RulesEngine.sol new file mode 100644 index 000000000..93fa215b9 --- /dev/null +++ b/contracts/extension/upgradeable/RulesEngine.sol @@ -0,0 +1,163 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/// @author thirdweb + +import "../interface/IRulesEngine.sol"; + +import "../../eip/interface/IERC20.sol"; +import "../../eip/interface/IERC20Metadata.sol"; +import "../../eip/interface/IERC721.sol"; +import "../../eip/interface/IERC1155.sol"; + +import "../../external-deps/openzeppelin/utils/structs/EnumerableSet.sol"; + +library RulesEngineStorage { + /// @custom:storage-location erc7201:rules.engine.storage + /// @dev keccak256(abi.encode(uint256(keccak256("rules.engine.storage")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 public constant RULES_ENGINE_STORAGE_POSITION = + 0x41d4cb087b2c44a761b2288e4c8ac115e76a546efd837c9a2e9cec2661a49a00; + + struct Data { + address rulesEngineOverride; + EnumerableSet.Bytes32Set ids; + mapping(bytes32 => IRulesEngine.RuleWithId) rules; + } + + function data() internal pure returns (Data storage data_) { + bytes32 position = RULES_ENGINE_STORAGE_POSITION; + assembly { + data_.slot := position + } + } +} + +abstract contract RulesEngine is IRulesEngine { + using EnumerableSet for EnumerableSet.Bytes32Set; + + /*/////////////////////////////////////////////////////////////// + View functions + //////////////////////////////////////////////////////////////*/ + + function getScore(address _tokenOwner) public view returns (uint256 score) { + address engineOverride = getRulesEngineOverride(); + if (engineOverride != address(0)) { + return IRulesEngine(engineOverride).getScore(_tokenOwner); + } + + bytes32[] memory ids = _rulesEngineStorage().ids.values(); + uint256 len = ids.length; + + for (uint256 i = 0; i < len; i += 1) { + RuleWithId memory rule = _rulesEngineStorage().rules[ids[i]]; + score += _getScoreForRule(_tokenOwner, rule); + } + } + + function getAllRules() external view returns (RuleWithId[] memory rules) { + bytes32[] memory ids = _rulesEngineStorage().ids.values(); + uint256 len = ids.length; + + rules = new RuleWithId[](len); + + for (uint256 i = 0; i < len; i += 1) { + rules[i] = _rulesEngineStorage().rules[ids[i]]; + } + } + + function getRulesEngineOverride() public view returns (address rulesEngineAddress) { + rulesEngineAddress = _rulesEngineStorage().rulesEngineOverride; + } + + /*/////////////////////////////////////////////////////////////// + External functions + //////////////////////////////////////////////////////////////*/ + + function createRuleMultiplicative(RuleTypeMultiplicative memory rule) external returns (bytes32 ruleId) { + require(_canSetRules(), "RulesEngine: cannot set rules"); + + ruleId = keccak256( + abi.encodePacked(rule.token, rule.tokenType, rule.tokenId, rule.scorePerOwnedToken, RuleType.Multiplicative) + ); + _createRule( + RuleWithId( + ruleId, + rule.token, + rule.tokenType, + rule.tokenId, + 0, // balance + rule.scorePerOwnedToken, + RuleType.Multiplicative + ) + ); + } + + function createRuleThreshold(RuleTypeThreshold memory rule) external returns (bytes32 ruleId) { + require(_canSetRules(), "RulesEngine: cannot set rules"); + + ruleId = keccak256( + abi.encodePacked(rule.token, rule.tokenType, rule.tokenId, rule.balance, rule.score, RuleType.Threshold) + ); + _createRule( + RuleWithId(ruleId, rule.token, rule.tokenType, rule.tokenId, rule.balance, rule.score, RuleType.Threshold) + ); + } + + function deleteRule(bytes32 _ruleId) external { + require(_canSetRules(), "RulesEngine: cannot set rules"); + _deleteRule(_ruleId); + } + + /*/////////////////////////////////////////////////////////////// + Internal functions + //////////////////////////////////////////////////////////////*/ + + function _getScoreForRule(address _tokenOwner, RuleWithId memory _rule) internal view returns (uint256 score) { + uint256 balance = 0; + + if (_rule.tokenType == TokenType.ERC20) { + // NOTE: We are rounding down the ERC20 balance to the nearest full unit. + uint256 unit = 10 ** IERC20Metadata(_rule.token).decimals(); + balance = IERC20(_rule.token).balanceOf(_tokenOwner) / unit; + } else if (_rule.tokenType == TokenType.ERC721) { + balance = IERC721(_rule.token).balanceOf(_tokenOwner); + } else if (_rule.tokenType == TokenType.ERC1155) { + balance = IERC1155(_rule.token).balanceOf(_tokenOwner, _rule.tokenId); + } + + if (_rule.ruleType == RuleType.Threshold) { + if (balance >= _rule.balance) { + score = _rule.score; + } + } else if (_rule.ruleType == RuleType.Multiplicative) { + score = balance * _rule.score; + } + } + + function _createRule(RuleWithId memory _rule) internal { + require(_rulesEngineStorage().ids.add(_rule.ruleId), "RulesEngine: rule already exists"); + _rulesEngineStorage().rules[_rule.ruleId] = _rule; + emit RuleCreated(_rule.ruleId, _rule); + } + + function _deleteRule(bytes32 _ruleId) internal { + require(_rulesEngineStorage().ids.remove(_ruleId), "RulesEngine: rule already exists"); + delete _rulesEngineStorage().rules[_ruleId]; + emit RuleDeleted(_ruleId); + } + + function setRulesEngineOverride(address _rulesEngineAddress) external { + require(_canOverrideRulesEngine(), "RulesEngine: cannot override rules engine"); + _rulesEngineStorage().rulesEngineOverride = _rulesEngineAddress; + + emit RulesEngineOverriden(_rulesEngineAddress); + } + + function _rulesEngineStorage() internal pure returns (RulesEngineStorage.Data storage data) { + data = RulesEngineStorage.data(); + } + + function _canSetRules() internal view virtual returns (bool); + + function _canOverrideRulesEngine() internal view virtual returns (bool); +} diff --git a/contracts/extension/upgradeable/SharedMetadataBatch.sol b/contracts/extension/upgradeable/SharedMetadataBatch.sol new file mode 100644 index 000000000..c4001864d --- /dev/null +++ b/contracts/extension/upgradeable/SharedMetadataBatch.sol @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/// @author thirdweb + +import "../../lib/NFTMetadataRenderer.sol"; +import "../interface/ISharedMetadataBatch.sol"; +import "../../external-deps/openzeppelin/utils/EnumerableSet.sol"; + +/** + * @title Shared Metadata Batch + * @notice Store a batch of shared metadata for NFTs + */ +library SharedMetadataBatchStorage { + /// @custom:storage-location erc7201:shared.metadata.batch.storage + /// @dev keccak256(abi.encode(uint256(keccak256("shared.metadata.batch.storage")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 public constant SHARED_METADATA_BATCH_STORAGE_POSITION = + 0xf85ae2b98503142dac20c6561e88360cff7f1cb5634b6ad090b7f724e2f67a00; + + struct Data { + EnumerableSet.Bytes32Set ids; + mapping(bytes32 => ISharedMetadataBatch.SharedMetadataWithId) metadata; + } + + function data() internal pure returns (Data storage data_) { + bytes32 position = SHARED_METADATA_BATCH_STORAGE_POSITION; + assembly { + data_.slot := position + } + } +} + +abstract contract SharedMetadataBatch is ISharedMetadataBatch { + using EnumerableSet for EnumerableSet.Bytes32Set; + + /// @notice Set shared metadata for NFTs + function setSharedMetadata(SharedMetadataInfo calldata metadata, bytes32 _id) external { + require(_canSetSharedMetadata(), "SharedMetadataBatch: cannot set shared metadata"); + _createSharedMetadata(metadata, _id); + } + + /// @notice Delete shared metadata for NFTs + function deleteSharedMetadata(bytes32 _id) external { + require(_canSetSharedMetadata(), "SharedMetadataBatch: cannot set shared metadata"); + require(_sharedMetadataBatchStorage().ids.remove(_id), "SharedMetadataBatch: shared metadata does not exist"); + + delete _sharedMetadataBatchStorage().metadata[_id]; + + emit SharedMetadataDeleted(_id); + } + + /// @notice Get all shared metadata + function getAllSharedMetadata() external view returns (SharedMetadataWithId[] memory metadata) { + bytes32[] memory ids = _sharedMetadataBatchStorage().ids.values(); + metadata = new SharedMetadataWithId[](ids.length); + + for (uint256 i = 0; i < ids.length; i += 1) { + metadata[i] = _sharedMetadataBatchStorage().metadata[ids[i]]; + } + } + + /// @dev Store shared metadata + function _createSharedMetadata(SharedMetadataInfo calldata _metadata, bytes32 _id) internal { + require(_sharedMetadataBatchStorage().ids.add(_id), "SharedMetadataBatch: shared metadata already exists"); + + _sharedMetadataBatchStorage().metadata[_id] = SharedMetadataWithId(_id, _metadata); + + emit SharedMetadataUpdated( + _id, + _metadata.name, + _metadata.description, + _metadata.imageURI, + _metadata.animationURI + ); + } + + /// @dev Token URI information getter + function _getURIFromSharedMetadata(bytes32 id, uint256 tokenId) internal view returns (string memory) { + SharedMetadataInfo memory info = _sharedMetadataBatchStorage().metadata[id].metadata; + + return + NFTMetadataRenderer.createMetadataEdition({ + name: info.name, + description: info.description, + imageURI: info.imageURI, + animationURI: info.animationURI, + tokenOfEdition: tokenId + }); + } + + /// @dev Get contract storage + function _sharedMetadataBatchStorage() internal pure returns (SharedMetadataBatchStorage.Data storage data) { + data = SharedMetadataBatchStorage.data(); + } + + /// @dev Returns whether shared metadata can be set in the given execution context. + function _canSetSharedMetadata() internal view virtual returns (bool); +} diff --git a/contracts/extension/upgradeable/impl/ContractMetadataImpl.sol b/contracts/extension/upgradeable/impl/ContractMetadataImpl.sol new file mode 100644 index 000000000..777cd9202 --- /dev/null +++ b/contracts/extension/upgradeable/impl/ContractMetadataImpl.sol @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "../ContractMetadata.sol"; + +import "../../interface/IPermissions.sol"; +import "../../interface/IERC2771Context.sol"; + +contract ContractMetadataImpl is ContractMetadata { + bytes32 private constant DEFAULT_ADMIN_ROLE = 0x00; + + function _canSetContractURI() internal view override returns (bool) { + return IPermissions(address(this)).hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + function _msgSender() internal view returns (address sender) { + if (IERC2771Context(address(this)).isTrustedForwarder(msg.sender)) { + // The assembly code is more direct than the Solidity version using `abi.decode`. + assembly { + sender := shr(96, calldataload(sub(calldatasize(), 20))) + } + } else { + return msg.sender; + } + } + + function _msgData() internal view returns (bytes calldata) { + if (IERC2771Context(address(this)).isTrustedForwarder(msg.sender)) { + return msg.data[:msg.data.length - 20]; + } else { + return msg.data; + } + } +} diff --git a/contracts/extension/upgradeable/impl/MetaTx.sol b/contracts/extension/upgradeable/impl/MetaTx.sol new file mode 100644 index 000000000..0904eef78 --- /dev/null +++ b/contracts/extension/upgradeable/impl/MetaTx.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "../ERC2771Context.sol"; + +contract MetaTx is ERC2771Context { + constructor(address[] memory trustedForwarder) ERC2771Context(trustedForwarder) {} +} diff --git a/contracts/extension/upgradeable/impl/PermissionsEnumerableImpl.sol b/contracts/extension/upgradeable/impl/PermissionsEnumerableImpl.sol new file mode 100644 index 000000000..4216d2f01 --- /dev/null +++ b/contracts/extension/upgradeable/impl/PermissionsEnumerableImpl.sol @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "../../interface/IERC2771Context.sol"; +import "../PermissionsEnumerable.sol"; + +contract PermissionsEnumerableImpl is PermissionsEnumerable { + function _msgSender() internal view override returns (address sender) { + if (IERC2771Context(address(this)).isTrustedForwarder(msg.sender)) { + // The assembly code is more direct than the Solidity version using `abi.decode`. + assembly { + sender := shr(96, calldataload(sub(calldatasize(), 20))) + } + } else { + return msg.sender; + } + } + + function _msgData() internal view override returns (bytes calldata) { + if (IERC2771Context(address(this)).isTrustedForwarder(msg.sender)) { + return msg.data[:msg.data.length - 20]; + } else { + return msg.data; + } + } +} diff --git a/contracts/extension/upgradeable/impl/PlatformFeeImpl.sol b/contracts/extension/upgradeable/impl/PlatformFeeImpl.sol new file mode 100644 index 000000000..8010d89ed --- /dev/null +++ b/contracts/extension/upgradeable/impl/PlatformFeeImpl.sol @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "../PlatformFee.sol"; + +import "../../interface/IPermissions.sol"; +import "../../interface/IERC2771Context.sol"; + +contract PlatformFeeImpl is PlatformFee { + bytes32 private constant DEFAULT_ADMIN_ROLE = 0x00; + + function _canSetPlatformFeeInfo() internal view override returns (bool) { + return IPermissions(address(this)).hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + function _msgSender() internal view returns (address sender) { + if (IERC2771Context(address(this)).isTrustedForwarder(msg.sender)) { + // The assembly code is more direct than the Solidity version using `abi.decode`. + assembly { + sender := shr(96, calldataload(sub(calldatasize(), 20))) + } + } else { + return msg.sender; + } + } + + function _msgData() internal view returns (bytes calldata) { + if (IERC2771Context(address(this)).isTrustedForwarder(msg.sender)) { + return msg.data[:msg.data.length - 20]; + } else { + return msg.data; + } + } +} diff --git a/contracts/extension/upgradeable/init/ContractMetadataInit.sol b/contracts/extension/upgradeable/init/ContractMetadataInit.sol new file mode 100644 index 000000000..c61ad085f --- /dev/null +++ b/contracts/extension/upgradeable/init/ContractMetadataInit.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { ContractMetadataStorage } from "../ContractMetadata.sol"; + +contract ContractMetadataInit { + event ContractURIUpdated(string prevURI, string newURI); + + /// @dev Lets a contract admin set the URI for contract-level metadata. + function _setupContractURI(string memory _uri) internal { + ContractMetadataStorage.Data storage data = ContractMetadataStorage.data(); + string memory prevURI = data.contractURI; + data.contractURI = _uri; + + emit ContractURIUpdated(prevURI, _uri); + } +} diff --git a/contracts/extension/upgradeable/init/ERC2771ContextInit.sol b/contracts/extension/upgradeable/init/ERC2771ContextInit.sol new file mode 100644 index 000000000..eef0d3e5a --- /dev/null +++ b/contracts/extension/upgradeable/init/ERC2771ContextInit.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { ERC2771ContextStorage } from "../ERC2771Context.sol"; +import "../Initializable.sol"; + +contract ERC2771ContextInit is Initializable { + function __ERC2771Context_init(address[] memory trustedForwarder) internal onlyInitializing { + __ERC2771Context_init_unchained(trustedForwarder); + } + + function __ERC2771Context_init_unchained(address[] memory trustedForwarder) internal onlyInitializing { + ERC2771ContextStorage.Data storage data = ERC2771ContextStorage.data(); + + for (uint256 i = 0; i < trustedForwarder.length; i++) { + data.trustedForwarder[trustedForwarder[i]] = true; + } + } +} diff --git a/contracts/extension/upgradeable/init/ERC721AInit.sol b/contracts/extension/upgradeable/init/ERC721AInit.sol new file mode 100644 index 000000000..17cc7e48f --- /dev/null +++ b/contracts/extension/upgradeable/init/ERC721AInit.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { ERC721AStorage } from "../../../eip/ERC721AUpgradeable.sol"; +import "../Initializable.sol"; + +contract ERC721AInit is Initializable { + function __ERC721A_init(string memory name_, string memory symbol_) internal onlyInitializing { + __ERC721A_init_unchained(name_, symbol_); + } + + function __ERC721A_init_unchained(string memory name_, string memory symbol_) internal onlyInitializing { + ERC721AStorage.Data storage data = ERC721AStorage.erc721AStorage(); + + data._name = name_; + data._symbol = symbol_; + data._currentIndex = _startTokenId(); + } + + function _startTokenId() internal view virtual returns (uint256) { + return 0; + } +} diff --git a/contracts/extension/upgradeable/init/ERC721AQueryableInit.sol b/contracts/extension/upgradeable/init/ERC721AQueryableInit.sol new file mode 100644 index 000000000..eb5da0987 --- /dev/null +++ b/contracts/extension/upgradeable/init/ERC721AQueryableInit.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "../../../eip/queryable/ERC721AStorage.sol"; +import "../../../eip/queryable/ERC721A__Initializable.sol"; + +contract ERC721AQueryableInit is ERC721A__Initializable { + function __ERC721A_init(string memory name_, string memory symbol_) internal onlyInitializingERC721A { + __ERC721A_init_unchained(name_, symbol_); + } + + function __ERC721A_init_unchained(string memory name_, string memory symbol_) internal onlyInitializingERC721A { + ERC721AStorage.layout()._name = name_; + ERC721AStorage.layout()._symbol = symbol_; + ERC721AStorage.layout()._currentIndex = _startTokenId(); + } + + function _startTokenId() internal view virtual returns (uint256) { + return 0; + } +} diff --git a/contracts/extension/upgradeable/init/OwnableInit.sol b/contracts/extension/upgradeable/init/OwnableInit.sol new file mode 100644 index 000000000..5e6e8c2f1 --- /dev/null +++ b/contracts/extension/upgradeable/init/OwnableInit.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { OwnableStorage } from "../Ownable.sol"; + +contract OwnableInit { + event OwnerUpdated(address indexed prevOwner, address indexed newOwner); + + /// @dev Lets a contract admin set a new owner for the contract. The new owner must be a contract admin. + function _setupOwner(address _newOwner) internal { + OwnableStorage.Data storage data = OwnableStorage.data(); + + address _prevOwner = data._owner; + data._owner = _newOwner; + + emit OwnerUpdated(_prevOwner, _newOwner); + } +} diff --git a/contracts/extension/upgradeable/init/PermissionsEnumerableInit.sol b/contracts/extension/upgradeable/init/PermissionsEnumerableInit.sol new file mode 100644 index 000000000..4b00abd2b --- /dev/null +++ b/contracts/extension/upgradeable/init/PermissionsEnumerableInit.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { PermissionsEnumerableStorage } from "../PermissionsEnumerable.sol"; +import "./PermissionsInit.sol"; + +contract PermissionsEnumerableInit is PermissionsInit { + /// @dev Sets up `role` for `account` + function _setupRole(bytes32 role, address account) internal override { + super._setupRole(role, account); + _addMember(role, account); + } + + /// @dev adds `account` to {roleMembers}, for `role` + function _addMember(bytes32 role, address account) internal { + PermissionsEnumerableStorage.Data storage data = PermissionsEnumerableStorage.data(); + uint256 idx = data.roleMembers[role].index; + data.roleMembers[role].index += 1; + + data.roleMembers[role].members[idx] = account; + data.roleMembers[role].indexOf[account] = idx; + } +} diff --git a/contracts/extension/upgradeable/init/PermissionsInit.sol b/contracts/extension/upgradeable/init/PermissionsInit.sol new file mode 100644 index 000000000..549b6661e --- /dev/null +++ b/contracts/extension/upgradeable/init/PermissionsInit.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { PermissionsStorage } from "../Permissions.sol"; + +contract PermissionsInit { + event RoleAdminChanged(bytes32 indexed role, bytes32 indexed previousAdminRole, bytes32 indexed newAdminRole); + event RoleGranted(bytes32 indexed role, address indexed account, address indexed sender); + + /// @dev Default admin role for all roles. Only accounts with this role can grant/revoke other roles. + bytes32 internal constant DEFAULT_ADMIN_ROLE = 0x00; + + /// @dev Sets up `role` for `account` + function _setupRole(bytes32 role, address account) internal virtual { + PermissionsStorage.Data storage data = PermissionsStorage.data(); + data._hasRole[role][account] = true; + emit RoleGranted(role, account, msg.sender); + } + + /// @dev Sets `adminRole` as `role`'s admin role. + function _setRoleAdmin(bytes32 role, bytes32 adminRole) internal virtual { + PermissionsStorage.Data storage data = PermissionsStorage.data(); + bytes32 previousAdminRole = data._getRoleAdmin[role]; + data._getRoleAdmin[role] = adminRole; + emit RoleAdminChanged(role, previousAdminRole, adminRole); + } +} diff --git a/contracts/extension/upgradeable/init/PlatformFeeInit.sol b/contracts/extension/upgradeable/init/PlatformFeeInit.sol new file mode 100644 index 000000000..92f390b1c --- /dev/null +++ b/contracts/extension/upgradeable/init/PlatformFeeInit.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { PlatformFeeStorage } from "../PlatformFee.sol"; + +contract PlatformFeeInit { + event PlatformFeeInfoUpdated(address indexed platformFeeRecipient, uint256 platformFeeBps); + + /// @dev Lets a contract admin update the platform fee recipient and bps + function _setupPlatformFeeInfo(address _platformFeeRecipient, uint256 _platformFeeBps) internal { + if (_platformFeeBps > 10_000) { + revert("Exceeds max bps"); + } + if (_platformFeeRecipient == address(0)) { + revert("Invalid recipient"); + } + + PlatformFeeStorage.Data storage data = PlatformFeeStorage.data(); + + data.platformFeeBps = uint16(_platformFeeBps); + data.platformFeeRecipient = _platformFeeRecipient; + + emit PlatformFeeInfoUpdated(_platformFeeRecipient, _platformFeeBps); + } +} diff --git a/contracts/extension/upgradeable/init/PrimarySaleInit.sol b/contracts/extension/upgradeable/init/PrimarySaleInit.sol new file mode 100644 index 000000000..edd7862c6 --- /dev/null +++ b/contracts/extension/upgradeable/init/PrimarySaleInit.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { PrimarySaleStorage } from "../PrimarySale.sol"; + +contract PrimarySaleInit { + /// @dev Emitted when a new sale recipient is set. + event PrimarySaleRecipientUpdated(address indexed recipient); + + /// @dev Lets a contract admin set the recipient for all primary sales. + function _setupPrimarySaleRecipient(address _saleRecipient) internal { + if (_saleRecipient == address(0)) { + revert("Invalid recipient"); + } + PrimarySaleStorage.Data storage data = PrimarySaleStorage.data(); + data.recipient = _saleRecipient; + emit PrimarySaleRecipientUpdated(_saleRecipient); + } +} diff --git a/contracts/extension/upgradeable/init/ReentrancyGuardInit.sol b/contracts/extension/upgradeable/init/ReentrancyGuardInit.sol new file mode 100644 index 000000000..b8bac13e1 --- /dev/null +++ b/contracts/extension/upgradeable/init/ReentrancyGuardInit.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { ReentrancyGuardStorage } from "../ReentrancyGuard.sol"; +import "../Initializable.sol"; + +contract ReentrancyGuardInit is Initializable { + uint256 private constant _NOT_ENTERED = 1; + + function __ReentrancyGuard_init() internal onlyInitializing { + __ReentrancyGuard_init_unchained(); + } + + function __ReentrancyGuard_init_unchained() internal onlyInitializing { + ReentrancyGuardStorage.Data storage data = ReentrancyGuardStorage.data(); + data._status = _NOT_ENTERED; + } +} diff --git a/contracts/extension/upgradeable/init/RoyaltyInit.sol b/contracts/extension/upgradeable/init/RoyaltyInit.sol new file mode 100644 index 000000000..70e638796 --- /dev/null +++ b/contracts/extension/upgradeable/init/RoyaltyInit.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { RoyaltyStorage, IRoyalty } from "../Royalty.sol"; + +contract RoyaltyInit { + event DefaultRoyalty(address indexed newRoyaltyRecipient, uint256 newRoyaltyBps); + + /// @dev Lets a contract admin update the default royalty recipient and bps. + function _setupDefaultRoyaltyInfo(address _royaltyRecipient, uint256 _royaltyBps) internal { + if (_royaltyBps > 10_000) { + revert("Exceeds max bps"); + } + + RoyaltyStorage.Data storage data = RoyaltyStorage.data(); + + data.royaltyRecipient = _royaltyRecipient; + data.royaltyBps = uint16(_royaltyBps); + + emit DefaultRoyalty(_royaltyRecipient, _royaltyBps); + } +} diff --git a/contracts/external-deps/chainlink/LinkTokenInterface.sol b/contracts/external-deps/chainlink/LinkTokenInterface.sol new file mode 100644 index 000000000..203f8684c --- /dev/null +++ b/contracts/external-deps/chainlink/LinkTokenInterface.sol @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +interface LinkTokenInterface { + function allowance(address owner, address spender) external view returns (uint256 remaining); + + function approve(address spender, uint256 value) external returns (bool success); + + function balanceOf(address owner) external view returns (uint256 balance); + + function decimals() external view returns (uint8 decimalPlaces); + + function decreaseApproval(address spender, uint256 addedValue) external returns (bool success); + + function increaseApproval(address spender, uint256 subtractedValue) external; + + function name() external view returns (string memory tokenName); + + function symbol() external view returns (string memory tokenSymbol); + + function totalSupply() external view returns (uint256 totalTokensIssued); + + function transfer(address to, uint256 value) external returns (bool success); + + function transferAndCall(address to, uint256 value, bytes calldata data) external returns (bool success); + + function transferFrom(address from, address to, uint256 value) external returns (bool success); +} diff --git a/contracts/external-deps/chainlink/VRFV2WrapperConsumerBase.sol b/contracts/external-deps/chainlink/VRFV2WrapperConsumerBase.sol new file mode 100644 index 000000000..48a62ee60 --- /dev/null +++ b/contracts/external-deps/chainlink/VRFV2WrapperConsumerBase.sol @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import "./LinkTokenInterface.sol"; +import "./VRFV2WrapperInterface.sol"; + +/** ******************************************************************************* + * @notice Interface for contracts using VRF randomness through the VRF V2 wrapper + * ******************************************************************************** + * @dev PURPOSE + * + * @dev Create VRF V2 requests without the need for subscription management. Rather than creating + * @dev and funding a VRF V2 subscription, a user can use this wrapper to create one off requests, + * @dev paying up front rather than at fulfillment. + * + * @dev Since the price is determined using the gas price of the request transaction rather than + * @dev the fulfillment transaction, the wrapper charges an additional premium on callback gas + * @dev usage, in addition to some extra overhead costs associated with the VRFV2Wrapper contract. + * ***************************************************************************** + * @dev USAGE + * + * @dev Calling contracts must inherit from VRFV2WrapperConsumerBase. The consumer must be funded + * @dev with enough LINK to make the request, otherwise requests will revert. To request randomness, + * @dev call the 'requestRandomness' function with the desired VRF parameters. This function handles + * @dev paying for the request based on the current pricing. + * + * @dev Consumers must implement the fullfillRandomWords function, which will be called during + * @dev fulfillment with the randomness result. + */ +abstract contract VRFV2WrapperConsumerBase { + // solhint-disable-next-line var-name-mixedcase + LinkTokenInterface internal immutable LINK; + // solhint-disable-next-line var-name-mixedcase + VRFV2WrapperInterface internal immutable VRF_V2_WRAPPER; + + /** + * @param _link is the address of LinkToken + * @param _vrfV2Wrapper is the address of the VRFV2Wrapper contract + */ + constructor(address _link, address _vrfV2Wrapper) { + LINK = LinkTokenInterface(_link); + VRF_V2_WRAPPER = VRFV2WrapperInterface(_vrfV2Wrapper); + } + + /** + * @dev Requests randomness from the VRF V2 wrapper. + * + * @param _callbackGasLimit is the gas limit that should be used when calling the consumer's + * fulfillRandomWords function. + * @param _requestConfirmations is the number of confirmations to wait before fulfilling the + * request. A higher number of confirmations increases security by reducing the likelihood + * that a chain re-org changes a published randomness outcome. + * @param _numWords is the number of random words to request. + * + * @return requestId is the VRF V2 request ID of the newly created randomness request. + */ + function requestRandomness( + uint32 _callbackGasLimit, + uint16 _requestConfirmations, + uint32 _numWords + ) internal returns (uint256 requestId) { + LINK.transferAndCall( + address(VRF_V2_WRAPPER), + VRF_V2_WRAPPER.calculateRequestPrice(_callbackGasLimit), + abi.encode(_callbackGasLimit, _requestConfirmations, _numWords) + ); + return VRF_V2_WRAPPER.lastRequestId(); + } + + /** + * @notice fulfillRandomWords handles the VRF V2 wrapper response. The consuming contract must + * @notice implement it. + * + * @param _requestId is the VRF V2 request ID. + * @param _randomWords is the randomness result. + */ + function fulfillRandomWords(uint256 _requestId, uint256[] memory _randomWords) internal virtual; + + function rawFulfillRandomWords(uint256 _requestId, uint256[] memory _randomWords) external { + require(msg.sender == address(VRF_V2_WRAPPER), "only VRF V2 wrapper can fulfill"); + fulfillRandomWords(_requestId, _randomWords); + } +} diff --git a/contracts/external-deps/chainlink/VRFV2WrapperInterface.sol b/contracts/external-deps/chainlink/VRFV2WrapperInterface.sol new file mode 100644 index 000000000..b636940bb --- /dev/null +++ b/contracts/external-deps/chainlink/VRFV2WrapperInterface.sol @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +interface VRFV2WrapperInterface { + /** + * @return the request ID of the most recent VRF V2 request made by this wrapper. This should only + * be relied option within the same transaction that the request was made. + */ + function lastRequestId() external view returns (uint256); + + /** + * @notice Calculates the price of a VRF request with the given callbackGasLimit at the current + * @notice block. + * + * @dev This function relies on the transaction gas price which is not automatically set during + * @dev simulation. To estimate the price at a specific gas price, use the estimatePrice function. + * + * @param _callbackGasLimit is the gas limit used to estimate the price. + */ + function calculateRequestPrice(uint32 _callbackGasLimit) external view returns (uint256); + + /** + * @notice Estimates the price of a VRF request with a specific gas limit and gas price. + * + * @dev This is a convenience function that can be called in simulation to better understand + * @dev pricing. + * + * @param _callbackGasLimit is the gas limit used to estimate the price. + * @param _requestGasPriceWei is the gas price in wei used for the estimation. + */ + function estimateRequestPrice( + uint32 _callbackGasLimit, + uint256 _requestGasPriceWei + ) external view returns (uint256); +} diff --git a/contracts/external-deps/openzeppelin/ERC1155PresetUpgradeable.sol b/contracts/external-deps/openzeppelin/ERC1155PresetUpgradeable.sol new file mode 100644 index 000000000..e3163c3e2 --- /dev/null +++ b/contracts/external-deps/openzeppelin/ERC1155PresetUpgradeable.sol @@ -0,0 +1,160 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.11; + +import { AccessControlEnumerableUpgradeable } from "@openzeppelin/contracts-upgradeable/access/AccessControlEnumerableUpgradeable.sol"; +import { ERC1155BurnableUpgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC1155/extensions/ERC1155BurnableUpgradeable.sol"; +import { ERC1155PausableUpgradeable, ERC1155Upgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC1155/extensions/ERC1155PausableUpgradeable.sol"; +import { ContextUpgradeable } from "@openzeppelin/contracts-upgradeable/utils/ContextUpgradeable.sol"; + +/** + * Changelog: + * 1. implements ERC721Holder and ERC1155Holder + * 2. implements totalSupply + */ + +import "@openzeppelin/contracts-upgradeable/token/ERC721/utils/ERC721HolderUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/token/ERC1155/utils/ERC1155HolderUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; + +/** + * @dev {ERC1155} token, including: + * + * - ability for holders to burn (destroy) their tokens + * - a minter role that allows for token minting (creation) + * - a pauser role that allows to stop all token transfers + * + * This contract uses {AccessControl} to lock permissioned functions using the + * different roles - head to its documentation for details. + * + * The account that deploys the contract will be granted the minter and pauser + * roles, as well as the default admin role, which will let it grant both minter + * and pauser roles to other accounts. + */ +contract ERC1155PresetUpgradeable is + Initializable, + ContextUpgradeable, + ERC721HolderUpgradeable, + ERC1155HolderUpgradeable, + AccessControlEnumerableUpgradeable, + ERC1155BurnableUpgradeable, + ERC1155PausableUpgradeable +{ + bytes32 internal constant MINTER_ROLE = keccak256("MINTER_ROLE"); + bytes32 internal constant PAUSER_ROLE = keccak256("PAUSER_ROLE"); + + mapping(uint256 => uint256) private _totalSupply; + + /// @dev Initializes the contract, like a constructor. + function __ERC1155Preset_init(address _deployer, string memory uri) internal onlyInitializing { + // Initialize inherited contracts, most base-like -> most derived. + __ERC1155_init(uri); + + __ERC1155Preset_init_unchained(_deployer); + } + + function __ERC1155Preset_init_unchained(address _deployer) internal onlyInitializing { + _setupRole(MINTER_ROLE, _deployer); + _setupRole(PAUSER_ROLE, _deployer); + } + + /** + * @dev Total amount of tokens in with a given id. + */ + function totalSupply(uint256 id) public view virtual returns (uint256) { + return _totalSupply[id]; + } + + /** + * @dev Creates `amount` new tokens for `to`, of token type `id`. + * + * See {ERC1155-_mint}. + * + * Requirements: + * + * - the caller must have the `MINTER_ROLE`. + */ + function mint(address to, uint256 id, uint256 amount, bytes memory data) public virtual { + require(hasRole(MINTER_ROLE, _msgSender()), "must have minter role"); + + _mint(to, id, amount, data); + } + + /** + * @dev xref:ROOT:erc1155.adoc#batch-operations[Batched] variant of {mint}. + */ + function mintBatch(address to, uint256[] memory ids, uint256[] memory amounts, bytes memory data) public virtual { + require(hasRole(MINTER_ROLE, _msgSender()), "must have minter role"); + + _mintBatch(to, ids, amounts, data); + } + + /** + * @dev Pauses all token transfers. + * + * See {ERC1155Pausable} and {Pausable-_pause}. + * + * Requirements: + * + * - the caller must have the `PAUSER_ROLE`. + */ + function pause() public virtual { + require(hasRole(PAUSER_ROLE, _msgSender()), "must have pauser role"); + _pause(); + } + + /** + * @dev Unpauses all token transfers. + * + * See {ERC1155Pausable} and {Pausable-_unpause}. + * + * Requirements: + * + * - the caller must have the `PAUSER_ROLE`. + */ + function unpause() public virtual { + require(hasRole(PAUSER_ROLE, _msgSender()), "must have pauser role"); + _unpause(); + } + + /** + * @dev See {IERC165-supportsInterface}. + */ + function supportsInterface( + bytes4 interfaceId + ) + public + view + virtual + override(AccessControlEnumerableUpgradeable, ERC1155Upgradeable, ERC1155ReceiverUpgradeable) + returns (bool) + { + return super.supportsInterface(interfaceId); + } + + /** + * @dev See {ERC1155-_beforeTokenTransfer}. + */ + function _beforeTokenTransfer( + address operator, + address from, + address to, + uint256[] memory ids, + uint256[] memory amounts, + bytes memory data + ) internal virtual override(ERC1155Upgradeable, ERC1155PausableUpgradeable) { + super._beforeTokenTransfer(operator, from, to, ids, amounts, data); + + if (from == address(0)) { + for (uint256 i = 0; i < ids.length; ++i) { + _totalSupply[ids[i]] += amounts[i]; + } + } + + if (to == address(0)) { + for (uint256 i = 0; i < ids.length; ++i) { + _totalSupply[ids[i]] -= amounts[i]; + } + } + } +} diff --git a/contracts/external-deps/openzeppelin/cryptography/EIP712ChainlessDomain.sol b/contracts/external-deps/openzeppelin/cryptography/EIP712ChainlessDomain.sol new file mode 100644 index 000000000..f5be7dd7c --- /dev/null +++ b/contracts/external-deps/openzeppelin/cryptography/EIP712ChainlessDomain.sol @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts v4.4.1 (utils/cryptography/draft-EIP712.sol) + +pragma solidity ^0.8.0; + +import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; + +/** + * @dev https://eips.ethereum.org/EIPS/eip-712[EIP 712] is a standard for hashing and signing of typed structured data. + * + * The encoding specified in the EIP is very generic, and such a generic implementation in Solidity is not feasible, + * thus this contract does not implement the encoding itself. Protocols need to implement the type-specific encoding + * they need in their contracts using a combination of `abi.encode` and `keccak256`. + * + * This contract implements the EIP 712 domain separator ({_domainSeparatorV4}) that is used as part of the encoding + * scheme, and the final step of the encoding to obtain the message digest that is then signed via ECDSA + * ({_hashTypedDataV4}). + * + * The implementation of the domain separator was designed to be as efficient as possible while still properly updating + * the chain id to protect against replay attacks on an eventual fork of the chain. + * + * NOTE: This contract implements the version of the encoding known as "v4", as implemented by the JSON RPC method + * https://docs.metamask.io/guide/signing-data.html[`eth_signTypedDataV4` in MetaMask]. + * + * _Available since v3.4._ + */ +abstract contract EIP712ChainlessDomain { + /* solhint-disable var-name-mixedcase */ + // Cache the domain separator as an immutable value, but also store the chain id that it corresponds to, in order to + // invalidate the cached domain separator if the chain id changes. + bytes32 private immutable _CACHED_DOMAIN_SEPARATOR; + address private immutable _CACHED_THIS; + + bytes32 private immutable _HASHED_NAME; + bytes32 private immutable _HASHED_VERSION; + bytes32 private immutable _TYPE_HASH; + + /* solhint-enable var-name-mixedcase */ + + /** + * @dev Initializes the domain separator and parameter caches. + * + * The meaning of `name` and `version` is specified in + * https://eips.ethereum.org/EIPS/eip-712#definition-of-domainseparator[EIP 712]: + * + * - `name`: the user readable name of the signing domain, i.e. the name of the DApp or the protocol. + * - `version`: the current major version of the signing domain. + * + * NOTE: These parameters cannot be changed except through a xref:learn::upgrading-smart-contracts.adoc[smart + * contract upgrade]. + */ + constructor(string memory name, string memory version) { + bytes32 hashedName = keccak256(bytes(name)); + bytes32 hashedVersion = keccak256(bytes(version)); + bytes32 typeHash = keccak256("EIP712Domain(string name,string version,address verifyingContract)"); + _HASHED_NAME = hashedName; + _HASHED_VERSION = hashedVersion; + _CACHED_DOMAIN_SEPARATOR = _buildDomainSeparator(typeHash, hashedName, hashedVersion); + _CACHED_THIS = address(this); + _TYPE_HASH = typeHash; + } + + /** + * @dev Returns the domain separator for the current chain. + */ + function _domainSeparatorV4() internal view returns (bytes32) { + if (address(this) == _CACHED_THIS) { + return _CACHED_DOMAIN_SEPARATOR; + } else { + return _buildDomainSeparator(_TYPE_HASH, _HASHED_NAME, _HASHED_VERSION); + } + } + + function _buildDomainSeparator( + bytes32 typeHash, + bytes32 nameHash, + bytes32 versionHash + ) private view returns (bytes32) { + return keccak256(abi.encode(typeHash, nameHash, versionHash, address(this))); + } + + /** + * @dev Given an already https://eips.ethereum.org/EIPS/eip-712#definition-of-hashstruct[hashed struct], this + * function returns the hash of the fully encoded EIP712 message for this domain. + * + * This hash can be used together with {ECDSA-recover} to obtain the signer of a message. For example: + * + * ```solidity + * bytes32 digest = _hashTypedDataV4(keccak256(abi.encode( + * keccak256("Mail(address to,string contents)"), + * mailTo, + * keccak256(bytes(mailContents)) + * ))); + * address signer = ECDSA.recover(digest, signature); + * ``` + */ + function _hashTypedDataV4(bytes32 structHash) internal view virtual returns (bytes32) { + return ECDSA.toTypedDataHash(_domainSeparatorV4(), structHash); + } +} diff --git a/contracts/external-deps/openzeppelin/finance/PaymentSplitterUpgradeable.sol b/contracts/external-deps/openzeppelin/finance/PaymentSplitterUpgradeable.sol new file mode 100644 index 000000000..c60b0048d --- /dev/null +++ b/contracts/external-deps/openzeppelin/finance/PaymentSplitterUpgradeable.sol @@ -0,0 +1,231 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts v4.4.1 (finance/PaymentSplitter.sol) + +pragma solidity ^0.8.11; + +import "@openzeppelin/contracts-upgradeable/token/ERC20/utils/SafeERC20Upgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/utils/AddressUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/utils/ContextUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; + +/** + * Changelog: + * - Change state variable visibility to internal: + * - `_totalReleased`, `_released`, `_erc20TotalReleased`, `_erc20Released`, `_pendingPayment` + * + * - Add `payeeCount`: returns the length of `_payees` + * - Add `releasable` functions as per recent updates in OZ PaymentSplitterUpgradeable (v4.7.0) + */ + +/** + * @title PaymentSplitter + * @dev This contract allows to split Ether payments among a group of accounts. The sender does not need to be aware + * that the Ether will be split in this way, since it is handled transparently by the contract. + * + * The split can be in equal parts or in any other arbitrary proportion. The way this is specified is by assigning each + * account to a number of shares. Of all the Ether that this contract receives, each account will then be able to claim + * an amount proportional to the percentage of total shares they were assigned. + * + * `PaymentSplitter` follows a _pull payment_ model. This means that payments are not automatically forwarded to the + * accounts but kept in this contract, and the actual transfer is triggered as a separate step by calling the {release} + * function. + * + * NOTE: This contract assumes that ERC20 tokens will behave similarly to native tokens (Ether). Rebasing tokens, and + * tokens that apply fees during transfers, are likely to not be supported as expected. If in doubt, we encourage you + * to run tests before sending real value to this contract. + */ +contract PaymentSplitterUpgradeable is Initializable, ContextUpgradeable { + event PayeeAdded(address account, uint256 shares); + event PaymentReleased(address to, uint256 amount); + event ERC20PaymentReleased(IERC20Upgradeable indexed token, address to, uint256 amount); + event PaymentReceived(address from, uint256 amount); + + uint256 private _totalShares; + uint256 internal _totalReleased; + + mapping(address => uint256) internal _shares; + mapping(address => uint256) internal _released; + address[] private _payees; + + mapping(IERC20Upgradeable => uint256) internal _erc20TotalReleased; + mapping(IERC20Upgradeable => mapping(address => uint256)) internal _erc20Released; + + /** + * @dev Creates an instance of `PaymentSplitter` where each account in `payees` is assigned the number of shares at + * the matching position in the `shares` array. + * + * All addresses in `payees` must be non-zero. Both arrays must have the same non-zero length, and there must be no + * duplicates in `payees`. + */ + function __PaymentSplitter_init(address[] memory payees, uint256[] memory shares_) internal onlyInitializing { + __Context_init_unchained(); + __PaymentSplitter_init_unchained(payees, shares_); + } + + function __PaymentSplitter_init_unchained( + address[] memory payees, + uint256[] memory shares_ + ) internal onlyInitializing { + require(payees.length == shares_.length, "PaymentSplitter: payees and shares length mismatch"); + require(payees.length > 0, "PaymentSplitter: no payees"); + + for (uint256 i = 0; i < payees.length; i++) { + _addPayee(payees[i], shares_[i]); + } + } + + /** + * @dev The Ether received will be logged with {PaymentReceived} events. Note that these events are not fully + * reliable: it's possible for a contract to receive Ether without triggering this function. This only affects the + * reliability of the events, and not the actual splitting of Ether. + * + * To learn more about this see the Solidity documentation for + * https://solidity.readthedocs.io/en/latest/contracts.html#fallback-function[fallback + * functions]. + */ + receive() external payable virtual { + emit PaymentReceived(_msgSender(), msg.value); + } + + /** + * @dev Getter for the total shares held by payees. + */ + function totalShares() public view returns (uint256) { + return _totalShares; + } + + /** + * @dev Getter for the total amount of Ether already released. + */ + function totalReleased() public view returns (uint256) { + return _totalReleased; + } + + /** + * @dev Getter for the total amount of `token` already released. `token` should be the address of an IERC20 + * contract. + */ + function totalReleased(IERC20Upgradeable token) public view returns (uint256) { + return _erc20TotalReleased[token]; + } + + /** + * @dev Getter for the amount of shares held by an account. + */ + function shares(address account) public view returns (uint256) { + return _shares[account]; + } + + /** + * @dev Getter for the amount of Ether already released to a payee. + */ + function released(address account) public view returns (uint256) { + return _released[account]; + } + + /** + * @dev Getter for the amount of `token` tokens already released to a payee. `token` should be the address of an + * IERC20 contract. + */ + function released(IERC20Upgradeable token, address account) public view returns (uint256) { + return _erc20Released[token][account]; + } + + /** + * @dev Getter for the address of the payee number `index`. + */ + function payee(uint256 index) public view returns (address) { + return _payees[index]; + } + + /** + * @dev Get the number of payees + */ + function payeeCount() public view returns (uint256) { + return _payees.length; + } + + /** + * @dev Getter for the amount of payee's releasable Ether. + */ + function releasable(address account) public view returns (uint256) { + uint256 totalReceived = address(this).balance + totalReleased(); + return _pendingPayment(account, totalReceived, released(account)); + } + + /** + * @dev Getter for the amount of payee's releasable `token` tokens. `token` should be the address of an + * IERC20 contract. + */ + function releasable(IERC20Upgradeable token, address account) public view returns (uint256) { + uint256 totalReceived = token.balanceOf(address(this)) + totalReleased(token); + return _pendingPayment(account, totalReceived, released(token, account)); + } + + /** + * @dev Triggers a transfer to `account` of the amount of Ether they are owed, according to their percentage of the + * total shares and their previous withdrawals. + */ + function release(address payable account) public virtual { + require(_shares[account] > 0, "PaymentSplitter: account has no shares"); + + uint256 payment = releasable(account); + + require(payment != 0, "PaymentSplitter: account is not due payment"); + + _released[account] += payment; + _totalReleased += payment; + + AddressUpgradeable.sendValue(account, payment); + emit PaymentReleased(account, payment); + } + + /** + * @dev Triggers a transfer to `account` of the amount of `token` tokens they are owed, according to their + * percentage of the total shares and their previous withdrawals. `token` must be the address of an IERC20 + * contract. + */ + function release(IERC20Upgradeable token, address account) public virtual { + require(_shares[account] > 0, "PaymentSplitter: account has no shares"); + + uint256 payment = releasable(token, account); + + require(payment != 0, "PaymentSplitter: account is not due payment"); + + _erc20Released[token][account] += payment; + _erc20TotalReleased[token] += payment; + + SafeERC20Upgradeable.safeTransfer(token, account, payment); + emit ERC20PaymentReleased(token, account, payment); + } + + /** + * @dev internal logic for computing the pending payment of an `account` given the token historical balances and + * already released amounts. + */ + function _pendingPayment( + address account, + uint256 totalReceived, + uint256 alreadyReleased + ) internal view returns (uint256) { + return (totalReceived * _shares[account]) / _totalShares - alreadyReleased; + } + + /** + * @dev Add a new payee to the contract. + * @param account The address of the payee to add. + * @param shares_ The number of shares owned by the payee. + */ + function _addPayee(address account, uint256 shares_) private { + require(account != address(0), "PaymentSplitter: account is the zero address"); + require(shares_ > 0, "PaymentSplitter: shares are 0"); + require(_shares[account] == 0, "PaymentSplitter: account already has shares"); + + _payees.push(account); + _shares[account] = shares_; + _totalShares = _totalShares + shares_; + emit PayeeAdded(account, shares_); + } + + uint256[43] private __gap; +} diff --git a/contracts/external-deps/openzeppelin/governance/utils/IVotes.sol b/contracts/external-deps/openzeppelin/governance/utils/IVotes.sol new file mode 100644 index 000000000..0bef3f920 --- /dev/null +++ b/contracts/external-deps/openzeppelin/governance/utils/IVotes.sol @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts (last updated v4.5.0) (governance/utils/IVotes.sol) +pragma solidity ^0.8.0; + +/** + * @dev Common interface for {ERC20Votes}, {ERC721Votes}, and other {Votes}-enabled contracts. + * + * _Available since v4.5._ + */ +interface IVotes { + /** + * @dev Emitted when an account changes their delegate. + */ + event DelegateChanged(address indexed delegator, address indexed fromDelegate, address indexed toDelegate); + + /** + * @dev Emitted when a token transfer or delegate change results in changes to a delegate's number of votes. + */ + event DelegateVotesChanged(address indexed delegate, uint256 previousBalance, uint256 newBalance); + + /** + * @dev Returns the current amount of votes that `account` has. + */ + function getVotes(address account) external view returns (uint256); + + /** + * @dev Returns the amount of votes that `account` had at the end of a past block (`blockNumber`). + */ + function getPastVotes(address account, uint256 blockNumber) external view returns (uint256); + + /** + * @dev Returns the total supply of votes available at the end of a past block (`blockNumber`). + * + * NOTE: This value is the sum of all available votes, which is not necessarily the sum of all delegated votes. + * Votes that have not been delegated are still part of total supply, even though they would not participate in a + * vote. + */ + function getPastTotalSupply(uint256 blockNumber) external view returns (uint256); + + /** + * @dev Returns the delegate that `account` has chosen. + */ + function delegates(address account) external view returns (address); + + /** + * @dev Delegates votes from the sender to `delegatee`. + */ + function delegate(address delegatee) external; + + /** + * @dev Delegates votes from signer to `delegatee`. + */ + function delegateBySig(address delegatee, uint256 nonce, uint256 expiry, uint8 v, bytes32 r, bytes32 s) external; +} diff --git a/contracts/external-deps/openzeppelin/metatx/ERC2771Context.sol b/contracts/external-deps/openzeppelin/metatx/ERC2771Context.sol new file mode 100644 index 000000000..788a9d235 --- /dev/null +++ b/contracts/external-deps/openzeppelin/metatx/ERC2771Context.sol @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts v4.4.0 (metatx/ERC2771Context.sol) + +pragma solidity ^0.8.11; + +import "@openzeppelin/contracts/utils/Context.sol"; + +/** + * @dev Context variant with ERC2771 support. + */ +abstract contract ERC2771Context is Context { + mapping(address => bool) private _trustedForwarder; + + constructor(address[] memory trustedForwarder) { + for (uint256 i = 0; i < trustedForwarder.length; i++) { + _trustedForwarder[trustedForwarder[i]] = true; + } + } + + function isTrustedForwarder(address forwarder) public view virtual returns (bool) { + return _trustedForwarder[forwarder]; + } + + function _msgSender() internal view virtual override returns (address sender) { + if (isTrustedForwarder(msg.sender)) { + // The assembly code is more direct than the Solidity version using `abi.decode`. + assembly { + sender := shr(96, calldataload(sub(calldatasize(), 20))) + } + } else { + return super._msgSender(); + } + } + + function _msgData() internal view virtual override returns (bytes calldata) { + if (isTrustedForwarder(msg.sender)) { + return msg.data[:msg.data.length - 20]; + } else { + return super._msgData(); + } + } + + uint256[49] private __gap; +} diff --git a/contracts/external-deps/openzeppelin/metatx/ERC2771ContextUpgradeable.sol b/contracts/external-deps/openzeppelin/metatx/ERC2771ContextUpgradeable.sol new file mode 100644 index 000000000..d0d803961 --- /dev/null +++ b/contracts/external-deps/openzeppelin/metatx/ERC2771ContextUpgradeable.sol @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts v4.4.0 (metatx/ERC2771Context.sol) + +pragma solidity ^0.8.11; + +import "@openzeppelin/contracts-upgradeable/utils/ContextUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; + +/** + * @dev Context variant with ERC2771 support. + */ +abstract contract ERC2771ContextUpgradeable is Initializable, ContextUpgradeable { + mapping(address => bool) private _trustedForwarder; + + function __ERC2771Context_init(address[] memory trustedForwarder) internal onlyInitializing { + __Context_init_unchained(); + __ERC2771Context_init_unchained(trustedForwarder); + } + + function __ERC2771Context_init_unchained(address[] memory trustedForwarder) internal onlyInitializing { + for (uint256 i = 0; i < trustedForwarder.length; i++) { + _trustedForwarder[trustedForwarder[i]] = true; + } + } + + function isTrustedForwarder(address forwarder) public view virtual returns (bool) { + return _trustedForwarder[forwarder]; + } + + function _msgSender() internal view virtual override returns (address sender) { + if (isTrustedForwarder(msg.sender)) { + // The assembly code is more direct than the Solidity version using `abi.decode`. + assembly { + sender := shr(96, calldataload(sub(calldatasize(), 20))) + } + } else { + return super._msgSender(); + } + } + + function _msgData() internal view virtual override returns (bytes calldata) { + if (isTrustedForwarder(msg.sender)) { + return msg.data[:msg.data.length - 20]; + } else { + return super._msgData(); + } + } + + uint256[49] private __gap; +} diff --git a/contracts/external-deps/openzeppelin/metatx/MinimalForwarderEOAOnly.sol b/contracts/external-deps/openzeppelin/metatx/MinimalForwarderEOAOnly.sol new file mode 100644 index 000000000..6a5c2ef61 --- /dev/null +++ b/contracts/external-deps/openzeppelin/metatx/MinimalForwarderEOAOnly.sol @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts (last updated v4.5.0) (metatx/MinimalForwarder.sol) + +pragma solidity ^0.8.0; + +import "../utils/cryptography/ECDSA.sol"; +import "../utils/cryptography/EIP712.sol"; + +/** + * @dev Simple minimal forwarder to be used together with an ERC2771 compatible contract. See {ERC2771Context}. + */ +contract MinimalForwarderEOAOnly is EIP712 { + using ECDSA for bytes32; + + struct ForwardRequest { + address from; + address to; + uint256 value; + uint256 gas; + uint256 nonce; + bytes data; + } + + bytes32 private constant _TYPEHASH = + keccak256("ForwardRequest(address from,address to,uint256 value,uint256 gas,uint256 nonce,bytes data)"); + + mapping(address => uint256) private _nonces; + + constructor() EIP712("GSNv2 Forwarder", "0.0.1") {} + + function getNonce(address from) public view returns (uint256) { + return _nonces[from]; + } + + function verify(ForwardRequest calldata req, bytes calldata signature) public view returns (bool) { + address signer = _hashTypedDataV4( + keccak256(abi.encode(_TYPEHASH, req.from, req.to, req.value, req.gas, req.nonce, keccak256(req.data))) + ).recover(signature); + return _nonces[req.from] == req.nonce && signer == req.from; + } + + function execute( + ForwardRequest calldata req, + bytes calldata signature + ) public payable returns (bool, bytes memory) { + require(msg.sender == tx.origin, "not EOA"); + require(verify(req, signature), "MinimalForwarder: signature does not match request"); + _nonces[req.from] = req.nonce + 1; + + (bool success, bytes memory returndata) = req.to.call{ gas: req.gas, value: req.value }( + abi.encodePacked(req.data, req.from) + ); + + // Validate that the relayer has sent enough gas for the call. + // See https://ronan.eth.link/blog/ethereum-gas-dangers/ + if (gasleft() <= req.gas / 63) { + // We explicitly trigger invalid opcode to consume all gas and bubble-up the effects, since + // neither revert or assert consume all gas since Solidity 0.8.0 + // https://docs.soliditylang.org/en/v0.8.0/control-structures.html#panic-via-assert-and-error-via-require + assembly { + invalid() + } + } + + return (success, returndata); + } +} diff --git a/contracts/external-deps/openzeppelin/proxy/Clones.sol b/contracts/external-deps/openzeppelin/proxy/Clones.sol new file mode 100644 index 000000000..712519892 --- /dev/null +++ b/contracts/external-deps/openzeppelin/proxy/Clones.sol @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts (last updated v4.8.0) (proxy/Clones.sol) + +pragma solidity ^0.8.0; + +/** + * @dev https://eips.ethereum.org/EIPS/eip-1167[EIP 1167] is a standard for + * deploying minimal proxy contracts, also known as "clones". + * + * > To simply and cheaply clone contract functionality in an immutable way, this standard specifies + * > a minimal bytecode implementation that delegates all calls to a known, fixed address. + * + * The library includes functions to deploy a proxy using either `create` (traditional deployment) or `create2` + * (salted deterministic deployment). It also includes functions to predict the addresses of clones deployed using the + * deterministic method. + * + * _Available since v3.4._ + */ +library Clones { + /** + * @dev Deploys and returns the address of a clone that mimics the behaviour of `implementation`. + * + * This function uses the create opcode, which should never revert. + */ + function clone(address implementation) internal returns (address instance) { + /// @solidity memory-safe-assembly + assembly { + // Cleans the upper 96 bits of the `implementation` word, then packs the first 3 bytes + // of the `implementation` address with the bytecode before the address. + mstore(0x00, or(shr(0xe8, shl(0x60, implementation)), 0x3d602d80600a3d3981f3363d3d373d3d3d363d73000000)) + // Packs the remaining 17 bytes of `implementation` with the bytecode after the address. + mstore(0x20, or(shl(0x78, implementation), 0x5af43d82803e903d91602b57fd5bf3)) + instance := create(0, 0x09, 0x37) + } + require(instance != address(0), "ERC1167: create failed"); + } + + /** + * @dev Deploys and returns the address of a clone that mimics the behaviour of `implementation`. + * + * This function uses the create2 opcode and a `salt` to deterministically deploy + * the clone. Using the same `implementation` and `salt` multiple time will revert, since + * the clones cannot be deployed twice at the same address. + */ + function cloneDeterministic(address implementation, bytes32 salt) internal returns (address instance) { + /// @solidity memory-safe-assembly + assembly { + // Cleans the upper 96 bits of the `implementation` word, then packs the first 3 bytes + // of the `implementation` address with the bytecode before the address. + mstore(0x00, or(shr(0xe8, shl(0x60, implementation)), 0x3d602d80600a3d3981f3363d3d373d3d3d363d73000000)) + // Packs the remaining 17 bytes of `implementation` with the bytecode after the address. + mstore(0x20, or(shl(0x78, implementation), 0x5af43d82803e903d91602b57fd5bf3)) + instance := create2(0, 0x09, 0x37, salt) + } + require(instance != address(0), "ERC1167: create2 failed"); + } + + /** + * @dev Computes the address of a clone deployed using {Clones-cloneDeterministic}. + */ + function predictDeterministicAddress( + address implementation, + bytes32 salt, + address deployer + ) internal pure returns (address predicted) { + /// @solidity memory-safe-assembly + assembly { + let ptr := mload(0x40) + mstore(add(ptr, 0x38), deployer) + mstore(add(ptr, 0x24), 0x5af43d82803e903d91602b57fd5bf3ff) + mstore(add(ptr, 0x14), implementation) + mstore(ptr, 0x3d602d80600a3d3981f3363d3d373d3d3d363d73) + mstore(add(ptr, 0x58), salt) + mstore(add(ptr, 0x78), keccak256(add(ptr, 0x0c), 0x37)) + predicted := keccak256(add(ptr, 0x43), 0x55) + } + } + + /** + * @dev Computes the address of a clone deployed using {Clones-cloneDeterministic}. + */ + function predictDeterministicAddress( + address implementation, + bytes32 salt + ) internal view returns (address predicted) { + return predictDeterministicAddress(implementation, salt, address(this)); + } +} diff --git a/contracts/external-deps/openzeppelin/proxy/ERC1967/ERC1967Proxy.sol b/contracts/external-deps/openzeppelin/proxy/ERC1967/ERC1967Proxy.sol new file mode 100644 index 000000000..a04d701ce --- /dev/null +++ b/contracts/external-deps/openzeppelin/proxy/ERC1967/ERC1967Proxy.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts (last updated v4.7.0) (proxy/ERC1967/ERC1967Proxy.sol) + +pragma solidity ^0.8.0; + +import "../Proxy.sol"; +import "./ERC1967Upgrade.sol"; + +/** + * @dev This contract implements an upgradeable proxy. It is upgradeable because calls are delegated to an + * implementation address that can be changed. This address is stored in storage in the location specified by + * https://eips.ethereum.org/EIPS/eip-1967[EIP1967], so that it doesn't conflict with the storage layout of the + * implementation behind the proxy. + */ +contract ERC1967Proxy is Proxy, ERC1967Upgrade { + /** + * @dev Initializes the upgradeable proxy with an initial implementation specified by `_logic`. + * + * If `_data` is nonempty, it's used as data in a delegate call to `_logic`. This will typically be an encoded + * function call, and allows initializing the storage of the proxy like a Solidity constructor. + */ + constructor(address _logic, bytes memory _data) payable { + _upgradeToAndCall(_logic, _data, false); + } + + /** + * @dev Returns the current implementation address. + */ + function _implementation() internal view virtual override returns (address impl) { + return ERC1967Upgrade._getImplementation(); + } +} diff --git a/contracts/external-deps/openzeppelin/proxy/ERC1967/ERC1967Upgrade.sol b/contracts/external-deps/openzeppelin/proxy/ERC1967/ERC1967Upgrade.sol new file mode 100644 index 000000000..d74e634a3 --- /dev/null +++ b/contracts/external-deps/openzeppelin/proxy/ERC1967/ERC1967Upgrade.sol @@ -0,0 +1,173 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts (last updated v4.5.0) (proxy/ERC1967/ERC1967Upgrade.sol) + +pragma solidity ^0.8.2; + +import "../beacon/IBeacon.sol"; +import "../IERC1822Proxiable.sol"; +import "../../../../lib/Address.sol"; +import "../../../../lib/StorageSlot.sol"; + +/** + * @dev This abstract contract provides getters and event emitting update functions for + * https://eips.ethereum.org/EIPS/eip-1967[EIP1967] slots. + * + * _Available since v4.1._ + * + * @custom:oz-upgrades-unsafe-allow delegatecall + */ +abstract contract ERC1967Upgrade { + // This is the keccak-256 hash of "eip1967.proxy.rollback" subtracted by 1 + bytes32 private constant _ROLLBACK_SLOT = 0x4910fdfa16fed3260ed0e7147f7cc6da11a60208b5b9406d12a635614ffd9143; + + /** + * @dev Storage slot with the address of the current implementation. + * This is the keccak-256 hash of "eip1967.proxy.implementation" subtracted by 1, and is + * validated in the constructor. + */ + bytes32 internal constant _IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc; + + /** + * @dev Emitted when the implementation is upgraded. + */ + event Upgraded(address indexed implementation); + + /** + * @dev Returns the current implementation address. + */ + function _getImplementation() internal view returns (address) { + return StorageSlot.getAddressSlot(_IMPLEMENTATION_SLOT).value; + } + + /** + * @dev Stores a new address in the EIP1967 implementation slot. + */ + function _setImplementation(address newImplementation) private { + require(Address.isContract(newImplementation), "ERC1967: new implementation is not a contract"); + StorageSlot.getAddressSlot(_IMPLEMENTATION_SLOT).value = newImplementation; + } + + /** + * @dev Perform implementation upgrade + * + * Emits an {Upgraded} event. + */ + function _upgradeTo(address newImplementation) internal { + _setImplementation(newImplementation); + emit Upgraded(newImplementation); + } + + /** + * @dev Perform implementation upgrade with additional setup call. + * + * Emits an {Upgraded} event. + */ + function _upgradeToAndCall(address newImplementation, bytes memory data, bool forceCall) internal { + _upgradeTo(newImplementation); + if (data.length > 0 || forceCall) { + Address.functionDelegateCall(newImplementation, data); + } + } + + /** + * @dev Perform implementation upgrade with security checks for UUPS proxies, and additional setup call. + * + * Emits an {Upgraded} event. + */ + function _upgradeToAndCallUUPS(address newImplementation, bytes memory data, bool forceCall) internal { + // Upgrades from old implementations will perform a rollback test. This test requires the new + // implementation to upgrade back to the old, non-ERC1822 compliant, implementation. Removing + // this special case will break upgrade paths from old UUPS implementation to new ones. + if (StorageSlot.getBooleanSlot(_ROLLBACK_SLOT).value) { + _setImplementation(newImplementation); + } else { + try IERC1822Proxiable(newImplementation).proxiableUUID() returns (bytes32 slot) { + require(slot == _IMPLEMENTATION_SLOT, "ERC1967Upgrade: unsupported proxiableUUID"); + } catch { + revert("ERC1967Upgrade: new implementation is not UUPS"); + } + _upgradeToAndCall(newImplementation, data, forceCall); + } + } + + /** + * @dev Storage slot with the admin of the contract. + * This is the keccak-256 hash of "eip1967.proxy.admin" subtracted by 1, and is + * validated in the constructor. + */ + bytes32 internal constant _ADMIN_SLOT = 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103; + + /** + * @dev Emitted when the admin account has changed. + */ + event AdminChanged(address previousAdmin, address newAdmin); + + /** + * @dev Returns the current admin. + */ + function _getAdmin() internal view returns (address) { + return StorageSlot.getAddressSlot(_ADMIN_SLOT).value; + } + + /** + * @dev Stores a new address in the EIP1967 admin slot. + */ + function _setAdmin(address newAdmin) private { + require(newAdmin != address(0), "ERC1967: new admin is the zero address"); + StorageSlot.getAddressSlot(_ADMIN_SLOT).value = newAdmin; + } + + /** + * @dev Changes the admin of the proxy. + * + * Emits an {AdminChanged} event. + */ + function _changeAdmin(address newAdmin) internal { + emit AdminChanged(_getAdmin(), newAdmin); + _setAdmin(newAdmin); + } + + /** + * @dev The storage slot of the UpgradeableBeacon contract which defines the implementation for this proxy. + * This is bytes32(uint256(keccak256('eip1967.proxy.beacon')) - 1)) and is validated in the constructor. + */ + bytes32 internal constant _BEACON_SLOT = 0xa3f0ad74e5423aebfd80d3ef4346578335a9a72aeaee59ff6cb3582b35133d50; + + /** + * @dev Emitted when the beacon is upgraded. + */ + event BeaconUpgraded(address indexed beacon); + + /** + * @dev Returns the current beacon. + */ + function _getBeacon() internal view returns (address) { + return StorageSlot.getAddressSlot(_BEACON_SLOT).value; + } + + /** + * @dev Stores a new beacon in the EIP1967 beacon slot. + */ + function _setBeacon(address newBeacon) private { + require(Address.isContract(newBeacon), "ERC1967: new beacon is not a contract"); + require( + Address.isContract(IBeacon(newBeacon).implementation()), + "ERC1967: beacon implementation is not a contract" + ); + StorageSlot.getAddressSlot(_BEACON_SLOT).value = newBeacon; + } + + /** + * @dev Perform beacon upgrade with additional setup call. Note: This upgrades the address of the beacon, it does + * not upgrade the implementation contained in the beacon (see {UpgradeableBeacon-_setImplementation} for that). + * + * Emits a {BeaconUpgraded} event. + */ + function _upgradeBeaconToAndCall(address newBeacon, bytes memory data, bool forceCall) internal { + _setBeacon(newBeacon); + emit BeaconUpgraded(newBeacon); + if (data.length > 0 || forceCall) { + Address.functionDelegateCall(IBeacon(newBeacon).implementation(), data); + } + } +} diff --git a/contracts/external-deps/openzeppelin/proxy/IERC1822Proxiable.sol b/contracts/external-deps/openzeppelin/proxy/IERC1822Proxiable.sol new file mode 100644 index 000000000..3b73d744c --- /dev/null +++ b/contracts/external-deps/openzeppelin/proxy/IERC1822Proxiable.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts (last updated v4.5.0) (interfaces/draft-IERC1822.sol) + +pragma solidity ^0.8.0; + +/** + * @dev ERC1822: Universal Upgradeable Proxy Standard (UUPS) documents a method for upgradeability through a simplified + * proxy whose upgrades are fully controlled by the current implementation. + */ +interface IERC1822Proxiable { + /** + * @dev Returns the storage slot that the proxiable contract assumes is being used to store the implementation + * address. + * + * IMPORTANT: A proxy pointing at a proxiable contract should not be considered proxiable itself, because this risks + * bricking a proxy that upgrades to it, by delegating to itself until out of gas. Thus it is critical that this + * function revert if invoked through a proxy. + */ + function proxiableUUID() external view returns (bytes32); +} diff --git a/contracts/external-deps/openzeppelin/proxy/Proxy.sol b/contracts/external-deps/openzeppelin/proxy/Proxy.sol new file mode 100644 index 000000000..988cf72a0 --- /dev/null +++ b/contracts/external-deps/openzeppelin/proxy/Proxy.sol @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts (last updated v4.6.0) (proxy/Proxy.sol) + +pragma solidity ^0.8.0; + +/** + * @dev This abstract contract provides a fallback function that delegates all calls to another contract using the EVM + * instruction `delegatecall`. We refer to the second contract as the _implementation_ behind the proxy, and it has to + * be specified by overriding the virtual {_implementation} function. + * + * Additionally, delegation to the implementation can be triggered manually through the {_fallback} function, or to a + * different contract through the {_delegate} function. + * + * The success and return data of the delegated call will be returned back to the caller of the proxy. + */ +abstract contract Proxy { + /** + * @dev Delegates the current call to `implementation`. + * + * This function does not return to its internal call site, it will return directly to the external caller. + */ + function _delegate(address implementation) internal virtual { + assembly { + // Copy msg.data. We take full control of memory in this inline assembly + // block because it will not return to Solidity code. We overwrite the + // Solidity scratch pad at memory position 0. + calldatacopy(0, 0, calldatasize()) + + // Call the implementation. + // out and outsize are 0 because we don't know the size yet. + let result := delegatecall(gas(), implementation, 0, calldatasize(), 0, 0) + + // Copy the returned data. + returndatacopy(0, 0, returndatasize()) + + switch result + // delegatecall returns 0 on error. + case 0 { + revert(0, returndatasize()) + } + default { + return(0, returndatasize()) + } + } + } + + /** + * @dev This is a virtual function that should be overridden so it returns the address to which the fallback function + * and {_fallback} should delegate. + */ + function _implementation() internal view virtual returns (address); + + /** + * @dev Delegates the current call to the address returned by `_implementation()`. + * + * This function does not return to its internal call site, it will return directly to the external caller. + */ + function _fallback() internal virtual { + _beforeFallback(); + _delegate(_implementation()); + } + + /** + * @dev Fallback function that delegates calls to the address returned by `_implementation()`. Will run if no other + * function in the contract matches the call data. + */ + fallback() external payable virtual { + _fallback(); + } + + /** + * @dev Fallback function that delegates calls to the address returned by `_implementation()`. Will run if call data + * is empty. + */ + receive() external payable virtual { + _fallback(); + } + + /** + * @dev Hook that is called before falling back to the implementation. Can happen as part of a manual `_fallback` + * call, or as part of the Solidity `fallback` or `receive` functions. + * + * If overridden should call `super._beforeFallback()`. + */ + function _beforeFallback() internal virtual {} +} diff --git a/contracts/external-deps/openzeppelin/proxy/beacon/IBeacon.sol b/contracts/external-deps/openzeppelin/proxy/beacon/IBeacon.sol new file mode 100644 index 000000000..fba3ee2ab --- /dev/null +++ b/contracts/external-deps/openzeppelin/proxy/beacon/IBeacon.sol @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts v4.4.1 (proxy/beacon/IBeacon.sol) + +pragma solidity ^0.8.0; + +/** + * @dev This is the interface that {BeaconProxy} expects of its beacon. + */ +interface IBeacon { + /** + * @dev Must return an address that can be used as a delegate call target. + * + * {BeaconProxy} will check that this address is a contract. + */ + function implementation() external view returns (address); +} diff --git a/contracts/external-deps/openzeppelin/proxy/utils/Initializable.sol b/contracts/external-deps/openzeppelin/proxy/utils/Initializable.sol new file mode 100644 index 000000000..a2ade335e --- /dev/null +++ b/contracts/external-deps/openzeppelin/proxy/utils/Initializable.sol @@ -0,0 +1,138 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts (last updated v4.7.0) (proxy/utils/Initializable.sol) + +pragma solidity ^0.8.2; + +import "../../../../lib/Address.sol"; + +/** + * @dev This is a base contract to aid in writing upgradeable contracts, or any kind of contract that will be deployed + * behind a proxy. Since proxied contracts do not make use of a constructor, it's common to move constructor logic to an + * external initializer function, usually called `initialize`. It then becomes necessary to protect this initializer + * function so it can only be called once. The {initializer} modifier provided by this contract will have this effect. + * + * The initialization functions use a version number. Once a version number is used, it is consumed and cannot be + * reused. This mechanism prevents re-execution of each "step" but allows the creation of new initialization steps in + * case an upgrade adds a module that needs to be initialized. + * + * For example: + * + * [.hljs-theme-light.nopadding] + * ``` + * contract MyToken is ERC20Upgradeable { + * function initialize() initializer public { + * __ERC20_init("MyToken", "MTK"); + * } + * } + * contract MyTokenV2 is MyToken, ERC20PermitUpgradeable { + * function initializeV2() reinitializer(2) public { + * __ERC20Permit_init("MyToken"); + * } + * } + * ``` + * + * TIP: To avoid leaving the proxy in an uninitialized state, the initializer function should be called as early as + * possible by providing the encoded function call as the `_data` argument to {ERC1967Proxy-constructor}. + * + * CAUTION: When used with inheritance, manual care must be taken to not invoke a parent initializer twice, or to ensure + * that all initializers are idempotent. This is not verified automatically as constructors are by Solidity. + * + * [CAUTION] + * ==== + * Avoid leaving a contract uninitialized. + * + * An uninitialized contract can be taken over by an attacker. This applies to both a proxy and its implementation + * contract, which may impact the proxy. To prevent the implementation contract from being used, you should invoke + * the {_disableInitializers} function in the constructor to automatically lock it when it is deployed: + * + * [.hljs-theme-light.nopadding] + * ``` + * /// @custom:oz-upgrades-unsafe-allow constructor + * constructor() { + * _disableInitializers(); + * } + * ``` + * ==== + */ +abstract contract Initializable { + /** + * @dev Indicates that the contract has been initialized. + * @custom:oz-retyped-from bool + */ + uint8 private _initialized; + + /** + * @dev Indicates that the contract is in the process of being initialized. + */ + bool private _initializing; + + /** + * @dev Triggered when the contract has been initialized or reinitialized. + */ + event Initialized(uint8 version); + + /** + * @dev A modifier that defines a protected initializer function that can be invoked at most once. In its scope, + * `onlyInitializing` functions can be used to initialize parent contracts. Equivalent to `reinitializer(1)`. + */ + modifier initializer() { + bool isTopLevelCall = !_initializing; + require( + (isTopLevelCall && _initialized < 1) || (!Address.isContract(address(this)) && _initialized == 1), + "Initializable: contract is already initialized" + ); + _initialized = 1; + if (isTopLevelCall) { + _initializing = true; + } + _; + if (isTopLevelCall) { + _initializing = false; + emit Initialized(1); + } + } + + /** + * @dev A modifier that defines a protected reinitializer function that can be invoked at most once, and only if the + * contract hasn't been initialized to a greater version before. In its scope, `onlyInitializing` functions can be + * used to initialize parent contracts. + * + * `initializer` is equivalent to `reinitializer(1)`, so a reinitializer may be used after the original + * initialization step. This is essential to configure modules that are added through upgrades and that require + * initialization. + * + * Note that versions can jump in increments greater than 1; this implies that if multiple reinitializers coexist in + * a contract, executing them in the right order is up to the developer or operator. + */ + modifier reinitializer(uint8 version) { + require(!_initializing && _initialized < version, "Initializable: contract is already initialized"); + _initialized = version; + _initializing = true; + _; + _initializing = false; + emit Initialized(version); + } + + /** + * @dev Modifier to protect an initialization function so that it can only be invoked by functions with the + * {initializer} and {reinitializer} modifiers, directly or indirectly. + */ + modifier onlyInitializing() { + require(_initializing, "Initializable: contract is not initializing"); + _; + } + + /** + * @dev Locks the contract, preventing any future reinitialization. This cannot be part of an initializer call. + * Calling this in the constructor of a contract will prevent that contract from being initialized or reinitialized + * to any version. It is recommended to use this to lock implementation contracts that are designed to be called + * through proxies. + */ + function _disableInitializers() internal virtual { + require(!_initializing, "Initializable: contract is initializing"); + if (_initialized < type(uint8).max) { + _initialized = type(uint8).max; + emit Initialized(type(uint8).max); + } + } +} diff --git a/contracts/external-deps/openzeppelin/security/ReentrancyGuard.sol b/contracts/external-deps/openzeppelin/security/ReentrancyGuard.sol new file mode 100644 index 000000000..70ae78e77 --- /dev/null +++ b/contracts/external-deps/openzeppelin/security/ReentrancyGuard.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts v4.4.1 (security/ReentrancyGuard.sol) + +pragma solidity ^0.8.0; + +abstract contract ReentrancyGuard { + uint256 private constant _NOT_ENTERED = 1; + uint256 private constant _ENTERED = 2; + + uint256 private _status; + + constructor() { + _status = _NOT_ENTERED; + } + + /** + * @dev Prevents a contract from calling itself, directly or indirectly. + */ + modifier nonReentrant() { + // On the first call to nonReentrant, _notEntered will be true + require(_status != _ENTERED, "ReentrancyGuard: reentrant call"); + + // Any calls to nonReentrant after this point will fail + _status = _ENTERED; + + _; + + // By storing the original value once again, a refund is triggered (see + // https://eips.ethereum.org/EIPS/eip-2200) + _status = _NOT_ENTERED; + } +} diff --git a/contracts/external-deps/openzeppelin/security/ReentrancyGuardUpgradeable.sol b/contracts/external-deps/openzeppelin/security/ReentrancyGuardUpgradeable.sol new file mode 100644 index 000000000..67041dbc7 --- /dev/null +++ b/contracts/external-deps/openzeppelin/security/ReentrancyGuardUpgradeable.sol @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts v4.4.1 (security/ReentrancyGuard.sol) + +pragma solidity ^0.8.0; +import "../proxy/utils/Initializable.sol"; + +abstract contract ReentrancyGuardUpgradeable is Initializable { + uint256 private constant _NOT_ENTERED = 1; + uint256 private constant _ENTERED = 2; + + uint256 private _status; + + function __ReentrancyGuard_init() internal onlyInitializing { + __ReentrancyGuard_init_unchained(); + } + + function __ReentrancyGuard_init_unchained() internal onlyInitializing { + _status = _NOT_ENTERED; + } + + /** + * @dev Prevents a contract from calling itself, directly or indirectly. + */ + modifier nonReentrant() { + // On the first call to nonReentrant, _notEntered will be true + require(_status != _ENTERED, "ReentrancyGuard: reentrant call"); + + // Any calls to nonReentrant after this point will fail + _status = _ENTERED; + + _; + + // By storing the original value once again, a refund is triggered (see + // https://eips.ethereum.org/EIPS/eip-2200) + _status = _NOT_ENTERED; + } + + /** + * @dev This empty reserved space is put in place to allow future versions to add new + * variables without shifting down storage in the inheritance chain. + * See https://docs.openzeppelin.com/contracts/4.x/upgradeable#storage_gaps + */ + uint256[49] private __gap; +} diff --git a/contracts/external-deps/openzeppelin/token/ERC1155/IERC1155Receiver.sol b/contracts/external-deps/openzeppelin/token/ERC1155/IERC1155Receiver.sol new file mode 100644 index 000000000..1abd0daf9 --- /dev/null +++ b/contracts/external-deps/openzeppelin/token/ERC1155/IERC1155Receiver.sol @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts (last updated v4.5.0) (token/ERC1155/IERC1155Receiver.sol) + +pragma solidity ^0.8.0; + +import "../../../../eip/interface/IERC165.sol"; + +/** + * @dev _Available since v3.1._ + */ +interface IERC1155Receiver is IERC165 { + /** + * @dev Handles the receipt of a single ERC1155 token type. This function is + * called at the end of a `safeTransferFrom` after the balance has been updated. + * + * NOTE: To accept the transfer, this must return + * `bytes4(keccak256("onERC1155Received(address,address,uint256,uint256,bytes)"))` + * (i.e. 0xf23a6e61, or its own function selector). + * + * @param operator The address which initiated the transfer (i.e. msg.sender) + * @param from The address which previously owned the token + * @param id The ID of the token being transferred + * @param value The amount of tokens being transferred + * @param data Additional data with no specified format + * @return `bytes4(keccak256("onERC1155Received(address,address,uint256,uint256,bytes)"))` if transfer is allowed + */ + function onERC1155Received( + address operator, + address from, + uint256 id, + uint256 value, + bytes calldata data + ) external returns (bytes4); + + /** + * @dev Handles the receipt of a multiple ERC1155 token types. This function + * is called at the end of a `safeBatchTransferFrom` after the balances have + * been updated. + * + * NOTE: To accept the transfer(s), this must return + * `bytes4(keccak256("onERC1155BatchReceived(address,address,uint256[],uint256[],bytes)"))` + * (i.e. 0xbc197c81, or its own function selector). + * + * @param operator The address which initiated the batch transfer (i.e. msg.sender) + * @param from The address which previously owned the token + * @param ids An array containing ids of each token being transferred (order and length must match values array) + * @param values An array containing amounts of each token being transferred (order and length must match ids array) + * @param data Additional data with no specified format + * @return `bytes4(keccak256("onERC1155BatchReceived(address,address,uint256[],uint256[],bytes)"))` if transfer is allowed + */ + function onERC1155BatchReceived( + address operator, + address from, + uint256[] calldata ids, + uint256[] calldata values, + bytes calldata data + ) external returns (bytes4); +} diff --git a/contracts/external-deps/openzeppelin/token/ERC1155/utils/ERC1155Holder.sol b/contracts/external-deps/openzeppelin/token/ERC1155/utils/ERC1155Holder.sol new file mode 100644 index 000000000..7249de841 --- /dev/null +++ b/contracts/external-deps/openzeppelin/token/ERC1155/utils/ERC1155Holder.sol @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts (last updated v4.5.0) (token/ERC1155/utils/ERC1155Holder.sol) + +pragma solidity ^0.8.0; + +import "./ERC1155Receiver.sol"; + +/** + * Simple implementation of `ERC1155Receiver` that will allow a contract to hold ERC1155 tokens. + * + * IMPORTANT: When inheriting this contract, you must include a way to use the received tokens, otherwise they will be + * stuck. + * + * @dev _Available since v3.1._ + */ +contract ERC1155Holder is ERC1155Receiver { + function onERC1155Received( + address, + address, + uint256, + uint256, + bytes memory + ) public virtual override returns (bytes4) { + return this.onERC1155Received.selector; + } + + function onERC1155BatchReceived( + address, + address, + uint256[] memory, + uint256[] memory, + bytes memory + ) public virtual override returns (bytes4) { + return this.onERC1155BatchReceived.selector; + } +} diff --git a/contracts/external-deps/openzeppelin/token/ERC1155/utils/ERC1155Receiver.sol b/contracts/external-deps/openzeppelin/token/ERC1155/utils/ERC1155Receiver.sol new file mode 100644 index 000000000..8a315b71c --- /dev/null +++ b/contracts/external-deps/openzeppelin/token/ERC1155/utils/ERC1155Receiver.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts v4.4.1 (token/ERC1155/utils/ERC1155Receiver.sol) + +pragma solidity ^0.8.0; + +import "../IERC1155Receiver.sol"; +import "../../../../../eip/ERC165.sol"; + +/** + * @dev _Available since v3.1._ + */ +abstract contract ERC1155Receiver is ERC165, IERC1155Receiver { + /** + * @dev See {IERC165-supportsInterface}. + */ + function supportsInterface(bytes4 interfaceId) public view virtual override(ERC165, IERC165) returns (bool) { + return interfaceId == type(IERC1155Receiver).interfaceId || super.supportsInterface(interfaceId); + } +} diff --git a/contracts/external-deps/openzeppelin/token/ERC20/ERC20.sol b/contracts/external-deps/openzeppelin/token/ERC20/ERC20.sol new file mode 100644 index 000000000..66ce9ec07 --- /dev/null +++ b/contracts/external-deps/openzeppelin/token/ERC20/ERC20.sol @@ -0,0 +1,359 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts (last updated v4.5.0) (token/ERC20/ERC20.sol) + +pragma solidity ^0.8.0; + +import "../../../../eip/interface/IERC20.sol"; +import "../../../../eip/interface/IERC20Metadata.sol"; +import "../../utils/Context.sol"; + +/** + * @dev Implementation of the {IERC20} interface. + * + * This implementation is agnostic to the way tokens are created. This means + * that a supply mechanism has to be added in a derived contract using {_mint}. + * For a generic mechanism see {ERC20PresetMinterPauser}. + * + * TIP: For a detailed writeup see our guide + * https://forum.zeppelin.solutions/t/how-to-implement-erc20-supply-mechanisms/226[How + * to implement supply mechanisms]. + * + * We have followed general OpenZeppelin Contracts guidelines: functions revert + * instead returning `false` on failure. This behavior is nonetheless + * conventional and does not conflict with the expectations of ERC20 + * applications. + * + * Additionally, an {Approval} event is emitted on calls to {transferFrom}. + * This allows applications to reconstruct the allowance for all accounts just + * by listening to said events. Other implementations of the EIP may not emit + * these events, as it isn't required by the specification. + * + * Finally, the non-standard {decreaseAllowance} and {increaseAllowance} + * functions have been added to mitigate the well-known issues around setting + * allowances. See {IERC20-approve}. + */ +contract ERC20 is Context, IERC20, IERC20Metadata { + mapping(address => uint256) private _balances; + + mapping(address => mapping(address => uint256)) private _allowances; + + uint256 private _totalSupply; + + string private _name; + string private _symbol; + + /** + * @dev Sets the values for {name} and {symbol}. + * + * The default value of {decimals} is 18. To select a different value for + * {decimals} you should overload it. + * + * All two of these values are immutable: they can only be set once during + * construction. + */ + constructor(string memory name_, string memory symbol_) { + _name = name_; + _symbol = symbol_; + } + + /** + * @dev Returns the name of the token. + */ + function name() public view virtual override returns (string memory) { + return _name; + } + + /** + * @dev Returns the symbol of the token, usually a shorter version of the + * name. + */ + function symbol() public view virtual override returns (string memory) { + return _symbol; + } + + /** + * @dev Returns the number of decimals used to get its user representation. + * For example, if `decimals` equals `2`, a balance of `505` tokens should + * be displayed to a user as `5.05` (`505 / 10 ** 2`). + * + * Tokens usually opt for a value of 18, imitating the relationship between + * Ether and Wei. This is the value {ERC20} uses, unless this function is + * overridden; + * + * NOTE: This information is only used for _display_ purposes: it in + * no way affects any of the arithmetic of the contract, including + * {IERC20-balanceOf} and {IERC20-transfer}. + */ + function decimals() public view virtual override returns (uint8) { + return 18; + } + + /** + * @dev See {IERC20-totalSupply}. + */ + function totalSupply() public view virtual override returns (uint256) { + return _totalSupply; + } + + /** + * @dev See {IERC20-balanceOf}. + */ + function balanceOf(address account) public view virtual override returns (uint256) { + return _balances[account]; + } + + /** + * @dev See {IERC20-transfer}. + * + * Requirements: + * + * - `to` cannot be the zero address. + * - the caller must have a balance of at least `amount`. + */ + function transfer(address to, uint256 amount) public virtual override returns (bool) { + address owner = _msgSender(); + _transfer(owner, to, amount); + return true; + } + + /** + * @dev See {IERC20-allowance}. + */ + function allowance(address owner, address spender) public view virtual override returns (uint256) { + return _allowances[owner][spender]; + } + + /** + * @dev See {IERC20-approve}. + * + * NOTE: If `amount` is the maximum `uint256`, the allowance is not updated on + * `transferFrom`. This is semantically equivalent to an infinite approval. + * + * Requirements: + * + * - `spender` cannot be the zero address. + */ + function approve(address spender, uint256 amount) public virtual override returns (bool) { + address owner = _msgSender(); + _approve(owner, spender, amount); + return true; + } + + /** + * @dev See {IERC20-transferFrom}. + * + * Emits an {Approval} event indicating the updated allowance. This is not + * required by the EIP. See the note at the beginning of {ERC20}. + * + * NOTE: Does not update the allowance if the current allowance + * is the maximum `uint256`. + * + * Requirements: + * + * - `from` and `to` cannot be the zero address. + * - `from` must have a balance of at least `amount`. + * - the caller must have allowance for ``from``'s tokens of at least + * `amount`. + */ + function transferFrom(address from, address to, uint256 amount) public virtual override returns (bool) { + address spender = _msgSender(); + _spendAllowance(from, spender, amount); + _transfer(from, to, amount); + return true; + } + + /** + * @dev Atomically increases the allowance granted to `spender` by the caller. + * + * This is an alternative to {approve} that can be used as a mitigation for + * problems described in {IERC20-approve}. + * + * Emits an {Approval} event indicating the updated allowance. + * + * Requirements: + * + * - `spender` cannot be the zero address. + */ + function increaseAllowance(address spender, uint256 addedValue) public virtual returns (bool) { + address owner = _msgSender(); + _approve(owner, spender, _allowances[owner][spender] + addedValue); + return true; + } + + /** + * @dev Atomically decreases the allowance granted to `spender` by the caller. + * + * This is an alternative to {approve} that can be used as a mitigation for + * problems described in {IERC20-approve}. + * + * Emits an {Approval} event indicating the updated allowance. + * + * Requirements: + * + * - `spender` cannot be the zero address. + * - `spender` must have allowance for the caller of at least + * `subtractedValue`. + */ + function decreaseAllowance(address spender, uint256 subtractedValue) public virtual returns (bool) { + address owner = _msgSender(); + uint256 currentAllowance = _allowances[owner][spender]; + require(currentAllowance >= subtractedValue, "ERC20: decreased allowance below zero"); + unchecked { + _approve(owner, spender, currentAllowance - subtractedValue); + } + + return true; + } + + /** + * @dev Moves `amount` of tokens from `sender` to `recipient`. + * + * This internal function is equivalent to {transfer}, and can be used to + * e.g. implement automatic token fees, slashing mechanisms, etc. + * + * Emits a {Transfer} event. + * + * Requirements: + * + * - `from` cannot be the zero address. + * - `to` cannot be the zero address. + * - `from` must have a balance of at least `amount`. + */ + function _transfer(address from, address to, uint256 amount) internal virtual { + require(from != address(0), "ERC20: transfer from the zero address"); + require(to != address(0), "ERC20: transfer to the zero address"); + + _beforeTokenTransfer(from, to, amount); + + uint256 fromBalance = _balances[from]; + require(fromBalance >= amount, "ERC20: transfer amount exceeds balance"); + unchecked { + _balances[from] = fromBalance - amount; + } + _balances[to] += amount; + + emit Transfer(from, to, amount); + + _afterTokenTransfer(from, to, amount); + } + + /** @dev Creates `amount` tokens and assigns them to `account`, increasing + * the total supply. + * + * Emits a {Transfer} event with `from` set to the zero address. + * + * Requirements: + * + * - `account` cannot be the zero address. + */ + function _mint(address account, uint256 amount) internal virtual { + require(account != address(0), "ERC20: mint to the zero address"); + + _beforeTokenTransfer(address(0), account, amount); + + _totalSupply += amount; + _balances[account] += amount; + emit Transfer(address(0), account, amount); + + _afterTokenTransfer(address(0), account, amount); + } + + /** + * @dev Destroys `amount` tokens from `account`, reducing the + * total supply. + * + * Emits a {Transfer} event with `to` set to the zero address. + * + * Requirements: + * + * - `account` cannot be the zero address. + * - `account` must have at least `amount` tokens. + */ + function _burn(address account, uint256 amount) internal virtual { + require(account != address(0), "ERC20: burn from the zero address"); + + _beforeTokenTransfer(account, address(0), amount); + + uint256 accountBalance = _balances[account]; + require(accountBalance >= amount, "ERC20: burn amount exceeds balance"); + unchecked { + _balances[account] = accountBalance - amount; + } + _totalSupply -= amount; + + emit Transfer(account, address(0), amount); + + _afterTokenTransfer(account, address(0), amount); + } + + /** + * @dev Sets `amount` as the allowance of `spender` over the `owner` s tokens. + * + * This internal function is equivalent to `approve`, and can be used to + * e.g. set automatic allowances for certain subsystems, etc. + * + * Emits an {Approval} event. + * + * Requirements: + * + * - `owner` cannot be the zero address. + * - `spender` cannot be the zero address. + */ + function _approve(address owner, address spender, uint256 amount) internal virtual { + require(owner != address(0), "ERC20: approve from the zero address"); + require(spender != address(0), "ERC20: approve to the zero address"); + + _allowances[owner][spender] = amount; + emit Approval(owner, spender, amount); + } + + /** + * @dev Spend `amount` form the allowance of `owner` toward `spender`. + * + * Does not update the allowance amount in case of infinite allowance. + * Revert if not enough allowance is available. + * + * Might emit an {Approval} event. + */ + function _spendAllowance(address owner, address spender, uint256 amount) internal virtual { + uint256 currentAllowance = allowance(owner, spender); + if (currentAllowance != type(uint256).max) { + require(currentAllowance >= amount, "ERC20: insufficient allowance"); + unchecked { + _approve(owner, spender, currentAllowance - amount); + } + } + } + + /** + * @dev Hook that is called before any transfer of tokens. This includes + * minting and burning. + * + * Calling conditions: + * + * - when `from` and `to` are both non-zero, `amount` of ``from``'s tokens + * will be transferred to `to`. + * - when `from` is zero, `amount` tokens will be minted for `to`. + * - when `to` is zero, `amount` of ``from``'s tokens will be burned. + * - `from` and `to` are never both zero. + * + * To learn more about hooks, head to xref:ROOT:extending-contracts.adoc#using-hooks[Using Hooks]. + */ + function _beforeTokenTransfer(address from, address to, uint256 amount) internal virtual {} + + /** + * @dev Hook that is called after any transfer of tokens. This includes + * minting and burning. + * + * Calling conditions: + * + * - when `from` and `to` are both non-zero, `amount` of ``from``'s tokens + * has been transferred to `to`. + * - when `from` is zero, `amount` tokens have been minted for `to`. + * - when `to` is zero, `amount` of ``from``'s tokens have been burned. + * - `from` and `to` are never both zero. + * + * To learn more about hooks, head to xref:ROOT:extending-contracts.adoc#using-hooks[Using Hooks]. + */ + function _afterTokenTransfer(address from, address to, uint256 amount) internal virtual {} +} diff --git a/contracts/external-deps/openzeppelin/token/ERC20/extensions/ERC20Permit.sol b/contracts/external-deps/openzeppelin/token/ERC20/extensions/ERC20Permit.sol new file mode 100644 index 000000000..e9433a92e --- /dev/null +++ b/contracts/external-deps/openzeppelin/token/ERC20/extensions/ERC20Permit.sol @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts v4.4.1 (token/ERC20/extensions/draft-ERC20Permit.sol) + +pragma solidity ^0.8.0; + +import "../../../../../eip/interface/IERC20Permit.sol"; +import "../ERC20.sol"; +import "../../../utils/cryptography/EIP712.sol"; +import "../../../utils/cryptography/ECDSA.sol"; +import "../../../utils/Counters.sol"; + +/** + * @dev Implementation of the ERC20 Permit extension allowing approvals to be made via signatures, as defined in + * https://eips.ethereum.org/EIPS/eip-2612[EIP-2612]. + * + * Adds the {permit} method, which can be used to change an account's ERC20 allowance (see {IERC20-allowance}) by + * presenting a message signed by the account. By not relying on `{IERC20-approve}`, the token holder account doesn't + * need to send a transaction, and thus is not required to hold Ether at all. + * + * _Available since v3.4._ + */ +abstract contract ERC20Permit is ERC20, IERC20Permit { + using Counters for Counters.Counter; + + mapping(address => Counters.Counter) private _nonces; + + // solhint-disable-next-line var-name-mixedcase + bytes32 private immutable _CACHED_DOMAIN_SEPARATOR; + + // solhint-disable-next-line var-name-mixedcase + uint256 private immutable _CACHED_CHAIN_ID; + + // solhint-disable-next-line var-name-mixedcase + address private immutable _CACHED_THIS; + + // solhint-disable-next-line var-name-mixedcase + bytes32 private immutable _PERMIT_TYPEHASH = + keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"); + + /** + * @dev Initializes the {EIP712} domain separator using the `name` parameter, and setting `version` to `"1"`. + * + * It's a good idea to use the same `name` that is defined as the ERC20 token name. + */ + constructor(string memory name_, string memory symbol_) ERC20(name_, symbol_) { + _CACHED_CHAIN_ID = block.chainid; + _CACHED_THIS = address(this); + _CACHED_DOMAIN_SEPARATOR = _buildDomainSeparator(); + } + + /** + * @dev See {IERC20Permit-permit}. + */ + function permit( + address owner, + address spender, + uint256 value, + uint256 deadline, + uint8 v, + bytes32 r, + bytes32 s + ) public virtual override { + require(block.timestamp <= deadline, "ERC20Permit: expired deadline"); + + bytes32 structHash = keccak256(abi.encode(_PERMIT_TYPEHASH, owner, spender, value, _useNonce(owner), deadline)); + + bytes32 hash = ECDSA.toTypedDataHash(DOMAIN_SEPARATOR(), structHash); + + address signer = ECDSA.recover(hash, v, r, s); + require(signer == owner, "ERC20Permit: invalid signature"); + + _approve(owner, spender, value); + } + + /** + * @dev See {IERC20Permit-nonces}. + */ + function nonces(address owner) public view virtual override returns (uint256) { + return _nonces[owner].current(); + } + + /** + * @dev See {IERC20Permit-DOMAIN_SEPARATOR}. + */ + // solhint-disable-next-line func-name-mixedcase + function DOMAIN_SEPARATOR() public view override returns (bytes32) { + if (address(this) == _CACHED_THIS && block.chainid == _CACHED_CHAIN_ID) { + return _CACHED_DOMAIN_SEPARATOR; + } else { + return _buildDomainSeparator(); + } + } + + function _buildDomainSeparator() private view returns (bytes32) { + return + keccak256( + abi.encode( + keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), + keccak256(bytes(name())), + keccak256("1"), + block.chainid, + address(this) + ) + ); + } + + /** + * @dev "Consume a nonce": return the current value and increment. + * + * _Available since v4.1._ + */ + function _useNonce(address owner) internal virtual returns (uint256 current) { + Counters.Counter storage nonce = _nonces[owner]; + current = nonce.current(); + nonce.increment(); + } +} diff --git a/contracts/external-deps/openzeppelin/token/ERC20/extensions/ERC20Votes.sol b/contracts/external-deps/openzeppelin/token/ERC20/extensions/ERC20Votes.sol new file mode 100644 index 000000000..e80527066 --- /dev/null +++ b/contracts/external-deps/openzeppelin/token/ERC20/extensions/ERC20Votes.sol @@ -0,0 +1,246 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +import "./ERC20Permit.sol"; + +import "../../../utils/math/Math.sol"; +import "../../../governance/utils/IVotes.sol"; +import "../../../utils/math/SafeCast.sol"; + +/** + * @dev Extension of ERC20 to support Compound-like voting and delegation. This version is more generic than Compound's, + * and supports token supply up to 2^224^ - 1, while COMP is limited to 2^96^ - 1. + * + * NOTE: If exact COMP compatibility is required, use the {ERC20VotesComp} variant of this module. + * + * This extension keeps a history (checkpoints) of each account's vote power. Vote power can be delegated either + * by calling the {delegate} function directly, or by providing a signature to be used with {delegateBySig}. Voting + * power can be queried through the public accessors {getVotes} and {getPastVotes}. + * + * By default, token balance does not account for voting power. This makes transfers cheaper. The downside is that it + * requires users to delegate to themselves in order to activate checkpoints and have their voting power tracked. + * + * _Available since v4.2._ + */ +abstract contract ERC20Votes is IVotes, ERC20Permit { + struct Checkpoint { + uint32 fromBlock; + uint224 votes; + } + + bytes32 private constant _DELEGATION_TYPEHASH = + keccak256("Delegation(address delegatee,uint256 nonce,uint256 expiry)"); + + mapping(address => address) private _delegates; + mapping(address => Checkpoint[]) private _checkpoints; + Checkpoint[] private _totalSupplyCheckpoints; + + /** + * @dev Get the `pos`-th checkpoint for `account`. + */ + function checkpoints(address account, uint32 pos) public view virtual returns (Checkpoint memory) { + return _checkpoints[account][pos]; + } + + /** + * @dev Get number of checkpoints for `account`. + */ + function numCheckpoints(address account) public view virtual returns (uint32) { + return SafeCast.toUint32(_checkpoints[account].length); + } + + /** + * @dev Get the address `account` is currently delegating to. + */ + function delegates(address account) public view virtual override returns (address) { + return _delegates[account]; + } + + /** + * @dev Gets the current votes balance for `account` + */ + function getVotes(address account) public view virtual override returns (uint256) { + uint256 pos = _checkpoints[account].length; + return pos == 0 ? 0 : _checkpoints[account][pos - 1].votes; + } + + /** + * @dev Retrieve the number of votes for `account` at the end of `blockNumber`. + * + * Requirements: + * + * - `blockNumber` must have been already mined + */ + function getPastVotes(address account, uint256 blockNumber) public view virtual override returns (uint256) { + require(blockNumber < block.number, "ERC20Votes: block not yet mined"); + return _checkpointsLookup(_checkpoints[account], blockNumber); + } + + /** + * @dev Retrieve the `totalSupply` at the end of `blockNumber`. Note, this value is the sum of all balances. + * It is but NOT the sum of all the delegated votes! + * + * Requirements: + * + * - `blockNumber` must have been already mined + */ + function getPastTotalSupply(uint256 blockNumber) public view virtual override returns (uint256) { + require(blockNumber < block.number, "ERC20Votes: block not yet mined"); + return _checkpointsLookup(_totalSupplyCheckpoints, blockNumber); + } + + /** + * @dev Lookup a value in a list of (sorted) checkpoints. + */ + function _checkpointsLookup(Checkpoint[] storage ckpts, uint256 blockNumber) private view returns (uint256) { + // We run a binary search to look for the earliest checkpoint taken after `blockNumber`. + // + // During the loop, the index of the wanted checkpoint remains in the range [low-1, high). + // With each iteration, either `low` or `high` is moved towards the middle of the range to maintain the invariant. + // - If the middle checkpoint is after `blockNumber`, we look in [low, mid) + // - If the middle checkpoint is before or equal to `blockNumber`, we look in [mid+1, high) + // Once we reach a single value (when low == high), we've found the right checkpoint at the index high-1, if not + // out of bounds (in which case we're looking too far in the past and the result is 0). + // Note that if the latest checkpoint available is exactly for `blockNumber`, we end up with an index that is + // past the end of the array, so we technically don't find a checkpoint after `blockNumber`, but it works out + // the same. + uint256 high = ckpts.length; + uint256 low = 0; + while (low < high) { + uint256 mid = Math.average(low, high); + if (ckpts[mid].fromBlock > blockNumber) { + high = mid; + } else { + low = mid + 1; + } + } + + return high == 0 ? 0 : ckpts[high - 1].votes; + } + + /** + * @dev Delegate votes from the sender to `delegatee`. + */ + function delegate(address delegatee) public virtual override { + // _delegate(_msgSender(), delegatee); //check + _delegate(_msgSender(), delegatee); + } + + /*////////////////////////////////////////////////////////////// + Voting - delegation by signature + //////////////////////////////////////////////////////////////*/ + + /** + * @dev Delegates votes from signer to `delegatee` + */ + function delegateBySig( + address delegatee, + uint256 nonce, + uint256 expiry, + uint8 v, + bytes32 r, + bytes32 s + ) public virtual override { + require(block.timestamp <= expiry, "ERC20Votes: signature expired"); + + bytes32 structHash = keccak256(abi.encode(_DELEGATION_TYPEHASH, delegatee, nonce, expiry)); + bytes32 hash = ECDSA.toTypedDataHash(DOMAIN_SEPARATOR(), structHash); + address signer = ECDSA.recover(hash, v, r, s); + + require(nonce == _useNonce(signer), "ERC20Votes: invalid nonce"); + _delegate(signer, delegatee); + } + + /** + * @dev Maximum token supply. Defaults to `type(uint224).max` (2^224^ - 1). + */ + function _maxSupply() internal view virtual returns (uint224) { + return type(uint224).max; + } + + /** + * @dev Snapshots the totalSupply after it has been increased. + */ + function _mint(address account, uint256 amount) internal virtual override { + super._mint(account, amount); + require(totalSupply() <= _maxSupply(), "ERC20Votes: total supply risks overflowing votes"); + + _writeCheckpoint(_totalSupplyCheckpoints, _add, amount); + } + + /** + * @dev Snapshots the totalSupply after it has been decreased. + */ + function _burn(address account, uint256 amount) internal virtual override { + super._burn(account, amount); + + _writeCheckpoint(_totalSupplyCheckpoints, _subtract, amount); + } + + /** + * @dev Move voting power when tokens are transferred. + * + * Emits a {DelegateVotesChanged} event. + */ + function _afterTokenTransfer(address from, address to, uint256 amount) internal virtual override { + super._afterTokenTransfer(from, to, amount); + + _moveVotingPower(delegates(from), delegates(to), amount); + } + + /** + * @dev Change delegation for `delegator` to `delegatee`. + * + * Emits events {DelegateChanged} and {DelegateVotesChanged}. + */ + function _delegate(address delegator, address delegatee) internal virtual { + address currentDelegate = delegates(delegator); + uint256 delegatorBalance = balanceOf(delegator); + _delegates[delegator] = delegatee; + + emit DelegateChanged(delegator, currentDelegate, delegatee); + + _moveVotingPower(currentDelegate, delegatee, delegatorBalance); + } + + function _moveVotingPower(address src, address dst, uint256 amount) private { + if (src != dst && amount > 0) { + if (src != address(0)) { + (uint256 oldWeight, uint256 newWeight) = _writeCheckpoint(_checkpoints[src], _subtract, amount); + emit DelegateVotesChanged(src, oldWeight, newWeight); + } + + if (dst != address(0)) { + (uint256 oldWeight, uint256 newWeight) = _writeCheckpoint(_checkpoints[dst], _add, amount); + emit DelegateVotesChanged(dst, oldWeight, newWeight); + } + } + } + + function _writeCheckpoint( + Checkpoint[] storage ckpts, + function(uint256, uint256) view returns (uint256) op, + uint256 delta + ) private returns (uint256 oldWeight, uint256 newWeight) { + uint256 pos = ckpts.length; + oldWeight = pos == 0 ? 0 : ckpts[pos - 1].votes; + newWeight = op(oldWeight, delta); + + if (pos > 0 && ckpts[pos - 1].fromBlock == block.number) { + ckpts[pos - 1].votes = SafeCast.toUint224(newWeight); + } else { + ckpts.push( + Checkpoint({ fromBlock: SafeCast.toUint32(block.number), votes: SafeCast.toUint224(newWeight) }) + ); + } + } + + function _add(uint256 a, uint256 b) private pure returns (uint256) { + return a + b; + } + + function _subtract(uint256 a, uint256 b) private pure returns (uint256) { + return a - b; + } +} diff --git a/contracts/external-deps/openzeppelin/token/ERC20/utils/SafeERC20.sol b/contracts/external-deps/openzeppelin/token/ERC20/utils/SafeERC20.sol new file mode 100644 index 000000000..00bdee343 --- /dev/null +++ b/contracts/external-deps/openzeppelin/token/ERC20/utils/SafeERC20.sol @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts v4.4.1 (token/ERC20/utils/SafeERC20.sol) + +pragma solidity ^0.8.0; + +import "../../../../../eip/interface/IERC20.sol"; +import { Address } from "../../../../../lib/Address.sol"; + +/** + * @title SafeERC20 + * @dev Wrappers around ERC20 operations that throw on failure (when the token + * contract returns false). Tokens that return no value (and instead revert or + * throw on failure) are also supported, non-reverting calls are assumed to be + * successful. + * To use this library you can add a `using SafeERC20 for IERC20;` statement to your contract, + * which allows you to call the safe operations as `token.safeTransfer(...)`, etc. + */ +library SafeERC20 { + using Address for address; + + function safeTransfer(IERC20 token, address to, uint256 value) internal { + _callOptionalReturn(token, abi.encodeWithSelector(token.transfer.selector, to, value)); + } + + function safeTransferFrom(IERC20 token, address from, address to, uint256 value) internal { + _callOptionalReturn(token, abi.encodeWithSelector(token.transferFrom.selector, from, to, value)); + } + + /** + * @dev Deprecated. This function has issues similar to the ones found in + * {IERC20-approve}, and its usage is discouraged. + * + * Whenever possible, use {safeIncreaseAllowance} and + * {safeDecreaseAllowance} instead. + */ + function safeApprove(IERC20 token, address spender, uint256 value) internal { + // safeApprove should only be called when setting an initial allowance, + // or when resetting it to zero. To increase and decrease it, use + // 'safeIncreaseAllowance' and 'safeDecreaseAllowance' + require( + (value == 0) || (token.allowance(address(this), spender) == 0), + "SafeERC20: approve from non-zero to non-zero allowance" + ); + _callOptionalReturn(token, abi.encodeWithSelector(token.approve.selector, spender, value)); + } + + function safeIncreaseAllowance(IERC20 token, address spender, uint256 value) internal { + uint256 newAllowance = token.allowance(address(this), spender) + value; + _callOptionalReturn(token, abi.encodeWithSelector(token.approve.selector, spender, newAllowance)); + } + + function safeDecreaseAllowance(IERC20 token, address spender, uint256 value) internal { + unchecked { + uint256 oldAllowance = token.allowance(address(this), spender); + require(oldAllowance >= value, "SafeERC20: decreased allowance below zero"); + uint256 newAllowance = oldAllowance - value; + _callOptionalReturn(token, abi.encodeWithSelector(token.approve.selector, spender, newAllowance)); + } + } + + /** + * @dev Imitates a Solidity high-level call (i.e. a regular function call to a contract), relaxing the requirement + * on the return value: the return value is optional (but if data is returned, it must not be false). + * @param token The token targeted by the call. + * @param data The call data (encoded using abi.encode or one of its variants). + */ + function _callOptionalReturn(IERC20 token, bytes memory data) private { + // We need to perform a low level call here, to bypass Solidity's return data size checking mechanism, since + // we're implementing it ourselves. We use {Address.functionCall} to perform this call, which verifies that + // the target address contains contract code and also asserts for success in the low-level call. + + bytes memory returndata = address(token).functionCall(data, "SafeERC20: low-level call failed"); + if (returndata.length > 0) { + // Return data is optional + require(abi.decode(returndata, (bool)), "SafeERC20: ERC20 operation did not succeed"); + } + } +} diff --git a/contracts/external-deps/openzeppelin/token/ERC721/IERC721Receiver.sol b/contracts/external-deps/openzeppelin/token/ERC721/IERC721Receiver.sol new file mode 100644 index 000000000..a42cb52ff --- /dev/null +++ b/contracts/external-deps/openzeppelin/token/ERC721/IERC721Receiver.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts v4.4.1 (token/ERC721/IERC721Receiver.sol) + +pragma solidity ^0.8.0; + +/** + * @title ERC721 token receiver interface + * @dev Interface for any contract that wants to support safeTransfers + * from ERC721 asset contracts. + */ +interface IERC721Receiver { + /** + * @dev Whenever an {IERC721} `tokenId` token is transferred to this contract via {IERC721-safeTransferFrom} + * by `operator` from `from`, this function is called. + * + * It must return its Solidity selector to confirm the token transfer. + * If any other value is returned or the interface is not implemented by the recipient, the transfer will be reverted. + * + * The selector can be obtained in Solidity with `IERC721.onERC721Received.selector`. + */ + function onERC721Received( + address operator, + address from, + uint256 tokenId, + bytes calldata data + ) external returns (bytes4); +} diff --git a/contracts/external-deps/openzeppelin/token/ERC721/utils/ERC721Holder.sol b/contracts/external-deps/openzeppelin/token/ERC721/utils/ERC721Holder.sol new file mode 100644 index 000000000..cfa533a47 --- /dev/null +++ b/contracts/external-deps/openzeppelin/token/ERC721/utils/ERC721Holder.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts v4.4.1 (token/ERC721/utils/ERC721Holder.sol) + +pragma solidity ^0.8.0; + +import "../IERC721Receiver.sol"; + +/** + * @dev Implementation of the {IERC721Receiver} interface. + * + * Accepts all token transfers. + * Make sure the contract is able to use its token with {IERC721-safeTransferFrom}, {IERC721-approve} or {IERC721-setApprovalForAll}. + */ +contract ERC721Holder is IERC721Receiver { + /** + * @dev See {IERC721Receiver-onERC721Received}. + * + * Always returns `IERC721Receiver.onERC721Received.selector`. + */ + function onERC721Received(address, address, uint256, bytes memory) public virtual override returns (bytes4) { + return this.onERC721Received.selector; + } +} diff --git a/contracts/external-deps/openzeppelin/utils/Base64.sol b/contracts/external-deps/openzeppelin/utils/Base64.sol new file mode 100644 index 000000000..4e08cd563 --- /dev/null +++ b/contracts/external-deps/openzeppelin/utils/Base64.sol @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts (last updated v4.7.0) (utils/Base64.sol) + +pragma solidity ^0.8.0; + +/** + * @dev Provides a set of functions to operate with Base64 strings. + * + * _Available since v4.5._ + */ +library Base64 { + /** + * @dev Base64 Encoding/Decoding Table + */ + string internal constant _TABLE = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + + /** + * @dev Converts a `bytes` to its Bytes64 `string` representation. + */ + function encode(bytes memory data) internal pure returns (string memory) { + /** + * Inspired by Brecht Devos (Brechtpd) implementation - MIT licence + * https://github.com/Brechtpd/base64/blob/e78d9fd951e7b0977ddca77d92dc85183770daf4/base64.sol + */ + if (data.length == 0) return ""; + + // Loads the table into memory + string memory table = _TABLE; + + // Encoding takes 3 bytes chunks of binary data from `bytes` data parameter + // and split into 4 numbers of 6 bits. + // The final Base64 length should be `bytes` data length multiplied by 4/3 rounded up + // - `data.length + 2` -> Round up + // - `/ 3` -> Number of 3-bytes chunks + // - `4 *` -> 4 characters for each chunk + string memory result = new string(4 * ((data.length + 2) / 3)); + + /// @solidity memory-safe-assembly + assembly { + // Prepare the lookup table (skip the first "length" byte) + let tablePtr := add(table, 1) + + // Prepare result pointer, jump over length + let resultPtr := add(result, 32) + + // Run over the input, 3 bytes at a time + for { + let dataPtr := data + let endPtr := add(data, mload(data)) + } lt(dataPtr, endPtr) { + + } { + // Advance 3 bytes + dataPtr := add(dataPtr, 3) + let input := mload(dataPtr) + + // To write each character, shift the 3 bytes (18 bits) chunk + // 4 times in blocks of 6 bits for each character (18, 12, 6, 0) + // and apply logical AND with 0x3F which is the number of + // the previous character in the ASCII table prior to the Base64 Table + // The result is then added to the table to get the character to write, + // and finally write it in the result pointer but with a left shift + // of 256 (1 byte) - 8 (1 ASCII char) = 248 bits + + mstore8(resultPtr, mload(add(tablePtr, and(shr(18, input), 0x3F)))) + resultPtr := add(resultPtr, 1) // Advance + + mstore8(resultPtr, mload(add(tablePtr, and(shr(12, input), 0x3F)))) + resultPtr := add(resultPtr, 1) // Advance + + mstore8(resultPtr, mload(add(tablePtr, and(shr(6, input), 0x3F)))) + resultPtr := add(resultPtr, 1) // Advance + + mstore8(resultPtr, mload(add(tablePtr, and(input, 0x3F)))) + resultPtr := add(resultPtr, 1) // Advance + } + + // When data `bytes` is not exactly 3 bytes long + // it is padded with `=` characters at the end + switch mod(mload(data), 3) + case 1 { + mstore8(sub(resultPtr, 1), 0x3d) + mstore8(sub(resultPtr, 2), 0x3d) + } + case 2 { + mstore8(sub(resultPtr, 1), 0x3d) + } + } + + return result; + } +} diff --git a/contracts/external-deps/openzeppelin/utils/Context.sol b/contracts/external-deps/openzeppelin/utils/Context.sol new file mode 100644 index 000000000..f304065b4 --- /dev/null +++ b/contracts/external-deps/openzeppelin/utils/Context.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts v4.4.1 (utils/Context.sol) + +pragma solidity ^0.8.0; + +/** + * @dev Provides information about the current execution context, including the + * sender of the transaction and its data. While these are generally available + * via msg.sender and msg.data, they should not be accessed in such a direct + * manner, since when dealing with meta-transactions the account sending and + * paying for execution may not be the actual sender (as far as an application + * is concerned). + * + * This contract is only required for intermediate, library-like contracts. + */ +abstract contract Context { + function _msgSender() internal view virtual returns (address) { + return msg.sender; + } + + function _msgData() internal view virtual returns (bytes calldata) { + return msg.data; + } +} diff --git a/contracts/external-deps/openzeppelin/utils/Counters.sol b/contracts/external-deps/openzeppelin/utils/Counters.sol new file mode 100644 index 000000000..8a4f2a2e7 --- /dev/null +++ b/contracts/external-deps/openzeppelin/utils/Counters.sol @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts v4.4.1 (utils/Counters.sol) + +pragma solidity ^0.8.0; + +/** + * @title Counters + * @author Matt Condon (@shrugs) + * @dev Provides counters that can only be incremented, decremented or reset. This can be used e.g. to track the number + * of elements in a mapping, issuing ERC721 ids, or counting request ids. + * + * Include with `using Counters for Counters.Counter;` + */ +library Counters { + struct Counter { + // This variable should never be directly accessed by users of the library: interactions must be restricted to + // the library's function. As of Solidity v0.5.2, this cannot be enforced, though there is a proposal to add + // this feature: see https://github.com/ethereum/solidity/issues/4637 + uint256 _value; // default: 0 + } + + function current(Counter storage counter) internal view returns (uint256) { + return counter._value; + } + + function increment(Counter storage counter) internal { + unchecked { + counter._value += 1; + } + } + + function decrement(Counter storage counter) internal { + uint256 value = counter._value; + require(value > 0, "Counter: decrement overflow"); + unchecked { + counter._value = value - 1; + } + } + + function reset(Counter storage counter) internal { + counter._value = 0; + } +} diff --git a/contracts/external-deps/openzeppelin/utils/Create2.sol b/contracts/external-deps/openzeppelin/utils/Create2.sol new file mode 100644 index 000000000..d810d8045 --- /dev/null +++ b/contracts/external-deps/openzeppelin/utils/Create2.sol @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts (last updated v4.7.0) (utils/Create2.sol) + +pragma solidity ^0.8.0; + +/** + * @dev Helper to make usage of the `CREATE2` EVM opcode easier and safer. + * `CREATE2` can be used to compute in advance the address where a smart + * contract will be deployed, which allows for interesting new mechanisms known + * as 'counterfactual interactions'. + * + * See the https://eips.ethereum.org/EIPS/eip-1014#motivation[EIP] for more + * information. + */ +library Create2 { + /** + * @dev Deploys a contract using `CREATE2`. The address where the contract + * will be deployed can be known in advance via {computeAddress}. + * + * The bytecode for a contract can be obtained from Solidity with + * `type(contractName).creationCode`. + * + * Requirements: + * + * - `bytecode` must not be empty. + * - `salt` must have not been used for `bytecode` already. + * - the factory must have a balance of at least `amount`. + * - if `amount` is non-zero, `bytecode` must have a `payable` constructor. + */ + function deploy(uint256 amount, bytes32 salt, bytes memory bytecode) internal returns (address) { + address addr; + require(address(this).balance >= amount, "Create2: insufficient balance"); + require(bytecode.length != 0, "Create2: bytecode length is zero"); + /// @solidity memory-safe-assembly + assembly { + addr := create2(amount, add(bytecode, 0x20), mload(bytecode), salt) + } + require(addr != address(0), "Create2: Failed on deploy"); + return addr; + } + + /** + * @dev Returns the address where a contract will be stored if deployed via {deploy}. Any change in the + * `bytecodeHash` or `salt` will result in a new destination address. + */ + function computeAddress(bytes32 salt, bytes32 bytecodeHash) internal view returns (address) { + return computeAddress(salt, bytecodeHash, address(this)); + } + + /** + * @dev Returns the address where a contract will be stored if deployed via {deploy} from a contract located at + * `deployer`. If `deployer` is this contract's address, returns the same value as {computeAddress}. + */ + function computeAddress(bytes32 salt, bytes32 bytecodeHash, address deployer) internal pure returns (address) { + bytes32 _data = keccak256(abi.encodePacked(bytes1(0xff), deployer, salt, bytecodeHash)); + return address(uint160(uint256(_data))); + } +} diff --git a/contracts/external-deps/openzeppelin/utils/ERC1155/ERC1155Holder.sol b/contracts/external-deps/openzeppelin/utils/ERC1155/ERC1155Holder.sol new file mode 100644 index 000000000..7249de841 --- /dev/null +++ b/contracts/external-deps/openzeppelin/utils/ERC1155/ERC1155Holder.sol @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts (last updated v4.5.0) (token/ERC1155/utils/ERC1155Holder.sol) + +pragma solidity ^0.8.0; + +import "./ERC1155Receiver.sol"; + +/** + * Simple implementation of `ERC1155Receiver` that will allow a contract to hold ERC1155 tokens. + * + * IMPORTANT: When inheriting this contract, you must include a way to use the received tokens, otherwise they will be + * stuck. + * + * @dev _Available since v3.1._ + */ +contract ERC1155Holder is ERC1155Receiver { + function onERC1155Received( + address, + address, + uint256, + uint256, + bytes memory + ) public virtual override returns (bytes4) { + return this.onERC1155Received.selector; + } + + function onERC1155BatchReceived( + address, + address, + uint256[] memory, + uint256[] memory, + bytes memory + ) public virtual override returns (bytes4) { + return this.onERC1155BatchReceived.selector; + } +} diff --git a/contracts/external-deps/openzeppelin/utils/ERC1155/ERC1155Receiver.sol b/contracts/external-deps/openzeppelin/utils/ERC1155/ERC1155Receiver.sol new file mode 100644 index 000000000..1f6ade8c5 --- /dev/null +++ b/contracts/external-deps/openzeppelin/utils/ERC1155/ERC1155Receiver.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: Apache 2.0 +// OpenZeppelin Contracts v4.4.1 (token/ERC1155/utils/ERC1155Receiver.sol) + +pragma solidity ^0.8.0; + +import "../../../../eip/interface/IERC1155Receiver.sol"; +import "../../../../eip/ERC165.sol"; + +/** + * @dev _Available since v3.1._ + */ +abstract contract ERC1155Receiver is ERC165, IERC1155Receiver { + /** + * @dev See {IERC165-supportsInterface}. + */ + function supportsInterface(bytes4 interfaceId) public view virtual override(ERC165, IERC165) returns (bool) { + return interfaceId == type(IERC1155Receiver).interfaceId || super.supportsInterface(interfaceId); + } +} diff --git a/contracts/external-deps/openzeppelin/utils/ERC721/ERC721Holder.sol b/contracts/external-deps/openzeppelin/utils/ERC721/ERC721Holder.sol new file mode 100644 index 000000000..5d364e52e --- /dev/null +++ b/contracts/external-deps/openzeppelin/utils/ERC721/ERC721Holder.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts v4.4.1 (token/ERC721/utils/ERC721Holder.sol) + +pragma solidity ^0.8.0; + +import "../../../../eip/interface/IERC721Receiver.sol"; + +/** + * @dev Implementation of the {IERC721Receiver} interface. + * + * Accepts all token transfers. + * Make sure the contract is able to use its token with {IERC721-safeTransferFrom}, {IERC721-approve} or {IERC721-setApprovalForAll}. + */ +contract ERC721Holder is IERC721Receiver { + /** + * @dev See {IERC721Receiver-onERC721Received}. + * + * Always returns `IERC721Receiver.onERC721Received.selector`. + */ + function onERC721Received(address, address, uint256, bytes memory) public virtual override returns (bytes4) { + return this.onERC721Received.selector; + } +} diff --git a/contracts/external-deps/openzeppelin/utils/EnumerableSet.sol b/contracts/external-deps/openzeppelin/utils/EnumerableSet.sol new file mode 100644 index 000000000..b6c647f07 --- /dev/null +++ b/contracts/external-deps/openzeppelin/utils/EnumerableSet.sol @@ -0,0 +1,367 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts (last updated v4.7.0) (utils/structs/EnumerableSet.sol) + +pragma solidity ^0.8.0; + +/** + * @dev Library for managing + * https://en.wikipedia.org/wiki/Set_(abstract_data_type)[sets] of primitive + * types. + * + * Sets have the following properties: + * + * - Elements are added, removed, and checked for existence in constant time + * (O(1)). + * - Elements are enumerated in O(n). No guarantees are made on the ordering. + * + * ``` + * contract Example { + * // Add the library methods + * using EnumerableSet for EnumerableSet.AddressSet; + * + * // Declare a set state variable + * EnumerableSet.AddressSet private mySet; + * } + * ``` + * + * As of v3.3.0, sets of type `bytes32` (`Bytes32Set`), `address` (`AddressSet`) + * and `uint256` (`UintSet`) are supported. + * + * [WARNING] + * ==== + * Trying to delete such a structure from storage will likely result in data corruption, rendering the structure unusable. + * See https://github.com/ethereum/solidity/pull/11843[ethereum/solidity#11843] for more info. + * + * In order to clean an EnumerableSet, you can either remove all elements one by one or create a fresh instance using an array of EnumerableSet. + * ==== + */ +library EnumerableSet { + // To implement this library for multiple types with as little code + // repetition as possible, we write it in terms of a generic Set type with + // bytes32 values. + // The Set implementation uses private functions, and user-facing + // implementations (such as AddressSet) are just wrappers around the + // underlying Set. + // This means that we can only create new EnumerableSets for types that fit + // in bytes32. + + struct Set { + // Storage of set values + bytes32[] _values; + // Position of the value in the `values` array, plus 1 because index 0 + // means a value is not in the set. + mapping(bytes32 => uint256) _indexes; + } + + /** + * @dev Add a value to a set. O(1). + * + * Returns true if the value was added to the set, that is if it was not + * already present. + */ + function _add(Set storage set, bytes32 value) private returns (bool) { + if (!_contains(set, value)) { + set._values.push(value); + // The value is stored at length-1, but we add 1 to all indexes + // and use 0 as a sentinel value + set._indexes[value] = set._values.length; + return true; + } else { + return false; + } + } + + /** + * @dev Removes a value from a set. O(1). + * + * Returns true if the value was removed from the set, that is if it was + * present. + */ + function _remove(Set storage set, bytes32 value) private returns (bool) { + // We read and store the value's index to prevent multiple reads from the same storage slot + uint256 valueIndex = set._indexes[value]; + + if (valueIndex != 0) { + // Equivalent to contains(set, value) + // To delete an element from the _values array in O(1), we swap the element to delete with the last one in + // the array, and then remove the last element (sometimes called as 'swap and pop'). + // This modifies the order of the array, as noted in {at}. + + uint256 toDeleteIndex = valueIndex - 1; + uint256 lastIndex = set._values.length - 1; + + if (lastIndex != toDeleteIndex) { + bytes32 lastValue = set._values[lastIndex]; + + // Move the last value to the index where the value to delete is + set._values[toDeleteIndex] = lastValue; + // Update the index for the moved value + set._indexes[lastValue] = valueIndex; // Replace lastValue's index to valueIndex + } + + // Delete the slot where the moved value was stored + set._values.pop(); + + // Delete the index for the deleted slot + delete set._indexes[value]; + + return true; + } else { + return false; + } + } + + /** + * @dev Returns true if the value is in the set. O(1). + */ + function _contains(Set storage set, bytes32 value) private view returns (bool) { + return set._indexes[value] != 0; + } + + /** + * @dev Returns the number of values on the set. O(1). + */ + function _length(Set storage set) private view returns (uint256) { + return set._values.length; + } + + /** + * @dev Returns the value stored at position `index` in the set. O(1). + * + * Note that there are no guarantees on the ordering of values inside the + * array, and it may change when more values are added or removed. + * + * Requirements: + * + * - `index` must be strictly less than {length}. + */ + function _at(Set storage set, uint256 index) private view returns (bytes32) { + return set._values[index]; + } + + /** + * @dev Return the entire set in an array + * + * WARNING: This operation will copy the entire storage to memory, which can be quite expensive. This is designed + * to mostly be used by view accessors that are queried without any gas fees. Developers should keep in mind that + * this function has an unbounded cost, and using it as part of a state-changing function may render the function + * uncallable if the set grows to a point where copying to memory consumes too much gas to fit in a block. + */ + function _values(Set storage set) private view returns (bytes32[] memory) { + return set._values; + } + + // Bytes32Set + + struct Bytes32Set { + Set _inner; + } + + /** + * @dev Add a value to a set. O(1). + * + * Returns true if the value was added to the set, that is if it was not + * already present. + */ + function add(Bytes32Set storage set, bytes32 value) internal returns (bool) { + return _add(set._inner, value); + } + + /** + * @dev Removes a value from a set. O(1). + * + * Returns true if the value was removed from the set, that is if it was + * present. + */ + function remove(Bytes32Set storage set, bytes32 value) internal returns (bool) { + return _remove(set._inner, value); + } + + /** + * @dev Returns true if the value is in the set. O(1). + */ + function contains(Bytes32Set storage set, bytes32 value) internal view returns (bool) { + return _contains(set._inner, value); + } + + /** + * @dev Returns the number of values in the set. O(1). + */ + function length(Bytes32Set storage set) internal view returns (uint256) { + return _length(set._inner); + } + + /** + * @dev Returns the value stored at position `index` in the set. O(1). + * + * Note that there are no guarantees on the ordering of values inside the + * array, and it may change when more values are added or removed. + * + * Requirements: + * + * - `index` must be strictly less than {length}. + */ + function at(Bytes32Set storage set, uint256 index) internal view returns (bytes32) { + return _at(set._inner, index); + } + + /** + * @dev Return the entire set in an array + * + * WARNING: This operation will copy the entire storage to memory, which can be quite expensive. This is designed + * to mostly be used by view accessors that are queried without any gas fees. Developers should keep in mind that + * this function has an unbounded cost, and using it as part of a state-changing function may render the function + * uncallable if the set grows to a point where copying to memory consumes too much gas to fit in a block. + */ + function values(Bytes32Set storage set) internal view returns (bytes32[] memory) { + return _values(set._inner); + } + + // AddressSet + + struct AddressSet { + Set _inner; + } + + /** + * @dev Add a value to a set. O(1). + * + * Returns true if the value was added to the set, that is if it was not + * already present. + */ + function add(AddressSet storage set, address value) internal returns (bool) { + return _add(set._inner, bytes32(uint256(uint160(value)))); + } + + /** + * @dev Removes a value from a set. O(1). + * + * Returns true if the value was removed from the set, that is if it was + * present. + */ + function remove(AddressSet storage set, address value) internal returns (bool) { + return _remove(set._inner, bytes32(uint256(uint160(value)))); + } + + /** + * @dev Returns true if the value is in the set. O(1). + */ + function contains(AddressSet storage set, address value) internal view returns (bool) { + return _contains(set._inner, bytes32(uint256(uint160(value)))); + } + + /** + * @dev Returns the number of values in the set. O(1). + */ + function length(AddressSet storage set) internal view returns (uint256) { + return _length(set._inner); + } + + /** + * @dev Returns the value stored at position `index` in the set. O(1). + * + * Note that there are no guarantees on the ordering of values inside the + * array, and it may change when more values are added or removed. + * + * Requirements: + * + * - `index` must be strictly less than {length}. + */ + function at(AddressSet storage set, uint256 index) internal view returns (address) { + return address(uint160(uint256(_at(set._inner, index)))); + } + + /** + * @dev Return the entire set in an array + * + * WARNING: This operation will copy the entire storage to memory, which can be quite expensive. This is designed + * to mostly be used by view accessors that are queried without any gas fees. Developers should keep in mind that + * this function has an unbounded cost, and using it as part of a state-changing function may render the function + * uncallable if the set grows to a point where copying to memory consumes too much gas to fit in a block. + */ + function values(AddressSet storage set) internal view returns (address[] memory) { + bytes32[] memory store = _values(set._inner); + address[] memory result; + + /// @solidity memory-safe-assembly + assembly { + result := store + } + + return result; + } + + // UintSet + + struct UintSet { + Set _inner; + } + + /** + * @dev Add a value to a set. O(1). + * + * Returns true if the value was added to the set, that is if it was not + * already present. + */ + function add(UintSet storage set, uint256 value) internal returns (bool) { + return _add(set._inner, bytes32(value)); + } + + /** + * @dev Removes a value from a set. O(1). + * + * Returns true if the value was removed from the set, that is if it was + * present. + */ + function remove(UintSet storage set, uint256 value) internal returns (bool) { + return _remove(set._inner, bytes32(value)); + } + + /** + * @dev Returns true if the value is in the set. O(1). + */ + function contains(UintSet storage set, uint256 value) internal view returns (bool) { + return _contains(set._inner, bytes32(value)); + } + + /** + * @dev Returns the number of values on the set. O(1). + */ + function length(UintSet storage set) internal view returns (uint256) { + return _length(set._inner); + } + + /** + * @dev Returns the value stored at position `index` in the set. O(1). + * + * Note that there are no guarantees on the ordering of values inside the + * array, and it may change when more values are added or removed. + * + * Requirements: + * + * - `index` must be strictly less than {length}. + */ + function at(UintSet storage set, uint256 index) internal view returns (uint256) { + return uint256(_at(set._inner, index)); + } + + /** + * @dev Return the entire set in an array + * + * WARNING: This operation will copy the entire storage to memory, which can be quite expensive. This is designed + * to mostly be used by view accessors that are queried without any gas fees. Developers should keep in mind that + * this function has an unbounded cost, and using it as part of a state-changing function may render the function + * uncallable if the set grows to a point where copying to memory consumes too much gas to fit in a block. + */ + function values(UintSet storage set) internal view returns (uint256[] memory) { + bytes32[] memory store = _values(set._inner); + uint256[] memory result; + + /// @solidity memory-safe-assembly + assembly { + result := store + } + + return result; + } +} diff --git a/contracts/external-deps/openzeppelin/utils/cryptography/ECDSA.sol b/contracts/external-deps/openzeppelin/utils/cryptography/ECDSA.sol new file mode 100644 index 000000000..0c9f09bdb --- /dev/null +++ b/contracts/external-deps/openzeppelin/utils/cryptography/ECDSA.sol @@ -0,0 +1,217 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts (last updated v4.8.0) (utils/cryptography/ECDSA.sol) + +pragma solidity ^0.8.0; + +import "../../../../lib/Strings.sol"; + +/** + * @dev Elliptic Curve Digital Signature Algorithm (ECDSA) operations. + * + * These functions can be used to verify that a message was signed by the holder + * of the private keys of a given address. + */ +library ECDSA { + enum RecoverError { + NoError, + InvalidSignature, + InvalidSignatureLength, + InvalidSignatureS, + InvalidSignatureV // Deprecated in v4.8 + } + + function _throwError(RecoverError error) private pure { + if (error == RecoverError.NoError) { + return; // no error: do nothing + } else if (error == RecoverError.InvalidSignature) { + revert("ECDSA: invalid signature"); + } else if (error == RecoverError.InvalidSignatureLength) { + revert("ECDSA: invalid signature length"); + } else if (error == RecoverError.InvalidSignatureS) { + revert("ECDSA: invalid signature 's' value"); + } + } + + /** + * @dev Returns the address that signed a hashed message (`hash`) with + * `signature` or error string. This address can then be used for verification purposes. + * + * The `ecrecover` EVM opcode allows for malleable (non-unique) signatures: + * this function rejects them by requiring the `s` value to be in the lower + * half order, and the `v` value to be either 27 or 28. + * + * IMPORTANT: `hash` _must_ be the result of a hash operation for the + * verification to be secure: it is possible to craft signatures that + * recover to arbitrary addresses for non-hashed data. A safe way to ensure + * this is by receiving a hash of the original message (which may otherwise + * be too long), and then calling {toEthSignedMessageHash} on it. + * + * Documentation for signature generation: + * - with https://web3js.readthedocs.io/en/v1.3.4/web3-eth-accounts.html#sign[Web3.js] + * - with https://docs.ethers.io/v5/api/signer/#Signer-signMessage[ethers] + * + * _Available since v4.3._ + */ + function tryRecover(bytes32 hash, bytes memory signature) internal pure returns (address, RecoverError) { + if (signature.length == 65) { + bytes32 r; + bytes32 s; + uint8 v; + // ecrecover takes the signature parameters, and the only way to get them + // currently is to use assembly. + /// @solidity memory-safe-assembly + assembly { + r := mload(add(signature, 0x20)) + s := mload(add(signature, 0x40)) + v := byte(0, mload(add(signature, 0x60))) + } + return tryRecover(hash, v, r, s); + } else { + return (address(0), RecoverError.InvalidSignatureLength); + } + } + + /** + * @dev Returns the address that signed a hashed message (`hash`) with + * `signature`. This address can then be used for verification purposes. + * + * The `ecrecover` EVM opcode allows for malleable (non-unique) signatures: + * this function rejects them by requiring the `s` value to be in the lower + * half order, and the `v` value to be either 27 or 28. + * + * IMPORTANT: `hash` _must_ be the result of a hash operation for the + * verification to be secure: it is possible to craft signatures that + * recover to arbitrary addresses for non-hashed data. A safe way to ensure + * this is by receiving a hash of the original message (which may otherwise + * be too long), and then calling {toEthSignedMessageHash} on it. + */ + function recover(bytes32 hash, bytes memory signature) internal pure returns (address) { + (address recovered, RecoverError error) = tryRecover(hash, signature); + _throwError(error); + return recovered; + } + + /** + * @dev Overload of {ECDSA-tryRecover} that receives the `r` and `vs` short-signature fields separately. + * + * See https://eips.ethereum.org/EIPS/eip-2098[EIP-2098 short signatures] + * + * _Available since v4.3._ + */ + function tryRecover(bytes32 hash, bytes32 r, bytes32 vs) internal pure returns (address, RecoverError) { + bytes32 s = vs & bytes32(0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff); + uint8 v = uint8((uint256(vs) >> 255) + 27); + return tryRecover(hash, v, r, s); + } + + /** + * @dev Overload of {ECDSA-recover} that receives the `r and `vs` short-signature fields separately. + * + * _Available since v4.2._ + */ + function recover(bytes32 hash, bytes32 r, bytes32 vs) internal pure returns (address) { + (address recovered, RecoverError error) = tryRecover(hash, r, vs); + _throwError(error); + return recovered; + } + + /** + * @dev Overload of {ECDSA-tryRecover} that receives the `v`, + * `r` and `s` signature fields separately. + * + * _Available since v4.3._ + */ + function tryRecover(bytes32 hash, uint8 v, bytes32 r, bytes32 s) internal pure returns (address, RecoverError) { + // EIP-2 still allows signature malleability for ecrecover(). Remove this possibility and make the signature + // unique. Appendix F in the Ethereum Yellow paper (https://ethereum.github.io/yellowpaper/paper.pdf), defines + // the valid range for s in (301): 0 < s < secp256k1n ÷ 2 + 1, and for v in (302): v ∈ {27, 28}. Most + // signatures from current libraries generate a unique signature with an s-value in the lower half order. + // + // If your library generates malleable signatures, such as s-values in the upper range, calculate a new s-value + // with 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 - s1 and flip v from 27 to 28 or + // vice versa. If your library also generates signatures with 0/1 for v instead 27/28, add 27 to v to accept + // these malleable signatures as well. + if (uint256(s) > 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0) { + return (address(0), RecoverError.InvalidSignatureS); + } + + // If the signature is valid (and not malleable), return the signer address + address signer = ecrecover(hash, v, r, s); + if (signer == address(0)) { + return (address(0), RecoverError.InvalidSignature); + } + + return (signer, RecoverError.NoError); + } + + /** + * @dev Overload of {ECDSA-recover} that receives the `v`, + * `r` and `s` signature fields separately. + */ + function recover(bytes32 hash, uint8 v, bytes32 r, bytes32 s) internal pure returns (address) { + (address recovered, RecoverError error) = tryRecover(hash, v, r, s); + _throwError(error); + return recovered; + } + + /** + * @dev Returns an Ethereum Signed Message, created from a `hash`. This + * produces hash corresponding to the one signed with the + * https://eth.wiki/json-rpc/API#eth_sign[`eth_sign`] + * JSON-RPC method as part of EIP-191. + * + * See {recover}. + */ + function toEthSignedMessageHash(bytes32 hash) internal pure returns (bytes32 message) { + // 32 is the length in bytes of hash, + // enforced by the type signature above + /// @solidity memory-safe-assembly + assembly { + mstore(0x00, "\x19Ethereum Signed Message:\n32") + mstore(0x1c, hash) + message := keccak256(0x00, 0x3c) + } + } + + /** + * @dev Returns an Ethereum Signed Message, created from `s`. This + * produces hash corresponding to the one signed with the + * https://eth.wiki/json-rpc/API#eth_sign[`eth_sign`] + * JSON-RPC method as part of EIP-191. + * + * See {recover}. + */ + function toEthSignedMessageHash(bytes memory s) internal pure returns (bytes32) { + return keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n", Strings.toString(s.length), s)); + } + + /** + * @dev Returns an Ethereum Signed Typed Data, created from a + * `domainSeparator` and a `structHash`. This produces hash corresponding + * to the one signed with the + * https://eips.ethereum.org/EIPS/eip-712[`eth_signTypedData`] + * JSON-RPC method as part of EIP-712. + * + * See {recover}. + */ + function toTypedDataHash(bytes32 domainSeparator, bytes32 structHash) internal pure returns (bytes32 data) { + /// @solidity memory-safe-assembly + assembly { + let ptr := mload(0x40) + mstore(ptr, "\x19\x01") + mstore(add(ptr, 0x02), domainSeparator) + mstore(add(ptr, 0x22), structHash) + data := keccak256(ptr, 0x42) + } + } + + /** + * @dev Returns an Ethereum Signed Data with intended validator, created from a + * `validator` and `data` according to the version 0 of EIP-191. + * + * See {recover}. + */ + function toDataWithIntendedValidatorHash(address validator, bytes memory data) internal pure returns (bytes32) { + return keccak256(abi.encodePacked("\x19\x00", validator, data)); + } +} diff --git a/contracts/external-deps/openzeppelin/utils/cryptography/EIP712.sol b/contracts/external-deps/openzeppelin/utils/cryptography/EIP712.sol new file mode 100644 index 000000000..a32c25b7f --- /dev/null +++ b/contracts/external-deps/openzeppelin/utils/cryptography/EIP712.sol @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts v4.4.1 (utils/cryptography/draft-EIP712.sol) + +pragma solidity ^0.8.0; + +import "./ECDSA.sol"; + +/** + * @dev https://eips.ethereum.org/EIPS/eip-712[EIP 712] is a standard for hashing and signing of typed structured data. + * + * The encoding specified in the EIP is very generic, and such a generic implementation in Solidity is not feasible, + * thus this contract does not implement the encoding itself. Protocols need to implement the type-specific encoding + * they need in their contracts using a combination of `abi.encode` and `keccak256`. + * + * This contract implements the EIP 712 domain separator ({_domainSeparatorV4}) that is used as part of the encoding + * scheme, and the final step of the encoding to obtain the message digest that is then signed via ECDSA + * ({_hashTypedDataV4}). + * + * The implementation of the domain separator was designed to be as efficient as possible while still properly updating + * the chain id to protect against replay attacks on an eventual fork of the chain. + * + * NOTE: This contract implements the version of the encoding known as "v4", as implemented by the JSON RPC method + * https://docs.metamask.io/guide/signing-data.html[`eth_signTypedDataV4` in MetaMask]. + * + * _Available since v3.4._ + */ +abstract contract EIP712 { + /* solhint-disable var-name-mixedcase */ + // Cache the domain separator as an immutable value, but also store the chain id that it corresponds to, in order to + // invalidate the cached domain separator if the chain id changes. + bytes32 private immutable _CACHED_DOMAIN_SEPARATOR; + uint256 private immutable _CACHED_CHAIN_ID; + address private immutable _CACHED_THIS; + + bytes32 private immutable _HASHED_NAME; + bytes32 private immutable _HASHED_VERSION; + bytes32 private immutable _TYPE_HASH; + + /* solhint-enable var-name-mixedcase */ + + /** + * @dev Initializes the domain separator and parameter caches. + * + * The meaning of `name` and `version` is specified in + * https://eips.ethereum.org/EIPS/eip-712#definition-of-domainseparator[EIP 712]: + * + * - `name`: the user readable name of the signing domain, i.e. the name of the DApp or the protocol. + * - `version`: the current major version of the signing domain. + * + * NOTE: These parameters cannot be changed except through a xref:learn::upgrading-smart-contracts.adoc[smart + * contract upgrade]. + */ + constructor(string memory name, string memory version) { + bytes32 hashedName = keccak256(bytes(name)); + bytes32 hashedVersion = keccak256(bytes(version)); + bytes32 typeHash = keccak256( + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" + ); + _HASHED_NAME = hashedName; + _HASHED_VERSION = hashedVersion; + _CACHED_CHAIN_ID = block.chainid; + _CACHED_DOMAIN_SEPARATOR = _buildDomainSeparator(typeHash, hashedName, hashedVersion); + _CACHED_THIS = address(this); + _TYPE_HASH = typeHash; + } + + /** + * @dev Returns the domain separator for the current chain. + */ + function _domainSeparatorV4() internal view returns (bytes32) { + if (address(this) == _CACHED_THIS && block.chainid == _CACHED_CHAIN_ID) { + return _CACHED_DOMAIN_SEPARATOR; + } else { + return _buildDomainSeparator(_TYPE_HASH, _HASHED_NAME, _HASHED_VERSION); + } + } + + function _buildDomainSeparator( + bytes32 typeHash, + bytes32 nameHash, + bytes32 versionHash + ) private view returns (bytes32) { + return keccak256(abi.encode(typeHash, nameHash, versionHash, block.chainid, address(this))); + } + + /** + * @dev Given an already https://eips.ethereum.org/EIPS/eip-712#definition-of-hashstruct[hashed struct], this + * function returns the hash of the fully encoded EIP712 message for this domain. + * + * This hash can be used together with {ECDSA-recover} to obtain the signer of a message. For example: + * + * ```solidity + * bytes32 digest = _hashTypedDataV4(keccak256(abi.encode( + * keccak256("Mail(address to,string contents)"), + * mailTo, + * keccak256(bytes(mailContents)) + * ))); + * address signer = ECDSA.recover(digest, signature); + * ``` + */ + function _hashTypedDataV4(bytes32 structHash) internal view virtual returns (bytes32) { + return ECDSA.toTypedDataHash(_domainSeparatorV4(), structHash); + } +} diff --git a/contracts/external-deps/openzeppelin/utils/math/Math.sol b/contracts/external-deps/openzeppelin/utils/math/Math.sol new file mode 100644 index 000000000..291d257b0 --- /dev/null +++ b/contracts/external-deps/openzeppelin/utils/math/Math.sol @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts (last updated v4.5.0) (utils/math/Math.sol) + +pragma solidity ^0.8.0; + +/** + * @dev Standard math utilities missing in the Solidity language. + */ +library Math { + /** + * @dev Returns the largest of two numbers. + */ + function max(uint256 a, uint256 b) internal pure returns (uint256) { + return a >= b ? a : b; + } + + /** + * @dev Returns the smallest of two numbers. + */ + function min(uint256 a, uint256 b) internal pure returns (uint256) { + return a < b ? a : b; + } + + /** + * @dev Returns the average of two numbers. The result is rounded towards + * zero. + */ + function average(uint256 a, uint256 b) internal pure returns (uint256) { + // (a + b) / 2 can overflow. + return (a & b) + (a ^ b) / 2; + } + + /** + * @dev Returns the ceiling of the division of two numbers. + * + * This differs from standard division with `/` in that it rounds up instead + * of rounding down. + */ + function ceilDiv(uint256 a, uint256 b) internal pure returns (uint256) { + // (a + b - 1) / b can overflow on addition, so we distribute. + return a / b + (a % b == 0 ? 0 : 1); + } +} diff --git a/contracts/external-deps/openzeppelin/utils/math/SafeCast.sol b/contracts/external-deps/openzeppelin/utils/math/SafeCast.sol new file mode 100644 index 000000000..3cd647357 --- /dev/null +++ b/contracts/external-deps/openzeppelin/utils/math/SafeCast.sol @@ -0,0 +1,241 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts v4.4.1 (utils/math/SafeCast.sol) + +pragma solidity ^0.8.0; + +/** + * @dev Wrappers over Solidity's uintXX/intXX casting operators with added overflow + * checks. + * + * Downcasting from uint256/int256 in Solidity does not revert on overflow. This can + * easily result in undesired exploitation or bugs, since developers usually + * assume that overflows raise errors. `SafeCast` restores this intuition by + * reverting the transaction when such an operation overflows. + * + * Using this library instead of the unchecked operations eliminates an entire + * class of bugs, so it's recommended to use it always. + * + * Can be combined with {SafeMath} and {SignedSafeMath} to extend it to smaller types, by performing + * all math on `uint256` and `int256` and then downcasting. + */ +library SafeCast { + /** + * @dev Returns the downcasted uint224 from uint256, reverting on + * overflow (when the input is greater than largest uint224). + * + * Counterpart to Solidity's `uint224` operator. + * + * Requirements: + * + * - input must fit into 224 bits + */ + function toUint224(uint256 value) internal pure returns (uint224) { + require(value <= type(uint224).max, "SafeCast: value doesn't fit in 224 bits"); + return uint224(value); + } + + /** + * @dev Returns the downcasted uint128 from uint256, reverting on + * overflow (when the input is greater than largest uint128). + * + * Counterpart to Solidity's `uint128` operator. + * + * Requirements: + * + * - input must fit into 128 bits + */ + function toUint128(uint256 value) internal pure returns (uint128) { + require(value <= type(uint128).max, "SafeCast: value doesn't fit in 128 bits"); + return uint128(value); + } + + /** + * @dev Returns the downcasted uint96 from uint256, reverting on + * overflow (when the input is greater than largest uint96). + * + * Counterpart to Solidity's `uint96` operator. + * + * Requirements: + * + * - input must fit into 96 bits + */ + function toUint96(uint256 value) internal pure returns (uint96) { + require(value <= type(uint96).max, "SafeCast: value doesn't fit in 96 bits"); + return uint96(value); + } + + /** + * @dev Returns the downcasted uint64 from uint256, reverting on + * overflow (when the input is greater than largest uint64). + * + * Counterpart to Solidity's `uint64` operator. + * + * Requirements: + * + * - input must fit into 64 bits + */ + function toUint64(uint256 value) internal pure returns (uint64) { + require(value <= type(uint64).max, "SafeCast: value doesn't fit in 64 bits"); + return uint64(value); + } + + /** + * @dev Returns the downcasted uint32 from uint256, reverting on + * overflow (when the input is greater than largest uint32). + * + * Counterpart to Solidity's `uint32` operator. + * + * Requirements: + * + * - input must fit into 32 bits + */ + function toUint32(uint256 value) internal pure returns (uint32) { + require(value <= type(uint32).max, "SafeCast: value doesn't fit in 32 bits"); + return uint32(value); + } + + /** + * @dev Returns the downcasted uint16 from uint256, reverting on + * overflow (when the input is greater than largest uint16). + * + * Counterpart to Solidity's `uint16` operator. + * + * Requirements: + * + * - input must fit into 16 bits + */ + function toUint16(uint256 value) internal pure returns (uint16) { + require(value <= type(uint16).max, "SafeCast: value doesn't fit in 16 bits"); + return uint16(value); + } + + /** + * @dev Returns the downcasted uint8 from uint256, reverting on + * overflow (when the input is greater than largest uint8). + * + * Counterpart to Solidity's `uint8` operator. + * + * Requirements: + * + * - input must fit into 8 bits. + */ + function toUint8(uint256 value) internal pure returns (uint8) { + require(value <= type(uint8).max, "SafeCast: value doesn't fit in 8 bits"); + return uint8(value); + } + + /** + * @dev Converts a signed int256 into an unsigned uint256. + * + * Requirements: + * + * - input must be greater than or equal to 0. + */ + function toUint256(int256 value) internal pure returns (uint256) { + require(value >= 0, "SafeCast: value must be positive"); + return uint256(value); + } + + /** + * @dev Returns the downcasted int128 from int256, reverting on + * overflow (when the input is less than smallest int128 or + * greater than largest int128). + * + * Counterpart to Solidity's `int128` operator. + * + * Requirements: + * + * - input must fit into 128 bits + * + * _Available since v3.1._ + */ + function toInt128(int256 value) internal pure returns (int128) { + require(value >= type(int128).min && value <= type(int128).max, "SafeCast: value doesn't fit in 128 bits"); + return int128(value); + } + + /** + * @dev Returns the downcasted int64 from int256, reverting on + * overflow (when the input is less than smallest int64 or + * greater than largest int64). + * + * Counterpart to Solidity's `int64` operator. + * + * Requirements: + * + * - input must fit into 64 bits + * + * _Available since v3.1._ + */ + function toInt64(int256 value) internal pure returns (int64) { + require(value >= type(int64).min && value <= type(int64).max, "SafeCast: value doesn't fit in 64 bits"); + return int64(value); + } + + /** + * @dev Returns the downcasted int32 from int256, reverting on + * overflow (when the input is less than smallest int32 or + * greater than largest int32). + * + * Counterpart to Solidity's `int32` operator. + * + * Requirements: + * + * - input must fit into 32 bits + * + * _Available since v3.1._ + */ + function toInt32(int256 value) internal pure returns (int32) { + require(value >= type(int32).min && value <= type(int32).max, "SafeCast: value doesn't fit in 32 bits"); + return int32(value); + } + + /** + * @dev Returns the downcasted int16 from int256, reverting on + * overflow (when the input is less than smallest int16 or + * greater than largest int16). + * + * Counterpart to Solidity's `int16` operator. + * + * Requirements: + * + * - input must fit into 16 bits + * + * _Available since v3.1._ + */ + function toInt16(int256 value) internal pure returns (int16) { + require(value >= type(int16).min && value <= type(int16).max, "SafeCast: value doesn't fit in 16 bits"); + return int16(value); + } + + /** + * @dev Returns the downcasted int8 from int256, reverting on + * overflow (when the input is less than smallest int8 or + * greater than largest int8). + * + * Counterpart to Solidity's `int8` operator. + * + * Requirements: + * + * - input must fit into 8 bits. + * + * _Available since v3.1._ + */ + function toInt8(int256 value) internal pure returns (int8) { + require(value >= type(int8).min && value <= type(int8).max, "SafeCast: value doesn't fit in 8 bits"); + return int8(value); + } + + /** + * @dev Converts an unsigned uint256 into a signed int256. + * + * Requirements: + * + * - input must be less than or equal to maxInt256. + */ + function toInt256(uint256 value) internal pure returns (int256) { + // Note: Unsafe cast below is okay because `type(int256).max` is guaranteed to be positive + require(value <= uint256(type(int256).max), "SafeCast: value doesn't fit in an int256"); + return int256(value); + } +} diff --git a/contracts/external-deps/openzeppelin/utils/math/SafeMath.sol b/contracts/external-deps/openzeppelin/utils/math/SafeMath.sol new file mode 100644 index 000000000..2f48fb736 --- /dev/null +++ b/contracts/external-deps/openzeppelin/utils/math/SafeMath.sol @@ -0,0 +1,215 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts (last updated v4.6.0) (utils/math/SafeMath.sol) + +pragma solidity ^0.8.0; + +// CAUTION +// This version of SafeMath should only be used with Solidity 0.8 or later, +// because it relies on the compiler's built in overflow checks. + +/** + * @dev Wrappers over Solidity's arithmetic operations. + * + * NOTE: `SafeMath` is generally not needed starting with Solidity 0.8, since the compiler + * now has built in overflow checking. + */ +library SafeMath { + /** + * @dev Returns the addition of two unsigned integers, with an overflow flag. + * + * _Available since v3.4._ + */ + function tryAdd(uint256 a, uint256 b) internal pure returns (bool, uint256) { + unchecked { + uint256 c = a + b; + if (c < a) return (false, 0); + return (true, c); + } + } + + /** + * @dev Returns the subtraction of two unsigned integers, with an overflow flag. + * + * _Available since v3.4._ + */ + function trySub(uint256 a, uint256 b) internal pure returns (bool, uint256) { + unchecked { + if (b > a) return (false, 0); + return (true, a - b); + } + } + + /** + * @dev Returns the multiplication of two unsigned integers, with an overflow flag. + * + * _Available since v3.4._ + */ + function tryMul(uint256 a, uint256 b) internal pure returns (bool, uint256) { + unchecked { + // Gas optimization: this is cheaper than requiring 'a' not being zero, but the + // benefit is lost if 'b' is also tested. + // See: https://github.com/OpenZeppelin/openzeppelin-contracts/pull/522 + if (a == 0) return (true, 0); + uint256 c = a * b; + if (c / a != b) return (false, 0); + return (true, c); + } + } + + /** + * @dev Returns the division of two unsigned integers, with a division by zero flag. + * + * _Available since v3.4._ + */ + function tryDiv(uint256 a, uint256 b) internal pure returns (bool, uint256) { + unchecked { + if (b == 0) return (false, 0); + return (true, a / b); + } + } + + /** + * @dev Returns the remainder of dividing two unsigned integers, with a division by zero flag. + * + * _Available since v3.4._ + */ + function tryMod(uint256 a, uint256 b) internal pure returns (bool, uint256) { + unchecked { + if (b == 0) return (false, 0); + return (true, a % b); + } + } + + /** + * @dev Returns the addition of two unsigned integers, reverting on + * overflow. + * + * Counterpart to Solidity's `+` operator. + * + * Requirements: + * + * - Addition cannot overflow. + */ + function add(uint256 a, uint256 b) internal pure returns (uint256) { + return a + b; + } + + /** + * @dev Returns the subtraction of two unsigned integers, reverting on + * overflow (when the result is negative). + * + * Counterpart to Solidity's `-` operator. + * + * Requirements: + * + * - Subtraction cannot overflow. + */ + function sub(uint256 a, uint256 b) internal pure returns (uint256) { + return a - b; + } + + /** + * @dev Returns the multiplication of two unsigned integers, reverting on + * overflow. + * + * Counterpart to Solidity's `*` operator. + * + * Requirements: + * + * - Multiplication cannot overflow. + */ + function mul(uint256 a, uint256 b) internal pure returns (uint256) { + return a * b; + } + + /** + * @dev Returns the integer division of two unsigned integers, reverting on + * division by zero. The result is rounded towards zero. + * + * Counterpart to Solidity's `/` operator. + * + * Requirements: + * + * - The divisor cannot be zero. + */ + function div(uint256 a, uint256 b) internal pure returns (uint256) { + return a / b; + } + + /** + * @dev Returns the remainder of dividing two unsigned integers. (unsigned integer modulo), + * reverting when dividing by zero. + * + * Counterpart to Solidity's `%` operator. This function uses a `revert` + * opcode (which leaves remaining gas untouched) while Solidity uses an + * invalid opcode to revert (consuming all remaining gas). + * + * Requirements: + * + * - The divisor cannot be zero. + */ + function mod(uint256 a, uint256 b) internal pure returns (uint256) { + return a % b; + } + + /** + * @dev Returns the subtraction of two unsigned integers, reverting with custom message on + * overflow (when the result is negative). + * + * CAUTION: This function is deprecated because it requires allocating memory for the error + * message unnecessarily. For custom revert reasons use {trySub}. + * + * Counterpart to Solidity's `-` operator. + * + * Requirements: + * + * - Subtraction cannot overflow. + */ + function sub(uint256 a, uint256 b, string memory errorMessage) internal pure returns (uint256) { + unchecked { + require(b <= a, errorMessage); + return a - b; + } + } + + /** + * @dev Returns the integer division of two unsigned integers, reverting with custom message on + * division by zero. The result is rounded towards zero. + * + * Counterpart to Solidity's `/` operator. Note: this function uses a + * `revert` opcode (which leaves remaining gas untouched) while Solidity + * uses an invalid opcode to revert (consuming all remaining gas). + * + * Requirements: + * + * - The divisor cannot be zero. + */ + function div(uint256 a, uint256 b, string memory errorMessage) internal pure returns (uint256) { + unchecked { + require(b > 0, errorMessage); + return a / b; + } + } + + /** + * @dev Returns the remainder of dividing two unsigned integers. (unsigned integer modulo), + * reverting with custom message when dividing by zero. + * + * CAUTION: This function is deprecated because it requires allocating memory for the error + * message unnecessarily. For custom revert reasons use {tryMod}. + * + * Counterpart to Solidity's `%` operator. This function uses a `revert` + * opcode (which leaves remaining gas untouched) while Solidity uses an + * invalid opcode to revert (consuming all remaining gas). + * + * Requirements: + * + * - The divisor cannot be zero. + */ + function mod(uint256 a, uint256 b, string memory errorMessage) internal pure returns (uint256) { + unchecked { + require(b > 0, errorMessage); + return a % b; + } + } +} diff --git a/contracts/external-deps/openzeppelin/utils/structs/EnumerableSet.sol b/contracts/external-deps/openzeppelin/utils/structs/EnumerableSet.sol new file mode 100644 index 000000000..b6c647f07 --- /dev/null +++ b/contracts/external-deps/openzeppelin/utils/structs/EnumerableSet.sol @@ -0,0 +1,367 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts (last updated v4.7.0) (utils/structs/EnumerableSet.sol) + +pragma solidity ^0.8.0; + +/** + * @dev Library for managing + * https://en.wikipedia.org/wiki/Set_(abstract_data_type)[sets] of primitive + * types. + * + * Sets have the following properties: + * + * - Elements are added, removed, and checked for existence in constant time + * (O(1)). + * - Elements are enumerated in O(n). No guarantees are made on the ordering. + * + * ``` + * contract Example { + * // Add the library methods + * using EnumerableSet for EnumerableSet.AddressSet; + * + * // Declare a set state variable + * EnumerableSet.AddressSet private mySet; + * } + * ``` + * + * As of v3.3.0, sets of type `bytes32` (`Bytes32Set`), `address` (`AddressSet`) + * and `uint256` (`UintSet`) are supported. + * + * [WARNING] + * ==== + * Trying to delete such a structure from storage will likely result in data corruption, rendering the structure unusable. + * See https://github.com/ethereum/solidity/pull/11843[ethereum/solidity#11843] for more info. + * + * In order to clean an EnumerableSet, you can either remove all elements one by one or create a fresh instance using an array of EnumerableSet. + * ==== + */ +library EnumerableSet { + // To implement this library for multiple types with as little code + // repetition as possible, we write it in terms of a generic Set type with + // bytes32 values. + // The Set implementation uses private functions, and user-facing + // implementations (such as AddressSet) are just wrappers around the + // underlying Set. + // This means that we can only create new EnumerableSets for types that fit + // in bytes32. + + struct Set { + // Storage of set values + bytes32[] _values; + // Position of the value in the `values` array, plus 1 because index 0 + // means a value is not in the set. + mapping(bytes32 => uint256) _indexes; + } + + /** + * @dev Add a value to a set. O(1). + * + * Returns true if the value was added to the set, that is if it was not + * already present. + */ + function _add(Set storage set, bytes32 value) private returns (bool) { + if (!_contains(set, value)) { + set._values.push(value); + // The value is stored at length-1, but we add 1 to all indexes + // and use 0 as a sentinel value + set._indexes[value] = set._values.length; + return true; + } else { + return false; + } + } + + /** + * @dev Removes a value from a set. O(1). + * + * Returns true if the value was removed from the set, that is if it was + * present. + */ + function _remove(Set storage set, bytes32 value) private returns (bool) { + // We read and store the value's index to prevent multiple reads from the same storage slot + uint256 valueIndex = set._indexes[value]; + + if (valueIndex != 0) { + // Equivalent to contains(set, value) + // To delete an element from the _values array in O(1), we swap the element to delete with the last one in + // the array, and then remove the last element (sometimes called as 'swap and pop'). + // This modifies the order of the array, as noted in {at}. + + uint256 toDeleteIndex = valueIndex - 1; + uint256 lastIndex = set._values.length - 1; + + if (lastIndex != toDeleteIndex) { + bytes32 lastValue = set._values[lastIndex]; + + // Move the last value to the index where the value to delete is + set._values[toDeleteIndex] = lastValue; + // Update the index for the moved value + set._indexes[lastValue] = valueIndex; // Replace lastValue's index to valueIndex + } + + // Delete the slot where the moved value was stored + set._values.pop(); + + // Delete the index for the deleted slot + delete set._indexes[value]; + + return true; + } else { + return false; + } + } + + /** + * @dev Returns true if the value is in the set. O(1). + */ + function _contains(Set storage set, bytes32 value) private view returns (bool) { + return set._indexes[value] != 0; + } + + /** + * @dev Returns the number of values on the set. O(1). + */ + function _length(Set storage set) private view returns (uint256) { + return set._values.length; + } + + /** + * @dev Returns the value stored at position `index` in the set. O(1). + * + * Note that there are no guarantees on the ordering of values inside the + * array, and it may change when more values are added or removed. + * + * Requirements: + * + * - `index` must be strictly less than {length}. + */ + function _at(Set storage set, uint256 index) private view returns (bytes32) { + return set._values[index]; + } + + /** + * @dev Return the entire set in an array + * + * WARNING: This operation will copy the entire storage to memory, which can be quite expensive. This is designed + * to mostly be used by view accessors that are queried without any gas fees. Developers should keep in mind that + * this function has an unbounded cost, and using it as part of a state-changing function may render the function + * uncallable if the set grows to a point where copying to memory consumes too much gas to fit in a block. + */ + function _values(Set storage set) private view returns (bytes32[] memory) { + return set._values; + } + + // Bytes32Set + + struct Bytes32Set { + Set _inner; + } + + /** + * @dev Add a value to a set. O(1). + * + * Returns true if the value was added to the set, that is if it was not + * already present. + */ + function add(Bytes32Set storage set, bytes32 value) internal returns (bool) { + return _add(set._inner, value); + } + + /** + * @dev Removes a value from a set. O(1). + * + * Returns true if the value was removed from the set, that is if it was + * present. + */ + function remove(Bytes32Set storage set, bytes32 value) internal returns (bool) { + return _remove(set._inner, value); + } + + /** + * @dev Returns true if the value is in the set. O(1). + */ + function contains(Bytes32Set storage set, bytes32 value) internal view returns (bool) { + return _contains(set._inner, value); + } + + /** + * @dev Returns the number of values in the set. O(1). + */ + function length(Bytes32Set storage set) internal view returns (uint256) { + return _length(set._inner); + } + + /** + * @dev Returns the value stored at position `index` in the set. O(1). + * + * Note that there are no guarantees on the ordering of values inside the + * array, and it may change when more values are added or removed. + * + * Requirements: + * + * - `index` must be strictly less than {length}. + */ + function at(Bytes32Set storage set, uint256 index) internal view returns (bytes32) { + return _at(set._inner, index); + } + + /** + * @dev Return the entire set in an array + * + * WARNING: This operation will copy the entire storage to memory, which can be quite expensive. This is designed + * to mostly be used by view accessors that are queried without any gas fees. Developers should keep in mind that + * this function has an unbounded cost, and using it as part of a state-changing function may render the function + * uncallable if the set grows to a point where copying to memory consumes too much gas to fit in a block. + */ + function values(Bytes32Set storage set) internal view returns (bytes32[] memory) { + return _values(set._inner); + } + + // AddressSet + + struct AddressSet { + Set _inner; + } + + /** + * @dev Add a value to a set. O(1). + * + * Returns true if the value was added to the set, that is if it was not + * already present. + */ + function add(AddressSet storage set, address value) internal returns (bool) { + return _add(set._inner, bytes32(uint256(uint160(value)))); + } + + /** + * @dev Removes a value from a set. O(1). + * + * Returns true if the value was removed from the set, that is if it was + * present. + */ + function remove(AddressSet storage set, address value) internal returns (bool) { + return _remove(set._inner, bytes32(uint256(uint160(value)))); + } + + /** + * @dev Returns true if the value is in the set. O(1). + */ + function contains(AddressSet storage set, address value) internal view returns (bool) { + return _contains(set._inner, bytes32(uint256(uint160(value)))); + } + + /** + * @dev Returns the number of values in the set. O(1). + */ + function length(AddressSet storage set) internal view returns (uint256) { + return _length(set._inner); + } + + /** + * @dev Returns the value stored at position `index` in the set. O(1). + * + * Note that there are no guarantees on the ordering of values inside the + * array, and it may change when more values are added or removed. + * + * Requirements: + * + * - `index` must be strictly less than {length}. + */ + function at(AddressSet storage set, uint256 index) internal view returns (address) { + return address(uint160(uint256(_at(set._inner, index)))); + } + + /** + * @dev Return the entire set in an array + * + * WARNING: This operation will copy the entire storage to memory, which can be quite expensive. This is designed + * to mostly be used by view accessors that are queried without any gas fees. Developers should keep in mind that + * this function has an unbounded cost, and using it as part of a state-changing function may render the function + * uncallable if the set grows to a point where copying to memory consumes too much gas to fit in a block. + */ + function values(AddressSet storage set) internal view returns (address[] memory) { + bytes32[] memory store = _values(set._inner); + address[] memory result; + + /// @solidity memory-safe-assembly + assembly { + result := store + } + + return result; + } + + // UintSet + + struct UintSet { + Set _inner; + } + + /** + * @dev Add a value to a set. O(1). + * + * Returns true if the value was added to the set, that is if it was not + * already present. + */ + function add(UintSet storage set, uint256 value) internal returns (bool) { + return _add(set._inner, bytes32(value)); + } + + /** + * @dev Removes a value from a set. O(1). + * + * Returns true if the value was removed from the set, that is if it was + * present. + */ + function remove(UintSet storage set, uint256 value) internal returns (bool) { + return _remove(set._inner, bytes32(value)); + } + + /** + * @dev Returns true if the value is in the set. O(1). + */ + function contains(UintSet storage set, uint256 value) internal view returns (bool) { + return _contains(set._inner, bytes32(value)); + } + + /** + * @dev Returns the number of values on the set. O(1). + */ + function length(UintSet storage set) internal view returns (uint256) { + return _length(set._inner); + } + + /** + * @dev Returns the value stored at position `index` in the set. O(1). + * + * Note that there are no guarantees on the ordering of values inside the + * array, and it may change when more values are added or removed. + * + * Requirements: + * + * - `index` must be strictly less than {length}. + */ + function at(UintSet storage set, uint256 index) internal view returns (uint256) { + return uint256(_at(set._inner, index)); + } + + /** + * @dev Return the entire set in an array + * + * WARNING: This operation will copy the entire storage to memory, which can be quite expensive. This is designed + * to mostly be used by view accessors that are queried without any gas fees. Developers should keep in mind that + * this function has an unbounded cost, and using it as part of a state-changing function may render the function + * uncallable if the set grows to a point where copying to memory consumes too much gas to fit in a block. + */ + function values(UintSet storage set) internal view returns (uint256[] memory) { + bytes32[] memory store = _values(set._inner); + uint256[] memory result; + + /// @solidity memory-safe-assembly + assembly { + result := store + } + + return result; + } +} diff --git a/contracts/infra/ContractPublisher.sol b/contracts/infra/ContractPublisher.sol new file mode 100644 index 000000000..a69ed5c05 --- /dev/null +++ b/contracts/infra/ContractPublisher.sol @@ -0,0 +1,268 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +// $$\ $$\ $$\ $$\ $$\ +// $$ | $$ | \__| $$ | $$ | +// $$$$$$\ $$$$$$$\ $$\ $$$$$$\ $$$$$$$ |$$\ $$\ $$\ $$$$$$\ $$$$$$$\ +// \_$$ _| $$ __$$\ $$ |$$ __$$\ $$ __$$ |$$ | $$ | $$ |$$ __$$\ $$ __$$\ +// $$ | $$ | $$ |$$ |$$ | \__|$$ / $$ |$$ | $$ | $$ |$$$$$$$$ |$$ | $$ | +// $$ |$$\ $$ | $$ |$$ |$$ | $$ | $$ |$$ | $$ | $$ |$$ ____|$$ | $$ | +// \$$$$ |$$ | $$ |$$ |$$ | \$$$$$$$ |\$$$$$\$$$$ |\$$$$$$$\ $$$$$$$ | +// \____/ \__| \__|\__|\__| \_______| \_____\____/ \_______|\_______/ + +// ========== External imports ========== +import "@openzeppelin/contracts/metatx/ERC2771Context.sol"; +import "@openzeppelin/contracts/access/AccessControlEnumerable.sol"; +import "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; +import "../extension/Multicall.sol"; + +// ========== Internal imports ========== +import { IContractPublisher } from "./interface/IContractPublisher.sol"; + +contract ContractPublisher is IContractPublisher, ERC2771Context, AccessControlEnumerable, Multicall { + using EnumerableSet for EnumerableSet.Bytes32Set; + + /*/////////////////////////////////////////////////////////////// + State variables + //////////////////////////////////////////////////////////////*/ + + /// @notice Whether the contract publisher is paused. + bool public isPaused; + IContractPublisher public prevPublisher; + + /// @dev Only MIGRATION holders can override previous publisher or migrate data + bytes32 private constant MIGRATION_ROLE = keccak256("MIGRATION_ROLE"); + + /*/////////////////////////////////////////////////////////////// + Mappings + //////////////////////////////////////////////////////////////*/ + + /// @dev Mapping from publisher address => set of published contracts. + mapping(address => CustomContractSet) private contractsOfPublisher; + /// @dev Mapping publisher address => profile uri + mapping(address => string) private profileUriOfPublisher; + /// @dev Mapping compilerMetadataUri => publishedMetadataUri + mapping(string => PublishedMetadataSet) private compilerMetadataUriToPublishedMetadataUris; + + /*/////////////////////////////////////////////////////////////// + Constructor + modifiers + //////////////////////////////////////////////////////////////*/ + + /// @dev Checks whether caller is publisher TODO enable external approvals + modifier onlyPublisher(address _publisher) { + require(_msgSender() == _publisher, "unapproved caller"); + + _; + } + + /// @dev Checks whether contract is unpaused or the caller is a contract admin. + modifier onlyUnpausedOrAdmin() { + require(!isPaused || hasRole(DEFAULT_ADMIN_ROLE, _msgSender()), "registry paused"); + + _; + } + + constructor( + address _defaultAdmin, + address _trustedForwarder, + IContractPublisher _prevPublisher + ) ERC2771Context(_trustedForwarder) { + _setupRole(DEFAULT_ADMIN_ROLE, _defaultAdmin); + _setupRole(MIGRATION_ROLE, _defaultAdmin); + _setRoleAdmin(MIGRATION_ROLE, MIGRATION_ROLE); + + prevPublisher = _prevPublisher; + } + + /*/////////////////////////////////////////////////////////////// + Getter logic + //////////////////////////////////////////////////////////////*/ + + /// @notice Returns the latest version of all contracts published by a publisher. + function getAllPublishedContracts( + address _publisher + ) external view returns (CustomContractInstance[] memory published) { + CustomContractInstance[] memory linkedData; + if (address(prevPublisher) != address(0)) { + linkedData = prevPublisher.getAllPublishedContracts(_publisher); + } + uint256 currentTotal = EnumerableSet.length(contractsOfPublisher[_publisher].contractIds); + uint256 prevTotal = linkedData.length; + uint256 total = prevTotal + currentTotal; + published = new CustomContractInstance[](total); + // fill in previously published contracts + for (uint256 i = 0; i < prevTotal; i += 1) { + published[i] = linkedData[i]; + } + // fill in current published contracts + for (uint256 i = 0; i < currentTotal; i += 1) { + bytes32 contractId = EnumerableSet.at(contractsOfPublisher[_publisher].contractIds, i); + published[i + prevTotal] = contractsOfPublisher[_publisher].contracts[contractId].latest; + } + } + + /// @notice Returns all versions of a published contract. + function getPublishedContractVersions( + address _publisher, + string memory _contractId + ) external view returns (CustomContractInstance[] memory published) { + CustomContractInstance[] memory linkedVersions; + + if (address(prevPublisher) != address(0)) { + linkedVersions = prevPublisher.getPublishedContractVersions(_publisher, _contractId); + } + uint256 prevTotal = linkedVersions.length; + + bytes32 id = keccak256(bytes(_contractId)); + uint256 currentTotal = contractsOfPublisher[_publisher].contracts[id].total; + uint256 total = prevTotal + currentTotal; + + published = new CustomContractInstance[](total); + + // fill in previously published contracts + for (uint256 i = 0; i < prevTotal; i += 1) { + published[i] = linkedVersions[i]; + } + // fill in current published contracts + for (uint256 i = 0; i < currentTotal; i += 1) { + published[i + prevTotal] = contractsOfPublisher[_publisher].contracts[id].instances[i]; + } + } + + /// @notice Returns the latest version of a contract published by a publisher. + function getPublishedContract( + address _publisher, + string memory _contractId + ) external view returns (CustomContractInstance memory published) { + published = contractsOfPublisher[_publisher].contracts[keccak256(bytes(_contractId))].latest; + // if not found, check the previous publisher + if (address(prevPublisher) != address(0) && published.publishTimestamp == 0) { + published = prevPublisher.getPublishedContract(_publisher, _contractId); + } + } + + /*/////////////////////////////////////////////////////////////// + Publish logic + //////////////////////////////////////////////////////////////*/ + + /// @notice Let's an account publish a contract. + function publishContract( + address _publisher, + string memory _contractId, + string memory _publishMetadataUri, + string memory _compilerMetadataUri, + bytes32 _bytecodeHash, + address _implementation + ) external onlyPublisher(_publisher) onlyUnpausedOrAdmin { + CustomContractInstance memory publishedContract = CustomContractInstance({ + contractId: _contractId, + publishTimestamp: block.timestamp, + publishMetadataUri: _publishMetadataUri, + bytecodeHash: _bytecodeHash, + implementation: _implementation + }); + + bytes32 contractIdInBytes = keccak256(bytes(_contractId)); + EnumerableSet.add(contractsOfPublisher[_publisher].contractIds, contractIdInBytes); + + contractsOfPublisher[_publisher].contracts[contractIdInBytes].latest = publishedContract; + + uint256 index = contractsOfPublisher[_publisher].contracts[contractIdInBytes].total; + contractsOfPublisher[_publisher].contracts[contractIdInBytes].total += 1; + contractsOfPublisher[_publisher].contracts[contractIdInBytes].instances[index] = publishedContract; + + uint256 metadataIndex = compilerMetadataUriToPublishedMetadataUris[_compilerMetadataUri].index; + compilerMetadataUriToPublishedMetadataUris[_compilerMetadataUri].uris[metadataIndex] = _publishMetadataUri; + compilerMetadataUriToPublishedMetadataUris[_compilerMetadataUri].index = metadataIndex + 1; + + emit ContractPublished(_msgSender(), _publisher, publishedContract); + } + + /// @notice Lets a publisher unpublish a contract and all its versions. + function unpublishContract( + address _publisher, + string memory _contractId + ) external onlyPublisher(_publisher) onlyUnpausedOrAdmin { + bytes32 contractIdInBytes = keccak256(bytes(_contractId)); + + bool removed = EnumerableSet.remove(contractsOfPublisher[_publisher].contractIds, contractIdInBytes); + require(removed, "given contractId DNE"); + + delete contractsOfPublisher[_publisher].contracts[contractIdInBytes]; + + emit ContractUnpublished(_msgSender(), _publisher, _contractId); + } + + function setPrevPublisher(IContractPublisher _prevPublisher) external { + require(hasRole(MIGRATION_ROLE, _msgSender()), "Not authorized"); + prevPublisher = _prevPublisher; + } + + /// @notice Lets an account set its own publisher profile uri + function setPublisherProfileUri(address publisher, string memory uri) public { + require( + (!isPaused && _msgSender() == publisher) || hasRole(MIGRATION_ROLE, _msgSender()), + "Registry paused or caller not authorized" + ); + string memory currentURI = profileUriOfPublisher[publisher]; + profileUriOfPublisher[publisher] = uri; + + emit PublisherProfileUpdated(publisher, currentURI, uri); + } + + // @notice Get a publisher profile uri + function getPublisherProfileUri(address publisher) public view returns (string memory uri) { + uri = profileUriOfPublisher[publisher]; + // if not found, check the previous publisher + if (address(prevPublisher) != address(0) && bytes(uri).length == 0) { + uri = prevPublisher.getPublisherProfileUri(publisher); + } + } + + /// @notice Retrieve the published metadata URI from a compiler metadata URI + function getPublishedUriFromCompilerUri( + string memory compilerMetadataUri + ) public view returns (string[] memory publishedMetadataUris) { + string[] memory linkedUris; + if (address(prevPublisher) != address(0)) { + linkedUris = prevPublisher.getPublishedUriFromCompilerUri(compilerMetadataUri); + } + uint256 prevTotal = linkedUris.length; + uint256 currentTotal = compilerMetadataUriToPublishedMetadataUris[compilerMetadataUri].index; + uint256 total = prevTotal + currentTotal; + publishedMetadataUris = new string[](total); + // fill in previously published uris + for (uint256 i = 0; i < prevTotal; i += 1) { + publishedMetadataUris[i] = linkedUris[i]; + } + // fill in current published uris + for (uint256 i = 0; i < currentTotal; i += 1) { + publishedMetadataUris[i + prevTotal] = compilerMetadataUriToPublishedMetadataUris[compilerMetadataUri].uris[ + i + ]; + } + } + + /*/////////////////////////////////////////////////////////////// + Miscellaneous + //////////////////////////////////////////////////////////////*/ + + /// @dev Lets a contract admin pause the registry. + function setPause(bool _pause) external { + require(hasRole(DEFAULT_ADMIN_ROLE, _msgSender()), "unapproved caller"); + isPaused = _pause; + emit Paused(_pause); + } + + /// @dev ERC2771Context overrides + function _msgSender() internal view virtual override(Context, ERC2771Context, Multicall) returns (address sender) { + return ERC2771Context._msgSender(); + } + + /// @dev ERC2771Context overrides + function _msgData() internal view virtual override(Context, ERC2771Context) returns (bytes calldata) { + return ERC2771Context._msgData(); + } +} diff --git a/contracts/infra/TWFactory.sol b/contracts/infra/TWFactory.sol new file mode 100644 index 000000000..0e47d7114 --- /dev/null +++ b/contracts/infra/TWFactory.sol @@ -0,0 +1,140 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/// @author thirdweb + +// $$\ $$\ $$\ $$\ $$\ +// $$ | $$ | \__| $$ | $$ | +// $$$$$$\ $$$$$$$\ $$\ $$$$$$\ $$$$$$$ |$$\ $$\ $$\ $$$$$$\ $$$$$$$\ +// \_$$ _| $$ __$$\ $$ |$$ __$$\ $$ __$$ |$$ | $$ | $$ |$$ __$$\ $$ __$$\ +// $$ | $$ | $$ |$$ |$$ | \__|$$ / $$ |$$ | $$ | $$ |$$$$$$$$ |$$ | $$ | +// $$ |$$\ $$ | $$ |$$ |$$ | $$ | $$ |$$ | $$ | $$ |$$ ____|$$ | $$ | +// \$$$$ |$$ | $$ |$$ |$$ | \$$$$$$$ |\$$$$$\$$$$ |\$$$$$$$\ $$$$$$$ | +// \____/ \__| \__|\__|\__| \_______| \_____\____/ \_______|\_______/ + +import { TWRegistry } from "./TWRegistry.sol"; +import "./interface/IThirdwebContract.sol"; +import "../extension/interface/IContractFactory.sol"; + +import { AccessControlEnumerable, Context } from "@openzeppelin/contracts/access/AccessControlEnumerable.sol"; +import { ERC2771Context } from "@openzeppelin/contracts/metatx/ERC2771Context.sol"; +import { Create2 } from "@openzeppelin/contracts/utils/Create2.sol"; +import { Multicall } from "../extension/Multicall.sol"; +import "@openzeppelin/contracts/utils/Address.sol"; +import { Clones } from "@openzeppelin/contracts/proxy/Clones.sol"; + +contract TWFactory is Multicall, ERC2771Context, AccessControlEnumerable, IContractFactory { + /// @dev Only FACTORY_ROLE holders can approve/unapprove implementations for proxies to point to. + bytes32 public constant FACTORY_ROLE = keccak256("FACTORY_ROLE"); + + TWRegistry public immutable registry; + + /// @dev Emitted when a proxy is deployed. + event ProxyDeployed(address indexed implementation, address proxy, address indexed deployer); + event ImplementationAdded(address implementation, bytes32 indexed contractType, uint256 version); + event ImplementationApproved(address implementation, bool isApproved); + + /// @dev mapping of implementation address to deployment approval + mapping(address => bool) public approval; + + /// @dev mapping of implementation address to implementation added version + mapping(bytes32 => uint256) public currentVersion; + + /// @dev mapping of contract type to module version to implementation address + mapping(bytes32 => mapping(uint256 => address)) public implementation; + + /// @dev mapping of proxy address to deployer address + mapping(address => address) public deployer; + + constructor(address _trustedForwarder, address _registry) ERC2771Context(_trustedForwarder) { + _setupRole(DEFAULT_ADMIN_ROLE, _msgSender()); + _setupRole(FACTORY_ROLE, _msgSender()); + + registry = TWRegistry(_registry); + } + + /// @dev Deploys a proxy that points to the latest version of the given contract type. + function deployProxy(bytes32 _type, bytes memory _data) external returns (address) { + bytes32 salt = bytes32(registry.count(_msgSender())); + return deployProxyDeterministic(_type, _data, salt); + } + + /** + * @dev Deploys a proxy at a deterministic address by taking in `salt` as a parameter. + * Proxy points to the latest version of the given contract type. + */ + function deployProxyDeterministic(bytes32 _type, bytes memory _data, bytes32 _salt) public returns (address) { + address _implementation = implementation[_type][currentVersion[_type]]; + return deployProxyByImplementation(_implementation, _data, _salt); + } + + /// @dev Deploys a proxy that points to the given implementation. + function deployProxyByImplementation( + address _implementation, + bytes memory _data, + bytes32 _salt + ) public override returns (address deployedProxy) { + require(approval[_implementation], "implementation not approved"); + + bytes32 salthash = keccak256(abi.encodePacked(_msgSender(), _salt)); + deployedProxy = Clones.cloneDeterministic(_implementation, salthash); + + deployer[deployedProxy] = _msgSender(); + + emit ProxyDeployed(_implementation, deployedProxy, _msgSender()); + + registry.add(_msgSender(), deployedProxy); + + if (_data.length > 0) { + // slither-disable-next-line unused-return + Address.functionCall(deployedProxy, _data); + } + } + + /// @dev Lets a contract admin set the address of a contract type x version. + function addImplementation(address _implementation) external { + require(hasRole(FACTORY_ROLE, _msgSender()), "not admin."); + + IThirdwebContract module = IThirdwebContract(_implementation); + + bytes32 ctype = module.contractType(); + require(ctype.length > 0, "invalid module"); + + uint8 version = module.contractVersion(); + uint8 currentVersionOfType = uint8(currentVersion[ctype]); + require(version >= currentVersionOfType, "wrong module version"); + + currentVersion[ctype] = version; + implementation[ctype][version] = _implementation; + approval[_implementation] = true; + + emit ImplementationAdded(_implementation, ctype, version); + } + + /// @dev Lets a contract admin approve a specific contract for deployment. + function approveImplementation(address _implementation, bool _toApprove) external { + require(hasRole(FACTORY_ROLE, _msgSender()), "not admin."); + + approval[_implementation] = _toApprove; + + emit ImplementationApproved(_implementation, _toApprove); + } + + /// @dev Returns the implementation given a contract type and version. + function getImplementation(bytes32 _type, uint256 _version) external view returns (address) { + return implementation[_type][_version]; + } + + /// @dev Returns the latest implementation given a contract type. + function getLatestImplementation(bytes32 _type) external view returns (address) { + return implementation[_type][currentVersion[_type]]; + } + + function _msgSender() internal view virtual override(Context, ERC2771Context, Multicall) returns (address sender) { + return ERC2771Context._msgSender(); + } + + function _msgData() internal view virtual override(Context, ERC2771Context) returns (bytes calldata) { + return ERC2771Context._msgData(); + } +} diff --git a/contracts/infra/TWFee.sol b/contracts/infra/TWFee.sol new file mode 100644 index 000000000..a6a1f3064 --- /dev/null +++ b/contracts/infra/TWFee.sol @@ -0,0 +1,162 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/// @author thirdweb + +import "./TWFactory.sol"; +import "./interface/ITWFee.sol"; + +import "@openzeppelin/contracts/access/AccessControlEnumerable.sol"; +import { Multicall } from "../extension/Multicall.sol"; +import "@openzeppelin/contracts/metatx/ERC2771Context.sol"; + +interface IFeeTierPlacementExtension { + /// @dev Returns the fee tier for a given proxy contract address and proxy deployer address. + function getFeeTier( + address deployer, + address proxy + ) external view returns (uint128 tierId, uint128 validUntilTimestamp); +} + +contract TWFee is ITWFee, Multicall, ERC2771Context, AccessControlEnumerable, IFeeTierPlacementExtension { + /// @dev The factory for deploying contracts. + TWFactory public immutable factory; + + /// @dev The maximum threshold for fees. 1% + uint256 public constant MAX_FEE_BPS = 100; + + /// @dev TIER_FEE_ROLE holders can create tiers. + bytes32 private constant TIER_FEE_ROLE = keccak256("TIER_FEE_ROLE"); + + /// @dev TIER_CONTROLLER_ROLE holders can assign tiers to deployer or proxy. + bytes32 private constant TIER_CONTROLLER_ROLE = keccak256("TIER_CONTROLLER_ROLE"); + + /// @dev Mapping from proxy contract or proxy deployer address => pricing tier. + mapping(address => Tier) private tier; + + /// @dev Mapping from pricing tier id => Fee Type (lib/FeeType.sol) => FeeInfo + mapping(uint256 => mapping(uint256 => FeeInfo)) public feeInfo; + + /// @dev If we want to extend the logic for fee tier placement, we + /// could easily points it to a different extension implementation. + IFeeTierPlacementExtension public tierPlacementExtension; + + struct Tier { + uint128 id; + uint128 validUntilTimestamp; + } + + struct FeeInfo { + uint256 bps; + address recipient; + } + + /// @dev Events + event TierUpdated(address indexed proxyOrDeployer, uint256 tierId, uint256 validUntilTimestamp); + event FeeTierUpdated(uint256 indexed tierId, uint256 indexed feeType, address recipient, uint256 bps); + + constructor(address _trustedForwarder, address _factory) ERC2771Context(_trustedForwarder) { + factory = TWFactory(_factory); + + _setupRole(DEFAULT_ADMIN_ROLE, _msgSender()); + _setupRole(TIER_FEE_ROLE, _msgSender()); + _setupRole(TIER_CONTROLLER_ROLE, _msgSender()); + } + + function setFeeTierPlacementExtension(address _extension) public onlyRole(DEFAULT_ADMIN_ROLE) { + tierPlacementExtension = IFeeTierPlacementExtension(_extension); + } + + /// @dev Returns the fee tier for a proxy deployer wallet or contract address. + function getFeeTier( + address _deployer, + address _proxy + ) public view override returns (uint128 tierId, uint128 validUntilTimestamp) { + Tier memory targetTier = tier[_proxy]; + if (block.timestamp <= targetTier.validUntilTimestamp) { + tierId = targetTier.id; + validUntilTimestamp = targetTier.validUntilTimestamp; + } else { + tierId = 0; + validUntilTimestamp = 0; + } + + // if the proxy doesn't have a tier, then look up the deployer's tier + if (tierId == 0 && validUntilTimestamp == 0) { + targetTier = tier[_deployer]; + if (block.timestamp <= targetTier.validUntilTimestamp) { + tierId = targetTier.id; + validUntilTimestamp = targetTier.validUntilTimestamp; + } else { + tierId = 0; + validUntilTimestamp = 0; + } + } + } + + /// @dev Returns the fee info for a given module and fee type. + function getFeeInfo(address _proxy, uint256 _feeType) external view returns (address recipient, uint256 bps) { + address deployer = factory.deployer(_proxy); + uint128 tierId = 0; + uint128 validUntilTimestamp = 0; + + if (address(tierPlacementExtension) != address(0)) { + // https://github.com/crytic/slither/issues/982 + // slither-disable-next-line unused-return + try tierPlacementExtension.getFeeTier(deployer, _proxy) returns ( + // slither-disable-next-line uninitialized-local,variable-scope + uint128 retTierId, + // slither-disable-next-line uninitialized-local,variable-scope + uint128 retValidUntilTimestamp + ) { + tierId = retTierId; + validUntilTimestamp = retValidUntilTimestamp; + // solhint-disable-next-line no-empty-blocks + } catch {} + } + + // if extension doesn't return a tier, then we fetch the local states + if (tierId == 0 && validUntilTimestamp == 0) { + (tierId, ) = getFeeTier(deployer, _proxy); + } + + FeeInfo memory targetFeeInfo = feeInfo[tierId][_feeType]; + (recipient, bps) = (targetFeeInfo.recipient, targetFeeInfo.bps); + } + + /// @dev Lets a TIER_CONTROLLER_ROLE holder assign a tier to a proxy deployer. + function setTier( + address _proxyOrDeployer, + uint128 _tierId, + uint128 _validUntilTimestamp + ) external onlyRole(TIER_CONTROLLER_ROLE) { + tier[_proxyOrDeployer] = Tier({ id: _tierId, validUntilTimestamp: _validUntilTimestamp }); + + emit TierUpdated(_proxyOrDeployer, _tierId, _validUntilTimestamp); + } + + /// @dev Lets the admin set fee bps and recipient for the given pricing tier and fee type. + function setFeeInfoForTier( + uint256 _tierId, + uint256 _feeBps, + address _feeRecipient, + uint256 _feeType + ) external onlyRole(TIER_FEE_ROLE) { + require(_feeBps <= MAX_FEE_BPS, "fee too high."); + + FeeInfo memory feeInfoToSet = FeeInfo({ bps: _feeBps, recipient: _feeRecipient }); + feeInfo[_tierId][_feeType] = feeInfoToSet; + + emit FeeTierUpdated(_tierId, _feeType, _feeRecipient, _feeBps); + } + + // ===== Getters ===== + + function _msgSender() internal view virtual override(Context, ERC2771Context, Multicall) returns (address sender) { + return ERC2771Context._msgSender(); + } + + function _msgData() internal view virtual override(Context, ERC2771Context) returns (bytes calldata) { + return ERC2771Context._msgData(); + } +} diff --git a/contracts/infra/TWMinimalFactory.sol b/contracts/infra/TWMinimalFactory.sol new file mode 100644 index 000000000..63b81b9ed --- /dev/null +++ b/contracts/infra/TWMinimalFactory.sol @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/// @author thirdweb + +// $$\ $$\ $$\ $$\ $$\ +// $$ | $$ | \__| $$ | $$ | +// $$$$$$\ $$$$$$$\ $$\ $$$$$$\ $$$$$$$ |$$\ $$\ $$\ $$$$$$\ $$$$$$$\ +// \_$$ _| $$ __$$\ $$ |$$ __$$\ $$ __$$ |$$ | $$ | $$ |$$ __$$\ $$ __$$\ +// $$ | $$ | $$ |$$ |$$ | \__|$$ / $$ |$$ | $$ | $$ |$$$$$$$$ |$$ | $$ | +// $$ |$$\ $$ | $$ |$$ |$$ | $$ | $$ |$$ | $$ | $$ |$$ ____|$$ | $$ | +// \$$$$ |$$ | $$ |$$ |$$ | \$$$$$$$ |\$$$$$\$$$$ |\$$$$$$$\ $$$$$$$ | +// \____/ \__| \__|\__|\__| \_______| \_____\____/ \_______|\_______/ + +import "@openzeppelin/contracts/utils/Address.sol"; +import "@openzeppelin/contracts/proxy/Clones.sol"; + +contract TWMinimalFactory { + /// @dev Deploys a proxy that points to the given implementation. + constructor(address _implementation, bytes memory _data, bytes32 _salt) payable { + address instance; + bytes32 salthash = keccak256(abi.encodePacked(msg.sender, _salt)); + assembly { + let ptr := mload(0x40) + mstore(ptr, 0x3d602d80600a3d3981f3363d3d373d3d3d363d73000000000000000000000000) + mstore(add(ptr, 0x14), shl(0x60, _implementation)) + mstore(add(ptr, 0x28), 0x5af43d82803e903d91602b57fd5bf30000000000000000000000000000000000) + instance := create2(0, ptr, 0x37, salthash) + } + + if (_data.length > 0) { + // instance.call{ value: msg.value }(_data); + + // solhint-disable-next-line avoid-low-level-calls + (bool success, bytes memory result) = instance.call{ value: msg.value }(_data); + + if (!success) { + // Next 5 lines from https://ethereum.stackexchange.com/a/83577 + if (result.length < 68) revert("Transaction reverted silently"); + assembly { + result := add(result, 0x04) + } + revert(abi.decode(result, (string))); + } + } + } +} diff --git a/contracts/infra/TWMultichainRegistry.sol b/contracts/infra/TWMultichainRegistry.sol new file mode 100644 index 000000000..888ee0a06 --- /dev/null +++ b/contracts/infra/TWMultichainRegistry.sol @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/// @author thirdweb + +// $$\ $$\ $$\ $$\ $$\ +// $$ | $$ | \__| $$ | $$ | +// $$$$$$\ $$$$$$$\ $$\ $$$$$$\ $$$$$$$ |$$\ $$\ $$\ $$$$$$\ $$$$$$$\ +// \_$$ _| $$ __$$\ $$ |$$ __$$\ $$ __$$ |$$ | $$ | $$ |$$ __$$\ $$ __$$\ +// $$ | $$ | $$ |$$ |$$ | \__|$$ / $$ |$$ | $$ | $$ |$$$$$$$$ |$$ | $$ | +// $$ |$$\ $$ | $$ |$$ |$$ | $$ | $$ |$$ | $$ | $$ |$$ ____|$$ | $$ | +// \$$$$ |$$ | $$ |$$ |$$ | \$$$$$$$ |\$$$$$\$$$$ |\$$$$$$$\ $$$$$$$ | +// \____/ \__| \__|\__|\__| \_______| \_____\____/ \_______|\_______/ + +import "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; +import "@openzeppelin/contracts/access/AccessControlEnumerable.sol"; +import "../extension/Multicall.sol"; +import "@openzeppelin/contracts/metatx/ERC2771Context.sol"; + +import "./interface/ITWMultichainRegistry.sol"; + +contract TWMultichainRegistry is ITWMultichainRegistry, Multicall, ERC2771Context, AccessControlEnumerable { + bytes32 public constant OPERATOR_ROLE = keccak256("OPERATOR_ROLE"); + + using EnumerableSet for EnumerableSet.AddressSet; + using EnumerableSet for EnumerableSet.UintSet; + + /// @dev wallet address => [contract addresses] + mapping(address => mapping(uint256 => EnumerableSet.AddressSet)) private deployments; + /// @dev contract address deployed => imported metadata uri + mapping(uint256 => mapping(address => string)) private addressToMetadataUri; + + EnumerableSet.UintSet private chainIds; + + constructor(address _trustedForwarder) ERC2771Context(_trustedForwarder) { + _setupRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + // slither-disable-next-line similar-names + function add(address _deployer, address _deployment, uint256 _chainId, string memory metadataUri) external { + require(hasRole(OPERATOR_ROLE, _msgSender()) || _deployer == _msgSender(), "not operator or deployer."); + + bool added = deployments[_deployer][_chainId].add(_deployment); + require(added, "failed to add"); + + chainIds.add(_chainId); + + if (bytes(metadataUri).length > 0) { + addressToMetadataUri[_chainId][_deployment] = metadataUri; + } + + emit Added(_deployer, _deployment, _chainId, metadataUri); + } + + // slither-disable-next-line similar-names + function remove(address _deployer, address _deployment, uint256 _chainId) external { + require(hasRole(OPERATOR_ROLE, _msgSender()) || _deployer == _msgSender(), "not operator or deployer."); + + bool removed = deployments[_deployer][_chainId].remove(_deployment); + require(removed, "failed to remove"); + + emit Deleted(_deployer, _deployment, _chainId); + } + + function getAll(address _deployer) external view returns (Deployment[] memory allDeployments) { + uint256 totalDeployments; + uint256 chainIdsLen = chainIds.length(); + + for (uint256 i = 0; i < chainIdsLen; i += 1) { + uint256 chainId = chainIds.at(i); + + totalDeployments += deployments[_deployer][chainId].length(); + } + + allDeployments = new Deployment[](totalDeployments); + uint256 idx; + + for (uint256 j = 0; j < chainIdsLen; j += 1) { + uint256 chainId = chainIds.at(j); + + uint256 len = deployments[_deployer][chainId].length(); + address[] memory deploymentAddrs = deployments[_deployer][chainId].values(); + + for (uint256 k = 0; k < len; k += 1) { + allDeployments[idx] = Deployment({ + deploymentAddress: deploymentAddrs[k], + chainId: chainId, + metadataURI: addressToMetadataUri[chainId][deploymentAddrs[k]] + }); + idx += 1; + } + } + } + + function count(address _deployer) external view returns (uint256 deploymentCount) { + uint256 chainIdsLen = chainIds.length(); + + for (uint256 i = 0; i < chainIdsLen; i += 1) { + uint256 chainId = chainIds.at(i); + + deploymentCount += deployments[_deployer][chainId].length(); + } + } + + function getMetadataUri(uint256 _chainId, address _deployment) external view returns (string memory metadataUri) { + metadataUri = addressToMetadataUri[_chainId][_deployment]; + } + + function _msgSender() internal view virtual override(Context, ERC2771Context, Multicall) returns (address sender) { + return ERC2771Context._msgSender(); + } + + function _msgData() internal view virtual override(Context, ERC2771Context) returns (bytes calldata) { + return ERC2771Context._msgData(); + } +} diff --git a/contracts/infra/TWProxy.sol b/contracts/infra/TWProxy.sol new file mode 100644 index 000000000..cf0dcb9d4 --- /dev/null +++ b/contracts/infra/TWProxy.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/// @author thirdweb + +// $$\ $$\ $$\ $$\ $$\ +// $$ | $$ | \__| $$ | $$ | +// $$$$$$\ $$$$$$$\ $$\ $$$$$$\ $$$$$$$ |$$\ $$\ $$\ $$$$$$\ $$$$$$$\ +// \_$$ _| $$ __$$\ $$ |$$ __$$\ $$ __$$ |$$ | $$ | $$ |$$ __$$\ $$ __$$\ +// $$ | $$ | $$ |$$ |$$ | \__|$$ / $$ |$$ | $$ | $$ |$$$$$$$$ |$$ | $$ | +// $$ |$$\ $$ | $$ |$$ |$$ | $$ | $$ |$$ | $$ | $$ |$$ ____|$$ | $$ | +// \$$$$ |$$ | $$ |$$ |$$ | \$$$$$$$ |\$$$$$\$$$$ |\$$$$$$$\ $$$$$$$ | +// \____/ \__| \__|\__|\__| \_______| \_____\____/ \_______|\_______/ + +import "@openzeppelin/contracts/proxy/Proxy.sol"; +import "@openzeppelin/contracts/utils/Address.sol"; +import "@openzeppelin/contracts/utils/StorageSlot.sol"; + +contract TWProxy is Proxy { + /** + * @dev Storage slot with the address of the current implementation. + * This is the keccak-256 hash of "eip1967.proxy.implementation" subtracted by 1, and is + * validated in the constructor. + */ + bytes32 internal constant _IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc; + + constructor(address _logic, bytes memory _data) payable { + assert(_IMPLEMENTATION_SLOT == bytes32(uint256(keccak256("eip1967.proxy.implementation")) - 1)); + StorageSlot.getAddressSlot(_IMPLEMENTATION_SLOT).value = _logic; + if (_data.length > 0) { + // slither-disable-next-line unused-return + Address.functionDelegateCall(_logic, _data); + } + } + + /** + * @dev Returns the current implementation address. + */ + function _implementation() internal view override returns (address impl) { + return StorageSlot.getAddressSlot(_IMPLEMENTATION_SLOT).value; + } +} diff --git a/contracts/infra/TWRegistry.sol b/contracts/infra/TWRegistry.sol new file mode 100644 index 000000000..5c42436e9 --- /dev/null +++ b/contracts/infra/TWRegistry.sol @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/// @author thirdweb + +// $$\ $$\ $$\ $$\ $$\ +// $$ | $$ | \__| $$ | $$ | +// $$$$$$\ $$$$$$$\ $$\ $$$$$$\ $$$$$$$ |$$\ $$\ $$\ $$$$$$\ $$$$$$$\ +// \_$$ _| $$ __$$\ $$ |$$ __$$\ $$ __$$ |$$ | $$ | $$ |$$ __$$\ $$ __$$\ +// $$ | $$ | $$ |$$ |$$ | \__|$$ / $$ |$$ | $$ | $$ |$$$$$$$$ |$$ | $$ | +// $$ |$$\ $$ | $$ |$$ |$$ | $$ | $$ |$$ | $$ | $$ |$$ ____|$$ | $$ | +// \$$$$ |$$ | $$ |$$ |$$ | \$$$$$$$ |\$$$$$\$$$$ |\$$$$$$$\ $$$$$$$ | +// \____/ \__| \__|\__|\__| \_______| \_____\____/ \_______|\_______/ + +import "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; +import "@openzeppelin/contracts/access/AccessControlEnumerable.sol"; +import "../extension/Multicall.sol"; +import "@openzeppelin/contracts/metatx/ERC2771Context.sol"; + +contract TWRegistry is Multicall, ERC2771Context, AccessControlEnumerable { + bytes32 public constant OPERATOR_ROLE = keccak256("OPERATOR_ROLE"); + + using EnumerableSet for EnumerableSet.AddressSet; + + /// @dev wallet address => [contract addresses] + mapping(address => EnumerableSet.AddressSet) private deployments; + + event Added(address indexed deployer, address indexed deployment); + event Deleted(address indexed deployer, address indexed deployment); + + constructor(address _trustedForwarder) ERC2771Context(_trustedForwarder) { + _setupRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + // slither-disable-next-line similar-names + function add(address _deployer, address _deployment) external { + require(hasRole(OPERATOR_ROLE, _msgSender()) || _deployer == _msgSender(), "not operator or deployer."); + + bool added = deployments[_deployer].add(_deployment); + require(added, "failed to add"); + + emit Added(_deployer, _deployment); + } + + // slither-disable-next-line similar-names + function remove(address _deployer, address _deployment) external { + require(hasRole(OPERATOR_ROLE, _msgSender()) || _deployer == _msgSender(), "not operator or deployer."); + + bool removed = deployments[_deployer].remove(_deployment); + require(removed, "failed to remove"); + + emit Deleted(_deployer, _deployment); + } + + function getAll(address _deployer) external view returns (address[] memory) { + return deployments[_deployer].values(); + } + + function count(address _deployer) external view returns (uint256) { + return deployments[_deployer].length(); + } + + function _msgSender() internal view virtual override(Context, ERC2771Context, Multicall) returns (address sender) { + return ERC2771Context._msgSender(); + } + + function _msgData() internal view virtual override(Context, ERC2771Context) returns (bytes calldata) { + return ERC2771Context._msgData(); + } +} diff --git a/contracts/infra/TWStatelessFactory.sol b/contracts/infra/TWStatelessFactory.sol new file mode 100644 index 000000000..43ee5c60d --- /dev/null +++ b/contracts/infra/TWStatelessFactory.sol @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/// @author thirdweb + +// $$\ $$\ $$\ $$\ $$\ +// $$ | $$ | \__| $$ | $$ | +// $$$$$$\ $$$$$$$\ $$\ $$$$$$\ $$$$$$$ |$$\ $$\ $$\ $$$$$$\ $$$$$$$\ +// \_$$ _| $$ __$$\ $$ |$$ __$$\ $$ __$$ |$$ | $$ | $$ |$$ __$$\ $$ __$$\ +// $$ | $$ | $$ |$$ |$$ | \__|$$ / $$ |$$ | $$ | $$ |$$$$$$$$ |$$ | $$ | +// $$ |$$\ $$ | $$ |$$ |$$ | $$ | $$ |$$ | $$ | $$ |$$ ____|$$ | $$ | +// \$$$$ |$$ | $$ |$$ |$$ | \$$$$$$$ |\$$$$$\$$$$ |\$$$$$$$\ $$$$$$$ | +// \____/ \__| \__|\__|\__| \_______| \_____\____/ \_______|\_______/ + +import "../extension/interface/IContractFactory.sol"; + +import "@openzeppelin/contracts/metatx/ERC2771Context.sol"; +import "../extension/Multicall.sol"; +import "@openzeppelin/contracts/proxy/Clones.sol"; + +contract TWStatelessFactory is Multicall, ERC2771Context, IContractFactory { + /// @dev Emitted when a proxy is deployed. + event ProxyDeployed(address indexed implementation, address proxy, address indexed deployer); + + constructor(address _trustedForwarder) ERC2771Context(_trustedForwarder) {} + + /// @dev Deploys a proxy that points to the given implementation. + function deployProxyByImplementation( + address _implementation, + bytes memory _data, + bytes32 _salt + ) public override returns (address deployedProxy) { + bytes32 salthash = keccak256(abi.encodePacked(_msgSender(), _salt)); + deployedProxy = Clones.cloneDeterministic(_implementation, salthash); + + emit ProxyDeployed(_implementation, deployedProxy, _msgSender()); + + if (_data.length > 0) { + // slither-disable-next-line unused-return + Address.functionCall(deployedProxy, _data); + } + } + + function _msgSender() internal view virtual override(Multicall, ERC2771Context) returns (address sender) { + return ERC2771Context._msgSender(); + } +} diff --git a/contracts/infra/forwarder/Forwarder.sol b/contracts/infra/forwarder/Forwarder.sol new file mode 100644 index 000000000..ed680c92e --- /dev/null +++ b/contracts/infra/forwarder/Forwarder.sol @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: Apache 2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +// $$\ $$\ $$\ $$\ $$\ +// $$ | $$ | \__| $$ | $$ | +// $$$$$$\ $$$$$$$\ $$\ $$$$$$\ $$$$$$$ |$$\ $$\ $$\ $$$$$$\ $$$$$$$\ +// \_$$ _| $$ __$$\ $$ |$$ __$$\ $$ __$$ |$$ | $$ | $$ |$$ __$$\ $$ __$$\ +// $$ | $$ | $$ |$$ |$$ | \__|$$ / $$ |$$ | $$ | $$ |$$$$$$$$ |$$ | $$ | +// $$ |$$\ $$ | $$ |$$ |$$ | $$ | $$ |$$ | $$ | $$ |$$ ____|$$ | $$ | +// \$$$$ |$$ | $$ |$$ |$$ | \$$$$$$$ |\$$$$$\$$$$ |\$$$$$$$\ $$$$$$$ | +// \____/ \__| \__|\__|\__| \_______| \_____\____/ \_______|\_______/ + +import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import "@openzeppelin/contracts/utils/cryptography/draft-EIP712.sol"; + +/* + * @dev Minimal forwarder for GSNv2 + */ +contract Forwarder is EIP712 { + using ECDSA for bytes32; + + struct ForwardRequest { + address from; + address to; + uint256 value; + uint256 gas; + uint256 nonce; + bytes data; + } + + bytes32 private constant TYPEHASH = + keccak256("ForwardRequest(address from,address to,uint256 value,uint256 gas,uint256 nonce,bytes data)"); + + mapping(address => uint256) private _nonces; + + constructor() EIP712("GSNv2 Forwarder", "0.0.1") {} + + function getNonce(address from) public view returns (uint256) { + return _nonces[from]; + } + + function verify(ForwardRequest calldata req, bytes calldata signature) public view returns (bool) { + address signer = _hashTypedDataV4( + keccak256(abi.encode(TYPEHASH, req.from, req.to, req.value, req.gas, req.nonce, keccak256(req.data))) + ).recover(signature); + + return _nonces[req.from] == req.nonce && signer == req.from; + } + + function execute( + ForwardRequest calldata req, + bytes calldata signature + ) public payable returns (bool, bytes memory) { + require(verify(req, signature), "MinimalForwarder: signature does not match request"); + _nonces[req.from] = req.nonce + 1; + + // solhint-disable-next-line avoid-low-level-calls + (bool success, bytes memory result) = req.to.call{ gas: req.gas, value: req.value }( + abi.encodePacked(req.data, req.from) + ); + + if (!success) { + // Next 5 lines from https://ethereum.stackexchange.com/a/83577 + if (result.length < 68) revert("Transaction reverted silently"); + assembly { + result := add(result, 0x04) + } + revert(abi.decode(result, (string))); + } + // Check gas: https://ronan.eth.link/blog/ethereum-gas-dangers/ + assert(gasleft() > req.gas / 63); + return (success, result); + } +} diff --git a/contracts/infra/forwarder/ForwarderChainlessDomain.sol b/contracts/infra/forwarder/ForwarderChainlessDomain.sol new file mode 100644 index 000000000..5b5f4b34f --- /dev/null +++ b/contracts/infra/forwarder/ForwarderChainlessDomain.sol @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: Apache 2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +// $$\ $$\ $$\ $$\ $$\ +// $$ | $$ | \__| $$ | $$ | +// $$$$$$\ $$$$$$$\ $$\ $$$$$$\ $$$$$$$ |$$\ $$\ $$\ $$$$$$\ $$$$$$$\ +// \_$$ _| $$ __$$\ $$ |$$ __$$\ $$ __$$ |$$ | $$ | $$ |$$ __$$\ $$ __$$\ +// $$ | $$ | $$ |$$ |$$ | \__|$$ / $$ |$$ | $$ | $$ |$$$$$$$$ |$$ | $$ | +// $$ |$$\ $$ | $$ |$$ |$$ | $$ | $$ |$$ | $$ | $$ |$$ ____|$$ | $$ | +// \$$$$ |$$ | $$ |$$ |$$ | \$$$$$$$ |\$$$$$\$$$$ |\$$$$$$$\ $$$$$$$ | +// \____/ \__| \__|\__|\__| \_______| \_____\____/ \_______|\_______/ + +import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import "../../external-deps/openzeppelin/cryptography/EIP712ChainlessDomain.sol"; + +/** + * @dev Simple minimal forwarder to be used together with an ERC2771 compatible contract. See {ERC2771Context}. + */ +contract ForwarderChainlessDomain is EIP712ChainlessDomain { + using ECDSA for bytes32; + + struct ForwardRequest { + address from; + address to; + uint256 value; + uint256 gas; + uint256 nonce; + bytes data; + uint256 chainid; + } + + bytes32 private constant _TYPEHASH = + keccak256( + "ForwardRequest(address from,address to,uint256 value,uint256 gas,uint256 nonce,bytes data,uint256 chainid)" + ); + + mapping(address => uint256) private _nonces; + + constructor() EIP712ChainlessDomain("GSNv2 Forwarder", "0.0.1") {} + + function getNonce(address from) public view returns (uint256) { + return _nonces[from]; + } + + function verify(ForwardRequest calldata req, bytes calldata signature) public view returns (bool) { + address signer = _hashTypedDataV4( + keccak256( + abi.encode( + _TYPEHASH, + req.from, + req.to, + req.value, + req.gas, + req.nonce, + keccak256(req.data), + block.chainid + ) + ) + ).recover(signature); + return _nonces[req.from] == req.nonce && signer == req.from; + } + + function execute( + ForwardRequest calldata req, + bytes calldata signature + ) public payable returns (bool, bytes memory) { + // require(req.chainid == block.chainid, "MinimalForwarder: invalid chainId"); + require(verify(req, signature), "MinimalForwarder: signature does not match request"); + _nonces[req.from] = req.nonce + 1; + + (bool success, bytes memory returndata) = req.to.call{ gas: req.gas, value: req.value }( + abi.encodePacked(req.data, req.from) + ); + + // Validate that the relayer has sent enough gas for the call. + // See https://ronan.eth.link/blog/ethereum-gas-dangers/ + if (gasleft() <= req.gas / 63) { + // We explicitly trigger invalid opcode to consume all gas and bubble-up the effects, since + // neither revert or assert consume all gas since Solidity 0.8.0 + // https://docs.soliditylang.org/en/v0.8.0/control-structures.html#panic-via-assert-and-error-via-require + assembly { + invalid() + } + } + + return (success, returndata); + } +} diff --git a/contracts/infra/forwarder/ForwarderConsumer.sol b/contracts/infra/forwarder/ForwarderConsumer.sol new file mode 100644 index 000000000..d44904799 --- /dev/null +++ b/contracts/infra/forwarder/ForwarderConsumer.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/// @author thirdweb + +// $$\ $$\ $$\ $$\ $$\ +// $$ | $$ | \__| $$ | $$ | +// $$$$$$\ $$$$$$$\ $$\ $$$$$$\ $$$$$$$ |$$\ $$\ $$\ $$$$$$\ $$$$$$$\ +// \_$$ _| $$ __$$\ $$ |$$ __$$\ $$ __$$ |$$ | $$ | $$ |$$ __$$\ $$ __$$\ +// $$ | $$ | $$ |$$ |$$ | \__|$$ / $$ |$$ | $$ | $$ |$$$$$$$$ |$$ | $$ | +// $$ |$$\ $$ | $$ |$$ |$$ | $$ | $$ |$$ | $$ | $$ |$$ ____|$$ | $$ | +// \$$$$ |$$ | $$ |$$ |$$ | \$$$$$$$ |\$$$$$\$$$$ |\$$$$$$$\ $$$$$$$ | +// \____/ \__| \__|\__|\__| \_______| \_____\____/ \_______|\_______/ + +import "@openzeppelin/contracts/metatx/ERC2771Context.sol"; + +contract ForwarderConsumer is ERC2771Context { + address public caller; + + constructor(address trustedForwarder) ERC2771Context(trustedForwarder) {} + + function setCaller() external { + caller = _msgSender(); + } +} diff --git a/contracts/infra/forwarder/ForwarderEOAOnly.sol b/contracts/infra/forwarder/ForwarderEOAOnly.sol new file mode 100644 index 000000000..b0612339f --- /dev/null +++ b/contracts/infra/forwarder/ForwarderEOAOnly.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/// @author thirdweb + +// $$\ $$\ $$\ $$\ $$\ +// $$ | $$ | \__| $$ | $$ | +// $$$$$$\ $$$$$$$\ $$\ $$$$$$\ $$$$$$$ |$$\ $$\ $$\ $$$$$$\ $$$$$$$\ +// \_$$ _| $$ __$$\ $$ |$$ __$$\ $$ __$$ |$$ | $$ | $$ |$$ __$$\ $$ __$$\ +// $$ | $$ | $$ |$$ |$$ | \__|$$ / $$ |$$ | $$ | $$ |$$$$$$$$ |$$ | $$ | +// $$ |$$\ $$ | $$ |$$ |$$ | $$ | $$ |$$ | $$ | $$ |$$ ____|$$ | $$ | +// \$$$$ |$$ | $$ |$$ |$$ | \$$$$$$$ |\$$$$$\$$$$ |\$$$$$$$\ $$$$$$$ | +// \____/ \__| \__|\__|\__| \_______| \_____\____/ \_______|\_______/ + +import "../../external-deps/openzeppelin/metatx/MinimalForwarderEOAOnly.sol"; + +/* + * @dev Minimal forwarder for GSNv2 + */ +contract ForwarderEOAOnly is MinimalForwarderEOAOnly { + // solhint-disable-next-line no-empty-blocks + constructor() MinimalForwarderEOAOnly() {} +} diff --git a/contracts/infra/interface/IContractDeployer.sol b/contracts/infra/interface/IContractDeployer.sol new file mode 100644 index 000000000..b767cc893 --- /dev/null +++ b/contracts/infra/interface/IContractDeployer.sol @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +interface IContractDeployer { + /// @dev Emitted when the registry is paused. + event Paused(bool isPaused); + + /// @dev Emitted when a contract is deployed. + event ContractDeployed(address indexed deployer, address indexed publisher, address deployedContract); + + /** + * @notice Deploys an instance of a published contract directly. + * + * @param publisher The address of the publisher. + * @param contractBytecode The bytecode of the contract to deploy. + * @param constructorArgs The encoded constructor args to deploy the contract with. + * @param salt The salt to use in the CREATE2 contract deployment. + * @param value The native token value to pass to the contract on deployment. + * @param publishMetadataUri The publish metadata URI for the contract to deploy. + * + * @return deployedAddress The address of the contract deployed. + */ + function deployInstance( + address publisher, + bytes memory contractBytecode, + bytes memory constructorArgs, + bytes32 salt, + uint256 value, + string memory publishMetadataUri + ) external returns (address deployedAddress); + + /** + * @notice Deploys a clone pointing to an implementation of a published contract. + * + * @param publisher The address of the publisher. + * @param implementation The contract implementation for the clone to point to. + * @param initializeData The encoded function call to initialize the contract with. + * @param salt The salt to use in the CREATE2 contract deployment. + * @param value The native token value to pass to the contract on deployment. + * @param publishMetadataUri The publish metadata URI and for the contract to deploy. + * + * @return deployedAddress The address of the contract deployed. + */ + function deployInstanceProxy( + address publisher, + address implementation, + bytes memory initializeData, + bytes32 salt, + uint256 value, + string memory publishMetadataUri + ) external returns (address deployedAddress); + + function getContractDeployer(address _contract) external view returns (address); +} diff --git a/contracts/infra/interface/IContractPublisher.sol b/contracts/infra/interface/IContractPublisher.sol new file mode 100644 index 000000000..27806668f --- /dev/null +++ b/contracts/infra/interface/IContractPublisher.sol @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; + +interface IContractPublisher { + struct CustomContractInstance { + string contractId; + uint256 publishTimestamp; + string publishMetadataUri; + bytes32 bytecodeHash; + address implementation; + } + + struct CustomContract { + uint256 total; + CustomContractInstance latest; + mapping(uint256 => CustomContractInstance) instances; + } + + struct CustomContractSet { + EnumerableSet.Bytes32Set contractIds; + mapping(bytes32 => CustomContract) contracts; + } + + struct PublishedMetadataSet { + uint256 index; + mapping(uint256 => string) uris; + } + + /// @dev Emitted when the registry is paused. + event Paused(bool isPaused); + + /// @dev Emitted when a contract is published. + event ContractPublished( + address indexed operator, + address indexed publisher, + CustomContractInstance publishedContract + ); + + /// @dev Emitted when a contract is unpublished. + event ContractUnpublished(address indexed operator, address indexed publisher, string indexed contractId); + + /// @dev Emitted when a publisher updates their profile URI. + event PublisherProfileUpdated(address indexed publisher, string prevURI, string newURI); + + /** + * @notice Returns the latest version of all contracts published by a publisher. + * + * @param publisher The address of the publisher. + * + * @return published An array of all contracts published by the publisher. + */ + function getAllPublishedContracts( + address publisher + ) external view returns (CustomContractInstance[] memory published); + + /** + * @notice Returns all versions of a published contract. + * + * @param publisher The address of the publisher. + * @param contractId The identifier for a published contract (that can have multiple verisons). + * + * @return published The desired contracts published by the publisher. + */ + function getPublishedContractVersions( + address publisher, + string memory contractId + ) external view returns (CustomContractInstance[] memory published); + + /** + * @notice Returns the latest version of a contract published by a publisher. + * + * @param publisher The address of the publisher. + * @param contractId The identifier for a published contract (that can have multiple verisons). + * + * @return published The desired contract published by the publisher. + */ + function getPublishedContract( + address publisher, + string memory contractId + ) external view returns (CustomContractInstance memory published); + + /** + * @notice Let's an account publish a contract. + * + * @param publisher The address of the publisher. + * @param contractId The identifier for a published contract (that can have multiple verisons). + * @param publishMetadataUri The IPFS URI of the publish metadata. + * @param compilerMetadataUri The IPFS URI of the compiler metadata. + * @param bytecodeHash The keccak256 hash of the contract bytecode. + * @param implementation (Optional) An implementation address that proxy contracts / clones can point to. Default value + * if such an implementation does not exist - address(0); + */ + function publishContract( + address publisher, + string memory contractId, + string memory publishMetadataUri, + string memory compilerMetadataUri, + bytes32 bytecodeHash, + address implementation + ) external; + + /** + * @notice Lets a publisher unpublish a contract and all its versions. + * + * @param publisher The address of the publisher. + * @param contractId The identifier for a published contract (that can have multiple verisons). + */ + function unpublishContract(address publisher, string memory contractId) external; + + /** + * @notice Lets an account set its publisher profile uri + */ + function setPublisherProfileUri(address publisher, string memory uri) external; + + /** + * @notice Get the publisher profile uri for a given publisher. + */ + function getPublisherProfileUri(address publisher) external view returns (string memory uri); + + /** + * @notice Retrieve the published metadata URI from a compiler metadata URI. + */ + function getPublishedUriFromCompilerUri( + string memory compilerMetadataUri + ) external view returns (string[] memory publishedMetadataUris); +} diff --git a/contracts/infra/interface/ITWFee.sol b/contracts/infra/interface/ITWFee.sol new file mode 100644 index 000000000..913ea493c --- /dev/null +++ b/contracts/infra/interface/ITWFee.sol @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +interface ITWFee { + function getFeeInfo(address _proxy, uint256 _type) external view returns (address recipient, uint256 bps); +} diff --git a/contracts/infra/interface/ITWMultichainRegistry.sol b/contracts/infra/interface/ITWMultichainRegistry.sol new file mode 100644 index 000000000..c91ab1fc6 --- /dev/null +++ b/contracts/infra/interface/ITWMultichainRegistry.sol @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +interface ITWMultichainRegistry { + struct Deployment { + address deploymentAddress; + uint256 chainId; + string metadataURI; + } + + event Added(address indexed deployer, address indexed deployment, uint256 indexed chainId, string metadataUri); + event Deleted(address indexed deployer, address indexed deployment, uint256 indexed chainId); + + /// @notice Add a deployment for a deployer. + function add(address _deployer, address _deployment, uint256 _chainId, string memory metadataUri) external; + + /// @notice Remove a deployment for a deployer. + function remove(address _deployer, address _deployment, uint256 _chainId) external; + + /// @notice Get all deployments for a deployer. + function getAll(address _deployer) external view returns (Deployment[] memory allDeployments); + + /// @notice Get the total number of deployments for a deployer. + function count(address _deployer) external view returns (uint256 deploymentCount); + + /// @notice Returns the metadata IPFS URI for a deployment on a given chain if previously registered via add(). + function getMetadataUri(uint256 _chainId, address _deployment) external view returns (string memory metadataUri); +} diff --git a/contracts/infra/interface/ITWRegistry.sol b/contracts/infra/interface/ITWRegistry.sol new file mode 100644 index 000000000..78ebf554f --- /dev/null +++ b/contracts/infra/interface/ITWRegistry.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +interface ITWRegistry { + struct Deployment { + address deploymentAddress; + uint256 chainId; + } + + event Added(address indexed deployer, address indexed deployment, uint256 indexed chainId); + event Deleted(address indexed deployer, address indexed deployment, uint256 indexed chainId); + + /// @notice Add a deployment for a deployer. + function add(address _deployer, address _deployment, uint256 _chainId) external; + + /// @notice Remove a deployment for a deployer. + function remove(address _deployer, address _deployment, uint256 _chainId) external; + + /// @notice Get all deployments for a deployer. + function getAll(address _deployer) external view returns (Deployment[] memory allDeployments); + + /// @notice Get the total number of deployments for a deployer. + function count(address _deployer) external view returns (uint256 deploymentCount); +} diff --git a/contracts/infra/interface/IThirdwebContract.sol b/contracts/infra/interface/IThirdwebContract.sol new file mode 100644 index 000000000..9b521a9c0 --- /dev/null +++ b/contracts/infra/interface/IThirdwebContract.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +interface IThirdwebContract { + /// @dev Returns the module type of the contract. + function contractType() external pure returns (bytes32); + + /// @dev Returns the version of the contract. + function contractVersion() external pure returns (uint8); + + /// @dev Returns the metadata URI of the contract. + function contractURI() external view returns (string memory); + + /** + * @dev Sets contract URI for the storefront-level metadata of the contract. + * Only module admin can call this function. + */ + function setContractURI(string calldata _uri) external; +} diff --git a/contracts/infra/interface/IWETH.sol b/contracts/infra/interface/IWETH.sol new file mode 100644 index 000000000..815bbf4e9 --- /dev/null +++ b/contracts/infra/interface/IWETH.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +interface IWETH { + function deposit() external payable; + + function withdraw(uint256 amount) external; + + function transfer(address to, uint256 value) external returns (bool); +} diff --git a/contracts/infra/registry/entrypoint/TWMultichainRegistryRouter.sol b/contracts/infra/registry/entrypoint/TWMultichainRegistryRouter.sol new file mode 100644 index 000000000..0075ca570 --- /dev/null +++ b/contracts/infra/registry/entrypoint/TWMultichainRegistryRouter.sol @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +// $$\ $$\ $$\ $$\ $$\ +// $$ | $$ | \__| $$ | $$ | +// $$$$$$\ $$$$$$$\ $$\ $$$$$$\ $$$$$$$ |$$\ $$\ $$\ $$$$$$\ $$$$$$$\ +// \_$$ _| $$ __$$\ $$ |$$ __$$\ $$ __$$ |$$ | $$ | $$ |$$ __$$\ $$ __$$\ +// $$ | $$ | $$ |$$ |$$ | \__|$$ / $$ |$$ | $$ | $$ |$$$$$$$$ |$$ | $$ | +// $$ |$$\ $$ | $$ |$$ |$$ | $$ | $$ |$$ | $$ | $$ |$$ ____|$$ | $$ | +// \$$$$ |$$ | $$ |$$ |$$ | \$$$$$$$ |\$$$$$\$$$$ |\$$$$$$$\ $$$$$$$ | +// \____/ \__| \__|\__|\__| \_______| \_____\____/ \_______|\_______/ + +// ========== Internal imports ========== + +import "../../../extension/plugin/PermissionsEnumerableLogic.sol"; +import "../../../extension/plugin/ERC2771ContextLogic.sol"; +import "../../../extension/Multicall.sol"; +import "../../../extension/plugin/Router.sol"; + +/** + * + * "Inherited by entrypoint" extensions. + * - PermissionsEnumerable + * - ERC2771Context + * - Multicall + * + * "NOT inherited by entrypoint" extensions. + * - TWMultichainRegistry + */ + +contract TWMultichainRegistryRouter is PermissionsEnumerableLogic, ERC2771ContextLogic, Router { + /*/////////////////////////////////////////////////////////////// + Constructor + initializer logic + //////////////////////////////////////////////////////////////*/ + + constructor( + address _pluginMap, + address[] memory _trustedForwarders + ) ERC2771ContextLogic(_trustedForwarders) Router(_pluginMap) { + _setupRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + /*/////////////////////////////////////////////////////////////// + Overridable Permissions + //////////////////////////////////////////////////////////////*/ + + /// @dev Returns whether plug-in can be set in the given execution context. + function _canSetPlugin() internal view override returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + function _msgSender() internal view override(ERC2771ContextLogic, PermissionsLogic, Multicall) returns (address) { + return ERC2771ContextLogic._msgSender(); + } + + function _msgData() internal view override(ERC2771ContextLogic, PermissionsLogic) returns (bytes calldata) { + return ERC2771ContextLogic._msgData(); + } +} diff --git a/contracts/infra/registry/registry-extension/TWMultichainRegistryLogic.sol b/contracts/infra/registry/registry-extension/TWMultichainRegistryLogic.sol new file mode 100644 index 000000000..3a0159d23 --- /dev/null +++ b/contracts/infra/registry/registry-extension/TWMultichainRegistryLogic.sol @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/// @author thirdweb + +import "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; + +import "../../../extension/plugin/ERC2771ContextConsumer.sol"; +import "../../../extension/plugin/PermissionsEnumerableLogic.sol"; + +import "../../interface/ITWMultichainRegistry.sol"; +import "./TWMultichainRegistryStorage.sol"; + +contract TWMultichainRegistryLogic is ITWMultichainRegistry, ERC2771ContextConsumer { + bytes32 public constant OPERATOR_ROLE = keccak256("OPERATOR_ROLE"); + + using EnumerableSet for EnumerableSet.AddressSet; + using EnumerableSet for EnumerableSet.UintSet; + + /*/////////////////////////////////////////////////////////////// + Generic contract logic + //////////////////////////////////////////////////////////////*/ + + /// @dev Returns the type of the contract. + function contractType() external pure returns (bytes32) { + return bytes32("TWMultichainRegistry"); + } + + /// @dev Returns the version of the contract. + function contractVersion() external pure returns (uint8) { + return uint8(1); + } + + /*/////////////////////////////////////////////////////////////// + Core Functions + //////////////////////////////////////////////////////////////*/ + + // slither-disable-next-line similar-names + function add(address _deployer, address _deployment, uint256 _chainId, string memory metadataUri) external { + require( + PermissionsEnumerableLogic(address(this)).hasRole(OPERATOR_ROLE, _msgSender()) || _deployer == _msgSender(), + "not operator or deployer." + ); + + TWMultichainRegistryStorage.Data storage data = TWMultichainRegistryStorage.multichainRegistryStorage(); + + bool added = data.deployments[_deployer][_chainId].add(_deployment); + require(added, "failed to add"); + + data.chainIds.add(_chainId); + + if (bytes(metadataUri).length > 0) { + data.addressToMetadataUri[_chainId][_deployment] = metadataUri; + } + + emit Added(_deployer, _deployment, _chainId, metadataUri); + } + + // slither-disable-next-line similar-names + function remove(address _deployer, address _deployment, uint256 _chainId) external { + require( + PermissionsEnumerableLogic(address(this)).hasRole(OPERATOR_ROLE, _msgSender()) || _deployer == _msgSender(), + "not operator or deployer." + ); + + TWMultichainRegistryStorage.Data storage data = TWMultichainRegistryStorage.multichainRegistryStorage(); + + bool removed = data.deployments[_deployer][_chainId].remove(_deployment); + require(removed, "failed to remove"); + + emit Deleted(_deployer, _deployment, _chainId); + } + + function getAll(address _deployer) external view returns (Deployment[] memory allDeployments) { + TWMultichainRegistryStorage.Data storage data = TWMultichainRegistryStorage.multichainRegistryStorage(); + uint256 totalDeployments; + uint256 chainIdsLen = data.chainIds.length(); + + for (uint256 i = 0; i < chainIdsLen; i += 1) { + uint256 chainId = data.chainIds.at(i); + + totalDeployments += data.deployments[_deployer][chainId].length(); + } + + allDeployments = new Deployment[](totalDeployments); + uint256 idx; + + for (uint256 j = 0; j < chainIdsLen; j += 1) { + uint256 chainId = data.chainIds.at(j); + + uint256 len = data.deployments[_deployer][chainId].length(); + address[] memory deploymentAddrs = data.deployments[_deployer][chainId].values(); + + for (uint256 k = 0; k < len; k += 1) { + allDeployments[idx] = Deployment({ + deploymentAddress: deploymentAddrs[k], + chainId: chainId, + metadataURI: data.addressToMetadataUri[chainId][deploymentAddrs[k]] + }); + idx += 1; + } + } + } + + function count(address _deployer) external view returns (uint256 deploymentCount) { + TWMultichainRegistryStorage.Data storage data = TWMultichainRegistryStorage.multichainRegistryStorage(); + uint256 chainIdsLen = data.chainIds.length(); + + for (uint256 i = 0; i < chainIdsLen; i += 1) { + uint256 chainId = data.chainIds.at(i); + + deploymentCount += data.deployments[_deployer][chainId].length(); + } + } + + function getMetadataUri(uint256 _chainId, address _deployment) external view returns (string memory metadataUri) { + TWMultichainRegistryStorage.Data storage data = TWMultichainRegistryStorage.multichainRegistryStorage(); + metadataUri = data.addressToMetadataUri[_chainId][_deployment]; + } +} diff --git a/contracts/infra/registry/registry-extension/TWMultichainRegistryStorage.sol b/contracts/infra/registry/registry-extension/TWMultichainRegistryStorage.sol new file mode 100644 index 000000000..a237b7560 --- /dev/null +++ b/contracts/infra/registry/registry-extension/TWMultichainRegistryStorage.sol @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/// @author thirdweb + +import "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; + +import "../../interface/ITWMultichainRegistry.sol"; + +library TWMultichainRegistryStorage { + /// @custom:storage-location erc7201:multichain.registry.storage + /// @dev keccak256(abi.encode(uint256(keccak256("multichain.registry.storage")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 public constant MULTICHAIN_REGISTRY_STORAGE_POSITION = + 0x14e6df431852605a9ea88d8bd521d0d3fa06563ab37f65080e288e5afad4ac00; + + struct Data { + /// @dev wallet address => [contract addresses] + mapping(address => mapping(uint256 => EnumerableSet.AddressSet)) deployments; + /// @dev contract address deployed => imported metadata uri + mapping(uint256 => mapping(address => string)) addressToMetadataUri; + EnumerableSet.UintSet chainIds; + } + + function multichainRegistryStorage() internal pure returns (Data storage multichainRegistryData) { + bytes32 position = MULTICHAIN_REGISTRY_STORAGE_POSITION; + assembly { + multichainRegistryData.slot := position + } + } +} diff --git a/contracts/legacy-contracts/extension/BatchMintMetadata_V1.sol b/contracts/legacy-contracts/extension/BatchMintMetadata_V1.sol new file mode 100644 index 000000000..7158488dd --- /dev/null +++ b/contracts/legacy-contracts/extension/BatchMintMetadata_V1.sol @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +/** + * @title Batch-mint Metadata + * @notice The `BatchMintMetadata` is a contract extension for any base NFT contract. It lets the smart contract + * using this extension set metadata for `n` number of NFTs all at once. This is enabled by storing a single + * base URI for a batch of `n` NFTs, where the metadata for each NFT in a relevant batch is `baseURI/tokenId`. + */ + +contract BatchMintMetadata_V1 { + /// @dev Largest tokenId of each batch of tokens with the same baseURI. + uint256[] private batchIds; + + /// @dev Mapping from id of a batch of tokens => to base URI for the respective batch of tokens. + mapping(uint256 => string) private baseURI; + + /** + * @notice Returns the count of batches of NFTs. + * @dev Each batch of tokens has an in ID and an associated `baseURI`. + * See {batchIds}. + */ + function getBaseURICount() public view returns (uint256) { + return batchIds.length; + } + + /** + * @notice Returns the ID for the batch of tokens at the given index. + * @dev See {getBaseURICount}. + * @param _index Index of the desired batch in batchIds array. + */ + function getBatchIdAtIndex(uint256 _index) public view returns (uint256) { + if (_index >= getBaseURICount()) { + revert("Invalid index"); + } + return batchIds[_index]; + } + + /// @dev Returns the id for the batch of tokens the given tokenId belongs to. + function _getBatchId(uint256 _tokenId) internal view returns (uint256 batchId, uint256 index) { + uint256 numOfTokenBatches = getBaseURICount(); + uint256[] memory indices = batchIds; + + for (uint256 i = 0; i < numOfTokenBatches; i += 1) { + if (_tokenId < indices[i]) { + index = i; + batchId = indices[i]; + + return (batchId, index); + } + } + + revert("Invalid tokenId"); + } + + /// @dev Returns the baseURI for a token. The intended metadata URI for the token is baseURI + tokenId. + function _getBaseURI(uint256 _tokenId) internal view returns (string memory) { + uint256 numOfTokenBatches = getBaseURICount(); + uint256[] memory indices = batchIds; + + for (uint256 i = 0; i < numOfTokenBatches; i += 1) { + if (_tokenId < indices[i]) { + return baseURI[indices[i]]; + } + } + revert("Invalid tokenId"); + } + + /// @dev Sets the base URI for the batch of tokens with the given batchId. + function _setBaseURI(uint256 _batchId, string memory _baseURI) internal { + baseURI[_batchId] = _baseURI; + } + + /// @dev Mints a batch of tokenIds and associates a common baseURI to all those Ids. + function _batchMintMetadata( + uint256 _startId, + uint256 _amountToMint, + string memory _baseURIForTokens + ) internal returns (uint256 nextTokenIdToMint, uint256 batchId) { + batchId = _startId + _amountToMint; + nextTokenIdToMint = batchId; + + batchIds.push(batchId); + + baseURI[batchId] = _baseURIForTokens; + } +} diff --git a/contracts/legacy-contracts/extension/DropSinglePhase1155_V1.sol b/contracts/legacy-contracts/extension/DropSinglePhase1155_V1.sol new file mode 100644 index 000000000..003906bd7 --- /dev/null +++ b/contracts/legacy-contracts/extension/DropSinglePhase1155_V1.sol @@ -0,0 +1,268 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "./interface/IDropSinglePhase1155_V1.sol"; +import "../../lib/MerkleProof.sol"; +import "../../lib/BitMaps.sol"; + +abstract contract DropSinglePhase1155_V1 is IDropSinglePhase1155_V1 { + using BitMaps for BitMaps.BitMap; + + /*/////////////////////////////////////////////////////////////// + Mappings + //////////////////////////////////////////////////////////////*/ + + /// @dev Mapping from tokenId => active claim condition for the tokenId. + mapping(uint256 => ClaimCondition) public claimCondition; + + /// @dev Mapping from tokenId => active claim condition's UID. + mapping(uint256 => bytes32) private conditionId; + + /** + * @dev Map from an account and uid for a claim condition, to the last timestamp + * at which the account claimed tokens under that claim condition. + */ + mapping(bytes32 => mapping(address => uint256)) private lastClaimTimestamp; + + /** + * @dev Map from a claim condition uid to whether an address in an allowlist + * has already claimed tokens i.e. used their place in the allowlist. + */ + mapping(bytes32 => BitMaps.BitMap) private usedAllowlistSpot; + + /*/////////////////////////////////////////////////////////////// + Drop logic + //////////////////////////////////////////////////////////////*/ + + /// @dev Lets an account claim tokens. + function claim( + address _receiver, + uint256 _tokenId, + uint256 _quantity, + address _currency, + uint256 _pricePerToken, + AllowlistProof calldata _allowlistProof, + bytes memory _data + ) public payable virtual override { + _beforeClaim(_tokenId, _receiver, _quantity, _currency, _pricePerToken, _allowlistProof, _data); + + ClaimCondition memory condition = claimCondition[_tokenId]; + bytes32 activeConditionId = conditionId[_tokenId]; + + /** + * We make allowlist checks (i.e. verifyClaimMerkleProof) before verifying the claim's general + * validity (i.e. verifyClaim) because we give precedence to the check of allow list quantity + * restriction over the check of the general claim condition's quantityLimitPerTransaction + * restriction. + */ + + // Verify inclusion in allowlist. + (bool validMerkleProof, ) = verifyClaimMerkleProof(_tokenId, _dropMsgSender(), _quantity, _allowlistProof); + + // Verify claim validity. If not valid, revert. + // when there's allowlist present --> verifyClaimMerkleProof will verify the maxQuantityInAllowlist value with hashed leaf in the allowlist + // when there's no allowlist, this check is true --> verifyClaim will check for _quantity being equal/less than the limit + bool toVerifyMaxQuantityPerTransaction = _allowlistProof.maxQuantityInAllowlist == 0 || + condition.merkleRoot == bytes32(0); + + verifyClaim( + _tokenId, + _dropMsgSender(), + _quantity, + _currency, + _pricePerToken, + toVerifyMaxQuantityPerTransaction + ); + + if (validMerkleProof && _allowlistProof.maxQuantityInAllowlist > 0) { + /** + * Mark the claimer's use of their position in the allowlist. A spot in an allowlist + * can be used only once. + */ + usedAllowlistSpot[activeConditionId].set(uint256(uint160(_dropMsgSender()))); + } + + // Update contract state. + condition.supplyClaimed += _quantity; + lastClaimTimestamp[activeConditionId][_dropMsgSender()] = block.timestamp; + claimCondition[_tokenId] = condition; + + // If there's a price, collect price. + _collectPriceOnClaim(address(0), _quantity, _currency, _pricePerToken); + + // Mint the relevant NFTs to claimer. + _transferTokensOnClaim(_receiver, _tokenId, _quantity); + + emit TokensClaimed(_dropMsgSender(), _receiver, _tokenId, _quantity); + + _afterClaim(_tokenId, _receiver, _quantity, _currency, _pricePerToken, _allowlistProof, _data); + } + + /// @dev Lets a contract admin set claim conditions. + function setClaimConditions( + uint256 _tokenId, + ClaimCondition calldata _condition, + bool _resetClaimEligibility + ) external override { + if (!_canSetClaimConditions()) { + revert("Not authorized"); + } + + ClaimCondition memory condition = claimCondition[_tokenId]; + bytes32 targetConditionId = conditionId[_tokenId]; + + uint256 supplyClaimedAlready = condition.supplyClaimed; + + if (targetConditionId == bytes32(0) || _resetClaimEligibility) { + supplyClaimedAlready = 0; + targetConditionId = keccak256(abi.encodePacked(_dropMsgSender(), block.number, _tokenId)); + } + + if (supplyClaimedAlready > _condition.maxClaimableSupply) { + revert("max supply claimed"); + } + + ClaimCondition memory updatedCondition = ClaimCondition({ + startTimestamp: _condition.startTimestamp, + maxClaimableSupply: _condition.maxClaimableSupply, + supplyClaimed: supplyClaimedAlready, + quantityLimitPerTransaction: _condition.quantityLimitPerTransaction, + waitTimeInSecondsBetweenClaims: _condition.waitTimeInSecondsBetweenClaims, + merkleRoot: _condition.merkleRoot, + pricePerToken: _condition.pricePerToken, + currency: _condition.currency + }); + + claimCondition[_tokenId] = updatedCondition; + conditionId[_tokenId] = targetConditionId; + + emit ClaimConditionUpdated(_tokenId, _condition, _resetClaimEligibility); + } + + /// @dev Checks a request to claim NFTs against the active claim condition's criteria. + function verifyClaim( + uint256 _tokenId, + address _claimer, + uint256 _quantity, + address _currency, + uint256 _pricePerToken, + bool verifyMaxQuantityPerTransaction + ) public view { + ClaimCondition memory currentClaimPhase = claimCondition[_tokenId]; + + if (_currency != currentClaimPhase.currency || _pricePerToken != currentClaimPhase.pricePerToken) { + revert("Invalid price or currency"); + } + + // If we're checking for an allowlist quantity restriction, ignore the general quantity restriction. + if ( + _quantity == 0 || + (verifyMaxQuantityPerTransaction && _quantity > currentClaimPhase.quantityLimitPerTransaction) + ) { + revert("Invalid quantity"); + } + + if (currentClaimPhase.supplyClaimed + _quantity > currentClaimPhase.maxClaimableSupply) { + revert("exceeds max supply"); + } + + (uint256 lastClaimedAt, uint256 nextValidClaimTimestamp) = getClaimTimestamp(_tokenId, _claimer); + if ( + currentClaimPhase.startTimestamp > block.timestamp || + (lastClaimedAt != 0 && block.timestamp < nextValidClaimTimestamp) + ) { + revert("cant claim yet"); + } + } + + /// @dev Checks whether a claimer meets the claim condition's allowlist criteria. + function verifyClaimMerkleProof( + uint256 _tokenId, + address _claimer, + uint256 _quantity, + AllowlistProof calldata _allowlistProof + ) public view returns (bool validMerkleProof, uint256 merkleProofIndex) { + ClaimCondition memory currentClaimPhase = claimCondition[_tokenId]; + + if (currentClaimPhase.merkleRoot != bytes32(0)) { + (validMerkleProof, merkleProofIndex) = MerkleProof.verify( + _allowlistProof.proof, + currentClaimPhase.merkleRoot, + keccak256(abi.encodePacked(_claimer, _allowlistProof.maxQuantityInAllowlist)) + ); + if (!validMerkleProof) { + revert("not in allowlist"); + } + + if (usedAllowlistSpot[conditionId[_tokenId]].get(uint256(uint160(_claimer)))) { + revert("proof claimed"); + } + + if (_allowlistProof.maxQuantityInAllowlist != 0 && _quantity > _allowlistProof.maxQuantityInAllowlist) { + revert("Invalid qty proof"); + } + } + } + + /// @dev Returns the timestamp for when a claimer is eligible for claiming NFTs again. + function getClaimTimestamp( + uint256 _tokenId, + address _claimer + ) public view returns (uint256 lastClaimedAt, uint256 nextValidClaimTimestamp) { + lastClaimedAt = lastClaimTimestamp[conditionId[_tokenId]][_claimer]; + + unchecked { + nextValidClaimTimestamp = lastClaimedAt + claimCondition[_tokenId].waitTimeInSecondsBetweenClaims; + + if (nextValidClaimTimestamp < lastClaimedAt) { + nextValidClaimTimestamp = type(uint256).max; + } + } + } + + /*//////////////////////////////////////////////////////////////////// + Optional hooks that can be implemented in the derived contract + ///////////////////////////////////////////////////////////////////*/ + + /// @dev Exposes the ability to override the msg sender. + function _dropMsgSender() internal virtual returns (address) { + return msg.sender; + } + + /// @dev Runs before every `claim` function call. + function _beforeClaim( + uint256 _tokenId, + address _receiver, + uint256 _quantity, + address _currency, + uint256 _pricePerToken, + AllowlistProof calldata _allowlistProof, + bytes memory _data + ) internal virtual {} + + /// @dev Runs after every `claim` function call. + function _afterClaim( + uint256 _tokenId, + address _receiver, + uint256 _quantity, + address _currency, + uint256 _pricePerToken, + AllowlistProof calldata _allowlistProof, + bytes memory _data + ) internal virtual {} + + /// @dev Collects and distributes the primary sale value of NFTs being claimed. + function _collectPriceOnClaim( + address _primarySaleRecipient, + uint256 _quantityToClaim, + address _currency, + uint256 _pricePerToken + ) internal virtual; + + /// @dev Transfers the NFTs being claimed. + function _transferTokensOnClaim(address _to, uint256 _tokenId, uint256 _quantityBeingClaimed) internal virtual; + + function _canSetClaimConditions() internal view virtual returns (bool); +} diff --git a/contracts/legacy-contracts/extension/DropSinglePhase_V1.sol b/contracts/legacy-contracts/extension/DropSinglePhase_V1.sol new file mode 100644 index 000000000..571a0150b --- /dev/null +++ b/contracts/legacy-contracts/extension/DropSinglePhase_V1.sol @@ -0,0 +1,252 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "./interface/IDropSinglePhase_V1.sol"; +import "../../lib/MerkleProof.sol"; +import "../../lib/BitMaps.sol"; + +abstract contract DropSinglePhase_V1 is IDropSinglePhase_V1 { + using BitMaps for BitMaps.BitMap; + + /*/////////////////////////////////////////////////////////////// + State variables + //////////////////////////////////////////////////////////////*/ + + /// @dev The active conditions for claiming tokens. + ClaimCondition public claimCondition; + + /// @dev The ID for the active claim condition. + bytes32 private conditionId; + + /*/////////////////////////////////////////////////////////////// + Mappings + //////////////////////////////////////////////////////////////*/ + + /** + * @dev Map from an account and uid for a claim condition, to the last timestamp + * at which the account claimed tokens under that claim condition. + */ + mapping(bytes32 => mapping(address => uint256)) private lastClaimTimestamp; + + /** + * @dev Map from a claim condition uid to whether an address in an allowlist + * has already claimed tokens i.e. used their place in the allowlist. + */ + mapping(bytes32 => BitMaps.BitMap) private usedAllowlistSpot; + + /*/////////////////////////////////////////////////////////////// + Drop logic + //////////////////////////////////////////////////////////////*/ + + /// @dev Lets an account claim tokens. + function claim( + address _receiver, + uint256 _quantity, + address _currency, + uint256 _pricePerToken, + AllowlistProof calldata _allowlistProof, + bytes memory _data + ) public payable virtual override { + _beforeClaim(_receiver, _quantity, _currency, _pricePerToken, _allowlistProof, _data); + + bytes32 activeConditionId = conditionId; + + /** + * We make allowlist checks (i.e. verifyClaimMerkleProof) before verifying the claim's general + * validity (i.e. verifyClaim) because we give precedence to the check of allow list quantity + * restriction over the check of the general claim condition's quantityLimitPerTransaction + * restriction. + */ + + // Verify inclusion in allowlist. + (bool validMerkleProof, ) = verifyClaimMerkleProof(_dropMsgSender(), _quantity, _allowlistProof); + + // Verify claim validity. If not valid, revert. + // when there's allowlist present --> verifyClaimMerkleProof will verify the maxQuantityInAllowlist value with hashed leaf in the allowlist + // when there's no allowlist, this check is true --> verifyClaim will check for _quantity being equal/less than the limit + bool toVerifyMaxQuantityPerTransaction = _allowlistProof.maxQuantityInAllowlist == 0 || + claimCondition.merkleRoot == bytes32(0); + + verifyClaim(_dropMsgSender(), _quantity, _currency, _pricePerToken, toVerifyMaxQuantityPerTransaction); + + if (validMerkleProof && _allowlistProof.maxQuantityInAllowlist > 0) { + /** + * Mark the claimer's use of their position in the allowlist. A spot in an allowlist + * can be used only once. + */ + usedAllowlistSpot[activeConditionId].set(uint256(uint160(_dropMsgSender()))); + } + + // Update contract state. + claimCondition.supplyClaimed += _quantity; + lastClaimTimestamp[activeConditionId][_dropMsgSender()] = block.timestamp; + + // If there's a price, collect price. + _collectPriceOnClaim(address(0), _quantity, _currency, _pricePerToken); + + // Mint the relevant NFTs to claimer. + uint256 startTokenId = _transferTokensOnClaim(_receiver, _quantity); + + emit TokensClaimed(_dropMsgSender(), _receiver, startTokenId, _quantity); + + _afterClaim(_receiver, _quantity, _currency, _pricePerToken, _allowlistProof, _data); + } + + /// @dev Lets a contract admin set claim conditions. + function setClaimConditions(ClaimCondition calldata _condition, bool _resetClaimEligibility) external override { + if (!_canSetClaimConditions()) { + revert("Not authorized"); + } + + bytes32 targetConditionId = conditionId; + uint256 supplyClaimedAlready = claimCondition.supplyClaimed; + + if (_resetClaimEligibility) { + supplyClaimedAlready = 0; + targetConditionId = keccak256(abi.encodePacked(_dropMsgSender(), block.number)); + } + + if (supplyClaimedAlready > _condition.maxClaimableSupply) { + revert("max supply claimed"); + } + + claimCondition = ClaimCondition({ + startTimestamp: _condition.startTimestamp, + maxClaimableSupply: _condition.maxClaimableSupply, + supplyClaimed: supplyClaimedAlready, + quantityLimitPerTransaction: _condition.quantityLimitPerTransaction, + waitTimeInSecondsBetweenClaims: _condition.waitTimeInSecondsBetweenClaims, + merkleRoot: _condition.merkleRoot, + pricePerToken: _condition.pricePerToken, + currency: _condition.currency + }); + conditionId = targetConditionId; + + emit ClaimConditionUpdated(_condition, _resetClaimEligibility); + } + + /// @dev Checks a request to claim NFTs against the active claim condition's criteria. + function verifyClaim( + address _claimer, + uint256 _quantity, + address _currency, + uint256 _pricePerToken, + bool verifyMaxQuantityPerTransaction + ) public view { + ClaimCondition memory currentClaimPhase = claimCondition; + + if (_currency != currentClaimPhase.currency || _pricePerToken != currentClaimPhase.pricePerToken) { + revert("Invalid price or currency"); + } + + // If we're checking for an allowlist quantity restriction, ignore the general quantity restriction. + if ( + _quantity == 0 || + (verifyMaxQuantityPerTransaction && _quantity > currentClaimPhase.quantityLimitPerTransaction) + ) { + revert("Invalid quantity"); + } + + if (currentClaimPhase.supplyClaimed + _quantity > currentClaimPhase.maxClaimableSupply) { + revert("exceeds max supply"); + } + + (uint256 lastClaimedAt, uint256 nextValidClaimTimestamp) = getClaimTimestamp(_claimer); + if ( + currentClaimPhase.startTimestamp > block.timestamp || + (lastClaimedAt != 0 && block.timestamp < nextValidClaimTimestamp) + ) { + revert("cant claim yet"); + } + } + + /// @dev Checks whether a claimer meets the claim condition's allowlist criteria. + function verifyClaimMerkleProof( + address _claimer, + uint256 _quantity, + AllowlistProof calldata _allowlistProof + ) public view returns (bool validMerkleProof, uint256 merkleProofIndex) { + ClaimCondition memory currentClaimPhase = claimCondition; + + if (currentClaimPhase.merkleRoot != bytes32(0)) { + (validMerkleProof, merkleProofIndex) = MerkleProof.verify( + _allowlistProof.proof, + currentClaimPhase.merkleRoot, + keccak256(abi.encodePacked(_claimer, _allowlistProof.maxQuantityInAllowlist)) + ); + if (!validMerkleProof) { + revert("not in allowlist"); + } + + if (usedAllowlistSpot[conditionId].get(uint256(uint160(_claimer)))) { + revert("proof claimed"); + } + + if (_allowlistProof.maxQuantityInAllowlist != 0 && _quantity > _allowlistProof.maxQuantityInAllowlist) { + revert("Invalid qty proof"); + } + } + } + + /// @dev Returns the timestamp for when a claimer is eligible for claiming NFTs again. + function getClaimTimestamp( + address _claimer + ) public view returns (uint256 lastClaimedAt, uint256 nextValidClaimTimestamp) { + lastClaimedAt = lastClaimTimestamp[conditionId][_claimer]; + + unchecked { + nextValidClaimTimestamp = lastClaimedAt + claimCondition.waitTimeInSecondsBetweenClaims; + + if (nextValidClaimTimestamp < lastClaimedAt) { + nextValidClaimTimestamp = type(uint256).max; + } + } + } + + /*//////////////////////////////////////////////////////////////////// + Optional hooks that can be implemented in the derived contract + ///////////////////////////////////////////////////////////////////*/ + + /// @dev Exposes the ability to override the msg sender. + function _dropMsgSender() internal virtual returns (address) { + return msg.sender; + } + + /// @dev Runs before every `claim` function call. + function _beforeClaim( + address _receiver, + uint256 _quantity, + address _currency, + uint256 _pricePerToken, + AllowlistProof calldata _allowlistProof, + bytes memory _data + ) internal virtual {} + + /// @dev Runs after every `claim` function call. + function _afterClaim( + address _receiver, + uint256 _quantity, + address _currency, + uint256 _pricePerToken, + AllowlistProof calldata _allowlistProof, + bytes memory _data + ) internal virtual {} + + /// @dev Collects and distributes the primary sale value of NFTs being claimed. + function _collectPriceOnClaim( + address _primarySaleRecipient, + uint256 _quantityToClaim, + address _currency, + uint256 _pricePerToken + ) internal virtual; + + /// @dev Transfers the NFTs being claimed. + function _transferTokensOnClaim( + address _to, + uint256 _quantityBeingClaimed + ) internal virtual returns (uint256 startTokenId); + + function _canSetClaimConditions() internal view virtual returns (bool); +} diff --git a/contracts/legacy-contracts/extension/LazyMintWithTier_V1.sol b/contracts/legacy-contracts/extension/LazyMintWithTier_V1.sol new file mode 100644 index 000000000..2acda2134 --- /dev/null +++ b/contracts/legacy-contracts/extension/LazyMintWithTier_V1.sol @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "../../extension/interface/ILazyMintWithTier.sol"; +import "./BatchMintMetadata_V1.sol"; + +/** + * The `LazyMint` is a contract extension for any base NFT contract. It lets you 'lazy mint' any number of NFTs + * at once. Here, 'lazy mint' means defining the metadata for particular tokenIds of your NFT contract, without actually + * minting a non-zero balance of NFTs of those tokenIds. + */ + +abstract contract LazyMintWithTier_V1 is ILazyMintWithTier, BatchMintMetadata_V1 { + struct TokenRange { + uint256 startIdInclusive; + uint256 endIdNonInclusive; + } + + struct TierMetadata { + string tier; + TokenRange[] ranges; + string[] baseURIs; + } + + /// @notice The tokenId assigned to the next new NFT to be lazy minted. + uint256 internal nextTokenIdToLazyMint; + + /// @notice Mapping from a tier -> the token IDs grouped under that tier. + mapping(string => TokenRange[]) internal tokensInTier; + + /// @notice A list of tiers used in this contract. + string[] private tiers; + + /** + * @notice Lets an authorized address lazy mint a given amount of NFTs. + * + * @param _amount The number of NFTs to lazy mint. + * @param _baseURIForTokens The base URI for the 'n' number of NFTs being lazy minted, where the metadata for each + * of those NFTs is `${baseURIForTokens}/${tokenId}`. + * @param _data Additional bytes data to be used at the discretion of the consumer of the contract. + * @return batchId A unique integer identifier for the batch of NFTs lazy minted together. + */ + function lazyMint( + uint256 _amount, + string calldata _baseURIForTokens, + string calldata _tier, + bytes calldata _data + ) public virtual override returns (uint256 batchId) { + if (!_canLazyMint()) { + revert("Not authorized"); + } + + if (_amount == 0) { + revert("0 amt"); + } + + uint256 startId = nextTokenIdToLazyMint; + + (nextTokenIdToLazyMint, batchId) = _batchMintMetadata(startId, _amount, _baseURIForTokens); + + // Handle tier info. + if (!(tokensInTier[_tier].length > 0)) { + tiers.push(_tier); + } + tokensInTier[_tier].push(TokenRange(startId, batchId)); + + emit TokensLazyMinted(_tier, startId, startId + _amount - 1, _baseURIForTokens, _data); + + return batchId; + } + + /// @notice Returns all metadata lazy minted for the given tier. + function _getMetadataInTier( + string memory _tier + ) private view returns (TokenRange[] memory tokens, string[] memory baseURIs) { + tokens = tokensInTier[_tier]; + + uint256 len = tokens.length; + baseURIs = new string[](len); + + for (uint256 i = 0; i < len; i += 1) { + baseURIs[i] = _getBaseURI(tokens[i].startIdInclusive); + } + } + + /// @notice Returns all metadata for all tiers created on the contract. + function getMetadataForAllTiers() external view returns (TierMetadata[] memory metadataForAllTiers) { + string[] memory allTiers = tiers; + uint256 len = allTiers.length; + + metadataForAllTiers = new TierMetadata[](len); + + for (uint256 i = 0; i < len; i += 1) { + (TokenRange[] memory tokens, string[] memory baseURIs) = _getMetadataInTier(allTiers[i]); + metadataForAllTiers[i] = TierMetadata(allTiers[i], tokens, baseURIs); + } + } + + /** + * @notice Returns whether any metadata is lazy minted for the given tier. + * + * @param _tier We check whether this given tier is empty. + */ + function isTierEmpty(string memory _tier) internal view returns (bool) { + return tokensInTier[_tier].length == 0; + } + + /// @dev Returns whether lazy minting can be performed in the given execution context. + function _canLazyMint() internal view virtual returns (bool); +} diff --git a/contracts/legacy-contracts/extension/LazyMint_V1.sol b/contracts/legacy-contracts/extension/LazyMint_V1.sol new file mode 100644 index 000000000..820ea4eed --- /dev/null +++ b/contracts/legacy-contracts/extension/LazyMint_V1.sol @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "../../extension/interface/ILazyMint.sol"; +import "./BatchMintMetadata_V1.sol"; + +/** + * The `LazyMint` is a contract extension for any base NFT contract. It lets you 'lazy mint' any number of NFTs + * at once. Here, 'lazy mint' means defining the metadata for particular tokenIds of your NFT contract, without actually + * minting a non-zero balance of NFTs of those tokenIds. + */ + +abstract contract LazyMint_V1 is ILazyMint, BatchMintMetadata_V1 { + /// @notice The tokenId assigned to the next new NFT to be lazy minted. + uint256 internal nextTokenIdToLazyMint; + + /** + * @notice Lets an authorized address lazy mint a given amount of NFTs. + * + * @param _amount The number of NFTs to lazy mint. + * @param _baseURIForTokens The base URI for the 'n' number of NFTs being lazy minted, where the metadata for each + * of those NFTs is `${baseURIForTokens}/${tokenId}`. + * @param _data Additional bytes data to be used at the discretion of the consumer of the contract. + * @return batchId A unique integer identifier for the batch of NFTs lazy minted together. + */ + function lazyMint( + uint256 _amount, + string calldata _baseURIForTokens, + bytes calldata _data + ) public virtual override returns (uint256 batchId) { + if (!_canLazyMint()) { + revert("Not authorized"); + } + + if (_amount == 0) { + revert("0 amt"); + } + + uint256 startId = nextTokenIdToLazyMint; + + (nextTokenIdToLazyMint, batchId) = _batchMintMetadata(startId, _amount, _baseURIForTokens); + + emit TokensLazyMinted(startId, startId + _amount - 1, _baseURIForTokens, _data); + + return batchId; + } + + /// @dev Returns whether lazy minting can be performed in the given execution context. + function _canLazyMint() internal view virtual returns (bool); +} diff --git a/contracts/legacy-contracts/extension/PlatformFee_V1.sol b/contracts/legacy-contracts/extension/PlatformFee_V1.sol new file mode 100644 index 000000000..9e3af425f --- /dev/null +++ b/contracts/legacy-contracts/extension/PlatformFee_V1.sol @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "./interface/IPlatformFee_V1.sol"; + +/** + * @title Platform Fee + * @notice Thirdweb's `PlatformFee` is a contract extension to be used with any base contract. It exposes functions for setting and reading + * the recipient of platform fee and the platform fee basis points, and lets the inheriting contract perform conditional logic + * that uses information about platform fees, if desired. + */ + +abstract contract PlatformFee is IPlatformFee { + /// @dev The sender is not authorized to perform the action + error PlatformFeeUnauthorized(); + + /// @dev The recipient is invalid + error PlatformFeeInvalidRecipient(address recipient); + + /// @dev The fee bps exceeded the max value + error PlatformFeeExceededMaxFeeBps(uint256 max, uint256 actual); + + /// @dev The address that receives all platform fees from all sales. + address private platformFeeRecipient; + + /// @dev The % of primary sales collected as platform fees. + uint16 private platformFeeBps; + + /// @dev Returns the platform fee recipient and bps. + function getPlatformFeeInfo() public view override returns (address, uint16) { + return (platformFeeRecipient, uint16(platformFeeBps)); + } + + /** + * @notice Updates the platform fee recipient and bps. + * @dev Caller should be authorized to set platform fee info. + * See {_canSetPlatformFeeInfo}. + * Emits {PlatformFeeInfoUpdated Event}; See {_setupPlatformFeeInfo}. + + * @param _platformFeeRecipient Address to be set as new platformFeeRecipient. + * @param _platformFeeBps Updated platformFeeBps. + */ + function setPlatformFeeInfo(address _platformFeeRecipient, uint256 _platformFeeBps) external override { + if (!_canSetPlatformFeeInfo()) { + revert PlatformFeeUnauthorized(); + } + _setupPlatformFeeInfo(_platformFeeRecipient, _platformFeeBps); + } + + /// @dev Sets the platform fee recipient and bps + function _setupPlatformFeeInfo(address _platformFeeRecipient, uint256 _platformFeeBps) internal { + if (_platformFeeBps > 10_000) { + revert PlatformFeeExceededMaxFeeBps(10_000, _platformFeeBps); + } + if (_platformFeeRecipient == address(0)) { + revert PlatformFeeInvalidRecipient(_platformFeeRecipient); + } + + platformFeeBps = uint16(_platformFeeBps); + platformFeeRecipient = _platformFeeRecipient; + + emit PlatformFeeInfoUpdated(_platformFeeRecipient, _platformFeeBps); + } + + /// @dev Returns whether platform fee info can be set in the given execution context. + function _canSetPlatformFeeInfo() internal view virtual returns (bool); +} diff --git a/contracts/legacy-contracts/extension/PrimarySale_V1.sol b/contracts/legacy-contracts/extension/PrimarySale_V1.sol new file mode 100644 index 000000000..165501754 --- /dev/null +++ b/contracts/legacy-contracts/extension/PrimarySale_V1.sol @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "./interface/IPrimarySale_V1.sol"; + +/** + * @title Primary Sale + * @notice Thirdweb's `PrimarySale` is a contract extension to be used with any base contract. It exposes functions for setting and reading + * the recipient of primary sales, and lets the inheriting contract perform conditional logic that uses information about + * primary sales, if desired. + */ + +abstract contract PrimarySale is IPrimarySale { + /// @dev The sender is not authorized to perform the action + error PrimarySaleUnauthorized(); + + /// @dev The recipient is invalid + error PrimarySaleInvalidRecipient(address recipient); + + /// @dev The address that receives all primary sales value. + address private recipient; + + /// @dev Returns primary sale recipient address. + function primarySaleRecipient() public view override returns (address) { + return recipient; + } + + /** + * @notice Updates primary sale recipient. + * @dev Caller should be authorized to set primary sales info. + * See {_canSetPrimarySaleRecipient}. + * Emits {PrimarySaleRecipientUpdated Event}; See {_setupPrimarySaleRecipient}. + * + * @param _saleRecipient Address to be set as new recipient of primary sales. + */ + function setPrimarySaleRecipient(address _saleRecipient) external override { + if (!_canSetPrimarySaleRecipient()) { + revert PrimarySaleUnauthorized(); + } + _setupPrimarySaleRecipient(_saleRecipient); + } + + /// @dev Lets a contract admin set the recipient for all primary sales. + function _setupPrimarySaleRecipient(address _saleRecipient) internal { + recipient = _saleRecipient; + emit PrimarySaleRecipientUpdated(_saleRecipient); + } + + /// @dev Returns whether primary sale recipient can be set in the given execution context. + function _canSetPrimarySaleRecipient() internal view virtual returns (bool); +} diff --git a/contracts/legacy-contracts/extension/interface/IClaimCondition_V1.sol b/contracts/legacy-contracts/extension/interface/IClaimCondition_V1.sol new file mode 100644 index 000000000..7615ded78 --- /dev/null +++ b/contracts/legacy-contracts/extension/interface/IClaimCondition_V1.sol @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "../../../lib/BitMaps.sol"; + +/** + * Thirdweb's 'Drop' contracts are distribution mechanisms for tokens. + * + * A contract admin (i.e. a holder of `DEFAULT_ADMIN_ROLE`) can set a series of claim conditions, + * ordered by their respective `startTimestamp`. A claim condition defines criteria under which + * accounts can mint tokens. Claim conditions can be overwritten or added to by the contract admin. + * At any moment, there is only one active claim condition. + */ + +interface IClaimCondition_V1 { + /** + * @notice The criteria that make up a claim condition. + * + * @param startTimestamp The unix timestamp after which the claim condition applies. + * The same claim condition applies until the `startTimestamp` + * of the next claim condition. + * + * @param maxClaimableSupply The maximum total number of tokens that can be claimed under + * the claim condition. + * + * @param supplyClaimed At any given point, the number of tokens that have been claimed + * under the claim condition. + * + * @param quantityLimitPerTransaction The maximum number of tokens that can be claimed in a single + * transaction. + * + * @param waitTimeInSecondsBetweenClaims The least number of seconds an account must wait after claiming + * tokens, to be able to claim tokens again. + * + * @param merkleRoot The allowlist of addresses that can claim tokens under the claim + * condition. + * + * @param pricePerToken The price required to pay per token claimed. + * + * @param currency The currency in which the `pricePerToken` must be paid. + */ + struct ClaimCondition { + uint256 startTimestamp; + uint256 maxClaimableSupply; + uint256 supplyClaimed; + uint256 quantityLimitPerTransaction; + uint256 waitTimeInSecondsBetweenClaims; + bytes32 merkleRoot; + uint256 pricePerToken; + address currency; + } +} diff --git a/contracts/legacy-contracts/extension/interface/IDropSinglePhase1155_V1.sol b/contracts/legacy-contracts/extension/interface/IDropSinglePhase1155_V1.sol new file mode 100644 index 000000000..0cf271b46 --- /dev/null +++ b/contracts/legacy-contracts/extension/interface/IDropSinglePhase1155_V1.sol @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "./IClaimCondition_V1.sol"; + +interface IDropSinglePhase1155_V1 is IClaimCondition_V1 { + struct AllowlistProof { + bytes32[] proof; + uint256 maxQuantityInAllowlist; + } + + /// @dev Emitted when tokens are claimed via `claim`. + event TokensClaimed( + address indexed claimer, + address indexed receiver, + uint256 indexed tokenId, + uint256 quantityClaimed + ); + + /// @dev Emitted when the contract's claim conditions are updated. + event ClaimConditionUpdated(uint256 indexed tokenId, ClaimCondition condition, bool resetEligibility); + + /** + * @notice Lets an account claim a given quantity of NFTs. + * + * @param tokenId The tokenId of the NFT to claim. + * @param receiver The receiver of the NFT to claim. + * @param quantity The quantity of the NFT to claim. + * @param currency The currency in which to pay for the claim. + * @param pricePerToken The price per token to pay for the claim. + * @param allowlistProof The proof of the claimer's inclusion in the merkle root allowlist + * of the claim conditions that apply. + * @param data Arbitrary bytes data that can be leveraged in the implementation of this interface. + */ + function claim( + address receiver, + uint256 tokenId, + uint256 quantity, + address currency, + uint256 pricePerToken, + AllowlistProof calldata allowlistProof, + bytes memory data + ) external payable; + + /** + * @notice Lets a contract admin (account with `DEFAULT_ADMIN_ROLE`) set claim conditions. + * + * @param phase Claim condition to set. + * + * @param resetClaimEligibility Whether to reset `limitLastClaimTimestamp` and `limitMerkleProofClaim` values when setting new + * claim conditions. + * + * @param tokenId The tokenId for which to set the relevant claim condition. + */ + function setClaimConditions(uint256 tokenId, ClaimCondition calldata phase, bool resetClaimEligibility) external; +} diff --git a/contracts/legacy-contracts/extension/interface/IDropSinglePhase_V1.sol b/contracts/legacy-contracts/extension/interface/IDropSinglePhase_V1.sol new file mode 100644 index 000000000..753455b84 --- /dev/null +++ b/contracts/legacy-contracts/extension/interface/IDropSinglePhase_V1.sol @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "./IClaimCondition_V1.sol"; + +interface IDropSinglePhase_V1 is IClaimCondition_V1 { + struct AllowlistProof { + bytes32[] proof; + uint256 maxQuantityInAllowlist; + } + + /// @dev Emitted when tokens are claimed via `claim`. + event TokensClaimed( + address indexed claimer, + address indexed receiver, + uint256 indexed startTokenId, + uint256 quantityClaimed + ); + + /// @dev Emitted when the contract's claim conditions are updated. + event ClaimConditionUpdated(ClaimCondition condition, bool resetEligibility); + + /** + * @notice Lets an account claim a given quantity of NFTs. + * + * @param receiver The receiver of the NFTs to claim. + * @param quantity The quantity of NFTs to claim. + * @param currency The currency in which to pay for the claim. + * @param pricePerToken The price per token to pay for the claim. + * @param allowlistProof The proof of the claimer's inclusion in the merkle root allowlist + * of the claim conditions that apply. + * @param data Arbitrary bytes data that can be leveraged in the implementation of this interface. + */ + function claim( + address receiver, + uint256 quantity, + address currency, + uint256 pricePerToken, + AllowlistProof calldata allowlistProof, + bytes memory data + ) external payable; + + /** + * @notice Lets a contract admin (account with `DEFAULT_ADMIN_ROLE`) set claim conditions. + * + * @param phase Claim condition to set. + * + * @param resetClaimEligibility Whether to reset `limitLastClaimTimestamp` and `limitMerkleProofClaim` values when setting new + * claim conditions. + */ + function setClaimConditions(ClaimCondition calldata phase, bool resetClaimEligibility) external; +} diff --git a/contracts/legacy-contracts/extension/interface/IPlatformFee_V1.sol b/contracts/legacy-contracts/extension/interface/IPlatformFee_V1.sol new file mode 100644 index 000000000..28932effa --- /dev/null +++ b/contracts/legacy-contracts/extension/interface/IPlatformFee_V1.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +/** + * Thirdweb's `PlatformFee` is a contract extension to be used with any base contract. It exposes functions for setting and reading + * the recipient of platform fee and the platform fee basis points, and lets the inheriting contract perform conditional logic + * that uses information about platform fees, if desired. + */ + +interface IPlatformFee { + /// @dev Returns the platform fee bps and recipient. + function getPlatformFeeInfo() external view returns (address, uint16); + + /// @dev Lets a module admin update the fees on primary sales. + function setPlatformFeeInfo(address _platformFeeRecipient, uint256 _platformFeeBps) external; + + /// @dev Emitted when fee on primary sales is updated. + event PlatformFeeInfoUpdated(address indexed platformFeeRecipient, uint256 platformFeeBps); +} diff --git a/contracts/legacy-contracts/extension/interface/IPrimarySale_V1.sol b/contracts/legacy-contracts/extension/interface/IPrimarySale_V1.sol new file mode 100644 index 000000000..6ca726842 --- /dev/null +++ b/contracts/legacy-contracts/extension/interface/IPrimarySale_V1.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +/** + * Thirdweb's `Primary` is a contract extension to be used with any base contract. It exposes functions for setting and reading + * the recipient of primary sales, and lets the inheriting contract perform conditional logic that uses information about + * primary sales, if desired. + */ + +interface IPrimarySale { + /// @dev The adress that receives all primary sales value. + function primarySaleRecipient() external view returns (address); + + /// @dev Lets a module admin set the default recipient of all primary sales. + function setPrimarySaleRecipient(address _saleRecipient) external; + + /// @dev Emitted when a new sale recipient is set. + event PrimarySaleRecipientUpdated(address indexed recipient); +} diff --git a/contracts/legacy-contracts/interface/ISignatureMintERC721_V1.sol b/contracts/legacy-contracts/interface/ISignatureMintERC721_V1.sol new file mode 100644 index 000000000..e22061e86 --- /dev/null +++ b/contracts/legacy-contracts/interface/ISignatureMintERC721_V1.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/// @author thirdweb + +import "../../prebuilts/interface/token/ITokenERC721.sol"; + +interface ISignatureMintERC721_V1 { + function mintWithSignature( + ITokenERC721.MintRequest calldata _req, + bytes calldata _signature + ) external payable returns (uint256 tokenIdMinted); + + function verify( + ITokenERC721.MintRequest calldata _req, + bytes calldata _signature + ) external view returns (bool, address); +} diff --git a/contracts/legacy-contracts/interface/drop/IDropClaimCondition_V2.sol b/contracts/legacy-contracts/interface/drop/IDropClaimCondition_V2.sol new file mode 100644 index 000000000..240465a0c --- /dev/null +++ b/contracts/legacy-contracts/interface/drop/IDropClaimCondition_V2.sol @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +import "@openzeppelin/contracts-upgradeable/utils/structs/BitMapsUpgradeable.sol"; + +/** + * Thirdweb's 'Drop' contracts are distribution mechanisms for tokens. + * + * A contract admin (i.e. a holder of `DEFAULT_ADMIN_ROLE`) can set a series of claim conditions, + * ordered by their respective `startTimestamp`. A claim condition defines criteria under which + * accounts can mint tokens. Claim conditions can be overwritten or added to by the contract admin. + * At any moment, there is only one active claim condition. + */ + +interface IDropClaimCondition_V2 { + /** + * @notice The criteria that make up a claim condition. + * + * @param startTimestamp The unix timestamp after which the claim condition applies. + * The same claim condition applies until the `startTimestamp` + * of the next claim condition. + * + * @param maxClaimableSupply The maximum total number of tokens that can be claimed under + * the claim condition. + * + * @param supplyClaimed At any given point, the number of tokens that have been claimed + * under the claim condition. + * + * @param quantityLimitPerTransaction The maximum number of tokens that can be claimed in a single + * transaction. + * + * @param waitTimeInSecondsBetweenClaims The least number of seconds an account must wait after claiming + * tokens, to be able to claim tokens again. + * + * @param merkleRoot The allowlist of addresses that can claim tokens under the claim + * condition. + * + * @param pricePerToken The price required to pay per token claimed. + * + * @param currency The currency in which the `pricePerToken` must be paid. + */ + struct ClaimCondition { + uint256 startTimestamp; + uint256 maxClaimableSupply; + uint256 supplyClaimed; + uint256 quantityLimitPerTransaction; + uint256 waitTimeInSecondsBetweenClaims; + bytes32 merkleRoot; + uint256 pricePerToken; + address currency; + } + + /** + * @notice The set of all claim conditions, at any given moment. + * Claim Phase ID = [currentStartId, currentStartId + length - 1]; + * + * @param currentStartId The uid for the first claim condition amongst the current set of + * claim conditions. The uid for each next claim condition is one + * more than the previous claim condition's uid. + * + * @param count The total number of phases / claim conditions in the list + * of claim conditions. + * + * @param phases The claim conditions at a given uid. Claim conditions + * are ordered in an ascending order by their `startTimestamp`. + * + * @param limitLastClaimTimestamp Map from an account and uid for a claim condition, to the last timestamp + * at which the account claimed tokens under that claim condition. + * + * @param limitMerkleProofClaim Map from a claim condition uid to whether an address in an allowlist + * has already claimed tokens i.e. used their place in the allowlist. + */ + struct ClaimConditionList { + uint256 currentStartId; + uint256 count; + mapping(uint256 => ClaimCondition) phases; + mapping(uint256 => mapping(address => uint256)) limitLastClaimTimestamp; + mapping(uint256 => BitMapsUpgradeable.BitMap) limitMerkleProofClaim; + } +} diff --git a/contracts/legacy-contracts/interface/drop/IDropERC1155_V2.sol b/contracts/legacy-contracts/interface/drop/IDropERC1155_V2.sol new file mode 100644 index 000000000..9184580db --- /dev/null +++ b/contracts/legacy-contracts/interface/drop/IDropERC1155_V2.sol @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/// @author thirdweb + +import "@openzeppelin/contracts-upgradeable/token/ERC1155/IERC1155Upgradeable.sol"; +import "./IDropClaimCondition_V2.sol"; + +/** + * Thirdweb's 'Drop' contracts are distribution mechanisms for tokens. The + * `DropERC721` contract is a distribution mechanism for ERC721 tokens. + * + * A minter wallet (i.e. holder of `MINTER_ROLE`) can (lazy)mint 'n' tokens + * at once by providing a single base URI for all tokens being lazy minted. + * The URI for each of the 'n' tokens lazy minted is the provided base URI + + * `{tokenId}` of the respective token. (e.g. "ipsf://Qmece.../1"). + * + * A minter can choose to lazy mint 'delayed-reveal' tokens. More on 'delayed-reveal' + * tokens in [this article](https://blog.thirdweb.com/delayed-reveal-nfts). + * + * A contract admin (i.e. holder of `DEFAULT_ADMIN_ROLE`) can create claim conditions + * with non-overlapping time windows, and accounts can claim the tokens according to + * restrictions defined in the claim condition that is active at the time of the transaction. + */ + +interface IDropERC1155_V2 is IERC1155Upgradeable, IDropClaimCondition_V2 { + /// @dev Emitted when tokens are claimed. + event TokensClaimed( + uint256 indexed claimConditionIndex, + uint256 indexed tokenId, + address indexed claimer, + address receiver, + uint256 quantityClaimed + ); + + /// @dev Emitted when tokens are lazy minted. + event TokensLazyMinted(uint256 startTokenId, uint256 endTokenId, string baseURI); + + /// @dev Emitted when new claim conditions are set for a token. + event ClaimConditionsUpdated(uint256 indexed tokenId, ClaimCondition[] claimConditions); + + /// @dev Emitted when the global max supply of a token is updated. + event MaxTotalSupplyUpdated(uint256 tokenId, uint256 maxTotalSupply); + + /// @dev Emitted when the wallet claim count for a given tokenId and address is updated. + event WalletClaimCountUpdated(uint256 tokenId, address indexed wallet, uint256 count); + + /// @dev Emitted when the max wallet claim count for a given tokenId is updated. + event MaxWalletClaimCountUpdated(uint256 tokenId, uint256 count); + + /// @dev Emitted when the sale recipient for a particular tokenId is updated. + event SaleRecipientForTokenUpdated(uint256 indexed tokenId, address saleRecipient); + + /** + * @notice Lets an account with `MINTER_ROLE` lazy mint 'n' NFTs. + * The URIs for each token is the provided `_baseURIForTokens` + `{tokenId}`. + * + * @param amount The amount of NFTs to lazy mint. + * @param baseURIForTokens The URI for the NFTs to lazy mint. + */ + function lazyMint(uint256 amount, string calldata baseURIForTokens) external; + + /** + * @notice Lets an account claim a given quantity of NFTs. + * + * @param receiver The receiver of the NFTs to claim. + * @param tokenId The unique ID of the token to claim. + * @param quantity The quantity of NFTs to claim. + * @param currency The currency in which to pay for the claim. + * @param pricePerToken The price per token to pay for the claim. + * @param proofs The proof of the claimer's inclusion in the merkle root allowlist + * of the claim conditions that apply. + * @param proofMaxQuantityPerTransaction (Optional) The maximum number of NFTs an address included in an + * allowlist can claim. + */ + function claim( + address receiver, + uint256 tokenId, + uint256 quantity, + address currency, + uint256 pricePerToken, + bytes32[] calldata proofs, + uint256 proofMaxQuantityPerTransaction + ) external payable; + + /** + * @notice Lets a contract admin (account with `DEFAULT_ADMIN_ROLE`) set claim conditions. + * + * @param tokenId The token ID for which to set mint conditions. + * @param phases Claim conditions in ascending order by `startTimestamp`. + * @param resetClaimEligibility Whether to reset `limitLastClaimTimestamp` and + * `limitMerkleProofClaim` values when setting new + * claim conditions. + */ + function setClaimConditions(uint256 tokenId, ClaimCondition[] calldata phases, bool resetClaimEligibility) external; +} diff --git a/contracts/legacy-contracts/interface/drop/IDropERC20_V2.sol b/contracts/legacy-contracts/interface/drop/IDropERC20_V2.sol new file mode 100644 index 000000000..d0b40e526 --- /dev/null +++ b/contracts/legacy-contracts/interface/drop/IDropERC20_V2.sol @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/// @author thirdweb + +import "@openzeppelin/contracts-upgradeable/token/ERC20/IERC20Upgradeable.sol"; +import "./IDropClaimCondition_V2.sol"; + +/** + * Thirdweb's 'Drop' contracts are distribution mechanisms for tokens. The + * `DropERC20` contract is a distribution mechanism for ERC20 tokens. + * + * A contract admin (i.e. holder of `DEFAULT_ADMIN_ROLE`) can create claim conditions + * with non-overlapping time windows, and accounts can claim the tokens according to + * restrictions defined in the claim condition that is active at the time of the transaction. + */ + +interface IDropERC20_V2 is IERC20Upgradeable, IDropClaimCondition_V2 { + /// @dev Emitted when tokens are claimed. + event TokensClaimed( + uint256 indexed claimConditionIndex, + address indexed claimer, + address indexed receiver, + uint256 quantityClaimed + ); + + /// @dev Emitted when new claim conditions are set. + event ClaimConditionsUpdated(ClaimCondition[] claimConditions); + + /// @dev Emitted when the global max supply of tokens is updated. + event MaxTotalSupplyUpdated(uint256 maxTotalSupply); + + /// @dev Emitted when the wallet claim count for an address is updated. + event WalletClaimCountUpdated(address indexed wallet, uint256 count); + + /// @dev Emitted when the global max wallet claim count is updated. + event MaxWalletClaimCountUpdated(uint256 count); + + /// @dev Emitted when the contract URI is updated. + event ContractURIUpdated(string prevURI, string newURI); + + /** + * @notice Lets an account claim a given quantity of tokens. + * + * @param receiver The receiver of the tokens to claim. + * @param quantity The quantity of tokens to claim. + * @param currency The currency in which to pay for the claim. + * @param pricePerToken The price per token (i.e. price per 1 ether unit of the token) + * to pay for the claim. + * @param proofs The proof of the claimer's inclusion in the merkle root allowlist + * of the claim conditions that apply. + * @param proofMaxQuantityPerTransaction (Optional) The maximum number of tokens an address included in an + * allowlist can claim. + */ + function claim( + address receiver, + uint256 quantity, + address currency, + uint256 pricePerToken, + bytes32[] calldata proofs, + uint256 proofMaxQuantityPerTransaction + ) external payable; + + /** + * @notice Lets a contract admin (account with `DEFAULT_ADMIN_ROLE`) set claim conditions. + * + * @param phases Claim conditions in ascending order by `startTimestamp`. + * @param resetClaimEligibility Whether to reset `limitLastClaimTimestamp` and + * `limitMerkleProofClaim` values when setting new + * claim conditions. + */ + function setClaimConditions(ClaimCondition[] calldata phases, bool resetClaimEligibility) external; +} diff --git a/contracts/legacy-contracts/interface/drop/IDropERC721_V3.sol b/contracts/legacy-contracts/interface/drop/IDropERC721_V3.sol new file mode 100644 index 000000000..7148c2b4d --- /dev/null +++ b/contracts/legacy-contracts/interface/drop/IDropERC721_V3.sol @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/// @author thirdweb + +import "@openzeppelin/contracts-upgradeable/token/ERC721/IERC721Upgradeable.sol"; +import "./IDropClaimCondition_V2.sol"; + +/** + * Thirdweb's 'Drop' contracts are distribution mechanisms for tokens. The + * `DropERC721` contract is a distribution mechanism for ERC721 tokens. + * + * A minter wallet (i.e. holder of `MINTER_ROLE`) can (lazy)mint 'n' tokens + * at once by providing a single base URI for all tokens being lazy minted. + * The URI for each of the 'n' tokens lazy minted is the provided base URI + + * `{tokenId}` of the respective token. (e.g. "ipsf://Qmece.../1"). + * + * A minter can choose to lazy mint 'delayed-reveal' tokens. More on 'delayed-reveal' + * tokens in [this article](https://blog.thirdweb.com/delayed-reveal-nfts). + * + * A contract admin (i.e. holder of `DEFAULT_ADMIN_ROLE`) can create claim conditions + * with non-overlapping time windows, and accounts can claim the tokens according to + * restrictions defined in the claim condition that is active at the time of the transaction. + */ + +interface IDropERC721_V3 is IERC721Upgradeable, IDropClaimCondition_V2 { + /// @dev Emitted when tokens are claimed. + event TokensClaimed( + uint256 indexed claimConditionIndex, + address indexed claimer, + address indexed receiver, + uint256 startTokenId, + uint256 quantityClaimed + ); + + /// @dev Emitted when tokens are lazy minted. + event TokensLazyMinted(uint256 startTokenId, uint256 endTokenId, string baseURI, bytes encryptedBaseURI); + + /// @dev Emitted when the URI for a batch of 'delayed-reveal' NFTs is revealed. + event NFTRevealed(uint256 endTokenId, string revealedURI); + + /// @dev Emitted when new claim conditions are set. + event ClaimConditionsUpdated(ClaimCondition[] claimConditions); + + /// @dev Emitted when the global max supply of tokens is updated. + event MaxTotalSupplyUpdated(uint256 maxTotalSupply); + + /// @dev Emitted when the wallet claim count for an address is updated. + event WalletClaimCountUpdated(address indexed wallet, uint256 count); + + /// @dev Emitted when the global max wallet claim count is updated. + event MaxWalletClaimCountUpdated(uint256 count); + + /** + * @notice Lets an account with `MINTER_ROLE` lazy mint 'n' NFTs. + * The URIs for each token is the provided `_baseURIForTokens` + `{tokenId}`. + * + * @param amount The amount of NFTs to lazy mint. + * @param baseURIForTokens The URI for the NFTs to lazy mint. If lazy minting + * 'delayed-reveal' NFTs, the is a URI for NFTs in the + * un-revealed state. + * @param encryptedBaseURI If lazy minting 'delayed-reveal' NFTs, this is the + * result of encrypting the URI of the NFTs in the revealed + * state. + */ + function lazyMint(uint256 amount, string calldata baseURIForTokens, bytes calldata encryptedBaseURI) external; + + /** + * @notice Lets an account claim a given quantity of NFTs. + * + * @param receiver The receiver of the NFTs to claim. + * @param quantity The quantity of NFTs to claim. + * @param currency The currency in which to pay for the claim. + * @param pricePerToken The price per token to pay for the claim. + * @param proofs The proof of the claimer's inclusion in the merkle root allowlist + * of the claim conditions that apply. + * @param proofMaxQuantityPerTransaction (Optional) The maximum number of NFTs an address included in an + * allowlist can claim. + */ + function claim( + address receiver, + uint256 quantity, + address currency, + uint256 pricePerToken, + bytes32[] calldata proofs, + uint256 proofMaxQuantityPerTransaction + ) external payable; + + /** + * @notice Lets a contract admin (account with `DEFAULT_ADMIN_ROLE`) set claim conditions. + * + * @param phases Claim conditions in ascending order by `startTimestamp`. + * @param resetClaimEligibility Whether to reset `limitLastClaimTimestamp` and + * `limitMerkleProofClaim` values when setting new + * claim conditions. + */ + function setClaimConditions(ClaimCondition[] calldata phases, bool resetClaimEligibility) external; +} diff --git a/contracts/legacy-contracts/pre-builts/DropERC1155_V2.sol b/contracts/legacy-contracts/pre-builts/DropERC1155_V2.sol new file mode 100644 index 000000000..2d8f04971 --- /dev/null +++ b/contracts/legacy-contracts/pre-builts/DropERC1155_V2.sol @@ -0,0 +1,731 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/// @author thirdweb + +// ========== External imports ========== + +import "@openzeppelin/contracts-upgradeable/token/ERC1155/ERC1155Upgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/token/ERC20/IERC20Upgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/interfaces/IERC2981Upgradeable.sol"; + +import "@openzeppelin/contracts-upgradeable/utils/structs/BitMapsUpgradeable.sol"; +import "../../extension/Multicall.sol"; +import "@openzeppelin/contracts-upgradeable/utils/StringsUpgradeable.sol"; + +import "@openzeppelin/contracts-upgradeable/access/AccessControlEnumerableUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/security/ReentrancyGuardUpgradeable.sol"; + +// ========== Internal imports ========== + +import "../../infra/interface/IThirdwebContract.sol"; + +// ========== Features ========== + +import "../../extension/interface/IPlatformFee.sol"; +import "../../extension/interface/IPrimarySale.sol"; +import "../../extension/interface/IRoyalty.sol"; +import "../../extension/interface/IOwnable.sol"; + +import { IDropERC1155_V2 } from "../interface/drop/IDropERC1155_V2.sol"; + +import "../../external-deps/openzeppelin/metatx/ERC2771ContextUpgradeable.sol"; + +import "../../lib/CurrencyTransferLib.sol"; +import "../../lib/FeeType.sol"; +import "../../lib/MerkleProof.sol"; + +contract DropERC1155_V2 is + Initializable, + IThirdwebContract, + IOwnable, + IRoyalty, + IPrimarySale, + IPlatformFee, + ReentrancyGuardUpgradeable, + ERC2771ContextUpgradeable, + Multicall, + AccessControlEnumerableUpgradeable, + ERC1155Upgradeable, + IDropERC1155_V2 +{ + using BitMapsUpgradeable for BitMapsUpgradeable.BitMap; + using StringsUpgradeable for uint256; + + /*/////////////////////////////////////////////////////////////// + State variables + //////////////////////////////////////////////////////////////*/ + + bytes32 private constant MODULE_TYPE = bytes32("DropERC1155"); + uint256 private constant VERSION = 2; + + // Token name + string public name; + + // Token symbol + string public symbol; + + /// @dev Only transfers to or from TRANSFER_ROLE holders are valid, when transfers are restricted. + bytes32 private constant TRANSFER_ROLE = keccak256("TRANSFER_ROLE"); + /// @dev Only MINTER_ROLE holders can lazy mint NFTs. + bytes32 private constant MINTER_ROLE = keccak256("MINTER_ROLE"); + + /// @dev Max bps in the thirdweb system + uint256 private constant MAX_BPS = 10_000; + + /// @dev Owner of the contract (purpose: OpenSea compatibility) + address private _owner; + + // @dev The next token ID of the NFT to "lazy mint". + uint256 public nextTokenIdToMint; + + /// @dev The address that receives all primary sales value. + address public primarySaleRecipient; + + /// @dev The address that receives all platform fees from all sales. + address private platformFeeRecipient; + + /// @dev The % of primary sales collected as platform fees. + uint16 private platformFeeBps; + + /// @dev The recipient of who gets the royalty. + address private royaltyRecipient; + + /// @dev The (default) address that receives all royalty value. + uint16 private royaltyBps; + + /// @dev Contract level metadata. + string public contractURI; + + /// @dev Largest tokenId of each batch of tokens with the same baseURI + uint256[] private baseURIIndices; + + /*/////////////////////////////////////////////////////////////// + Mappings + //////////////////////////////////////////////////////////////*/ + + /** + * @dev Mapping from 'Largest tokenId of a batch of tokens with the same baseURI' + * to base URI for the respective batch of tokens. + **/ + mapping(uint256 => string) private baseURI; + + /// @dev Mapping from token ID => total circulating supply of tokens with that ID. + mapping(uint256 => uint256) public totalSupply; + + /// @dev Mapping from token ID => maximum possible total circulating supply of tokens with that ID. + mapping(uint256 => uint256) public maxTotalSupply; + + /// @dev Mapping from token ID => the set of all claim conditions, at any given moment, for tokens of the token ID. + mapping(uint256 => ClaimConditionList) public claimCondition; + + /// @dev Mapping from token ID => the address of the recipient of primary sales. + mapping(uint256 => address) public saleRecipient; + + /// @dev Mapping from token ID => royalty recipient and bps for tokens of the token ID. + mapping(uint256 => RoyaltyInfo) private royaltyInfoForToken; + + /// @dev Mapping from token ID => claimer wallet address => total number of NFTs of the token ID a wallet has claimed. + mapping(uint256 => mapping(address => uint256)) public walletClaimCount; + + /// @dev Mapping from token ID => the max number of NFTs of the token ID a wallet can claim. + mapping(uint256 => uint256) public maxWalletClaimCount; + + /*/////////////////////////////////////////////////////////////// + Constructor + initializer logic + //////////////////////////////////////////////////////////////*/ + + constructor() initializer {} + + /// @dev Initializes the contract, like a constructor. + function initialize( + address _defaultAdmin, + string memory _name, + string memory _symbol, + string memory _contractURI, + address[] memory _trustedForwarders, + address _saleRecipient, + address _royaltyRecipient, + uint128 _royaltyBps, + uint128 _platformFeeBps, + address _platformFeeRecipient + ) external initializer { + // Initialize inherited contracts, most base-like -> most derived. + __ReentrancyGuard_init(); + __ERC2771Context_init_unchained(_trustedForwarders); + __ERC1155_init_unchained(""); + + // Initialize this contract's state. + name = _name; + symbol = _symbol; + royaltyRecipient = _royaltyRecipient; + royaltyBps = uint16(_royaltyBps); + platformFeeRecipient = _platformFeeRecipient; + primarySaleRecipient = _saleRecipient; + contractURI = _contractURI; + platformFeeBps = uint16(_platformFeeBps); + _owner = _defaultAdmin; + + _setupRole(DEFAULT_ADMIN_ROLE, _defaultAdmin); + _setupRole(MINTER_ROLE, _defaultAdmin); + _setupRole(TRANSFER_ROLE, _defaultAdmin); + _setupRole(TRANSFER_ROLE, address(0)); + } + + /*/////////////////////////////////////////////////////////////// + Generic contract logic + //////////////////////////////////////////////////////////////*/ + + /// @dev Returns the type of the contract. + function contractType() external pure returns (bytes32) { + return MODULE_TYPE; + } + + /// @dev Returns the version of the contract. + function contractVersion() external pure returns (uint8) { + return uint8(VERSION); + } + + /** + * @dev Returns the address of the current owner. + */ + function owner() public view returns (address) { + return hasRole(DEFAULT_ADMIN_ROLE, _owner) ? _owner : address(0); + } + + /*/////////////////////////////////////////////////////////////// + ERC 165 / 1155 / 2981 logic + //////////////////////////////////////////////////////////////*/ + + /// @dev Returns the URI for a given tokenId. + function uri(uint256 _tokenId) public view override returns (string memory _tokenURI) { + for (uint256 i = 0; i < baseURIIndices.length; i += 1) { + if (_tokenId < baseURIIndices[i]) { + return string(abi.encodePacked(baseURI[baseURIIndices[i]], _tokenId.toString())); + } + } + + return ""; + } + + /// @dev See ERC 165 + function supportsInterface( + bytes4 interfaceId + ) + public + view + virtual + override(ERC1155Upgradeable, AccessControlEnumerableUpgradeable, IERC165Upgradeable, IERC165) + returns (bool) + { + return super.supportsInterface(interfaceId) || type(IERC2981Upgradeable).interfaceId == interfaceId; + } + + /// @dev Returns the royalty recipient and amount, given a tokenId and sale price. + function royaltyInfo( + uint256 tokenId, + uint256 salePrice + ) external view virtual returns (address receiver, uint256 royaltyAmount) { + (address recipient, uint256 bps) = getRoyaltyInfoForToken(tokenId); + receiver = recipient; + royaltyAmount = (salePrice * bps) / MAX_BPS; + } + + /*/////////////////////////////////////////////////////////////// + Minting logic + //////////////////////////////////////////////////////////////*/ + + /** + * @dev Lets an account with `MINTER_ROLE` lazy mint 'n' NFTs. + * The URIs for each token is the provided `_baseURIForTokens` + `{tokenId}`. + */ + function lazyMint(uint256 _amount, string calldata _baseURIForTokens) external onlyRole(MINTER_ROLE) { + uint256 startId = nextTokenIdToMint; + uint256 baseURIIndex = startId + _amount; + + nextTokenIdToMint = baseURIIndex; + baseURI[baseURIIndex] = _baseURIForTokens; + baseURIIndices.push(baseURIIndex); + + emit TokensLazyMinted(startId, startId + _amount - 1, _baseURIForTokens); + } + + /*/////////////////////////////////////////////////////////////// + Claim logic + //////////////////////////////////////////////////////////////*/ + + /// @dev Lets an account claim a given quantity of NFTs, of a single tokenId. + function claim( + address _receiver, + uint256 _tokenId, + uint256 _quantity, + address _currency, + uint256 _pricePerToken, + bytes32[] calldata _proofs, + uint256 _proofMaxQuantityPerTransaction + ) external payable nonReentrant { + require(isTrustedForwarder(msg.sender) || _msgSender() == tx.origin, "BOT"); + + // Get the active claim condition index. + uint256 activeConditionId = getActiveClaimConditionId(_tokenId); + + /** + * We make allowlist checks (i.e. verifyClaimMerkleProof) before verifying the claim's general + * validity (i.e. verifyClaim) because we give precedence to the check of allow list quantity + * restriction over the check of the general claim condition's quantityLimitPerTransaction + * restriction. + */ + + // Verify inclusion in allowlist. + (bool validMerkleProof, ) = verifyClaimMerkleProof( + activeConditionId, + _msgSender(), + _tokenId, + _quantity, + _proofs, + _proofMaxQuantityPerTransaction + ); + + // Verify claim validity. If not valid, revert. + // when there's allowlist present --> verifyClaimMerkleProof will verify the _proofMaxQuantityPerTransaction value with hashed leaf in the allowlist + // when there's no allowlist, this check is true --> verifyClaim will check for _quantity being less/equal than the limit + bool toVerifyMaxQuantityPerTransaction = _proofMaxQuantityPerTransaction == 0 || + claimCondition[_tokenId].phases[activeConditionId].merkleRoot == bytes32(0); + verifyClaim( + activeConditionId, + _msgSender(), + _tokenId, + _quantity, + _currency, + _pricePerToken, + toVerifyMaxQuantityPerTransaction + ); + + if (validMerkleProof && _proofMaxQuantityPerTransaction > 0) { + /** + * Mark the claimer's use of their position in the allowlist. A spot in an allowlist + * can be used only once. + */ + claimCondition[_tokenId].limitMerkleProofClaim[activeConditionId].set(uint256(uint160(_msgSender()))); + } + + // If there's a price, collect price. + collectClaimPrice(_quantity, _currency, _pricePerToken, _tokenId); + + // Mint the relevant tokens to claimer. + transferClaimedTokens(_receiver, activeConditionId, _tokenId, _quantity); + + emit TokensClaimed(activeConditionId, _tokenId, _msgSender(), _receiver, _quantity); + } + + /// @dev Lets a contract admin (account with `DEFAULT_ADMIN_ROLE`) set claim conditions, for a tokenId. + function setClaimConditions( + uint256 _tokenId, + ClaimCondition[] calldata _phases, + bool _resetClaimEligibility + ) external onlyRole(DEFAULT_ADMIN_ROLE) { + ClaimConditionList storage condition = claimCondition[_tokenId]; + uint256 existingStartIndex = condition.currentStartId; + uint256 existingPhaseCount = condition.count; + + /** + * `limitLastClaimTimestamp` and `limitMerkleProofClaim` are mappings that use a + * claim condition's UID as a key. + * + * If `_resetClaimEligibility == true`, we assign completely new UIDs to the claim + * conditions in `_phases`, effectively resetting the restrictions on claims expressed + * by `limitLastClaimTimestamp` and `limitMerkleProofClaim`. + */ + uint256 newStartIndex = existingStartIndex; + if (_resetClaimEligibility) { + newStartIndex = existingStartIndex + existingPhaseCount; + } + + condition.count = _phases.length; + condition.currentStartId = newStartIndex; + + uint256 lastConditionStartTimestamp; + for (uint256 i = 0; i < _phases.length; i++) { + require( + i == 0 || lastConditionStartTimestamp < _phases[i].startTimestamp, + "startTimestamp must be in ascending order." + ); + + uint256 supplyClaimedAlready = condition.phases[newStartIndex + i].supplyClaimed; + require(supplyClaimedAlready <= _phases[i].maxClaimableSupply, "max supply claimed already"); + + condition.phases[newStartIndex + i] = _phases[i]; + condition.phases[newStartIndex + i].supplyClaimed = supplyClaimedAlready; + + lastConditionStartTimestamp = _phases[i].startTimestamp; + } + + /** + * Gas refunds (as much as possible) + * + * If `_resetClaimEligibility == true`, we assign completely new UIDs to the claim + * conditions in `_phases`. So, we delete claim conditions with UID < `newStartIndex`. + * + * If `_resetClaimEligibility == false`, and there are more existing claim conditions + * than in `_phases`, we delete the existing claim conditions that don't get replaced + * by the conditions in `_phases`. + */ + if (_resetClaimEligibility) { + for (uint256 i = existingStartIndex; i < newStartIndex; i++) { + delete condition.phases[i]; + delete condition.limitMerkleProofClaim[i]; + } + } else { + if (existingPhaseCount > _phases.length) { + for (uint256 i = _phases.length; i < existingPhaseCount; i++) { + delete condition.phases[newStartIndex + i]; + delete condition.limitMerkleProofClaim[newStartIndex + i]; + } + } + } + + emit ClaimConditionsUpdated(_tokenId, _phases); + } + + /// @dev Collects and distributes the primary sale value of NFTs being claimed. + function collectClaimPrice( + uint256 _quantityToClaim, + address _currency, + uint256 _pricePerToken, + uint256 _tokenId + ) internal { + if (_pricePerToken == 0) { + return; + } + + uint256 totalPrice = _quantityToClaim * _pricePerToken; + uint256 platformFees = (totalPrice * platformFeeBps) / MAX_BPS; + + if (_currency == CurrencyTransferLib.NATIVE_TOKEN) { + require(msg.value == totalPrice, "must send total price."); + } + + address recipient = saleRecipient[_tokenId] == address(0) ? primarySaleRecipient : saleRecipient[_tokenId]; + CurrencyTransferLib.transferCurrency(_currency, _msgSender(), platformFeeRecipient, platformFees); + CurrencyTransferLib.transferCurrency(_currency, _msgSender(), recipient, totalPrice - platformFees); + } + + /// @dev Transfers the NFTs being claimed. + function transferClaimedTokens( + address _to, + uint256 _conditionId, + uint256 _tokenId, + uint256 _quantityBeingClaimed + ) internal { + // Update the supply minted under mint condition. + claimCondition[_tokenId].phases[_conditionId].supplyClaimed += _quantityBeingClaimed; + + // if transfer claimed tokens is called when to != msg.sender, it'd use msg.sender's limits. + // behavior would be similar to msg.sender mint for itself, then transfer to `to`. + claimCondition[_tokenId].limitLastClaimTimestamp[_conditionId][_msgSender()] = block.timestamp; + + walletClaimCount[_tokenId][_msgSender()] += _quantityBeingClaimed; + + _mint(_to, _tokenId, _quantityBeingClaimed, ""); + } + + /// @dev Checks a request to claim NFTs against the active claim condition's criteria. + function verifyClaim( + uint256 _conditionId, + address _claimer, + uint256 _tokenId, + uint256 _quantity, + address _currency, + uint256 _pricePerToken, + bool verifyMaxQuantityPerTransaction + ) public view { + ClaimCondition memory currentClaimPhase = claimCondition[_tokenId].phases[_conditionId]; + + require( + _currency == currentClaimPhase.currency && _pricePerToken == currentClaimPhase.pricePerToken, + "invalid currency or price specified." + ); + // If we're checking for an allowlist quantity restriction, ignore the general quantity restriction. + require( + _quantity > 0 && + (!verifyMaxQuantityPerTransaction || _quantity <= currentClaimPhase.quantityLimitPerTransaction), + "invalid quantity claimed." + ); + require( + currentClaimPhase.supplyClaimed + _quantity <= currentClaimPhase.maxClaimableSupply, + "exceed max mint supply." + ); + require( + maxTotalSupply[_tokenId] == 0 || totalSupply[_tokenId] + _quantity <= maxTotalSupply[_tokenId], + "exceed max total supply" + ); + require( + maxWalletClaimCount[_tokenId] == 0 || + walletClaimCount[_tokenId][_claimer] + _quantity <= maxWalletClaimCount[_tokenId], + "exceed claim limit for wallet" + ); + + (uint256 lastClaimTimestamp, uint256 nextValidClaimTimestamp) = getClaimTimestamp( + _tokenId, + _conditionId, + _claimer + ); + require(lastClaimTimestamp == 0 || block.timestamp >= nextValidClaimTimestamp, "cannot claim yet."); + } + + /// @dev Checks whether a claimer meets the claim condition's allowlist criteria. + function verifyClaimMerkleProof( + uint256 _conditionId, + address _claimer, + uint256 _tokenId, + uint256 _quantity, + bytes32[] calldata _proofs, + uint256 _proofMaxQuantityPerTransaction + ) public view returns (bool validMerkleProof, uint256 merkleProofIndex) { + ClaimCondition memory currentClaimPhase = claimCondition[_tokenId].phases[_conditionId]; + + if (currentClaimPhase.merkleRoot != bytes32(0)) { + (validMerkleProof, merkleProofIndex) = MerkleProof.verify( + _proofs, + currentClaimPhase.merkleRoot, + keccak256(abi.encodePacked(_claimer, _proofMaxQuantityPerTransaction)) + ); + require(validMerkleProof, "not in whitelist."); + require( + !claimCondition[_tokenId].limitMerkleProofClaim[_conditionId].get(uint256(uint160(_claimer))), + "proof claimed." + ); + require( + _proofMaxQuantityPerTransaction == 0 || _quantity <= _proofMaxQuantityPerTransaction, + "invalid quantity proof." + ); + } + } + + /*/////////////////////////////////////////////////////////////// + Getter functions + //////////////////////////////////////////////////////////////*/ + + /// @dev At any given moment, returns the uid for the active claim condition, for a given tokenId. + function getActiveClaimConditionId(uint256 _tokenId) public view returns (uint256) { + ClaimConditionList storage conditionList = claimCondition[_tokenId]; + for (uint256 i = conditionList.currentStartId + conditionList.count; i > conditionList.currentStartId; i--) { + if (block.timestamp >= conditionList.phases[i - 1].startTimestamp) { + return i - 1; + } + } + + revert("no active mint condition."); + } + + /// @dev Returns the platform fee recipient and bps. + function getPlatformFeeInfo() external view returns (address, uint16) { + return (platformFeeRecipient, uint16(platformFeeBps)); + } + + /// @dev Returns the default royalty recipient and bps. + function getDefaultRoyaltyInfo() external view returns (address, uint16) { + return (royaltyRecipient, uint16(royaltyBps)); + } + + /// @dev Returns the royalty recipient and bps for a particular token Id. + function getRoyaltyInfoForToken(uint256 _tokenId) public view returns (address, uint16) { + RoyaltyInfo memory royaltyForToken = royaltyInfoForToken[_tokenId]; + + return + royaltyForToken.recipient == address(0) + ? (royaltyRecipient, uint16(royaltyBps)) + : (royaltyForToken.recipient, uint16(royaltyForToken.bps)); + } + + /// @dev Returns the timestamp for when a claimer is eligible for claiming NFTs again. + function getClaimTimestamp( + uint256 _tokenId, + uint256 _conditionId, + address _claimer + ) public view returns (uint256 lastClaimTimestamp, uint256 nextValidClaimTimestamp) { + lastClaimTimestamp = claimCondition[_tokenId].limitLastClaimTimestamp[_conditionId][_claimer]; + + unchecked { + nextValidClaimTimestamp = + lastClaimTimestamp + + claimCondition[_tokenId].phases[_conditionId].waitTimeInSecondsBetweenClaims; + + if (nextValidClaimTimestamp < lastClaimTimestamp) { + nextValidClaimTimestamp = type(uint256).max; + } + } + } + + /// @dev Returns the claim condition at the given uid. + function getClaimConditionById( + uint256 _tokenId, + uint256 _conditionId + ) external view returns (ClaimCondition memory condition) { + condition = claimCondition[_tokenId].phases[_conditionId]; + } + + /*/////////////////////////////////////////////////////////////// + Setter functions + //////////////////////////////////////////////////////////////*/ + + /// @dev Lets a contract admin set a claim count for a wallet. + function setWalletClaimCount( + uint256 _tokenId, + address _claimer, + uint256 _count + ) external onlyRole(DEFAULT_ADMIN_ROLE) { + walletClaimCount[_tokenId][_claimer] = _count; + emit WalletClaimCountUpdated(_tokenId, _claimer, _count); + } + + /// @dev Lets a contract admin set a maximum number of NFTs of a tokenId that can be claimed by any wallet. + function setMaxWalletClaimCount(uint256 _tokenId, uint256 _count) external onlyRole(DEFAULT_ADMIN_ROLE) { + maxWalletClaimCount[_tokenId] = _count; + emit MaxWalletClaimCountUpdated(_tokenId, _count); + } + + /// @dev Lets a module admin set a max total supply for token. + function setMaxTotalSupply(uint256 _tokenId, uint256 _maxTotalSupply) external onlyRole(DEFAULT_ADMIN_ROLE) { + maxTotalSupply[_tokenId] = _maxTotalSupply; + emit MaxTotalSupplyUpdated(_tokenId, _maxTotalSupply); + } + + /// @dev Lets a contract admin set the recipient for all primary sales. + function setPrimarySaleRecipient(address _saleRecipient) external onlyRole(DEFAULT_ADMIN_ROLE) { + primarySaleRecipient = _saleRecipient; + emit PrimarySaleRecipientUpdated(_saleRecipient); + } + + /// @dev Lets a contract admin set the recipient for all primary sales. + function setSaleRecipientForToken(uint256 _tokenId, address _saleRecipient) external onlyRole(DEFAULT_ADMIN_ROLE) { + saleRecipient[_tokenId] = _saleRecipient; + emit SaleRecipientForTokenUpdated(_tokenId, _saleRecipient); + } + + /// @dev Lets a contract admin update the default royalty recipient and bps. + function setDefaultRoyaltyInfo( + address _royaltyRecipient, + uint256 _royaltyBps + ) external onlyRole(DEFAULT_ADMIN_ROLE) { + require(_royaltyBps <= MAX_BPS, "exceed royalty bps"); + + royaltyRecipient = _royaltyRecipient; + royaltyBps = uint16(_royaltyBps); + + emit DefaultRoyalty(_royaltyRecipient, _royaltyBps); + } + + /// @dev Lets a contract admin set the royalty recipient and bps for a particular token Id. + function setRoyaltyInfoForToken( + uint256 _tokenId, + address _recipient, + uint256 _bps + ) external onlyRole(DEFAULT_ADMIN_ROLE) { + require(_bps <= MAX_BPS, "exceed royalty bps"); + + royaltyInfoForToken[_tokenId] = RoyaltyInfo({ recipient: _recipient, bps: _bps }); + + emit RoyaltyForToken(_tokenId, _recipient, _bps); + } + + /// @dev Lets a contract admin update the platform fee recipient and bps + function setPlatformFeeInfo( + address _platformFeeRecipient, + uint256 _platformFeeBps + ) external onlyRole(DEFAULT_ADMIN_ROLE) { + require(_platformFeeBps <= MAX_BPS, "bps <= 10000."); + + platformFeeBps = uint16(_platformFeeBps); + platformFeeRecipient = _platformFeeRecipient; + + emit PlatformFeeInfoUpdated(_platformFeeRecipient, _platformFeeBps); + } + + /// @dev Lets a contract admin set a new owner for the contract. The new owner must be a contract admin. + function setOwner(address _newOwner) external onlyRole(DEFAULT_ADMIN_ROLE) { + require(hasRole(DEFAULT_ADMIN_ROLE, _newOwner), "new owner not module admin."); + emit OwnerUpdated(_owner, _newOwner); + _owner = _newOwner; + } + + /// @dev Lets a contract admin set the URI for contract-level metadata. + function setContractURI(string calldata _uri) external onlyRole(DEFAULT_ADMIN_ROLE) { + contractURI = _uri; + } + + /*/////////////////////////////////////////////////////////////// + Miscellaneous + //////////////////////////////////////////////////////////////*/ + + /// @dev Lets a token owner burn the tokens they own (i.e. destroy for good) + function burn(address account, uint256 id, uint256 value) public virtual { + require( + account == _msgSender() || isApprovedForAll(account, _msgSender()), + "ERC1155: caller is not owner nor approved." + ); + + _burn(account, id, value); + } + + /// @dev Lets a token owner burn multiple tokens they own at once (i.e. destroy for good) + function burnBatch(address account, uint256[] memory ids, uint256[] memory values) public virtual { + require( + account == _msgSender() || isApprovedForAll(account, _msgSender()), + "ERC1155: caller is not owner nor approved." + ); + + _burnBatch(account, ids, values); + } + + /** + * @dev See {ERC1155-_beforeTokenTransfer}. + */ + function _beforeTokenTransfer( + address operator, + address from, + address to, + uint256[] memory ids, + uint256[] memory amounts, + bytes memory data + ) internal virtual override { + super._beforeTokenTransfer(operator, from, to, ids, amounts, data); + + // if transfer is restricted on the contract, we still want to allow burning and minting + if (!hasRole(TRANSFER_ROLE, address(0)) && from != address(0) && to != address(0)) { + require(hasRole(TRANSFER_ROLE, from) || hasRole(TRANSFER_ROLE, to), "restricted to TRANSFER_ROLE holders."); + } + + if (from == address(0)) { + for (uint256 i = 0; i < ids.length; ++i) { + totalSupply[ids[i]] += amounts[i]; + } + } + + if (to == address(0)) { + for (uint256 i = 0; i < ids.length; ++i) { + totalSupply[ids[i]] -= amounts[i]; + } + } + } + + function _msgSender() + internal + view + virtual + override(ContextUpgradeable, ERC2771ContextUpgradeable, Multicall) + returns (address sender) + { + return ERC2771ContextUpgradeable._msgSender(); + } + + function _msgData() + internal + view + virtual + override(ContextUpgradeable, ERC2771ContextUpgradeable) + returns (bytes calldata) + { + return ERC2771ContextUpgradeable._msgData(); + } +} diff --git a/contracts/legacy-contracts/pre-builts/DropERC20_V2.sol b/contracts/legacy-contracts/pre-builts/DropERC20_V2.sol new file mode 100644 index 000000000..72310658a --- /dev/null +++ b/contracts/legacy-contracts/pre-builts/DropERC20_V2.sol @@ -0,0 +1,521 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/// @author thirdweb + +// ========== External imports ========== + +import "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20BurnableUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20VotesUpgradeable.sol"; + +import "@openzeppelin/contracts-upgradeable/access/AccessControlEnumerableUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/security/ReentrancyGuardUpgradeable.sol"; + +import "@openzeppelin/contracts-upgradeable/utils/structs/BitMapsUpgradeable.sol"; +import "../../extension/Multicall.sol"; + +// ========== Internal imports ========== + +import "../../infra/interface/IThirdwebContract.sol"; + +// ========== Features ========== + +import "../../extension/interface/IPlatformFee.sol"; +import "../../extension/interface/IPrimarySale.sol"; + +import { IDropERC20_V2 } from "../interface/drop/IDropERC20_V2.sol"; + +import "../../external-deps/openzeppelin/metatx/ERC2771ContextUpgradeable.sol"; + +import "../../lib/MerkleProof.sol"; +import "../../lib/CurrencyTransferLib.sol"; +import "../../lib/FeeType.sol"; + +contract DropERC20_V2 is + Initializable, + IThirdwebContract, + IPrimarySale, + IPlatformFee, + ReentrancyGuardUpgradeable, + ERC2771ContextUpgradeable, + Multicall, + AccessControlEnumerableUpgradeable, + ERC20BurnableUpgradeable, + ERC20VotesUpgradeable, + IDropERC20_V2 +{ + using BitMapsUpgradeable for BitMapsUpgradeable.BitMap; + + /*/////////////////////////////////////////////////////////////// + State variables + //////////////////////////////////////////////////////////////*/ + + bytes32 private constant MODULE_TYPE = bytes32("DropERC20"); + uint128 private constant VERSION = 2; + + /// @dev Only transfers to or from TRANSFER_ROLE holders are valid, when transfers are restricted. + bytes32 private constant TRANSFER_ROLE = keccak256("TRANSFER_ROLE"); + + /// @dev Contract level metadata. + string public contractURI; + + /// @dev Max bps in the thirdweb system. + uint128 internal constant MAX_BPS = 10_000; + + /// @dev The % of primary sales collected as platform fees. + uint128 internal platformFeeBps; + + /// @dev The address that receives all platform fees from all sales. + address internal platformFeeRecipient; + + /// @dev The address that receives all primary sales value. + address public primarySaleRecipient; + + /// @dev The max number of tokens a wallet can claim. + uint256 public maxWalletClaimCount; + + /// @dev Global max total supply of tokens. + uint256 public maxTotalSupply; + + /// @dev The set of all claim conditions, at any given moment. + ClaimConditionList public claimCondition; + + /*/////////////////////////////////////////////////////////////// + Mappings + //////////////////////////////////////////////////////////////*/ + + /// @dev Mapping from address => number of tokens a wallet has claimed. + mapping(address => uint256) public walletClaimCount; + + /*/////////////////////////////////////////////////////////////// + Constructor + initializer logic + //////////////////////////////////////////////////////////////*/ + + constructor() initializer {} + + /// @dev Initializes the contract, like a constructor. + function initialize( + address _defaultAdmin, + string memory _name, + string memory _symbol, + string memory _contractURI, + address[] memory _trustedForwarders, + address _primarySaleRecipient, + address _platformFeeRecipient, + uint256 _platformFeeBps + ) external initializer { + // Initialize inherited contracts, most base-like -> most derived. + __ERC2771Context_init_unchained(_trustedForwarders); + __ERC20Permit_init(_name); + __ERC20_init_unchained(_name, _symbol); + + // Initialize this contract's state. + contractURI = _contractURI; + primarySaleRecipient = _primarySaleRecipient; + platformFeeRecipient = _platformFeeRecipient; + platformFeeBps = uint128(_platformFeeBps); + + _setupRole(DEFAULT_ADMIN_ROLE, _defaultAdmin); + _setupRole(TRANSFER_ROLE, _defaultAdmin); + _setupRole(TRANSFER_ROLE, address(0)); + } + + /*/////////////////////////////////////////////////////////////// + Generic contract logic + //////////////////////////////////////////////////////////////*/ + + /// @dev Returns the type of the contract. + function contractType() external pure returns (bytes32) { + return MODULE_TYPE; + } + + /// @dev Returns the version of the contract. + function contractVersion() external pure returns (uint8) { + return uint8(VERSION); + } + + /*/////////////////////////////////////////////////////////////// + ERC 165 + ERC20 transfer hooks + //////////////////////////////////////////////////////////////*/ + + /// @dev See ERC 165 + function supportsInterface( + bytes4 interfaceId + ) public view virtual override(AccessControlEnumerableUpgradeable) returns (bool) { + return super.supportsInterface(interfaceId); + } + + function _afterTokenTransfer( + address from, + address to, + uint256 amount + ) internal virtual override(ERC20Upgradeable, ERC20VotesUpgradeable) { + super._afterTokenTransfer(from, to, amount); + } + + /// @dev Runs on every transfer. + function _beforeTokenTransfer(address from, address to, uint256 amount) internal override(ERC20Upgradeable) { + super._beforeTokenTransfer(from, to, amount); + + if (!hasRole(TRANSFER_ROLE, address(0)) && from != address(0) && to != address(0)) { + require(hasRole(TRANSFER_ROLE, from) || hasRole(TRANSFER_ROLE, to), "transfers restricted."); + } + } + + /*/////////////////////////////////////////////////////////////// + Claim logic + //////////////////////////////////////////////////////////////*/ + + /// @dev Lets an account claim tokens. + function claim( + address _receiver, + uint256 _quantity, + address _currency, + uint256 _pricePerToken, + bytes32[] calldata _proofs, + uint256 _proofMaxQuantityPerTransaction + ) external payable nonReentrant { + require(isTrustedForwarder(msg.sender) || _msgSender() == tx.origin, "BOT"); + + // Get the claim conditions. + uint256 activeConditionId = getActiveClaimConditionId(); + + /** + * We make allowlist checks (i.e. verifyClaimMerkleProof) before verifying the claim's general + * validity (i.e. verifyClaim) because we give precedence to the check of allow list quantity + * restriction over the check of the general claim condition's quantityLimitPerTransaction + * restriction. + */ + + // Verify inclusion in allowlist. + (bool validMerkleProof, ) = verifyClaimMerkleProof( + activeConditionId, + _msgSender(), + _quantity, + _proofs, + _proofMaxQuantityPerTransaction + ); + + // Verify claim validity. If not valid, revert. + // when there's allowlist present --> verifyClaimMerkleProof will verify the _proofMaxQuantityPerTransaction value with hashed leaf in the allowlist + // when there's no allowlist, this check is true --> verifyClaim will check for _quantity being less/equal than the limit + bool toVerifyMaxQuantityPerTransaction = _proofMaxQuantityPerTransaction == 0 || + claimCondition.phases[activeConditionId].merkleRoot == bytes32(0); + verifyClaim( + activeConditionId, + _msgSender(), + _quantity, + _currency, + _pricePerToken, + toVerifyMaxQuantityPerTransaction + ); + + if (validMerkleProof && _proofMaxQuantityPerTransaction > 0) { + /** + * Mark the claimer's use of their position in the allowlist. A spot in an allowlist + * can be used only once. + */ + claimCondition.limitMerkleProofClaim[activeConditionId].set(uint256(uint160(_msgSender()))); + } + + // If there's a price, collect price. + collectClaimPrice(_quantity, _currency, _pricePerToken); + + // Mint the relevant NFTs to claimer. + transferClaimedTokens(_receiver, activeConditionId, _quantity); + + emit TokensClaimed(activeConditionId, _msgSender(), _receiver, _quantity); + } + + /// @dev Lets a contract admin (account with `DEFAULT_ADMIN_ROLE`) set claim conditions. + function setClaimConditions( + ClaimCondition[] calldata _phases, + bool _resetClaimEligibility + ) external onlyRole(DEFAULT_ADMIN_ROLE) { + uint256 existingStartIndex = claimCondition.currentStartId; + uint256 existingPhaseCount = claimCondition.count; + + /** + * `limitLastClaimTimestamp` and `limitMerkleProofClaim` are mappings that use a + * claim condition's UID as a key. + * + * If `_resetClaimEligibility == true`, we assign completely new UIDs to the claim + * conditions in `_phases`, effectively resetting the restrictions on claims expressed + * by `limitLastClaimTimestamp` and `limitMerkleProofClaim`. + */ + uint256 newStartIndex = existingStartIndex; + if (_resetClaimEligibility) { + newStartIndex = existingStartIndex + existingPhaseCount; + } + + claimCondition.count = _phases.length; + claimCondition.currentStartId = newStartIndex; + + uint256 lastConditionStartTimestamp; + for (uint256 i = 0; i < _phases.length; i++) { + require( + i == 0 || lastConditionStartTimestamp < _phases[i].startTimestamp, + "startTimestamp must be in ascending order." + ); + + uint256 supplyClaimedAlready = claimCondition.phases[newStartIndex + i].supplyClaimed; + require(supplyClaimedAlready <= _phases[i].maxClaimableSupply, "max supply claimed already"); + + claimCondition.phases[newStartIndex + i] = _phases[i]; + claimCondition.phases[newStartIndex + i].supplyClaimed = supplyClaimedAlready; + + lastConditionStartTimestamp = _phases[i].startTimestamp; + } + + /** + * Gas refunds (as much as possible) + * + * If `_resetClaimEligibility == true`, we assign completely new UIDs to the claim + * conditions in `_phases`. So, we delete claim conditions with UID < `newStartIndex`. + * + * If `_resetClaimEligibility == false`, and there are more existing claim conditions + * than in `_phases`, we delete the existing claim conditions that don't get replaced + * by the conditions in `_phases`. + */ + if (_resetClaimEligibility) { + for (uint256 i = existingStartIndex; i < newStartIndex; i++) { + delete claimCondition.phases[i]; + delete claimCondition.limitMerkleProofClaim[i]; + } + } else { + if (existingPhaseCount > _phases.length) { + for (uint256 i = _phases.length; i < existingPhaseCount; i++) { + delete claimCondition.phases[newStartIndex + i]; + delete claimCondition.limitMerkleProofClaim[newStartIndex + i]; + } + } + } + + emit ClaimConditionsUpdated(_phases); + } + + /// @dev Collects and distributes the primary sale value of tokens being claimed. + function collectClaimPrice(uint256 _quantityToClaim, address _currency, uint256 _pricePerToken) internal { + if (_pricePerToken == 0) { + return; + } + + // `_pricePerToken` is interpreted as price per 1 ether unit of the ERC20 tokens. + uint256 totalPrice = (_quantityToClaim * _pricePerToken) / 1 ether; + require(totalPrice > 0, "quantity too low"); + + uint256 platformFees = (totalPrice * platformFeeBps) / MAX_BPS; + + if (_currency == CurrencyTransferLib.NATIVE_TOKEN) { + require(msg.value == totalPrice, "must send total price."); + } + + CurrencyTransferLib.transferCurrency(_currency, _msgSender(), platformFeeRecipient, platformFees); + CurrencyTransferLib.transferCurrency(_currency, _msgSender(), primarySaleRecipient, totalPrice - platformFees); + } + + /// @dev Transfers the tokens being claimed. + function transferClaimedTokens(address _to, uint256 _conditionId, uint256 _quantityBeingClaimed) internal { + // Update the supply minted under mint condition. + claimCondition.phases[_conditionId].supplyClaimed += _quantityBeingClaimed; + + // if transfer claimed tokens is called when to != msg.sender, it'd use msg.sender's limits. + // behavior would be similar to msg.sender mint for itself, then transfer to `to`. + claimCondition.limitLastClaimTimestamp[_conditionId][_msgSender()] = block.timestamp; + walletClaimCount[_msgSender()] += _quantityBeingClaimed; + + _mint(_to, _quantityBeingClaimed); + } + + /// @dev Checks a request to claim tokens against the active claim condition's criteria. + function verifyClaim( + uint256 _conditionId, + address _claimer, + uint256 _quantity, + address _currency, + uint256 _pricePerToken, + bool verifyMaxQuantityPerTransaction + ) public view { + ClaimCondition memory currentClaimPhase = claimCondition.phases[_conditionId]; + + require( + _currency == currentClaimPhase.currency && _pricePerToken == currentClaimPhase.pricePerToken, + "invalid currency or price specified." + ); + // If we're checking for an allowlist quantity restriction, ignore the general quantity restriction. + require( + _quantity > 0 && + (!verifyMaxQuantityPerTransaction || _quantity <= currentClaimPhase.quantityLimitPerTransaction), + "invalid quantity claimed." + ); + require( + currentClaimPhase.supplyClaimed + _quantity <= currentClaimPhase.maxClaimableSupply, + "exceed max mint supply." + ); + + uint256 _maxTotalSupply = maxTotalSupply; + uint256 _maxWalletClaimCount = maxWalletClaimCount; + require(_maxTotalSupply == 0 || totalSupply() + _quantity <= _maxTotalSupply, "exceed max total supply."); + require( + _maxWalletClaimCount == 0 || walletClaimCount[_claimer] + _quantity <= _maxWalletClaimCount, + "exceed claim limit for wallet" + ); + + (, uint256 nextValidClaimTimestamp) = getClaimTimestamp(_conditionId, _claimer); + require(block.timestamp >= nextValidClaimTimestamp, "cannot claim yet."); + } + + /// @dev Checks whether a claimer meets the claim condition's allowlist criteria. + function verifyClaimMerkleProof( + uint256 _conditionId, + address _claimer, + uint256 _quantity, + bytes32[] calldata _proofs, + uint256 _proofMaxQuantityPerTransaction + ) public view returns (bool validMerkleProof, uint256 merkleProofIndex) { + ClaimCondition memory currentClaimPhase = claimCondition.phases[_conditionId]; + + if (currentClaimPhase.merkleRoot != bytes32(0)) { + (validMerkleProof, merkleProofIndex) = MerkleProof.verify( + _proofs, + currentClaimPhase.merkleRoot, + keccak256(abi.encodePacked(_claimer, _proofMaxQuantityPerTransaction)) + ); + require(validMerkleProof, "not in whitelist."); + require( + !claimCondition.limitMerkleProofClaim[_conditionId].get(uint256(uint160(_claimer))), + "proof claimed." + ); + require( + _proofMaxQuantityPerTransaction == 0 || _quantity <= _proofMaxQuantityPerTransaction, + "invalid quantity proof." + ); + } + } + + /*/////////////////////////////////////////////////////////////// + Getter functions + //////////////////////////////////////////////////////////////*/ + + /// @dev At any given moment, returns the uid for the active claim condition. + function getActiveClaimConditionId() public view returns (uint256) { + for (uint256 i = claimCondition.currentStartId + claimCondition.count; i > claimCondition.currentStartId; i--) { + if (block.timestamp >= claimCondition.phases[i - 1].startTimestamp) { + return i - 1; + } + } + + revert("no active mint condition."); + } + + /// @dev Returns the timestamp for when a claimer is eligible for claiming tokens again. + function getClaimTimestamp( + uint256 _conditionId, + address _claimer + ) public view returns (uint256 lastClaimTimestamp, uint256 nextValidClaimTimestamp) { + lastClaimTimestamp = claimCondition.limitLastClaimTimestamp[_conditionId][_claimer]; + + if (lastClaimTimestamp != 0) { + unchecked { + nextValidClaimTimestamp = + lastClaimTimestamp + + claimCondition.phases[_conditionId].waitTimeInSecondsBetweenClaims; + + if (nextValidClaimTimestamp < lastClaimTimestamp) { + nextValidClaimTimestamp = type(uint256).max; + } + } + } + } + + /// @dev Returns the claim condition at the given uid. + function getClaimConditionById(uint256 _conditionId) external view returns (ClaimCondition memory condition) { + condition = claimCondition.phases[_conditionId]; + } + + /// @dev Returns the platform fee recipient and bps. + function getPlatformFeeInfo() external view returns (address, uint16) { + return (platformFeeRecipient, uint16(platformFeeBps)); + } + + /*/////////////////////////////////////////////////////////////// + Setter functions + //////////////////////////////////////////////////////////////*/ + + /// @dev Lets a contract admin set a claim count for a wallet. + function setWalletClaimCount(address _claimer, uint256 _count) external onlyRole(DEFAULT_ADMIN_ROLE) { + walletClaimCount[_claimer] = _count; + emit WalletClaimCountUpdated(_claimer, _count); + } + + /// @dev Set a maximum number of tokens that can be claimed by any wallet. Must be parsed to 18 decimals when setting, by adding 18 zeros after the desired value. + function setMaxWalletClaimCount(uint256 _count) external onlyRole(DEFAULT_ADMIN_ROLE) { + maxWalletClaimCount = _count; + emit MaxWalletClaimCountUpdated(_count); + } + + /// @dev Set global maximum supply. Must be parsed to 18 decimals when setting, by adding 18 zeros after the desired value. + function setMaxTotalSupply(uint256 _maxTotalSupply) external onlyRole(DEFAULT_ADMIN_ROLE) { + maxTotalSupply = _maxTotalSupply; + emit MaxTotalSupplyUpdated(_maxTotalSupply); + } + + /// @dev Lets a contract admin set the recipient for all primary sales. + function setPrimarySaleRecipient(address _saleRecipient) external onlyRole(DEFAULT_ADMIN_ROLE) { + primarySaleRecipient = _saleRecipient; + emit PrimarySaleRecipientUpdated(_saleRecipient); + } + + /// @dev Lets a contract admin update the platform fee recipient and bps + function setPlatformFeeInfo( + address _platformFeeRecipient, + uint256 _platformFeeBps + ) external onlyRole(DEFAULT_ADMIN_ROLE) { + require(_platformFeeBps <= MAX_BPS, "bps <= 10000."); + + platformFeeBps = uint64(_platformFeeBps); + platformFeeRecipient = _platformFeeRecipient; + + emit PlatformFeeInfoUpdated(_platformFeeRecipient, _platformFeeBps); + } + + /// @dev Lets a contract admin set the URI for contract-level metadata. + function setContractURI(string calldata _uri) external onlyRole(DEFAULT_ADMIN_ROLE) { + string memory prevURI = contractURI; + contractURI = _uri; + + emit ContractURIUpdated(prevURI, _uri); + } + + /*/////////////////////////////////////////////////////////////// + Miscellaneous + //////////////////////////////////////////////////////////////*/ + + function _mint(address account, uint256 amount) internal virtual override(ERC20Upgradeable, ERC20VotesUpgradeable) { + super._mint(account, amount); + } + + function _burn(address account, uint256 amount) internal virtual override(ERC20Upgradeable, ERC20VotesUpgradeable) { + super._burn(account, amount); + } + + function _msgSender() + internal + view + virtual + override(ContextUpgradeable, ERC2771ContextUpgradeable, Multicall) + returns (address sender) + { + return ERC2771ContextUpgradeable._msgSender(); + } + + function _msgData() + internal + view + virtual + override(ContextUpgradeable, ERC2771ContextUpgradeable) + returns (bytes calldata) + { + return ERC2771ContextUpgradeable._msgData(); + } +} diff --git a/contracts/legacy-contracts/pre-builts/DropERC721_V3.sol b/contracts/legacy-contracts/pre-builts/DropERC721_V3.sol new file mode 100644 index 000000000..2c7534d6e --- /dev/null +++ b/contracts/legacy-contracts/pre-builts/DropERC721_V3.sol @@ -0,0 +1,745 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/// @author thirdweb + +// ========== External imports ========== + +import "@openzeppelin/contracts-upgradeable/token/ERC721/extensions/ERC721EnumerableUpgradeable.sol"; + +import "@openzeppelin/contracts-upgradeable/interfaces/IERC2981Upgradeable.sol"; + +import "@openzeppelin/contracts-upgradeable/access/AccessControlEnumerableUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/security/ReentrancyGuardUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/utils/structs/BitMapsUpgradeable.sol"; +import "../../extension/Multicall.sol"; +import "@openzeppelin/contracts-upgradeable/utils/StringsUpgradeable.sol"; + +// ========== Internal imports ========== + +import { IDropERC721_V3 } from "../interface/drop/IDropERC721_V3.sol"; +import "../../infra/interface/IThirdwebContract.sol"; + +// ========== Features ========== + +import "../../extension/interface/IPlatformFee.sol"; +import "../../extension/interface/IPrimarySale.sol"; +import "../../extension/interface/IRoyalty.sol"; +import "../../extension/interface/IOwnable.sol"; + +import "../../external-deps/openzeppelin/metatx/ERC2771ContextUpgradeable.sol"; + +import "../../lib/CurrencyTransferLib.sol"; +import "../../lib/FeeType.sol"; +import "../../lib/MerkleProof.sol"; + +contract DropERC721_V3 is + Initializable, + IThirdwebContract, + IOwnable, + IRoyalty, + IPrimarySale, + IPlatformFee, + ReentrancyGuardUpgradeable, + ERC2771ContextUpgradeable, + Multicall, + AccessControlEnumerableUpgradeable, + ERC721EnumerableUpgradeable, + IDropERC721_V3 +{ + using BitMapsUpgradeable for BitMapsUpgradeable.BitMap; + using StringsUpgradeable for uint256; + + /*/////////////////////////////////////////////////////////////// + State variables + //////////////////////////////////////////////////////////////*/ + + bytes32 private constant MODULE_TYPE = bytes32("DropERC721"); + uint256 private constant VERSION = 3; + + /// @dev Only transfers to or from TRANSFER_ROLE holders are valid, when transfers are restricted. + bytes32 private constant TRANSFER_ROLE = keccak256("TRANSFER_ROLE"); + /// @dev Only MINTER_ROLE holders can lazy mint NFTs. + bytes32 private constant MINTER_ROLE = keccak256("MINTER_ROLE"); + + /// @dev Max bps in the thirdweb system. + uint256 private constant MAX_BPS = 10_000; + + /// @dev Owner of the contract (purpose: OpenSea compatibility) + address private _owner; + + /// @dev The next token ID of the NFT to "lazy mint". + uint256 public nextTokenIdToMint; + + /// @dev The next token ID of the NFT that can be claimed. + uint256 public nextTokenIdToClaim; + + /// @dev The address that receives all primary sales value. + address public primarySaleRecipient; + + /// @dev The max number of NFTs a wallet can claim. + uint256 public maxWalletClaimCount; + + /// @dev Global max total supply of NFTs. + uint256 public maxTotalSupply; + + /// @dev The address that receives all platform fees from all sales. + address private platformFeeRecipient; + + /// @dev The % of primary sales collected as platform fees. + uint16 private platformFeeBps; + + /// @dev The (default) address that receives all royalty value. + address private royaltyRecipient; + + /// @dev The (default) % of a sale to take as royalty (in basis points). + uint16 private royaltyBps; + + /// @dev Contract level metadata. + string public contractURI; + + /// @dev Largest tokenId of each batch of tokens with the same baseURI + uint256[] public baseURIIndices; + + /// @dev The set of all claim conditions, at any given moment. + ClaimConditionList public claimCondition; + + /*/////////////////////////////////////////////////////////////// + Mappings + //////////////////////////////////////////////////////////////*/ + + /** + * @dev Mapping from 'Largest tokenId of a batch of tokens with the same baseURI' + * to base URI for the respective batch of tokens. + **/ + mapping(uint256 => string) private baseURI; + + /** + * @dev Mapping from 'Largest tokenId of a batch of 'delayed-reveal' tokens with + * the same baseURI' to encrypted base URI for the respective batch of tokens. + **/ + mapping(uint256 => bytes) public encryptedData; + + /// @dev Mapping from address => total number of NFTs a wallet has claimed. + mapping(address => uint256) public walletClaimCount; + + /// @dev Token ID => royalty recipient and bps for token + mapping(uint256 => RoyaltyInfo) private royaltyInfoForToken; + + /*/////////////////////////////////////////////////////////////// + Constructor + initializer logic + //////////////////////////////////////////////////////////////*/ + + constructor() initializer {} + + /// @dev Initializes the contract, like a constructor. + function initialize( + address _defaultAdmin, + string memory _name, + string memory _symbol, + string memory _contractURI, + address[] memory _trustedForwarders, + address _saleRecipient, + address _royaltyRecipient, + uint128 _royaltyBps, + uint128 _platformFeeBps, + address _platformFeeRecipient + ) external initializer { + // Initialize inherited contracts, most base-like -> most derived. + __ReentrancyGuard_init(); + __ERC2771Context_init(_trustedForwarders); + __ERC721_init(_name, _symbol); + + // Initialize this contract's state. + royaltyRecipient = _royaltyRecipient; + royaltyBps = uint16(_royaltyBps); + platformFeeRecipient = _platformFeeRecipient; + platformFeeBps = uint16(_platformFeeBps); + primarySaleRecipient = _saleRecipient; + contractURI = _contractURI; + _owner = _defaultAdmin; + + _setupRole(DEFAULT_ADMIN_ROLE, _defaultAdmin); + _setupRole(MINTER_ROLE, _defaultAdmin); + _setupRole(TRANSFER_ROLE, _defaultAdmin); + _setupRole(TRANSFER_ROLE, address(0)); + } + + /*/////////////////////////////////////////////////////////////// + Generic contract logic + //////////////////////////////////////////////////////////////*/ + + /// @dev Returns the type of the contract. + function contractType() external pure returns (bytes32) { + return MODULE_TYPE; + } + + /// @dev Returns the version of the contract. + function contractVersion() external pure returns (uint8) { + return uint8(VERSION); + } + + /** + * @dev Returns the address of the current owner. + */ + function owner() public view returns (address) { + return hasRole(DEFAULT_ADMIN_ROLE, _owner) ? _owner : address(0); + } + + /*/////////////////////////////////////////////////////////////// + ERC 165 / 721 / 2981 logic + //////////////////////////////////////////////////////////////*/ + + /// @dev Returns the URI for a given tokenId. + function tokenURI(uint256 _tokenId) public view override returns (string memory) { + for (uint256 i = 0; i < baseURIIndices.length; i += 1) { + if (_tokenId < baseURIIndices[i]) { + if (encryptedData[baseURIIndices[i]].length != 0) { + return string(abi.encodePacked(baseURI[baseURIIndices[i]], "0")); + } else { + return string(abi.encodePacked(baseURI[baseURIIndices[i]], _tokenId.toString())); + } + } + } + + return ""; + } + + /// @dev See ERC 165 + function supportsInterface( + bytes4 interfaceId + ) + public + view + virtual + override(ERC721EnumerableUpgradeable, AccessControlEnumerableUpgradeable, IERC165Upgradeable, IERC165) + returns (bool) + { + return super.supportsInterface(interfaceId) || type(IERC2981Upgradeable).interfaceId == interfaceId; + } + + /// @dev Returns the royalty recipient and amount, given a tokenId and sale price. + function royaltyInfo( + uint256 tokenId, + uint256 salePrice + ) external view virtual returns (address receiver, uint256 royaltyAmount) { + (address recipient, uint256 bps) = getRoyaltyInfoForToken(tokenId); + receiver = recipient; + royaltyAmount = (salePrice * bps) / MAX_BPS; + } + + /*/////////////////////////////////////////////////////////////// + Minting + delayed-reveal logic + //////////////////////////////////////////////////////////////*/ + + /** + * @dev Lets an account with `MINTER_ROLE` lazy mint 'n' NFTs. + * The URIs for each token is the provided `_baseURIForTokens` + `{tokenId}`. + */ + function lazyMint( + uint256 _amount, + string calldata _baseURIForTokens, + bytes calldata _data + ) external onlyRole(MINTER_ROLE) { + uint256 startId = nextTokenIdToMint; + uint256 baseURIIndex = startId + _amount; + + nextTokenIdToMint = baseURIIndex; + baseURI[baseURIIndex] = _baseURIForTokens; + baseURIIndices.push(baseURIIndex); + + if (_data.length > 0) { + (bytes memory encryptedURI, bytes32 provenanceHash) = abi.decode(_data, (bytes, bytes32)); + + if (encryptedURI.length != 0 && provenanceHash != "") { + encryptedData[baseURIIndex] = _data; + } + } + + emit TokensLazyMinted(startId, startId + _amount - 1, _baseURIForTokens, _data); + } + + /// @dev Lets an account with `MINTER_ROLE` reveal the URI for a batch of 'delayed-reveal' NFTs. + function reveal( + uint256 index, + bytes calldata _key + ) external onlyRole(MINTER_ROLE) returns (string memory revealedURI) { + require(index < baseURIIndices.length, "invalid index."); + + uint256 _index = baseURIIndices[index]; + bytes memory data = encryptedData[_index]; + (bytes memory encryptedURI, bytes32 provenanceHash) = abi.decode(data, (bytes, bytes32)); + + require(encryptedURI.length != 0, "nothing to reveal."); + + revealedURI = string(encryptDecrypt(encryptedURI, _key)); + + require(keccak256(abi.encodePacked(revealedURI, _key, block.chainid)) == provenanceHash, "Incorrect key"); + + baseURI[_index] = revealedURI; + delete encryptedData[_index]; + + emit NFTRevealed(_index, revealedURI); + + return revealedURI; + } + + /// @dev See: https://ethereum.stackexchange.com/questions/69825/decrypt-message-on-chain + function encryptDecrypt(bytes memory data, bytes calldata key) public pure returns (bytes memory result) { + // Store data length on stack for later use + uint256 length = data.length; + + // solhint-disable-next-line no-inline-assembly + assembly { + // Set result to free memory pointer + result := mload(0x40) + // Increase free memory pointer by lenght + 32 + mstore(0x40, add(add(result, length), 32)) + // Set result length + mstore(result, length) + } + + // Iterate over the data stepping by 32 bytes + for (uint256 i = 0; i < length; i += 32) { + // Generate hash of the key and offset + bytes32 hash = keccak256(abi.encodePacked(key, i)); + + bytes32 chunk; + // solhint-disable-next-line no-inline-assembly + assembly { + // Read 32-bytes data chunk + chunk := mload(add(data, add(i, 32))) + } + // XOR the chunk with hash + chunk ^= hash; + // solhint-disable-next-line no-inline-assembly + assembly { + // Write 32-byte encrypted chunk + mstore(add(result, add(i, 32)), chunk) + } + } + } + + /*/////////////////////////////////////////////////////////////// + Claim logic + //////////////////////////////////////////////////////////////*/ + + /// @dev Lets an account claim NFTs. + function claim( + address _receiver, + uint256 _quantity, + address _currency, + uint256 _pricePerToken, + bytes32[] calldata _proofs, + uint256 _proofMaxQuantityPerTransaction + ) external payable nonReentrant { + require(isTrustedForwarder(msg.sender) || _msgSender() == tx.origin, "BOT"); + + uint256 tokenIdToClaim = nextTokenIdToClaim; + + // Get the claim conditions. + uint256 activeConditionId = getActiveClaimConditionId(); + + /** + * We make allowlist checks (i.e. verifyClaimMerkleProof) before verifying the claim's general + * validity (i.e. verifyClaim) because we give precedence to the check of allow list quantity + * restriction over the check of the general claim condition's quantityLimitPerTransaction + * restriction. + */ + + // Verify inclusion in allowlist. + (bool validMerkleProof, ) = verifyClaimMerkleProof( + activeConditionId, + _msgSender(), + _quantity, + _proofs, + _proofMaxQuantityPerTransaction + ); + + // Verify claim validity. If not valid, revert. + // when there's allowlist present --> verifyClaimMerkleProof will verify the _proofMaxQuantityPerTransaction value with hashed leaf in the allowlist + // when there's no allowlist, this check is true --> verifyClaim will check for _quantity being less/equal than the limit + bool toVerifyMaxQuantityPerTransaction = _proofMaxQuantityPerTransaction == 0 || + claimCondition.phases[activeConditionId].merkleRoot == bytes32(0); + verifyClaim( + activeConditionId, + _msgSender(), + _quantity, + _currency, + _pricePerToken, + toVerifyMaxQuantityPerTransaction + ); + + if (validMerkleProof && _proofMaxQuantityPerTransaction > 0) { + /** + * Mark the claimer's use of their position in the allowlist. A spot in an allowlist + * can be used only once. + */ + claimCondition.limitMerkleProofClaim[activeConditionId].set(uint256(uint160(_msgSender()))); + } + + // If there's a price, collect price. + collectClaimPrice(_quantity, _currency, _pricePerToken); + + // Mint the relevant NFTs to claimer. + transferClaimedTokens(_receiver, activeConditionId, _quantity); + + emit TokensClaimed(activeConditionId, _msgSender(), _receiver, tokenIdToClaim, _quantity); + } + + /// @dev Lets a contract admin (account with `DEFAULT_ADMIN_ROLE`) set claim conditions. + function setClaimConditions( + ClaimCondition[] calldata _phases, + bool _resetClaimEligibility + ) external onlyRole(DEFAULT_ADMIN_ROLE) { + uint256 existingStartIndex = claimCondition.currentStartId; + uint256 existingPhaseCount = claimCondition.count; + + /** + * `limitLastClaimTimestamp` and `limitMerkleProofClaim` are mappings that use a + * claim condition's UID as a key. + * + * If `_resetClaimEligibility == true`, we assign completely new UIDs to the claim + * conditions in `_phases`, effectively resetting the restrictions on claims expressed + * by `limitLastClaimTimestamp` and `limitMerkleProofClaim`. + */ + uint256 newStartIndex = existingStartIndex; + if (_resetClaimEligibility) { + newStartIndex = existingStartIndex + existingPhaseCount; + } + + claimCondition.count = _phases.length; + claimCondition.currentStartId = newStartIndex; + + uint256 lastConditionStartTimestamp; + for (uint256 i = 0; i < _phases.length; i++) { + require(i == 0 || lastConditionStartTimestamp < _phases[i].startTimestamp, "ST"); + + uint256 supplyClaimedAlready = claimCondition.phases[newStartIndex + i].supplyClaimed; + require(supplyClaimedAlready <= _phases[i].maxClaimableSupply, "max supply claimed already"); + + claimCondition.phases[newStartIndex + i] = _phases[i]; + claimCondition.phases[newStartIndex + i].supplyClaimed = supplyClaimedAlready; + + lastConditionStartTimestamp = _phases[i].startTimestamp; + } + + /** + * Gas refunds (as much as possible) + * + * If `_resetClaimEligibility == true`, we assign completely new UIDs to the claim + * conditions in `_phases`. So, we delete claim conditions with UID < `newStartIndex`. + * + * If `_resetClaimEligibility == false`, and there are more existing claim conditions + * than in `_phases`, we delete the existing claim conditions that don't get replaced + * by the conditions in `_phases`. + */ + if (_resetClaimEligibility) { + for (uint256 i = existingStartIndex; i < newStartIndex; i++) { + delete claimCondition.phases[i]; + delete claimCondition.limitMerkleProofClaim[i]; + } + } else { + if (existingPhaseCount > _phases.length) { + for (uint256 i = _phases.length; i < existingPhaseCount; i++) { + delete claimCondition.phases[newStartIndex + i]; + delete claimCondition.limitMerkleProofClaim[newStartIndex + i]; + } + } + } + + emit ClaimConditionsUpdated(_phases); + } + + /// @dev Collects and distributes the primary sale value of NFTs being claimed. + function collectClaimPrice(uint256 _quantityToClaim, address _currency, uint256 _pricePerToken) internal { + if (_pricePerToken == 0) { + return; + } + + uint256 totalPrice = _quantityToClaim * _pricePerToken; + uint256 platformFees = (totalPrice * platformFeeBps) / MAX_BPS; + + if (_currency == CurrencyTransferLib.NATIVE_TOKEN) { + require(msg.value == totalPrice, "must send total price."); + } + + CurrencyTransferLib.transferCurrency(_currency, _msgSender(), platformFeeRecipient, platformFees); + CurrencyTransferLib.transferCurrency(_currency, _msgSender(), primarySaleRecipient, totalPrice - platformFees); + } + + /// @dev Transfers the NFTs being claimed. + function transferClaimedTokens(address _to, uint256 _conditionId, uint256 _quantityBeingClaimed) internal { + // Update the supply minted under mint condition. + claimCondition.phases[_conditionId].supplyClaimed += _quantityBeingClaimed; + + // if transfer claimed tokens is called when `to != msg.sender`, it'd use msg.sender's limits. + // behavior would be similar to `msg.sender` mint for itself, then transfer to `_to`. + claimCondition.limitLastClaimTimestamp[_conditionId][_msgSender()] = block.timestamp; + walletClaimCount[_msgSender()] += _quantityBeingClaimed; + + uint256 tokenIdToClaim = nextTokenIdToClaim; + + for (uint256 i = 0; i < _quantityBeingClaimed; i += 1) { + _mint(_to, tokenIdToClaim); + tokenIdToClaim += 1; + } + + nextTokenIdToClaim = tokenIdToClaim; + } + + /// @dev Checks a request to claim NFTs against the active claim condition's criteria. + function verifyClaim( + uint256 _conditionId, + address _claimer, + uint256 _quantity, + address _currency, + uint256 _pricePerToken, + bool verifyMaxQuantityPerTransaction + ) public view { + ClaimCondition memory currentClaimPhase = claimCondition.phases[_conditionId]; + + require( + _currency == currentClaimPhase.currency && _pricePerToken == currentClaimPhase.pricePerToken, + "invalid currency or price." + ); + + // If we're checking for an allowlist quantity restriction, ignore the general quantity restriction. + require( + _quantity > 0 && + (!verifyMaxQuantityPerTransaction || _quantity <= currentClaimPhase.quantityLimitPerTransaction), + "invalid quantity." + ); + require( + currentClaimPhase.supplyClaimed + _quantity <= currentClaimPhase.maxClaimableSupply, + "exceed max claimable supply." + ); + require(nextTokenIdToClaim + _quantity <= nextTokenIdToMint, "not enough minted tokens."); + require(maxTotalSupply == 0 || nextTokenIdToClaim + _quantity <= maxTotalSupply, "exceed max total supply."); + require( + maxWalletClaimCount == 0 || walletClaimCount[_claimer] + _quantity <= maxWalletClaimCount, + "exceed claim limit" + ); + + (uint256 lastClaimTimestamp, uint256 nextValidClaimTimestamp) = getClaimTimestamp(_conditionId, _claimer); + require(lastClaimTimestamp == 0 || block.timestamp >= nextValidClaimTimestamp, "cannot claim."); + } + + /// @dev Checks whether a claimer meets the claim condition's allowlist criteria. + function verifyClaimMerkleProof( + uint256 _conditionId, + address _claimer, + uint256 _quantity, + bytes32[] calldata _proofs, + uint256 _proofMaxQuantityPerTransaction + ) public view returns (bool validMerkleProof, uint256 merkleProofIndex) { + ClaimCondition memory currentClaimPhase = claimCondition.phases[_conditionId]; + + if (currentClaimPhase.merkleRoot != bytes32(0)) { + (validMerkleProof, merkleProofIndex) = MerkleProof.verify( + _proofs, + currentClaimPhase.merkleRoot, + keccak256(abi.encodePacked(_claimer, _proofMaxQuantityPerTransaction)) + ); + require(validMerkleProof, "not in whitelist."); + require( + !claimCondition.limitMerkleProofClaim[_conditionId].get(uint256(uint160(_claimer))), + "proof claimed." + ); + require( + _proofMaxQuantityPerTransaction == 0 || _quantity <= _proofMaxQuantityPerTransaction, + "invalid quantity proof." + ); + } + } + + /*/////////////////////////////////////////////////////////////// + Getter functions + //////////////////////////////////////////////////////////////*/ + + /// @dev At any given moment, returns the uid for the active claim condition. + function getActiveClaimConditionId() public view returns (uint256) { + for (uint256 i = claimCondition.currentStartId + claimCondition.count; i > claimCondition.currentStartId; i--) { + if (block.timestamp >= claimCondition.phases[i - 1].startTimestamp) { + return i - 1; + } + } + + revert("!CONDITION."); + } + + /// @dev Returns the royalty recipient and bps for a particular token Id. + function getRoyaltyInfoForToken(uint256 _tokenId) public view returns (address, uint16) { + RoyaltyInfo memory royaltyForToken = royaltyInfoForToken[_tokenId]; + + return + royaltyForToken.recipient == address(0) + ? (royaltyRecipient, uint16(royaltyBps)) + : (royaltyForToken.recipient, uint16(royaltyForToken.bps)); + } + + /// @dev Returns the platform fee recipient and bps. + function getPlatformFeeInfo() external view returns (address, uint16) { + return (platformFeeRecipient, uint16(platformFeeBps)); + } + + /// @dev Returns the default royalty recipient and bps. + function getDefaultRoyaltyInfo() external view returns (address, uint16) { + return (royaltyRecipient, uint16(royaltyBps)); + } + + /// @dev Returns the timestamp for when a claimer is eligible for claiming NFTs again. + function getClaimTimestamp( + uint256 _conditionId, + address _claimer + ) public view returns (uint256 lastClaimTimestamp, uint256 nextValidClaimTimestamp) { + lastClaimTimestamp = claimCondition.limitLastClaimTimestamp[_conditionId][_claimer]; + + unchecked { + nextValidClaimTimestamp = + lastClaimTimestamp + + claimCondition.phases[_conditionId].waitTimeInSecondsBetweenClaims; + + if (nextValidClaimTimestamp < lastClaimTimestamp) { + nextValidClaimTimestamp = type(uint256).max; + } + } + } + + /// @dev Returns the claim condition at the given uid. + function getClaimConditionById(uint256 _conditionId) external view returns (ClaimCondition memory condition) { + condition = claimCondition.phases[_conditionId]; + } + + /// @dev Returns the amount of stored baseURIs + function getBaseURICount() external view returns (uint256) { + return baseURIIndices.length; + } + + /*/////////////////////////////////////////////////////////////// + Setter functions + //////////////////////////////////////////////////////////////*/ + + /// @dev Lets a contract admin set a claim count for a wallet. + function setWalletClaimCount(address _claimer, uint256 _count) external onlyRole(DEFAULT_ADMIN_ROLE) { + walletClaimCount[_claimer] = _count; + emit WalletClaimCountUpdated(_claimer, _count); + } + + /// @dev Lets a contract admin set a maximum number of NFTs that can be claimed by any wallet. + function setMaxWalletClaimCount(uint256 _count) external onlyRole(DEFAULT_ADMIN_ROLE) { + maxWalletClaimCount = _count; + emit MaxWalletClaimCountUpdated(_count); + } + + /// @dev Lets a contract admin set the global maximum supply for collection's NFTs. + function setMaxTotalSupply(uint256 _maxTotalSupply) external onlyRole(DEFAULT_ADMIN_ROLE) { + maxTotalSupply = _maxTotalSupply; + emit MaxTotalSupplyUpdated(_maxTotalSupply); + } + + /// @dev Lets a contract admin set the recipient for all primary sales. + function setPrimarySaleRecipient(address _saleRecipient) external onlyRole(DEFAULT_ADMIN_ROLE) { + primarySaleRecipient = _saleRecipient; + emit PrimarySaleRecipientUpdated(_saleRecipient); + } + + /// @dev Lets a contract admin update the default royalty recipient and bps. + function setDefaultRoyaltyInfo( + address _royaltyRecipient, + uint256 _royaltyBps + ) external onlyRole(DEFAULT_ADMIN_ROLE) { + require(_royaltyBps <= MAX_BPS, "> MAX_BPS"); + + royaltyRecipient = _royaltyRecipient; + royaltyBps = uint16(_royaltyBps); + + emit DefaultRoyalty(_royaltyRecipient, _royaltyBps); + } + + /// @dev Lets a contract admin set the royalty recipient and bps for a particular token Id. + function setRoyaltyInfoForToken( + uint256 _tokenId, + address _recipient, + uint256 _bps + ) external onlyRole(DEFAULT_ADMIN_ROLE) { + require(_bps <= MAX_BPS, "> MAX_BPS"); + + royaltyInfoForToken[_tokenId] = RoyaltyInfo({ recipient: _recipient, bps: _bps }); + + emit RoyaltyForToken(_tokenId, _recipient, _bps); + } + + /// @dev Lets a contract admin update the platform fee recipient and bps + function setPlatformFeeInfo( + address _platformFeeRecipient, + uint256 _platformFeeBps + ) external onlyRole(DEFAULT_ADMIN_ROLE) { + require(_platformFeeBps <= MAX_BPS, "> MAX_BPS."); + + platformFeeBps = uint16(_platformFeeBps); + platformFeeRecipient = _platformFeeRecipient; + + emit PlatformFeeInfoUpdated(_platformFeeRecipient, _platformFeeBps); + } + + /// @dev Lets a contract admin set a new owner for the contract. The new owner must be a contract admin. + function setOwner(address _newOwner) external onlyRole(DEFAULT_ADMIN_ROLE) { + require(hasRole(DEFAULT_ADMIN_ROLE, _newOwner), "!ADMIN"); + address _prevOwner = _owner; + _owner = _newOwner; + + emit OwnerUpdated(_prevOwner, _newOwner); + } + + /// @dev Lets a contract admin set the URI for contract-level metadata. + function setContractURI(string calldata _uri) external onlyRole(DEFAULT_ADMIN_ROLE) { + contractURI = _uri; + } + + /*/////////////////////////////////////////////////////////////// + Miscellaneous + //////////////////////////////////////////////////////////////*/ + + /// @dev Burns `tokenId`. See {ERC721-_burn}. + function burn(uint256 tokenId) public virtual { + //solhint-disable-next-line max-line-length + require(_isApprovedOrOwner(_msgSender(), tokenId), "caller not owner nor approved"); + _burn(tokenId); + } + + /// @dev See {ERC721-_beforeTokenTransfer}. + function _beforeTokenTransfer( + address from, + address to, + uint256 tokenId, + uint256 batchSize + ) internal virtual override(ERC721EnumerableUpgradeable) { + super._beforeTokenTransfer(from, to, tokenId, batchSize); + + // if transfer is restricted on the contract, we still want to allow burning and minting + if (!hasRole(TRANSFER_ROLE, address(0)) && from != address(0) && to != address(0)) { + require(hasRole(TRANSFER_ROLE, from) || hasRole(TRANSFER_ROLE, to), "!TRANSFER_ROLE"); + } + } + + function _msgSender() + internal + view + virtual + override(ContextUpgradeable, ERC2771ContextUpgradeable, Multicall) + returns (address sender) + { + return ERC2771ContextUpgradeable._msgSender(); + } + + function _msgData() + internal + view + virtual + override(ContextUpgradeable, ERC2771ContextUpgradeable) + returns (bytes calldata) + { + return ERC2771ContextUpgradeable._msgData(); + } +} diff --git a/contracts/legacy-contracts/pre-builts/SignatureDrop_V4.sol b/contracts/legacy-contracts/pre-builts/SignatureDrop_V4.sol new file mode 100644 index 000000000..4b3e313bc --- /dev/null +++ b/contracts/legacy-contracts/pre-builts/SignatureDrop_V4.sol @@ -0,0 +1,360 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/// @author thirdweb + +// ========== External imports ========== + +import "../../extension/Multicall.sol"; +import "@openzeppelin/contracts-upgradeable/utils/StringsUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/interfaces/IERC2981Upgradeable.sol"; + +import "erc721a-upgradeable/contracts/ERC721AUpgradeable.sol"; + +// ========== Internal imports ========== + +import "../../external-deps/openzeppelin/metatx/ERC2771ContextUpgradeable.sol"; +import "../../lib/CurrencyTransferLib.sol"; + +// ========== Features ========== + +import "../../extension/ContractMetadata.sol"; +import "../../extension/PlatformFee.sol"; +import "../../extension/Royalty.sol"; +import "../../extension/PrimarySale.sol"; +import "../../extension/Ownable.sol"; +import "../../extension/DelayedReveal.sol"; +import "../extension/LazyMint_V1.sol"; +import "../../extension/PermissionsEnumerable.sol"; +import "../extension/DropSinglePhase_V1.sol"; +import "../../extension/SignatureMintERC721Upgradeable.sol"; + +contract SignatureDrop_V4 is + Initializable, + ContractMetadata, + PlatformFee, + Royalty, + PrimarySale, + Ownable, + DelayedReveal, + LazyMint_V1, + PermissionsEnumerable, + DropSinglePhase_V1, + SignatureMintERC721Upgradeable, + ERC2771ContextUpgradeable, + Multicall, + ERC721AUpgradeable +{ + using StringsUpgradeable for uint256; + + /*/////////////////////////////////////////////////////////////// + State variables + //////////////////////////////////////////////////////////////*/ + + /// @dev Only transfers to or from TRANSFER_ROLE holders are valid, when transfers are restricted. + bytes32 private transferRole; + /// @dev Only MINTER_ROLE holders can sign off on `MintRequest`s and lazy mint tokens. + bytes32 private minterRole; + + /// @dev Max bps in the thirdweb system. + uint256 private constant MAX_BPS = 10_000; + + /*/////////////////////////////////////////////////////////////// + Constructor + initializer logic + //////////////////////////////////////////////////////////////*/ + + /// @dev Initializes the contract, like a constructor. + function initialize( + address _defaultAdmin, + string memory _name, + string memory _symbol, + string memory _contractURI, + address[] memory _trustedForwarders, + address _saleRecipient, + address _royaltyRecipient, + uint128 _royaltyBps, + uint128 _platformFeeBps, + address _platformFeeRecipient + ) external initializer { + transferRole = keccak256("TRANSFER_ROLE"); + minterRole = keccak256("MINTER_ROLE"); + + // Initialize inherited contracts, most base-like -> most derived. + __ERC2771Context_init(_trustedForwarders); + __ERC721A_init(_name, _symbol); + __SignatureMintERC721_init(); + + _setupContractURI(_contractURI); + _setupOwner(_defaultAdmin); + + _setupRole(DEFAULT_ADMIN_ROLE, _defaultAdmin); + _setupRole(minterRole, _defaultAdmin); + _setupRole(transferRole, _defaultAdmin); + _setupRole(transferRole, address(0)); + + _setupPlatformFeeInfo(_platformFeeRecipient, _platformFeeBps); + _setupDefaultRoyaltyInfo(_royaltyRecipient, _royaltyBps); + _setupPrimarySaleRecipient(_saleRecipient); + } + + /*/////////////////////////////////////////////////////////////// + ERC 165 / 721 / 2981 logic + //////////////////////////////////////////////////////////////*/ + + /// @dev Returns the URI for a given tokenId. + function tokenURI(uint256 _tokenId) public view override returns (string memory) { + (uint256 batchId, ) = _getBatchId(_tokenId); + string memory batchUri = _getBaseURI(_tokenId); + + if (isEncryptedBatch(batchId)) { + return string(abi.encodePacked(batchUri, "0")); + } else { + return string(abi.encodePacked(batchUri, _tokenId.toString())); + } + } + + /// @dev See ERC 165 + function supportsInterface( + bytes4 interfaceId + ) public view virtual override(ERC721AUpgradeable, IERC165) returns (bool) { + return super.supportsInterface(interfaceId) || type(IERC2981Upgradeable).interfaceId == interfaceId; + } + + function contractType() external pure returns (bytes32) { + return bytes32("SignatureDrop"); + } + + function contractVersion() external pure returns (uint8) { + return uint8(4); + } + + /*/////////////////////////////////////////////////////////////// + Lazy minting + delayed-reveal logic + //////////////////////////////////////////////////////////////*/ + + /** + * @dev Lets an account with `MINTER_ROLE` lazy mint 'n' NFTs. + * The URIs for each token is the provided `_baseURIForTokens` + `{tokenId}`. + */ + function lazyMint( + uint256 _amount, + string calldata _baseURIForTokens, + bytes calldata _data + ) public override returns (uint256 batchId) { + if (_data.length > 0) { + (bytes memory encryptedURI, bytes32 provenanceHash) = abi.decode(_data, (bytes, bytes32)); + if (encryptedURI.length != 0 && provenanceHash != "") { + _setEncryptedData(nextTokenIdToLazyMint + _amount, _data); + } + } + + return super.lazyMint(_amount, _baseURIForTokens, _data); + } + + /// @dev Lets an account with `MINTER_ROLE` reveal the URI for a batch of 'delayed-reveal' NFTs. + function reveal( + uint256 _index, + bytes calldata _key + ) external onlyRole(minterRole) returns (string memory revealedURI) { + uint256 batchId = getBatchIdAtIndex(_index); + revealedURI = getRevealURI(batchId, _key); + + _setEncryptedData(batchId, ""); + _setBaseURI(batchId, revealedURI); + + emit TokenURIRevealed(_index, revealedURI); + } + + /*/////////////////////////////////////////////////////////////// + Claiming lazy minted tokens logic + //////////////////////////////////////////////////////////////*/ + + /// @dev Claim lazy minted tokens via signature. + function mintWithSignature( + MintRequest calldata _req, + bytes calldata _signature + ) external payable returns (address signer) { + uint256 tokenIdToMint = _currentIndex; + if (tokenIdToMint + _req.quantity > nextTokenIdToLazyMint) { + revert("Not enough tokens"); + } + + // Verify and process payload. + signer = _processRequest(_req, _signature); + + address receiver = _req.to; + + // Collect price + _collectPriceOnClaim(_req.primarySaleRecipient, _req.quantity, _req.currency, _req.pricePerToken); + + // Set royalties, if applicable. + if (_req.royaltyRecipient != address(0) && _req.royaltyBps != 0) { + _setupRoyaltyInfoForToken(tokenIdToMint, _req.royaltyRecipient, _req.royaltyBps); + } + + // Mint tokens. + _safeMint(receiver, _req.quantity); + + emit TokensMintedWithSignature(signer, receiver, tokenIdToMint, _req); + } + + /*/////////////////////////////////////////////////////////////// + Internal functions + //////////////////////////////////////////////////////////////*/ + + /// @dev Runs before every `claim` function call. + function _beforeClaim( + address, + uint256 _quantity, + address, + uint256, + AllowlistProof calldata, + bytes memory + ) internal view override { + bool bot = isTrustedForwarder(msg.sender) || _msgSender() == tx.origin; + require(bot, "BOT"); + require(_currentIndex + _quantity <= nextTokenIdToLazyMint, "Not enough tokens"); + } + + /// @dev Collects and distributes the primary sale value of NFTs being claimed. + function _collectPriceOnClaim( + address _primarySaleRecipient, + uint256 _quantityToClaim, + address _currency, + uint256 _pricePerToken + ) internal override { + if (_pricePerToken == 0) { + return; + } + + (address platformFeeRecipient, uint16 platformFeeBps) = getPlatformFeeInfo(); + + address saleRecipient = _primarySaleRecipient == address(0) ? primarySaleRecipient() : _primarySaleRecipient; + + uint256 totalPrice = _quantityToClaim * _pricePerToken; + uint256 platformFees = (totalPrice * platformFeeBps) / MAX_BPS; + + if (_currency == CurrencyTransferLib.NATIVE_TOKEN) { + if (msg.value != totalPrice) { + revert("Must send total price"); + } + } + + CurrencyTransferLib.transferCurrency(_currency, _msgSender(), platformFeeRecipient, platformFees); + CurrencyTransferLib.transferCurrency(_currency, _msgSender(), saleRecipient, totalPrice - platformFees); + } + + /// @dev Transfers the NFTs being claimed. + function _transferTokensOnClaim( + address _to, + uint256 _quantityBeingClaimed + ) internal override returns (uint256 startTokenId) { + startTokenId = _currentIndex; + _safeMint(_to, _quantityBeingClaimed); + } + + /// @dev Returns whether a given address is authorized to sign mint requests. + function _isAuthorizedSigner(address _signer) internal view override returns (bool) { + return hasRole(minterRole, _signer); + } + + /// @dev Checks whether platform fee info can be set in the given execution context. + function _canSetPlatformFeeInfo() internal view override returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + /// @dev Checks whether primary sale recipient can be set in the given execution context. + function _canSetPrimarySaleRecipient() internal view override returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + /// @dev Checks whether owner can be set in the given execution context. + function _canSetOwner() internal view override returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + /// @dev Checks whether royalty info can be set in the given execution context. + function _canSetRoyaltyInfo() internal view override returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + /// @dev Checks whether contract metadata can be set in the given execution context. + function _canSetContractURI() internal view override returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + /// @dev Checks whether platform fee info can be set in the given execution context. + function _canSetClaimConditions() internal view override returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + /// @dev Returns whether lazy minting can be done in the given execution context. + function _canLazyMint() internal view virtual override returns (bool) { + return hasRole(minterRole, _msgSender()); + } + + /*/////////////////////////////////////////////////////////////// + Miscellaneous + //////////////////////////////////////////////////////////////*/ + + /** + * Returns the total amount of tokens minted in the contract. + */ + function totalMinted() external view returns (uint256) { + unchecked { + return _currentIndex - _startTokenId(); + } + } + + /// @dev The tokenId of the next NFT that will be minted / lazy minted. + function nextTokenIdToMint() external view returns (uint256) { + return nextTokenIdToLazyMint; + } + + /// @dev Burns `tokenId`. See {ERC721-_burn}. + function burn(uint256 tokenId) external virtual { + // note: ERC721AUpgradeable's `_burn(uint256,bool)` internally checks for token approvals. + _burn(tokenId, true); + } + + /// @dev See {ERC721-_beforeTokenTransfer}. + function _beforeTokenTransfers( + address from, + address to, + uint256 startTokenId, + uint256 quantity + ) internal virtual override { + super._beforeTokenTransfers(from, to, startTokenId, quantity); + + // if transfer is restricted on the contract, we still want to allow burning and minting + if (!hasRole(transferRole, address(0)) && from != address(0) && to != address(0)) { + if (!hasRole(transferRole, from) && !hasRole(transferRole, to)) { + revert("!Transfer-Role"); + } + } + } + + function _dropMsgSender() internal view virtual override returns (address) { + return _msgSender(); + } + + function _msgSender() + internal + view + virtual + override(ContextUpgradeable, ERC2771ContextUpgradeable, Multicall) + returns (address sender) + { + return ERC2771ContextUpgradeable._msgSender(); + } + + function _msgData() + internal + view + virtual + override(ContextUpgradeable, ERC2771ContextUpgradeable) + returns (bytes calldata) + { + return ERC2771ContextUpgradeable._msgData(); + } +} diff --git a/contracts/legacy-contracts/smart-wallet/interface/IAccountPermissions_V1.sol b/contracts/legacy-contracts/smart-wallet/interface/IAccountPermissions_V1.sol new file mode 100644 index 000000000..3a2124861 --- /dev/null +++ b/contracts/legacy-contracts/smart-wallet/interface/IAccountPermissions_V1.sol @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +interface IAccountPermissions_V1 { + /*/////////////////////////////////////////////////////////////// + Types + //////////////////////////////////////////////////////////////*/ + + /** + * @notice The payload that must be signed by an authorized wallet to set permissions for a signer to use the smart wallet. + * + * @param signer The addres of the signer to give permissions. + * @param approvedTargets The list of approved targets that a role holder can call using the smart wallet. + * @param nativeTokenLimitPerTransaction The maximum value that can be transferred by a role holder in a single transaction. + * @param permissionStartTimestamp The UNIX timestamp at and after which a signer has permission to use the smart wallet. + * @param permissionEndTimestamp The UNIX timestamp at and after which a signer no longer has permission to use the smart wallet. + * @param reqValidityStartTimestamp The UNIX timestamp at and after which a signature is valid. + * @param reqValidityEndTimestamp The UNIX timestamp at and after which a signature is invalid/expired. + * @param uid A unique non-repeatable ID for the payload. + */ + struct SignerPermissionRequest { + address signer; + address[] approvedTargets; + uint256 nativeTokenLimitPerTransaction; + uint128 permissionStartTimestamp; + uint128 permissionEndTimestamp; + uint128 reqValidityStartTimestamp; + uint128 reqValidityEndTimestamp; + bytes32 uid; + } + + /** + * @notice The permissions that a signer has to use the smart wallet. + * + * @param signer The address of the signer. + * @param approvedTargets The list of approved targets that a role holder can call using the smart wallet. + * @param nativeTokenLimitPerTransaction The maximum value that can be transferred by a role holder in a single transaction. + * @param startTimestamp The UNIX timestamp at and after which a signer has permission to use the smart wallet. + * @param endTimestamp The UNIX timestamp at and after which a signer no longer has permission to use the smart wallet. + */ + struct SignerPermissions { + address signer; + address[] approvedTargets; + uint256 nativeTokenLimitPerTransaction; + uint128 startTimestamp; + uint128 endTimestamp; + } + + /** + * @notice Internal struct for storing permissions for a signer (without approved targets). + * + * @param nativeTokenLimitPerTransaction The maximum value that can be transferred by a role holder in a single transaction. + * @param startTimestamp The UNIX timestamp at and after which a signer has permission to use the smart wallet. + * @param endTimestamp The UNIX timestamp at and after which a signer no longer has permission to use the smart wallet. + */ + struct SignerPermissionsStatic { + uint256 nativeTokenLimitPerTransaction; + uint128 startTimestamp; + uint128 endTimestamp; + } + + /*/////////////////////////////////////////////////////////////// + Events + //////////////////////////////////////////////////////////////*/ + + /// @notice Emitted when permissions for a signer are updated. + event SignerPermissionsUpdated( + address indexed authorizingSigner, + address indexed targetSigner, + SignerPermissionRequest permissions + ); + + /// @notice Emitted when an admin is set or removed. + event AdminUpdated(address indexed signer, bool isAdmin); + + /*/////////////////////////////////////////////////////////////// + View functions + //////////////////////////////////////////////////////////////*/ + + /// @notice Returns whether the given account is an admin. + function isAdmin(address signer) external view returns (bool); + + /// @notice Returns whether the given account is an active signer on the account. + function isActiveSigner(address signer) external view returns (bool); + + /// @notice Returns the restrictions under which a signer can use the smart wallet. + function getPermissionsForSigner(address signer) external view returns (SignerPermissions memory permissions); + + /// @notice Returns all active and inactive signers of the account. + function getAllSigners() external view returns (SignerPermissions[] memory signers); + + /// @notice Returns all signers with active permissions to use the account. + function getAllActiveSigners() external view returns (SignerPermissions[] memory signers); + + /// @notice Returns all admins of the account. + function getAllAdmins() external view returns (address[] memory admins); + + /// @dev Verifies that a request is signed by an authorized account. + function verifySignerPermissionRequest( + SignerPermissionRequest calldata req, + bytes calldata signature + ) external view returns (bool success, address signer); + + /*/////////////////////////////////////////////////////////////// + External functions + //////////////////////////////////////////////////////////////*/ + + /// @notice Adds / removes an account as an admin. + function setAdmin(address account, bool isAdmin) external; + + /// @notice Sets the permissions for a given signer. + function setPermissionsForSigner(SignerPermissionRequest calldata req, bytes calldata signature) external; +} diff --git a/contracts/lib/Address.sol b/contracts/lib/Address.sol new file mode 100644 index 000000000..bd1e6ce2f --- /dev/null +++ b/contracts/lib/Address.sol @@ -0,0 +1,244 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.1; + +/// @author thirdweb, OpenZeppelin Contracts (v4.9.0) + +/** + * @dev Collection of functions related to the address type + */ +library Address { + /** + * @dev Returns true if `account` is a contract. + * + * [IMPORTANT] + * ==== + * It is unsafe to assume that an address for which this function returns + * false is an externally-owned account (EOA) and not a contract. + * + * Among others, `isContract` will return false for the following + * types of addresses: + * + * - an externally-owned account + * - a contract in construction + * - an address where a contract will be created + * - an address where a contract lived, but was destroyed + * + * Furthermore, `isContract` will also return true if the target contract within + * the same transaction is already scheduled for destruction by `SELFDESTRUCT`, + * which only has an effect at the end of a transaction. + * ==== + * + * [IMPORTANT] + * ==== + * You shouldn't rely on `isContract` to protect against flash loan attacks! + * + * Preventing calls from contracts is highly discouraged. It breaks composability, breaks support for smart wallets + * like Gnosis Safe, and does not provide security since it can be circumvented by calling from a contract + * constructor. + * ==== + */ + function isContract(address account) internal view returns (bool) { + // This method relies on extcodesize/address.code.length, which returns 0 + // for contracts in construction, since the code is only stored at the end + // of the constructor execution. + + return account.code.length > 0; + } + + /** + * @dev Replacement for Solidity's `transfer`: sends `amount` wei to + * `recipient`, forwarding all available gas and reverting on errors. + * + * https://eips.ethereum.org/EIPS/eip-1884[EIP1884] increases the gas cost + * of certain opcodes, possibly making contracts go over the 2300 gas limit + * imposed by `transfer`, making them unable to receive funds via + * `transfer`. {sendValue} removes this limitation. + * + * https://consensys.net/diligence/blog/2019/09/stop-using-soliditys-transfer-now/[Learn more]. + * + * IMPORTANT: because control is transferred to `recipient`, care must be + * taken to not create reentrancy vulnerabilities. Consider using + * {ReentrancyGuard} or the + * https://solidity.readthedocs.io/en/v0.8.0/security-considerations.html#use-the-checks-effects-interactions-pattern[checks-effects-interactions pattern]. + */ + function sendValue(address payable recipient, uint256 amount) internal { + require(address(this).balance >= amount, "Address: insufficient balance"); + + (bool success, ) = recipient.call{ value: amount }(""); + require(success, "Address: unable to send value, recipient may have reverted"); + } + + /** + * @dev Performs a Solidity function call using a low level `call`. A + * plain `call` is an unsafe replacement for a function call: use this + * function instead. + * + * If `target` reverts with a revert reason, it is bubbled up by this + * function (like regular Solidity function calls). + * + * Returns the raw returned data. To convert to the expected return value, + * use https://solidity.readthedocs.io/en/latest/units-and-global-variables.html?highlight=abi.decode#abi-encoding-and-decoding-functions[`abi.decode`]. + * + * Requirements: + * + * - `target` must be a contract. + * - calling `target` with `data` must not revert. + * + * _Available since v3.1._ + */ + function functionCall(address target, bytes memory data) internal returns (bytes memory) { + return functionCallWithValue(target, data, 0, "Address: low-level call failed"); + } + + /** + * @dev Same as {xref-Address-functionCall-address-bytes-}[`functionCall`], but with + * `errorMessage` as a fallback revert reason when `target` reverts. + * + * _Available since v3.1._ + */ + function functionCall( + address target, + bytes memory data, + string memory errorMessage + ) internal returns (bytes memory) { + return functionCallWithValue(target, data, 0, errorMessage); + } + + /** + * @dev Same as {xref-Address-functionCall-address-bytes-}[`functionCall`], + * but also transferring `value` wei to `target`. + * + * Requirements: + * + * - the calling contract must have an ETH balance of at least `value`. + * - the called Solidity function must be `payable`. + * + * _Available since v3.1._ + */ + function functionCallWithValue(address target, bytes memory data, uint256 value) internal returns (bytes memory) { + return functionCallWithValue(target, data, value, "Address: low-level call with value failed"); + } + + /** + * @dev Same as {xref-Address-functionCallWithValue-address-bytes-uint256-}[`functionCallWithValue`], but + * with `errorMessage` as a fallback revert reason when `target` reverts. + * + * _Available since v3.1._ + */ + function functionCallWithValue( + address target, + bytes memory data, + uint256 value, + string memory errorMessage + ) internal returns (bytes memory) { + require(address(this).balance >= value, "Address: insufficient balance for call"); + (bool success, bytes memory returndata) = target.call{ value: value }(data); + return verifyCallResultFromTarget(target, success, returndata, errorMessage); + } + + /** + * @dev Same as {xref-Address-functionCall-address-bytes-}[`functionCall`], + * but performing a static call. + * + * _Available since v3.3._ + */ + function functionStaticCall(address target, bytes memory data) internal view returns (bytes memory) { + return functionStaticCall(target, data, "Address: low-level static call failed"); + } + + /** + * @dev Same as {xref-Address-functionCall-address-bytes-string-}[`functionCall`], + * but performing a static call. + * + * _Available since v3.3._ + */ + function functionStaticCall( + address target, + bytes memory data, + string memory errorMessage + ) internal view returns (bytes memory) { + (bool success, bytes memory returndata) = target.staticcall(data); + return verifyCallResultFromTarget(target, success, returndata, errorMessage); + } + + /** + * @dev Same as {xref-Address-functionCall-address-bytes-}[`functionCall`], + * but performing a delegate call. + * + * _Available since v3.4._ + */ + function functionDelegateCall(address target, bytes memory data) internal returns (bytes memory) { + return functionDelegateCall(target, data, "Address: low-level delegate call failed"); + } + + /** + * @dev Same as {xref-Address-functionCall-address-bytes-string-}[`functionCall`], + * but performing a delegate call. + * + * _Available since v3.4._ + */ + function functionDelegateCall( + address target, + bytes memory data, + string memory errorMessage + ) internal returns (bytes memory) { + (bool success, bytes memory returndata) = target.delegatecall(data); + return verifyCallResultFromTarget(target, success, returndata, errorMessage); + } + + /** + * @dev Tool to verify that a low level call to smart-contract was successful, and revert (either by bubbling + * the revert reason or using the provided one) in case of unsuccessful call or if target was not a contract. + * + * _Available since v4.8._ + */ + function verifyCallResultFromTarget( + address target, + bool success, + bytes memory returndata, + string memory errorMessage + ) internal view returns (bytes memory) { + if (success) { + if (returndata.length == 0) { + // only check isContract if the call was successful and the return data is empty + // otherwise we already know that it was a contract + require(isContract(target), "Address: call to non-contract"); + } + return returndata; + } else { + _revert(returndata, errorMessage); + } + } + + /** + * @dev Tool to verify that a low level call was successful, and revert if it wasn't, either by bubbling the + * revert reason or using the provided one. + * + * _Available since v4.3._ + */ + function verifyCallResult( + bool success, + bytes memory returndata, + string memory errorMessage + ) internal pure returns (bytes memory) { + if (success) { + return returndata; + } else { + _revert(returndata, errorMessage); + } + } + + function _revert(bytes memory returndata, string memory errorMessage) private pure { + // Look for revert reason and bubble it up if present + if (returndata.length > 0) { + // The easiest way to bubble the revert reason is using memory via assembly + /// @solidity memory-safe-assembly + assembly { + let returndata_size := mload(returndata) + revert(add(32, returndata), returndata_size) + } + } else { + revert(errorMessage); + } + } +} diff --git a/contracts/lib/BitMaps.sol b/contracts/lib/BitMaps.sol new file mode 100644 index 000000000..00336dc07 --- /dev/null +++ b/contracts/lib/BitMaps.sol @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +/** + * @dev Library for managing uint256 to bool mapping in a compact and efficient way, providing the keys are sequential. + * Largely inspired by Uniswap's [merkle-distributor](https://github.com/Uniswap/merkle-distributor/blob/master/contracts/MerkleDistributor.sol). + */ +library BitMaps { + struct BitMap { + mapping(uint256 => uint256) _data; + } + + /** + * @dev Returns whether the bit at `index` is set. + */ + function get(BitMap storage bitmap, uint256 index) internal view returns (bool) { + uint256 bucket = index >> 8; + uint256 mask = 1 << (index & 0xff); + return bitmap._data[bucket] & mask != 0; + } + + /** + * @dev Sets the bit at `index` to the boolean `value`. + */ + function setTo(BitMap storage bitmap, uint256 index, bool value) internal { + if (value) { + set(bitmap, index); + } else { + unset(bitmap, index); + } + } + + /** + * @dev Sets the bit at `index`. + */ + function set(BitMap storage bitmap, uint256 index) internal { + uint256 bucket = index >> 8; + uint256 mask = 1 << (index & 0xff); + bitmap._data[bucket] |= mask; + } + + /** + * @dev Unsets the bit at `index`. + */ + function unset(BitMap storage bitmap, uint256 index) internal { + uint256 bucket = index >> 8; + uint256 mask = 1 << (index & 0xff); + bitmap._data[bucket] &= ~mask; + } +} diff --git a/contracts/lib/BytesLib.sol b/contracts/lib/BytesLib.sol new file mode 100644 index 000000000..6cc1ebdaf --- /dev/null +++ b/contracts/lib/BytesLib.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb +/// Credits: https://github.com/GNSPS/solidity-bytes-utils/blob/master/contracts/BytesLib.sol + +library BytesLib { + function toAddress(bytes memory _bytes, uint256 _start) internal pure returns (address) { + require(_bytes.length >= _start + 20, "toAddress_outOfBounds"); + address tempAddress; + + assembly { + tempAddress := div(mload(add(add(_bytes, 0x20), _start)), 0x1000000000000000000000000) + } + + return tempAddress; + } +} diff --git a/contracts/lib/CurrencyTransferLib.sol b/contracts/lib/CurrencyTransferLib.sol new file mode 100644 index 000000000..531020ffa --- /dev/null +++ b/contracts/lib/CurrencyTransferLib.sol @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +// Helper interfaces +import { IWETH } from "../infra/interface/IWETH.sol"; +import { SafeERC20, IERC20 } from "../external-deps/openzeppelin/token/ERC20/utils/SafeERC20.sol"; + +library CurrencyTransferLib { + using SafeERC20 for IERC20; + + error CurrencyTransferLibMismatchedValue(uint256 expected, uint256 actual); + error CurrencyTransferLibFailedNativeTransfer(address recipient, uint256 value); + + /// @dev The address interpreted as native token of the chain. + address public constant NATIVE_TOKEN = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + + /// @dev Transfers a given amount of currency. + function transferCurrency(address _currency, address _from, address _to, uint256 _amount) internal { + if (_amount == 0) { + return; + } + + if (_currency == NATIVE_TOKEN) { + safeTransferNativeToken(_to, _amount); + } else { + safeTransferERC20(_currency, _from, _to, _amount); + } + } + + /// @dev Transfers a given amount of currency. (With native token wrapping) + function transferCurrencyWithWrapper( + address _currency, + address _from, + address _to, + uint256 _amount, + address _nativeTokenWrapper + ) internal { + if (_amount == 0) { + return; + } + + if (_currency == NATIVE_TOKEN) { + if (_from == address(this)) { + // withdraw from weth then transfer withdrawn native token to recipient + IWETH(_nativeTokenWrapper).withdraw(_amount); + safeTransferNativeTokenWithWrapper(_to, _amount, _nativeTokenWrapper); + } else if (_to == address(this)) { + // store native currency in weth + if (_amount != msg.value) { + revert CurrencyTransferLibMismatchedValue(msg.value, _amount); + } + IWETH(_nativeTokenWrapper).deposit{ value: _amount }(); + } else { + safeTransferNativeTokenWithWrapper(_to, _amount, _nativeTokenWrapper); + } + } else { + safeTransferERC20(_currency, _from, _to, _amount); + } + } + + /// @dev Transfer `amount` of ERC20 token from `from` to `to`. + function safeTransferERC20(address _currency, address _from, address _to, uint256 _amount) internal { + if (_from == _to) { + return; + } + + if (_from == address(this)) { + IERC20(_currency).safeTransfer(_to, _amount); + } else { + IERC20(_currency).safeTransferFrom(_from, _to, _amount); + } + } + + /// @dev Transfers `amount` of native token to `to`. + function safeTransferNativeToken(address to, uint256 value) internal { + // solhint-disable avoid-low-level-calls + // slither-disable-next-line low-level-calls + (bool success, ) = to.call{ value: value }(""); + if (!success) { + revert CurrencyTransferLibFailedNativeTransfer(to, value); + } + } + + /// @dev Transfers `amount` of native token to `to`. (With native token wrapping) + function safeTransferNativeTokenWithWrapper(address to, uint256 value, address _nativeTokenWrapper) internal { + // solhint-disable avoid-low-level-calls + // slither-disable-next-line low-level-calls + (bool success, ) = to.call{ value: value }(""); + if (!success) { + IWETH(_nativeTokenWrapper).deposit{ value: value }(); + IERC20(_nativeTokenWrapper).safeTransfer(to, value); + } + } +} diff --git a/contracts/lib/FeeType.sol b/contracts/lib/FeeType.sol new file mode 100644 index 000000000..12f77227e --- /dev/null +++ b/contracts/lib/FeeType.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/// @author thirdweb + +library FeeType { + uint256 internal constant PRIMARY_SALE = 0; + uint256 internal constant MARKET_SALE = 1; + uint256 internal constant SPLIT = 2; +} diff --git a/contracts/lib/MerkleProof.sol b/contracts/lib/MerkleProof.sol new file mode 100644 index 000000000..f8a703761 --- /dev/null +++ b/contracts/lib/MerkleProof.sol @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: Apache 2.0 +pragma solidity ^0.8.0; + +/// @author OpenZeppelin, thirdweb + +library MerkleProof { + function verify(bytes32[] calldata proof, bytes32 root, bytes32 leaf) internal pure returns (bool, uint256) { + bytes32 computedHash = leaf; + uint256 index = 0; + + for (uint256 i = 0; i < proof.length; i++) { + index *= 2; + bytes32 proofElement = proof[i]; + + if (computedHash <= proofElement) { + // Hash(current computed hash + current element of the proof) + computedHash = _efficientHash(computedHash, proofElement); + } else { + // Hash(current element of the proof + current computed hash) + computedHash = _efficientHash(proofElement, computedHash); + index += 1; + } + } + + // Check if the computed hash (root) is equal to the provided root + return (computedHash == root, index); + } + + /** + * @dev Implementation of keccak256(abi.encode(a, b)) that doesn't allocate or expand memory. + */ + function _efficientHash(bytes32 a, bytes32 b) private pure returns (bytes32 value) { + /// @solidity memory-safe-assembly + assembly { + mstore(0x00, a) + mstore(0x20, b) + value := keccak256(0x00, 0x40) + } + } +} diff --git a/contracts/lib/NFTMetadataRenderer.sol b/contracts/lib/NFTMetadataRenderer.sol new file mode 100644 index 000000000..4fd657159 --- /dev/null +++ b/contracts/lib/NFTMetadataRenderer.sol @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/* solhint-disable quotes */ + +/// @author thirdweb +/// credits: Zora + +import "./Strings.sol"; +import "../external-deps/openzeppelin/utils/Base64.sol"; + +/// NFT metadata library for rendering metadata associated with editions +library NFTMetadataRenderer { + /** + * @notice Generate edition metadata from storage information as base64-json blob + * @dev Combines the media data and metadata + * @param name Name of NFT in metadata + * @param description Description of NFT in metadata + * @param imageURI URI of image to render for edition + * @param animationURI URI of animation to render for edition + * @param tokenOfEdition Token ID for specific token + */ + function createMetadataEdition( + string memory name, + string memory description, + string memory imageURI, + string memory animationURI, + uint256 tokenOfEdition + ) internal pure returns (string memory) { + string memory _tokenMediaData = tokenMediaData(imageURI, animationURI); + bytes memory json = createMetadataJSON(name, description, _tokenMediaData, tokenOfEdition); + return encodeMetadataJSON(json); + } + + /** + * @param name Name of NFT in metadata + * @param description Description of NFT in metadata + * @param mediaData Data for media to include in json object + * @param tokenOfEdition Token ID for specific token + */ + function createMetadataJSON( + string memory name, + string memory description, + string memory mediaData, + uint256 tokenOfEdition + ) internal pure returns (bytes memory) { + return + abi.encodePacked( + '{"name": "', + name, + " ", + Strings.toString(tokenOfEdition), + '", "', + 'description": "', + description, + '", "', + mediaData, + 'properties": {"number": ', + Strings.toString(tokenOfEdition), + ', "name": "', + name, + '"}}' + ); + } + + /// Encodes the argument json bytes into base64-data uri format + /// @param json Raw json to base64 and turn into a data-uri + function encodeMetadataJSON(bytes memory json) internal pure returns (string memory) { + return string(abi.encodePacked("data:application/json;base64,", Base64.encode(json))); + } + + /// Generates edition metadata from storage information as base64-json blob + /// Combines the media data and metadata + /// @param imageUrl URL of image to render for edition + /// @param animationUrl URL of animation to render for edition + function tokenMediaData(string memory imageUrl, string memory animationUrl) internal pure returns (string memory) { + bool hasImage = bytes(imageUrl).length > 0; + bool hasAnimation = bytes(animationUrl).length > 0; + if (hasImage && hasAnimation) { + return string(abi.encodePacked('image": "', imageUrl, '", "animation_url": "', animationUrl, '", "')); + } + if (hasImage) { + return string(abi.encodePacked('image": "', imageUrl, '", "')); + } + if (hasAnimation) { + return string(abi.encodePacked('animation_url": "', animationUrl, '", "')); + } + + return ""; + } +} diff --git a/contracts/lib/StorageSlot.sol b/contracts/lib/StorageSlot.sol new file mode 100644 index 000000000..9fe0bb02e --- /dev/null +++ b/contracts/lib/StorageSlot.sol @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +library StorageSlot { + struct AddressSlot { + address value; + } + + struct BooleanSlot { + bool value; + } + + struct Bytes32Slot { + bytes32 value; + } + + struct Uint256Slot { + uint256 value; + } + + /// @dev Returns an `AddressSlot` with member `value` located at `slot`. + function getAddressSlot(bytes32 slot) internal pure returns (AddressSlot storage r) { + /// @solidity memory-safe-assembly + assembly { + r.slot := slot + } + } + + /// @dev Returns an `BooleanSlot` with member `value` located at `slot`. + function getBooleanSlot(bytes32 slot) internal pure returns (BooleanSlot storage r) { + /// @solidity memory-safe-assembly + assembly { + r.slot := slot + } + } + + /// @dev Returns an `Bytes32Slot` with member `value` located at `slot`. + function getBytes32Slot(bytes32 slot) internal pure returns (Bytes32Slot storage r) { + /// @solidity memory-safe-assembly + assembly { + r.slot := slot + } + } + + /// @dev Returns an `Uint256Slot` with member `value` located at `slot`. + function getUint256Slot(bytes32 slot) internal pure returns (Uint256Slot storage r) { + /// @solidity memory-safe-assembly + assembly { + r.slot := slot + } + } +} diff --git a/contracts/lib/StringSet.sol b/contracts/lib/StringSet.sol new file mode 100644 index 000000000..630300bd1 --- /dev/null +++ b/contracts/lib/StringSet.sol @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +library StringSet { + /** + * @param _values storage of set values + * @param _indexes position of the value in the array + 1. (Note: index 0 means a value is not in the set.) + */ + struct Set { + string[] _values; + mapping(string => uint256) _indexes; + } + + /// @dev Add a value to a set. + /// Returns `true` if the value is not already present in set. + function _add(Set storage set, string memory value) private returns (bool) { + if (!_contains(set, value)) { + set._values.push(value); + + set._indexes[value] = set._values.length; + return true; + } else { + return false; + } + } + + /// @dev Removes a value from a set. + /// Returns `true` if the value was present and so, successfully removed from the set. + function _remove(Set storage set, string memory value) private returns (bool) { + uint256 valueIndex = set._indexes[value]; + + if (valueIndex != 0) { + uint256 toDeleteIndex = valueIndex - 1; + uint256 lastIndex = set._values.length - 1; + + if (lastIndex != toDeleteIndex) { + string memory lastValue = set._values[lastIndex]; + + set._values[toDeleteIndex] = lastValue; + + set._indexes[lastValue] = valueIndex; + } + + set._values.pop(); + + delete set._indexes[value]; + + return true; + } else { + return false; + } + } + + /// @dev Returns whether `value` is in the set. + function _contains(Set storage set, string memory value) private view returns (bool) { + return set._indexes[value] != 0; + } + + /// @dev Returns the number of elements in the set. + function _length(Set storage set) private view returns (uint256) { + return set._values.length; + } + + /// @dev Returns the element stored at position `index` in the set. + /// Note: the ordering of elements is not guaranteed to be fixed. It is unsafe to rely on + /// or compute based on the index of set elements. + function _at(Set storage set, uint256 index) private view returns (string memory) { + return set._values[index]; + } + + /// @dev Returns the values stored in the set. + function _values(Set storage set) private view returns (string[] memory) { + return set._values; + } + + /// @dev Add `value` to the set. + function add(Set storage set, string memory value) internal returns (bool) { + return _add(set, value); + } + + /// @dev Remove `value` from the set. + function remove(Set storage set, string memory value) internal returns (bool) { + return _remove(set, value); + } + + /// @dev Returns whether `value` is in the set. + function contains(Set storage set, string memory value) internal view returns (bool) { + return _contains(set, value); + } + + /// @dev Returns the number of elements in the set. + function length(Set storage set) internal view returns (uint256) { + return _length(set); + } + + /// @dev Returns the element stored at position `index` in the set. + function at(Set storage set, uint256 index) internal view returns (string memory) { + return _at(set, index); + } + + /// @dev Returns the values stored in the set. + function values(Set storage set) internal view returns (string[] memory) { + return _values(set); + } +} diff --git a/contracts/lib/Strings.sol b/contracts/lib/Strings.sol new file mode 100644 index 000000000..499df4583 --- /dev/null +++ b/contracts/lib/Strings.sol @@ -0,0 +1,195 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +/** + * @dev String operations. + */ +library Strings { + bytes16 private constant _HEX_SYMBOLS = "0123456789abcdef"; + + /** + * @dev Converts a `uint256` to its ASCII `string` decimal representation. + */ + function toString(uint256 value) internal pure returns (string memory) { + // Inspired by OraclizeAPI's implementation - MIT licence + // https://github.com/oraclize/ethereum-api/blob/b42146b063c7d6ee1358846c198246239e9360e8/oraclizeAPI_0.4.25.sol + + if (value == 0) { + return "0"; + } + uint256 temp = value; + uint256 digits; + while (temp != 0) { + digits++; + temp /= 10; + } + bytes memory buffer = new bytes(digits); + while (value != 0) { + digits -= 1; + buffer[digits] = bytes1(uint8(48 + uint256(value % 10))); + value /= 10; + } + return string(buffer); + } + + /** + * @dev Converts a `uint256` to its ASCII `string` hexadecimal representation. + */ + function toHexString(uint256 value) internal pure returns (string memory) { + if (value == 0) { + return "0x00"; + } + uint256 temp = value; + uint256 length = 0; + while (temp != 0) { + length++; + temp >>= 8; + } + return toHexString(value, length); + } + + /** + * @dev Converts a `uint256` to its ASCII `string` hexadecimal representation with fixed length. + */ + function toHexString(uint256 value, uint256 length) internal pure returns (string memory) { + bytes memory buffer = new bytes(2 * length + 2); + buffer[0] = "0"; + buffer[1] = "x"; + for (uint256 i = 2 * length + 1; i > 1; --i) { + buffer[i] = _HEX_SYMBOLS[value & 0xf]; + value >>= 4; + } + require(value == 0, "Strings: hex length insufficient"); + return string(buffer); + } + + /// @dev Returns the hexadecimal representation of `value`. + /// The output is prefixed with "0x", encoded using 2 hexadecimal digits per byte, + /// and the alphabets are capitalized conditionally according to + /// https://eips.ethereum.org/EIPS/eip-55 + function toHexStringChecksummed(address value) internal pure returns (string memory str) { + str = toHexString(value); + /// @solidity memory-safe-assembly + assembly { + let mask := shl(6, div(not(0), 255)) // `0b010000000100000000 ...` + let o := add(str, 0x22) + let hashed := and(keccak256(o, 40), mul(34, mask)) // `0b10001000 ... ` + let t := shl(240, 136) // `0b10001000 << 240` + for { + let i := 0 + } 1 { + + } { + mstore(add(i, i), mul(t, byte(i, hashed))) + i := add(i, 1) + if eq(i, 20) { + break + } + } + mstore(o, xor(mload(o), shr(1, and(mload(0x00), and(mload(o), mask))))) + o := add(o, 0x20) + mstore(o, xor(mload(o), shr(1, and(mload(0x20), and(mload(o), mask))))) + } + } + + /// @dev Returns the hexadecimal representation of `value`. + /// The output is prefixed with "0x" and encoded using 2 hexadecimal digits per byte. + function toHexString(address value) internal pure returns (string memory str) { + str = toHexStringNoPrefix(value); + /// @solidity memory-safe-assembly + assembly { + let strLength := add(mload(str), 2) // Compute the length. + mstore(str, 0x3078) // Write the "0x" prefix. + str := sub(str, 2) // Move the pointer. + mstore(str, strLength) // Write the length. + } + } + + /// @dev Returns the hexadecimal representation of `value`. + /// The output is encoded using 2 hexadecimal digits per byte. + function toHexStringNoPrefix(address value) internal pure returns (string memory str) { + /// @solidity memory-safe-assembly + assembly { + str := mload(0x40) + + // Allocate the memory. + // We need 0x20 bytes for the trailing zeros padding, 0x20 bytes for the length, + // 0x02 bytes for the prefix, and 0x28 bytes for the digits. + // The next multiple of 0x20 above (0x20 + 0x20 + 0x02 + 0x28) is 0x80. + mstore(0x40, add(str, 0x80)) + + // Store "0123456789abcdef" in scratch space. + mstore(0x0f, 0x30313233343536373839616263646566) + + str := add(str, 2) + mstore(str, 40) + + let o := add(str, 0x20) + mstore(add(o, 40), 0) + + value := shl(96, value) + + // We write the string from rightmost digit to leftmost digit. + // The following is essentially a do-while loop that also handles the zero case. + for { + let i := 0 + } 1 { + + } { + let p := add(o, add(i, i)) + let temp := byte(i, value) + mstore8(add(p, 1), mload(and(temp, 15))) + mstore8(p, mload(shr(4, temp))) + i := add(i, 1) + if eq(i, 20) { + break + } + } + } + } + + /// @dev Returns the hex encoded string from the raw bytes. + /// The output is encoded using 2 hexadecimal digits per byte. + function toHexString(bytes memory raw) internal pure returns (string memory str) { + str = toHexStringNoPrefix(raw); + /// @solidity memory-safe-assembly + assembly { + let strLength := add(mload(str), 2) // Compute the length. + mstore(str, 0x3078) // Write the "0x" prefix. + str := sub(str, 2) // Move the pointer. + mstore(str, strLength) // Write the length. + } + } + + /// @dev Returns the hex encoded string from the raw bytes. + /// The output is encoded using 2 hexadecimal digits per byte. + function toHexStringNoPrefix(bytes memory raw) internal pure returns (string memory str) { + /// @solidity memory-safe-assembly + assembly { + let length := mload(raw) + str := add(mload(0x40), 2) // Skip 2 bytes for the optional prefix. + mstore(str, add(length, length)) // Store the length of the output. + + // Store "0123456789abcdef" in scratch space. + mstore(0x0f, 0x30313233343536373839616263646566) + + let o := add(str, 0x20) + let end := add(raw, length) + + for { + + } iszero(eq(raw, end)) { + + } { + raw := add(raw, 1) + mstore8(add(o, 1), mload(and(mload(raw), 15))) + mstore8(o, mload(and(shr(4, mload(raw)), 15))) + o := add(o, 2) + } + mstore(o, 0) // Zeroize the slot after the string. + mstore(0x40, add(o, 0x20)) // Allocate the memory. + } + } +} diff --git a/contracts/package.json b/contracts/package.json new file mode 100644 index 000000000..afa0b661e --- /dev/null +++ b/contracts/package.json @@ -0,0 +1,29 @@ +{ + "name": "@thirdweb-dev/contracts", + "description": "Collection of smart contracts deployable via the thirdweb SDK, dashboard and CLI", + "version": "3.15.0", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/thirdweb-dev/contracts.git" + }, + "bugs": { + "url": "https://github.com/thirdweb-dev/contracts/issues" + }, + "author": "thirdweb engineering ", + "homepage": "https://thirdweb.com", + "files": [ + "**/*.sol", + "/abi" + ], + "dependencies": { + "@openzeppelin/contracts": "^4.9.3", + "@openzeppelin/contracts-upgradeable": "^4.9.3", + "erc721a-upgradeable": "^3.3.0", + "@thirdweb-dev/dynamic-contracts": "^1.2.4", + "solady": "0.0.180" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/contracts/prebuilts/airdrop/Airdrop.sol b/contracts/prebuilts/airdrop/Airdrop.sol new file mode 100644 index 000000000..b2865c9b2 --- /dev/null +++ b/contracts/prebuilts/airdrop/Airdrop.sol @@ -0,0 +1,616 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/// @author thirdweb + +// $$\ $$\ $$\ $$\ $$\ +// $$ | $$ | \__| $$ | $$ | +// $$$$$$\ $$$$$$$\ $$\ $$$$$$\ $$$$$$$ |$$\ $$\ $$\ $$$$$$\ $$$$$$$\ +// \_$$ _| $$ __$$\ $$ |$$ __$$\ $$ __$$ |$$ | $$ | $$ |$$ __$$\ $$ __$$\ +// $$ | $$ | $$ |$$ |$$ | \__|$$ / $$ |$$ | $$ | $$ |$$$$$$$$ |$$ | $$ | +// $$ |$$\ $$ | $$ |$$ |$$ | $$ | $$ |$$ | $$ | $$ |$$ ____|$$ | $$ | +// \$$$$ |$$ | $$ |$$ |$$ | \$$$$$$$ |\$$$$$\$$$$ |\$$$$$$$\ $$$$$$$ | +// \____/ \__| \__|\__|\__| \_______| \_____\____/ \_______|\_______/ + +import "solady/src/utils/MerkleProofLib.sol"; +import "solady/src/utils/ECDSA.sol"; +import "solady/src/utils/EIP712.sol"; +import "solady/src/utils/SafeTransferLib.sol"; +import "solady/src/utils/SignatureCheckerLib.sol"; + +import { Initializable } from "../../extension/Initializable.sol"; +import { Ownable } from "../../extension/Ownable.sol"; +import { ContractMetadata } from "../../extension/ContractMetadata.sol"; + +import "../../eip/interface/IERC20.sol"; +import "../../eip/interface/IERC721.sol"; +import "../../eip/interface/IERC1155.sol"; + +contract Airdrop is EIP712, Initializable, Ownable, ContractMetadata { + /*/////////////////////////////////////////////////////////////// + State, constants & structs + //////////////////////////////////////////////////////////////*/ + + /// @dev token contract address => conditionId + mapping(address => uint256) public tokenConditionId; + /// @dev token contract address => merkle root + mapping(address => bytes32) public tokenMerkleRoot; + /// @dev conditionId => hash(claimer address, token address, token id [1155]) => has claimed + mapping(uint256 => mapping(bytes32 => bool)) private claimed; + /// @dev Mapping from request UID => whether the request is processed. + mapping(bytes32 => bool) public processed; + + struct AirdropContentERC20 { + address recipient; + uint256 amount; + } + + struct AirdropContentERC721 { + address recipient; + uint256 tokenId; + } + + struct AirdropContentERC1155 { + address recipient; + uint256 tokenId; + uint256 amount; + } + + struct AirdropRequestERC20 { + bytes32 uid; + address tokenAddress; + uint256 expirationTimestamp; + AirdropContentERC20[] contents; + } + + struct AirdropRequestERC721 { + bytes32 uid; + address tokenAddress; + uint256 expirationTimestamp; + AirdropContentERC721[] contents; + } + + struct AirdropRequestERC1155 { + bytes32 uid; + address tokenAddress; + uint256 expirationTimestamp; + AirdropContentERC1155[] contents; + } + + bytes32 private constant CONTENT_TYPEHASH_ERC20 = + keccak256("AirdropContentERC20(address recipient,uint256 amount)"); + bytes32 private constant REQUEST_TYPEHASH_ERC20 = + keccak256( + "AirdropRequestERC20(bytes32 uid,address tokenAddress,uint256 expirationTimestamp,AirdropContentERC20[] contents)AirdropContentERC20(address recipient,uint256 amount)" + ); + + bytes32 private constant CONTENT_TYPEHASH_ERC721 = + keccak256("AirdropContentERC721(address recipient,uint256 tokenId)"); + bytes32 private constant REQUEST_TYPEHASH_ERC721 = + keccak256( + "AirdropRequestERC721(bytes32 uid,address tokenAddress,uint256 expirationTimestamp,AirdropContentERC721[] contents)AirdropContentERC721(address recipient,uint256 tokenId)" + ); + + bytes32 private constant CONTENT_TYPEHASH_ERC1155 = + keccak256("AirdropContentERC1155(address recipient,uint256 tokenId,uint256 amount)"); + bytes32 private constant REQUEST_TYPEHASH_ERC1155 = + keccak256( + "AirdropRequestERC1155(bytes32 uid,address tokenAddress,uint256 expirationTimestamp,AirdropContentERC1155[] contents)AirdropContentERC1155(address recipient,uint256 tokenId,uint256 amount)" + ); + + address private constant NATIVE_TOKEN_ADDRESS = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + + /*/////////////////////////////////////////////////////////////// + Errors + //////////////////////////////////////////////////////////////*/ + + error AirdropInvalidProof(); + error AirdropAlreadyClaimed(); + error AirdropNoMerkleRoot(); + error AirdropValueMismatch(); + error AirdropRequestExpired(uint256 expirationTimestamp); + error AirdropRequestAlreadyProcessed(); + error AirdropRequestInvalidSigner(); + + /*/////////////////////////////////////////////////////////////// + Events + //////////////////////////////////////////////////////////////*/ + + event Airdrop(address token); + event AirdropWithSignature(address token); + event AirdropClaimed(address token, address receiver); + + /*/////////////////////////////////////////////////////////////// + Constructor + //////////////////////////////////////////////////////////////*/ + + constructor() { + _disableInitializers(); + } + + function initialize(address _defaultAdmin, string memory _contractURI) external initializer { + _setupOwner(_defaultAdmin); + _setupContractURI(_contractURI); + } + + /*/////////////////////////////////////////////////////////////// + Airdrop Push + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Lets contract-owner send native token (eth) to a list of addresses. + * @dev Owner should send total airdrop amount as msg.value. + * Can only be called by contract owner. + * + * @param _contents List containing recipients and amounts to airdrop + */ + function airdropNativeToken(AirdropContentERC20[] calldata _contents) external payable onlyOwner { + uint256 len = _contents.length; + uint256 nativeTokenAmount; + + for (uint256 i = 0; i < len; i++) { + nativeTokenAmount += _contents[i].amount; + + SafeTransferLib.safeTransferETH(_contents[i].recipient, _contents[i].amount); + } + + if (nativeTokenAmount != msg.value) { + revert AirdropValueMismatch(); + } + + emit Airdrop(NATIVE_TOKEN_ADDRESS); + } + + /** + * @notice Lets contract owner send ERC20 tokens to a list of addresses. + * @dev The token-owner should approve total airdrop amount to this contract. + * Can only be called by contract owner. + * + * @param _tokenAddress Address of the ERC20 token being airdropped + * @param _contents List containing recipients and amounts to airdrop + */ + function airdropERC20(address _tokenAddress, AirdropContentERC20[] calldata _contents) external onlyOwner { + uint256 len = _contents.length; + + for (uint256 i = 0; i < len; i++) { + SafeTransferLib.safeTransferFrom(_tokenAddress, msg.sender, _contents[i].recipient, _contents[i].amount); + } + + emit Airdrop(_tokenAddress); + } + + /** + * @notice Lets contract owner send ERC721 tokens to a list of addresses. + * @dev The token-owner should approve airdrop tokenIds to this contract. + * Can only be called by contract owner. + * + * @param _tokenAddress Address of the ERC721 token being airdropped + * @param _contents List containing recipients and tokenIds to airdrop + */ + function airdropERC721(address _tokenAddress, AirdropContentERC721[] calldata _contents) external onlyOwner { + uint256 len = _contents.length; + + for (uint256 i = 0; i < len; i++) { + IERC721(_tokenAddress).safeTransferFrom(msg.sender, _contents[i].recipient, _contents[i].tokenId); + } + + emit Airdrop(_tokenAddress); + } + + /** + * @notice Lets contract owner send ERC1155 tokens to a list of addresses. + * @dev The token-owner should approve airdrop tokenIds and amounts to this contract. + * Can only be called by contract owner. + * + * @param _tokenAddress Address of the ERC1155 token being airdropped + * @param _contents List containing recipients, tokenIds, and amounts to airdrop + */ + function airdropERC1155(address _tokenAddress, AirdropContentERC1155[] calldata _contents) external onlyOwner { + uint256 len = _contents.length; + + for (uint256 i = 0; i < len; i++) { + IERC1155(_tokenAddress).safeTransferFrom( + msg.sender, + _contents[i].recipient, + _contents[i].tokenId, + _contents[i].amount, + "" + ); + } + + emit Airdrop(_tokenAddress); + } + + /*/////////////////////////////////////////////////////////////// + Airdrop With Signature + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Lets contract owner send ERC20 tokens to a list of addresses with EIP-712 signature. + * @dev The token-owner should approve airdrop amounts to this contract. + * Signer should be the contract owner. + * + * @param req Struct containing airdrop contents, uid, and expiration timestamp + * @param signature EIP-712 signature to perform the airdrop + */ + function airdropERC20WithSignature(AirdropRequestERC20 calldata req, bytes calldata signature) external { + // verify expiration timestamp + if (req.expirationTimestamp < block.timestamp) { + revert AirdropRequestExpired(req.expirationTimestamp); + } + + if (processed[req.uid]) { + revert AirdropRequestAlreadyProcessed(); + } + + // verify data + if (!_verifyRequestSignerERC20(req, signature)) { + revert AirdropRequestInvalidSigner(); + } + + processed[req.uid] = true; + + uint256 len = req.contents.length; + address _from = owner(); + + for (uint256 i = 0; i < len; i++) { + SafeTransferLib.safeTransferFrom( + req.tokenAddress, + _from, + req.contents[i].recipient, + req.contents[i].amount + ); + } + + emit AirdropWithSignature(req.tokenAddress); + } + + /** + * @notice Lets contract owner send ERC721 tokens to a list of addresses with EIP-712 signature. + * @dev The token-owner should approve airdrop tokenIds to this contract. + * Signer should be the contract owner. + * + * @param req Struct containing airdrop contents, uid, and expiration timestamp + * @param signature EIP-712 signature to perform the airdrop + */ + function airdropERC721WithSignature(AirdropRequestERC721 calldata req, bytes calldata signature) external { + // verify expiration timestamp + if (req.expirationTimestamp < block.timestamp) { + revert AirdropRequestExpired(req.expirationTimestamp); + } + + if (processed[req.uid]) { + revert AirdropRequestAlreadyProcessed(); + } + + // verify data + if (!_verifyRequestSignerERC721(req, signature)) { + revert AirdropRequestInvalidSigner(); + } + + processed[req.uid] = true; + + address _from = owner(); + uint256 len = req.contents.length; + + for (uint256 i = 0; i < len; i++) { + IERC721(req.tokenAddress).safeTransferFrom(_from, req.contents[i].recipient, req.contents[i].tokenId); + } + + emit AirdropWithSignature(req.tokenAddress); + } + + /** + * @notice Lets contract owner send ERC1155 tokens to a list of addresses with EIP-712 signature. + * @dev The token-owner should approve airdrop tokenIds and amounts to this contract. + * Signer should be the contract owner. + * + * @param req Struct containing airdrop contents, uid, and expiration timestamp + * @param signature EIP-712 signature to perform the airdrop + */ + function airdropERC1155WithSignature(AirdropRequestERC1155 calldata req, bytes calldata signature) external { + // verify expiration timestamp + if (req.expirationTimestamp < block.timestamp) { + revert AirdropRequestExpired(req.expirationTimestamp); + } + + if (processed[req.uid]) { + revert AirdropRequestAlreadyProcessed(); + } + + // verify data + if (!_verifyRequestSignerERC1155(req, signature)) { + revert AirdropRequestInvalidSigner(); + } + + processed[req.uid] = true; + + address _from = owner(); + uint256 len = req.contents.length; + + for (uint256 i = 0; i < len; i++) { + IERC1155(req.tokenAddress).safeTransferFrom( + _from, + req.contents[i].recipient, + req.contents[i].tokenId, + req.contents[i].amount, + "" + ); + } + + emit AirdropWithSignature(req.tokenAddress); + } + + /*/////////////////////////////////////////////////////////////// + Airdrop Claimable + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Lets allowlisted addresses claim ERC20 airdrop tokens. + * @dev The token-owner should approve total airdrop amount to this contract, + * and set merkle root of allowlisted address for this airdrop. + * + * @param _token Address of ERC20 airdrop token + * @param _receiver Allowlisted address for which the token is being claimed + * @param _quantity Allowlisted quantity of tokens to claim + * @param _proofs Merkle proofs for allowlist verification + */ + function claimERC20(address _token, address _receiver, uint256 _quantity, bytes32[] calldata _proofs) external { + bytes32 claimHash = _getClaimHashERC20(_receiver, _token); + uint256 conditionId = tokenConditionId[_token]; + + if (claimed[conditionId][claimHash]) { + revert AirdropAlreadyClaimed(); + } + + bytes32 _tokenMerkleRoot = tokenMerkleRoot[_token]; + if (_tokenMerkleRoot == bytes32(0)) { + revert AirdropNoMerkleRoot(); + } + + bool valid = MerkleProofLib.verifyCalldata( + _proofs, + _tokenMerkleRoot, + keccak256(abi.encodePacked(_receiver, _quantity)) + ); + if (!valid) { + revert AirdropInvalidProof(); + } + + claimed[conditionId][claimHash] = true; + + SafeTransferLib.safeTransferFrom(_token, owner(), _receiver, _quantity); + + emit AirdropClaimed(_token, _receiver); + } + + /** + * @notice Lets allowlisted addresses claim ERC721 airdrop tokens. + * @dev The token-owner should approve airdrop tokenIds to this contract, + * and set merkle root of allowlisted address for this airdrop. + * + * @param _token Address of ERC721 airdrop token + * @param _receiver Allowlisted address for which the token is being claimed + * @param _tokenId Allowlisted tokenId to claim + * @param _proofs Merkle proofs for allowlist verification + */ + function claimERC721(address _token, address _receiver, uint256 _tokenId, bytes32[] calldata _proofs) external { + bytes32 claimHash = _getClaimHashERC721(_receiver, _token, _tokenId); + uint256 conditionId = tokenConditionId[_token]; + + if (claimed[conditionId][claimHash]) { + revert AirdropAlreadyClaimed(); + } + + bytes32 _tokenMerkleRoot = tokenMerkleRoot[_token]; + if (_tokenMerkleRoot == bytes32(0)) { + revert AirdropNoMerkleRoot(); + } + + bool valid = MerkleProofLib.verifyCalldata( + _proofs, + _tokenMerkleRoot, + keccak256(abi.encodePacked(_receiver, _tokenId)) + ); + if (!valid) { + revert AirdropInvalidProof(); + } + + claimed[conditionId][claimHash] = true; + + IERC721(_token).safeTransferFrom(owner(), _receiver, _tokenId); + + emit AirdropClaimed(_token, _receiver); + } + + /** + * @notice Lets allowlisted addresses claim ERC1155 airdrop tokens. + * @dev The token-owner should approve tokenIds and total airdrop amounts to this contract, + * and set merkle root of allowlisted address for this airdrop. + * + * @param _token Address of ERC1155 airdrop token + * @param _receiver Allowlisted address for which the token is being claimed + * @param _tokenId Allowlisted tokenId to claim + * @param _quantity Allowlisted quantity of tokens to claim + * @param _proofs Merkle proofs for allowlist verification + */ + function claimERC1155( + address _token, + address _receiver, + uint256 _tokenId, + uint256 _quantity, + bytes32[] calldata _proofs + ) external { + bytes32 claimHash = _getClaimHashERC1155(_receiver, _token, _tokenId); + uint256 conditionId = tokenConditionId[_token]; + + if (claimed[conditionId][claimHash]) { + revert AirdropAlreadyClaimed(); + } + + bytes32 _tokenMerkleRoot = tokenMerkleRoot[_token]; + if (_tokenMerkleRoot == bytes32(0)) { + revert AirdropNoMerkleRoot(); + } + + bool valid = MerkleProofLib.verifyCalldata( + _proofs, + _tokenMerkleRoot, + keccak256(abi.encodePacked(_receiver, _tokenId, _quantity)) + ); + if (!valid) { + revert AirdropInvalidProof(); + } + + claimed[conditionId][claimHash] = true; + + IERC1155(_token).safeTransferFrom(owner(), _receiver, _tokenId, _quantity, ""); + + emit AirdropClaimed(_token, _receiver); + } + + /*/////////////////////////////////////////////////////////////// + Setter functions + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Lets contract owner set merkle root (allowlist) for claim based airdrops. + * + * @param _token Address of airdrop token + * @param _tokenMerkleRoot Merkle root of allowlist + * @param _resetClaimStatus Reset claim status / amount claimed so far to zero for all recipients + */ + function setMerkleRoot(address _token, bytes32 _tokenMerkleRoot, bool _resetClaimStatus) external onlyOwner { + if (_resetClaimStatus || tokenConditionId[_token] == 0) { + tokenConditionId[_token] += 1; + } + tokenMerkleRoot[_token] = _tokenMerkleRoot; + } + + /*/////////////////////////////////////////////////////////////// + Miscellaneous + //////////////////////////////////////////////////////////////*/ + + /// @notice Returns claim status of a receiver for a claim based airdrop + function isClaimed(address _receiver, address _token, uint256 _tokenId) external view returns (bool) { + uint256 _conditionId = tokenConditionId[_token]; + + bytes32 claimHash = keccak256(abi.encodePacked(_receiver, _token, _tokenId)); + if (claimed[_conditionId][claimHash]) { + return true; + } + + claimHash = keccak256(abi.encodePacked(_receiver, _token)); + if (claimed[_conditionId][claimHash]) { + return true; + } + + return false; + } + /// @dev Checks whether contract owner can be set in the given execution context. + function _canSetOwner() internal view virtual override returns (bool) { + return msg.sender == owner(); + } + + /// @dev Checks whether contract metadata can be set in the given execution context. + function _canSetContractURI() internal view virtual override returns (bool) { + return msg.sender == owner(); + } + + /// @dev Domain name and version for EIP-712 + function _domainNameAndVersion() internal pure override returns (string memory name, string memory version) { + name = "Airdrop"; + version = "1"; + } + + /// @dev Keccak256 hash of receiver and token addresses, for claim based airdrop status tracking + function _getClaimHashERC20(address _receiver, address _token) private view returns (bytes32) { + return keccak256(abi.encodePacked(_receiver, _token)); + } + + /// @dev Keccak256 hash of receiver, token address and tokenId, for claim based airdrop status tracking + function _getClaimHashERC721(address _receiver, address _token, uint256 _tokenId) private view returns (bytes32) { + return keccak256(abi.encodePacked(_receiver, _token, _tokenId)); + } + + /// @dev Keccak256 hash of receiver, token address and tokenId, for claim based airdrop status tracking + function _getClaimHashERC1155(address _receiver, address _token, uint256 _tokenId) private view returns (bytes32) { + return keccak256(abi.encodePacked(_receiver, _token, _tokenId)); + } + + /// @dev Hash nested struct within AirdropRequest___ + function _hashContentInfoERC20(AirdropContentERC20[] calldata contents) private pure returns (bytes32) { + bytes32[] memory contentHashes = new bytes32[](contents.length); + for (uint256 i = 0; i < contents.length; i++) { + contentHashes[i] = keccak256(abi.encode(CONTENT_TYPEHASH_ERC20, contents[i].recipient, contents[i].amount)); + } + return keccak256(abi.encodePacked(contentHashes)); + } + + /// @dev Hash nested struct within AirdropRequest___ + function _hashContentInfoERC721(AirdropContentERC721[] calldata contents) private pure returns (bytes32) { + bytes32[] memory contentHashes = new bytes32[](contents.length); + for (uint256 i = 0; i < contents.length; i++) { + contentHashes[i] = keccak256( + abi.encode(CONTENT_TYPEHASH_ERC721, contents[i].recipient, contents[i].tokenId) + ); + } + return keccak256(abi.encodePacked(contentHashes)); + } + + /// @dev Hash nested struct within AirdropRequest___ + function _hashContentInfoERC1155(AirdropContentERC1155[] calldata contents) private pure returns (bytes32) { + bytes32[] memory contentHashes = new bytes32[](contents.length); + for (uint256 i = 0; i < contents.length; i++) { + contentHashes[i] = keccak256( + abi.encode(CONTENT_TYPEHASH_ERC1155, contents[i].recipient, contents[i].tokenId, contents[i].amount) + ); + } + return keccak256(abi.encodePacked(contentHashes)); + } + + /// @dev Verify EIP-712 signature + function _verifyRequestSignerERC20( + AirdropRequestERC20 calldata req, + bytes calldata signature + ) private view returns (bool) { + bytes32 contentHash = _hashContentInfoERC20(req.contents); + bytes32 structHash = keccak256( + abi.encode(REQUEST_TYPEHASH_ERC20, req.uid, req.tokenAddress, req.expirationTimestamp, contentHash) + ); + + bytes32 digest = _hashTypedData(structHash); + + return SignatureCheckerLib.isValidSignatureNowCalldata(owner(), digest, signature); + } + + /// @dev Verify EIP-712 signature + function _verifyRequestSignerERC721( + AirdropRequestERC721 calldata req, + bytes calldata signature + ) private view returns (bool) { + bytes32 contentHash = _hashContentInfoERC721(req.contents); + bytes32 structHash = keccak256( + abi.encode(REQUEST_TYPEHASH_ERC721, req.uid, req.tokenAddress, req.expirationTimestamp, contentHash) + ); + + bytes32 digest = _hashTypedData(structHash); + + return SignatureCheckerLib.isValidSignatureNowCalldata(owner(), digest, signature); + } + + /// @dev Verify EIP-712 signature + function _verifyRequestSignerERC1155( + AirdropRequestERC1155 calldata req, + bytes calldata signature + ) private view returns (bool) { + bytes32 contentHash = _hashContentInfoERC1155(req.contents); + bytes32 structHash = keccak256( + abi.encode(REQUEST_TYPEHASH_ERC1155, req.uid, req.tokenAddress, req.expirationTimestamp, contentHash) + ); + + bytes32 digest = _hashTypedData(structHash); + + return SignatureCheckerLib.isValidSignatureNowCalldata(owner(), digest, signature); + } +} diff --git a/contracts/prebuilts/drop/DropERC1155.sol b/contracts/prebuilts/drop/DropERC1155.sol new file mode 100644 index 000000000..4d4e12a9f --- /dev/null +++ b/contracts/prebuilts/drop/DropERC1155.sol @@ -0,0 +1,380 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/// @author thirdweb + +// $$\ $$\ $$\ $$\ $$\ +// $$ | $$ | \__| $$ | $$ | +// $$$$$$\ $$$$$$$\ $$\ $$$$$$\ $$$$$$$ |$$\ $$\ $$\ $$$$$$\ $$$$$$$\ +// \_$$ _| $$ __$$\ $$ |$$ __$$\ $$ __$$ |$$ | $$ | $$ |$$ __$$\ $$ __$$\ +// $$ | $$ | $$ |$$ |$$ | \__|$$ / $$ |$$ | $$ | $$ |$$$$$$$$ |$$ | $$ | +// $$ |$$\ $$ | $$ |$$ |$$ | $$ | $$ |$$ | $$ | $$ |$$ ____|$$ | $$ | +// \$$$$ |$$ | $$ |$$ |$$ | \$$$$$$$ |\$$$$$\$$$$ |\$$$$$$$\ $$$$$$$ | +// \____/ \__| \__|\__|\__| \_______| \_____\____/ \_______|\_______/ + +// ========== External imports ========== + +import "@openzeppelin/contracts-upgradeable/token/ERC1155/ERC1155Upgradeable.sol"; + +import "../../extension/Multicall.sol"; +import "@openzeppelin/contracts-upgradeable/utils/StringsUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/interfaces/IERC2981Upgradeable.sol"; + +// ========== Internal imports ========== + +import "../../external-deps/openzeppelin/metatx/ERC2771ContextUpgradeable.sol"; +import "../../lib/CurrencyTransferLib.sol"; + +// ========== Features ========== + +import "../../extension/ContractMetadata.sol"; +import "../../extension/PlatformFee.sol"; +import "../../extension/Royalty.sol"; +import "../../extension/PrimarySale.sol"; +import "../../extension/Ownable.sol"; +import "../../extension/LazyMint.sol"; +import "../../extension/PermissionsEnumerable.sol"; +import "../../extension/Drop1155.sol"; + +contract DropERC1155 is + Initializable, + ContractMetadata, + PlatformFee, + Royalty, + PrimarySale, + Ownable, + LazyMint, + PermissionsEnumerable, + Drop1155, + ERC2771ContextUpgradeable, + Multicall, + ERC1155Upgradeable +{ + using StringsUpgradeable for uint256; + + /*/////////////////////////////////////////////////////////////// + State variables + //////////////////////////////////////////////////////////////*/ + + // Token name + string public name; + + // Token symbol + string public symbol; + + /// @dev Only transfers to or from TRANSFER_ROLE holders are valid, when transfers are restricted. + bytes32 private transferRole; + /// @dev Only MINTER_ROLE holders can sign off on `MintRequest`s and lazy mint tokens. + bytes32 private minterRole; + /// @dev Only METADATA_ROLE holders can reveal the URI for a batch of delayed reveal NFTs, and update or freeze batch metadata. + bytes32 private metadataRole; + + /// @dev Max bps in the thirdweb system. + uint256 private constant MAX_BPS = 10_000; + + /*/////////////////////////////////////////////////////////////// + Mappings + //////////////////////////////////////////////////////////////*/ + + /// @dev Mapping from token ID => total circulating supply of tokens with that ID. + mapping(uint256 => uint256) public totalSupply; + + /// @dev Mapping from token ID => maximum possible total circulating supply of tokens with that ID. + mapping(uint256 => uint256) public maxTotalSupply; + + /// @dev Mapping from token ID => the address of the recipient of primary sales. + mapping(uint256 => address) public saleRecipient; + + /*/////////////////////////////////////////////////////////////// + Events + //////////////////////////////////////////////////////////////*/ + + /// @dev Emitted when the global max supply of a token is updated. + event MaxTotalSupplyUpdated(uint256 tokenId, uint256 maxTotalSupply); + + /// @dev Emitted when the sale recipient for a particular tokenId is updated. + event SaleRecipientForTokenUpdated(uint256 indexed tokenId, address saleRecipient); + + /*/////////////////////////////////////////////////////////////// + Constructor + initializer logic + //////////////////////////////////////////////////////////////*/ + + constructor() initializer {} + + /// @dev Initializes the contract, like a constructor. + function initialize( + address _defaultAdmin, + string memory _name, + string memory _symbol, + string memory _contractURI, + address[] memory _trustedForwarders, + address _saleRecipient, + address _royaltyRecipient, + uint128 _royaltyBps, + uint128 _platformFeeBps, + address _platformFeeRecipient + ) external initializer { + bytes32 _transferRole = keccak256("TRANSFER_ROLE"); + bytes32 _minterRole = keccak256("MINTER_ROLE"); + bytes32 _metadataRole = keccak256("METADATA_ROLE"); + + // Initialize inherited contracts, most base-like -> most derived. + __ERC2771Context_init(_trustedForwarders); + __ERC1155_init_unchained(""); + + // Initialize this contract's state. + _setupContractURI(_contractURI); + _setupOwner(_defaultAdmin); + + _setupRole(DEFAULT_ADMIN_ROLE, _defaultAdmin); + _setupRole(_minterRole, _defaultAdmin); + _setupRole(_transferRole, _defaultAdmin); + _setupRole(_transferRole, address(0)); + _setupRole(_metadataRole, _defaultAdmin); + _setRoleAdmin(_metadataRole, _metadataRole); + + _setupPlatformFeeInfo(_platformFeeRecipient, _platformFeeBps); + _setupDefaultRoyaltyInfo(_royaltyRecipient, _royaltyBps); + _setupPrimarySaleRecipient(_saleRecipient); + + transferRole = _transferRole; + minterRole = _minterRole; + metadataRole = _metadataRole; + name = _name; + symbol = _symbol; + } + + /*/////////////////////////////////////////////////////////////// + ERC 165 / 1155 / 2981 logic + //////////////////////////////////////////////////////////////*/ + + /// @dev Returns the uri for a given tokenId. + function uri(uint256 _tokenId) public view override returns (string memory) { + string memory batchUri = _getBaseURI(_tokenId); + return string(abi.encodePacked(batchUri, _tokenId.toString())); + } + + /// @dev See ERC 165 + function supportsInterface( + bytes4 interfaceId + ) public view virtual override(ERC1155Upgradeable, IERC165) returns (bool) { + return super.supportsInterface(interfaceId) || type(IERC2981Upgradeable).interfaceId == interfaceId; + } + + /*/////////////////////////////////////////////////////////////// + Contract identifiers + //////////////////////////////////////////////////////////////*/ + + function contractType() external pure returns (bytes32) { + return bytes32("DropERC1155"); + } + + function contractVersion() external pure returns (uint8) { + return uint8(4); + } + + /*/////////////////////////////////////////////////////////////// + Setter functions + //////////////////////////////////////////////////////////////*/ + + /// @dev Lets a module admin set a max total supply for token. + function setMaxTotalSupply(uint256 _tokenId, uint256 _maxTotalSupply) external onlyRole(DEFAULT_ADMIN_ROLE) { + maxTotalSupply[_tokenId] = _maxTotalSupply; + emit MaxTotalSupplyUpdated(_tokenId, _maxTotalSupply); + } + + /// @dev Lets a contract admin set the recipient for all primary sales. + function setSaleRecipientForToken(uint256 _tokenId, address _saleRecipient) external onlyRole(DEFAULT_ADMIN_ROLE) { + saleRecipient[_tokenId] = _saleRecipient; + emit SaleRecipientForTokenUpdated(_tokenId, _saleRecipient); + } + + /** + * @notice Updates the base URI for a batch of tokens. + * + * @param _index Index of the desired batch in batchIds array. + * @param _uri the new base URI for the batch. + */ + function updateBatchBaseURI(uint256 _index, string calldata _uri) external onlyRole(metadataRole) { + uint256 batchId = getBatchIdAtIndex(_index); + _setBaseURI(batchId, _uri); + } + + /** + * @notice Freezes the base URI for a batch of tokens. + * + * @param _index Index of the desired batch in batchIds array. + */ + function freezeBatchBaseURI(uint256 _index) external onlyRole(metadataRole) { + uint256 batchId = getBatchIdAtIndex(_index); + _freezeBaseURI(batchId); + } + + /*/////////////////////////////////////////////////////////////// + Internal functions + //////////////////////////////////////////////////////////////*/ + + /// @dev Runs before every `claim` function call. + function _beforeClaim( + uint256 _tokenId, + address, + uint256 _quantity, + address, + uint256, + AllowlistProof calldata, + bytes memory + ) internal view override { + require( + maxTotalSupply[_tokenId] == 0 || totalSupply[_tokenId] + _quantity <= maxTotalSupply[_tokenId], + "exceed max total supply" + ); + } + + /// @dev Collects and distributes the primary sale value of NFTs being claimed. + function collectPriceOnClaim( + uint256 _tokenId, + address _primarySaleRecipient, + uint256 _quantityToClaim, + address _currency, + uint256 _pricePerToken + ) internal override { + if (_pricePerToken == 0) { + require(msg.value == 0, "!V"); + return; + } + + (address platformFeeRecipient, uint16 platformFeeBps) = getPlatformFeeInfo(); + + address _saleRecipient = _primarySaleRecipient == address(0) + ? (saleRecipient[_tokenId] == address(0) ? primarySaleRecipient() : saleRecipient[_tokenId]) + : _primarySaleRecipient; + + uint256 totalPrice = _quantityToClaim * _pricePerToken; + uint256 platformFees = (totalPrice * platformFeeBps) / MAX_BPS; + + bool validMsgValue; + if (_currency == CurrencyTransferLib.NATIVE_TOKEN) { + validMsgValue = msg.value == totalPrice; + } else { + validMsgValue = msg.value == 0; + } + require(validMsgValue, "!V"); + + CurrencyTransferLib.transferCurrency(_currency, _msgSender(), platformFeeRecipient, platformFees); + CurrencyTransferLib.transferCurrency(_currency, _msgSender(), _saleRecipient, totalPrice - platformFees); + } + + /// @dev Transfers the NFTs being claimed. + function transferTokensOnClaim(address _to, uint256 _tokenId, uint256 _quantityBeingClaimed) internal override { + _mint(_to, _tokenId, _quantityBeingClaimed, ""); + } + + /// @dev Checks whether platform fee info can be set in the given execution context. + function _canSetPlatformFeeInfo() internal view override returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + /// @dev Checks whether primary sale recipient can be set in the given execution context. + function _canSetPrimarySaleRecipient() internal view override returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + /// @dev Checks whether owner can be set in the given execution context. + function _canSetOwner() internal view override returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + /// @dev Checks whether royalty info can be set in the given execution context. + function _canSetRoyaltyInfo() internal view override returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + /// @dev Checks whether contract metadata can be set in the given execution context. + function _canSetContractURI() internal view override returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + /// @dev Checks whether platform fee info can be set in the given execution context. + function _canSetClaimConditions() internal view override returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + /// @dev Returns whether lazy minting can be done in the given execution context. + function _canLazyMint() internal view virtual override returns (bool) { + return hasRole(minterRole, _msgSender()); + } + + /*/////////////////////////////////////////////////////////////// + Miscellaneous + //////////////////////////////////////////////////////////////*/ + + /// @dev The tokenId of the next NFT that will be minted / lazy minted. + function nextTokenIdToMint() external view returns (uint256) { + return nextTokenIdToLazyMint; + } + + /// @dev Lets a token owner burn multiple tokens they own at once (i.e. destroy for good) + function burnBatch(address account, uint256[] memory ids, uint256[] memory values) public virtual { + require( + account == _msgSender() || isApprovedForAll(account, _msgSender()), + "ERC1155: caller is not owner nor approved." + ); + + _burnBatch(account, ids, values); + } + + /** + * @dev See {ERC1155-_beforeTokenTransfer}. + */ + function _beforeTokenTransfer( + address operator, + address from, + address to, + uint256[] memory ids, + uint256[] memory amounts, + bytes memory data + ) internal virtual override { + super._beforeTokenTransfer(operator, from, to, ids, amounts, data); + + // if transfer is restricted on the contract, we still want to allow burning and minting + if (!hasRole(transferRole, address(0)) && from != address(0) && to != address(0)) { + require(hasRole(transferRole, from) || hasRole(transferRole, to), "restricted to TRANSFER_ROLE holders."); + } + + if (from == address(0)) { + for (uint256 i = 0; i < ids.length; ++i) { + totalSupply[ids[i]] += amounts[i]; + } + } + + if (to == address(0)) { + for (uint256 i = 0; i < ids.length; ++i) { + totalSupply[ids[i]] -= amounts[i]; + } + } + } + + function _dropMsgSender() internal view virtual override returns (address) { + return _msgSender(); + } + + function _msgSender() + internal + view + virtual + override(ContextUpgradeable, ERC2771ContextUpgradeable, Multicall) + returns (address sender) + { + return ERC2771ContextUpgradeable._msgSender(); + } + + function _msgData() + internal + view + virtual + override(ContextUpgradeable, ERC2771ContextUpgradeable) + returns (bytes calldata) + { + return ERC2771ContextUpgradeable._msgData(); + } +} diff --git a/contracts/prebuilts/drop/DropERC20.sol b/contracts/prebuilts/drop/DropERC20.sol new file mode 100644 index 000000000..859931a89 --- /dev/null +++ b/contracts/prebuilts/drop/DropERC20.sol @@ -0,0 +1,252 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/// @author thirdweb + +// $$\ $$\ $$\ $$\ $$\ +// $$ | $$ | \__| $$ | $$ | +// $$$$$$\ $$$$$$$\ $$\ $$$$$$\ $$$$$$$ |$$\ $$\ $$\ $$$$$$\ $$$$$$$\ +// \_$$ _| $$ __$$\ $$ |$$ __$$\ $$ __$$ |$$ | $$ | $$ |$$ __$$\ $$ __$$\ +// $$ | $$ | $$ |$$ |$$ | \__|$$ / $$ |$$ | $$ | $$ |$$$$$$$$ |$$ | $$ | +// $$ |$$\ $$ | $$ |$$ |$$ | $$ | $$ |$$ | $$ | $$ |$$ ____|$$ | $$ | +// \$$$$ |$$ | $$ |$$ |$$ | \$$$$$$$ |\$$$$$\$$$$ |\$$$$$$$\ $$$$$$$ | +// \____/ \__| \__|\__|\__| \_______| \_____\____/ \_______|\_______/ + +// ========== External imports ========== +import "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20BurnableUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20VotesUpgradeable.sol"; + +import "../../extension/Multicall.sol"; +import "@openzeppelin/contracts-upgradeable/utils/StringsUpgradeable.sol"; + +// ========== Internal imports ========== + +import "../../external-deps/openzeppelin/metatx/ERC2771ContextUpgradeable.sol"; +import "../../lib/CurrencyTransferLib.sol"; + +// ========== Features ========== + +import "../../extension/ContractMetadata.sol"; +import "../../extension/PlatformFee.sol"; +import "../../extension/PrimarySale.sol"; +import "../../extension/PermissionsEnumerable.sol"; +import "../../extension/Drop.sol"; + +contract DropERC20 is + Initializable, + ContractMetadata, + PlatformFee, + PrimarySale, + PermissionsEnumerable, + Drop, + ERC2771ContextUpgradeable, + Multicall, + ERC20BurnableUpgradeable, + ERC20VotesUpgradeable +{ + using StringsUpgradeable for uint256; + + /*/////////////////////////////////////////////////////////////// + State variables + //////////////////////////////////////////////////////////////*/ + + /// @dev Only transfers to or from TRANSFER_ROLE holders are valid, when transfers are restricted. + bytes32 private transferRole; + + /// @dev Max bps in the thirdweb system. + uint256 private constant MAX_BPS = 10_000; + + /// @dev Global max total supply of tokens. + uint256 public maxTotalSupply; + + /// @dev Emitted when the global max supply of tokens is updated. + event MaxTotalSupplyUpdated(uint256 maxTotalSupply); + + /*/////////////////////////////////////////////////////////////// + Constructor + initializer logic + //////////////////////////////////////////////////////////////*/ + + constructor() initializer {} + + /// @dev Initializes the contract, like a constructor. + function initialize( + address _defaultAdmin, + string memory _name, + string memory _symbol, + string memory _contractURI, + address[] memory _trustedForwarders, + address _saleRecipient, + address _platformFeeRecipient, + uint128 _platformFeeBps + ) external initializer { + bytes32 _transferRole = keccak256("TRANSFER_ROLE"); + + // Initialize inherited contracts, most base-like -> most derived. + __ERC2771Context_init(_trustedForwarders); + __ERC20Permit_init(_name); + __ERC20_init_unchained(_name, _symbol); + + _setupContractURI(_contractURI); + + _setupRole(DEFAULT_ADMIN_ROLE, _defaultAdmin); + _setupRole(_transferRole, _defaultAdmin); + _setupRole(_transferRole, address(0)); + + _setupPlatformFeeInfo(_platformFeeRecipient, _platformFeeBps); + _setupPrimarySaleRecipient(_saleRecipient); + + transferRole = _transferRole; + } + + /*/////////////////////////////////////////////////////////////// + Contract identifiers + //////////////////////////////////////////////////////////////*/ + + function contractType() external pure returns (bytes32) { + return bytes32("DropERC20"); + } + + function contractVersion() external pure returns (uint8) { + return uint8(4); + } + + /*/////////////////////////////////////////////////////////////// + Setter functions + //////////////////////////////////////////////////////////////*/ + + /// @dev Lets a contract admin set the global maximum supply for collection's NFTs. + function setMaxTotalSupply(uint256 _maxTotalSupply) external onlyRole(DEFAULT_ADMIN_ROLE) { + maxTotalSupply = _maxTotalSupply; + emit MaxTotalSupplyUpdated(_maxTotalSupply); + } + + /*/////////////////////////////////////////////////////////////// + Internal functions + //////////////////////////////////////////////////////////////*/ + + /// @dev Runs before every `claim` function call. + function _beforeClaim( + address, + uint256 _quantity, + address, + uint256, + AllowlistProof calldata, + bytes memory + ) internal view override { + uint256 _maxTotalSupply = maxTotalSupply; + require(_maxTotalSupply == 0 || totalSupply() + _quantity <= _maxTotalSupply, "exceed max total supply."); + } + + /// @dev Collects and distributes the primary sale value of tokens being claimed. + function _collectPriceOnClaim( + address _primarySaleRecipient, + uint256 _quantityToClaim, + address _currency, + uint256 _pricePerToken + ) internal override { + if (_pricePerToken == 0) { + require(msg.value == 0, "!Value"); + return; + } + + (address platformFeeRecipient, uint16 platformFeeBps) = getPlatformFeeInfo(); + + address saleRecipient = _primarySaleRecipient == address(0) ? primarySaleRecipient() : _primarySaleRecipient; + + // `_pricePerToken` is interpreted as price per 1 ether unit of the ERC20 tokens. + uint256 totalPrice = (_quantityToClaim * _pricePerToken) / 1 ether; + require(totalPrice > 0, "quantity too low"); + + uint256 platformFees = (totalPrice * platformFeeBps) / MAX_BPS; + + bool validMsgValue; + if (_currency == CurrencyTransferLib.NATIVE_TOKEN) { + validMsgValue = msg.value == totalPrice; + } else { + validMsgValue = msg.value == 0; + } + require(validMsgValue, "Invalid msg value"); + + CurrencyTransferLib.transferCurrency(_currency, _msgSender(), platformFeeRecipient, platformFees); + CurrencyTransferLib.transferCurrency(_currency, _msgSender(), saleRecipient, totalPrice - platformFees); + } + + /// @dev Transfers the tokens being claimed. + function _transferTokensOnClaim(address _to, uint256 _quantityBeingClaimed) internal override returns (uint256) { + _mint(_to, _quantityBeingClaimed); + return 0; + } + + /// @dev Checks whether platform fee info can be set in the given execution context. + function _canSetPlatformFeeInfo() internal view override returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + /// @dev Checks whether primary sale recipient can be set in the given execution context. + function _canSetPrimarySaleRecipient() internal view override returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + /// @dev Checks whether contract metadata can be set in the given execution context. + function _canSetContractURI() internal view override returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + /// @dev Checks whether platform fee info can be set in the given execution context. + function _canSetClaimConditions() internal view override returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + /*/////////////////////////////////////////////////////////////// + Miscellaneous + //////////////////////////////////////////////////////////////*/ + + function _mint(address account, uint256 amount) internal virtual override(ERC20Upgradeable, ERC20VotesUpgradeable) { + super._mint(account, amount); + } + + function _burn(address account, uint256 amount) internal virtual override(ERC20Upgradeable, ERC20VotesUpgradeable) { + super._burn(account, amount); + } + + function _afterTokenTransfer( + address from, + address to, + uint256 amount + ) internal virtual override(ERC20Upgradeable, ERC20VotesUpgradeable) { + super._afterTokenTransfer(from, to, amount); + } + + /// @dev Runs on every transfer. + function _beforeTokenTransfer(address from, address to, uint256 amount) internal override(ERC20Upgradeable) { + super._beforeTokenTransfer(from, to, amount); + + if (!hasRole(transferRole, address(0)) && from != address(0) && to != address(0)) { + require(hasRole(transferRole, from) || hasRole(transferRole, to), "transfers restricted."); + } + } + + function _dropMsgSender() internal view virtual override returns (address) { + return _msgSender(); + } + + function _msgSender() + internal + view + virtual + override(ContextUpgradeable, ERC2771ContextUpgradeable, Multicall) + returns (address sender) + { + return ERC2771ContextUpgradeable._msgSender(); + } + + function _msgData() + internal + view + virtual + override(ContextUpgradeable, ERC2771ContextUpgradeable) + returns (bytes calldata) + { + return ERC2771ContextUpgradeable._msgData(); + } +} diff --git a/contracts/prebuilts/drop/DropERC721.sol b/contracts/prebuilts/drop/DropERC721.sol new file mode 100644 index 000000000..5c6738636 --- /dev/null +++ b/contracts/prebuilts/drop/DropERC721.sol @@ -0,0 +1,389 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/// @author thirdweb + +// $$\ $$\ $$\ $$\ $$\ +// $$ | $$ | \__| $$ | $$ | +// $$$$$$\ $$$$$$$\ $$\ $$$$$$\ $$$$$$$ |$$\ $$\ $$\ $$$$$$\ $$$$$$$\ +// \_$$ _| $$ __$$\ $$ |$$ __$$\ $$ __$$ |$$ | $$ | $$ |$$ __$$\ $$ __$$\ +// $$ | $$ | $$ |$$ |$$ | \__|$$ / $$ |$$ | $$ | $$ |$$$$$$$$ |$$ | $$ | +// $$ |$$\ $$ | $$ |$$ |$$ | $$ | $$ |$$ | $$ | $$ |$$ ____|$$ | $$ | +// \$$$$ |$$ | $$ |$$ |$$ | \$$$$$$$ |\$$$$$\$$$$ |\$$$$$$$\ $$$$$$$ | +// \____/ \__| \__|\__|\__| \_______| \_____\____/ \_______|\_______/ + +// ========== External imports ========== + +import "../../extension/Multicall.sol"; +import "@openzeppelin/contracts-upgradeable/utils/StringsUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/interfaces/IERC2981Upgradeable.sol"; + +import "../../eip/ERC721AVirtualApproveUpgradeable.sol"; + +// ========== Internal imports ========== + +import "../../external-deps/openzeppelin/metatx/ERC2771ContextUpgradeable.sol"; +import "../../lib/CurrencyTransferLib.sol"; + +// ========== Features ========== + +import "../../extension/ContractMetadata.sol"; +import "../../extension/PlatformFee.sol"; +import "../../extension/Royalty.sol"; +import "../../extension/PrimarySale.sol"; +import "../../extension/Ownable.sol"; +import "../../extension/DelayedReveal.sol"; +import "../../extension/LazyMint.sol"; +import "../../extension/PermissionsEnumerable.sol"; +import "../../extension/Drop.sol"; + +contract DropERC721 is + Initializable, + ContractMetadata, + PlatformFee, + Royalty, + PrimarySale, + Ownable, + DelayedReveal, + LazyMint, + PermissionsEnumerable, + Drop, + ERC2771ContextUpgradeable, + Multicall, + ERC721AUpgradeable +{ + using StringsUpgradeable for uint256; + + /*/////////////////////////////////////////////////////////////// + State variables + //////////////////////////////////////////////////////////////*/ + + /// @dev Only transfers to or from TRANSFER_ROLE holders are valid, when transfers are restricted. + bytes32 private transferRole; + /// @dev Only MINTER_ROLE holders can sign off on `MintRequest`s and lazy mint tokens. + bytes32 private minterRole; + /// @dev Only METADATA_ROLE holders can reveal the URI for a batch of delayed reveal NFTs, and update or freeze batch metadata. + bytes32 private metadataRole; + + /// @dev Max bps in the thirdweb system. + uint256 private constant MAX_BPS = 10_000; + + /// @dev Global max total supply of NFTs. + uint256 public maxTotalSupply; + + /// @dev Emitted when the global max supply of tokens is updated. + event MaxTotalSupplyUpdated(uint256 maxTotalSupply); + + /*/////////////////////////////////////////////////////////////// + Constructor + initializer logic + //////////////////////////////////////////////////////////////*/ + + constructor() initializer {} + + /// @dev Initializes the contract, like a constructor. + function initialize( + address _defaultAdmin, + string memory _name, + string memory _symbol, + string memory _contractURI, + address[] memory _trustedForwarders, + address _saleRecipient, + address _royaltyRecipient, + uint128 _royaltyBps, + uint128 _platformFeeBps, + address _platformFeeRecipient + ) external initializer { + bytes32 _transferRole = keccak256("TRANSFER_ROLE"); + bytes32 _minterRole = keccak256("MINTER_ROLE"); + bytes32 _metadataRole = keccak256("METADATA_ROLE"); + + // Initialize inherited contracts, most base-like -> most derived. + __ERC2771Context_init(_trustedForwarders); + __ERC721A_init(_name, _symbol); + + _setupContractURI(_contractURI); + _setupOwner(_defaultAdmin); + + _setupRole(DEFAULT_ADMIN_ROLE, _defaultAdmin); + _setupRole(_minterRole, _defaultAdmin); + _setupRole(_transferRole, _defaultAdmin); + _setupRole(_transferRole, address(0)); + _setupRole(_metadataRole, _defaultAdmin); + _setRoleAdmin(_metadataRole, _metadataRole); + + _setupPlatformFeeInfo(_platformFeeRecipient, _platformFeeBps); + _setupDefaultRoyaltyInfo(_royaltyRecipient, _royaltyBps); + _setupPrimarySaleRecipient(_saleRecipient); + + transferRole = _transferRole; + minterRole = _minterRole; + metadataRole = _metadataRole; + } + + /*/////////////////////////////////////////////////////////////// + ERC 165 / 721 / 2981 logic + //////////////////////////////////////////////////////////////*/ + + /// @dev Returns the URI for a given tokenId. + function tokenURI(uint256 _tokenId) public view override returns (string memory) { + (uint256 batchId, ) = _getBatchId(_tokenId); + string memory batchUri = _getBaseURI(_tokenId); + + if (isEncryptedBatch(batchId)) { + return string(abi.encodePacked(batchUri, "0")); + } else { + return string(abi.encodePacked(batchUri, _tokenId.toString())); + } + } + + /// @dev See ERC 165 + function supportsInterface( + bytes4 interfaceId + ) public view virtual override(ERC721AUpgradeable, IERC165) returns (bool) { + return super.supportsInterface(interfaceId) || type(IERC2981Upgradeable).interfaceId == interfaceId; + } + + /*/////////////////////////////////////////////////////////////// + Contract identifiers + //////////////////////////////////////////////////////////////*/ + + function contractType() external pure returns (bytes32) { + return bytes32("DropERC721"); + } + + function contractVersion() external pure returns (uint8) { + return uint8(4); + } + + /*/////////////////////////////////////////////////////////////// + Lazy minting + delayed-reveal logic + //////////////////////////////////////////////////////////////*/ + + /** + * @dev Lets an account with `MINTER_ROLE` lazy mint 'n' NFTs. + * The URIs for each token is the provided `_baseURIForTokens` + `{tokenId}`. + */ + function lazyMint( + uint256 _amount, + string calldata _baseURIForTokens, + bytes calldata _data + ) public override returns (uint256 batchId) { + if (_data.length > 0) { + (bytes memory encryptedURI, bytes32 provenanceHash) = abi.decode(_data, (bytes, bytes32)); + if (encryptedURI.length != 0 && provenanceHash != "") { + _setEncryptedData(nextTokenIdToLazyMint + _amount, _data); + } + } + + return super.lazyMint(_amount, _baseURIForTokens, _data); + } + + /// @dev Lets an account with `METADATA_ROLE` reveal the URI for a batch of 'delayed-reveal' NFTs. + /// @param _index the ID of a token with the desired batch. + /// @param _key the key to decrypt the batch's URI. + function reveal( + uint256 _index, + bytes calldata _key + ) external onlyRole(metadataRole) returns (string memory revealedURI) { + uint256 batchId = getBatchIdAtIndex(_index); + revealedURI = getRevealURI(batchId, _key); + + _setEncryptedData(batchId, ""); + _setBaseURI(batchId, revealedURI); + + emit TokenURIRevealed(_index, revealedURI); + } + + /** + * @notice Updates the base URI for a batch of tokens. Can only be called if the batch has been revealed/is not encrypted. + * + * @param _index Index of the desired batch in batchIds array + * @param _uri the new base URI for the batch. + */ + function updateBatchBaseURI(uint256 _index, string calldata _uri) external onlyRole(metadataRole) { + require(!isEncryptedBatch(getBatchIdAtIndex(_index)), "Encrypted batch"); + uint256 batchId = getBatchIdAtIndex(_index); + _setBaseURI(batchId, _uri); + } + + /** + * @notice Freezes the base URI for a batch of tokens. + * + * @param _index Index of the desired batch in batchIds array. + */ + function freezeBatchBaseURI(uint256 _index) external onlyRole(metadataRole) { + require(!isEncryptedBatch(getBatchIdAtIndex(_index)), "Encrypted batch"); + uint256 batchId = getBatchIdAtIndex(_index); + _freezeBaseURI(batchId); + } + + /*/////////////////////////////////////////////////////////////// + Setter functions + //////////////////////////////////////////////////////////////*/ + + /// @dev Lets a contract admin set the global maximum supply for collection's NFTs. + function setMaxTotalSupply(uint256 _maxTotalSupply) external onlyRole(DEFAULT_ADMIN_ROLE) { + maxTotalSupply = _maxTotalSupply; + emit MaxTotalSupplyUpdated(_maxTotalSupply); + } + + /*/////////////////////////////////////////////////////////////// + Internal functions + //////////////////////////////////////////////////////////////*/ + + /// @dev Runs before every `claim` function call. + function _beforeClaim( + address, + uint256 _quantity, + address, + uint256, + AllowlistProof calldata, + bytes memory + ) internal view override { + require(_currentIndex + _quantity <= nextTokenIdToLazyMint, "!Tokens"); + require(maxTotalSupply == 0 || _currentIndex + _quantity <= maxTotalSupply, "!Supply"); + } + + /// @dev Collects and distributes the primary sale value of NFTs being claimed. + function _collectPriceOnClaim( + address _primarySaleRecipient, + uint256 _quantityToClaim, + address _currency, + uint256 _pricePerToken + ) internal override { + if (_pricePerToken == 0) { + require(msg.value == 0, "!V"); + return; + } + + (address platformFeeRecipient, uint16 platformFeeBps) = getPlatformFeeInfo(); + + address saleRecipient = _primarySaleRecipient == address(0) ? primarySaleRecipient() : _primarySaleRecipient; + + uint256 totalPrice = _quantityToClaim * _pricePerToken; + uint256 platformFees = (totalPrice * platformFeeBps) / MAX_BPS; + + bool validMsgValue; + if (_currency == CurrencyTransferLib.NATIVE_TOKEN) { + validMsgValue = msg.value == totalPrice; + } else { + validMsgValue = msg.value == 0; + } + require(validMsgValue, "!V"); + + CurrencyTransferLib.transferCurrency(_currency, _msgSender(), platformFeeRecipient, platformFees); + CurrencyTransferLib.transferCurrency(_currency, _msgSender(), saleRecipient, totalPrice - platformFees); + } + + /// @dev Transfers the NFTs being claimed. + function _transferTokensOnClaim( + address _to, + uint256 _quantityBeingClaimed + ) internal override returns (uint256 startTokenId) { + startTokenId = _currentIndex; + _safeMint(_to, _quantityBeingClaimed); + } + + /// @dev Checks whether platform fee info can be set in the given execution context. + function _canSetPlatformFeeInfo() internal view override returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + /// @dev Checks whether primary sale recipient can be set in the given execution context. + function _canSetPrimarySaleRecipient() internal view override returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + /// @dev Checks whether owner can be set in the given execution context. + function _canSetOwner() internal view override returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + /// @dev Checks whether royalty info can be set in the given execution context. + function _canSetRoyaltyInfo() internal view override returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + /// @dev Checks whether contract metadata can be set in the given execution context. + function _canSetContractURI() internal view override returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + /// @dev Checks whether platform fee info can be set in the given execution context. + function _canSetClaimConditions() internal view override returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + /// @dev Returns whether lazy minting can be done in the given execution context. + function _canLazyMint() internal view virtual override returns (bool) { + return hasRole(minterRole, _msgSender()); + } + + /*/////////////////////////////////////////////////////////////// + Miscellaneous + //////////////////////////////////////////////////////////////*/ + + /** + * Returns the total amount of tokens minted in the contract. + */ + function totalMinted() external view returns (uint256) { + return _totalMinted(); + } + + /// @dev The tokenId of the next NFT that will be minted / lazy minted. + function nextTokenIdToMint() external view returns (uint256) { + return nextTokenIdToLazyMint; + } + + /// @dev The next token ID of the NFT that can be claimed. + function nextTokenIdToClaim() external view returns (uint256) { + return _currentIndex; + } + + /// @dev Burns `tokenId`. See {ERC721-_burn}. + function burn(uint256 tokenId) external virtual { + // note: ERC721AUpgradeable's `_burn(uint256,bool)` internally checks for token approvals. + _burn(tokenId, true); + } + + /// @dev See {ERC721-_beforeTokenTransfer}. + function _beforeTokenTransfers( + address from, + address to, + uint256 startTokenId, + uint256 quantity + ) internal virtual override { + super._beforeTokenTransfers(from, to, startTokenId, quantity); + + // if transfer is restricted on the contract, we still want to allow burning and minting + if (!hasRole(transferRole, address(0)) && from != address(0) && to != address(0)) { + if (!hasRole(transferRole, from) && !hasRole(transferRole, to)) { + revert("!Transfer-Role"); + } + } + } + + function _dropMsgSender() internal view virtual override returns (address) { + return _msgSender(); + } + + function _msgSender() + internal + view + virtual + override(ContextUpgradeable, ERC2771ContextUpgradeable, Multicall) + returns (address sender) + { + return ERC2771ContextUpgradeable._msgSender(); + } + + function _msgData() + internal + view + virtual + override(ContextUpgradeable, ERC2771ContextUpgradeable) + returns (bytes calldata) + { + return ERC2771ContextUpgradeable._msgData(); + } +} diff --git a/contracts/prebuilts/drop/drop.md b/contracts/prebuilts/drop/drop.md new file mode 100644 index 000000000..a4de5e77a --- /dev/null +++ b/contracts/prebuilts/drop/drop.md @@ -0,0 +1,176 @@ +# Drop design document. + +This is a live document that explains what the [thirdweb](https://thirdweb.com/) `Drop` smart contracts are, how they work and can be used, and why they are written the way they are. + +The document is written for technical and non-technical readers. To ask further questions about any of thirdweb’s `Drop`, please join the [thirdweb discord](https://discord.gg/thirdweb) or create a github issue. + +--- + +## Background + +The thirdweb `Drop` contracts are distribution mechanisms for tokens. This distribution mechanism is offered for ERC20, ERC721 and ERC1155 tokens, as `DropERC20`, `DropERC721` and `DropERC1155`. + +The `Drop` contracts are meant to be used when the goal of the contract creator is for an audience to come in and claim tokens within certain restrictions e.g. — ‘only addresses in an allowlist can mint tokens’, or ‘minters must pay _x_ amount of price in _y_ currency to mint’, etc. + +The `Drop` contracts let the contract creator establish phases (periods of time), where each phase can specify multiple such restrictions on the minting of tokens during that period of time. We refer to such a phase as a ‘claim condition’. + +### Why we’re building `Drop` + +We’ve observed that there are largely three distinct contexts under which one mints tokens — + +1. Minting tokens for yourself on a contract you own. E.g. a person wants to mint their Twitter profile picture as an NFT. +2. Having an audience mint tokens on a contract you own. + 1. The nature of tokens to be minted by the audience is pre-determined by the contract admin. E.g. a 10k NFT drop where the contents of the NFTs to be minted by the audience is already known and determined by the contract admin before the audience comes in to mint NFTs. + 2. The nature of tokens to be minted by the audience is _not_ pre-determined by the contract admin. E.g. a course ‘certificate’ dynamically generated with the name of the course participant, to be minted by the course participant at the time of course completion. + +The thirdweb `Token` contracts serve the cases described in (1) and 2(ii). + +The thirdweb `Drop` contracts serve the case described in 2(i). They are written to give a contract creator granular control over restrictions around an audience minting tokens from the same contract (or ‘collection’, in the case of NFTs) over an extended period of time. + +## Technical Details + +The distribution mechanism of `Drop` is as follows — A contract admin establishes a series of ‘claim conditions’. A ‘claim condition’ is a period of time in which accounts can mint tokens on the respective `Drop` contract, within a set of restrictions defined by the ‘claim condition’. + +### Claim Conditions + +The following makes up a claim condition — + +```solidity +struct ClaimCondition { + uint256 startTimestamp; + uint256 maxClaimableSupply; + uint256 supplyClaimed; + uint256 quantityLimitPerWallet; + bytes32 merkleRoot; + uint256 pricePerToken; + address currency; +} + +``` + +| Parameters | Type | Description | +| ---------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | +| startTimestamp | uint256 | The unix timestamp after which the claim condition applies. The same claim condition applies until the startTimestamp of the next claim condition. | +| maxClaimableSupply | uint256 | The maximum total number of tokens that can be claimed under the claim condition. | +| supplyClaimed | uint256 | At any given point, the number of tokens that have been claimed under the claim condition. | +| quantityLimitPerWallet | uint256 | The maximum number of tokens that can be claimed by a wallet under a given claim condition. | +| merkleRoot | bytes32 | The allowlist of addresses that can claim tokens under the claim condition. | + +(Optional) The allowlist may specify quantity limits, price and currency for addresses in the list, overriding these values under that claim condition. + +The parameters that make up a claim condition can be composed in different ways to create specific restrictions around a mint. For example, a single claim condition where: + +- `quantityLimitPerWallet = 5` +- `merkleRoot = bytes32(0)` + +creates restrictions around a mint, where (1) a wallet can mint at most 5 tokens and (2) all wallets are subject to general claim condition limits, without any overrides. + +A `Drop` contract lets a contract admin establish a series of claim conditions, at once. Since each claim condition specifies a `startTime`, a contract admin can establish a series of claim conditions, ordered by their start time, to specify different set of restrictions around minting, during different periods of time. + +At any moment, there is only one active claim condition, and an account attempting to mint tokens on the respective `Drop` contract successfully or unsuccessfully, based on whether the account passes the restrictions defined by that moment’s active claim condition. + +A `Drop` contract natively keeps track of claim conditions set by a contract admin in a ‘claim conditions list’, which looks as follows — + +```solidity +struct ClaimConditionList { + uint256 currentStartId; + uint256 count; + mapping(uint256 => ClaimCondition) conditions; + mapping(uint256 => mapping(address => uint256)) supplyClaimedByWallet; +} + +``` + +| Parameter | Description | +| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| currentStartId | The uid for the first claim condition amongst the current set of claim conditions. The uid for each next claim condition is one more than the previous claim condition's uid. | +| count | The total number of claim conditions in the list of claim conditions. | +| conditions | The claim conditions at a given uid. Claim conditions are ordered in an ascending order by their startTimestamp. | +| supplyClaimedByWallet | Map from a claim condition uid and account to the supply claimed by that account. | + +### Allowlist as an override list + +As mentioned above, an allowlist can specify different conditions for addresses in the list. This way, it serves as an override over general/open restrictions for non-allowlisted addresses. +In this allowlist or override-list, an admin can set any/all of these three: + +- quantity limit +- price +- currency + +If a value is not set for any of these, then the value specified in general claim condition will be used. However, currency override will be considered only when a price override is set too, and not without it. + +> **IMPORTANT**: _The allowlist should not contain an address more than once in the same merkle tree. Multiple instances for the same address (with different/same quantity, price etc.) may not function as expected and may lead to unexpected behavior during claim. (More details in Limitations section)._ + +### Setting claim conditions + +In all `Drop` contracts, a contract admin specifies the following when setting claim conditions: + +| Parameter | Type | Description | +| --------------------- | ---------------- | ---------------------------------------------------------------------------------- | +| conditions | ClaimCondition[] | Claim conditions in ascending order by `startTimestamp`. | +| resetClaimEligibility | bool | Whether to reset `supplyClaimedByWallet` values when setting new claim conditions. | + +When setting claim conditions, any existing set of claim conditions stored in `ClaimConditionsList` are overwritten with the new claim conditions specified in `conditions`. + +The claim conditions specified in `conditions` are expected to be in ordered in ascending order, by their ‘start time’. As a result, only one claim condition is active during at any given time. + +Each of the claim conditions specified in `conditions` is assigned a unique integer ID. The UID of the first condition in `conditions` is stored as the `ClaimConditionList.currentStartId` and each next claim condition’s UID is one more than the previous condition’s UID. + +![claim-conditions-diagram-1.png](/assets/claim-conditions-diagram-1.png) + +The `resetClaimEligibility` boolean flag determines what UIDs are assigned to the claim conditions specified in `conditions`. Since `ClaimConditionList.supplyClaimedByWallet` is indexed by the UID of claim conditions, this gives a contract admin more granular control over the restrictions that claim conditions can express. We now illustrate this with an example: + +Let’s say an existing claim condition **C1** specifies the following restrictions: + +- `quantityLimitPerWallet = 1` +- `merkleRoot = bytes32(0)` +- `pricePerToken = 0.1 ether` +- `currency = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE` (i.e. native token of the chain e.g ether for Ethereum mainnet) + +At a high level, **C1** expresses the following restrictions on minting — any address can claim at most one token, ever, by paying 0.1 ether in price. + +Let’s say the contract admin wants to increase the price per token from 0.1 ether to 0.2 ether, while ensuring that wallets that have already claimed tokens are not able to claim tokens again. Essentially, the contract admin now wants to instantiate a claim condition **C2** with the following restrictions: + +- `quantityLimitPerWallet = 1` +- `merkleRoot = bytes32(0)` +- `pricePerToken = 0.2 ether` +- `currency = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE` (i.e. native token of the chain e.g ether for Ethereum mainnet) + +To go from **C1** to **C2** while ensuring that wallets that have already claimed tokens are not able to claim tokens again, the contract admin will set claim conditions while specifying `resetClaimEligibility == false`. As a result, the **C2** will be assigned the same UID as **C1**. Since `ClaimConditionList.supplyClaimedByWallet` is indexed by the UID of claim conditions, the information of the quantity of tokens claimed by the wallet during **C1** will not be lost. And so, wallets that claimed tokens during **C1** will now be ineligible to claim tokens during **C2** because of the following check: + +```solidity +// pseudo-code +supplyClaimedByWallet = claimCondition.supplyClaimedByWallet[conditionId][claimer]; + +require(quantityToClaim + supplyClaimedByWallet <= quantityLimitPerWallet); +``` + +### EIPs supported / implemented + +The distribution mechanism for tokens expressed by thirdweb’s `Drop` is implemented for ERC20, ERC721 and ERC1155 tokens, as `DropERC20`, `DropERC721` and `DropERC1155`. + +There are a few key differences between the three implementations — + +- `DropERC20` is written for the distribution of completely fungible, ERC20 tokens. On the other hand, `DropERC721` and `DropERC1155` are written for the distribution of NFTs, which requires ‘lazy minting’ i.e. defining the content of the NFTs before an audience comes in to mint them during a claim condition. +- Both `DropERC20` and `DropERC721` maintain a global, contract-wide `ClaimConditionsList` which stores the claim conditions under which tokens can be minted. The `DropERC1155` contract, on the other hand, maintains a `ClaimConditionList` for every integer token ID that an NFT can assume. And so, a contract admin can set up claim conditions per NFT i.e. per token ID, in the `DropERC1155` contract. + +## Limitations + +### Sybil attacks + +The distribution mechanism of thirdweb’s `Drop` contracts is vulnerable to [sybil attacks](https://en.wikipedia.org/wiki/Sybil_attack). That is, despite the various ways in which restrictions can be applied to the minting of tokens, some restrictions that claim conditions can express target wallets and not persons. + +For example, the restriction `quantityLimitPerWallet` expresses the max quantity a _wallet_ can claim during the respective claim condition. A sophisticated actor may generate multiple wallets to claim tokens in a way that undermines such restrictions, when viewing such restrictions as restrictions on unique persons, and not wallets. + +### Allowlist behavior + +When specifying allowlist of addresses, and quantities, price, etc. for those addresses, contract admins must ensure that they don't list an address more than once in the same merkle tree. + +For e.g. admin wishes to grant user X permission to mint 2 tokens at 0.25 ETH, and 4 tokens at 0.5 ETH. In this case, the contract design will not permit the user X to claim 6 tokens with different prices as desired. Instead, the user may be limited to claiming just 2 tokens or 4 tokens based on their order of claiming. + +To avoid such pitfalls, an address should be listed only once per merkle tree or allowlist. + +## Authors + +- [nkrishang](https://github.com/nkrishang) +- [thirdweb team](https://github.com/thirdweb-dev) diff --git a/contracts/prebuilts/interface/ILoyaltyCard.sol b/contracts/prebuilts/interface/ILoyaltyCard.sol new file mode 100644 index 000000000..52a9fdc9b --- /dev/null +++ b/contracts/prebuilts/interface/ILoyaltyCard.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "../../extension/interface/INFTMetadata.sol"; +import "../../extension/interface/ISignatureMintERC721.sol"; +import "../../eip/interface/IERC721.sol"; + +interface ILoyaltyCard { + /// @dev Emitted when an account with MINTER_ROLE mints an NFT. + event TokensMinted(address indexed mintedTo, uint256 indexed tokenIdMinted, string uri); + + /** + * @notice Lets an account with MINTER_ROLE mint an NFT. + * + * @param to The address to mint the NFT to. + * @param uri The URI to assign to the NFT. + * + * @return tokenId of the NFT minted. + */ + function mintTo(address to, string calldata uri) external returns (uint256); + + /// @notice Let's a loyalty card owner or approved operator cancel the loyalty card. + function cancel(uint256 tokenId) external; + + /// @notice Let's an approved party cancel the loyalty card (no approval needed). + function revoke(uint256 tokenId) external; +} diff --git a/contracts/prebuilts/interface/ILoyaltyPoints.sol b/contracts/prebuilts/interface/ILoyaltyPoints.sol new file mode 100644 index 000000000..47c7e92c9 --- /dev/null +++ b/contracts/prebuilts/interface/ILoyaltyPoints.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +interface ILoyaltyPoints { + /// @dev Emitted when an account with MINTER_ROLE mints an NFT. + event TokensMinted(address indexed mintedTo, uint256 quantityMinted); + + /// @notice Returns the total tokens minted to `owner` in the contract's lifetime. + function getTotalMintedInLifetime(address owner) external view returns (uint256); + + /** + * @notice Lets an account with MINTER_ROLE mint an NFT. + * + * @param to The address to mint tokens to. + * @param amount The amount of tokens to mint. + */ + function mintTo(address to, uint256 amount) external; + + /// @notice Let's a loyalty pointsß owner or approved operator cancel the given amount of loyalty points. + function cancel(address owner, uint256 amount) external; + + /// @notice Let's an approved party revoke a holder's loyalty points (no approval needed). + function revoke(address owner, uint256 amount) external; +} diff --git a/contracts/prebuilts/interface/IMultiwrap.sol b/contracts/prebuilts/interface/IMultiwrap.sol new file mode 100644 index 000000000..16c6ef008 --- /dev/null +++ b/contracts/prebuilts/interface/IMultiwrap.sol @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "../../extension/interface/ITokenBundle.sol"; + +/** + * Thirdweb's Multiwrap contract lets you wrap arbitrary ERC20, ERC721 and ERC1155 + * tokens you own into a single wrapped token / NFT. + * + * A wrapped NFT can be unwrapped i.e. burned in exchange for its underlying contents. + */ + +interface IMultiwrap is ITokenBundle { + /// @dev Emitted when tokens are wrapped. + event TokensWrapped( + address indexed wrapper, + address indexed recipientOfWrappedToken, + uint256 indexed tokenIdOfWrappedToken, + Token[] wrappedContents + ); + + /// @dev Emitted when tokens are unwrapped. + event TokensUnwrapped( + address indexed unwrapper, + address indexed recipientOfWrappedContents, + uint256 indexed tokenIdOfWrappedToken + ); + + /** + * @notice Wrap multiple ERC1155, ERC721, ERC20 tokens into a single wrapped NFT. + * + * @param wrappedContents The tokens to wrap. + * @param uriForWrappedToken The metadata URI for the wrapped NFT. + * @param recipient The recipient of the wrapped NFT. + */ + function wrap( + Token[] memory wrappedContents, + string calldata uriForWrappedToken, + address recipient + ) external payable returns (uint256 tokenId); + + /** + * @notice Unwrap a wrapped NFT to retrieve underlying ERC1155, ERC721, ERC20 tokens. + * + * @param tokenId The token Id of the wrapped NFT to unwrap. + * @param recipient The recipient of the underlying ERC1155, ERC721, ERC20 tokens of the wrapped NFT. + */ + function unwrap(uint256 tokenId, address recipient) external; +} diff --git a/contracts/prebuilts/interface/IPack.sol b/contracts/prebuilts/interface/IPack.sol new file mode 100644 index 000000000..f28cac3f8 --- /dev/null +++ b/contracts/prebuilts/interface/IPack.sol @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "../../extension/interface/ITokenBundle.sol"; + +/** + * The thirdweb `Pack` contract is a lootbox mechanism. An account can bundle up arbitrary ERC20, ERC721 and ERC1155 tokens into + * a set of packs. A pack can then be opened in return for a selection of the tokens in the pack. The selection of tokens distributed + * on opening a pack depends on the relative supply of all tokens in the packs. + */ + +interface IPack is ITokenBundle { + /** + * @notice All info relevant to packs. + * + * @param perUnitAmounts Mapping from a UID -> to the per-unit amount of that asset i.e. `Token` at that index. + * @param openStartTimestamp The timestamp after which packs can be opened. + * @param amountDistributedPerOpen The number of reward units distributed per open. + */ + struct PackInfo { + uint256[] perUnitAmounts; + uint128 openStartTimestamp; + uint128 amountDistributedPerOpen; + } + + /// @notice Emitted when a set of packs is created. + event PackCreated(uint256 indexed packId, address recipient, uint256 totalPacksCreated); + + /// @notice Emitted when more packs are minted for a packId. + event PackUpdated(uint256 indexed packId, address recipient, uint256 totalPacksCreated); + + /// @notice Emitted when a pack is opened. + event PackOpened( + uint256 indexed packId, + address indexed opener, + uint256 numOfPacksOpened, + Token[] rewardUnitsDistributed + ); + + /** + * @notice Creates a pack with the stated contents. + * + * @param contents The reward units to pack in the packs. + * @param numOfRewardUnits The number of reward units to create, for each asset specified in `contents`. + * @param packUri The (metadata) URI assigned to the packs created. + * @param openStartTimestamp The timestamp after which packs can be opened. + * @param amountDistributedPerOpen The number of reward units distributed per open. + * @param recipient The recipient of the packs created. + * + * @return packId The unique identifier of the created set of packs. + * @return packTotalSupply The total number of packs created. + */ + function createPack( + Token[] calldata contents, + uint256[] calldata numOfRewardUnits, + string calldata packUri, + uint128 openStartTimestamp, + uint128 amountDistributedPerOpen, + address recipient + ) external payable returns (uint256 packId, uint256 packTotalSupply); + + /** + * @notice Lets a pack owner open a pack and receive the pack's reward unit. + * + * @param packId The identifier of the pack to open. + * @param amountToOpen The number of packs to open at once. + */ + function openPack(uint256 packId, uint256 amountToOpen) external returns (Token[] memory); +} diff --git a/contracts/prebuilts/interface/IPackVRFDirect.sol b/contracts/prebuilts/interface/IPackVRFDirect.sol new file mode 100644 index 000000000..116c9ec30 --- /dev/null +++ b/contracts/prebuilts/interface/IPackVRFDirect.sol @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "../../extension/interface/ITokenBundle.sol"; + +/** + * The thirdweb `Pack` contract is a lootbox mechanism. An account can bundle up arbitrary ERC20, ERC721 and ERC1155 tokens into + * a set of packs. A pack can then be opened in return for a selection of the tokens in the pack. The selection of tokens distributed + * on opening a pack depends on the relative supply of all tokens in the packs. + */ + +interface IPackVRFDirect is ITokenBundle { + /** + * @notice All info relevant to packs. + * + * @param perUnitAmounts Mapping from a UID -> to the per-unit amount of that asset i.e. `Token` at that index. + * @param openStartTimestamp The timestamp after which packs can be opened. + * @param amountDistributedPerOpen The number of reward units distributed per open. + */ + struct PackInfo { + uint256[] perUnitAmounts; + uint128 openStartTimestamp; + uint128 amountDistributedPerOpen; + } + + /// @notice Emitted when a set of packs is created. + event PackCreated(uint256 indexed packId, address recipient, uint256 totalPacksCreated); + + /// @notice Emitted when the opening of a pack is requested. + event PackOpenRequested(address indexed opener, uint256 indexed packId, uint256 amountToOpen, uint256 requestId); + + /// @notice Emitted when Chainlink VRF fulfills a random number request. + event PackRandomnessFulfilled(uint256 indexed packId, uint256 indexed requestId); + + /// @notice Emitted when a pack is opened. + event PackOpened( + uint256 indexed packId, + address indexed opener, + uint256 numOfPacksOpened, + Token[] rewardUnitsDistributed + ); + + /** + * @notice Creates a pack with the stated contents. + * + * @param contents The reward units to pack in the packs. + * @param numOfRewardUnits The number of reward units to create, for each asset specified in `contents`. + * @param packUri The (metadata) URI assigned to the packs created. + * @param openStartTimestamp The timestamp after which packs can be opened. + * @param amountDistributedPerOpen The number of reward units distributed per open. + * @param recipient The recipient of the packs created. + * + * @return packId The unique identifier of the created set of packs. + * @return packTotalSupply The total number of packs created. + */ + function createPack( + Token[] calldata contents, + uint256[] calldata numOfRewardUnits, + string calldata packUri, + uint128 openStartTimestamp, + uint128 amountDistributedPerOpen, + address recipient + ) external payable returns (uint256 packId, uint256 packTotalSupply); + + /** + * @notice Lets a pack owner request to open a pack. + * + * @param packId The identifier of the pack to open. + * @param amountToOpen The number of packs to open at once. + */ + function openPack(uint256 packId, uint256 amountToOpen) external returns (uint256 requestId); + + /// @notice Called by a pack opener to claim rewards from the opened pack. + function claimRewards() external returns (Token[] memory rewardUnits); + + /// @notice Called by a pack opener to open a pack in a single transaction, instead of calling openPack and claimRewards separately. + function openPackAndClaimRewards( + uint256 _packId, + uint256 _amountToOpen, + uint32 _callBackGasLimit + ) external returns (uint256); + + /// @notice Returns whether a pack opener is ready to call `claimRewards`. + function canClaimRewards(address _opener) external view returns (bool); +} diff --git a/contracts/prebuilts/interface/airdrop/IAirdropERC1155.sol b/contracts/prebuilts/interface/airdrop/IAirdropERC1155.sol new file mode 100644 index 000000000..7350e1029 --- /dev/null +++ b/contracts/prebuilts/interface/airdrop/IAirdropERC1155.sol @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/** + * Thirdweb's `Airdrop` contracts provide a lightweight and easy to use mechanism + * to drop tokens. + * + * `AirdropERC1155` contract is an airdrop contract for ERC1155 tokens. It follows a + * push mechanism for transfer of tokens to intended recipients. + */ + +interface IAirdropERC1155 { + /// @notice Emitted when an airdrop fails for a recipient address. + event AirdropFailed( + address indexed tokenAddress, + address indexed tokenOwner, + address indexed recipient, + uint256 tokenId, + uint256 amount + ); + + /** + * @notice Details of amount and recipient for airdropped token. + * + * @param recipient The recipient of the tokens. + * @param tokenId ID of the ERC1155 token being airdropped. + * @param amount The quantity of tokens to airdrop. + */ + struct AirdropContent { + address recipient; + uint256 tokenId; + uint256 amount; + } + + /** + * @notice Lets contract-owner send ERC1155 tokens to a list of addresses. + * @dev The token-owner should approve target tokens to Airdrop contract, + * which acts as operator for the tokens. + * + * @param tokenAddress The contract address of the tokens to transfer. + * @param tokenOwner The owner of the tokens to transfer. + * @param contents List containing recipient, tokenId to airdrop. + */ + function airdropERC1155(address tokenAddress, address tokenOwner, AirdropContent[] calldata contents) external; +} diff --git a/contracts/prebuilts/interface/airdrop/IAirdropERC1155Claimable.sol b/contracts/prebuilts/interface/airdrop/IAirdropERC1155Claimable.sol new file mode 100644 index 000000000..9fb65233b --- /dev/null +++ b/contracts/prebuilts/interface/airdrop/IAirdropERC1155Claimable.sol @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/** + * Thirdweb's `Airdrop` contracts provide a lightweight and easy to use mechanism + * to drop tokens. + * + * `AirdropERC1155Claimable` contract is an airdrop contract for ERC1155 tokens. It follows a + * pull mechanism for transfer of tokens, where allowlisted recipients can claim tokens from + * the contract. + */ + +interface IAirdropERC1155Claimable { + /// @dev Emitted when tokens are claimed. + event TokensClaimed( + address indexed claimer, + address indexed receiver, + uint256 indexed tokenId, + uint256 quantityClaimed + ); + + /** + * @notice Lets an account claim a given quantity of ERC1155 tokens. + * + * @param receiver The receiver of the tokens to claim. + * @param quantity The quantity of tokens to claim. + * @param tokenId Token Id to claim. + * @param proofs The proof of the claimer's inclusion in the merkle root allowlist + * of the claim conditions that apply. + * @param proofMaxQuantityForWallet The maximum number of tokens an address included in an + * allowlist can claim. + */ + function claim( + address receiver, + uint256 quantity, + uint256 tokenId, + bytes32[] calldata proofs, + uint256 proofMaxQuantityForWallet + ) external; +} diff --git a/contracts/prebuilts/interface/airdrop/IAirdropERC20.sol b/contracts/prebuilts/interface/airdrop/IAirdropERC20.sol new file mode 100644 index 000000000..86964d74d --- /dev/null +++ b/contracts/prebuilts/interface/airdrop/IAirdropERC20.sol @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/** + * Thirdweb's `Airdrop` contracts provide a lightweight and easy to use mechanism + * to drop tokens. + * + * `AirdropERC20` contract is an airdrop contract for ERC20 tokens. It follows a + * push mechanism for transfer of tokens to intended recipients. + */ + +interface IAirdropERC20 { + /// @notice Emitted when an airdrop fails for a recipient address. + event AirdropFailed( + address indexed tokenAddress, + address indexed tokenOwner, + address indexed recipient, + uint256 amount + ); + + /** + * @notice Details of amount and recipient for airdropped token. + * + * @param recipient The recipient of the tokens. + * @param amount The quantity of tokens to airdrop. + */ + struct AirdropContent { + address recipient; + uint256 amount; + } + + /** + * @notice Lets contract-owner send ERC20 tokens to a list of addresses. + * @dev The token-owner should approve target tokens to Airdrop contract, + * which acts as operator for the tokens. + * + * @param tokenAddress The contract address of the tokens to transfer. + * @param tokenOwner The owner of the tokens to transfer. + * @param contents List containing recipient, tokenId to airdrop. + */ + function airdropERC20( + address tokenAddress, + address tokenOwner, + AirdropContent[] calldata contents + ) external payable; +} diff --git a/contracts/prebuilts/interface/airdrop/IAirdropERC20Claimable.sol b/contracts/prebuilts/interface/airdrop/IAirdropERC20Claimable.sol new file mode 100644 index 000000000..e238910fa --- /dev/null +++ b/contracts/prebuilts/interface/airdrop/IAirdropERC20Claimable.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/** + * Thirdweb's `Airdrop` contracts provide a lightweight and easy to use mechanism + * to drop tokens. + * + * `AirdropERC20Claimable` contract is an airdrop contract for ERC20 tokens. It follows a + * pull mechanism for transfer of tokens, where allowlisted recipients can claim tokens from + * the contract. + */ + +interface IAirdropERC20Claimable { + /// @dev Emitted when tokens are claimed. + event TokensClaimed(address indexed claimer, address indexed receiver, uint256 quantityClaimed); + + /** + * @notice Lets an account claim a given quantity of NFTs. + * + * @param receiver The receiver of the NFTs to claim. + * @param quantity The quantity of NFTs to claim. + * @param proofs The proof of the claimer's inclusion in the merkle root allowlist + * of the claim conditions that apply. + * @param proofMaxQuantityForWallet The maximum number of NFTs an address included in an + * allowlist can claim. + */ + function claim( + address receiver, + uint256 quantity, + bytes32[] calldata proofs, + uint256 proofMaxQuantityForWallet + ) external; +} diff --git a/contracts/prebuilts/interface/airdrop/IAirdropERC721.sol b/contracts/prebuilts/interface/airdrop/IAirdropERC721.sol new file mode 100644 index 000000000..3d94d422c --- /dev/null +++ b/contracts/prebuilts/interface/airdrop/IAirdropERC721.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/** + * Thirdweb's `Airdrop` contracts provide a lightweight and easy to use mechanism + * to drop tokens. + * + * `AirdropERC721` contract is an airdrop contract for ERC721 tokens. It follows a + * push mechanism for transfer of tokens to intended recipients. + */ + +interface IAirdropERC721 { + /// @notice Emitted when an airdrop fails for a recipient address. + event AirdropFailed( + address indexed tokenAddress, + address indexed tokenOwner, + address indexed recipient, + uint256 tokenId + ); + + /** + * @notice Details of amount and recipient for airdropped token. + * + * @param recipient The recipient of the tokens. + * @param tokenId ID of the ERC721 token being airdropped. + */ + struct AirdropContent { + address recipient; + uint256 tokenId; + } + + /** + * @notice Lets contract-owner send ERC721 tokens to a list of addresses. + * @dev The token-owner should approve target tokens to Airdrop contract, + * which acts as operator for the tokens. + * + * @param tokenAddress The contract address of the tokens to transfer. + * @param tokenOwner The owner of the tokens to transfer. + * @param contents List containing recipient, tokenId to airdrop. + */ + function airdropERC721(address tokenAddress, address tokenOwner, AirdropContent[] calldata contents) external; +} diff --git a/contracts/prebuilts/interface/airdrop/IAirdropERC721Claimable.sol b/contracts/prebuilts/interface/airdrop/IAirdropERC721Claimable.sol new file mode 100644 index 000000000..4ca9aefd1 --- /dev/null +++ b/contracts/prebuilts/interface/airdrop/IAirdropERC721Claimable.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/** + * Thirdweb's `Airdrop` contracts provide a lightweight and easy to use mechanism + * to drop tokens. + * + * `AirdropERC721Claimable` contract is an airdrop contract for ERC721 tokens. It follows a + * pull mechanism for transfer of tokens, where allowlisted recipients can claim tokens from + * the contract. + */ + +interface IAirdropERC721Claimable { + /// @dev Emitted when tokens are claimed. + event TokensClaimed(address indexed claimer, address indexed receiver, uint256 quantityClaimed); + + /** + * @notice Lets an account claim a given quantity of NFTs. + * + * @param receiver The receiver of the NFTs to claim. + * @param quantity The quantity of NFTs to claim. + * @param proofs The proof of the claimer's inclusion in the merkle root allowlist + * of the claim conditions that apply. + * @param proofMaxQuantityForWallet The maximum number of NFTs an address included in an + * allowlist can claim. + */ + function claim( + address receiver, + uint256 quantity, + bytes32[] calldata proofs, + uint256 proofMaxQuantityForWallet + ) external; +} diff --git a/contracts/prebuilts/interface/drop/IDropClaimCondition.sol b/contracts/prebuilts/interface/drop/IDropClaimCondition.sol new file mode 100644 index 000000000..02c3ac5cb --- /dev/null +++ b/contracts/prebuilts/interface/drop/IDropClaimCondition.sol @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "@openzeppelin/contracts-upgradeable/utils/structs/BitMapsUpgradeable.sol"; + +/** + * Thirdweb's 'Drop' contracts are distribution mechanisms for tokens. + * + * A contract admin (i.e. a holder of `DEFAULT_ADMIN_ROLE`) can set a series of claim conditions, + * ordered by their respective `startTimestamp`. A claim condition defines criteria under which + * accounts can mint tokens. Claim conditions can be overwritten or added to by the contract admin. + * At any moment, there is only one active claim condition. + */ + +interface IDropClaimCondition { + /** + * @notice The criteria that make up a claim condition. + * + * @param startTimestamp The unix timestamp after which the claim condition applies. + * The same claim condition applies until the `startTimestamp` + * of the next claim condition. + * + * @param maxClaimableSupply The maximum total number of tokens that can be claimed under + * the claim condition. + * + * @param supplyClaimed At any given point, the number of tokens that have been claimed + * under the claim condition. + * + * @param quantityLimitPerWallet The maximum number of tokens that can be claimed by a wallet. + * + * @param waitTimeInSecondsBetweenClaims The least number of seconds an account must wait after claiming + * tokens, to be able to claim tokens again. + * + * @param merkleRoot The allowlist of addresses that can claim tokens under the claim + * condition. + * + * @param pricePerToken The price required to pay per token claimed. + * + * @param currency The currency in which the `pricePerToken` must be paid. + */ + struct ClaimCondition { + uint256 startTimestamp; + uint256 maxClaimableSupply; + uint256 supplyClaimed; + uint256 quantityLimitPerWallet; + uint256 waitTimeInSecondsBetweenClaims; + bytes32 merkleRoot; + uint256 pricePerToken; + address currency; + } + + /** + * @notice The set of all claim conditions, at any given moment. + * Claim Phase ID = [currentStartId, currentStartId + length - 1]; + * + * @param currentStartId The uid for the first claim condition amongst the current set of + * claim conditions. The uid for each next claim condition is one + * more than the previous claim condition's uid. + * + * @param count The total number of phases / claim conditions in the list + * of claim conditions. + * + * @param phases The claim conditions at a given uid. Claim conditions + * are ordered in an ascending order by their `startTimestamp`. + * + * @param limitLastClaimTimestamp Map from an account and uid for a claim condition, to the last timestamp + * at which the account claimed tokens under that claim condition. + * + * @param limitMerkleProofClaim Map from a claim condition uid to whether an address in an allowlist + * has already claimed tokens i.e. used their place in the allowlist. + * + * @param supplyClaimedByWallet Map from a claim condition uid and account to supply claimed by account. + */ + struct ClaimConditionList { + uint256 currentStartId; + uint256 count; + mapping(uint256 => ClaimCondition) phases; + mapping(uint256 => mapping(address => uint256)) limitLastClaimTimestamp; + mapping(uint256 => BitMapsUpgradeable.BitMap) limitMerkleProofClaim; + mapping(uint256 => mapping(address => uint256)) supplyClaimedByWallet; + } +} diff --git a/contracts/prebuilts/interface/drop/IDropERC1155.sol b/contracts/prebuilts/interface/drop/IDropERC1155.sol new file mode 100644 index 000000000..2c3d4181a --- /dev/null +++ b/contracts/prebuilts/interface/drop/IDropERC1155.sol @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@openzeppelin/contracts-upgradeable/token/ERC1155/IERC1155Upgradeable.sol"; +import "./IDropClaimCondition.sol"; + +/** + * Thirdweb's 'Drop' contracts are distribution mechanisms for tokens. The + * `DropERC721` contract is a distribution mechanism for ERC721 tokens. + * + * A minter wallet (i.e. holder of `MINTER_ROLE`) can (lazy)mint 'n' tokens + * at once by providing a single base URI for all tokens being lazy minted. + * The URI for each of the 'n' tokens lazy minted is the provided base URI + + * `{tokenId}` of the respective token. (e.g. "ipsf://Qmece.../1"). + * + * A minter can choose to lazy mint 'delayed-reveal' tokens. More on 'delayed-reveal' + * tokens in [this article](https://blog.thirdweb.com/delayed-reveal-nfts). + * + * A contract admin (i.e. holder of `DEFAULT_ADMIN_ROLE`) can create claim conditions + * with non-overlapping time windows, and accounts can claim the tokens according to + * restrictions defined in the claim condition that is active at the time of the transaction. + */ + +interface IDropERC1155 is IERC1155Upgradeable, IDropClaimCondition { + struct AllowlistProof { + bytes32[] proof; + uint256 maxQuantityInAllowlist; + } + + /// @dev Emitted when tokens are claimed. + event TokensClaimed( + uint256 indexed claimConditionIndex, + uint256 indexed tokenId, + address indexed claimer, + address receiver, + uint256 quantityClaimed + ); + + /// @dev Emitted when tokens are lazy minted. + event TokensLazyMinted(uint256 startTokenId, uint256 endTokenId, string baseURI); + + /// @dev Emitted when new claim conditions are set for a token. + event ClaimConditionsUpdated(uint256 indexed tokenId, ClaimCondition[] claimConditions); + + /// @dev Emitted when the global max supply of a token is updated. + event MaxTotalSupplyUpdated(uint256 tokenId, uint256 maxTotalSupply); + + /// @dev Emitted when the sale recipient for a particular tokenId is updated. + event SaleRecipientForTokenUpdated(uint256 indexed tokenId, address saleRecipient); + + /** + * @notice Lets an account with `MINTER_ROLE` lazy mint 'n' NFTs. + * The URIs for each token is the provided `_baseURIForTokens` + `{tokenId}`. + * + * @param amount The amount of NFTs to lazy mint. + * @param baseURIForTokens The URI for the NFTs to lazy mint. + */ + function lazyMint(uint256 amount, string calldata baseURIForTokens) external; + + /** + * @notice Lets an account claim a given quantity of NFTs. + * + * @param receiver The receiver of the NFT to claim. + * @param tokenId The tokenId of the NFT to claim. + * @param quantity The quantity of the NFT to claim. + * @param currency The currency in which to pay for the claim. + * @param pricePerToken The price per token to pay for the claim. + * @param allowlistProof The proof of the claimer's inclusion in the merkle root allowlist + * of the claim conditions that apply. + * @param data Arbitrary bytes data that can be leveraged in the implementation of this interface. + */ + function claim( + address receiver, + uint256 tokenId, + uint256 quantity, + address currency, + uint256 pricePerToken, + AllowlistProof calldata allowlistProof, + bytes memory data + ) external payable; + + /** + * @notice Lets a contract admin (account with `DEFAULT_ADMIN_ROLE`) set claim conditions. + * + * @param tokenId The token ID for which to set mint conditions. + * @param phases Claim conditions in ascending order by `startTimestamp`. + * @param resetClaimEligibility Whether to reset `limitLastClaimTimestamp` and + * `limitMerkleProofClaim` values when setting new + * claim conditions. + */ + function setClaimConditions(uint256 tokenId, ClaimCondition[] calldata phases, bool resetClaimEligibility) external; +} diff --git a/contracts/prebuilts/interface/drop/IDropERC20.sol b/contracts/prebuilts/interface/drop/IDropERC20.sol new file mode 100644 index 000000000..d0bde6c7c --- /dev/null +++ b/contracts/prebuilts/interface/drop/IDropERC20.sol @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@openzeppelin/contracts-upgradeable/token/ERC20/IERC20Upgradeable.sol"; +import "./IDropClaimCondition.sol"; + +/** + * Thirdweb's 'Drop' contracts are distribution mechanisms for tokens. The + * `DropERC20` contract is a distribution mechanism for ERC20 tokens. + * + * A contract admin (i.e. holder of `DEFAULT_ADMIN_ROLE`) can create claim conditions + * with non-overlapping time windows, and accounts can claim the tokens according to + * restrictions defined in the claim condition that is active at the time of the transaction. + */ + +interface IDropERC20 is IERC20Upgradeable, IDropClaimCondition { + struct AllowlistProof { + bytes32[] proof; + uint256 maxQuantityInAllowlist; + } + + /// @dev Emitted when tokens are claimed. + event TokensClaimed( + uint256 indexed claimConditionIndex, + address indexed claimer, + address indexed receiver, + uint256 quantityClaimed + ); + + /// @dev Emitted when new claim conditions are set. + event ClaimConditionsUpdated(ClaimCondition[] claimConditions); + + /// @dev Emitted when the global max supply of tokens is updated. + event MaxTotalSupplyUpdated(uint256 maxTotalSupply); + + /// @dev Emitted when the contract URI is updated. + event ContractURIUpdated(string prevURI, string newURI); + + /** + * @notice Lets an account claim a given quantity of NFTs. + * + * @param receiver The receiver of the tokens to claim. + * @param quantity The quantity of tokens to claim. + * @param currency The currency in which to pay for the claim. + * @param pricePerToken The price per token to pay for the claim. + * @param allowlistProof The proof of the claimer's inclusion in the merkle root allowlist + * of the claim conditions that apply. + * @param data Arbitrary bytes data that can be leveraged in the implementation of this interface. + */ + function claim( + address receiver, + uint256 quantity, + address currency, + uint256 pricePerToken, + AllowlistProof calldata allowlistProof, + bytes memory data + ) external payable; + + /** + * @notice Lets a contract admin (account with `DEFAULT_ADMIN_ROLE`) set claim conditions. + * + * @param phases Claim conditions in ascending order by `startTimestamp`. + * @param resetClaimEligibility Whether to reset `limitLastClaimTimestamp` and + * `limitMerkleProofClaim` values when setting new + * claim conditions. + */ + function setClaimConditions(ClaimCondition[] calldata phases, bool resetClaimEligibility) external; +} diff --git a/contracts/prebuilts/interface/drop/IDropERC721.sol b/contracts/prebuilts/interface/drop/IDropERC721.sol new file mode 100644 index 000000000..e115de49c --- /dev/null +++ b/contracts/prebuilts/interface/drop/IDropERC721.sol @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@openzeppelin/contracts-upgradeable/token/ERC721/IERC721Upgradeable.sol"; +import "./IDropClaimCondition.sol"; + +/** + * Thirdweb's 'Drop' contracts are distribution mechanisms for tokens. The + * `DropERC721` contract is a distribution mechanism for ERC721 tokens. + * + * A minter wallet (i.e. holder of `MINTER_ROLE`) can (lazy)mint 'n' tokens + * at once by providing a single base URI for all tokens being lazy minted. + * The URI for each of the 'n' tokens lazy minted is the provided base URI + + * `{tokenId}` of the respective token. (e.g. "ipsf://Qmece.../1"). + * + * A minter can choose to lazy mint 'delayed-reveal' tokens. More on 'delayed-reveal' + * tokens in [this article](https://blog.thirdweb.com/delayed-reveal-nfts). + * + * A contract admin (i.e. holder of `DEFAULT_ADMIN_ROLE`) can create claim conditions + * with non-overlapping time windows, and accounts can claim the tokens according to + * restrictions defined in the claim condition that is active at the time of the transaction. + */ + +interface IDropERC721 is IERC721Upgradeable, IDropClaimCondition { + struct AllowlistProof { + bytes32[] proof; + uint256 maxQuantityInAllowlist; + } + + /// @dev Emitted when tokens are claimed. + event TokensClaimed( + uint256 indexed claimConditionIndex, + address indexed claimer, + address indexed receiver, + uint256 startTokenId, + uint256 quantityClaimed + ); + + /// @dev Emitted when tokens are lazy minted. + event TokensLazyMinted(uint256 startTokenId, uint256 endTokenId, string baseURI, bytes encryptedBaseURI); + + /// @dev Emitted when the URI for a batch of 'delayed-reveal' NFTs is revealed. + event NFTRevealed(uint256 endTokenId, string revealedURI); + + /// @dev Emitted when new claim conditions are set. + event ClaimConditionsUpdated(ClaimCondition[] claimConditions); + + /// @dev Emitted when the global max supply of tokens is updated. + event MaxTotalSupplyUpdated(uint256 maxTotalSupply); + + /** + * @notice Lets an account with `MINTER_ROLE` lazy mint 'n' NFTs. + * The URIs for each token is the provided `_baseURIForTokens` + `{tokenId}`. + * + * @param amount The amount of NFTs to lazy mint. + * @param baseURIForTokens The URI for the NFTs to lazy mint. If lazy minting + * 'delayed-reveal' NFTs, the is a URI for NFTs in the + * un-revealed state. + * @param encryptedBaseURI If lazy minting 'delayed-reveal' NFTs, this is the + * result of encrypting the URI of the NFTs in the revealed + * state. + */ + function lazyMint(uint256 amount, string calldata baseURIForTokens, bytes calldata encryptedBaseURI) external; + + /** + * @notice Lets an account claim a given quantity of NFTs. + * + * @param receiver The receiver of the NFTs to claim. + * @param quantity The quantity of NFTs to claim. + * @param currency The currency in which to pay for the claim. + * @param pricePerToken The price per token to pay for the claim. + * @param allowlistProof The proof of the claimer's inclusion in the merkle root allowlist + * of the claim conditions that apply. + * @param data Arbitrary bytes data that can be leveraged in the implementation of this interface. + */ + function claim( + address receiver, + uint256 quantity, + address currency, + uint256 pricePerToken, + AllowlistProof calldata allowlistProof, + bytes memory data + ) external payable; + + /** + * @notice Lets a contract admin (account with `DEFAULT_ADMIN_ROLE`) set claim conditions. + * + * @param phases Claim conditions in ascending order by `startTimestamp`. + * @param resetClaimEligibility Whether to reset `limitLastClaimTimestamp` and + * `limitMerkleProofClaim` values when setting new + * claim conditions. + */ + function setClaimConditions(ClaimCondition[] calldata phases, bool resetClaimEligibility) external; +} diff --git a/contracts/prebuilts/interface/marketplace/IMarketplace.sol b/contracts/prebuilts/interface/marketplace/IMarketplace.sol new file mode 100644 index 000000000..d485451d4 --- /dev/null +++ b/contracts/prebuilts/interface/marketplace/IMarketplace.sol @@ -0,0 +1,329 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "../../../infra/interface/IThirdwebContract.sol"; +import "../../../extension/interface/IPlatformFee.sol"; + +interface IMarketplace is IThirdwebContract, IPlatformFee { + /// @notice Type of the tokens that can be listed for sale. + enum TokenType { + ERC1155, + ERC721 + } + + /** + * @notice The two types of listings. + * `Direct`: NFTs listed for sale at a fixed price. + * `Auction`: NFTs listed for sale in an auction. + */ + enum ListingType { + Direct, + Auction + } + + /** + * @notice The information related to either (1) an offer on a direct listing, or (2) a bid in an auction. + * + * @dev The type of the listing at ID `lisingId` determines how the `Offer` is interpreted. + * If the listing is of type `Direct`, the `Offer` is interpreted as an offer to a direct listing. + * If the listing is of type `Auction`, the `Offer` is interpreted as a bid in an auction. + * + * @param listingId The uid of the listing the offer is made to. + * @param offeror The account making the offer. + * @param quantityWanted The quantity of tokens from the listing wanted by the offeror. + * This is the entire listing quantity if the listing is an auction. + * @param currency The currency in which the offer is made. + * @param pricePerToken The price per token offered to the lister. + * @param expirationTimestamp The timestamp after which a seller cannot accept this offer. + */ + struct Offer { + uint256 listingId; + address offeror; + uint256 quantityWanted; + address currency; + uint256 pricePerToken; + uint256 expirationTimestamp; + } + + /** + * @dev For use in `createListing` as a parameter type. + * + * @param assetContract The contract address of the NFT to list for sale. + + * @param tokenId The tokenId on `assetContract` of the NFT to list for sale. + + * @param startTime The unix timestamp after which the listing is active. For direct listings: + * 'active' means NFTs can be bought from the listing. For auctions, + * 'active' means bids can be made in the auction. + * + * @param secondsUntilEndTime No. of seconds after `startTime`, after which the listing is inactive. + * For direct listings: 'inactive' means NFTs cannot be bought from the listing. + * For auctions: 'inactive' means bids can no longer be made in the auction. + * + * @param quantityToList The quantity of NFT of ID `tokenId` on the given `assetContract` to list. For + * ERC 721 tokens to list for sale, the contract strictly defaults this to `1`, + * Regardless of the value of `quantityToList` passed. + * + * @param currencyToAccept For direct listings: the currency in which a buyer must pay the listing's fixed price + * to buy the NFT(s). For auctions: the currency in which the bidders must make bids. + * + * @param reservePricePerToken For direct listings: this value is ignored. For auctions: the minimum bid amount of + * the auction is `reservePricePerToken * quantityToList` + * + * @param buyoutPricePerToken For direct listings: interpreted as 'price per token' listed. For auctions: if + * `buyoutPricePerToken` is greater than 0, and a bidder's bid is at least as great as + * `buyoutPricePerToken * quantityToList`, the bidder wins the auction, and the auction + * is closed. + * + * @param listingType The type of listing to create - a direct listing or an auction. + **/ + struct ListingParameters { + address assetContract; + uint256 tokenId; + uint256 startTime; + uint256 secondsUntilEndTime; + uint256 quantityToList; + address currencyToAccept; + uint256 reservePricePerToken; + uint256 buyoutPricePerToken; + ListingType listingType; + } + + /** + * @notice The information related to a listing; either (1) a direct listing, or (2) an auction listing. + * + * @dev For direct listings: + * (1) `reservePricePerToken` is ignored. + * (2) `buyoutPricePerToken` is simply interpreted as 'price per token'. + * + * @param listingId The uid for the listing. + * + * @param tokenOwner The owner of the tokens listed for sale. + * + * @param assetContract The contract address of the NFT to list for sale. + + * @param tokenId The tokenId on `assetContract` of the NFT to list for sale. + + * @param startTime The unix timestamp after which the listing is active. For direct listings: + * 'active' means NFTs can be bought from the listing. For auctions, + * 'active' means bids can be made in the auction. + * + * @param endTime The timestamp after which the listing is inactive. + * For direct listings: 'inactive' means NFTs cannot be bought from the listing. + * For auctions: 'inactive' means bids can no longer be made in the auction. + * + * @param quantity The quantity of NFT of ID `tokenId` on the given `assetContract` listed. For + * ERC 721 tokens to list for sale, the contract strictly defaults this to `1`, + * Regardless of the value of `quantityToList` passed. + * + * @param currency For direct listings: the currency in which a buyer must pay the listing's fixed price + * to buy the NFT(s). For auctions: the currency in which the bidders must make bids. + * + * @param reservePricePerToken For direct listings: this value is ignored. For auctions: the minimum bid amount of + * the auction is `reservePricePerToken * quantityToList` + * + * @param buyoutPricePerToken For direct listings: interpreted as 'price per token' listed. For auctions: if + * `buyoutPricePerToken` is greater than 0, and a bidder's bid is at least as great as + * `buyoutPricePerToken * quantityToList`, the bidder wins the auction, and the auction + * is closed. + * + * @param tokenType The type of the token(s) listed for for sale -- ERC721 or ERC1155 + * + * @param listingType The type of listing to create - a direct listing or an auction. + **/ + struct Listing { + uint256 listingId; + address tokenOwner; + address assetContract; + uint256 tokenId; + uint256 startTime; + uint256 endTime; + uint256 quantity; + address currency; + uint256 reservePricePerToken; + uint256 buyoutPricePerToken; + TokenType tokenType; + ListingType listingType; + } + + /// @dev Emitted when a new listing is created. + event ListingAdded( + uint256 indexed listingId, + address indexed assetContract, + address indexed lister, + Listing listing + ); + + /// @dev Emitted when the parameters of a listing are updated. + event ListingUpdated(uint256 indexed listingId, address indexed listingCreator); + + /// @dev Emitted when a listing is cancelled. + event ListingRemoved(uint256 indexed listingId, address indexed listingCreator); + + /** + * @dev Emitted when a buyer buys from a direct listing, or a lister accepts some + * buyer's offer to their direct listing. + */ + event NewSale( + uint256 indexed listingId, + address indexed assetContract, + address indexed lister, + address buyer, + uint256 quantityBought, + uint256 totalPricePaid + ); + + /// @dev Emitted when (1) a new offer is made to a direct listing, or (2) when a new bid is made in an auction. + event NewOffer( + uint256 indexed listingId, + address indexed offeror, + ListingType indexed listingType, + uint256 quantityWanted, + uint256 totalOfferAmount, + address currency + ); + + /// @dev Emitted when an auction is closed. + event AuctionClosed( + uint256 indexed listingId, + address indexed closer, + bool indexed cancelled, + address auctionCreator, + address winningBidder + ); + + /// @dev Emitted when auction buffers are updated. + event AuctionBuffersUpdated(uint256 timeBuffer, uint256 bidBufferBps); + + /** + * @notice Lets a token owner list tokens (ERC 721 or ERC 1155) for sale in a direct listing, or an auction. + * + * @dev NFTs to list for sale in an auction are escrowed in Marketplace. For direct listings, the contract + * only checks whether the listing's creator owns and has approved Marketplace to transfer the NFTs to list. + * + * @param _params The parameters that govern the listing to be created. + */ + function createListing(ListingParameters memory _params) external; + + /** + * @notice Lets a listing's creator edit the listing's parameters. A direct listing can be edited whenever. + * An auction listing cannot be edited after the auction has started. + * + * @param _listingId The uid of the listing to edit. + * + * @param _quantityToList The amount of NFTs to list for sale in the listing. For direct listings, the contract + * only checks whether the listing creator owns and has approved Marketplace to transfer + * `_quantityToList` amount of NFTs to list for sale. For auction listings, the contract + * ensures that exactly `_quantityToList` amount of NFTs to list are escrowed. + * + * @param _reservePricePerToken For direct listings: this value is ignored. For auctions: the minimum bid amount of + * the auction is `reservePricePerToken * quantityToList` + * + * @param _buyoutPricePerToken For direct listings: interpreted as 'price per token' listed. For auctions: if + * `buyoutPricePerToken` is greater than 0, and a bidder's bid is at least as great as + * `buyoutPricePerToken * quantityToList`, the bidder wins the auction, and the auction + * is closed. + * + * @param _currencyToAccept For direct listings: the currency in which a buyer must pay the listing's fixed price + * to buy the NFT(s). For auctions: the currency in which the bidders must make bids. + * + * @param _startTime The unix timestamp after which listing is active. For direct listings: + * 'active' means NFTs can be bought from the listing. For auctions, + * 'active' means bids can be made in the auction. + * + * @param _secondsUntilEndTime No. of seconds after the provided `_startTime`, after which the listing is inactive. + * For direct listings: 'inactive' means NFTs cannot be bought from the listing. + * For auctions: 'inactive' means bids can no longer be made in the auction. + */ + function updateListing( + uint256 _listingId, + uint256 _quantityToList, + uint256 _reservePricePerToken, + uint256 _buyoutPricePerToken, + address _currencyToAccept, + uint256 _startTime, + uint256 _secondsUntilEndTime + ) external; + + /** + * @notice Lets a direct listing creator cancel their listing. + * + * @param _listingId The unique Id of the listing to cancel. + */ + function cancelDirectListing(uint256 _listingId) external; + + /** + * @notice Lets someone buy a given quantity of tokens from a direct listing by paying the fixed price. + * + * @param _listingId The uid of the direct listing to buy from. + * @param _buyFor The receiver of the NFT being bought. + * @param _quantity The amount of NFTs to buy from the direct listing. + * @param _currency The currency to pay the price in. + * @param _totalPrice The total price to pay for the tokens being bought. + * + * @dev A sale will fail to execute if either: + * (1) buyer does not own or has not approved Marketplace to transfer the appropriate + * amount of currency (or hasn't sent the appropriate amount of native tokens) + * + * (2) the lister does not own or has removed Marketplace's + * approval to transfer the tokens listed for sale. + */ + function buy( + uint256 _listingId, + address _buyFor, + uint256 _quantity, + address _currency, + uint256 _totalPrice + ) external payable; + + /** + * @notice Lets someone make an offer to a direct listing, or bid in an auction. + * + * @dev Each (address, listing ID) pair maps to a single unique offer. So e.g. if a buyer makes + * makes two offers to the same direct listing, the last offer is counted as the buyer's + * offer to that listing. + * + * @param _listingId The unique ID of the listing to make an offer/bid to. + * + * @param _quantityWanted For auction listings: the 'quantity wanted' is the total amount of NFTs + * being auctioned, regardless of the value of `_quantityWanted` passed. + * For direct listings: `_quantityWanted` is the quantity of NFTs from the + * listing, for which the offer is being made. + * + * @param _currency For auction listings: the 'currency of the bid' is the currency accepted + * by the auction, regardless of the value of `_currency` passed. For direct + * listings: this is the currency in which the offer is made. + * + * @param _pricePerToken For direct listings: offered price per token. For auction listings: the bid + * amount per token. The total offer/bid amount is `_quantityWanted * _pricePerToken`. + * + * @param _expirationTimestamp For auction listings: inapplicable. For direct listings: The timestamp after which + * the seller can no longer accept the offer. + */ + function offer( + uint256 _listingId, + uint256 _quantityWanted, + address _currency, + uint256 _pricePerToken, + uint256 _expirationTimestamp + ) external payable; + + /** + * @notice Lets a listing's creator accept an offer to their direct listing. + * @param _listingId The unique ID of the listing for which to accept the offer. + * @param _offeror The address of the buyer whose offer is to be accepted. + * @param _currency The currency of the offer that is to be accepted. + * @param _totalPrice The total price of the offer that is to be accepted. + */ + function acceptOffer(uint256 _listingId, address _offeror, address _currency, uint256 _totalPrice) external; + + /** + * @notice Lets any account close an auction on behalf of either the (1) auction's creator, or (2) winning bidder. + * For (1): The auction creator is sent the winning bid amount. + * For (2): The winning bidder is sent the auctioned NFTs. + * + * @param _listingId The uid of the listing (the auction to close). + * @param _closeFor For whom the auction is being closed - the auction creator or winning bidder. + */ + function closeAuction(uint256 _listingId, address _closeFor) external; +} diff --git a/contracts/prebuilts/interface/staking/IEditionStake.sol b/contracts/prebuilts/interface/staking/IEditionStake.sol new file mode 100644 index 000000000..e8fd48353 --- /dev/null +++ b/contracts/prebuilts/interface/staking/IEditionStake.sol @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/** + * Thirdweb's EditionStake smart contract allows users to stake their ERC-1155 NFTs + * and earn rewards in form of an ERC-20 token. + * + * note: + * - Reward token and staking token can't be changed after deployment. + * + * - ERC1155 tokens from only the specified contract can be staked. + * + * - All token/NFT transfers require approval on their respective contracts. + * + * - Admin must deposit reward tokens using the `depositRewardTokens` function only. + * Any direct transfers may cause unintended consequences, such as locking of tokens. + * + * - Users must stake NFTs using the `stake` function only. + * Any direct transfers may cause unintended consequences, such as locking of NFTs. + */ + +interface IEditionStake { + /// @dev Emitted when contract admin withdraws reward tokens. + event RewardTokensWithdrawnByAdmin(uint256 _amount); + + /// @dev Emitted when contract admin deposits reward tokens. + event RewardTokensDepositedByAdmin(uint256 _amount); + + /** + * @notice Lets a contract admin (account with `DEFAULT_ADMIN_ROLE`) deposit reward-tokens. + * + * note: Tokens should be approved on the reward-token contract before depositing. + * + * @param _amount Amount of tokens to deposit. + */ + function depositRewardTokens(uint256 _amount) external payable; + + /** + * @notice Lets a contract admin (account with `DEFAULT_ADMIN_ROLE`) withdraw reward-tokens. + * Useful for removing excess balance, thus preventing locking of tokens. + * + * @param _amount Amount of tokens to deposit. + */ + function withdrawRewardTokens(uint256 _amount) external; +} diff --git a/contracts/prebuilts/interface/staking/INFTStake.sol b/contracts/prebuilts/interface/staking/INFTStake.sol new file mode 100644 index 000000000..0ff235059 --- /dev/null +++ b/contracts/prebuilts/interface/staking/INFTStake.sol @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/** + * Thirdweb's NFTStake smart contract allows users to stake their ERC-721 NFTs + * and earn rewards in form of an ERC-20 token. + * + * note: + * - Reward token and staking token can't be changed after deployment. + * + * - ERC721 tokens from only the specified contract can be staked. + * + * - All token/NFT transfers require approval on their respective contracts. + * + * - Admin must deposit reward tokens using the `depositRewardTokens` function only. + * Any direct transfers may cause unintended consequences, such as locking of tokens. + * + * - Users must stake NFTs using the `stake` function only. + * Any direct transfers may cause unintended consequences, such as locking of NFTs. + */ + +interface INFTStake { + /// @dev Emitted when contract admin withdraws reward tokens. + event RewardTokensWithdrawnByAdmin(uint256 _amount); + + /// @dev Emitted when contract admin deposits reward tokens. + event RewardTokensDepositedByAdmin(uint256 _amount); + + /** + * @notice Lets a contract admin (account with `DEFAULT_ADMIN_ROLE`) deposit reward-tokens. + * + * note: Tokens should be approved on the reward-token contract before depositing. + * + * @param _amount Amount of tokens to deposit. + */ + function depositRewardTokens(uint256 _amount) external payable; + + /** + * @notice Lets a contract admin (account with `DEFAULT_ADMIN_ROLE`) withdraw reward-tokens. + * Useful for removing excess balance, thus preventing locking of tokens. + * + * @param _amount Amount of tokens to deposit. + */ + function withdrawRewardTokens(uint256 _amount) external; +} diff --git a/contracts/prebuilts/interface/staking/ITokenStake.sol b/contracts/prebuilts/interface/staking/ITokenStake.sol new file mode 100644 index 000000000..25fb220fe --- /dev/null +++ b/contracts/prebuilts/interface/staking/ITokenStake.sol @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/** + * Thirdweb's TokenStake smart contract allows users to stake their ERC-20 Tokens + * and earn rewards in form of a different ERC-20 token. + * + * note: + * - Reward token and staking token can't be changed after deployment. + * Reward token contract can't be same as the staking token contract. + * + * - ERC20 tokens from only the specified contract can be staked. + * + * - All token transfers require approval on their respective token-contracts. + * + * - Admin must deposit reward tokens using the `depositRewardTokens` function only. + * Any direct transfers may cause unintended consequences, such as locking of tokens. + * + * - Users must stake tokens using the `stake` function only. + * Any direct transfers may cause unintended consequences, such as locking of tokens. + */ + +interface ITokenStake { + /// @dev Emitted when contract admin withdraws reward tokens. + event RewardTokensWithdrawnByAdmin(uint256 _amount); + + /// @dev Emitted when contract admin deposits reward tokens. + event RewardTokensDepositedByAdmin(uint256 _amount); + + /** + * @notice Lets a contract admin (account with `DEFAULT_ADMIN_ROLE`) deposit reward-tokens. + * + * note: Tokens should be approved on the reward-token contract before depositing. + * + * @param _amount Amount of tokens to deposit. + */ + function depositRewardTokens(uint256 _amount) external payable; + + /** + * @notice Lets a contract admin (account with `DEFAULT_ADMIN_ROLE`) withdraw reward-tokens. + * Useful for removing excess balance, thus preventing locking of tokens. + * + * @param _amount Amount of tokens to deposit. + */ + function withdrawRewardTokens(uint256 _amount) external; +} diff --git a/contracts/prebuilts/interface/token/ITokenERC1155.sol b/contracts/prebuilts/interface/token/ITokenERC1155.sol new file mode 100644 index 000000000..656878796 --- /dev/null +++ b/contracts/prebuilts/interface/token/ITokenERC1155.sol @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@openzeppelin/contracts-upgradeable/token/ERC1155/IERC1155Upgradeable.sol"; + +/** + * `SignatureMint1155` is an ERC 1155 contract. It lets anyone mint NFTs by producing a mint request + * and a signature (produced by an account with MINTER_ROLE, signing the mint request). + */ +interface ITokenERC1155 is IERC1155Upgradeable { + /** + * @notice The body of a request to mint NFTs. + * + * @param to The receiver of the NFTs to mint. + * @param royaltyRecipient The recipient of the minted NFT's secondary sales royalties. + * @param primarySaleRecipient The recipient of the minted NFT's primary sales proceeds. + * @param tokenId Optional: specify only if not first mint. + * @param uri The URI of the NFT to mint. + * @param quantity The quantity of NFTs to mint. + * @param pricePerToken Price to pay for minting with the signature. + * @param currency The currency in which the price per token must be paid. + * @param validityStartTimestamp The unix timestamp after which the request is valid. + * @param validityEndTimestamp The unix timestamp after which the request expires. + * @param uid A unique identifier for the request. + */ + struct MintRequest { + address to; + address royaltyRecipient; + uint256 royaltyBps; + address primarySaleRecipient; + uint256 tokenId; + string uri; + uint256 quantity; + uint256 pricePerToken; + address currency; + uint128 validityStartTimestamp; + uint128 validityEndTimestamp; + bytes32 uid; + } + + /// @dev Emitted when an account with MINTER_ROLE mints an NFT. + event TokensMinted(address indexed mintedTo, uint256 indexed tokenIdMinted, string uri, uint256 quantityMinted); + + /// @dev Emitted when tokens are minted. + event TokensMintedWithSignature( + address indexed signer, + address indexed mintedTo, + uint256 indexed tokenIdMinted, + MintRequest mintRequest + ); + + /** + * @notice Verifies that a mint request is signed by an account holding + * MINTER_ROLE (at the time of the function call). + * + * @param req The mint request. + * @param signature The signature produced by an account signing the mint request. + * + * returns (success, signer) Result of verification and the recovered address. + */ + function verify( + MintRequest calldata req, + bytes calldata signature + ) external view returns (bool success, address signer); + + /** + * @notice Lets an account with MINTER_ROLE mint an NFT. + * + * @param to The address to mint the NFT to. + * @param tokenId The tokenId of the NFTs to mint + * @param uri The URI to assign to the NFT. + * @param amount The number of copies of the NFT to mint. + * + */ + function mintTo(address to, uint256 tokenId, string calldata uri, uint256 amount) external; + + /** + * @notice Mints an NFT according to the provided mint request. + * + * @param req The mint request. + * @param signature he signature produced by an account signing the mint request. + */ + function mintWithSignature(MintRequest calldata req, bytes calldata signature) external payable; +} diff --git a/contracts/prebuilts/interface/token/ITokenERC20.sol b/contracts/prebuilts/interface/token/ITokenERC20.sol new file mode 100644 index 000000000..4a97956df --- /dev/null +++ b/contracts/prebuilts/interface/token/ITokenERC20.sol @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/IERC20MetadataUpgradeable.sol"; + +interface ITokenERC20 is IERC20MetadataUpgradeable { + /** + * @notice The body of a request to mint tokens. + * + * @param to The receiver of the tokens to mint. + * @param primarySaleRecipient The receiver of the primary sale funds from the mint. + * @param quantity The quantity of tpkens to mint. + * @param price Price to pay for minting with the signature. + * @param currency The currency in which the price per token must be paid. + * @param validityStartTimestamp The unix timestamp after which the request is valid. + * @param validityEndTimestamp The unix timestamp after which the request expires. + * @param uid A unique identifier for the request. + */ + struct MintRequest { + address to; + address primarySaleRecipient; + uint256 quantity; + uint256 price; + address currency; + uint128 validityStartTimestamp; + uint128 validityEndTimestamp; + bytes32 uid; + } + + /// @dev Emitted when an account with MINTER_ROLE mints an NFT. + event TokensMinted(address indexed mintedTo, uint256 quantityMinted); + + /// @dev Emitted when tokens are minted. + event TokensMintedWithSignature(address indexed signer, address indexed mintedTo, MintRequest mintRequest); + + /** + * @notice Verifies that a mint request is signed by an account holding + * MINTER_ROLE (at the time of the function call). + * + * @param req The mint request. + * @param signature The signature produced by an account signing the mint request. + * + * returns (success, signer) Result of verification and the recovered address. + */ + function verify( + MintRequest calldata req, + bytes calldata signature + ) external view returns (bool success, address signer); + + /** + * @dev Creates `amount` new tokens for `to`. + * + * See {ERC20-_mint}. + * + * Requirements: + * + * - the caller must have the `MINTER_ROLE`. + */ + function mintTo(address to, uint256 amount) external; + + /** + * @notice Mints an NFT according to the provided mint request. + * + * @param req The mint request. + * @param signature he signature produced by an account signing the mint request. + */ + function mintWithSignature(MintRequest calldata req, bytes calldata signature) external payable; +} diff --git a/contracts/prebuilts/interface/token/ITokenERC721.sol b/contracts/prebuilts/interface/token/ITokenERC721.sol new file mode 100644 index 000000000..caf0016d9 --- /dev/null +++ b/contracts/prebuilts/interface/token/ITokenERC721.sol @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@openzeppelin/contracts-upgradeable/token/ERC721/IERC721Upgradeable.sol"; + +/** + * `SignatureMint` is an ERC 721 contract. It lets anyone mint NFTs by producing a mint request + * and a signature (produced by an account with MINTER_ROLE, signing the mint request). + */ +interface ITokenERC721 is IERC721Upgradeable { + /** + * @notice The body of a request to mint NFTs. + * + * @param to The receiver of the NFTs to mint. + * @param uri The URI of the NFT to mint. + * @param price Price to pay for minting with the signature. + * @param currency The currency in which the price per token must be paid. + * @param validityStartTimestamp The unix timestamp after which the request is valid. + * @param validityEndTimestamp The unix timestamp after which the request expires. + * @param uid A unique identifier for the request. + */ + struct MintRequest { + address to; + address royaltyRecipient; + uint256 royaltyBps; + address primarySaleRecipient; + string uri; + uint256 price; + address currency; + uint128 validityStartTimestamp; + uint128 validityEndTimestamp; + bytes32 uid; + } + + /// @dev Emitted when an account with MINTER_ROLE mints an NFT. + event TokensMinted(address indexed mintedTo, uint256 indexed tokenIdMinted, string uri); + + /// @dev Emitted when tokens are minted. + event TokensMintedWithSignature( + address indexed signer, + address indexed mintedTo, + uint256 indexed tokenIdMinted, + MintRequest mintRequest + ); + + /** + * @notice Verifies that a mint request is signed by an account holding + * MINTER_ROLE (at the time of the function call). + * + * @param req The mint request. + * @param signature The signature produced by an account signing the mint request. + * + * returns (success, signer) Result of verification and the recovered address. + */ + function verify( + MintRequest calldata req, + bytes calldata signature + ) external view returns (bool success, address signer); + + /** + * @notice Lets an account with MINTER_ROLE mint an NFT. + * + * @param to The address to mint the NFT to. + * @param uri The URI to assign to the NFT. + * + * @return tokenId of the NFT minted. + */ + function mintTo(address to, string calldata uri) external returns (uint256); + + /** + * @notice Mints an NFT according to the provided mint request. + * + * @param req The mint request. + * @param signature he signature produced by an account signing the mint request. + */ + function mintWithSignature(MintRequest calldata req, bytes calldata signature) external payable returns (uint256); +} diff --git a/contracts/prebuilts/loyalty/LoyaltyCard.sol b/contracts/prebuilts/loyalty/LoyaltyCard.sol new file mode 100644 index 000000000..a841aab17 --- /dev/null +++ b/contracts/prebuilts/loyalty/LoyaltyCard.sol @@ -0,0 +1,328 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/// @author thirdweb + +// $$\ $$\ $$\ $$\ $$\ +// $$ | $$ | \__| $$ | $$ | +// $$$$$$\ $$$$$$$\ $$\ $$$$$$\ $$$$$$$ |$$\ $$\ $$\ $$$$$$\ $$$$$$$\ +// \_$$ _| $$ __$$\ $$ |$$ __$$\ $$ __$$ |$$ | $$ | $$ |$$ __$$\ $$ __$$\ +// $$ | $$ | $$ |$$ |$$ | \__|$$ / $$ |$$ | $$ | $$ |$$$$$$$$ |$$ | $$ | +// $$ |$$\ $$ | $$ |$$ |$$ | $$ | $$ |$$ | $$ | $$ |$$ ____|$$ | $$ | +// \$$$$ |$$ | $$ |$$ |$$ | \$$$$$$$ |\$$$$$\$$$$ |\$$$$$$$\ $$$$$$$ | +// \____/ \__| \__|\__|\__| \_______| \_____\____/ \_______|\_______/ + +// Interface +import "../interface/ILoyaltyCard.sol"; + +// Base +import "../../eip/ERC721AVirtualApproveUpgradeable.sol"; + +// Lib +import "../../lib/CurrencyTransferLib.sol"; + +// Extensions +import "../../extension/NFTMetadata.sol"; +import "../../extension/SignatureMintERC721Upgradeable.sol"; +import "../../extension/ContractMetadata.sol"; +import "../../extension/Ownable.sol"; +import "../../extension/Royalty.sol"; +import "../../extension/PrimarySale.sol"; +import "../../extension/PlatformFee.sol"; +import "../../extension/Multicall.sol"; +import "../../extension/PermissionsEnumerable.sol"; +import "../../external-deps/openzeppelin/metatx/ERC2771ContextUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/security/ReentrancyGuardUpgradeable.sol"; + +/** + * @title LoyaltyCard + * + * @custom:description This contract is a loyalty card NFT collection. Each NFT represents a loyalty card, and the NFT's metadata + * contains the loyalty card's information. A loyalty card's metadata can be updated by an admin of the contract. + * A loyalty card can be cancelled (i.e. 'burned') by its owner or an approved operator. A loyalty card can be revoked + * (i.e. 'burned') without its owner's approval, by an admin of the contract. + */ +contract LoyaltyCard is + ILoyaltyCard, + ContractMetadata, + Ownable, + Royalty, + PrimarySale, + PlatformFee, + Multicall, + PermissionsEnumerable, + ReentrancyGuardUpgradeable, + ERC2771ContextUpgradeable, + NFTMetadata, + SignatureMintERC721Upgradeable, + ERC721AUpgradeable +{ + /*/////////////////////////////////////////////////////////////// + State variables + //////////////////////////////////////////////////////////////*/ + + /// @dev Only TRANSFER_ROLE holders can have tokens transferred from or to them, during restricted transfers. + bytes32 private constant TRANSFER_ROLE = keccak256("TRANSFER_ROLE"); + /// @dev Only MINTER_ROLE holders can sign off on `MintRequest`s. + bytes32 private constant MINTER_ROLE = keccak256("MINTER_ROLE"); + /// @dev Only METADATA_ROLE holders can update NFT metadata. + bytes32 private constant METADATA_ROLE = keccak256("METADATA_ROLE"); + /// @dev Only REVOKE_ROLE holders can revoke a loyalty card. + bytes32 private constant REVOKE_ROLE = keccak256("REVOKE_ROLE"); + + /// @dev Max bps in the thirdweb system. + uint256 private constant MAX_BPS = 10_000; + + /*/////////////////////////////////////////////////////////////// + Constructor + initializer + //////////////////////////////////////////////////////////////*/ + + constructor() initializer {} + + /// @dev Initializes the contract, like a constructor. + function initialize( + address _defaultAdmin, + string memory _name, + string memory _symbol, + string memory _contractURI, + address[] memory _trustedForwarders, + address _saleRecipient, + address _royaltyRecipient, + uint128 _royaltyBps, + uint128 _platformFeeBps, + address _platformFeeRecipient + ) external initializer { + // Initialize inherited contracts, most base-like -> most derived. + __ERC2771Context_init(_trustedForwarders); + __ERC721A_init(_name, _symbol); + __SignatureMintERC721_init(); + __ReentrancyGuard_init(); + + _setupContractURI(_contractURI); + _setupOwner(_defaultAdmin); + + _setupRole(DEFAULT_ADMIN_ROLE, _defaultAdmin); + _setupRole(MINTER_ROLE, _defaultAdmin); + _setupRole(TRANSFER_ROLE, _defaultAdmin); + + _setupRole(METADATA_ROLE, _defaultAdmin); + _setRoleAdmin(METADATA_ROLE, METADATA_ROLE); + + _setupRole(REVOKE_ROLE, _defaultAdmin); + _setRoleAdmin(REVOKE_ROLE, REVOKE_ROLE); + + _setupPlatformFeeInfo(_platformFeeRecipient, _platformFeeBps); + _setupDefaultRoyaltyInfo(_royaltyRecipient, _royaltyBps); + _setupPrimarySaleRecipient(_saleRecipient); + } + + /*/////////////////////////////////////////////////////////////// + ERC 165 / 721 / 2981 logic + //////////////////////////////////////////////////////////////*/ + + /// @dev Returns the URI for a given tokenId. + function tokenURI(uint256 _tokenId) public view override returns (string memory) { + return _getTokenURI(_tokenId); + } + + /// @dev See ERC 165 + function supportsInterface( + bytes4 interfaceId + ) public view virtual override(ERC721AUpgradeable, IERC165) returns (bool) { + return super.supportsInterface(interfaceId) || type(IERC2981).interfaceId == interfaceId; + } + + /*/////////////////////////////////////////////////////////////// + External functions + //////////////////////////////////////////////////////////////*/ + + /// @dev Mints an NFT according to the provided mint request. Always mints 1 NFT. + function mintWithSignature( + MintRequest calldata _req, + bytes calldata _signature + ) external payable nonReentrant returns (address signer) { + require(_req.quantity == 1, "LoyaltyCard: only 1 NFT can be minted at a time."); + + signer = _processRequest(_req, _signature); + address receiver = _req.to; + uint256 tokenIdMinted = _mintTo(receiver, _req.uri); + + // Set royalties, if applicable. + if (_req.royaltyRecipient != address(0) && _req.royaltyBps != 0) { + _setupRoyaltyInfoForToken(tokenIdMinted, _req.royaltyRecipient, _req.royaltyBps); + } + + _collectPrice(_req.primarySaleRecipient, _req.quantity, _req.currency, _req.pricePerToken); + + emit TokensMintedWithSignature(signer, receiver, tokenIdMinted, _req); + } + + /// @dev Lets an account with MINTER_ROLE mint an NFT. Always mints 1 NFT. + function mintTo( + address _to, + string calldata _uri + ) external onlyRole(MINTER_ROLE) nonReentrant returns (uint256 tokenIdMinted) { + tokenIdMinted = _mintTo(_to, _uri); + emit TokensMinted(_to, tokenIdMinted, _uri); + } + + /// @dev Burns `tokenId`. See {ERC721-_burn}. + function cancel(uint256 tokenId) external virtual override { + // note: ERC721AUpgradeable's `_burn(uint256,bool)` internally checks for token approvals. + _burn(tokenId, true); + } + + /// @dev Burns `tokenId`. See {ERC721-_burn}. + function revoke(uint256 tokenId) external virtual override onlyRole(REVOKE_ROLE) { + _burn(tokenId); + } + + /*/////////////////////////////////////////////////////////////// + Miscellaneous + //////////////////////////////////////////////////////////////*/ + + /** + * @dev Returns the total amount of tokens minted in the contract. + */ + function totalMinted() external view returns (uint256) { + unchecked { + return _currentIndex - _startTokenId(); + } + } + + /// @dev The tokenId of the next NFT that will be minted / lazy minted. + function nextTokenIdToMint() external view returns (uint256) { + return _currentIndex; + } + + /*/////////////////////////////////////////////////////////////// + Internal functions + //////////////////////////////////////////////////////////////*/ + + /// @dev Collects and distributes the primary sale value of NFTs being claimed. + function _collectPrice( + address _primarySaleRecipient, + uint256 _quantityToClaim, + address _currency, + uint256 _pricePerToken + ) internal { + if (_pricePerToken == 0) { + require(msg.value == 0, "!Value"); + return; + } + + uint256 totalPrice = _quantityToClaim * _pricePerToken; + + bool validMsgValue; + if (_currency == CurrencyTransferLib.NATIVE_TOKEN) { + validMsgValue = msg.value == totalPrice; + } else { + validMsgValue = msg.value == 0; + } + require(validMsgValue, "Invalid msg value"); + + address saleRecipient = _primarySaleRecipient == address(0) ? primarySaleRecipient() : _primarySaleRecipient; + + uint256 fees; + address feeRecipient; + + PlatformFeeType feeType = getPlatformFeeType(); + if (feeType == PlatformFeeType.Flat) { + (feeRecipient, fees) = getFlatPlatformFeeInfo(); + } else { + uint16 platformFeeBps; + (feeRecipient, platformFeeBps) = getPlatformFeeInfo(); + fees = (totalPrice * platformFeeBps) / MAX_BPS; + } + + require(totalPrice >= fees, "Fees greater than price"); + + CurrencyTransferLib.transferCurrency(_currency, _msgSender(), feeRecipient, fees); + CurrencyTransferLib.transferCurrency(_currency, _msgSender(), saleRecipient, totalPrice - fees); + } + + /// @dev Mints an NFT to `to` + function _mintTo(address _to, string calldata _uri) internal returns (uint256 tokenIdToMint) { + tokenIdToMint = _currentIndex; + + _setTokenURI(tokenIdToMint, _uri); + _safeMint(_to, 1); + } + + /// @dev See {ERC721-_beforeTokenTransfer}. + function _beforeTokenTransfers( + address from, + address to, + uint256 startTokenId, + uint256 quantity + ) internal virtual override { + super._beforeTokenTransfers(from, to, startTokenId, quantity); + + // if transfer is restricted on the contract, we still want to allow burning and minting + if (!hasRole(TRANSFER_ROLE, address(0)) && from != address(0) && to != address(0)) { + if (!hasRole(TRANSFER_ROLE, from) && !hasRole(TRANSFER_ROLE, to)) { + revert("!Transfer-Role"); + } + } + } + + /// @dev Checks whether platform fee info can be set in the given execution context. + function _canSetPlatformFeeInfo() internal view override returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + /// @dev Checks whether primary sale recipient can be set in the given execution context. + function _canSetPrimarySaleRecipient() internal view override returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + /// @dev Checks whether owner can be set in the given execution context. + function _canSetOwner() internal view override returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + /// @dev Checks whether royalty info can be set in the given execution context. + function _canSetRoyaltyInfo() internal view override returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + /// @dev Checks whether contract metadata can be set in the given execution context. + function _canSetContractURI() internal view override returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + /// @dev Returns whether a given address is authorized to sign mint requests. + function _isAuthorizedSigner(address _signer) internal view override returns (bool) { + return hasRole(MINTER_ROLE, _signer); + } + + /// @dev Returns whether metadata can be set in the given execution context. + function _canSetMetadata() internal view virtual override returns (bool) { + return hasRole(METADATA_ROLE, _msgSender()); + } + + /// @dev Returns whether metadata can be frozen in the given execution context. + function _canFreezeMetadata() internal view virtual override returns (bool) { + return hasRole(METADATA_ROLE, _msgSender()); + } + + function _msgSender() + internal + view + virtual + override(ContextUpgradeable, ERC2771ContextUpgradeable, Multicall) + returns (address sender) + { + return ERC2771ContextUpgradeable._msgSender(); + } + + function _msgData() + internal + view + virtual + override(ContextUpgradeable, ERC2771ContextUpgradeable) + returns (bytes calldata) + { + return ERC2771ContextUpgradeable._msgData(); + } +} diff --git a/contracts/prebuilts/marketplace-legacy/Marketplace.sol b/contracts/prebuilts/marketplace-legacy/Marketplace.sol new file mode 100644 index 000000000..0a7ea2301 --- /dev/null +++ b/contracts/prebuilts/marketplace-legacy/Marketplace.sol @@ -0,0 +1,907 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/// @author thirdweb + +// $$\ $$\ $$\ $$\ $$\ +// $$ | $$ | \__| $$ | $$ | +// $$$$$$\ $$$$$$$\ $$\ $$$$$$\ $$$$$$$ |$$\ $$\ $$\ $$$$$$\ $$$$$$$\ +// \_$$ _| $$ __$$\ $$ |$$ __$$\ $$ __$$ |$$ | $$ | $$ |$$ __$$\ $$ __$$\ +// $$ | $$ | $$ |$$ |$$ | \__|$$ / $$ |$$ | $$ | $$ |$$$$$$$$ |$$ | $$ | +// $$ |$$\ $$ | $$ |$$ |$$ | $$ | $$ |$$ | $$ | $$ |$$ ____|$$ | $$ | +// \$$$$ |$$ | $$ |$$ |$$ | \$$$$$$$ |\$$$$$\$$$$ |\$$$$$$$\ $$$$$$$ | +// \____/ \__| \__|\__|\__| \_______| \_____\____/ \_______|\_______/ + +// ========== External imports ========== + +import "@openzeppelin/contracts-upgradeable/token/ERC20/IERC20Upgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/token/ERC1155/IERC1155Upgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/token/ERC721/IERC721Upgradeable.sol"; + +import "@openzeppelin/contracts-upgradeable/token/ERC1155/IERC1155ReceiverUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/token/ERC721/IERC721ReceiverUpgradeable.sol"; + +import "@openzeppelin/contracts-upgradeable/utils/introspection/IERC165Upgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/interfaces/IERC2981Upgradeable.sol"; + +import "@openzeppelin/contracts-upgradeable/access/AccessControlEnumerableUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/security/ReentrancyGuardUpgradeable.sol"; + +// ========== Internal imports ========== + +import { IMarketplace } from "../interface/marketplace/IMarketplace.sol"; + +import "../../external-deps/openzeppelin/metatx/ERC2771ContextUpgradeable.sol"; + +import "../../extension/Multicall.sol"; +import "../../lib/CurrencyTransferLib.sol"; +import "../../lib/FeeType.sol"; + +contract Marketplace is + Initializable, + IMarketplace, + ReentrancyGuardUpgradeable, + ERC2771ContextUpgradeable, + Multicall, + AccessControlEnumerableUpgradeable, + IERC721ReceiverUpgradeable, + IERC1155ReceiverUpgradeable +{ + /*/////////////////////////////////////////////////////////////// + State variables + //////////////////////////////////////////////////////////////*/ + + bytes32 private constant MODULE_TYPE = bytes32("Marketplace"); + uint256 private constant VERSION = 2; + + /// @dev Only lister role holders can create listings, when listings are restricted by lister address. + bytes32 private constant LISTER_ROLE = keccak256("LISTER_ROLE"); + /// @dev Only assets from NFT contracts with asset role can be listed, when listings are restricted by asset address. + bytes32 private constant ASSET_ROLE = keccak256("ASSET_ROLE"); + + /// @dev The address of the native token wrapper contract. + address private immutable nativeTokenWrapper; + + /// @dev Total number of listings ever created in the marketplace. + uint256 public totalListings; + + /// @dev Contract level metadata. + string public contractURI; + + /// @dev The address that receives all platform fees from all sales. + address private platformFeeRecipient; + + /// @dev The max bps of the contract. So, 10_000 == 100 % + uint64 public constant MAX_BPS = 10_000; + + /// @dev The % of primary sales collected as platform fees. + uint64 private platformFeeBps; + + /// @dev + /** + * @dev The amount of time added to an auction's 'endTime', if a bid is made within `timeBuffer` + * seconds of the existing `endTime`. Default: 15 minutes. + */ + uint64 public timeBuffer; + + /// @dev The minimum % increase required from the previous winning bid. Default: 5%. + uint64 public bidBufferBps; + + /*/////////////////////////////////////////////////////////////// + Mappings + //////////////////////////////////////////////////////////////*/ + + /// @dev Mapping from uid of listing => listing info. + mapping(uint256 => Listing) public listings; + + /// @dev Mapping from uid of a direct listing => offeror address => offer made to the direct listing by the respective offeror. + mapping(uint256 => mapping(address => Offer)) public offers; + + /// @dev Mapping from uid of an auction listing => current winning bid in an auction. + mapping(uint256 => Offer) public winningBid; + + /*/////////////////////////////////////////////////////////////// + Modifiers + //////////////////////////////////////////////////////////////*/ + + /// @dev Checks whether caller is a listing creator. + modifier onlyListingCreator(uint256 _listingId) { + require(listings[_listingId].tokenOwner == _msgSender(), "!OWNER"); + _; + } + + /// @dev Checks whether a listing exists. + modifier onlyExistingListing(uint256 _listingId) { + require(listings[_listingId].assetContract != address(0), "DNE"); + _; + } + + /*/////////////////////////////////////////////////////////////// + Constructor + initializer logic + //////////////////////////////////////////////////////////////*/ + + constructor(address _nativeTokenWrapper) initializer { + nativeTokenWrapper = _nativeTokenWrapper; + } + + /// @dev Initializes the contract, like a constructor. + function initialize( + address _defaultAdmin, + string memory _contractURI, + address[] memory _trustedForwarders, + address _platformFeeRecipient, + uint256 _platformFeeBps + ) external initializer { + // Initialize inherited contracts, most base-like -> most derived. + __ReentrancyGuard_init(); + __ERC2771Context_init(_trustedForwarders); + + // Initialize this contract's state. + timeBuffer = 15 minutes; + bidBufferBps = 500; + + contractURI = _contractURI; + platformFeeBps = uint64(_platformFeeBps); + platformFeeRecipient = _platformFeeRecipient; + + _setupRole(DEFAULT_ADMIN_ROLE, _defaultAdmin); + _setupRole(LISTER_ROLE, address(0)); + _setupRole(ASSET_ROLE, address(0)); + } + + /*/////////////////////////////////////////////////////////////// + Generic contract logic + //////////////////////////////////////////////////////////////*/ + + /// @dev Lets the contract receives native tokens from `nativeTokenWrapper` withdraw. + receive() external payable {} + + /// @dev Returns the type of the contract. + function contractType() external pure returns (bytes32) { + return MODULE_TYPE; + } + + /// @dev Returns the version of the contract. + function contractVersion() external pure returns (uint8) { + return uint8(VERSION); + } + + /*/////////////////////////////////////////////////////////////// + ERC 165 / 721 / 1155 logic + //////////////////////////////////////////////////////////////*/ + + function onERC1155Received( + address, + address, + uint256, + uint256, + bytes memory + ) public virtual override returns (bytes4) { + return this.onERC1155Received.selector; + } + + function onERC1155BatchReceived( + address, + address, + uint256[] memory, + uint256[] memory, + bytes memory + ) public virtual override returns (bytes4) { + return this.onERC1155BatchReceived.selector; + } + + function onERC721Received(address, address, uint256, bytes calldata) external pure override returns (bytes4) { + return this.onERC721Received.selector; + } + + function supportsInterface( + bytes4 interfaceId + ) public view virtual override(AccessControlEnumerableUpgradeable, IERC165Upgradeable) returns (bool) { + return + interfaceId == type(IERC1155ReceiverUpgradeable).interfaceId || + interfaceId == type(IERC721ReceiverUpgradeable).interfaceId || + super.supportsInterface(interfaceId); + } + + /*/////////////////////////////////////////////////////////////// + Listing (create-update-delete) logic + //////////////////////////////////////////////////////////////*/ + + /// @dev Lets a token owner list tokens for sale: Direct Listing or Auction. + function createListing(ListingParameters memory _params) external override { + // Get values to populate `Listing`. + uint256 listingId = totalListings; + totalListings += 1; + + address tokenOwner = _msgSender(); + TokenType tokenTypeOfListing = getTokenType(_params.assetContract); + uint256 tokenAmountToList = getSafeQuantity(tokenTypeOfListing, _params.quantityToList); + + require(tokenAmountToList > 0, "QUANTITY"); + require(hasRole(LISTER_ROLE, address(0)) || hasRole(LISTER_ROLE, _msgSender()), "!LISTER"); + require(hasRole(ASSET_ROLE, address(0)) || hasRole(ASSET_ROLE, _params.assetContract), "!ASSET"); + + uint256 startTime = _params.startTime; + if (startTime < block.timestamp) { + // do not allow listing to start in the past (1 hour buffer) + require(block.timestamp - startTime < 1 hours, "ST"); + startTime = block.timestamp; + } + + validateOwnershipAndApproval( + tokenOwner, + _params.assetContract, + _params.tokenId, + tokenAmountToList, + tokenTypeOfListing + ); + + Listing memory newListing = Listing({ + listingId: listingId, + tokenOwner: tokenOwner, + assetContract: _params.assetContract, + tokenId: _params.tokenId, + startTime: startTime, + endTime: startTime + _params.secondsUntilEndTime, + quantity: tokenAmountToList, + currency: _params.currencyToAccept, + reservePricePerToken: _params.reservePricePerToken, + buyoutPricePerToken: _params.buyoutPricePerToken, + tokenType: tokenTypeOfListing, + listingType: _params.listingType + }); + + listings[listingId] = newListing; + + // Tokens listed for sale in an auction are escrowed in Marketplace. + if (newListing.listingType == ListingType.Auction) { + require( + newListing.buyoutPricePerToken == 0 || + newListing.buyoutPricePerToken >= newListing.reservePricePerToken, + "RESERVE" + ); + transferListingTokens(tokenOwner, address(this), tokenAmountToList, newListing); + } + + emit ListingAdded(listingId, _params.assetContract, tokenOwner, newListing); + } + + /// @dev Lets a listing's creator edit the listing's parameters. + function updateListing( + uint256 _listingId, + uint256 _quantityToList, + uint256 _reservePricePerToken, + uint256 _buyoutPricePerToken, + address _currencyToAccept, + uint256 _startTime, + uint256 _secondsUntilEndTime + ) external override onlyListingCreator(_listingId) { + Listing memory targetListing = listings[_listingId]; + uint256 safeNewQuantity = getSafeQuantity(targetListing.tokenType, _quantityToList); + bool isAuction = targetListing.listingType == ListingType.Auction; + + require(safeNewQuantity != 0, "QUANTITY"); + + // Can only edit auction listing before it starts. + if (isAuction) { + require(block.timestamp < targetListing.startTime, "STARTED"); + require(_buyoutPricePerToken == 0 || _buyoutPricePerToken >= _reservePricePerToken, "RESERVE"); + } + + if (_startTime < block.timestamp) { + // do not allow listing to start in the past (1 hour buffer) + require(block.timestamp - _startTime < 1 hours, "ST"); + _startTime = block.timestamp; + } + + uint256 newStartTime = _startTime == 0 ? targetListing.startTime : _startTime; + listings[_listingId] = Listing({ + listingId: _listingId, + tokenOwner: _msgSender(), + assetContract: targetListing.assetContract, + tokenId: targetListing.tokenId, + startTime: newStartTime, + endTime: _secondsUntilEndTime == 0 ? targetListing.endTime : newStartTime + _secondsUntilEndTime, + quantity: safeNewQuantity, + currency: _currencyToAccept, + reservePricePerToken: _reservePricePerToken, + buyoutPricePerToken: _buyoutPricePerToken, + tokenType: targetListing.tokenType, + listingType: targetListing.listingType + }); + + // Must validate ownership and approval of the new quantity of tokens for direct listing. + if (targetListing.quantity != safeNewQuantity) { + // Transfer all escrowed tokens back to the lister, to be reflected in the lister's + // balance for the upcoming ownership and approval check. + if (isAuction) { + transferListingTokens(address(this), targetListing.tokenOwner, targetListing.quantity, targetListing); + } + + validateOwnershipAndApproval( + targetListing.tokenOwner, + targetListing.assetContract, + targetListing.tokenId, + safeNewQuantity, + targetListing.tokenType + ); + + // Escrow the new quantity of tokens to list in the auction. + if (isAuction) { + transferListingTokens(targetListing.tokenOwner, address(this), safeNewQuantity, targetListing); + } + } + + emit ListingUpdated(_listingId, targetListing.tokenOwner); + } + + /// @dev Lets a direct listing creator cancel their listing. + function cancelDirectListing(uint256 _listingId) external onlyListingCreator(_listingId) { + Listing memory targetListing = listings[_listingId]; + + require(targetListing.listingType == ListingType.Direct, "!DIRECT"); + + delete listings[_listingId]; + + emit ListingRemoved(_listingId, targetListing.tokenOwner); + } + + /*/////////////////////////////////////////////////////////////// + Direct lisitngs sales logic + //////////////////////////////////////////////////////////////*/ + + /// @dev Lets an account buy a given quantity of tokens from a listing. + function buy( + uint256 _listingId, + address _buyFor, + uint256 _quantityToBuy, + address _currency, + uint256 _totalPrice + ) external payable override nonReentrant onlyExistingListing(_listingId) { + Listing memory targetListing = listings[_listingId]; + address payer = _msgSender(); + + // Check whether the settled total price and currency to use are correct. + require( + _currency == targetListing.currency && _totalPrice == (targetListing.buyoutPricePerToken * _quantityToBuy), + "!PRICE" + ); + + executeSale( + targetListing, + payer, + _buyFor, + targetListing.currency, + targetListing.buyoutPricePerToken * _quantityToBuy, + _quantityToBuy + ); + } + + /// @dev Lets a listing's creator accept an offer for their direct listing. + function acceptOffer( + uint256 _listingId, + address _offeror, + address _currency, + uint256 _pricePerToken + ) external override nonReentrant onlyListingCreator(_listingId) onlyExistingListing(_listingId) { + Offer memory targetOffer = offers[_listingId][_offeror]; + Listing memory targetListing = listings[_listingId]; + + require(_currency == targetOffer.currency && _pricePerToken == targetOffer.pricePerToken, "!PRICE"); + require(targetOffer.expirationTimestamp > block.timestamp, "EXPIRED"); + + delete offers[_listingId][_offeror]; + + executeSale( + targetListing, + _offeror, + _offeror, + targetOffer.currency, + targetOffer.pricePerToken * targetOffer.quantityWanted, + targetOffer.quantityWanted + ); + } + + /// @dev Performs a direct listing sale. + function executeSale( + Listing memory _targetListing, + address _payer, + address _receiver, + address _currency, + uint256 _currencyAmountToTransfer, + uint256 _listingTokenAmountToTransfer + ) internal { + validateDirectListingSale( + _targetListing, + _payer, + _listingTokenAmountToTransfer, + _currency, + _currencyAmountToTransfer + ); + + _targetListing.quantity -= _listingTokenAmountToTransfer; + listings[_targetListing.listingId] = _targetListing; + + payout(_payer, _targetListing.tokenOwner, _currency, _currencyAmountToTransfer, _targetListing); + transferListingTokens(_targetListing.tokenOwner, _receiver, _listingTokenAmountToTransfer, _targetListing); + + emit NewSale( + _targetListing.listingId, + _targetListing.assetContract, + _targetListing.tokenOwner, + _receiver, + _listingTokenAmountToTransfer, + _currencyAmountToTransfer + ); + } + + /*/////////////////////////////////////////////////////////////// + Offer/bid logic + //////////////////////////////////////////////////////////////*/ + + /// @dev Lets an account (1) make an offer to a direct listing, or (2) make a bid in an auction. + function offer( + uint256 _listingId, + uint256 _quantityWanted, + address _currency, + uint256 _pricePerToken, + uint256 _expirationTimestamp + ) external payable override nonReentrant onlyExistingListing(_listingId) { + Listing memory targetListing = listings[_listingId]; + + require( + targetListing.endTime > block.timestamp && targetListing.startTime < block.timestamp, + "inactive listing." + ); + + // Both - (1) offers to direct listings, and (2) bids to auctions - share the same structure. + Offer memory newOffer = Offer({ + listingId: _listingId, + offeror: _msgSender(), + quantityWanted: _quantityWanted, + currency: _currency, + pricePerToken: _pricePerToken, + expirationTimestamp: _expirationTimestamp + }); + + if (targetListing.listingType == ListingType.Auction) { + // A bid to an auction must be made in the auction's desired currency. + require(newOffer.currency == targetListing.currency, "must use approved currency to bid"); + require(newOffer.pricePerToken != 0, "bidding zero amount"); + + // A bid must be made for all auction items. + newOffer.quantityWanted = getSafeQuantity(targetListing.tokenType, targetListing.quantity); + + handleBid(targetListing, newOffer); + } else if (targetListing.listingType == ListingType.Direct) { + // Prevent potentially lost/locked native token. + require(msg.value == 0, "no value needed"); + + // Offers to direct listings cannot be made directly in native tokens. + newOffer.currency = _currency == CurrencyTransferLib.NATIVE_TOKEN ? nativeTokenWrapper : _currency; + newOffer.quantityWanted = getSafeQuantity(targetListing.tokenType, _quantityWanted); + + handleOffer(targetListing, newOffer); + } + } + + /// @dev Processes a new offer to a direct listing. + function handleOffer(Listing memory _targetListing, Offer memory _newOffer) internal { + require( + _newOffer.quantityWanted <= _targetListing.quantity && _targetListing.quantity > 0, + "insufficient tokens in listing." + ); + + validateERC20BalAndAllowance( + _newOffer.offeror, + _newOffer.currency, + _newOffer.pricePerToken * _newOffer.quantityWanted + ); + + offers[_targetListing.listingId][_newOffer.offeror] = _newOffer; + + emit NewOffer( + _targetListing.listingId, + _newOffer.offeror, + _targetListing.listingType, + _newOffer.quantityWanted, + _newOffer.pricePerToken * _newOffer.quantityWanted, + _newOffer.currency + ); + } + + /// @dev Processes an incoming bid in an auction. + function handleBid(Listing memory _targetListing, Offer memory _incomingBid) internal { + Offer memory currentWinningBid = winningBid[_targetListing.listingId]; + uint256 currentOfferAmount = currentWinningBid.pricePerToken * currentWinningBid.quantityWanted; + uint256 incomingOfferAmount = _incomingBid.pricePerToken * _incomingBid.quantityWanted; + address _nativeTokenWrapper = nativeTokenWrapper; + + // Close auction and execute sale if there's a buyout price and incoming offer amount is buyout price. + if ( + _targetListing.buyoutPricePerToken > 0 && + incomingOfferAmount >= _targetListing.buyoutPricePerToken * _targetListing.quantity + ) { + _closeAuctionForBidder(_targetListing, _incomingBid); + } else { + /** + * If there's an existng winning bid, incoming bid amount must be bid buffer % greater. + * Else, bid amount must be at least as great as reserve price + */ + require( + isNewWinningBid( + _targetListing.reservePricePerToken * _targetListing.quantity, + currentOfferAmount, + incomingOfferAmount + ), + "not winning bid." + ); + + // Update the winning bid and listing's end time before external contract calls. + winningBid[_targetListing.listingId] = _incomingBid; + + if (_targetListing.endTime - block.timestamp <= timeBuffer) { + _targetListing.endTime += timeBuffer; + listings[_targetListing.listingId] = _targetListing; + } + } + + // Payout previous highest bid. + if (currentWinningBid.offeror != address(0) && currentOfferAmount > 0) { + CurrencyTransferLib.transferCurrencyWithWrapper( + _targetListing.currency, + address(this), + currentWinningBid.offeror, + currentOfferAmount, + _nativeTokenWrapper + ); + } + + // Collect incoming bid + CurrencyTransferLib.transferCurrencyWithWrapper( + _targetListing.currency, + _incomingBid.offeror, + address(this), + incomingOfferAmount, + _nativeTokenWrapper + ); + + emit NewOffer( + _targetListing.listingId, + _incomingBid.offeror, + _targetListing.listingType, + _incomingBid.quantityWanted, + _incomingBid.pricePerToken * _incomingBid.quantityWanted, + _incomingBid.currency + ); + } + + /// @dev Checks whether an incoming bid is the new current highest bid. + function isNewWinningBid( + uint256 _reserveAmount, + uint256 _currentWinningBidAmount, + uint256 _incomingBidAmount + ) internal view returns (bool isValidNewBid) { + if (_currentWinningBidAmount == 0) { + isValidNewBid = _incomingBidAmount >= _reserveAmount; + } else { + isValidNewBid = (_incomingBidAmount > _currentWinningBidAmount && + ((_incomingBidAmount - _currentWinningBidAmount) * MAX_BPS) / _currentWinningBidAmount >= bidBufferBps); + } + } + + /*/////////////////////////////////////////////////////////////// + Auction lisitngs sales logic + //////////////////////////////////////////////////////////////*/ + + /// @dev Lets an account close an auction for either the (1) winning bidder, or (2) auction creator. + function closeAuction( + uint256 _listingId, + address _closeFor + ) external override nonReentrant onlyExistingListing(_listingId) { + Listing memory targetListing = listings[_listingId]; + + require(targetListing.listingType == ListingType.Auction, "not an auction."); + + Offer memory targetBid = winningBid[_listingId]; + + // Cancel auction if (1) auction hasn't started, or (2) auction doesn't have any bids. + bool toCancel = targetListing.startTime > block.timestamp || targetBid.offeror == address(0); + + if (toCancel) { + // cancel auction listing owner check + _cancelAuction(targetListing); + } else { + require(targetListing.endTime < block.timestamp, "cannot close auction before it has ended."); + + // No `else if` to let auction close in 1 tx when targetListing.tokenOwner == targetBid.offeror. + if (_closeFor == targetListing.tokenOwner) { + _closeAuctionForAuctionCreator(targetListing, targetBid); + } + + if (_closeFor == targetBid.offeror) { + _closeAuctionForBidder(targetListing, targetBid); + } + } + } + + /// @dev Cancels an auction. + function _cancelAuction(Listing memory _targetListing) internal { + require(listings[_targetListing.listingId].tokenOwner == _msgSender(), "caller is not the listing creator."); + + delete listings[_targetListing.listingId]; + + transferListingTokens(address(this), _targetListing.tokenOwner, _targetListing.quantity, _targetListing); + + emit AuctionClosed(_targetListing.listingId, _msgSender(), true, _targetListing.tokenOwner, address(0)); + } + + /// @dev Closes an auction for an auction creator; distributes winning bid amount to auction creator. + function _closeAuctionForAuctionCreator(Listing memory _targetListing, Offer memory _winningBid) internal { + uint256 payoutAmount = _winningBid.pricePerToken * _targetListing.quantity; + + _targetListing.quantity = 0; + _targetListing.endTime = block.timestamp; + listings[_targetListing.listingId] = _targetListing; + + _winningBid.pricePerToken = 0; + winningBid[_targetListing.listingId] = _winningBid; + + payout(address(this), _targetListing.tokenOwner, _targetListing.currency, payoutAmount, _targetListing); + + emit AuctionClosed( + _targetListing.listingId, + _msgSender(), + false, + _targetListing.tokenOwner, + _winningBid.offeror + ); + } + + /// @dev Closes an auction for the winning bidder; distributes auction items to the winning bidder. + function _closeAuctionForBidder(Listing memory _targetListing, Offer memory _winningBid) internal { + uint256 quantityToSend = _winningBid.quantityWanted; + + _targetListing.endTime = block.timestamp; + _winningBid.quantityWanted = 0; + + winningBid[_targetListing.listingId] = _winningBid; + listings[_targetListing.listingId] = _targetListing; + + transferListingTokens(address(this), _winningBid.offeror, quantityToSend, _targetListing); + + emit AuctionClosed( + _targetListing.listingId, + _msgSender(), + false, + _targetListing.tokenOwner, + _winningBid.offeror + ); + } + + /*/////////////////////////////////////////////////////////////// + Shared (direct+auction listings) internal functions + //////////////////////////////////////////////////////////////*/ + + /// @dev Transfers tokens listed for sale in a direct or auction listing. + function transferListingTokens(address _from, address _to, uint256 _quantity, Listing memory _listing) internal { + if (_listing.tokenType == TokenType.ERC1155) { + IERC1155Upgradeable(_listing.assetContract).safeTransferFrom(_from, _to, _listing.tokenId, _quantity, ""); + } else if (_listing.tokenType == TokenType.ERC721) { + IERC721Upgradeable(_listing.assetContract).safeTransferFrom(_from, _to, _listing.tokenId, ""); + } + } + + /// @dev Pays out stakeholders in a sale. + function payout( + address _payer, + address _payee, + address _currencyToUse, + uint256 _totalPayoutAmount, + Listing memory _listing + ) internal { + uint256 platformFeeCut = (_totalPayoutAmount * platformFeeBps) / MAX_BPS; + + uint256 royaltyCut; + address royaltyRecipient; + + // Distribute royalties. See Sushiswap's https://github.com/sushiswap/shoyu/blob/master/contracts/base/BaseExchange.sol#L296 + try IERC2981Upgradeable(_listing.assetContract).royaltyInfo(_listing.tokenId, _totalPayoutAmount) returns ( + address royaltyFeeRecipient, + uint256 royaltyFeeAmount + ) { + if (royaltyFeeRecipient != address(0) && royaltyFeeAmount > 0) { + require(royaltyFeeAmount + platformFeeCut <= _totalPayoutAmount, "fees exceed the price"); + royaltyRecipient = royaltyFeeRecipient; + royaltyCut = royaltyFeeAmount; + } + } catch {} + + // Distribute price to token owner + address _nativeTokenWrapper = nativeTokenWrapper; + + CurrencyTransferLib.transferCurrencyWithWrapper( + _currencyToUse, + _payer, + platformFeeRecipient, + platformFeeCut, + _nativeTokenWrapper + ); + CurrencyTransferLib.transferCurrencyWithWrapper( + _currencyToUse, + _payer, + royaltyRecipient, + royaltyCut, + _nativeTokenWrapper + ); + CurrencyTransferLib.transferCurrencyWithWrapper( + _currencyToUse, + _payer, + _payee, + _totalPayoutAmount - (platformFeeCut + royaltyCut), + _nativeTokenWrapper + ); + } + + /// @dev Validates that `_addrToCheck` owns and has approved markeplace to transfer the appropriate amount of currency + function validateERC20BalAndAllowance( + address _addrToCheck, + address _currency, + uint256 _currencyAmountToCheckAgainst + ) internal view { + require( + IERC20Upgradeable(_currency).balanceOf(_addrToCheck) >= _currencyAmountToCheckAgainst && + IERC20Upgradeable(_currency).allowance(_addrToCheck, address(this)) >= _currencyAmountToCheckAgainst, + "!BAL20" + ); + } + + /// @dev Validates that `_tokenOwner` owns and has approved Market to transfer NFTs. + function validateOwnershipAndApproval( + address _tokenOwner, + address _assetContract, + uint256 _tokenId, + uint256 _quantity, + TokenType _tokenType + ) internal view { + address market = address(this); + bool isValid; + + if (_tokenType == TokenType.ERC1155) { + isValid = + IERC1155Upgradeable(_assetContract).balanceOf(_tokenOwner, _tokenId) >= _quantity && + IERC1155Upgradeable(_assetContract).isApprovedForAll(_tokenOwner, market); + } else if (_tokenType == TokenType.ERC721) { + isValid = + IERC721Upgradeable(_assetContract).ownerOf(_tokenId) == _tokenOwner && + (IERC721Upgradeable(_assetContract).getApproved(_tokenId) == market || + IERC721Upgradeable(_assetContract).isApprovedForAll(_tokenOwner, market)); + } + + require(isValid, "!BALNFT"); + } + + /// @dev Validates conditions of a direct listing sale. + function validateDirectListingSale( + Listing memory _listing, + address _payer, + uint256 _quantityToBuy, + address _currency, + uint256 settledTotalPrice + ) internal { + require(_listing.listingType == ListingType.Direct, "cannot buy from listing."); + + // Check whether a valid quantity of listed tokens is being bought. + require( + _listing.quantity > 0 && _quantityToBuy > 0 && _quantityToBuy <= _listing.quantity, + "invalid amount of tokens." + ); + + // Check if sale is made within the listing window. + require(block.timestamp < _listing.endTime && block.timestamp > _listing.startTime, "not within sale window."); + + // Check: buyer owns and has approved sufficient currency for sale. + if (_currency == CurrencyTransferLib.NATIVE_TOKEN) { + require(msg.value == settledTotalPrice, "msg.value != price"); + } else { + validateERC20BalAndAllowance(_payer, _currency, settledTotalPrice); + } + + // Check whether token owner owns and has approved `quantityToBuy` amount of listing tokens from the listing. + validateOwnershipAndApproval( + _listing.tokenOwner, + _listing.assetContract, + _listing.tokenId, + _quantityToBuy, + _listing.tokenType + ); + } + + /*/////////////////////////////////////////////////////////////// + Getter functions + //////////////////////////////////////////////////////////////*/ + + /// @dev Enforces quantity == 1 if tokenType is TokenType.ERC721. + function getSafeQuantity( + TokenType _tokenType, + uint256 _quantityToCheck + ) internal pure returns (uint256 safeQuantity) { + if (_quantityToCheck == 0) { + safeQuantity = 0; + } else { + safeQuantity = _tokenType == TokenType.ERC721 ? 1 : _quantityToCheck; + } + } + + /// @dev Returns the interface supported by a contract. + function getTokenType(address _assetContract) internal view returns (TokenType tokenType) { + if (IERC165Upgradeable(_assetContract).supportsInterface(type(IERC1155Upgradeable).interfaceId)) { + tokenType = TokenType.ERC1155; + } else if (IERC165Upgradeable(_assetContract).supportsInterface(type(IERC721Upgradeable).interfaceId)) { + tokenType = TokenType.ERC721; + } else { + revert("token must be ERC1155 or ERC721."); + } + } + + /// @dev Returns the platform fee recipient and bps. + function getPlatformFeeInfo() external view returns (address, uint16) { + return (platformFeeRecipient, uint16(platformFeeBps)); + } + + /*/////////////////////////////////////////////////////////////// + Setter functions + //////////////////////////////////////////////////////////////*/ + + /// @dev Lets a contract admin update platform fee recipient and bps. + function setPlatformFeeInfo( + address _platformFeeRecipient, + uint256 _platformFeeBps + ) external onlyRole(DEFAULT_ADMIN_ROLE) { + require(_platformFeeBps <= MAX_BPS, "bps <= 10000."); + + platformFeeBps = uint64(_platformFeeBps); + platformFeeRecipient = _platformFeeRecipient; + + emit PlatformFeeInfoUpdated(_platformFeeRecipient, _platformFeeBps); + } + + /// @dev Lets a contract admin set auction buffers. + function setAuctionBuffers(uint256 _timeBuffer, uint256 _bidBufferBps) external onlyRole(DEFAULT_ADMIN_ROLE) { + require(_bidBufferBps < MAX_BPS, "invalid BPS."); + + timeBuffer = uint64(_timeBuffer); + bidBufferBps = uint64(_bidBufferBps); + + emit AuctionBuffersUpdated(_timeBuffer, _bidBufferBps); + } + + /// @dev Lets a contract admin set the URI for the contract-level metadata. + function setContractURI(string calldata _uri) external onlyRole(DEFAULT_ADMIN_ROLE) { + contractURI = _uri; + } + + /*/////////////////////////////////////////////////////////////// + Miscellaneous + //////////////////////////////////////////////////////////////*/ + + function _msgSender() + internal + view + virtual + override(ContextUpgradeable, ERC2771ContextUpgradeable, Multicall) + returns (address sender) + { + return ERC2771ContextUpgradeable._msgSender(); + } + + function _msgData() + internal + view + virtual + override(ContextUpgradeable, ERC2771ContextUpgradeable) + returns (bytes calldata) + { + return ERC2771ContextUpgradeable._msgData(); + } +} diff --git a/contracts/prebuilts/marketplace-legacy/marketplace.md b/contracts/prebuilts/marketplace-legacy/marketplace.md new file mode 100644 index 000000000..cdfb7683c --- /dev/null +++ b/contracts/prebuilts/marketplace-legacy/marketplace.md @@ -0,0 +1,223 @@ +# Marketplace design document. + +This is a live document that explains what the [thirdweb](https://thirdweb.com/) `Marketplace` smart contract is, how it works and can be used, and why it is written the way it is. + +The document is written for technical and non-technical readers. To ask further questions about `Marketplace`, please join the [thirdweb discord](https://discord.gg/thirdweb) or create a [github issue](https://github.com/thirdweb-dev/contracts/issues). + +--- +## Background + +The [thirdweb](https://thirdweb.com/) `Marketplace` is a market where where people can sell NFTs — [ERC 721](https://eips.ethereum.org/EIPS/eip-721) or [ERC 1155](https://eips.ethereum.org/EIPS/eip-1155) tokens — at a fixed price ( what we'll refer to as a "Direct listing"), or auction them (what we'll refer to as an "Auction listing"). + +### Direct Listings +An NFT owner (or 'lister') can list their NFTs for sale at a fixed price. A potential buyer can buy the NFT for the specified price, or make an offer to buy the listed NFTs for a different price or currency, which the lister can choose to accept. + +To list NFTs for sale, the lister specifies — + +| Parameter | Type | Description | +| --- | --- | --- | +| `assetContract` | address | The contract address of the NFTs being listed for sale. | +| `tokenId` | uint256 | The token ID on the 'assetContract' of the NFTs to list for sale. | +| `startTime` | uint256 | The unix timestamp after which NFTs can be bought from the listing. | +| `secondsUntilEndTime` | uint256 | No. of seconds after `startTime`, after which NFTs can no longer be bought from the listing. | +| `quantityToList` | uint256 | The amount of NFTs of the given 'assetContract' and 'tokenId' to list for sale. For ERC721 NFTs, this is always 1. | +| `currencyToAccept` | address | The address of the currency accepted by the listing. Either an ERC20 token or the chain's native token (e.g. ether on Ethereum mainnet). | +| `buyoutPricePerToken` | uint256 | The price per unit of NFT listed for sale. | + +The listed NFTs do not leave the wallet of the lister until a sale is executed with the seller and buyer's consent. To list NFTs for sale, the lister must own the NFTs being listed, and approve the market to transfer the NFTs. The latter lets the market transfer NFTs to a buyer who buys the NFTs for the accepted price. + +To make an offer to a direct listing, a buyer specifies — + +| Parameter | Type | Description | +| --- | --- | --- | +| `listingId` | uint256 | The unique identifier of the listing to buy NFTs from. | +| `quantityWanted` | uint256 | The quantity of NFTs from the listing for which the offer is made. For ERC721 NFTs, this is always 1. | +| `pricePerToken` | uint256 | The offered price per token. | +| `currency` | address | The currency in which the offer is made. | +| `expirationTimestamp` | uint256 | The unix timestamp after which the offer expires. + +When making an offer to a direct listing, the offer amount is not escrowed in the Marketplace. Instead, making an offer requires the buyer to approve Marketplace to transfer the appropriate amount of currency to let Marketplace transfer the offer amount from the buyer to the lister, in case the lister accepts the buyer's offer. + +To buy NFTs from a direct listing buy paying the listing's specified price, a buyer specifies - + +| Parameter | Type | Description | +| --- | --- | --- | +| `listingId` | uint256 | The unique identifier of the listing to buy NFTs from. | +| `buyFor` | address | The recipient of the NFTs being bought. | +| `quantity` | uint256 | The quantity of NFTs being bought from the listing. For ERC721 NFTs, this is always 1. | +| `currency` | address | The currency in which to pay for the NFTs being bought. | +| `totalPrice` | uint256 | The total price to pay for the NFTs being bought. | + +A sale will fail to execute if either (1) the buyer does not own or has not approved Marketplace to transfer the appropriate amount of currency (or hasn't sent the appropriate amount of native tokens), or (2) the lister does not own or has removed Marketplace's approval to transfer the tokens listed for sale. + +A sale is executed when either a buyer pays the fixed price, or the seller accepts an offer made to the listing. + +### Auction listings + +An NFT owner (or 'lister') can auction their NFTs. Potential buyers make bids in the auction. At the closing of the auction, the buyer with the wining bid gets the auctioned NFTs, and the lister gets the winning bid amount. + +Auctions on thirdweb's Marketplace are [english auctions](https://www.wallstreetmojo.com/english-auction/). + +To list NFTs in an auction, a lister specifies — + +| Parameter | Type | Description | +| --- | --- | --- | +| `assetContract` | address | The contract address of the NFTs being listed for sale. | +| `tokenId` | uint256 | The token ID on the 'assetContract' of the NFTs to list for sale. | +| `startTime` | uint256 | The unix timestamp after which NFTs can be bought from the listing. | +| `secondsUntilEndTime` | uint256 | No. of seconds after `startTime`, after which NFTs can no longer be bought from the listing. | +| `quantityToList` | uint256 | The amount of NFTs of the given 'assetContract' and 'tokenId' to list for sale. For ERC721 NFTs, this is always 1. | +| `currencyToAccept` | address | The address of the currency accepted by the listing. Either an ERC20 token or the chain's native token (e.g. ether on Ethereum mainnet). | +| `rerservePricePerToken` | uint256 | All bids made to this auction must be at least as great as the reserve price per unit of NFTs auctioned, times the total number of NFTs put up for auction. | +| `buyoutPricePerToken` | uint256 | An optional parameter. If a buyer bids an amount greater than or equal to the buyout price per unit of NFTs auctioned, times the total number of NFTs put up for auction, the auction is considered closed and the buyer wins the auctioned items. | + +Every auction listing obeys two 'buffers' to make it a fair auction: + +1. **Time buffer**: this is measured in seconds (by default, 15 minutes or 900 seconds). If a winning bid is made within the buffer of the auction closing (e.g. 15 minutes within the auction closing), the auction's closing time is increased by the buffer to prevent buyers from making last minute winning bids, and to give time to other buyers to make a higher bid if they wish to. +2. **Bid buffer**: this is a percentage (by default, 5%). A new bid is considered to be a winning bid only if its bid amount is at least the bid buffer (e.g. 5%) greater than the previous winning bid. This prevents buyers from making insignificantly higher bids to win the auctioned items. + +These buffer values are contract-wide, which means every auction conducted in the Marketplace obeys, at any given moment, the same buffers. These buffers can be configured by contract admins i.e. accounts with the `DEFAULT_ADMIN_ROLE` role. + +The NFTs to list in an auction *do* leave the wallet of the lister, and are escrowed in the market until the closing of the auction. Whenever a new winning bid is made by a buyer, the buyer deposits this bid amount into the market; this bid amount is escrowed in the market until a new winning bid is made. The previous winning bid amount is automatically refunded to the respective bidder. + +**Note:** As a result, the new winning bidder pays for the gas used in refunding the previous winning bidder. This trade-off is made for better UX for bidders — a bidder that has been outbid is automatically refunded, and does not need to pull out their deposited bid manually. This reduces bidding to a single action, instead of two actions — bidding, and pulling out the bid on being outbid. + +If the lister sets a `buyoutPricePerToken`, the marketplace expects the `buyoutPricePerToken` to be greater than or equal to the `reservePricePerToken` of the auction. + +Once the auction window ends, the seller collects the highest bid, and the buyer collects the auctioned NFTs. + +### Main difference in treatment: Direct vs Auction listings + +The main difference in how we treat 'direct listings' versus 'auction listings' concerns the level of commitment from the seller and buyers. + +- **Direct listings** are *low commitment*, high frequency listings; people constantly list and de-list their NFTs based on market trends. So, the listed NFTs and offer amounts are *not* escrowed in the Marketplace to keep the seller's NFTs and the buyer's currency liquid. Allowing users to list NFTs for sale just by approvals gives them the freedom to list the same NFT in multiple marketplaces, e.g. this `Marketplace` contract, OpenSea, etc. at the same time. +- **Auction listings** are *high commitment*, low frequency listings. The seller and bidders respect the auction window, recognize that their NFTs / bid amounts will be illiquid for the auction duration, and expect a guaranteed payout at auction closing — the auctioned items for the bidder, and the winning bid amount for the seller. So, tokens listed for sale in an auction, and the highest bid at any given moment *are* escrowed in the market. + +### Why we're building this Marketplace + +The previous (v1) [thirdweb Market contract](https://github.com/thirdweb-dev/contracts/tree/v1) has the following critical pitfalls - + +- Sellers cannot conduct auctions. +- NFTs listed for sale in a direct listings are escrowed in the contract. +- Buyers cannot make offers to direct listings. + +These are features that are already offered by popular marketplaces like [OpenSea](https://opensea.io/). The current thirdweb [Marketplace](https://github.com/thirdweb-dev/contracts/blob/main/contracts/marketplace/Marketplace.sol) contract consolidates all these features into a single smart contract, so thirdweb's users can *truly* have their own OpenSea and more. + +We're building this for customers who want to have their NFTs listed for sale on their *own* market. + +![marketplace-1.png](/assets/marketplace-1.png) + +### What the Marketplace will look like to users + +There are two groups of users — (1) thirdweb's customers who'll set up the marketplace, and (2) the end users of thirdweb customers' marketplaces. + +To thirdweb customers, the `Marketplace` can be set up like any of the other thirdweb contract (e.g. 'NFT Collection') through the thirdweb dashboard, the thirdweb SDK, or by directly consuming the open sourced marketplace smart contract. + +To the end users of thirdweb customers, the experience of using the marketplace will feel familiar to popular marketplace platforms like OpenSea, Zora, etc. The biggest difference in user experience will be that performing any action on the marketplace requires gas fees. + +- Thirdweb's customers + - Deploy the marketplace contract like any other thirdweb contract. + - Can set a % 'platform fee'. This % is collected on every sale — when a buyer buys tokens from a direct listing, and when a seller collects the highest bid on auction closing. This platform fee is distributed to the platform fee recipient (set by a contract admin). + - Can set auction buffers. These auction buffers apply to all auctions being conducted in the market. + - End users of thirdweb customers + - Can list NFTs for sale at a fixed price. + - Can edit an existing listing's parameters, e.g. the currency accepted. An auction's parameters cannot be edited once it has started. + - Can make offers to NFTs listed for a fixed price. + - Can auction NFTs. + - Can make bids to auctions. + - Must pay gas fees to perform any actions, including the actions just listed. + +## Technical details + +At a high level, we want `Marketplace` to be a single smart contract that supports all features related to both direct listings *and* auction listings. + +To write the feature-rich Marketplace contract without exceeding the code size limit of smart contracts, we leverage the similarity in the concepts required by direct and auction listings. + +| Type | Concept | | | | | | | | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| | `start time | end time | quantity of tokens listed | currency accepted by listing | reserve price: minimum bid amount | buyout price: price to pay to directly buy the token listed | buy partial amount from the total amount of tokens listed | Type of token listed: ERC721 or ERC1155 | +| Direct | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | +| Auction | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | + +As we can see, the parameters that make up a direct listing and an auction listing are highly similar. So, we use common data structures and functions to handle direct listing and auction listing features. This means e.g. a single function can have multiple behaviors based on which actor calls it, when they call it, and the listing type of the listing in question. + +The same goes for offers to direct listings, and bids made in an auction. The parameters for offers to direct listings and bids made in an auction are identical. + +| Offer type | Concepts for an offer | | | | +| --- | --- | --- | --- | --- | +| | Offeror: the account making the offer | quantity wanted from the listing | total offer amount | currency in which the offer is made | +| Bid | ✅ | ✅ | ✅ | ✅ | +| Offer to direct listing | ✅ | ✅ | ✅ | ✅ | + +And so, we use common data structures and functions to handle offers to direct listings and bids to auctions. Though the two types of offers share the same concepts, they require different logic. This again means e.g. a single function can have multiple behaviors based on which actor calls it, when they call it, and the listing type of the listing in question. + +### Design strategy for `Marketplace` + +The `Marketplace` smart contract works with two main concepts — (1) direct listings + offers, and (2) auctions + bids. + +We use common functions and data structures wherever an (1) action is common to both concepts and (2) the data to manage for that action is common to both concepts. + +**Example**: Common action and data handled. + +- Action: creating a listing | Data: `ListingParameters` + +```solidity +struct ListingParameters { + address assetContract; + uint256 tokenId; + uint256 startTime; + uint256 secondsUntilEndTime; + uint256 quantityToList; + address currencyToAccept; + uint256 reservePricePerToken; + uint256 buyoutPricePerToken; + ListingType listingType; +} +``` + +- There is a single `createListing` function to create both a direct listing, or an auction. + +**Example**: Distinct action or data handled. + +An auction has the concept of formally being closed whereas a direct listing does not. On auction closing, both the lister and winning bidder call can call `closeAuction` to collect the winning bid, and the auctioned items, respectively. There is no such corollary in the case of direct listings. + +### EIPs implemented / supported + +To be able to escrow NFTs in the case of auctions, Marketplace implements the receiver interfaces for [ERC1155](https://eips.ethereum.org/EIPS/eip-1155) and [ERC721](https://eips.ethereum.org/EIPS/eip-721) tokens. + +To enable meta-transactions (gasless), Marketplace implements [ERC2771](https://eips.ethereum.org/EIPS/eip-2771). + +Marketplace also honors [ERC2981](https://eips.ethereum.org/EIPS/eip-2981) for the distribution of royalties on direct and auction listings. + +### Events emitted + +All events emitted by the contract, as well as when they're emitted, can be found in the interface of the contract, [here](https://github.com/thirdweb-dev/contracts/blob/main/contracts/interfaces/marketplace/IMarketplace.sol). In general, events are emitted whenever there is a state change in the contract. + +### Currency transfers + +The `Marketplace` contract supports both ERC20 currencies, and a chain's native token (e.g. ether for Ethereum mainnet). This means that any action that involves transferring currency (e.g. buying a token from a direct listing) can be performed with either an ERC20 token or the chain's native token. + + +💡 **Note**: The only exception is offers to direct listings — these can only be made with ERC20 tokens, since Marketplace needs to transfer the offer amount from the buyer to the lister, in case the lister accepts the buyer's offer. This cannot be done with native tokens without escrowing the requisite amount of currency. + +The contract wraps all native tokens deposited into it as the canonical ERC20 wrapped version of the native token (e.g. WETH for ether). The contract unwraps the wrapped native token when transferring native tokens to a given address. + +If the contract fails to transfer out native tokens, it wraps them back to wrapped native tokens, and transfers the wrapped native tokens to the concerned address. The contract may fail to transfer out native tokens to an address, if the address represents a smart contract that cannot accept native tokens transferred to it directly. + +### Alternative designs and trade-offs + +**Two contracts instead of one:** + +The main alternative design considered for the `Marketplace` was to split the smart contract into two smart contracts, where each handles (1) only direct listings + offers, or (2) only auction listings + bids. + +Such a design gives us two 'lean' contracts instead of one large one, and the cost for deploying just one of these two contracts is less than deploying the single, large `Marketplace` contract. Having two separate contracts positions the thirdweb system to be more modular, where a thirdweb customer can only deploy the smart contract that gives them the specific functionality they want. + +Ultimately, we've written a single, large `Marketplace` smart contract since (1) we've seen no strong demand to use just one of those two kinds of listings - direct or auction listings - and not the other, and (2) the contract size of Marketplace does not affect the cost of deploying the contract to users of the thirdweb dashboard/sdk/contracts, since thirdweb now follows the proxy pattern for smart contract deployment. + +**Trade-off of having a single `Marketplace` contract** + +Having a single, large contract gives us less room to add the ability for the marketplace and its users to conduct 'off-chain actions'. + +Marketplace platforms like OpenSea make actions like making an offer to a direct listing, gasless. End users of the marketplace sign messages expressing intent to perform an action (e.g. list *x* NFT for sale at the price of 10 ETH), and a centralized order-book infrastructure matches two seller-buyer intents, and send the respective signed messages by the seller and buyer to their market smart contract for the sale to be executed. + +We're working on breaking up, sizing down and optimizing the `Marketplace` contract to accommodate such off-chain actions, and coming up with a central order-book infrastructure that each thirdweb customer can run on their own. diff --git a/contracts/prebuilts/marketplace/IMarketplace.sol b/contracts/prebuilts/marketplace/IMarketplace.sol new file mode 100644 index 000000000..0b9e05bcf --- /dev/null +++ b/contracts/prebuilts/marketplace/IMarketplace.sol @@ -0,0 +1,512 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/// @author thirdweb + +/** + * @author thirdweb.com + * + * The `DirectListings` extension smart contract lets you buy and sell NFTs (ERC-721 or ERC-1155) for a fixed price. + */ +interface IDirectListings { + enum TokenType { + ERC721, + ERC1155 + } + + enum Status { + UNSET, + CREATED, + COMPLETED, + CANCELLED + } + + /** + * @notice The parameters a seller sets when creating or updating a listing. + * + * @param assetContract The address of the smart contract of the NFTs being listed. + * @param tokenId The tokenId of the NFTs being listed. + * @param quantity The quantity of NFTs being listed. This must be non-zero, and is expected to + * be `1` for ERC-721 NFTs. + * @param currency The currency in which the price must be paid when buying the listed NFTs. + * @param pricePerToken The price to pay per unit of NFTs listed. + * @param startTimestamp The UNIX timestamp at and after which NFTs can be bought from the listing. + * @param endTimestamp The UNIX timestamp at and after which NFTs cannot be bought from the listing. + * @param reserved Whether the listing is reserved to be bought from a specific set of buyers. + */ + struct ListingParameters { + address assetContract; + uint256 tokenId; + uint256 quantity; + address currency; + uint256 pricePerToken; + uint128 startTimestamp; + uint128 endTimestamp; + bool reserved; + } + + /** + * @notice The information stored for a listing. + * + * @param listingId The unique ID of the listing. + * @param listingCreator The creator of the listing. + * @param assetContract The address of the smart contract of the NFTs being listed. + * @param tokenId The tokenId of the NFTs being listed. + * @param quantity The quantity of NFTs being listed. This must be non-zero, and is expected to + * be `1` for ERC-721 NFTs. + * @param currency The currency in which the price must be paid when buying the listed NFTs. + * @param pricePerToken The price to pay per unit of NFTs listed. + * @param startTimestamp The UNIX timestamp at and after which NFTs can be bought from the listing. + * @param endTimestamp The UNIX timestamp at and after which NFTs cannot be bought from the listing. + * @param reserved Whether the listing is reserved to be bought from a specific set of buyers. + * @param status The status of the listing (created, completed, or cancelled). + * @param tokenType The type of token listed (ERC-721 or ERC-1155) + */ + struct Listing { + uint256 listingId; + uint256 tokenId; + uint256 quantity; + uint256 pricePerToken; + uint128 startTimestamp; + uint128 endTimestamp; + address listingCreator; + address assetContract; + address currency; + TokenType tokenType; + Status status; + bool reserved; + } + + /// @notice Emitted when a new listing is created. + event NewListing( + address indexed listingCreator, + uint256 indexed listingId, + address indexed assetContract, + Listing listing + ); + + /// @notice Emitted when a listing is updated. + event UpdatedListing( + address indexed listingCreator, + uint256 indexed listingId, + address indexed assetContract, + Listing listing + ); + + /// @notice Emitted when a listing is cancelled. + event CancelledListing(address indexed listingCreator, uint256 indexed listingId); + + /// @notice Emitted when a buyer is approved to buy from a reserved listing. + event BuyerApprovedForListing(uint256 indexed listingId, address indexed buyer, bool approved); + + /// @notice Emitted when a currency is approved as a form of payment for the listing. + event CurrencyApprovedForListing(uint256 indexed listingId, address indexed currency, uint256 pricePerToken); + + /// @notice Emitted when NFTs are bought from a listing. + event NewSale( + address indexed listingCreator, + uint256 indexed listingId, + address indexed assetContract, + uint256 tokenId, + address buyer, + uint256 quantityBought, + uint256 totalPricePaid + ); + + /** + * @notice List NFTs (ERC721 or ERC1155) for sale at a fixed price. + * + * @param _params The parameters of a listing a seller sets when creating a listing. + * + * @return listingId The unique integer ID of the listing. + */ + function createListing(ListingParameters memory _params) external returns (uint256 listingId); + + /** + * @notice Update parameters of a listing of NFTs. + * + * @param _listingId The ID of the listing to update. + * @param _params The parameters of a listing a seller sets when updating a listing. + */ + function updateListing(uint256 _listingId, ListingParameters memory _params) external; + + /** + * @notice Cancel a listing. + * + * @param _listingId The ID of the listing to cancel. + */ + function cancelListing(uint256 _listingId) external; + + /** + * @notice Approve a buyer to buy from a reserved listing. + * + * @param _listingId The ID of the listing to update. + * @param _buyer The address of the buyer to approve to buy from the listing. + * @param _toApprove Whether to approve the buyer to buy from the listing. + */ + function approveBuyerForListing(uint256 _listingId, address _buyer, bool _toApprove) external; + + /** + * @notice Approve a currency as a form of payment for the listing. + * + * @param _listingId The ID of the listing to update. + * @param _currency The address of the currency to approve as a form of payment for the listing. + * @param _pricePerTokenInCurrency The price per token for the currency to approve. + */ + function approveCurrencyForListing( + uint256 _listingId, + address _currency, + uint256 _pricePerTokenInCurrency + ) external; + + /** + * @notice Buy NFTs from a listing. + * + * @param _listingId The ID of the listing to update. + * @param _buyFor The recipient of the NFTs being bought. + * @param _quantity The quantity of NFTs to buy from the listing. + * @param _currency The currency to use to pay for NFTs. + * @param _expectedTotalPrice The expected total price to pay for the NFTs being bought. + */ + function buyFromListing( + uint256 _listingId, + address _buyFor, + uint256 _quantity, + address _currency, + uint256 _expectedTotalPrice + ) external payable; + + /** + * @notice Returns the total number of listings created. + * @dev At any point, the return value is the ID of the next listing created. + */ + function totalListings() external view returns (uint256); + + /// @notice Returns all listings between the start and end Id (both inclusive) provided. + function getAllListings(uint256 _startId, uint256 _endId) external view returns (Listing[] memory listings); + + /** + * @notice Returns all valid listings between the start and end Id (both inclusive) provided. + * A valid listing is where the listing creator still owns and has approved Marketplace + * to transfer the listed NFTs. + */ + function getAllValidListings(uint256 _startId, uint256 _endId) external view returns (Listing[] memory listings); + + /** + * @notice Returns a listing at the provided listing ID. + * + * @param _listingId The ID of the listing to fetch. + */ + function getListing(uint256 _listingId) external view returns (Listing memory listing); +} + +/** + * The `EnglishAuctions` extension smart contract lets you sell NFTs (ERC-721 or ERC-1155) in an english auction. + */ + +interface IEnglishAuctions { + enum TokenType { + ERC721, + ERC1155 + } + + enum Status { + UNSET, + CREATED, + COMPLETED, + CANCELLED + } + + /** + * @notice The parameters a seller sets when creating an auction listing. + * + * @param assetContract The address of the smart contract of the NFTs being auctioned. + * @param tokenId The tokenId of the NFTs being auctioned. + * @param quantity The quantity of NFTs being auctioned. This must be non-zero, and is expected to + * be `1` for ERC-721 NFTs. + * @param currency The currency in which the bid must be made when bidding for the auctioned NFTs. + * @param minimumBidAmount The minimum bid amount for the auction. + * @param buyoutBidAmount The total bid amount for which the bidder can directly purchase the auctioned items and close the auction as a result. + * @param timeBufferInSeconds This is a buffer e.g. x seconds. If a new winning bid is made less than x seconds before expirationTimestamp, the + * expirationTimestamp is increased by x seconds. + * @param bidBufferBps This is a buffer in basis points e.g. x%. To be considered as a new winning bid, a bid must be at least x% greater than + * the current winning bid. + * @param startTimestamp The timestamp at and after which bids can be made to the auction + * @param endTimestamp The timestamp at and after which bids cannot be made to the auction. + */ + struct AuctionParameters { + address assetContract; + uint256 tokenId; + uint256 quantity; + address currency; + uint256 minimumBidAmount; + uint256 buyoutBidAmount; + uint64 timeBufferInSeconds; + uint64 bidBufferBps; + uint64 startTimestamp; + uint64 endTimestamp; + } + + /** + * @notice The information stored for an auction. + * + * @param auctionId The unique ID of the auction. + * @param auctionCreator The creator of the auction. + * @param assetContract The address of the smart contract of the NFTs being auctioned. + * @param tokenId The tokenId of the NFTs being auctioned. + * @param quantity The quantity of NFTs being auctioned. This must be non-zero, and is expected to + * be `1` for ERC-721 NFTs. + * @param currency The currency in which the bid must be made when bidding for the auctioned NFTs. + * @param minimumBidAmount The minimum bid amount for the auction. + * @param buyoutBidAmount The total bid amount for which the bidder can directly purchase the auctioned items and close the auction as a result. + * @param timeBufferInSeconds This is a buffer e.g. x seconds. If a new winning bid is made less than x seconds before expirationTimestamp, the + * expirationTimestamp is increased by x seconds. + * @param bidBufferBps This is a buffer in basis points e.g. x%. To be considered as a new winning bid, a bid must be at least x% greater than + * the current winning bid. + * @param startTimestamp The timestamp at and after which bids can be made to the auction + * @param endTimestamp The timestamp at and after which bids cannot be made to the auction. + * @param status The status of the auction (created, completed, or cancelled). + * @param tokenType The type of NFTs auctioned (ERC-721 or ERC-1155) + */ + struct Auction { + uint256 auctionId; + uint256 tokenId; + uint256 quantity; + uint256 minimumBidAmount; + uint256 buyoutBidAmount; + uint64 timeBufferInSeconds; + uint64 bidBufferBps; + uint64 startTimestamp; + uint64 endTimestamp; + address auctionCreator; + address assetContract; + address currency; + TokenType tokenType; + Status status; + } + + /** + * @notice The information stored for a bid made in an auction. + * + * @param auctionId The unique ID of the auction. + * @param bidder The address of the bidder. + * @param bidAmount The total bid amount (in the currency specified by the auction). + */ + struct Bid { + uint256 auctionId; + address bidder; + uint256 bidAmount; + } + + struct AuctionPayoutStatus { + bool paidOutAuctionTokens; + bool paidOutBidAmount; + } + + /// @dev Emitted when a new auction is created. + event NewAuction( + address indexed auctionCreator, + uint256 indexed auctionId, + address indexed assetContract, + Auction auction + ); + + /// @dev Emitted when a new bid is made in an auction. + event NewBid( + uint256 indexed auctionId, + address indexed bidder, + address indexed assetContract, + uint256 bidAmount, + Auction auction + ); + + /// @notice Emitted when a auction is cancelled. + event CancelledAuction(address indexed auctionCreator, uint256 indexed auctionId); + + /// @dev Emitted when an auction is closed. + event AuctionClosed( + uint256 indexed auctionId, + address indexed assetContract, + address indexed closer, + uint256 tokenId, + address auctionCreator, + address winningBidder + ); + + /** + * @notice Put up NFTs (ERC721 or ERC1155) for an english auction. + * + * @param _params The parameters of an auction a seller sets when creating an auction. + * + * @return auctionId The unique integer ID of the auction. + */ + function createAuction(AuctionParameters memory _params) external returns (uint256 auctionId); + + /** + * @notice Cancel an auction. + * + * @param _auctionId The ID of the auction to cancel. + */ + function cancelAuction(uint256 _auctionId) external; + + /** + * @notice Distribute the winning bid amount to the auction creator. + * + * @param _auctionId The ID of an auction. + */ + function collectAuctionPayout(uint256 _auctionId) external; + + /** + * @notice Distribute the auctioned NFTs to the winning bidder. + * + * @param _auctionId The ID of an auction. + */ + function collectAuctionTokens(uint256 _auctionId) external; + + /** + * @notice Bid in an active auction. + * + * @param _auctionId The ID of the auction to bid in. + * @param _bidAmount The bid amount in the currency specified by the auction. + */ + function bidInAuction(uint256 _auctionId, uint256 _bidAmount) external payable; + + /** + * @notice Returns whether a given bid amount would make for a winning bid in an auction. + * + * @param _auctionId The ID of an auction. + * @param _bidAmount The bid amount to check. + */ + function isNewWinningBid(uint256 _auctionId, uint256 _bidAmount) external view returns (bool); + + /// @notice Returns the auction of the provided auction ID. + function getAuction(uint256 _auctionId) external view returns (Auction memory auction); + + /// @notice Returns all non-cancelled auctions. + function getAllAuctions(uint256 _startId, uint256 _endId) external view returns (Auction[] memory auctions); + + /// @notice Returns all active auctions. + function getAllValidAuctions(uint256 _startId, uint256 _endId) external view returns (Auction[] memory auctions); + + /// @notice Returns the winning bid of an active auction. + function getWinningBid( + uint256 _auctionId + ) external view returns (address bidder, address currency, uint256 bidAmount); + + /// @notice Returns whether an auction is active. + function isAuctionExpired(uint256 _auctionId) external view returns (bool); +} + +/** + * The `Offers` extension smart contract lets you make and accept offers made for NFTs (ERC-721 or ERC-1155). + */ + +interface IOffers { + enum TokenType { + ERC721, + ERC1155, + ERC20 + } + + enum Status { + UNSET, + CREATED, + COMPLETED, + CANCELLED + } + + /** + * @notice The parameters an offeror sets when making an offer for NFTs. + * + * @param assetContract The contract of the NFTs for which the offer is being made. + * @param tokenId The tokenId of the NFT for which the offer is being made. + * @param quantity The quantity of NFTs wanted. + * @param currency The currency offered for the NFTs. + * @param totalPrice The total offer amount for the NFTs. + * @param expirationTimestamp The timestamp at and after which the offer cannot be accepted. + */ + struct OfferParams { + address assetContract; + uint256 tokenId; + uint256 quantity; + address currency; + uint256 totalPrice; + uint256 expirationTimestamp; + } + + /** + * @notice The information stored for the offer made. + * + * @param offerId The ID of the offer. + * @param offeror The address of the offeror. + * @param assetContract The contract of the NFTs for which the offer is being made. + * @param tokenId The tokenId of the NFT for which the offer is being made. + * @param quantity The quantity of NFTs wanted. + * @param currency The currency offered for the NFTs. + * @param totalPrice The total offer amount for the NFTs. + * @param expirationTimestamp The timestamp at and after which the offer cannot be accepted. + * @param status The status of the offer (created, completed, or cancelled). + * @param tokenType The type of token (ERC-721 or ERC-1155) the offer is made for. + */ + struct Offer { + uint256 offerId; + uint256 tokenId; + uint256 quantity; + uint256 totalPrice; + uint256 expirationTimestamp; + address offeror; + address assetContract; + address currency; + TokenType tokenType; + Status status; + } + + /// @dev Emitted when a new offer is created. + event NewOffer(address indexed offeror, uint256 indexed offerId, address indexed assetContract, Offer offer); + + /// @dev Emitted when an offer is cancelled. + event CancelledOffer(address indexed offeror, uint256 indexed offerId); + + /// @dev Emitted when an offer is accepted. + event AcceptedOffer( + address indexed offeror, + uint256 indexed offerId, + address indexed assetContract, + uint256 tokenId, + address seller, + uint256 quantityBought, + uint256 totalPricePaid + ); + + /** + * @notice Make an offer for NFTs (ERC-721 or ERC-1155) + * + * @param _params The parameters of an offer. + * + * @return offerId The unique integer ID assigned to the offer. + */ + function makeOffer(OfferParams memory _params) external returns (uint256 offerId); + + /** + * @notice Cancel an offer. + * + * @param _offerId The ID of the offer to cancel. + */ + function cancelOffer(uint256 _offerId) external; + + /** + * @notice Accept an offer. + * + * @param _offerId The ID of the offer to accept. + */ + function acceptOffer(uint256 _offerId) external; + + /// @notice Returns an offer for the given offer ID. + function getOffer(uint256 _offerId) external view returns (Offer memory offer); + + /// @notice Returns all active (i.e. non-expired or cancelled) offers. + function getAllOffers(uint256 _startId, uint256 _endId) external view returns (Offer[] memory offers); + + /// @notice Returns all valid offers. An offer is valid if the offeror owns and has approved Marketplace to transfer the offer amount of currency. + function getAllValidOffers(uint256 _startId, uint256 _endId) external view returns (Offer[] memory offers); +} diff --git a/contracts/prebuilts/marketplace/direct-listings/DirectListingsLogic.sol b/contracts/prebuilts/marketplace/direct-listings/DirectListingsLogic.sol new file mode 100644 index 000000000..32ec02abf --- /dev/null +++ b/contracts/prebuilts/marketplace/direct-listings/DirectListingsLogic.sol @@ -0,0 +1,568 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/// @author thirdweb + +import "./DirectListingsStorage.sol"; + +// ====== External imports ====== +import "@openzeppelin/contracts/token/ERC1155/IERC1155.sol"; +import "../../../eip/interface/IERC721.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/interfaces/IERC2981.sol"; + +// ====== Internal imports ====== + +import "../../../extension/interface/IPlatformFee.sol"; +import "../../../extension/upgradeable/ERC2771ContextConsumer.sol"; +import "../../../extension/upgradeable/ReentrancyGuard.sol"; +import "../../../extension/upgradeable/PermissionsEnumerable.sol"; +import { RoyaltyPaymentsLogic } from "../../../extension/upgradeable/RoyaltyPayments.sol"; +import { CurrencyTransferLib } from "../../../lib/CurrencyTransferLib.sol"; + +/** + * @author thirdweb.com + */ +contract DirectListingsLogic is IDirectListings, ReentrancyGuard, ERC2771ContextConsumer { + /*/////////////////////////////////////////////////////////////// + Constants / Immutables + //////////////////////////////////////////////////////////////*/ + + /// @dev Only lister role holders can create listings, when listings are restricted by lister address. + bytes32 private constant LISTER_ROLE = keccak256("LISTER_ROLE"); + /// @dev Only assets from NFT contracts with asset role can be listed, when listings are restricted by asset address. + bytes32 private constant ASSET_ROLE = keccak256("ASSET_ROLE"); + + /// @dev The max bps of the contract. So, 10_000 == 100 % + uint64 private constant MAX_BPS = 10_000; + + /// @dev The address of the native token wrapper contract. + address private immutable nativeTokenWrapper; + + /*/////////////////////////////////////////////////////////////// + Modifier + //////////////////////////////////////////////////////////////*/ + + /// @dev Checks whether the caller has LISTER_ROLE. + modifier onlyListerRole() { + require(Permissions(address(this)).hasRoleWithSwitch(LISTER_ROLE, _msgSender()), "!LISTER_ROLE"); + _; + } + + /// @dev Checks whether the caller has ASSET_ROLE. + modifier onlyAssetRole(address _asset) { + require(Permissions(address(this)).hasRoleWithSwitch(ASSET_ROLE, _asset), "!ASSET_ROLE"); + _; + } + + /// @dev Checks whether caller is a listing creator. + modifier onlyListingCreator(uint256 _listingId) { + require( + _directListingsStorage().listings[_listingId].listingCreator == _msgSender(), + "Marketplace: not listing creator." + ); + _; + } + + /// @dev Checks whether a listing exists. + modifier onlyExistingListing(uint256 _listingId) { + require( + _directListingsStorage().listings[_listingId].status == IDirectListings.Status.CREATED, + "Marketplace: invalid listing." + ); + _; + } + + /*/////////////////////////////////////////////////////////////// + Constructor logic + //////////////////////////////////////////////////////////////*/ + + constructor(address _nativeTokenWrapper) { + nativeTokenWrapper = _nativeTokenWrapper; + } + + /*/////////////////////////////////////////////////////////////// + External functions + //////////////////////////////////////////////////////////////*/ + + /// @notice List NFTs (ERC721 or ERC1155) for sale at a fixed price. + function createListing( + ListingParameters calldata _params + ) external onlyListerRole onlyAssetRole(_params.assetContract) returns (uint256 listingId) { + listingId = _getNextListingId(); + address listingCreator = _msgSender(); + TokenType tokenType = _getTokenType(_params.assetContract); + + uint128 startTime = _params.startTimestamp; + uint128 endTime = _params.endTimestamp; + require(startTime < endTime, "Marketplace: endTimestamp not greater than startTimestamp."); + if (startTime < block.timestamp) { + require(startTime + 60 minutes >= block.timestamp, "Marketplace: invalid startTimestamp."); + + startTime = uint128(block.timestamp); + endTime = endTime == type(uint128).max + ? endTime + : startTime + (_params.endTimestamp - _params.startTimestamp); + } + + _validateNewListing(_params, tokenType); + + Listing memory listing = Listing({ + listingId: listingId, + listingCreator: listingCreator, + assetContract: _params.assetContract, + tokenId: _params.tokenId, + quantity: _params.quantity, + currency: _params.currency, + pricePerToken: _params.pricePerToken, + startTimestamp: startTime, + endTimestamp: endTime, + reserved: _params.reserved, + tokenType: tokenType, + status: IDirectListings.Status.CREATED + }); + + _directListingsStorage().listings[listingId] = listing; + + emit NewListing(listingCreator, listingId, _params.assetContract, listing); + } + + /// @notice Update parameters of a listing of NFTs. + function updateListing( + uint256 _listingId, + ListingParameters memory _params + ) external onlyExistingListing(_listingId) onlyAssetRole(_params.assetContract) onlyListingCreator(_listingId) { + address listingCreator = _msgSender(); + Listing memory listing = _directListingsStorage().listings[_listingId]; + TokenType tokenType = _getTokenType(_params.assetContract); + + require(listing.endTimestamp > block.timestamp, "Marketplace: listing expired."); + + require( + listing.assetContract == _params.assetContract && listing.tokenId == _params.tokenId, + "Marketplace: cannot update what token is listed." + ); + + uint128 startTime = _params.startTimestamp; + uint128 endTime = _params.endTimestamp; + require(startTime < endTime, "Marketplace: endTimestamp not greater than startTimestamp."); + require( + listing.startTimestamp > block.timestamp || + (startTime == listing.startTimestamp && endTime > block.timestamp), + "Marketplace: listing already active." + ); + if (startTime != listing.startTimestamp && startTime < block.timestamp) { + require(startTime + 60 minutes >= block.timestamp, "Marketplace: invalid startTimestamp."); + + startTime = uint128(block.timestamp); + + endTime = endTime == listing.endTimestamp || endTime == type(uint128).max + ? endTime + : startTime + (_params.endTimestamp - _params.startTimestamp); + } + + { + uint256 _approvedCurrencyPrice = _directListingsStorage().currencyPriceForListing[_listingId][ + _params.currency + ]; + require( + _approvedCurrencyPrice == 0 || _params.pricePerToken == _approvedCurrencyPrice, + "Marketplace: price different from approved price" + ); + } + + _validateNewListing(_params, tokenType); + + listing = Listing({ + listingId: _listingId, + listingCreator: listingCreator, + assetContract: _params.assetContract, + tokenId: _params.tokenId, + quantity: _params.quantity, + currency: _params.currency, + pricePerToken: _params.pricePerToken, + startTimestamp: startTime, + endTimestamp: endTime, + reserved: _params.reserved, + tokenType: tokenType, + status: IDirectListings.Status.CREATED + }); + + _directListingsStorage().listings[_listingId] = listing; + + emit UpdatedListing(listingCreator, _listingId, _params.assetContract, listing); + } + + /// @notice Cancel a listing. + function cancelListing(uint256 _listingId) external onlyExistingListing(_listingId) onlyListingCreator(_listingId) { + _directListingsStorage().listings[_listingId].status = IDirectListings.Status.CANCELLED; + emit CancelledListing(_msgSender(), _listingId); + } + + /// @notice Approve a buyer to buy from a reserved listing. + function approveBuyerForListing( + uint256 _listingId, + address _buyer, + bool _toApprove + ) external onlyExistingListing(_listingId) onlyListingCreator(_listingId) { + require(_directListingsStorage().listings[_listingId].reserved, "Marketplace: listing not reserved."); + + _directListingsStorage().isBuyerApprovedForListing[_listingId][_buyer] = _toApprove; + + emit BuyerApprovedForListing(_listingId, _buyer, _toApprove); + } + + /// @notice Approve a currency as a form of payment for the listing. + function approveCurrencyForListing( + uint256 _listingId, + address _currency, + uint256 _pricePerTokenInCurrency + ) external onlyExistingListing(_listingId) onlyListingCreator(_listingId) { + Listing memory listing = _directListingsStorage().listings[_listingId]; + require( + _currency != listing.currency || _pricePerTokenInCurrency == listing.pricePerToken, + "Marketplace: approving listing currency with different price." + ); + require( + _directListingsStorage().currencyPriceForListing[_listingId][_currency] != _pricePerTokenInCurrency, + "Marketplace: price unchanged." + ); + + _directListingsStorage().currencyPriceForListing[_listingId][_currency] = _pricePerTokenInCurrency; + + emit CurrencyApprovedForListing(_listingId, _currency, _pricePerTokenInCurrency); + } + + /// @notice Buy NFTs from a listing. + function buyFromListing( + uint256 _listingId, + address _buyFor, + uint256 _quantity, + address _currency, + uint256 _expectedTotalPrice + ) external payable nonReentrant onlyExistingListing(_listingId) { + Listing memory listing = _directListingsStorage().listings[_listingId]; + address buyer = _msgSender(); + + require( + !listing.reserved || _directListingsStorage().isBuyerApprovedForListing[_listingId][buyer], + "buyer not approved" + ); + require(_quantity > 0 && _quantity <= listing.quantity, "Buying invalid quantity"); + require( + block.timestamp < listing.endTimestamp && block.timestamp >= listing.startTimestamp, + "not within sale window." + ); + + require( + _validateOwnershipAndApproval( + listing.listingCreator, + listing.assetContract, + listing.tokenId, + _quantity, + listing.tokenType + ), + "Marketplace: not owner or approved tokens." + ); + + uint256 targetTotalPrice; + + if (_directListingsStorage().currencyPriceForListing[_listingId][_currency] > 0) { + targetTotalPrice = _quantity * _directListingsStorage().currencyPriceForListing[_listingId][_currency]; + } else { + require(_currency == listing.currency, "Paying in invalid currency."); + targetTotalPrice = _quantity * listing.pricePerToken; + } + + require(targetTotalPrice == _expectedTotalPrice, "Unexpected total price"); + + // Check: buyer owns and has approved sufficient currency for sale. + if (_currency == CurrencyTransferLib.NATIVE_TOKEN) { + require(msg.value == targetTotalPrice, "Marketplace: msg.value must exactly be the total price."); + } else { + require(msg.value == 0, "Marketplace: invalid native tokens sent."); + _validateERC20BalAndAllowance(buyer, _currency, targetTotalPrice); + } + + if (listing.quantity == _quantity) { + _directListingsStorage().listings[_listingId].status = IDirectListings.Status.COMPLETED; + } + _directListingsStorage().listings[_listingId].quantity -= _quantity; + + _payout(buyer, listing.listingCreator, _currency, targetTotalPrice, listing); + _transferListingTokens(listing.listingCreator, _buyFor, _quantity, listing); + + emit NewSale( + listing.listingCreator, + listing.listingId, + listing.assetContract, + listing.tokenId, + buyer, + _quantity, + targetTotalPrice + ); + } + + /*/////////////////////////////////////////////////////////////// + View functions + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Returns the total number of listings created. + * @dev At any point, the return value is the ID of the next listing created. + */ + function totalListings() external view returns (uint256) { + return _directListingsStorage().totalListings; + } + + /// @notice Returns whether a buyer is approved for a listing. + function isBuyerApprovedForListing(uint256 _listingId, address _buyer) external view returns (bool) { + return _directListingsStorage().isBuyerApprovedForListing[_listingId][_buyer]; + } + + /// @notice Returns whether a currency is approved for a listing. + function isCurrencyApprovedForListing(uint256 _listingId, address _currency) external view returns (bool) { + return _directListingsStorage().currencyPriceForListing[_listingId][_currency] > 0; + } + + /// @notice Returns the price per token for a listing, in the given currency. + function currencyPriceForListing(uint256 _listingId, address _currency) external view returns (uint256) { + if (_directListingsStorage().currencyPriceForListing[_listingId][_currency] == 0) { + revert("Currency not approved for listing"); + } + + return _directListingsStorage().currencyPriceForListing[_listingId][_currency]; + } + + /// @notice Returns all non-cancelled listings. + function getAllListings(uint256 _startId, uint256 _endId) external view returns (Listing[] memory _allListings) { + require(_startId <= _endId && _endId < _directListingsStorage().totalListings, "invalid range"); + + _allListings = new Listing[](_endId - _startId + 1); + + for (uint256 i = _startId; i <= _endId; i += 1) { + _allListings[i - _startId] = _directListingsStorage().listings[i]; + } + } + + /** + * @notice Returns all valid listings between the start and end Id (both inclusive) provided. + * A valid listing is where the listing creator still owns and has approved Marketplace + * to transfer the listed NFTs. + */ + function getAllValidListings( + uint256 _startId, + uint256 _endId + ) external view returns (Listing[] memory _validListings) { + require(_startId <= _endId && _endId < _directListingsStorage().totalListings, "invalid range"); + + Listing[] memory _listings = new Listing[](_endId - _startId + 1); + uint256 _listingCount; + + for (uint256 i = _startId; i <= _endId; i += 1) { + _listings[i - _startId] = _directListingsStorage().listings[i]; + if (_validateExistingListing(_listings[i - _startId])) { + _listingCount += 1; + } + } + + _validListings = new Listing[](_listingCount); + uint256 index = 0; + uint256 count = _listings.length; + for (uint256 i = 0; i < count; i += 1) { + if (_validateExistingListing(_listings[i])) { + _validListings[index++] = _listings[i]; + } + } + } + + /// @notice Returns a listing at a particular listing ID. + function getListing(uint256 _listingId) external view returns (Listing memory listing) { + listing = _directListingsStorage().listings[_listingId]; + } + + /*/////////////////////////////////////////////////////////////// + Internal functions + //////////////////////////////////////////////////////////////*/ + + /// @dev Returns the next listing Id. + function _getNextListingId() internal returns (uint256 id) { + id = _directListingsStorage().totalListings; + _directListingsStorage().totalListings += 1; + } + + /// @dev Returns the interface supported by a contract. + function _getTokenType(address _assetContract) internal view returns (TokenType tokenType) { + if (IERC165(_assetContract).supportsInterface(type(IERC1155).interfaceId)) { + tokenType = TokenType.ERC1155; + } else if (IERC165(_assetContract).supportsInterface(type(IERC721).interfaceId)) { + tokenType = TokenType.ERC721; + } else { + revert("Marketplace: listed token must be ERC1155 or ERC721."); + } + } + + /// @dev Checks whether the listing creator owns and has approved marketplace to transfer listed tokens. + function _validateNewListing(ListingParameters memory _params, TokenType _tokenType) internal view { + require(_params.quantity > 0, "Marketplace: listing zero quantity."); + require(_params.quantity == 1 || _tokenType == TokenType.ERC1155, "Marketplace: listing invalid quantity."); + + require( + _validateOwnershipAndApproval( + _msgSender(), + _params.assetContract, + _params.tokenId, + _params.quantity, + _tokenType + ), + "Marketplace: not owner or approved tokens." + ); + } + + /// @dev Checks whether the listing exists, is active, and if the lister has sufficient balance. + function _validateExistingListing(Listing memory _targetListing) internal view returns (bool isValid) { + isValid = + _targetListing.startTimestamp <= block.timestamp && + _targetListing.endTimestamp > block.timestamp && + _targetListing.status == IDirectListings.Status.CREATED && + _validateOwnershipAndApproval( + _targetListing.listingCreator, + _targetListing.assetContract, + _targetListing.tokenId, + _targetListing.quantity, + _targetListing.tokenType + ); + } + + /// @dev Validates that `_tokenOwner` owns and has approved Marketplace to transfer NFTs. + function _validateOwnershipAndApproval( + address _tokenOwner, + address _assetContract, + uint256 _tokenId, + uint256 _quantity, + TokenType _tokenType + ) internal view returns (bool isValid) { + address market = address(this); + + if (_tokenType == TokenType.ERC1155) { + isValid = + IERC1155(_assetContract).balanceOf(_tokenOwner, _tokenId) >= _quantity && + IERC1155(_assetContract).isApprovedForAll(_tokenOwner, market); + } else if (_tokenType == TokenType.ERC721) { + address owner; + address operator; + + // failsafe for reverts in case of non-existent tokens + try IERC721(_assetContract).ownerOf(_tokenId) returns (address _owner) { + owner = _owner; + + // Nesting the approval check inside this try block, to run only if owner check doesn't revert. + // If the previous check for owner fails, then the return value will always evaluate to false. + try IERC721(_assetContract).getApproved(_tokenId) returns (address _operator) { + operator = _operator; + } catch {} + } catch {} + + isValid = + owner == _tokenOwner && + (operator == market || IERC721(_assetContract).isApprovedForAll(_tokenOwner, market)); + } + } + + /// @dev Validates that `_tokenOwner` owns and has approved Markeplace to transfer the appropriate amount of currency + function _validateERC20BalAndAllowance(address _tokenOwner, address _currency, uint256 _amount) internal view { + require( + IERC20(_currency).balanceOf(_tokenOwner) >= _amount && + IERC20(_currency).allowance(_tokenOwner, address(this)) >= _amount, + "!BAL20" + ); + } + + /// @dev Transfers tokens listed for sale in a direct or auction listing. + function _transferListingTokens(address _from, address _to, uint256 _quantity, Listing memory _listing) internal { + if (_listing.tokenType == TokenType.ERC1155) { + IERC1155(_listing.assetContract).safeTransferFrom(_from, _to, _listing.tokenId, _quantity, ""); + } else if (_listing.tokenType == TokenType.ERC721) { + IERC721(_listing.assetContract).safeTransferFrom(_from, _to, _listing.tokenId, ""); + } + } + + /// @dev Pays out stakeholders in a sale. + function _payout( + address _payer, + address _payee, + address _currencyToUse, + uint256 _totalPayoutAmount, + Listing memory _listing + ) internal { + address _nativeTokenWrapper = nativeTokenWrapper; + uint256 amountRemaining; + + // Payout platform fee + { + (address platformFeeRecipient, uint16 platformFeeBps) = IPlatformFee(address(this)).getPlatformFeeInfo(); + uint256 platformFeeCut = (_totalPayoutAmount * platformFeeBps) / MAX_BPS; + + // Transfer platform fee + CurrencyTransferLib.transferCurrencyWithWrapper( + _currencyToUse, + _payer, + platformFeeRecipient, + platformFeeCut, + _nativeTokenWrapper + ); + + amountRemaining = _totalPayoutAmount - platformFeeCut; + } + + // Payout royalties + { + // Get royalty recipients and amounts + (address payable[] memory recipients, uint256[] memory amounts) = RoyaltyPaymentsLogic(address(this)) + .getRoyalty(_listing.assetContract, _listing.tokenId, _totalPayoutAmount); + + uint256 royaltyRecipientCount = recipients.length; + + if (royaltyRecipientCount != 0) { + uint256 royaltyCut; + address royaltyRecipient; + + for (uint256 i = 0; i < royaltyRecipientCount; ) { + royaltyRecipient = recipients[i]; + royaltyCut = amounts[i]; + + // Check payout amount remaining is enough to cover royalty payment + require(amountRemaining >= royaltyCut, "fees exceed the price"); + + // Transfer royalty + CurrencyTransferLib.transferCurrencyWithWrapper( + _currencyToUse, + _payer, + royaltyRecipient, + royaltyCut, + _nativeTokenWrapper + ); + + unchecked { + amountRemaining -= royaltyCut; + ++i; + } + } + } + } + + // Distribute price to token owner + CurrencyTransferLib.transferCurrencyWithWrapper( + _currencyToUse, + _payer, + _payee, + amountRemaining, + _nativeTokenWrapper + ); + } + + /// @dev Returns the DirectListings storage. + function _directListingsStorage() internal pure returns (DirectListingsStorage.Data storage data) { + data = DirectListingsStorage.data(); + } +} diff --git a/contracts/prebuilts/marketplace/direct-listings/DirectListingsStorage.sol b/contracts/prebuilts/marketplace/direct-listings/DirectListingsStorage.sol new file mode 100644 index 000000000..b9dfd1485 --- /dev/null +++ b/contracts/prebuilts/marketplace/direct-listings/DirectListingsStorage.sol @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/// @author thirdweb + +import { IDirectListings } from "../IMarketplace.sol"; + +/** + * @author thirdweb.com + */ +library DirectListingsStorage { + /// @custom:storage-location erc7201:direct.listings.storage + /// @dev keccak256(abi.encode(uint256(keccak256("direct.listings.storage")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 public constant DIRECT_LISTINGS_STORAGE_POSITION = + 0xa5370dfa5e46a36b8e1214352e211aa04006b977c8fd45a98e6b8c6e230ba000; + + struct Data { + uint256 totalListings; + mapping(uint256 => IDirectListings.Listing) listings; + mapping(uint256 => mapping(address => bool)) isBuyerApprovedForListing; + mapping(uint256 => mapping(address => uint256)) currencyPriceForListing; + } + + function data() internal pure returns (Data storage data_) { + bytes32 position = DIRECT_LISTINGS_STORAGE_POSITION; + assembly { + data_.slot := position + } + } +} diff --git a/contracts/prebuilts/marketplace/english-auctions/EnglishAuctionsLogic.sol b/contracts/prebuilts/marketplace/english-auctions/EnglishAuctionsLogic.sol new file mode 100644 index 000000000..89a56f756 --- /dev/null +++ b/contracts/prebuilts/marketplace/english-auctions/EnglishAuctionsLogic.sol @@ -0,0 +1,535 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/// @author thirdweb + +import "./EnglishAuctionsStorage.sol"; + +// ====== External imports ====== +import "@openzeppelin/contracts/utils/Context.sol"; +import "@openzeppelin/contracts/utils/introspection/IERC165.sol"; +import "@openzeppelin/contracts/token/ERC1155/IERC1155.sol"; +import "../../../eip/interface/IERC721.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/interfaces/IERC2981.sol"; + +// ====== Internal imports ====== + +import "../../../extension/interface/IPlatformFee.sol"; +import "../../../extension/upgradeable/ERC2771ContextConsumer.sol"; +import "../../../extension/upgradeable/ReentrancyGuard.sol"; +import "../../../extension/upgradeable/PermissionsEnumerable.sol"; +import { RoyaltyPaymentsLogic } from "../../../extension/upgradeable/RoyaltyPayments.sol"; +import { CurrencyTransferLib } from "../../../lib/CurrencyTransferLib.sol"; + +/** + * @author thirdweb.com + */ +contract EnglishAuctionsLogic is IEnglishAuctions, ReentrancyGuard, ERC2771ContextConsumer { + /*/////////////////////////////////////////////////////////////// + Constants / Immutables + //////////////////////////////////////////////////////////////*/ + + /// @dev Only lister role holders can create auctions, when auctions are restricted by lister address. + bytes32 private constant LISTER_ROLE = keccak256("LISTER_ROLE"); + /// @dev Only assets from NFT contracts with asset role can be auctioned, when auctions are restricted by asset address. + bytes32 private constant ASSET_ROLE = keccak256("ASSET_ROLE"); + + /// @dev The max bps of the contract. So, 10_000 == 100 % + uint64 private constant MAX_BPS = 10_000; + + /// @dev The address of the native token wrapper contract. + address private immutable nativeTokenWrapper; + + /*/////////////////////////////////////////////////////////////// + Modifiers + //////////////////////////////////////////////////////////////*/ + + modifier onlyListerRole() { + require(Permissions(address(this)).hasRoleWithSwitch(LISTER_ROLE, _msgSender()), "!LISTER_ROLE"); + _; + } + + modifier onlyAssetRole(address _asset) { + require(Permissions(address(this)).hasRoleWithSwitch(ASSET_ROLE, _asset), "!ASSET_ROLE"); + _; + } + + /// @dev Checks whether caller is a auction creator. + modifier onlyAuctionCreator(uint256 _auctionId) { + require( + _englishAuctionsStorage().auctions[_auctionId].auctionCreator == _msgSender(), + "Marketplace: not auction creator." + ); + _; + } + + /// @dev Checks whether an auction exists. + modifier onlyExistingAuction(uint256 _auctionId) { + require( + _englishAuctionsStorage().auctions[_auctionId].status == IEnglishAuctions.Status.CREATED, + "Marketplace: invalid auction." + ); + _; + } + + /*/////////////////////////////////////////////////////////////// + Constructor logic + //////////////////////////////////////////////////////////////*/ + + constructor(address _nativeTokenWrapper) { + nativeTokenWrapper = _nativeTokenWrapper; + } + + /*/////////////////////////////////////////////////////////////// + External functions + //////////////////////////////////////////////////////////////*/ + + /// @notice Auction ERC721 or ERC1155 NFTs. + function createAuction( + AuctionParameters calldata _params + ) external onlyListerRole onlyAssetRole(_params.assetContract) nonReentrant returns (uint256 auctionId) { + auctionId = _getNextAuctionId(); + address auctionCreator = _msgSender(); + TokenType tokenType = _getTokenType(_params.assetContract); + + _validateNewAuction(_params, tokenType); + + Auction memory auction = Auction({ + auctionId: auctionId, + auctionCreator: auctionCreator, + assetContract: _params.assetContract, + tokenId: _params.tokenId, + quantity: _params.quantity, + currency: _params.currency, + minimumBidAmount: _params.minimumBidAmount, + buyoutBidAmount: _params.buyoutBidAmount, + timeBufferInSeconds: _params.timeBufferInSeconds, + bidBufferBps: _params.bidBufferBps, + startTimestamp: _params.startTimestamp, + endTimestamp: _params.endTimestamp, + tokenType: tokenType, + status: IEnglishAuctions.Status.CREATED + }); + + _englishAuctionsStorage().auctions[auctionId] = auction; + + _transferAuctionTokens(auctionCreator, address(this), auction); + + emit NewAuction(auctionCreator, auctionId, _params.assetContract, auction); + } + + function bidInAuction( + uint256 _auctionId, + uint256 _bidAmount + ) external payable nonReentrant onlyExistingAuction(_auctionId) { + Auction memory _targetAuction = _englishAuctionsStorage().auctions[_auctionId]; + + require( + _targetAuction.endTimestamp > block.timestamp && _targetAuction.startTimestamp <= block.timestamp, + "Marketplace: inactive auction." + ); + require(_bidAmount != 0, "Marketplace: Bidding with zero amount."); + require( + _targetAuction.currency == CurrencyTransferLib.NATIVE_TOKEN || msg.value == 0, + "Marketplace: invalid native tokens sent." + ); + require( + _bidAmount <= _targetAuction.buyoutBidAmount || _targetAuction.buyoutBidAmount == 0, + "Marketplace: Bidding above buyout price." + ); + + Bid memory newBid = Bid({ auctionId: _auctionId, bidder: _msgSender(), bidAmount: _bidAmount }); + + _handleBid(_targetAuction, newBid); + } + + function collectAuctionPayout(uint256 _auctionId) external nonReentrant { + require( + !_englishAuctionsStorage().payoutStatus[_auctionId].paidOutBidAmount, + "Marketplace: payout already completed." + ); + _englishAuctionsStorage().payoutStatus[_auctionId].paidOutBidAmount = true; + + Auction memory _targetAuction = _englishAuctionsStorage().auctions[_auctionId]; + Bid memory _winningBid = _englishAuctionsStorage().winningBid[_auctionId]; + + require(_targetAuction.status != IEnglishAuctions.Status.CANCELLED, "Marketplace: invalid auction."); + require(_targetAuction.endTimestamp <= block.timestamp, "Marketplace: auction still active."); + require(_winningBid.bidder != address(0), "Marketplace: no bids were made."); + + _closeAuctionForAuctionCreator(_targetAuction, _winningBid); + + if (_targetAuction.status != IEnglishAuctions.Status.COMPLETED) { + _englishAuctionsStorage().auctions[_auctionId].status = IEnglishAuctions.Status.COMPLETED; + } + } + + function collectAuctionTokens(uint256 _auctionId) external nonReentrant { + Auction memory _targetAuction = _englishAuctionsStorage().auctions[_auctionId]; + Bid memory _winningBid = _englishAuctionsStorage().winningBid[_auctionId]; + + require(_targetAuction.status != IEnglishAuctions.Status.CANCELLED, "Marketplace: invalid auction."); + require(_targetAuction.endTimestamp <= block.timestamp, "Marketplace: auction still active."); + require(_winningBid.bidder != address(0), "Marketplace: no bids were made."); + + _closeAuctionForBidder(_targetAuction, _winningBid); + + if (_targetAuction.status != IEnglishAuctions.Status.COMPLETED) { + _englishAuctionsStorage().auctions[_auctionId].status = IEnglishAuctions.Status.COMPLETED; + } + } + + /// @dev Cancels an auction. + function cancelAuction( + uint256 _auctionId + ) external onlyExistingAuction(_auctionId) onlyAuctionCreator(_auctionId) nonReentrant { + Auction memory _targetAuction = _englishAuctionsStorage().auctions[_auctionId]; + Bid memory _winningBid = _englishAuctionsStorage().winningBid[_auctionId]; + + require(_winningBid.bidder == address(0), "Marketplace: bids already made."); + + _englishAuctionsStorage().auctions[_auctionId].status = IEnglishAuctions.Status.CANCELLED; + + _transferAuctionTokens(address(this), _targetAuction.auctionCreator, _targetAuction); + + emit CancelledAuction(_targetAuction.auctionCreator, _auctionId); + } + + /*/////////////////////////////////////////////////////////////// + View functions + //////////////////////////////////////////////////////////////*/ + + function isNewWinningBid( + uint256 _auctionId, + uint256 _bidAmount + ) external view onlyExistingAuction(_auctionId) returns (bool) { + Auction memory _targetAuction = _englishAuctionsStorage().auctions[_auctionId]; + Bid memory _currentWinningBid = _englishAuctionsStorage().winningBid[_auctionId]; + + return + _isNewWinningBid( + _targetAuction.minimumBidAmount, + _currentWinningBid.bidAmount, + _bidAmount, + _targetAuction.bidBufferBps + ); + } + + function totalAuctions() external view returns (uint256) { + return _englishAuctionsStorage().totalAuctions; + } + + function getAuction(uint256 _auctionId) external view returns (Auction memory _auction) { + _auction = _englishAuctionsStorage().auctions[_auctionId]; + } + + function getAllAuctions(uint256 _startId, uint256 _endId) external view returns (Auction[] memory _allAuctions) { + require(_startId <= _endId && _endId < _englishAuctionsStorage().totalAuctions, "invalid range"); + + _allAuctions = new Auction[](_endId - _startId + 1); + + for (uint256 i = _startId; i <= _endId; i += 1) { + _allAuctions[i - _startId] = _englishAuctionsStorage().auctions[i]; + } + } + + function getAllValidAuctions( + uint256 _startId, + uint256 _endId + ) external view returns (Auction[] memory _validAuctions) { + require(_startId <= _endId && _endId < _englishAuctionsStorage().totalAuctions, "invalid range"); + + Auction[] memory _auctions = new Auction[](_endId - _startId + 1); + uint256 _auctionCount; + + for (uint256 i = _startId; i <= _endId; i += 1) { + uint256 j = i - _startId; + _auctions[j] = _englishAuctionsStorage().auctions[i]; + if ( + _auctions[j].startTimestamp <= block.timestamp && + _auctions[j].endTimestamp > block.timestamp && + _auctions[j].status == IEnglishAuctions.Status.CREATED && + _auctions[j].assetContract != address(0) + ) { + _auctionCount += 1; + } + } + + _validAuctions = new Auction[](_auctionCount); + uint256 index = 0; + uint256 count = _auctions.length; + for (uint256 i = 0; i < count; i += 1) { + if ( + _auctions[i].startTimestamp <= block.timestamp && + _auctions[i].endTimestamp > block.timestamp && + _auctions[i].status == IEnglishAuctions.Status.CREATED && + _auctions[i].assetContract != address(0) + ) { + _validAuctions[index++] = _auctions[i]; + } + } + } + + function getWinningBid( + uint256 _auctionId + ) external view returns (address _bidder, address _currency, uint256 _bidAmount) { + Auction memory _targetAuction = _englishAuctionsStorage().auctions[_auctionId]; + Bid memory _currentWinningBid = _englishAuctionsStorage().winningBid[_auctionId]; + + _bidder = _currentWinningBid.bidder; + _currency = _targetAuction.currency; + _bidAmount = _currentWinningBid.bidAmount; + } + + function isAuctionExpired(uint256 _auctionId) external view onlyExistingAuction(_auctionId) returns (bool) { + return _englishAuctionsStorage().auctions[_auctionId].endTimestamp >= block.timestamp; + } + + /*/////////////////////////////////////////////////////////////// + Internal functions + //////////////////////////////////////////////////////////////*/ + + /// @dev Returns the next auction Id. + function _getNextAuctionId() internal returns (uint256 id) { + id = _englishAuctionsStorage().totalAuctions; + _englishAuctionsStorage().totalAuctions += 1; + } + + /// @dev Returns the interface supported by a contract. + function _getTokenType(address _assetContract) internal view returns (TokenType tokenType) { + if (IERC165(_assetContract).supportsInterface(type(IERC1155).interfaceId)) { + tokenType = TokenType.ERC1155; + } else if (IERC165(_assetContract).supportsInterface(type(IERC721).interfaceId)) { + tokenType = TokenType.ERC721; + } else { + revert("Marketplace: auctioned token must be ERC1155 or ERC721."); + } + } + + /// @dev Checks whether the auction creator owns and has approved marketplace to transfer auctioned tokens. + function _validateNewAuction(AuctionParameters memory _params, TokenType _tokenType) internal view { + require(_params.quantity > 0, "Marketplace: auctioning zero quantity."); + require(_params.quantity == 1 || _tokenType == TokenType.ERC1155, "Marketplace: auctioning invalid quantity."); + require(_params.timeBufferInSeconds > 0, "Marketplace: no time-buffer."); + require(_params.bidBufferBps > 0, "Marketplace: no bid-buffer."); + require( + _params.startTimestamp + 60 minutes >= block.timestamp && _params.startTimestamp < _params.endTimestamp, + "Marketplace: invalid timestamps." + ); + require( + _params.buyoutBidAmount == 0 || _params.buyoutBidAmount >= _params.minimumBidAmount, + "Marketplace: invalid bid amounts." + ); + } + + /// @dev Processes an incoming bid in an auction. + function _handleBid(Auction memory _targetAuction, Bid memory _incomingBid) internal { + Bid memory currentWinningBid = _englishAuctionsStorage().winningBid[_targetAuction.auctionId]; + uint256 currentBidAmount = currentWinningBid.bidAmount; + uint256 incomingBidAmount = _incomingBid.bidAmount; + address _nativeTokenWrapper = nativeTokenWrapper; + + // Close auction and execute sale if there's a buyout price and incoming bid amount is buyout price. + if (_targetAuction.buyoutBidAmount > 0 && incomingBidAmount >= _targetAuction.buyoutBidAmount) { + incomingBidAmount = _targetAuction.buyoutBidAmount; + _incomingBid.bidAmount = _targetAuction.buyoutBidAmount; + + _closeAuctionForBidder(_targetAuction, _incomingBid); + } else { + /** + * If there's an exisitng winning bid, incoming bid amount must be bid buffer % greater. + * Else, bid amount must be at least as great as minimum bid amount + */ + require( + _isNewWinningBid( + _targetAuction.minimumBidAmount, + currentBidAmount, + incomingBidAmount, + _targetAuction.bidBufferBps + ), + "Marketplace: not winning bid." + ); + + // Update the winning bid and auction's end time before external contract calls. + _englishAuctionsStorage().winningBid[_targetAuction.auctionId] = _incomingBid; + + if (_targetAuction.endTimestamp - block.timestamp <= _targetAuction.timeBufferInSeconds) { + _targetAuction.endTimestamp += _targetAuction.timeBufferInSeconds; + _englishAuctionsStorage().auctions[_targetAuction.auctionId] = _targetAuction; + } + } + + // Payout previous highest bid. + if (currentWinningBid.bidder != address(0) && currentBidAmount > 0) { + CurrencyTransferLib.transferCurrencyWithWrapper( + _targetAuction.currency, + address(this), + currentWinningBid.bidder, + currentBidAmount, + _nativeTokenWrapper + ); + } + + // Collect incoming bid + CurrencyTransferLib.transferCurrencyWithWrapper( + _targetAuction.currency, + _incomingBid.bidder, + address(this), + incomingBidAmount, + _nativeTokenWrapper + ); + + emit NewBid( + _targetAuction.auctionId, + _incomingBid.bidder, + _targetAuction.assetContract, + _incomingBid.bidAmount, + _targetAuction + ); + } + + /// @dev Checks whether an incoming bid is the new current highest bid. + function _isNewWinningBid( + uint256 _minimumBidAmount, + uint256 _currentWinningBidAmount, + uint256 _incomingBidAmount, + uint256 _bidBufferBps + ) internal pure returns (bool isValidNewBid) { + if (_currentWinningBidAmount == 0) { + isValidNewBid = _incomingBidAmount >= _minimumBidAmount; + } else { + isValidNewBid = (_incomingBidAmount > _currentWinningBidAmount && + ((_incomingBidAmount - _currentWinningBidAmount) * MAX_BPS) / _currentWinningBidAmount >= + _bidBufferBps); + } + } + + /// @dev Closes an auction for the winning bidder; distributes auction items to the winning bidder. + function _closeAuctionForBidder(Auction memory _targetAuction, Bid memory _winningBid) internal { + require( + !_englishAuctionsStorage().payoutStatus[_targetAuction.auctionId].paidOutAuctionTokens, + "Marketplace: payout already completed." + ); + _englishAuctionsStorage().payoutStatus[_targetAuction.auctionId].paidOutAuctionTokens = true; + + _targetAuction.endTimestamp = uint64(block.timestamp); + + _englishAuctionsStorage().winningBid[_targetAuction.auctionId] = _winningBid; + _englishAuctionsStorage().auctions[_targetAuction.auctionId] = _targetAuction; + + _transferAuctionTokens(address(this), _winningBid.bidder, _targetAuction); + + emit AuctionClosed( + _targetAuction.auctionId, + _targetAuction.assetContract, + _msgSender(), + _targetAuction.tokenId, + _targetAuction.auctionCreator, + _winningBid.bidder + ); + } + + /// @dev Closes an auction for an auction creator; distributes winning bid amount to auction creator. + function _closeAuctionForAuctionCreator(Auction memory _targetAuction, Bid memory _winningBid) internal { + uint256 payoutAmount = _winningBid.bidAmount; + _payout(address(this), _targetAuction.auctionCreator, _targetAuction.currency, payoutAmount, _targetAuction); + + emit AuctionClosed( + _targetAuction.auctionId, + _targetAuction.assetContract, + _msgSender(), + _targetAuction.tokenId, + _targetAuction.auctionCreator, + _winningBid.bidder + ); + } + + /// @dev Transfers tokens for auction. + function _transferAuctionTokens(address _from, address _to, Auction memory _auction) internal { + if (_auction.tokenType == TokenType.ERC1155) { + IERC1155(_auction.assetContract).safeTransferFrom(_from, _to, _auction.tokenId, _auction.quantity, ""); + } else if (_auction.tokenType == TokenType.ERC721) { + IERC721(_auction.assetContract).safeTransferFrom(_from, _to, _auction.tokenId, ""); + } + } + + /// @dev Pays out stakeholders in auction. + function _payout( + address _payer, + address _payee, + address _currencyToUse, + uint256 _totalPayoutAmount, + Auction memory _targetAuction + ) internal { + address _nativeTokenWrapper = nativeTokenWrapper; + uint256 amountRemaining; + + // Payout platform fee + { + (address platformFeeRecipient, uint16 platformFeeBps) = IPlatformFee(address(this)).getPlatformFeeInfo(); + uint256 platformFeeCut = (_totalPayoutAmount * platformFeeBps) / MAX_BPS; + + // Transfer platform fee + CurrencyTransferLib.transferCurrencyWithWrapper( + _currencyToUse, + _payer, + platformFeeRecipient, + platformFeeCut, + _nativeTokenWrapper + ); + + amountRemaining = _totalPayoutAmount - platformFeeCut; + } + + // Payout royalties + { + // Get royalty recipients and amounts + (address payable[] memory recipients, uint256[] memory amounts) = RoyaltyPaymentsLogic(address(this)) + .getRoyalty(_targetAuction.assetContract, _targetAuction.tokenId, _totalPayoutAmount); + + uint256 royaltyRecipientCount = recipients.length; + + if (royaltyRecipientCount != 0) { + uint256 royaltyCut; + address royaltyRecipient; + + for (uint256 i = 0; i < royaltyRecipientCount; ) { + royaltyRecipient = recipients[i]; + royaltyCut = amounts[i]; + + // Check payout amount remaining is enough to cover royalty payment + require(amountRemaining >= royaltyCut, "fees exceed the price"); + + // Transfer royalty + CurrencyTransferLib.transferCurrencyWithWrapper( + _currencyToUse, + _payer, + royaltyRecipient, + royaltyCut, + _nativeTokenWrapper + ); + + unchecked { + amountRemaining -= royaltyCut; + ++i; + } + } + } + } + + // Distribute price to token owner + CurrencyTransferLib.transferCurrencyWithWrapper( + _currencyToUse, + _payer, + _payee, + amountRemaining, + _nativeTokenWrapper + ); + } + + /// @dev Returns the EnglishAuctions storage. + function _englishAuctionsStorage() internal pure returns (EnglishAuctionsStorage.Data storage data) { + data = EnglishAuctionsStorage.data(); + } +} diff --git a/contracts/prebuilts/marketplace/english-auctions/EnglishAuctionsStorage.sol b/contracts/prebuilts/marketplace/english-auctions/EnglishAuctionsStorage.sol new file mode 100644 index 000000000..d886289ce --- /dev/null +++ b/contracts/prebuilts/marketplace/english-auctions/EnglishAuctionsStorage.sol @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/// @author thirdweb + +import { IEnglishAuctions } from "../IMarketplace.sol"; + +/** + * @author thirdweb.com + */ +library EnglishAuctionsStorage { + /// @custom:storage-location erc7201:english.auctions.storage + /// @dev keccak256(abi.encode(uint256(keccak256("english.auctions.storage")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 public constant ENGLISH_AUCTIONS_STORAGE_POSITION = + 0x89032daddd224983b4d69fda31dc440901185d9636f6e798dbe1e433d9d34c00; + + struct Data { + uint256 totalAuctions; + mapping(uint256 => IEnglishAuctions.Auction) auctions; + mapping(uint256 => IEnglishAuctions.Bid) winningBid; + mapping(uint256 => IEnglishAuctions.AuctionPayoutStatus) payoutStatus; + } + + function data() internal pure returns (Data storage data_) { + bytes32 position = ENGLISH_AUCTIONS_STORAGE_POSITION; + assembly { + data_.slot := position + } + } +} diff --git a/contracts/prebuilts/marketplace/entrypoint/MarketplaceV3.sol b/contracts/prebuilts/marketplace/entrypoint/MarketplaceV3.sol new file mode 100644 index 000000000..d4f01945e --- /dev/null +++ b/contracts/prebuilts/marketplace/entrypoint/MarketplaceV3.sol @@ -0,0 +1,170 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +/// @author thirdweb + +// $$\ $$\ $$\ $$\ $$\ +// $$ | $$ | \__| $$ | $$ | +// $$$$$$\ $$$$$$$\ $$\ $$$$$$\ $$$$$$$ |$$\ $$\ $$\ $$$$$$\ $$$$$$$\ +// \_$$ _| $$ __$$\ $$ |$$ __$$\ $$ __$$ |$$ | $$ | $$ |$$ __$$\ $$ __$$\ +// $$ | $$ | $$ |$$ |$$ | \__|$$ / $$ |$$ | $$ | $$ |$$$$$$$$ |$$ | $$ | +// $$ |$$\ $$ | $$ |$$ |$$ | $$ | $$ |$$ | $$ | $$ |$$ ____|$$ | $$ | +// \$$$$ |$$ | $$ |$$ |$$ | \$$$$$$$ |\$$$$$\$$$$ |\$$$$$$$\ $$$$$$$ | +// \____/ \__| \__|\__|\__| \_______| \_____\____/ \_______|\_______/ + +// ====== External imports ====== +import "@openzeppelin/contracts/token/ERC1155/IERC1155Receiver.sol"; +import "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol"; + +import { ERC721Holder } from "@openzeppelin/contracts/token/ERC721/utils/ERC721Holder.sol"; +import { ERC1155Holder, ERC1155Receiver } from "@openzeppelin/contracts/token/ERC1155/utils/ERC1155Holder.sol"; + +// ========== Internal imports ========== +import { Router } from "../../../extension/plugin/RouterImmutable.sol"; + +import "../../../extension/Multicall.sol"; +import "../../../extension/upgradeable/Initializable.sol"; +import "../../../extension/upgradeable/ContractMetadata.sol"; +import "../../../extension/upgradeable/PlatformFee.sol"; +import "../../../extension/upgradeable/PermissionsEnumerable.sol"; +import "../../../extension/upgradeable/init/ReentrancyGuardInit.sol"; +import "../../../extension/upgradeable/ERC2771ContextUpgradeable.sol"; +import { RoyaltyPaymentsLogic } from "../../../extension/upgradeable/RoyaltyPayments.sol"; + +/** + * @author thirdweb.com + */ +contract MarketplaceV3 is + Initializable, + Multicall, + ContractMetadata, + PlatformFee, + PermissionsEnumerable, + ReentrancyGuardInit, + ERC2771ContextUpgradeable, + RoyaltyPaymentsLogic, + ERC721Holder, + ERC1155Holder, + Router +{ + /// @dev Only EXTENSION_ROLE holders can perform upgrades. + bytes32 private constant EXTENSION_ROLE = keccak256("EXTENSION_ROLE"); + + bytes32 private constant MODULE_TYPE = bytes32("MarketplaceV3"); + uint256 private constant VERSION = 3; + + /// @dev The address of the native token wrapper contract. + address private immutable nativeTokenWrapper; + + /*/////////////////////////////////////////////////////////////// + Constructor + initializer logic + //////////////////////////////////////////////////////////////*/ + + constructor( + address _pluginMap, + address _royaltyEngineAddress, + address _nativeTokenWrapper + ) Router(_pluginMap) RoyaltyPaymentsLogic(_royaltyEngineAddress) { + nativeTokenWrapper = _nativeTokenWrapper; + _disableInitializers(); + } + + receive() external payable override { + assert(msg.sender == nativeTokenWrapper); // only accept ETH via fallback from the native token wrapper contract + } + + /// @dev Initializes the contract, like a constructor. + function initialize( + address _defaultAdmin, + string memory _contractURI, + address[] memory _trustedForwarders, + address _platformFeeRecipient, + uint16 _platformFeeBps + ) external initializer { + // Initialize inherited contracts, most base-like -> most derived. + __ReentrancyGuard_init(); + __ERC2771Context_init(_trustedForwarders); + + // Initialize this contract's state. + _setupContractURI(_contractURI); + _setupPlatformFeeInfo(_platformFeeRecipient, _platformFeeBps); + + _setupRole(DEFAULT_ADMIN_ROLE, _defaultAdmin); + _setupRole(EXTENSION_ROLE, _defaultAdmin); + _setupRole(keccak256("LISTER_ROLE"), address(0)); + _setupRole(keccak256("ASSET_ROLE"), address(0)); + + _setupRole(EXTENSION_ROLE, _defaultAdmin); + _setRoleAdmin(EXTENSION_ROLE, EXTENSION_ROLE); + } + + /*/////////////////////////////////////////////////////////////// + Generic contract logic + //////////////////////////////////////////////////////////////*/ + + /// @dev Returns the type of the contract. + function contractType() external pure returns (bytes32) { + return MODULE_TYPE; + } + + /// @dev Returns the version of the contract. + function contractVersion() external pure returns (uint8) { + return uint8(VERSION); + } + + /*/////////////////////////////////////////////////////////////// + ERC 165 / 721 / 1155 logic + //////////////////////////////////////////////////////////////*/ + + function supportsInterface( + bytes4 interfaceId + ) public view virtual override(Router, IERC165, ERC1155Receiver) returns (bool) { + return + interfaceId == type(IERC1155Receiver).interfaceId || + interfaceId == type(IERC721Receiver).interfaceId || + super.supportsInterface(interfaceId); + } + + /*/////////////////////////////////////////////////////////////// + Overridable Permissions + //////////////////////////////////////////////////////////////*/ + + /// @dev Checks whether platform fee info can be set in the given execution context. + function _canSetPlatformFeeInfo() internal view override returns (bool) { + return _hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + /// @dev Checks whether contract metadata can be set in the given execution context. + function _canSetContractURI() internal view override returns (bool) { + return _hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + /// @dev Returns whether royalty engine address can be set in the given execution context. + function _canSetRoyaltyEngine() internal view override returns (bool) { + return _hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + /// @dev Checks whether an account has a particular role. + function _hasRole(bytes32 _role, address _account) internal view returns (bool) { + PermissionsStorage.Data storage data = PermissionsStorage.data(); + return data._hasRole[_role][_account]; + } + + /// @dev Returns whether plug-in can be set in the given execution context. + function _canSetPlugin() internal view override returns (bool) { + return _hasRole(EXTENSION_ROLE, msg.sender); + } + + function _msgSender() + internal + view + override(ERC2771ContextUpgradeable, Permissions, Multicall) + returns (address sender) + { + return ERC2771ContextUpgradeable._msgSender(); + } + + function _msgData() internal view override(ERC2771ContextUpgradeable, Permissions) returns (bytes calldata) { + return ERC2771ContextUpgradeable._msgData(); + } +} diff --git a/contracts/prebuilts/marketplace/marketplace-v3.md b/contracts/prebuilts/marketplace/marketplace-v3.md new file mode 100644 index 000000000..d224b95fe --- /dev/null +++ b/contracts/prebuilts/marketplace/marketplace-v3.md @@ -0,0 +1,794 @@ +# Marketplace V3 design document. + +This is a live document that explains what the [thirdweb](https://thirdweb.com/) `Marketplace V3` smart contract is, how it works and can be used, and why it is written the way it is. + +The document is written for technical and non-technical readers. To ask further questions about `Marketplace V3`, please join the [thirdweb discord](https://discord.gg/thirdweb) or create a [github issue](https://github.com/thirdweb-dev/contracts/issues). + +--- + +## Background + +The [thirdweb](https://thirdweb.com/) `Marketplace V3` is a marketplace where where people can sell NFTs — [ERC 721](https://eips.ethereum.org/EIPS/eip-721) or [ERC 1155](https://eips.ethereum.org/EIPS/eip-1155) tokens — at a fixed price ( what we'll refer to as a "Direct listing"), or auction them (what we'll refer to as an "Auction listing"). It also allows users to make "Offers" on unlisted NFTs. + +`Marketplace V3` offers improvements over previous version in terms of design and features, which are discussed in this document. You can refer to previous (v2) `Marketplace` design document [here](https://github.com/thirdweb-dev/contracts/blob/main/contracts/prebuilts/marketplace-legacy/marketplace.md). + +## Context behind this update + +We have given `Marketplace` an update that was long overdue. The marketplace product is still made up of three core ways of exchanging NFTs for money: + +1. Selling NFTs via a ‘direct listing’. +2. Auctioning off NFTs. +3. Making offers for NFTs not on sale at all, or at favorable prices. + +The core improvement about the `Marketplace V3` smart contract is better developer experience of working with the contract. + +Previous version had some limitations, arising due to (1) the smart contract size limit of `~24.576 kb` on Ethereum mainnet (and other thirdweb supported chains), and (2) the way the smart contract code is organized (single, large smart contract that inherits other contracts). The previous `Marketplace` smart contract has functions that have multiple jobs, behave in many different ways under different circumstances, and a lack of convenient view functions to read data easily. + +Moreover, over time, we received feature requests for `Marketplace`, some of which have been incorporated in `Marketplace V3`, for e.g.: + +- Ability to accept multiple currencies for direct listings +- Ability to explicitly cancel listings +- Explicit getter functions for fetching high level states e.g. “has an auction ended”, “who is the winning bidder”, etc. +- Simplify start time and expiration time for listings + +For all these reasons and feature additions, the `Marketplace` contract is getting an update, and being rolled out as `Marketplace V3`. In this update: + +- the contract has been broken down into independent extensions (later offered in ContractKit). +- the contract provides explicit functions for each important action (something that is missing from the contract, today). +- the contract provides convenient view functions for all relevant state of the contract, without expecting users to rely on events to read critical information. + +Finally, to accomplish all these things without the constraint of the smart contract size limit, the `Marketplace V3` contract is written in the following new code pattern, which we call `Plugin Pattern`. It was influenced by [EIP-2535](https://eips.ethereum.org/EIPS/eip-2535). You can read more about Plugin Pattern [here](https://blog.thirdweb.com/). + +## Extensions that make up `Marketplace V3` + +The `Marketplace V3` smart contract is now written as the sum of three main extension smart contracts: + +1. `DirectListings`: List NFTs for sale at a fixed price. Buy NFTs from listings. +2. `EnglishAuctions`: Put NFTs up for auction. Bid for NFTs up on auction. The highest bid within an auction’s duration wins. +3. `Offers`: Make offers of ERC20 or native token currency for NFTs. Accept a favorable offer if you own the NFTs wanted. + +Each of these extension smart contracts is independent, and does not care about the state of the other extension contracts. + +### What the Marketplace will look like to users + +There are two groups of users — (1) thirdweb's customers who'll set up the marketplace, and (2) the end users of thirdweb customers' marketplaces. + +To thirdweb customers, the marketplace can be set up like any of the other thirdweb contract (e.g. 'NFT Collection') through the thirdweb dashboard, the thirdweb SDK, or by directly consuming the open sourced marketplace smart contract. + +To the end users of thirdweb customers, the experience of using the marketplace will feel familiar to popular marketplace platforms like OpenSea, Zora, etc. The biggest difference in user experience will be that performing any action on the marketplace requires gas fees. + +- Thirdweb's customers + - Deploy the marketplace contract like any other thirdweb contract. + - Can set a % 'platform fee'. This % is collected on every sale — when a buyer buys tokens from a direct listing, and when a seller collects the highest bid on auction closing. This platform fee is distributed to the platform fee recipient (set by a contract admin). + - Can list NFTs for sale at a fixed price. + - Can edit an existing listing's parameters, e.g. the currency accepted. An auction's parameters cannot be edited once it has started. + - Can make offers to NFTs listed/unlisted for a fixed price. + - Can auction NFTs. + - Can make bids to auctions. + - Must pay gas fees to perform any actions, including the actions just listed. + +### EIPs implemented / supported + +To be able to escrow NFTs in the case of auctions, Marketplace implements the receiver interfaces for [ERC1155](https://eips.ethereum.org/EIPS/eip-1155) and [ERC721](https://eips.ethereum.org/EIPS/eip-721) tokens. + +To enable meta-transactions (gasless), Marketplace implements [ERC2771](https://eips.ethereum.org/EIPS/eip-2771). + +Marketplace also honors [ERC2981](https://eips.ethereum.org/EIPS/eip-2981) for the distribution of royalties on direct and auction listings. + +### Events emitted + +All events emitted by the contract, as well as when they're emitted, can be found in the interface of the contract, [here](https://github.com/thirdweb-dev/contracts/blob/main/contracts/prebuilts/marketplace/IMarketplace.sol). In general, events are emitted whenever there is a state change in the contract. + +### Currency transfers + +The contract supports both ERC20 currencies and a chain's native token (e.g. ether for Ethereum mainnet). This means that any action that involves transferring currency (e.g. buying a token from a direct listing) can be performed with either an ERC20 token or the chain's native token. + +💡 **Note**: The exception is offers — these can only be made with ERC20 tokens, since Marketplace needs to transfer the offer amount from the buyer to the seller, in case the latter accepts the offer. This cannot be done with native tokens without escrowing the requisite amount of currency. + +The contract wraps all native tokens deposited into it as the canonical ERC20 wrapped version of the native token (e.g. WETH for ether). The contract unwraps the wrapped native token when transferring native tokens to a given address. + +If the contract fails to transfer out native tokens, it wraps them back to wrapped native tokens, and transfers the wrapped native tokens to the concerned address. The contract may fail to transfer out native tokens to an address, if the address represents a smart contract that cannot accept native tokens transferred to it directly. + +# API Reference for Extensions + +## Direct listings + +The `DirectListings` extension smart contract lets you buy and sell NFTs (ERC-721 or ERC-1155) for a fixed price. + +### `createListing` + +**What:** List NFTs (ERC721 or ERC1155) for sale at a fixed price. + +- Interface + + ```solidity + struct ListingParameters { + address assetContract; + uint256 tokenId; + uint256 quantity; + address currency; + uint256 pricePerToken; + uint128 startTimestamp; + uint128 endTimestamp; + bool reserved; + } + + function createListing(ListingParameters memory params) external returns (uint256 listingId); + + ``` + +- Parameters + | Parameter | Description | + | ------------- | --------------------------------------------------------------------------------------------------- | + | assetContract | The address of the smart contract of the NFTs being listed. | + | tokenId | The tokenId of the NFTs being listed. | + | quantity | The quantity of NFTs being listed. This must be non-zero, and is expected to be 1 for ERC-721 NFTs. | + | currency | The currency in which the price must be paid when buying the listed NFTs. The address considered for native tokens of the chain is 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE | + | pricePerToken | The price to pay per unit of NFTs listed. | + | startTimestamp | The UNIX timestamp at and after which NFTs can be bought from the listing. | + | expirationTimestamp | The UNIX timestamp at and after which NFTs cannot be bought from the listing. | + | reserved | Whether the listing is reserved to be bought from a specific set of buyers. | +- Criteria that must be satisfied + - The listing creator must own the NFTs being listed. + - The listing creator must have already approved Marketplace to transfer the NFTs being listed (since the creator is not required to escrow NFTs in the Marketplace). + - The listing creator must list a non-zero quantity of tokens. If listing ERC-721 tokens, the listing creator must list only quantity `1`. + - The listing start time must not be less than 1+ hour before the block timestamp of the transaction. The listing end time must be after the listing start time. + - Only ERC-721 or ERC-1155 tokens must be listed. + - The listing creator must have `LISTER_ROLE` if role restrictions are active. + - The asset being listed must have `ASSET_ROLE` if role restrictions are active. + +### `updateListing` + +**What:** Update information (e.g. price) for one of your listings on the marketplace. + +- Interface + + ```solidity + struct ListingParameters { + address assetContract; + uint256 tokenId; + uint256 quantity; + address currency; + uint256 pricePerToken; + uint128 startTimestamp; + uint128 endTimestamp; + bool reserved; + } + + function updateListing(uint256 listingId, ListingParameters memory params) external + ``` + +- Parameters + | Parameter | Description | + | ------------- | --------------------------------------------------------------------------------------------------- | + | listingId | The unique ID of the listing being updated. | + | assetContract | The address of the smart contract of the NFTs being listed. | + | tokenId | The tokenId of the NFTs being listed. | + | quantity | The quantity of NFTs being listed. This must be non-zero, and is expected to be 1 for ERC-721 NFTs. | + | currency | The currency in which the price must be paid when buying the listed NFTs. The address considered for native tokens of the chain is 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE | + | pricePerToken | The price to pay per unit of NFTs listed. | + | startTimestamp | The UNIX timestamp at and after which NFTs can be bought from the listing. | + | expirationTimestamp | The UNIX timestamp at and after which NFTs cannot be bought from the listing. | + | reserved | Whether the listing is reserved to be bought from a specific set of buyers. | +- Criteria that must be satisfied + - The caller of the function _must_ be the creator of the listing being updated. + - The listing creator must own the NFTs being listed. + - The listing creator must have already approved Marketplace to transfer the NFTs being listed (since the creator is not required to escrow NFTs in the Marketplace). + - The listing creator must list a non-zero quantity of tokens. If listing ERC-721 tokens, the listing creator must list only quantity `1`. + - Only ERC-721 or ERC-1155 tokens must be listed. + - The listing start time must be greater than or equal to the incumbent start timestamp. The listing end time must be after the listing start time. + - The asset being listed must have `ASSET_ROLE` if role restrictions are active. + +### `cancelListing` + +**What:** Cancel (i.e. delete) one of your listings on the marketplace. + +- Interface + + ```solidity + function cancelListing(uint256 listingId) external; + + ``` + +- Parameters + | Parameter | Description | + | --------- | --------------------------------------------------- | + | listingId | The unique ID of the listing to cancel i.e. delete. | +- Criteria that must be satisfied + - The caller of the function _must_ be the creator of the listing being cancelled. + - The listing must exist. + +### `approveBuyerForListing` + +**What:** Approve a buyer to buy from a reserved listing. + +- Interface + + ```solidity + function approveBuyerForListing( + uint256 listingId, + address buyer, + bool toApprove + ) external; + + ``` + +- Parameters + | Parameter | Description | + | --------- | ------------------------------------------------------------ | + | listingId | The unique ID of the listing. | + | buyer | The address of the buyer to approve to buy from the listing. | + | toApprove | Whether to approve the buyer to buy from the listing. | +- Criteria that must be satisfied + - The caller of the function _must_ be the creator of the listing in question. + - The listing must be reserved. + +### `approveCurrencyForListing` + +**What:** Approve a currency as a form of payment for the listing. + +- Interface + + ```solidity + function approveCurrencyForListing( + uint256 listingId, + address currency, + uint256 pricePerTokenInCurrency, + ) external; + + ``` + +- Parameters + | Parameter | Description | + | ----------------------- | ---------------------------------------------------------------------------- | + | listingId | The unique ID of the listing. | + | currency | The address of the currency to approve as a form of payment for the listing. | + | pricePerTokenInCurrency | The price per token for the currency to approve. A value of 0 here disapprove a currency. | +- Criteria that must be satisfied + - The caller of the function _must_ be the creator of the listing in question. + - The currency being approved must not be the main currency accepted by the listing. + +### `buyFromListing` + +**What:** Buy NFTs from a listing. + +- Interface + + ```solidity + function buyFromListing( + uint256 listingId, + address buyFor, + uint256 quantity, + address currency, + uint256 expectedTotalPrice + ) external payable; + + ``` + +- Parameters + | Parameter | Description | + | ------------------ | ---------------------------------------------------------- | + | listingId | The unique ID of the listing to buy NFTs from. | + | buyFor | The recipient of the NFTs being bought. | + | quantity | The quantity of NFTs to buy from the listing. | + | currency | The currency to use to pay for NFTs. | + | expectedTotalPrice | The expected total price to pay for the NFTs being bought. | +- Criteria that must be satisfied + - The buyer must own the total price amount to pay for the NFTs being bought. + - The buyer must approve the Marketplace to transfer the total price amount to pay for the NFTs being bought. + - If paying in native tokens, the buyer must send exactly the expected total price amount of native tokens along with the transaction. + - The buyer’s expected total price must match the actual total price for the NFTs being bought. + - The buyer must buy a non-zero quantity of NFTs. + - The buyer must not attempt to buy more NFTs than are listed at the time. + - The buyer must pay in a currency approved by the listing creator. + +### `totalListings` + +**What:** Returns the total number of listings created so far. + +- Interface + + ```solidity + function totalListings() external view returns (uint256); + + ``` + +### `getAllListings` + +**What:** Returns all listings between the start and end Id (both inclusive) provided. + +- Interface + + ```solidity + enum TokenType { + ERC721, + ERC1155 + } + + enum Status { + UNSET, + CREATED, + COMPLETED, + CANCELLED + } + + struct Listing { + uint256 listingId; + address listingCreator; + address assetContract; + uint256 tokenId; + uint256 quantity; + address currency; + uint256 pricePerToken; + uint128 startTimestamp; + uint128 endTimestamp; + bool reserved; + TokenType tokenType; + Status status; + } + + function getAllListings(uint256 startId, uint256 endId) external view returns (Listing[] memory listings); + + ``` + +- Parameters + | Parameter | Description | + | --------- | -------------------------- | + | startId | Inclusive start listing Id | + | endId | Inclusive end listing Id | + +### `getAllValidListings` + +**What:** Returns all valid listings between the start and end Id (both inclusive) provided. A valid listing is where the listing is active, as well as the creator still owns and has approved Marketplace to transfer the listed NFTs. + +- Interface + + ```solidity + function getAllValidListings(uint256 startId, uint256 endId) external view returns (Listing[] memory listings); + + ``` + +- Parameters + | Parameter | Description | + | --------- | -------------------------- | + | startId | Inclusive start listing Id | + | endId | Inclusive end listing Id | + +### `getListing` + +**What:** Returns a listing at the provided listing ID. + +- Interface + + ```solidity + function getListing(uint256 listingId) external view returns (Listing memory listing); + + ``` + +- Parameters + | Parameter | Description | + | --------- | ------------------------------- | + | listingId | The ID of the listing to fetch. | + +## English auctions + +The `EnglishAuctions` extension smart contract lets you sell NFTs (ERC-721 or ERC-1155) in an english auction. + +### `createAuction` + +**What:** Put up NFTs (ERC721 or ERC1155) for an english auction. + +- **What is an English auction?** + - `Alice` deposits her NFTs in the Marketplace contract and specifies: **[1]** a minimum bid amount, and **[2]** a duration for the auction. + - `Bob` is the first person to make a bid. + - _Before_ the auction duration ends, `Bob` makes a bid in the auction (≥ minimum bid). + - `Bob`'s bid is now deposited and locked in the Marketplace. + - `Tom` also wants the auctioned NFTs. `Tom`'s bid _must_ be greater than `Bob`'s bid. + - _Before_ the auction duration ends, `Tom` makes a bid in the auction (≥ `Bob`'s bid). + - `Tom`'s bid is now deposited and locked in the Marketplace. `Bob`'s is _automatically_ refunded his bid. + - _After_ the auction duration ends: + - `Alice` collects the highest bid that has been deposited in Marketplace. + - The “highest bidder” e.g. `Tom` collects the auctioned NFTs. +- Interface + + ```solidity + struct AuctionParameters { + address assetContract; + uint256 tokenId; + uint256 quantity; + address currency; + uint256 minimumBidAmount; + uint256 buyoutBidAmount; + uint64 timeBufferInSeconds; + uint64 bidBufferBps; + uint64 startTimestamp; + uint64 endTimestamp; + } + + function createAuction(AuctionParameters memory params) external returns (uint256 auctionId); + + ``` + +- Parameters + | Parameter | Description | + | ------------------- | ---------------------------------------------------------------------------------------------------------------------- | + | assetContract | The address of the smart contract of the NFTs being auctioned. | + | tokenId | The tokenId of the NFTs being auctioned. | + | quantity | The quantity of NFTs being auctioned. This must be non-zero, and is expected to be 1 for ERC-721 NFTs. | + | currency | The currency in which the bid must be made when bidding for the auctioned NFTs. | + | minimumBidAmount | The minimum bid amount for the auction. | + | buyoutBidAmount | The total bid amount for which the bidder can directly purchase the auctioned items and close the auction as a result. | + | timeBufferInSeconds | This is a buffer e.g. x seconds. If a new winning bid is made less than x seconds before expirationTimestamp, the expirationTimestamp is increased by x seconds. | + | bidBufferBps | This is a buffer in basis points e.g. x%. To be considered as a new winning bid, a bid must be at least x% greater than the current winning bid. | + | startTimestamp | The timestamp at and after which bids can be made to the auction | + | expirationTimestamp | The timestamp at and after which bids cannot be made to the auction. | +- Criteria that must be satisfied + - The auction creator must own and approve Marketplace to transfer the auctioned tokens to itself. + - The auction creator must auction a non-zero quantity of tokens. If the auctioned token is ERC721, the quantity must be `1`. + - The auction creator must specify a non-zero time and bid buffers. + - The minimum bid amount must be less than the buyout bid amount. + - The auction start time must not be less than 1+ hour before the block timestamp of the transaction. The auction end time must be after the auction start time. + - The auctioned token must be ERC-721 or ERC-1155. + - The auction creator must have `LISTER_ROLE` if role restrictions are active. + - The asset being auctioned must have `ASSET_ROLE` if role restrictions are active. + +### `cancelAuction` + +**What:** Cancel an auction. + +- Interface + + ```solidity + function cancelAuction(uint256 auctionId) external; + + ``` + +- Parameters + | Parameter | Description | + | --------- | --------------------------------------- | + | auctionId | The unique ID of the auction to cancel. | +- Criteria that must be satisfied + - The caller of the function must be the auction creator. + - There must be no bids placed in the ongoing auction. (Default true for all auctions that haven’t started) + +### `collectAuctionPayout` + +**What:** Once the auction ends, collect the highest bid made for your auctioned NFTs. + +- Interface + + ```solidity + function collectAuctionPayout(uint256 auctionId) external; + + ``` + +- Parameters + | Parameter | Description | + | --------- | ------------------------------------------------------- | + | auctionId | The unique ID of the auction to collect the payout for. | +- Criteria that must be satisfied + - The auction must be expired. + - The auction must have received at least one valid bid. + +### `collectAuctionTokens` + +**What:** Once the auction ends, collect the auctioned NFTs for which you were the highest bidder. + +- Interface + + ```solidity + function collectAuctionTokens(uint256 auctionId) external; + + ``` + +- Parameters + | Parameter | Description | + | --------- | ------------------------------------------------------- | + | auctionId | The unique ID of the auction to collect the payout for. | +- Criteria that must be satisfied + - The auction must be expired. + - The caller must be the winning bidder. + +### `bidInAuction` + +**What:** Make a bid in an auction. + +- Interface + + ```solidity + function bidInAuction(uint256 auctionId, uint256 bidAmount) external payable; + + ``` + +- Parameters + | Parameter | Description | + | --------- | --------------------------------------- | + | auctionId | The unique ID of the auction to bid in. | + | bidAmount | The total bid amount. | +- Criteria that must be satisfied + - Auction must not be expired. + - The caller must own and approve Marketplace to transfer the requisite bid amount to itself. + - The bid amount must be a winning bid amount. (For convenience, this can be verified by calling `isNewWinningBid`) + +### `isNewWinningBid` + +**What:** Check whether a given bid amount would make for a new winning bid. + +- Interface + + ```solidity + function isNewWinningBid(uint256 auctionId, uint256 bidAmount) external view returns (bool); + + ``` + +- Parameters + | Parameter | Description | + | --------- | --------------------------------------- | + | auctionId | The unique ID of the auction to bid in. | + | bidAmount | The total bid amount. | +- Criteria that must be satisfied + - The auction must not have been cancelled or expired. + +### `totalAuctions` + +**What:** Returns the total number of auctions created so far. + +- Interface + + ```solidity + function totalAuctions() external view returns (uint256); + + ``` + +### `getAuction` + +**What:** Fetch the auction info at a particular auction ID. + +- Interface + + ```solidity + struct Auction { + uint256 auctionId; + address auctionCreator; + address assetContract; + uint256 tokenId; + uint256 quantity; + address currency; + uint256 minimumBidAmount; + uint256 buyoutBidAmount; + uint64 timeBufferInSeconds; + uint64 bidBufferBps; + uint64 startTimestamp; + uint64 endTimestamp; + TokenType tokenType; + Status status; + } + + function getAuction(uint256 auctionId) external view returns (Auction memory auction); + + ``` + +- Parameters + | Parameter | Description | + | --------- | ----------------------------- | + | auctionId | The unique ID of the auction. | + +### `getAllAuctions` + +**What:** Returns all auctions between the start and end Id (both inclusive) provided. + +- Interface + + ```solidity + function getAllAuctions(uint256 startId, uint256 endId) external view returns (Auction[] memory auctions); + + ``` + +- Parameters + | Parameter | Description | + | --------- | -------------------------- | + | startId | Inclusive start auction Id | + | endId | Inclusive end auction Id | + +### `getAllValidAuctions` + +**What:** Returns all valid auctions between the start and end Id (both inclusive) provided. A valid auction is where the auction is active, as well as the creator still owns and has approved Marketplace to transfer the auctioned NFTs. + +- Interface + + ```solidity + function getAllValidAuctions(uint256 startId, uint256 endId) external view returns (Auction[] memory auctions); + + ``` + +- Parameters + | Parameter | Description | + | --------- | -------------------------- | + | startId | Inclusive start auction Id | + | endId | Inclusive end auction Id | + +### `getWinningBid` + +**What:** Get the winning bid of an auction. + +- Interface + + ```solidity + function getWinningBid(uint256 auctionId) + external + view + returns ( + address bidder, + address currency, + uint256 bidAmount + ); + + ``` + +- Parameters + | Parameter | Description | + | --------- | ---------------------------- | + | auctionId | The unique ID of an auction. | + +### `isAuctionExpired` + +**What:** Returns whether an auction is expired or not. + +- Interface + + ```solidity + function isAuctionExpired(uint256 auctionId) external view returns (bool); + + ``` + +- Parameters + | Parameter | Description | + | --------- | ---------------------------- | + | auctionId | The unique ID of an auction. | + +## Offers + +### `makeOffer` + +**What:** Make an offer for any ERC721 or ERC1155 NFTs (unless `ASSET_ROLE` restrictions apply) + +- Interface + + ```solidity + struct OfferParams { + address assetContract; + uint256 tokenId; + uint256 quantity; + address currency; + uint256 totalPrice; + uint256 expirationTimestamp; + } + + function makeOffer(OfferParams memory params) external returns (uint256 offerId); + + ``` + +- Parameters + | Parameter | Description | + | ------------------- | ----------------------------------------- | + | assetContract | The contract address of the NFTs wanted. | + | tokenId | The tokenId of the NFTs wanted. | + | quantity | The quantity of NFTs wanted. | + | currency | The currency offered for the NFT wanted. | + | totalPrice | The price offered for the NFTs wanted. | + | expirationTimestamp | The timestamp at which the offer expires. | +- Criteria that must be satisfied + - The offeror must own and approve Marketplace to transfer the requisite amount currency offered for the NFTs wanted. + - The offeror must make an offer for non-zero quantity of NFTs. If offering for ERC721 tokens, the quantity wanted must be `1`. + - Expiration timestamp must be greater than block timestamp, or within 1 hour of block timestamp. + +### `cancelOffer` + +**What:** Cancel an existing offer. + +- Interface + + ```solidity + function cancelOffer(uint256 offerId) external; + + ``` + +- Parameters + | Parameter | Description | + | --------- | -------------------------- | + | offerId | The unique ID of the offer | +- Criteria that must be satisfied + - The caller of the function must be the offeror. + +### `acceptOffer` + +**What:** Accept an offer made for your NFTs. + +- Interface + + ```solidity + function acceptOffer(uint256 offerId) external; + + ``` + +- Parameters + | Parameter | Description | + | --------- | --------------------------- | + | offerId | The unique ID of the offer. | +- Criteria that must be satisfied + - The caller of the function must own and approve Marketplace to transfer the tokens for which the offer is made. + - The offeror must still own and have approved Marketplace to transfer the requisite amount currency offered for the NFTs wanted. + +### `totalOffers` + +**What:** Returns the total number of offers created so far. + +- Interface + + ```solidity + function totalOffers() external view returns (uint256); + + ``` + +### `getOffer` + +**What:** Returns the offer at a particular offer Id. + +- Interface + + ```solidity + struct Offer { + uint256 offerId; + address offeror; + address assetContract; + uint256 tokenId; + uint256 quantity; + address currency; + uint256 totalPrice; + uint256 expirationTimestamp; + TokenType tokenType; + Status status; + } + + function getOffer(uint256 offerId) external view returns (Offer memory offer); + + ``` + +- Parameters + | Parameter | Description | + | --------- | -------------------------- | + | offerId | The unique ID of an offer. | + +### `getAllOffers` + +**What:** Returns all offers between the start and end Id (both inclusive) provided. + +- Interface + + ```solidity + function getAllOffers(uint256 startId, uint256 endId) external view returns (Offer[] memory offers); + + ``` + +- Parameters + | Parameter | Description | + | --------- | -------------------------- | + | startId | Inclusive start offer Id | + | endId | Inclusive end offer Id | + +### `getAllValidOffers` + +**What:** Returns all valid offers between the start and end Id (both inclusive) provided. A valid offer is where the offer is active, as well as the offeror still owns and has approved Marketplace to transfer the currency tokens. + +- Interface + + ```solidity + function getAllValidOffer(uint256 startId, uint256 endId) external view returns (Offer[] memory offers); + + ``` + +- Parameters + | Parameter | Description | + | --------- | -------------------------- | + | startId | Inclusive start offer Id | + | endId | Inclusive end offer Id | diff --git a/contracts/prebuilts/marketplace/offers/OffersLogic.sol b/contracts/prebuilts/marketplace/offers/OffersLogic.sol new file mode 100644 index 000000000..8f8724d89 --- /dev/null +++ b/contracts/prebuilts/marketplace/offers/OffersLogic.sol @@ -0,0 +1,347 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/// @author thirdweb + +import "./OffersStorage.sol"; + +// ====== External imports ====== +import "@openzeppelin/contracts/utils/Context.sol"; +import "@openzeppelin/contracts/utils/introspection/IERC165.sol"; +import "../../../eip/interface/IERC721.sol"; +import "@openzeppelin/contracts/token/ERC1155/IERC1155.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/interfaces/IERC2981.sol"; + +// ====== Internal imports ====== + +import "../../../extension/interface/IPlatformFee.sol"; +import "../../../extension/upgradeable/ERC2771ContextConsumer.sol"; +import "../../../extension/upgradeable/ReentrancyGuard.sol"; +import "../../../extension/upgradeable/PermissionsEnumerable.sol"; +import { RoyaltyPaymentsLogic } from "../../../extension/upgradeable/RoyaltyPayments.sol"; +import { CurrencyTransferLib } from "../../../lib/CurrencyTransferLib.sol"; + +/** + * @author thirdweb.com + */ +contract OffersLogic is IOffers, ReentrancyGuard, ERC2771ContextConsumer { + /*/////////////////////////////////////////////////////////////// + Constants / Immutables + //////////////////////////////////////////////////////////////*/ + /// @dev Can create offer for only assets from NFT contracts with asset role, when offers are restricted by asset address. + bytes32 private constant ASSET_ROLE = keccak256("ASSET_ROLE"); + + /// @dev The max bps of the contract. So, 10_000 == 100 % + uint64 private constant MAX_BPS = 10_000; + + /*/////////////////////////////////////////////////////////////// + Modifiers + //////////////////////////////////////////////////////////////*/ + + modifier onlyAssetRole(address _asset) { + require(Permissions(address(this)).hasRoleWithSwitch(ASSET_ROLE, _asset), "!ASSET_ROLE"); + _; + } + + /// @dev Checks whether caller is a offer creator. + modifier onlyOfferor(uint256 _offerId) { + require(_offersStorage().offers[_offerId].offeror == _msgSender(), "!Offeror"); + _; + } + + /// @dev Checks whether an auction exists. + modifier onlyExistingOffer(uint256 _offerId) { + require(_offersStorage().offers[_offerId].status == IOffers.Status.CREATED, "Marketplace: invalid offer."); + _; + } + + /*/////////////////////////////////////////////////////////////// + Constructor logic + //////////////////////////////////////////////////////////////*/ + + constructor() {} + + /*/////////////////////////////////////////////////////////////// + External functions + //////////////////////////////////////////////////////////////*/ + + function makeOffer( + OfferParams memory _params + ) external onlyAssetRole(_params.assetContract) returns (uint256 _offerId) { + _offerId = _getNextOfferId(); + address _offeror = _msgSender(); + TokenType _tokenType = _getTokenType(_params.assetContract); + + _validateNewOffer(_params, _tokenType); + + Offer memory _offer = Offer({ + offerId: _offerId, + offeror: _offeror, + assetContract: _params.assetContract, + tokenId: _params.tokenId, + tokenType: _tokenType, + quantity: _params.quantity, + currency: _params.currency, + totalPrice: _params.totalPrice, + expirationTimestamp: _params.expirationTimestamp, + status: IOffers.Status.CREATED + }); + + _offersStorage().offers[_offerId] = _offer; + + emit NewOffer(_offeror, _offerId, _params.assetContract, _offer); + } + + function cancelOffer(uint256 _offerId) external onlyExistingOffer(_offerId) onlyOfferor(_offerId) { + _offersStorage().offers[_offerId].status = IOffers.Status.CANCELLED; + + emit CancelledOffer(_msgSender(), _offerId); + } + + function acceptOffer(uint256 _offerId) external nonReentrant onlyExistingOffer(_offerId) { + Offer memory _targetOffer = _offersStorage().offers[_offerId]; + + require(_targetOffer.expirationTimestamp > block.timestamp, "EXPIRED"); + + require( + _validateERC20BalAndAllowance(_targetOffer.offeror, _targetOffer.currency, _targetOffer.totalPrice), + "Marketplace: insufficient currency balance." + ); + + _validateOwnershipAndApproval( + _msgSender(), + _targetOffer.assetContract, + _targetOffer.tokenId, + _targetOffer.quantity, + _targetOffer.tokenType + ); + + _offersStorage().offers[_offerId].status = IOffers.Status.COMPLETED; + + _payout(_targetOffer.offeror, _msgSender(), _targetOffer.currency, _targetOffer.totalPrice, _targetOffer); + _transferOfferTokens(_msgSender(), _targetOffer.offeror, _targetOffer.quantity, _targetOffer); + + emit AcceptedOffer( + _targetOffer.offeror, + _targetOffer.offerId, + _targetOffer.assetContract, + _targetOffer.tokenId, + _msgSender(), + _targetOffer.quantity, + _targetOffer.totalPrice + ); + } + + /*/////////////////////////////////////////////////////////////// + View functions + //////////////////////////////////////////////////////////////*/ + + /// @dev Returns total number of offers + function totalOffers() public view returns (uint256) { + return _offersStorage().totalOffers; + } + + /// @dev Returns existing offer with the given uid. + function getOffer(uint256 _offerId) external view returns (Offer memory _offer) { + _offer = _offersStorage().offers[_offerId]; + } + + /// @dev Returns all existing offers within the specified range. + function getAllOffers(uint256 _startId, uint256 _endId) external view returns (Offer[] memory _allOffers) { + require(_startId <= _endId && _endId < _offersStorage().totalOffers, "invalid range"); + + _allOffers = new Offer[](_endId - _startId + 1); + + for (uint256 i = _startId; i <= _endId; i += 1) { + _allOffers[i - _startId] = _offersStorage().offers[i]; + } + } + + /// @dev Returns offers within the specified range, where offeror has sufficient balance. + function getAllValidOffers(uint256 _startId, uint256 _endId) external view returns (Offer[] memory _validOffers) { + require(_startId <= _endId && _endId < _offersStorage().totalOffers, "invalid range"); + + Offer[] memory _offers = new Offer[](_endId - _startId + 1); + uint256 _offerCount; + + for (uint256 i = _startId; i <= _endId; i += 1) { + uint256 j = i - _startId; + _offers[j] = _offersStorage().offers[i]; + if (_validateExistingOffer(_offers[j])) { + _offerCount += 1; + } + } + + _validOffers = new Offer[](_offerCount); + uint256 index = 0; + uint256 count = _offers.length; + for (uint256 i = 0; i < count; i += 1) { + if (_validateExistingOffer(_offers[i])) { + _validOffers[index++] = _offers[i]; + } + } + } + + /*/////////////////////////////////////////////////////////////// + Internal functions + //////////////////////////////////////////////////////////////*/ + + /// @dev Returns the next offer Id. + function _getNextOfferId() internal returns (uint256 id) { + id = _offersStorage().totalOffers; + _offersStorage().totalOffers += 1; + } + + /// @dev Returns the interface supported by a contract. + function _getTokenType(address _assetContract) internal view returns (TokenType tokenType) { + if (IERC165(_assetContract).supportsInterface(type(IERC1155).interfaceId)) { + tokenType = TokenType.ERC1155; + } else if (IERC165(_assetContract).supportsInterface(type(IERC721).interfaceId)) { + tokenType = TokenType.ERC721; + } else { + revert("Marketplace: token must be ERC1155 or ERC721."); + } + } + + /// @dev Checks whether the auction creator owns and has approved marketplace to transfer auctioned tokens. + function _validateNewOffer(OfferParams memory _params, TokenType _tokenType) internal view { + require(_params.totalPrice > 0, "zero price."); + require(_params.quantity > 0, "Marketplace: wanted zero tokens."); + require(_params.quantity == 1 || _tokenType == TokenType.ERC1155, "Marketplace: wanted invalid quantity."); + require( + _params.expirationTimestamp + 60 minutes > block.timestamp, + "Marketplace: invalid expiration timestamp." + ); + + require( + _validateERC20BalAndAllowance(_msgSender(), _params.currency, _params.totalPrice), + "Marketplace: insufficient currency balance." + ); + } + + /// @dev Checks whether the offer exists, is active, and if the offeror has sufficient balance. + function _validateExistingOffer(Offer memory _targetOffer) internal view returns (bool isValid) { + isValid = + _targetOffer.expirationTimestamp > block.timestamp && + _targetOffer.status == IOffers.Status.CREATED && + _validateERC20BalAndAllowance(_targetOffer.offeror, _targetOffer.currency, _targetOffer.totalPrice); + } + + /// @dev Validates that `_tokenOwner` owns and has approved Marketplace to transfer NFTs. + function _validateOwnershipAndApproval( + address _tokenOwner, + address _assetContract, + uint256 _tokenId, + uint256 _quantity, + TokenType _tokenType + ) internal view { + address market = address(this); + bool isValid; + + if (_tokenType == TokenType.ERC1155) { + isValid = + IERC1155(_assetContract).balanceOf(_tokenOwner, _tokenId) >= _quantity && + IERC1155(_assetContract).isApprovedForAll(_tokenOwner, market); + } else if (_tokenType == TokenType.ERC721) { + isValid = + IERC721(_assetContract).ownerOf(_tokenId) == _tokenOwner && + (IERC721(_assetContract).getApproved(_tokenId) == market || + IERC721(_assetContract).isApprovedForAll(_tokenOwner, market)); + } + + require(isValid, "Marketplace: not owner or approved tokens."); + } + + /// @dev Validates that `_tokenOwner` owns and has approved Markeplace to transfer the appropriate amount of currency + function _validateERC20BalAndAllowance( + address _tokenOwner, + address _currency, + uint256 _amount + ) internal view returns (bool isValid) { + isValid = + IERC20(_currency).balanceOf(_tokenOwner) >= _amount && + IERC20(_currency).allowance(_tokenOwner, address(this)) >= _amount; + } + + /// @dev Transfers tokens. + function _transferOfferTokens(address _from, address _to, uint256 _quantity, Offer memory _offer) internal { + if (_offer.tokenType == TokenType.ERC1155) { + IERC1155(_offer.assetContract).safeTransferFrom(_from, _to, _offer.tokenId, _quantity, ""); + } else if (_offer.tokenType == TokenType.ERC721) { + IERC721(_offer.assetContract).safeTransferFrom(_from, _to, _offer.tokenId, ""); + } + } + + /// @dev Pays out stakeholders in a sale. + function _payout( + address _payer, + address _payee, + address _currencyToUse, + uint256 _totalPayoutAmount, + Offer memory _offer + ) internal { + uint256 amountRemaining; + + // Payout platform fee + { + (address platformFeeRecipient, uint16 platformFeeBps) = IPlatformFee(address(this)).getPlatformFeeInfo(); + uint256 platformFeeCut = (_totalPayoutAmount * platformFeeBps) / MAX_BPS; + + // Transfer platform fee + CurrencyTransferLib.transferCurrencyWithWrapper( + _currencyToUse, + _payer, + platformFeeRecipient, + platformFeeCut, + address(0) + ); + + amountRemaining = _totalPayoutAmount - platformFeeCut; + } + + // Payout royalties + { + // Get royalty recipients and amounts + (address payable[] memory recipients, uint256[] memory amounts) = RoyaltyPaymentsLogic(address(this)) + .getRoyalty(_offer.assetContract, _offer.tokenId, _totalPayoutAmount); + + uint256 royaltyRecipientCount = recipients.length; + + if (royaltyRecipientCount != 0) { + uint256 royaltyCut; + address royaltyRecipient; + + for (uint256 i = 0; i < royaltyRecipientCount; ) { + royaltyRecipient = recipients[i]; + royaltyCut = amounts[i]; + + // Check payout amount remaining is enough to cover royalty payment + require(amountRemaining >= royaltyCut, "fees exceed the price"); + + // Transfer royalty + CurrencyTransferLib.transferCurrencyWithWrapper( + _currencyToUse, + _payer, + royaltyRecipient, + royaltyCut, + address(0) + ); + + unchecked { + amountRemaining -= royaltyCut; + ++i; + } + } + } + } + + // Distribute price to token owner + CurrencyTransferLib.transferCurrencyWithWrapper(_currencyToUse, _payer, _payee, amountRemaining, address(0)); + } + + /// @dev Returns the Offers storage. + function _offersStorage() internal pure returns (OffersStorage.Data storage data) { + data = OffersStorage.data(); + } +} diff --git a/contracts/prebuilts/marketplace/offers/OffersStorage.sol b/contracts/prebuilts/marketplace/offers/OffersStorage.sol new file mode 100644 index 000000000..2716b9ed0 --- /dev/null +++ b/contracts/prebuilts/marketplace/offers/OffersStorage.sol @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/// @author thirdweb + +import { IOffers } from "../IMarketplace.sol"; + +/** + * @author thirdweb.com + */ +library OffersStorage { + /// @custom:storage-location erc7201:offers.storage + /// @dev keccak256(abi.encode(uint256(keccak256("offers.storage")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 public constant OFFERS_STORAGE_POSITION = + 0x8f8effea55e8d961f30e12024b944289ed8a7f60abcf4b3989df2dc98a914300; + + struct Data { + uint256 totalOffers; + mapping(uint256 => IOffers.Offer) offers; + } + + function data() internal pure returns (Data storage data_) { + bytes32 position = OFFERS_STORAGE_POSITION; + assembly { + data_.slot := position + } + } +} diff --git a/contracts/prebuilts/multiwrap/Multiwrap.sol b/contracts/prebuilts/multiwrap/Multiwrap.sol new file mode 100644 index 000000000..e074ba1d8 --- /dev/null +++ b/contracts/prebuilts/multiwrap/Multiwrap.sol @@ -0,0 +1,264 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/// @author thirdweb + +// $$\ $$\ $$\ $$\ $$\ +// $$ | $$ | \__| $$ | $$ | +// $$$$$$\ $$$$$$$\ $$\ $$$$$$\ $$$$$$$ |$$\ $$\ $$\ $$$$$$\ $$$$$$$\ +// \_$$ _| $$ __$$\ $$ |$$ __$$\ $$ __$$ |$$ | $$ | $$ |$$ __$$\ $$ __$$\ +// $$ | $$ | $$ |$$ |$$ | \__|$$ / $$ |$$ | $$ | $$ |$$$$$$$$ |$$ | $$ | +// $$ |$$\ $$ | $$ |$$ |$$ | $$ | $$ |$$ | $$ | $$ |$$ ____|$$ | $$ | +// \$$$$ |$$ | $$ |$$ |$$ | \$$$$$$$ |\$$$$$\$$$$ |\$$$$$$$\ $$$$$$$ | +// \____/ \__| \__|\__|\__| \_______| \_____\____/ \_______|\_______/ + +// ========== External imports ========== +import "@openzeppelin/contracts-upgradeable/token/ERC721/extensions/ERC721EnumerableUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/security/ReentrancyGuardUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/interfaces/IERC2981Upgradeable.sol"; + +// ========== Internal imports ========== + +import "../interface/IMultiwrap.sol"; +import "../../extension/Multicall.sol"; +import "../../external-deps/openzeppelin/metatx/ERC2771ContextUpgradeable.sol"; + +// ========== Features ========== + +import "../../extension/ContractMetadata.sol"; +import "../../extension/Royalty.sol"; +import "../../extension/Ownable.sol"; +import "../../extension/PermissionsEnumerable.sol"; +import { TokenStore, ERC1155Receiver, IERC1155Receiver } from "../../extension/TokenStore.sol"; + +contract Multiwrap is + Initializable, + ContractMetadata, + Royalty, + Ownable, + PermissionsEnumerable, + TokenStore, + ReentrancyGuardUpgradeable, + ERC2771ContextUpgradeable, + Multicall, + ERC721EnumerableUpgradeable, + IMultiwrap +{ + /*/////////////////////////////////////////////////////////////// + State variables + //////////////////////////////////////////////////////////////*/ + + bytes32 private constant MODULE_TYPE = bytes32("Multiwrap"); + uint256 private constant VERSION = 1; + + /// @dev Only transfers to or from TRANSFER_ROLE holders are valid, when transfers are restricted. + bytes32 private constant TRANSFER_ROLE = keccak256("TRANSFER_ROLE"); + /// @dev Only MINTER_ROLE holders can wrap tokens, when wrapping is restricted. + bytes32 private constant MINTER_ROLE = keccak256("MINTER_ROLE"); + /// @dev Only UNWRAP_ROLE holders can unwrap tokens, when unwrapping is restricted. + bytes32 private constant UNWRAP_ROLE = keccak256("UNWRAP_ROLE"); + /// @dev Only assets with ASSET_ROLE can be wrapped, when wrapping is restricted to particular assets. + bytes32 private constant ASSET_ROLE = keccak256("ASSET_ROLE"); + + /// @dev The next token ID of the NFT to mint. + uint256 public nextTokenIdToMint; + + /*/////////////////////////////////////////////////////////////// + Constructor + initializer logic + //////////////////////////////////////////////////////////////*/ + + constructor(address _nativeTokenWrapper) TokenStore(_nativeTokenWrapper) initializer {} + + /// @dev Initializes the contract, like a constructor. + function initialize( + address _defaultAdmin, + string memory _name, + string memory _symbol, + string memory _contractURI, + address[] memory _trustedForwarders, + address _royaltyRecipient, + uint256 _royaltyBps + ) external initializer { + // Initialize inherited contracts, most base-like -> most derived. + __ReentrancyGuard_init(); + __ERC2771Context_init(_trustedForwarders); + __ERC721_init(_name, _symbol); + + // Initialize this contract's state. + _setupDefaultRoyaltyInfo(_royaltyRecipient, _royaltyBps); + _setupOwner(_defaultAdmin); + _setupContractURI(_contractURI); + + _setupRole(DEFAULT_ADMIN_ROLE, _defaultAdmin); + _setupRole(MINTER_ROLE, _defaultAdmin); + _setupRole(TRANSFER_ROLE, _defaultAdmin); + + // note: see `_beforeTokenTransfer` for TRANSFER_ROLE behaviour. + _setupRole(TRANSFER_ROLE, address(0)); + + // note: see `onlyRoleWithSwitch` for UNWRAP_ROLE behaviour. + _setupRole(UNWRAP_ROLE, address(0)); + + // note: see `onlyRoleWithSwitch` for UNWRAP_ROLE behaviour. + _setupRole(ASSET_ROLE, address(0)); + } + + /*/////////////////////////////////////////////////////////////// + Modifiers + //////////////////////////////////////////////////////////////*/ + + modifier onlyRoleWithSwitch(bytes32 role) { + _checkRoleWithSwitch(role, _msgSender()); + _; + } + + /*/////////////////////////////////////////////////////////////// + Generic contract logic + //////////////////////////////////////////////////////////////*/ + + /// @dev Returns the type of the contract. + function contractType() external pure returns (bytes32) { + return MODULE_TYPE; + } + + /// @dev Returns the version of the contract. + function contractVersion() external pure returns (uint8) { + return uint8(VERSION); + } + + /// @dev Lets the contract receive ether to unwrap native tokens. + receive() external payable { + require(msg.sender == nativeTokenWrapper, "caller not native token wrapper."); + } + + /*/////////////////////////////////////////////////////////////// + ERC 165 / 721 / 2981 logic + //////////////////////////////////////////////////////////////*/ + + /// @dev Returns the URI for a given tokenId. + function tokenURI(uint256 _tokenId) public view override returns (string memory) { + return getUriOfBundle(_tokenId); + } + + /// @dev See ERC 165 + function supportsInterface( + bytes4 interfaceId + ) public view virtual override(ERC1155Receiver, ERC721EnumerableUpgradeable, IERC165) returns (bool) { + return + super.supportsInterface(interfaceId) || + interfaceId == type(IERC721Upgradeable).interfaceId || + interfaceId == type(IERC1155Receiver).interfaceId || + interfaceId == type(IERC2981Upgradeable).interfaceId; + } + + /*/////////////////////////////////////////////////////////////// + Wrapping / Unwrapping logic + //////////////////////////////////////////////////////////////*/ + + /// @dev Wrap multiple ERC1155, ERC721, ERC20 tokens into a single wrapped NFT. + function wrap( + Token[] calldata _tokensToWrap, + string calldata _uriForWrappedToken, + address _recipient + ) external payable nonReentrant onlyRoleWithSwitch(MINTER_ROLE) returns (uint256 tokenId) { + if (!hasRole(ASSET_ROLE, address(0))) { + for (uint256 i = 0; i < _tokensToWrap.length; i += 1) { + _checkRole(ASSET_ROLE, _tokensToWrap[i].assetContract); + } + } + + tokenId = nextTokenIdToMint; + nextTokenIdToMint += 1; + + _storeTokens(_msgSender(), _tokensToWrap, _uriForWrappedToken, tokenId); + + _safeMint(_recipient, tokenId); + + emit TokensWrapped(_msgSender(), _recipient, tokenId, _tokensToWrap); + } + + /// @dev Unwrap a wrapped NFT to retrieve underlying ERC1155, ERC721, ERC20 tokens. + function unwrap(uint256 _tokenId, address _recipient) external nonReentrant onlyRoleWithSwitch(UNWRAP_ROLE) { + require(_tokenId < nextTokenIdToMint, "wrapped NFT DNE."); + require(_isApprovedOrOwner(_msgSender(), _tokenId), "caller not approved for unwrapping."); + + _burn(_tokenId); + _releaseTokens(_recipient, _tokenId); + + emit TokensUnwrapped(_msgSender(), _recipient, _tokenId); + } + + /*/////////////////////////////////////////////////////////////// + Getter functions + //////////////////////////////////////////////////////////////*/ + + /// @dev Returns the underlying contents of a wrapped NFT. + function getWrappedContents(uint256 _tokenId) external view returns (Token[] memory contents) { + uint256 total = getTokenCountOfBundle(_tokenId); + contents = new Token[](total); + + for (uint256 i = 0; i < total; i += 1) { + contents[i] = getTokenOfBundle(_tokenId, i); + } + } + + /*/////////////////////////////////////////////////////////////// + Internal functions + //////////////////////////////////////////////////////////////*/ + + /// @dev Returns whether owner can be set in the given execution context. + function _canSetOwner() internal view override returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + /// @dev Returns whether royalty info can be set in the given execution context. + function _canSetRoyaltyInfo() internal view override returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + /// @dev Returns whether contract metadata can be set in the given execution context. + function _canSetContractURI() internal view override returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + /*/////////////////////////////////////////////////////////////// + Miscellaneous + //////////////////////////////////////////////////////////////*/ + + /** + * @dev See {ERC721-_beforeTokenTransfer}. + */ + function _beforeTokenTransfer( + address from, + address to, + uint256 tokenId, + uint256 batchSize + ) internal virtual override { + super._beforeTokenTransfer(from, to, tokenId, batchSize); + + // if transfer is restricted on the contract, we still want to allow burning and minting + if (!hasRole(TRANSFER_ROLE, address(0)) && from != address(0) && to != address(0)) { + require(hasRole(TRANSFER_ROLE, from) || hasRole(TRANSFER_ROLE, to), "!TRANSFER_ROLE"); + } + } + + function _msgSender() + internal + view + virtual + override(ContextUpgradeable, ERC2771ContextUpgradeable, Multicall) + returns (address sender) + { + return ERC2771ContextUpgradeable._msgSender(); + } + + function _msgData() + internal + view + virtual + override(ContextUpgradeable, ERC2771ContextUpgradeable) + returns (bytes calldata) + { + return ERC2771ContextUpgradeable._msgData(); + } +} diff --git a/contracts/prebuilts/multiwrap/multiwrap.md b/contracts/prebuilts/multiwrap/multiwrap.md new file mode 100644 index 000000000..9d49b82e9 --- /dev/null +++ b/contracts/prebuilts/multiwrap/multiwrap.md @@ -0,0 +1,139 @@ +# Multiwrap design document. + +This is a live document that explains what the [thirdweb](https://thirdweb.com/) `Multiwrap` smart contract is, how it works and can be used, and why it is designed the way it is. + +The document is written for technical and non-technical readers. To ask further questions about thirdweb’s `Multiwrap` contract, please join the [thirdweb discord](https://discord.gg/thirdweb) or create a github issue. + +--- + +## Background + +The thirdweb Multiwrap contract lets you wrap arbitrary ERC20, ERC721 and ERC1155 tokens you own into a single wrapped token / NFT. + +The `Multiwrap` contract is meant to be used for bundling up multiple assets (ERC20 / ERC721 / ERC1155) into a single wrapped token, which can then be unwrapped in exchange for the underlying tokens. + +The single wrapped token received on bundling up multiple assets, as mentioned above, is an ERC721 NFT. It can be transferred, sold on any NFT Marketplace, and generate royalties just like any other NFTs. + +### How the `Multiwrap` product *should* work + +![multiwrap-diagram.png](/assets/multiwrap-diagram.png) + +A token owner should be able to wrap any combination of *n* ERC20, ERC721 or ERC1155 tokens as a wrapped NFT. When wrapping, the token owner should be able to specify a recipient for the wrapped NFT. At the time of wrapping, the token owner should be able to set the metadata of the wrapped NFT that will be minted. + +The wrapped NFT owner should be able to unwrap the NFT to retrieve the underlying tokens of the wrapped NFT. At the time of unwrapping, the wrapped NFT owner should be able to specify a recipient for the underlying tokens of the wrapped NFT. + +The `Multiwrap` contract creator should be able to apply the following role-based restrictions: + +- Restrict what assets can be wrapped on the contract. +- Restrict which wallets can wrap tokens on the contract. +- Restrict what wallets can unwrap owned wrapped NFTs. + +### Core parts of the `Multiwrap` product +- A token owner should be able to wrap any combination of *n* ERC20, ERC721 or ERC1155 tokens as a wrapped token. +- A wrapped token owner should be able to unwrap the token to retrieve the underlying contents of the wrapped token. + +### Why we’re building `Multiwrap` + +We're building `Multiwrap` for cases where an application wishes to bundle up / distribute / transact over *n* independent tokens all at once, as a single asset. This opens up several novel NFT use cases. + +For example, consider a lending service where people can take out a loan while putting up an NFT as a collateral. Using `Multiwrap`, a borrower can wrap their NFT with some ether, and put up the resultant wrapped ERC721 NFT as collateral on the lending service. Now, the borrower's NFT, as collateral, has a floor value. + +## Technical Details + +The `Multiwrap`contract itself is an ERC721 contract. + +It lets you wrap arbitrary ERC20, ERC721 or ERC1155 tokens you own into a single wrapped token / NFT. This means escrowing the relevant ERC20, ERC721 and ERC1155 tokens into the `Multiwrap` contract, and receiving the wrapped NFT in exchange. + +This wrapped NFT can later be 'unwrapped' i.e. burned in exchange for the underlying tokens. + +### Wrapping tokens + +To wrap multiple ERC20, ERC721 or ERC1155 tokens as a single wrapped NFT, a token owner must: +- approve the relevant tokens to be transferred by the `Multiwrap` contract. +- specify the tokens to be wrapped into a single wrapped NFT. The following is the format in which each token to be wrapped must be specified: + +```solidity +/// @notice The type of assets that can be wrapped. +enum TokenType { ERC20, ERC721, ERC1155 } + +struct Token { + address assetContract; + TokenType tokenType; + uint256 tokenId; + uint256 totalAmount; +} +``` + +| Parameters | Type | Description | +| --- | --- | --- | +| assetContract | address | The contract address of the asset to wrap. | +| tokenType | TokenType | The token type (ERC20 / ERC721 / ERC1155) of the asset to wrap. | +| tokenId | uint256 | The token Id of the asset to wrap, if the asset is an ERC721 / ERC1155 NFT. | +| totalAmount | uint256 | The amount of the asset to wrap, if the asset is an ERC20 / ERC1155 fungible token. | + +Each token in the bundle of tokens to be wrapped as a single wrapped NFT must be specified to the `Multiwrap` contract in the form of the `Token` struct. The contract handles the respective token based on the value of `tokenType` provided. Any incorrect values passed (e.g. the `totalAmount` specified to be wrapped exceeds the token owner's token balance) will cause the wrapping transaction to revert. + +Multiple tokens can be wrapped as a single wrapped NFT by calling the following function: + +```solidity +function wrap( + Token[] memory tokensToWrap, + string calldata uriForWrappedToken, + address recipient +) external payable returns (uint256 tokenId); +``` + +| Parameters | Type | Description | +| --- | --- | --- | +| tokensToWrap | Token[] | The tokens to wrap. | +| uriForWrappedToken | string | The metadata URI for the wrapped NFT. | +| recipient | address | The recipient of the wrapped NFT. | + +### Unwrapping the wrapped NFT + +The single wrapped NFT, received on wrapping multiple assets as explained in the previous section, can be unwrapped in exchange for the underlying assets. + +A wrapped NFT can be unwrapped either by the owner, or a wallet approved by the owner to transfer the NFT via `setApprovalForAll` or `approve` ERC721 functions. + +When unwrapping the wrapped NFT, the wrapped NFT is burned.**** + +A wrapped NFT can be unwrapped by calling the following function: + +```solidity +function unwrap( + uint256 tokenId, + address recipient +) external; +``` + +| Parameters | Type | Description | +| --- | --- | --- | +| tokenId | Token[] | The token Id of the wrapped NFT to unwrap. | +| recipient | address | The recipient of the underlying ERC20, ERC721 or ERC1155 tokens of the wrapped NFT. | + +## Permissions + +| Role name | Type (Switch / !Switch) | Purpose | +| -- | -- | -- | +| TRANSFER_ROLE | Switch | Only token transfers to or from role holders are allowed. | +| MINTER_ROLE | Switch | Only role holders can wrap tokens. | +| UNWRAP_ROLE | Switch | Only role holders can unwrap wrapped NFTs. | +| ASSET_ROLE | Switch | Only assets with the role can be wrapped. | + +What does **Type (Switch / !Switch)** mean? +- **Switch:** If `address(0)` has `ROLE`, then the `ROLE` restrictions don't apply. +- **!Switch:** `ROLE` restrictions always apply. + +## Relevant EIPs + +| EIP | Link | Relation to `Multiwrap` | +| -- | -- | -- | +| 721 | https://eips.ethereum.org/EIPS/eip-721 | Multiwrap itself is an ERC721 contract. The wrapped NFT received by a token owner on wrapping is an ERC721 NFT. Additionally, ERC721 tokens can be wrapped. | +| 20 | https://eips.ethereum.org/EIPS/eip-20 | ERC20 tokens can be wrapped. | +| 1155 | https://eips.ethereum.org/EIPS/eip-1155 | ERC1155 tokens can be wrapped. | +| 2981 | https://eips.ethereum.org/EIPS/eip-2981 | Multiwrap implements ERC 2981 for distributing royalties for sales of the wrapped NFTs. | +| 2771 | https://eips.ethereum.org/EIPS/eip-2771 | Multiwrap implements ERC 2771 to support meta-transactions (aka “gasless” transactions). | + +## Authors +- [nkrishang](https://github.com/nkrishang) +- [thirdweb team](https://github.com/thirdweb-dev) diff --git a/contracts/prebuilts/open-edition/OpenEditionERC721.sol b/contracts/prebuilts/open-edition/OpenEditionERC721.sol new file mode 100644 index 000000000..f802b2f14 --- /dev/null +++ b/contracts/prebuilts/open-edition/OpenEditionERC721.sol @@ -0,0 +1,268 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/// @author thirdweb + +// $$\ $$\ $$\ $$\ $$\ +// $$ | $$ | \__| $$ | $$ | +// $$$$$$\ $$$$$$$\ $$\ $$$$$$\ $$$$$$$ |$$\ $$\ $$\ $$$$$$\ $$$$$$$\ +// \_$$ _| $$ __$$\ $$ |$$ __$$\ $$ __$$ |$$ | $$ | $$ |$$ __$$\ $$ __$$\ +// $$ | $$ | $$ |$$ |$$ | \__|$$ / $$ |$$ | $$ | $$ |$$$$$$$$ |$$ | $$ | +// $$ |$$\ $$ | $$ |$$ |$$ | $$ | $$ |$$ | $$ | $$ |$$ ____|$$ | $$ | +// \$$$$ |$$ | $$ |$$ |$$ | \$$$$$$$ |\$$$$$\$$$$ |\$$$$$$$\ $$$$$$$ | +// \____/ \__| \__|\__|\__| \_______| \_____\____/ \_______|\_______/ + +// ========== External imports ========== + +import "@openzeppelin/contracts-upgradeable/utils/StringsUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/interfaces/IERC2981Upgradeable.sol"; + +import "../../eip/queryable/ERC721AQueryableUpgradeable.sol"; + +// ========== Internal imports ========== + +import "../../external-deps/openzeppelin/metatx/ERC2771ContextUpgradeable.sol"; +import "../../lib/CurrencyTransferLib.sol"; + +// ========== Features ========== + +import "../../extension/Multicall.sol"; +import "../../extension/ContractMetadata.sol"; +import "../../extension/Royalty.sol"; +import "../../extension/PrimarySale.sol"; +import "../../extension/Ownable.sol"; +import "../../extension/SharedMetadata.sol"; +import "../../extension/PermissionsEnumerable.sol"; +import "../../extension/Drop.sol"; + +contract OpenEditionERC721 is + Initializable, + ContractMetadata, + Royalty, + PrimarySale, + Ownable, + SharedMetadata, + PermissionsEnumerable, + Drop, + ERC2771ContextUpgradeable, + Multicall, + ERC721AQueryableUpgradeable +{ + using StringsUpgradeable for uint256; + + /*/////////////////////////////////////////////////////////////// + State variables + //////////////////////////////////////////////////////////////*/ + + /// @dev Only transfers to or from TRANSFER_ROLE holders are valid, when transfers are restricted. + bytes32 private transferRole; + /// @dev Only MINTER_ROLE holders can update the shared metadata of tokens. + bytes32 private minterRole; + + /// @dev Max bps in the thirdweb system. + uint256 private constant MAX_BPS = 10_000; + + /*/////////////////////////////////////////////////////////////// + Constructor + initializer logic + //////////////////////////////////////////////////////////////*/ + + constructor() initializer {} + + /// @dev Initializes the contract, like a constructor. + function initialize( + address _defaultAdmin, + string memory _name, + string memory _symbol, + string memory _contractURI, + address[] memory _trustedForwarders, + address _saleRecipient, + address _royaltyRecipient, + uint128 _royaltyBps + ) external initializerERC721A initializer { + bytes32 _transferRole = keccak256("TRANSFER_ROLE"); + bytes32 _minterRole = keccak256("MINTER_ROLE"); + + // Initialize inherited contracts, most base-like -> most derived. + __ERC2771Context_init(_trustedForwarders); + __ERC721A_init(_name, _symbol); + + _setupContractURI(_contractURI); + _setupOwner(_defaultAdmin); + + _setupRole(DEFAULT_ADMIN_ROLE, _defaultAdmin); + _setupRole(_minterRole, _defaultAdmin); + _setupRole(_transferRole, _defaultAdmin); + _setupRole(_transferRole, address(0)); + + _setupDefaultRoyaltyInfo(_royaltyRecipient, _royaltyBps); + _setupPrimarySaleRecipient(_saleRecipient); + + transferRole = _transferRole; + minterRole = _minterRole; + } + + /*/////////////////////////////////////////////////////////////// + ERC 165 / 721 / 2981 logic + //////////////////////////////////////////////////////////////*/ + + /// @dev Returns the URI for a given tokenId. + function tokenURI( + uint256 _tokenId + ) public view virtual override(ERC721AUpgradeable, IERC721AUpgradeable) returns (string memory) { + if (!_exists(_tokenId)) { + revert("!ID"); + } + + return _getURIFromSharedMetadata(_tokenId); + } + + /// @dev See ERC 165 + function supportsInterface( + bytes4 interfaceId + ) public view virtual override(ERC721AUpgradeable, IERC165, IERC721AUpgradeable) returns (bool) { + return super.supportsInterface(interfaceId) || type(IERC2981Upgradeable).interfaceId == interfaceId; + } + + /// @dev The start token ID for the contract. + function _startTokenId() internal pure override returns (uint256) { + return 1; + } + + function startTokenId() public pure returns (uint256) { + return _startTokenId(); + } + + /*/////////////////////////////////////////////////////////////// + Internal functions + //////////////////////////////////////////////////////////////*/ + + /// @dev Collects and distributes the primary sale value of NFTs being claimed. + function _collectPriceOnClaim( + address _primarySaleRecipient, + uint256 _quantityToClaim, + address _currency, + uint256 _pricePerToken + ) internal override { + if (_pricePerToken == 0) { + require(msg.value == 0, "!Value"); + return; + } + + uint256 totalPrice = _quantityToClaim * _pricePerToken; + + bool validMsgValue; + if (_currency == CurrencyTransferLib.NATIVE_TOKEN) { + validMsgValue = msg.value == totalPrice; + } else { + validMsgValue = msg.value == 0; + } + require(validMsgValue, "!V"); + + address saleRecipient = _primarySaleRecipient == address(0) ? primarySaleRecipient() : _primarySaleRecipient; + + CurrencyTransferLib.transferCurrency(_currency, _msgSender(), saleRecipient, totalPrice); + } + + /// @dev Transfers the NFTs being claimed. + function _transferTokensOnClaim( + address _to, + uint256 _quantityBeingClaimed + ) internal override returns (uint256 startTokenId_) { + startTokenId_ = _nextTokenId(); + _safeMint(_to, _quantityBeingClaimed); + } + + /// @dev Checks whether primary sale recipient can be set in the given execution context. + function _canSetPrimarySaleRecipient() internal view override returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + /// @dev Checks whether owner can be set in the given execution context. + function _canSetOwner() internal view override returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + /// @dev Checks whether royalty info can be set in the given execution context. + function _canSetRoyaltyInfo() internal view override returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + /// @dev Checks whether contract metadata can be set in the given execution context. + function _canSetContractURI() internal view override returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + /// @dev Checks whether platform fee info can be set in the given execution context. + function _canSetClaimConditions() internal view override returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + /// @dev Returns whether the shared metadata of tokens can be set in the given execution context. + function _canSetSharedMetadata() internal view virtual override returns (bool) { + return hasRole(minterRole, _msgSender()); + } + + /*/////////////////////////////////////////////////////////////// + Miscellaneous + //////////////////////////////////////////////////////////////*/ + + /** + * Returns the total amount of tokens minted in the contract. + */ + function totalMinted() external view returns (uint256) { + unchecked { + return _nextTokenId() - _startTokenId(); + } + } + + /// @dev The tokenId of the next NFT that will be minted / lazy minted. + function nextTokenIdToMint() external view returns (uint256) { + return _nextTokenId(); + } + + /// @dev The next token ID of the NFT that can be claimed. + function nextTokenIdToClaim() external view returns (uint256) { + return _nextTokenId(); + } + + /// @dev Burns `tokenId`. See {ERC721-_burn}. + function burn(uint256 tokenId) external virtual { + // note: ERC721AUpgradeable's `_burn(uint256,bool)` internally checks for token approvals. + _burn(tokenId, true); + } + + /// @dev See {ERC721-_beforeTokenTransfer}. + function _beforeTokenTransfers( + address from, + address to, + uint256 startTokenId_, + uint256 quantity + ) internal virtual override { + super._beforeTokenTransfers(from, to, startTokenId_, quantity); + + // if transfer is restricted on the contract, we still want to allow burning and minting + if (!hasRole(transferRole, address(0)) && from != address(0) && to != address(0)) { + if (!hasRole(transferRole, from) && !hasRole(transferRole, to)) { + revert("!T"); + } + } + } + + function _dropMsgSender() internal view virtual override returns (address) { + return _msgSender(); + } + + function _msgSenderERC721A() internal view virtual override returns (address) { + return _msgSender(); + } + + function _msgSender() + internal + view + virtual + override(ERC2771ContextUpgradeable, Multicall) + returns (address sender) + { + return ERC2771ContextUpgradeable._msgSender(); + } +} diff --git a/contracts/prebuilts/open-edition/OpenEditionERC721FlatFee.sol b/contracts/prebuilts/open-edition/OpenEditionERC721FlatFee.sol new file mode 100644 index 000000000..4ad26f927 --- /dev/null +++ b/contracts/prebuilts/open-edition/OpenEditionERC721FlatFee.sol @@ -0,0 +1,290 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/// @author thirdweb + +// $$\ $$\ $$\ $$\ $$\ +// $$ | $$ | \__| $$ | $$ | +// $$$$$$\ $$$$$$$\ $$\ $$$$$$\ $$$$$$$ |$$\ $$\ $$\ $$$$$$\ $$$$$$$\ +// \_$$ _| $$ __$$\ $$ |$$ __$$\ $$ __$$ |$$ | $$ | $$ |$$ __$$\ $$ __$$\ +// $$ | $$ | $$ |$$ |$$ | \__|$$ / $$ |$$ | $$ | $$ |$$$$$$$$ |$$ | $$ | +// $$ |$$\ $$ | $$ |$$ |$$ | $$ | $$ |$$ | $$ | $$ |$$ ____|$$ | $$ | +// \$$$$ |$$ | $$ |$$ |$$ | \$$$$$$$ |\$$$$$\$$$$ |\$$$$$$$\ $$$$$$$ | +// \____/ \__| \__|\__|\__| \_______| \_____\____/ \_______|\_______/ + +// ========== External imports ========== + +import "@openzeppelin/contracts-upgradeable/utils/StringsUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/interfaces/IERC2981Upgradeable.sol"; + +import "../../eip/queryable/ERC721AQueryableUpgradeable.sol"; + +// ========== Internal imports ========== + +import "../../external-deps/openzeppelin/metatx/ERC2771ContextUpgradeable.sol"; +import "../../lib/CurrencyTransferLib.sol"; + +// ========== Features ========== + +import "../../extension/Multicall.sol"; +import "../../extension/ContractMetadata.sol"; +import "../../extension/Royalty.sol"; +import "../../extension/PrimarySale.sol"; +import "../../extension/Ownable.sol"; +import "../../extension/SharedMetadata.sol"; +import "../../extension/PermissionsEnumerable.sol"; +import "../../extension/Drop.sol"; +import "../../extension/PlatformFee.sol"; + +contract OpenEditionERC721FlatFee is + Initializable, + ContractMetadata, + PlatformFee, + Royalty, + PrimarySale, + Ownable, + SharedMetadata, + PermissionsEnumerable, + Drop, + ERC2771ContextUpgradeable, + Multicall, + ERC721AQueryableUpgradeable +{ + using StringsUpgradeable for uint256; + + /*/////////////////////////////////////////////////////////////// + State variables + //////////////////////////////////////////////////////////////*/ + + /// @dev Only transfers to or from TRANSFER_ROLE holders are valid, when transfers are restricted. + bytes32 private transferRole; + /// @dev Only MINTER_ROLE holders can update the shared metadata of tokens. + bytes32 private minterRole; + + /// @dev Max bps in the thirdweb system. + uint256 private constant MAX_BPS = 10_000; + + /*/////////////////////////////////////////////////////////////// + Constructor + initializer logic + //////////////////////////////////////////////////////////////*/ + + constructor() initializer {} + + /// @dev Initializes the contract, like a constructor. + function initialize( + address _defaultAdmin, + string memory _name, + string memory _symbol, + string memory _contractURI, + address[] memory _trustedForwarders, + address _saleRecipient, + address _royaltyRecipient, + uint128 _royaltyBps, + uint128 _platformFeeBps, + address _platformFeeRecipient + ) external initializerERC721A initializer { + bytes32 _transferRole = keccak256("TRANSFER_ROLE"); + bytes32 _minterRole = keccak256("MINTER_ROLE"); + + // Initialize inherited contracts, most base-like -> most derived. + __ERC2771Context_init(_trustedForwarders); + __ERC721A_init(_name, _symbol); + + _setupContractURI(_contractURI); + _setupOwner(_defaultAdmin); + + _setupRole(DEFAULT_ADMIN_ROLE, _defaultAdmin); + _setupRole(_minterRole, _defaultAdmin); + _setupRole(_transferRole, _defaultAdmin); + _setupRole(_transferRole, address(0)); + + _setupPlatformFeeInfo(_platformFeeRecipient, _platformFeeBps); + _setupDefaultRoyaltyInfo(_royaltyRecipient, _royaltyBps); + _setupPrimarySaleRecipient(_saleRecipient); + + transferRole = _transferRole; + minterRole = _minterRole; + } + + /*/////////////////////////////////////////////////////////////// + ERC 165 / 721 / 2981 logic + //////////////////////////////////////////////////////////////*/ + + /// @dev Returns the URI for a given tokenId. + function tokenURI( + uint256 _tokenId + ) public view virtual override(ERC721AUpgradeable, IERC721AUpgradeable) returns (string memory) { + if (!_exists(_tokenId)) { + revert("!ID"); + } + + return _getURIFromSharedMetadata(_tokenId); + } + + /// @dev See ERC 165 + function supportsInterface( + bytes4 interfaceId + ) public view virtual override(ERC721AUpgradeable, IERC165, IERC721AUpgradeable) returns (bool) { + return super.supportsInterface(interfaceId) || type(IERC2981Upgradeable).interfaceId == interfaceId; + } + + /// @dev The start token ID for the contract. + function _startTokenId() internal pure override returns (uint256) { + return 1; + } + + function startTokenId() public pure returns (uint256) { + return _startTokenId(); + } + + /*/////////////////////////////////////////////////////////////// + Internal functions + //////////////////////////////////////////////////////////////*/ + + /// @dev Collects and distributes the primary sale value of NFTs being claimed. + function _collectPriceOnClaim( + address _primarySaleRecipient, + uint256 _quantityToClaim, + address _currency, + uint256 _pricePerToken + ) internal override { + if (_pricePerToken == 0) { + require(msg.value == 0, "!Value"); + return; + } + + uint256 totalPrice = _quantityToClaim * _pricePerToken; + uint256 platformFees; + address platformFeeRecipient; + + if (getPlatformFeeType() == IPlatformFee.PlatformFeeType.Flat) { + (platformFeeRecipient, platformFees) = getFlatPlatformFeeInfo(); + } else { + (address recipient, uint16 platformFeeBps) = getPlatformFeeInfo(); + platformFeeRecipient = recipient; + platformFees = ((totalPrice * platformFeeBps) / MAX_BPS); + } + require(totalPrice >= platformFees, "price less than platform fee"); + + bool validMsgValue; + if (_currency == CurrencyTransferLib.NATIVE_TOKEN) { + validMsgValue = msg.value == totalPrice; + } else { + validMsgValue = msg.value == 0; + } + require(validMsgValue, "!V"); + + address saleRecipient = _primarySaleRecipient == address(0) ? primarySaleRecipient() : _primarySaleRecipient; + + CurrencyTransferLib.transferCurrency(_currency, _msgSender(), platformFeeRecipient, platformFees); + CurrencyTransferLib.transferCurrency(_currency, _msgSender(), saleRecipient, totalPrice - platformFees); + } + + /// @dev Transfers the NFTs being claimed. + function _transferTokensOnClaim( + address _to, + uint256 _quantityBeingClaimed + ) internal override returns (uint256 startTokenId_) { + startTokenId_ = _nextTokenId(); + _safeMint(_to, _quantityBeingClaimed); + } + + /// @dev Checks whether primary sale recipient can be set in the given execution context. + function _canSetPrimarySaleRecipient() internal view override returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + /// @dev Checks whether owner can be set in the given execution context. + function _canSetOwner() internal view override returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + /// @dev Checks whether royalty info can be set in the given execution context. + function _canSetRoyaltyInfo() internal view override returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + /// @dev Checks whether contract metadata can be set in the given execution context. + function _canSetContractURI() internal view override returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + /// @dev Checks whether platform fee info can be set in the given execution context. + function _canSetClaimConditions() internal view override returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + /// @dev Returns whether the shared metadata of tokens can be set in the given execution context. + function _canSetSharedMetadata() internal view virtual override returns (bool) { + return hasRole(minterRole, _msgSender()); + } + + /// @dev Checks whether platform fee info can be set in the given execution context. + function _canSetPlatformFeeInfo() internal view override returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + /*/////////////////////////////////////////////////////////////// + Miscellaneous + //////////////////////////////////////////////////////////////*/ + + /** + * Returns the total amount of tokens minted in the contract. + */ + function totalMinted() external view returns (uint256) { + unchecked { + return _nextTokenId() - _startTokenId(); + } + } + + /// @dev The tokenId of the next NFT that will be minted / lazy minted. + function nextTokenIdToMint() external view returns (uint256) { + return _nextTokenId(); + } + + /// @dev The next token ID of the NFT that can be claimed. + function nextTokenIdToClaim() external view returns (uint256) { + return _nextTokenId(); + } + + /// @dev Burns `tokenId`. See {ERC721-_burn}. + function burn(uint256 tokenId) external virtual { + // note: ERC721AUpgradeable's `_burn(uint256,bool)` internally checks for token approvals. + _burn(tokenId, true); + } + + /// @dev See {ERC721-_beforeTokenTransfer}. + function _beforeTokenTransfers( + address from, + address to, + uint256 startTokenId_, + uint256 quantity + ) internal virtual override { + super._beforeTokenTransfers(from, to, startTokenId_, quantity); + + // if transfer is restricted on the contract, we still want to allow burning and minting + if (!hasRole(transferRole, address(0)) && from != address(0) && to != address(0)) { + if (!hasRole(transferRole, from) && !hasRole(transferRole, to)) { + revert("!T"); + } + } + } + + function _dropMsgSender() internal view virtual override returns (address) { + return _msgSender(); + } + + function _msgSenderERC721A() internal view virtual override returns (address) { + return _msgSender(); + } + + function _msgSender() + internal + view + virtual + override(ERC2771ContextUpgradeable, Multicall) + returns (address sender) + { + return ERC2771ContextUpgradeable._msgSender(); + } +} diff --git a/contracts/prebuilts/pack/Pack.sol b/contracts/prebuilts/pack/Pack.sol new file mode 100644 index 000000000..196448def --- /dev/null +++ b/contracts/prebuilts/pack/Pack.sol @@ -0,0 +1,463 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/// @author thirdweb + +// $$\ $$\ $$\ $$\ $$\ +// $$ | $$ | \__| $$ | $$ | +// $$$$$$\ $$$$$$$\ $$\ $$$$$$\ $$$$$$$ |$$\ $$\ $$\ $$$$$$\ $$$$$$$\ +// \_$$ _| $$ __$$\ $$ |$$ __$$\ $$ __$$ |$$ | $$ | $$ |$$ __$$\ $$ __$$\ +// $$ | $$ | $$ |$$ |$$ | \__|$$ / $$ |$$ | $$ | $$ |$$$$$$$$ |$$ | $$ | +// $$ |$$\ $$ | $$ |$$ |$$ | $$ | $$ |$$ | $$ | $$ |$$ ____|$$ | $$ | +// \$$$$ |$$ | $$ |$$ |$$ | \$$$$$$$ |\$$$$$\$$$$ |\$$$$$$$\ $$$$$$$ | +// \____/ \__| \__|\__|\__| \_______| \_____\____/ \_______|\_______/ + +// ========== External imports ========== + +import "@openzeppelin/contracts-upgradeable/token/ERC1155/extensions/ERC1155PausableUpgradeable.sol"; + +import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import "@openzeppelin/contracts-upgradeable/security/ReentrancyGuardUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/interfaces/IERC2981Upgradeable.sol"; +import "@openzeppelin/contracts/interfaces/IERC721Receiver.sol"; +import { IERC1155Receiver } from "@openzeppelin/contracts/interfaces/IERC1155Receiver.sol"; + +// ========== Internal imports ========== + +import "../interface/IPack.sol"; +import "../../extension/Multicall.sol"; +import "../../external-deps/openzeppelin/metatx/ERC2771ContextUpgradeable.sol"; + +// ========== Features ========== + +import "../../extension/ContractMetadata.sol"; +import "../../extension/Royalty.sol"; +import "../../extension/Ownable.sol"; +import "../../extension/PermissionsEnumerable.sol"; +import { TokenStore, ERC1155Receiver } from "../../extension/TokenStore.sol"; + +contract Pack is + Initializable, + ContractMetadata, + Ownable, + Royalty, + PermissionsEnumerable, + TokenStore, + ReentrancyGuardUpgradeable, + ERC2771ContextUpgradeable, + Multicall, + ERC1155Upgradeable, + IPack +{ + /*/////////////////////////////////////////////////////////////// + State variables + //////////////////////////////////////////////////////////////*/ + + bytes32 private constant MODULE_TYPE = bytes32("Pack"); + uint256 private constant VERSION = 2; + + /// @dev Only transfers to or from TRANSFER_ROLE holders are valid, when transfers are restricted. + bytes32 private constant TRANSFER_ROLE = keccak256("TRANSFER_ROLE"); + /// @dev Only MINTER_ROLE holders can create packs. + bytes32 private constant MINTER_ROLE = keccak256("MINTER_ROLE"); + /// @dev Only assets with ASSET_ROLE can be packed, when packing is restricted to particular assets. + bytes32 private constant ASSET_ROLE = keccak256("ASSET_ROLE"); + + // Token name + string public name; + + // Token symbol + string public symbol; + + /// @dev The token Id of the next set of packs to be minted. + uint256 public nextTokenIdToMint; + + /*/////////////////////////////////////////////////////////////// + Mappings + //////////////////////////////////////////////////////////////*/ + + /// @dev Mapping from token ID => total circulating supply of token with that ID. + mapping(uint256 => uint256) public totalSupply; + + /// @dev Mapping from pack ID => The state of that set of packs. + mapping(uint256 => PackInfo) private packInfo; + + /// @dev Checks if pack-creator allowed to add more tokens to a packId; set to false after first transfer + mapping(uint256 => bool) public canUpdatePack; + + /*/////////////////////////////////////////////////////////////// + Constructor + initializer logic + //////////////////////////////////////////////////////////////*/ + + constructor(address _nativeTokenWrapper) TokenStore(_nativeTokenWrapper) initializer {} + + /// @dev Initializes the contract, like a constructor. + /* solhint-disable no-unused-vars */ + function initialize( + address _defaultAdmin, + string memory _name, + string memory _symbol, + string memory _contractURI, + address[] memory _trustedForwarders, + address _royaltyRecipient, + uint256 _royaltyBps + ) external initializer { + __ERC1155_init(_contractURI); + + name = _name; + symbol = _symbol; + + _setupContractURI(_contractURI); + _setupOwner(_defaultAdmin); + _setupRole(DEFAULT_ADMIN_ROLE, _defaultAdmin); + _setupRole(MINTER_ROLE, _defaultAdmin); + + // note: see `onlyRoleWithSwitch` for ASSET_ROLE behaviour. + _setupRole(ASSET_ROLE, address(0)); + _setupRole(TRANSFER_ROLE, address(0)); + + _setupDefaultRoyaltyInfo(_royaltyRecipient, _royaltyBps); + } + + /* solhint-enable no-unused-vars */ + + receive() external payable { + require(msg.sender == nativeTokenWrapper, "!nativeTokenWrapper."); + } + + /*/////////////////////////////////////////////////////////////// + Modifiers + //////////////////////////////////////////////////////////////*/ + + modifier onlyRoleWithSwitch(bytes32 role) { + _checkRoleWithSwitch(role, _msgSender()); + _; + } + + /*/////////////////////////////////////////////////////////////// + Generic contract logic + //////////////////////////////////////////////////////////////*/ + + /// @dev Returns the type of the contract. + function contractType() external pure returns (bytes32) { + return MODULE_TYPE; + } + + /// @dev Returns the version of the contract. + function contractVersion() external pure returns (uint8) { + return uint8(VERSION); + } + + /*/////////////////////////////////////////////////////////////// + ERC 165 / 1155 / 2981 logic + //////////////////////////////////////////////////////////////*/ + + /// @dev Returns the URI for a given tokenId. + function uri(uint256 _tokenId) public view override returns (string memory) { + return getUriOfBundle(_tokenId); + } + + /// @dev See ERC 165 + function supportsInterface( + bytes4 interfaceId + ) public view virtual override(ERC1155Receiver, ERC1155Upgradeable, IERC165) returns (bool) { + return + super.supportsInterface(interfaceId) || + type(IERC2981Upgradeable).interfaceId == interfaceId || + type(IERC721Receiver).interfaceId == interfaceId || + type(IERC1155Receiver).interfaceId == interfaceId; + } + + /*/////////////////////////////////////////////////////////////// + Pack logic: create | open packs. + //////////////////////////////////////////////////////////////*/ + + /// @dev Creates a pack with the stated contents. + function createPack( + Token[] calldata _contents, + uint256[] calldata _numOfRewardUnits, + string memory _packUri, + uint128 _openStartTimestamp, + uint128 _amountDistributedPerOpen, + address _recipient + ) external payable onlyRoleWithSwitch(MINTER_ROLE) nonReentrant returns (uint256 packId, uint256 packTotalSupply) { + require(_contents.length > 0 && _contents.length == _numOfRewardUnits.length, "!Len"); + + if (!hasRole(ASSET_ROLE, address(0))) { + for (uint256 i = 0; i < _contents.length; i += 1) { + _checkRole(ASSET_ROLE, _contents[i].assetContract); + } + } + + packId = nextTokenIdToMint; + nextTokenIdToMint += 1; + + packTotalSupply = escrowPackContents( + _contents, + _numOfRewardUnits, + _packUri, + packId, + _amountDistributedPerOpen, + false + ); + + packInfo[packId].openStartTimestamp = _openStartTimestamp; + packInfo[packId].amountDistributedPerOpen = _amountDistributedPerOpen; + + canUpdatePack[packId] = true; + + _mint(_recipient, packId, packTotalSupply, ""); + + emit PackCreated(packId, _recipient, packTotalSupply); + } + + /// @dev Add contents to an existing packId. + function addPackContents( + uint256 _packId, + Token[] calldata _contents, + uint256[] calldata _numOfRewardUnits, + address _recipient + ) + external + payable + onlyRoleWithSwitch(MINTER_ROLE) + nonReentrant + returns (uint256 packTotalSupply, uint256 newSupplyAdded) + { + require(canUpdatePack[_packId], "!Allowed"); + require(_contents.length > 0 && _contents.length == _numOfRewardUnits.length, "!Len"); + require(balanceOf(_recipient, _packId) != 0, "!Bal"); + + if (!hasRole(ASSET_ROLE, address(0))) { + for (uint256 i = 0; i < _contents.length; i += 1) { + _checkRole(ASSET_ROLE, _contents[i].assetContract); + } + } + + uint256 amountPerOpen = packInfo[_packId].amountDistributedPerOpen; + + newSupplyAdded = escrowPackContents(_contents, _numOfRewardUnits, "", _packId, amountPerOpen, true); + packTotalSupply = totalSupply[_packId] + newSupplyAdded; + + _mint(_recipient, _packId, newSupplyAdded, ""); + + emit PackUpdated(_packId, _recipient, newSupplyAdded); + } + + /// @notice Lets a pack owner open packs and receive the packs' reward units. + function openPack(uint256 _packId, uint256 _amountToOpen) external nonReentrant returns (Token[] memory) { + address opener = _msgSender(); + + require(opener == tx.origin, "!EOA"); + require(balanceOf(opener, _packId) >= _amountToOpen, "!Bal"); + + PackInfo memory pack = packInfo[_packId]; + require(pack.openStartTimestamp <= block.timestamp, "cant open"); + + Token[] memory rewardUnits = getRewardUnits(_packId, _amountToOpen, pack.amountDistributedPerOpen, pack); + + _burn(opener, _packId, _amountToOpen); + + _transferTokenBatch(address(this), opener, rewardUnits); + + emit PackOpened(_packId, opener, _amountToOpen, rewardUnits); + + return rewardUnits; + } + + /// @dev Stores assets within the contract. + function escrowPackContents( + Token[] calldata _contents, + uint256[] calldata _numOfRewardUnits, + string memory _packUri, + uint256 packId, + uint256 amountPerOpen, + bool isUpdate + ) internal returns (uint256 supplyToMint) { + uint256 sumOfRewardUnits; + + for (uint256 i = 0; i < _contents.length; i += 1) { + require(_contents[i].totalAmount != 0, "0 amt"); + require(_contents[i].totalAmount % _numOfRewardUnits[i] == 0, "!R"); + require(_contents[i].tokenType != TokenType.ERC721 || _contents[i].totalAmount == 1, "!R"); + + sumOfRewardUnits += _numOfRewardUnits[i]; + + packInfo[packId].perUnitAmounts.push(_contents[i].totalAmount / _numOfRewardUnits[i]); + } + + require(sumOfRewardUnits % amountPerOpen == 0, "!Amt"); + supplyToMint = sumOfRewardUnits / amountPerOpen; + + if (isUpdate) { + for (uint256 i = 0; i < _contents.length; i += 1) { + _addTokenInBundle(_contents[i], packId); + } + _transferTokenBatch(_msgSender(), address(this), _contents); + } else { + _storeTokens(_msgSender(), _contents, _packUri, packId); + } + } + + /// @dev Returns the reward units to distribute. + function getRewardUnits( + uint256 _packId, + uint256 _numOfPacksToOpen, + uint256 _rewardUnitsPerOpen, + PackInfo memory pack + ) internal returns (Token[] memory rewardUnits) { + uint256 numOfRewardUnitsToDistribute = _numOfPacksToOpen * _rewardUnitsPerOpen; + rewardUnits = new Token[](numOfRewardUnitsToDistribute); + uint256 totalRewardUnits = totalSupply[_packId] * _rewardUnitsPerOpen; + uint256 totalRewardKinds = getTokenCountOfBundle(_packId); + + uint256 random = generateRandomValue(); + + (Token[] memory _token, ) = getPackContents(_packId); + bool[] memory _isUpdated = new bool[](totalRewardKinds); + for (uint256 i; i < numOfRewardUnitsToDistribute; ) { + uint256 randomVal = uint256(keccak256(abi.encode(random, i))); + uint256 target = randomVal % totalRewardUnits; + uint256 step; + for (uint256 j; j < totalRewardKinds; ) { + uint256 perUnitAmount = pack.perUnitAmounts[j]; + uint256 totalRewardUnitsOfKind = _token[j].totalAmount / perUnitAmount; + if (target < step + totalRewardUnitsOfKind) { + _token[j].totalAmount -= perUnitAmount; + _isUpdated[j] = true; + rewardUnits[i].assetContract = _token[j].assetContract; + rewardUnits[i].tokenType = _token[j].tokenType; + rewardUnits[i].tokenId = _token[j].tokenId; + rewardUnits[i].totalAmount = perUnitAmount; + totalRewardUnits -= 1; + break; + } else { + step += totalRewardUnitsOfKind; + } + unchecked { + ++j; + } + } + unchecked { + ++i; + } + } + for (uint256 i; i < totalRewardKinds; ) { + if (_isUpdated[i]) { + _updateTokenInBundle(_token[i], _packId, i); + } + unchecked { + ++i; + } + } + } + + /*/////////////////////////////////////////////////////////////// + Getter functions + //////////////////////////////////////////////////////////////*/ + + /// @dev Returns the underlying contents of a pack. + function getPackContents( + uint256 _packId + ) public view returns (Token[] memory contents, uint256[] memory perUnitAmounts) { + PackInfo memory pack = packInfo[_packId]; + uint256 total = getTokenCountOfBundle(_packId); + contents = new Token[](total); + perUnitAmounts = new uint256[](total); + + for (uint256 i; i < total; ) { + contents[i] = getTokenOfBundle(_packId, i); + unchecked { + ++i; + } + } + perUnitAmounts = pack.perUnitAmounts; + } + + /*/////////////////////////////////////////////////////////////// + Internal functions + //////////////////////////////////////////////////////////////*/ + + /// @dev Returns whether owner can be set in the given execution context. + function _canSetOwner() internal view override returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + /// @dev Returns whether royalty info can be set in the given execution context. + function _canSetRoyaltyInfo() internal view override returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + /// @dev Returns whether contract metadata can be set in the given execution context. + function _canSetContractURI() internal view override returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + /*/////////////////////////////////////////////////////////////// + Miscellaneous + //////////////////////////////////////////////////////////////*/ + + function generateRandomValue() internal view returns (uint256 random) { + random = uint256(keccak256(abi.encodePacked(_msgSender(), blockhash(block.number - 1), block.difficulty))); + } + + /** + * @dev See {ERC1155-_beforeTokenTransfer}. + */ + function _beforeTokenTransfer( + address operator, + address from, + address to, + uint256[] memory ids, + uint256[] memory amounts, + bytes memory data + ) internal virtual override { + super._beforeTokenTransfer(operator, from, to, ids, amounts, data); + + // if transfer is restricted on the contract, we still want to allow burning and minting + if (!hasRole(TRANSFER_ROLE, address(0)) && from != address(0) && to != address(0)) { + require(hasRole(TRANSFER_ROLE, from) || hasRole(TRANSFER_ROLE, to), "!TRANSFER_ROLE"); + } + + if (from == address(0)) { + for (uint256 i = 0; i < ids.length; ++i) { + totalSupply[ids[i]] += amounts[i]; + } + } else { + for (uint256 i = 0; i < ids.length; ++i) { + // pack can no longer be updated after first transfer to non-zero address + if (canUpdatePack[ids[i]] && amounts[i] != 0) { + canUpdatePack[ids[i]] = false; + } + } + } + + if (to == address(0)) { + for (uint256 i = 0; i < ids.length; ++i) { + totalSupply[ids[i]] -= amounts[i]; + } + } + } + + /// @dev See EIP-2771 + function _msgSender() + internal + view + virtual + override(ContextUpgradeable, ERC2771ContextUpgradeable, Multicall) + returns (address sender) + { + return ERC2771ContextUpgradeable._msgSender(); + } + + /// @dev See EIP-2771 + function _msgData() + internal + view + virtual + override(ContextUpgradeable, ERC2771ContextUpgradeable) + returns (bytes calldata) + { + return ERC2771ContextUpgradeable._msgData(); + } +} diff --git a/contracts/prebuilts/pack/PackVRFDirect.sol b/contracts/prebuilts/pack/PackVRFDirect.sol new file mode 100644 index 000000000..541ceb904 --- /dev/null +++ b/contracts/prebuilts/pack/PackVRFDirect.sol @@ -0,0 +1,516 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/// @author thirdweb + +// $$\ $$\ $$\ $$\ $$\ +// $$ | $$ | \__| $$ | $$ | +// $$$$$$\ $$$$$$$\ $$\ $$$$$$\ $$$$$$$ |$$\ $$\ $$\ $$$$$$\ $$$$$$$\ +// \_$$ _| $$ __$$\ $$ |$$ __$$\ $$ __$$ |$$ | $$ | $$ |$$ __$$\ $$ __$$\ +// $$ | $$ | $$ |$$ |$$ | \__|$$ / $$ |$$ | $$ | $$ |$$$$$$$$ |$$ | $$ | +// $$ |$$\ $$ | $$ |$$ |$$ | $$ | $$ |$$ | $$ | $$ |$$ ____|$$ | $$ | +// \$$$$ |$$ | $$ |$$ |$$ | \$$$$$$$ |\$$$$$\$$$$ |\$$$$$$$\ $$$$$$$ | +// \____/ \__| \__|\__|\__| \_______| \_____\____/ \_______|\_______/ + +// ========== External imports ========== + +import "@openzeppelin/contracts-upgradeable/token/ERC1155/extensions/ERC1155PausableUpgradeable.sol"; + +import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import "@openzeppelin/contracts-upgradeable/security/ReentrancyGuardUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/interfaces/IERC2981Upgradeable.sol"; +import "@openzeppelin/contracts/interfaces/IERC721Receiver.sol"; +import { IERC1155Receiver } from "@openzeppelin/contracts/interfaces/IERC1155Receiver.sol"; + +import "../../external-deps/chainlink/VRFV2WrapperConsumerBase.sol"; + +// ========== Internal imports ========== + +import "../interface/IPackVRFDirect.sol"; +import "../../extension/Multicall.sol"; +import "../../external-deps/openzeppelin/metatx/ERC2771ContextUpgradeable.sol"; + +// ========== Features ========== + +import "../../extension/ContractMetadata.sol"; +import "../../extension/Royalty.sol"; +import "../../extension/Ownable.sol"; +import "../../extension/PermissionsEnumerable.sol"; +import { TokenStore, ERC1155Receiver } from "../../extension/TokenStore.sol"; + +/** + NOTE: This contract is a work in progress. + */ + +contract PackVRFDirect is + Initializable, + VRFV2WrapperConsumerBase, + ContractMetadata, + Ownable, + Royalty, + Permissions, + TokenStore, + ReentrancyGuardUpgradeable, + ERC2771ContextUpgradeable, + Multicall, + ERC1155Upgradeable, + IPackVRFDirect +{ + /*/////////////////////////////////////////////////////////////// + State variables + //////////////////////////////////////////////////////////////*/ + + bytes32 private constant MODULE_TYPE = bytes32("PackVRFDirect"); + uint256 private constant VERSION = 2; + + address private immutable forwarder; + + // Token name + string public name; + + // Token symbol + string public symbol; + + /// @dev Only MINTER_ROLE holders can create packs. + bytes32 private minterRole; + + /// @dev The token Id of the next set of packs to be minted. + uint256 public nextTokenIdToMint; + + /*/////////////////////////////////////////////////////////////// + Mappings + //////////////////////////////////////////////////////////////*/ + + /// @dev Mapping from token ID => total circulating supply of token with that ID. + mapping(uint256 => uint256) public totalSupply; + + /// @dev Mapping from pack ID => The state of that set of packs. + mapping(uint256 => PackInfo) private packInfo; + + /*/////////////////////////////////////////////////////////////// + VRF state + //////////////////////////////////////////////////////////////*/ + + uint32 private constant CALLBACKGASLIMIT = 100_000; + uint16 private constant REQUEST_CONFIRMATIONS = 3; + uint32 private constant NUMWORDS = 1; + + struct RequestInfo { + uint256 packId; + address opener; + uint256 amountToOpen; + uint256[] randomWords; + bool openOnFulfillRandomness; + } + + mapping(uint256 => RequestInfo) private requestInfo; + mapping(address => uint256) private openerToReqId; + + /*/////////////////////////////////////////////////////////////// + Constructor + initializer logic + //////////////////////////////////////////////////////////////*/ + + constructor( + address _nativeTokenWrapper, + address _trustedForwarder, + address _linkTokenAddress, + address _vrfV2Wrapper + ) VRFV2WrapperConsumerBase(_linkTokenAddress, _vrfV2Wrapper) TokenStore(_nativeTokenWrapper) initializer { + forwarder = _trustedForwarder; + } + + /// @dev Initializes the contract, like a constructor. + function initialize( + address _defaultAdmin, + string memory _name, + string memory _symbol, + string memory _contractURI, + address[] memory _trustedForwarders, + address _royaltyRecipient, + uint256 _royaltyBps + ) external initializer { + bytes32 _minterRole = keccak256("MINTER_ROLE"); + + /** note: The immutable state-variable `forwarder` is an EOA-only forwarder, + * which guards against automated attacks. + * + * Use other forwarders only if there's a strong reason to bypass this check. + */ + address[] memory forwarders = new address[](_trustedForwarders.length + 1); + uint256 i; + for (; i < _trustedForwarders.length; i++) { + forwarders[i] = _trustedForwarders[i]; + } + forwarders[i] = forwarder; + __ERC2771Context_init(forwarders); + __ERC1155_init(_contractURI); + + name = _name; + symbol = _symbol; + + _setupContractURI(_contractURI); + _setupOwner(_defaultAdmin); + _setupRole(DEFAULT_ADMIN_ROLE, _defaultAdmin); + _setupRole(_minterRole, _defaultAdmin); + + _setupDefaultRoyaltyInfo(_royaltyRecipient, _royaltyBps); + + minterRole = _minterRole; + } + + receive() external payable { + require(msg.sender == nativeTokenWrapper, "!nativeTokenWrapper."); + } + + /*/////////////////////////////////////////////////////////////// + Generic contract logic + //////////////////////////////////////////////////////////////*/ + + /// @dev Returns the type of the contract. + function contractType() external pure returns (bytes32) { + return MODULE_TYPE; + } + + /// @dev Returns the version of the contract. + function contractVersion() external pure returns (uint8) { + return uint8(VERSION); + } + + /*/////////////////////////////////////////////////////////////// + ERC 165 / 1155 / 2981 logic + //////////////////////////////////////////////////////////////*/ + + /// @dev Returns the URI for a given tokenId. + function uri(uint256 _tokenId) public view override returns (string memory) { + return getUriOfBundle(_tokenId); + } + + /// @dev See ERC 165 + function supportsInterface( + bytes4 interfaceId + ) public view virtual override(ERC1155Receiver, ERC1155Upgradeable, IERC165) returns (bool) { + return + super.supportsInterface(interfaceId) || + type(IERC2981Upgradeable).interfaceId == interfaceId || + type(IERC721Receiver).interfaceId == interfaceId || + type(IERC1155Receiver).interfaceId == interfaceId; + } + + /*/////////////////////////////////////////////////////////////// + Pack logic: create | open packs. + //////////////////////////////////////////////////////////////*/ + + /// @dev Creates a pack with the stated contents. + function createPack( + Token[] calldata _contents, + uint256[] calldata _numOfRewardUnits, + string memory _packUri, + uint128 _openStartTimestamp, + uint128 _amountDistributedPerOpen, + address _recipient + ) external payable onlyRole(minterRole) nonReentrant returns (uint256 packId, uint256 packTotalSupply) { + require(_contents.length > 0 && _contents.length == _numOfRewardUnits.length, "!Len"); + + packId = nextTokenIdToMint; + nextTokenIdToMint += 1; + + packTotalSupply = escrowPackContents( + _contents, + _numOfRewardUnits, + _packUri, + packId, + _amountDistributedPerOpen, + false + ); + + packInfo[packId].openStartTimestamp = _openStartTimestamp; + packInfo[packId].amountDistributedPerOpen = _amountDistributedPerOpen; + + // canUpdatePack[packId] = true; + + _mint(_recipient, packId, packTotalSupply, ""); + + emit PackCreated(packId, _recipient, packTotalSupply); + } + + /*/////////////////////////////////////////////////////////////// + VRF logic + //////////////////////////////////////////////////////////////*/ + + function openPackAndClaimRewards( + uint256 _packId, + uint256 _amountToOpen, + uint32 _callBackGasLimit + ) external returns (uint256) { + return _requestOpenPack(_packId, _amountToOpen, _callBackGasLimit, true); + } + + /// @notice Lets a pack owner open packs and receive the packs' reward units. + function openPack(uint256 _packId, uint256 _amountToOpen) external returns (uint256) { + return _requestOpenPack(_packId, _amountToOpen, CALLBACKGASLIMIT, false); + } + + function _requestOpenPack( + uint256 _packId, + uint256 _amountToOpen, + uint32 _callBackGasLimit, + bool _openOnFulfill + ) internal returns (uint256 requestId) { + address opener = _msgSender(); + + require(isTrustedForwarder(msg.sender) || opener == tx.origin, "!EOA"); + + require(openerToReqId[opener] == 0, "ReqInFlight"); + + require(_amountToOpen > 0 && balanceOf(opener, _packId) >= _amountToOpen, "!Bal"); + require(packInfo[_packId].openStartTimestamp <= block.timestamp, "!Open"); + + // Transfer packs into the contract. + _safeTransferFrom(opener, address(this), _packId, _amountToOpen, ""); + + // Request VRF for randomness. + requestId = requestRandomness(_callBackGasLimit, REQUEST_CONFIRMATIONS, NUMWORDS); + require(requestId > 0, "!VRF"); + + // Mark request as active; store request parameters. + requestInfo[requestId].packId = _packId; + requestInfo[requestId].opener = opener; + requestInfo[requestId].amountToOpen = _amountToOpen; + requestInfo[requestId].openOnFulfillRandomness = _openOnFulfill; + openerToReqId[opener] = requestId; + + emit PackOpenRequested(opener, _packId, _amountToOpen, requestId); + } + + /// @notice Called by Chainlink VRF to fulfill a random number request. + function fulfillRandomWords(uint256 _requestId, uint256[] memory _randomWords) internal override { + RequestInfo memory info = requestInfo[_requestId]; + + require(info.randomWords.length == 0, "!Req"); + requestInfo[_requestId].randomWords = _randomWords; + + emit PackRandomnessFulfilled(info.packId, _requestId); + + if (info.openOnFulfillRandomness) { + try PackVRFDirect(payable(address(this))).sendRewardsIndirect(info.opener) {} catch {} + } + } + + /// @notice Returns whether a pack opener is ready to call `claimRewards`. + function canClaimRewards(address _opener) public view returns (bool) { + uint256 requestId = openerToReqId[_opener]; + return requestId > 0 && requestInfo[requestId].randomWords.length > 0; + } + + /// @notice Lets a pack owner open packs and receive the packs' reward units. + function claimRewards() external returns (Token[] memory) { + return _claimRewards(_msgSender()); + } + + /// @notice Lets a pack owner open packs and receive the packs' reward units. + function sendRewardsIndirect(address _opener) external { + require(msg.sender == address(this)); + _claimRewards(_opener); + } + + function _claimRewards(address opener) internal returns (Token[] memory) { + require(isTrustedForwarder(msg.sender) || msg.sender == address(this) || opener == tx.origin, "!EOA"); + require(canClaimRewards(opener), "!ActiveReq"); + uint256 reqId = openerToReqId[opener]; + RequestInfo memory info = requestInfo[reqId]; + + delete openerToReqId[opener]; + delete requestInfo[reqId]; + + PackInfo memory pack = packInfo[info.packId]; + + Token[] memory rewardUnits = getRewardUnits( + info.randomWords[0], + info.packId, + info.amountToOpen, + pack.amountDistributedPerOpen, + pack + ); + + // Burn packs. + _burn(address(this), info.packId, info.amountToOpen); + + _transferTokenBatch(address(this), opener, rewardUnits); + + emit PackOpened(info.packId, opener, info.amountToOpen, rewardUnits); + + return rewardUnits; + } + + /// @dev Stores assets within the contract. + function escrowPackContents( + Token[] calldata _contents, + uint256[] calldata _numOfRewardUnits, + string memory _packUri, + uint256 packId, + uint256 amountPerOpen, + bool isUpdate + ) internal returns (uint256 supplyToMint) { + uint256 sumOfRewardUnits; + + for (uint256 i = 0; i < _contents.length; i += 1) { + require(_contents[i].totalAmount != 0, "0 amt"); + require(_contents[i].totalAmount % _numOfRewardUnits[i] == 0, "!R"); + require(_contents[i].tokenType != TokenType.ERC721 || _contents[i].totalAmount == 1, "!R"); + + sumOfRewardUnits += _numOfRewardUnits[i]; + + packInfo[packId].perUnitAmounts.push(_contents[i].totalAmount / _numOfRewardUnits[i]); + } + + require(sumOfRewardUnits % amountPerOpen == 0, "!Amt"); + supplyToMint = sumOfRewardUnits / amountPerOpen; + + if (isUpdate) { + for (uint256 i = 0; i < _contents.length; i += 1) { + _addTokenInBundle(_contents[i], packId); + } + _transferTokenBatch(_msgSender(), address(this), _contents); + } else { + _storeTokens(_msgSender(), _contents, _packUri, packId); + } + } + + /// @dev Returns the reward units to distribute. + function getRewardUnits( + uint256 _random, + uint256 _packId, + uint256 _numOfPacksToOpen, + uint256 _rewardUnitsPerOpen, + PackInfo memory pack + ) internal returns (Token[] memory rewardUnits) { + uint256 numOfRewardUnitsToDistribute = _numOfPacksToOpen * _rewardUnitsPerOpen; + rewardUnits = new Token[](numOfRewardUnitsToDistribute); + uint256 totalRewardUnits = totalSupply[_packId] * _rewardUnitsPerOpen; + uint256 totalRewardKinds = getTokenCountOfBundle(_packId); + + (Token[] memory _token, ) = getPackContents(_packId); + bool[] memory _isUpdated = new bool[](totalRewardKinds); + for (uint256 i = 0; i < numOfRewardUnitsToDistribute; i += 1) { + uint256 randomVal = uint256(keccak256(abi.encode(_random, i))); + uint256 target = randomVal % totalRewardUnits; + uint256 step; + + for (uint256 j = 0; j < totalRewardKinds; j += 1) { + uint256 totalRewardUnitsOfKind = _token[j].totalAmount / pack.perUnitAmounts[j]; + + if (target < step + totalRewardUnitsOfKind) { + _token[j].totalAmount -= pack.perUnitAmounts[j]; + _isUpdated[j] = true; + + rewardUnits[i].assetContract = _token[j].assetContract; + rewardUnits[i].tokenType = _token[j].tokenType; + rewardUnits[i].tokenId = _token[j].tokenId; + rewardUnits[i].totalAmount = pack.perUnitAmounts[j]; + + totalRewardUnits -= 1; + + break; + } else { + step += totalRewardUnitsOfKind; + } + } + } + + for (uint256 i = 0; i < totalRewardKinds; i += 1) { + if (_isUpdated[i]) { + _updateTokenInBundle(_token[i], _packId, i); + } + } + } + + /*/////////////////////////////////////////////////////////////// + Getter functions + //////////////////////////////////////////////////////////////*/ + + /// @dev Returns the underlying contents of a pack. + function getPackContents( + uint256 _packId + ) public view returns (Token[] memory contents, uint256[] memory perUnitAmounts) { + PackInfo memory pack = packInfo[_packId]; + uint256 total = getTokenCountOfBundle(_packId); + contents = new Token[](total); + perUnitAmounts = new uint256[](total); + + for (uint256 i = 0; i < total; i += 1) { + contents[i] = getTokenOfBundle(_packId, i); + } + perUnitAmounts = pack.perUnitAmounts; + } + + /*/////////////////////////////////////////////////////////////// + Internal functions + //////////////////////////////////////////////////////////////*/ + + /// @dev Returns whether owner can be set in the given execution context. + function _canSetOwner() internal view override returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + /// @dev Returns whether royalty info can be set in the given execution context. + function _canSetRoyaltyInfo() internal view override returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + /// @dev Returns whether contract metadata can be set in the given execution context. + function _canSetContractURI() internal view override returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + /*/////////////////////////////////////////////////////////////// + Miscellaneous + //////////////////////////////////////////////////////////////*/ + + /** + * @dev See {ERC1155-_beforeTokenTransfer}. + */ + function _beforeTokenTransfer( + address operator, + address from, + address to, + uint256[] memory ids, + uint256[] memory amounts, + bytes memory data + ) internal virtual override { + super._beforeTokenTransfer(operator, from, to, ids, amounts, data); + + if (from == address(0)) { + for (uint256 i = 0; i < ids.length; ++i) { + totalSupply[ids[i]] += amounts[i]; + } + } + + if (to == address(0)) { + for (uint256 i = 0; i < ids.length; ++i) { + totalSupply[ids[i]] -= amounts[i]; + } + } + } + + /// @dev See EIP-2771 + function _msgSender() + internal + view + virtual + override(ContextUpgradeable, ERC2771ContextUpgradeable, Multicall) + returns (address sender) + { + return ERC2771ContextUpgradeable._msgSender(); + } + + /// @dev See EIP-2771 + function _msgData() + internal + view + virtual + override(ContextUpgradeable, ERC2771ContextUpgradeable) + returns (bytes calldata) + { + return ERC2771ContextUpgradeable._msgData(); + } +} diff --git a/contracts/prebuilts/pack/pack.md b/contracts/prebuilts/pack/pack.md new file mode 100644 index 000000000..986805bf2 --- /dev/null +++ b/contracts/prebuilts/pack/pack.md @@ -0,0 +1,261 @@ +# Pack design document. + +This is a live document that explains what the [thirdweb](https://thirdweb.com/) `Pack` smart contract is, how it works and can be used, and why it is designed the way it is. + +The document is written for technical and non-technical readers. To ask further questions about thirdweb’s `Pack` contract, please join the [thirdweb discord](https://discord.gg/thirdweb) or create a github issue. + +# Background + +The thirdweb `Pack` contract is a lootbox mechanism. An account can bundle up arbitrary ERC20, ERC721 and ERC1155 tokens into a set of packs. A pack can then be opened in return for a selection of the tokens in the pack. The selection of tokens distributed on opening a pack depends on the relative supply of all tokens in the packs. + +> **IMPORTANT**: _Pack functions, such as opening of packs, can be costly in terms of gas usage due to random selection of rewards. Please check your gas estimates/usage, and do a trial on testnets before any mainnet deployment._ + +## Product: How packs _should_ work (without web3 terminology) + +Let's say we want to create a set of packs with three kinds of rewards - 80 **circles**, 15 **squares**, and 5 **stars** — and we want exactly 1 reward to be distributed when a pack is opened. + +In this case, with thirdweb’s `Pack` contract, each pack is guaranteed to yield exactly 1 reward. To deliver this guarantee, the number of packs created is equal to the sum of the supplies of each reward. So, we now have `80 + 15 + 5` i.e. `100` packs at hand. + +![pack-diag-1.png](/assets/pack-diag-1.png) + +On opening one of these 100 packs, the opener will receive one of the pack's rewards - either a **circle**, a **square**, or a **star**. The chances of receiving a particular reward is determined by how many of that reward exists across our set of packs. + +The percentage chance of receiving a particular kind of reward (e.g. a **star**) on opening a pack is calculated as:`(number_of_stars_packed) / (total number of packs)` + +In the beginning, 80 **circles**, 15 **squares**, and 5 **stars** exist across our set of 100 packs. That means the chances of receiving a **circle** upon opening a pack is `80/100` i.e. 80%. Similarly, a pack opener stands a 15% chance of receiving a **square**, and a 5% chance of receiving a **star** upon opening a pack. + +![pack-diag-2.png](/assets/pack-diag-2.png) + +The chances of receiving each kind of reward change as packs are opened. Let's say one of our 100 packs is opened, yielding a **circle**. We then have 99 packs remaining, with _79_ **circles**, 15 **squares**, and 5 **stars** packed. + +For the next pack that is opened, the opener will have a `79/99` i.e. around 79.8% chance of receiving a **circle**, around 15.2% chance of receiving a **square**, and around 5.1% chance of receiving a **star**. + +### Core parts of `Pack` as a product + +Given the above illustration of ‘how packs _should_ work’, we can now note down certain core parts of the `Pack` product, that any implementation of `Pack` should maintain: + +- A creator can pack arbitrary ERC20, ERC721 and ERC1155 tokens into a set of packs. +- The % chance of receiving a particular reward on opening a pack should be a function of the relative supplies of the rewards within a pack. That is, opening a pack _should not_ be like a lottery, where there’s an unchanging % chance of being distributed, assigned to rewards in a set of packs. +- A pack opener _should not_ be able to tell beforehand what reward they’ll receive on opening a pack. +- Each pack in a set of packs can be opened whenever the respective pack owner chooses to open the pack. +- Packs must be capable of being transferred and sold on a marketplace. + +## Why we’re building `Pack` + +Packs are designed to work as generic packs that contain rewards in them, where a pack can be opened to retrieve the rewards in that pack. + +Packs like these already exist as e.g. regular [Pokemon card packs](https://www.pokemoncenter.com/category/booster-packs), or in other forms that use blockchain technology, like [NBA Topshot](https://nbatopshot.com/) packs. This concept is ubiquitous across various cultures, sectors and products. + +As tokens continue to get legitimized as assets / items, we’re bringing ‘packs’ — a long-standing way of gamifying distribution of items — on-chain, as a primitive with a robust implementation that can be used across all chains, and for all kinds of use cases. + +# Technical details + +We’ll now go over the technical details of the `Pack` contract, with references to the example given in the previous section — ‘How packs work (without web3 terminology)’. + +## What can be packed in packs? + +You can create a set of packs with any combination of any number of ERC20, ERC721 and ERC1155 tokens. For example, you can create a set of packs with 10,000 [USDC](https://www.circle.com/en/usdc) (ERC20), 1 [Bored Ape Yacht Club](https://opensea.io/collection/boredapeyachtclub) NFT (ERC721), and 50 of [adidas originals’ first NFT](https://opensea.io/assets/0x28472a58a490c5e09a238847f66a68a47cc76f0f/0) (ERC1155). + +With strictly non-fungible tokens i.e. ERC721 NFTs, each NFT has a supply of 1. This means if a pack is opened and an ERC721 NFT is selected by the `Pack` contract to be distributed to the opener, that 1 NFT will be distributed to the opener. + +With fungible (ERC20) and semi-fungible (ERC1155) tokens, you must specify how many of those tokens must be distributed on opening a pack, as a unit. For example, if adding 10,000 USDC to a pack, you may specify that 20 USDC, as a unit, are meant to be distributed on opening a pack. This means you’re adding 500 units of 20 USDC to the set of packs you’re creating. + +And so, what can be packed in packs are _n_ number of configurations like ‘500 units of 20 USDC’. These configurations are interpreted by the `Pack` contract as `PackContent`: + +```solidity +enum TokenType { ERC20, ERC721, ERC1155 } + +struct Token { + address assetContract; + TokenType tokenType; + uint256 tokenId; + uint256 totalAmount; +} + +uint256 perUnitAmount; +``` + +| Value | Description | +| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| assetContract | The contract address of the token. | +| tokenType | The type of the token -- ERC20 / ERC721 / ERC1155 | +| tokenId | The tokenId of the token. (Not applicable for ERC20 tokens. The contract will ignore this value for ERC20 tokens.) | +| totalAmount | The total amount of this token packed in the pack. (Not applicable for ERC721 tokens. The contract will always consider this as 1 for ERC721 tokens.) | +| perUnitAmount | The amount of this token to distribute as a unit, on opening a pack. (Not applicable for ERC721 tokens. The contract will always consider this as 1 for ERC721 tokens.) | + +**Note:** A pack can contain different configurations for the same token. For example, the same set of packs can contain ‘500 units of 20 USDC’ and ‘10 units of 1000 USDC’ as two independent types of underlying rewards. + +## Creating packs + +You can create packs with any ERC20, ERC721 or ERC1155 tokens that you own. To create packs, you must specify the following: + +```solidity +/// @dev Creates a pack with the stated contents. +function createPack( + Token[] calldata contents, + uint256[] calldata numOfRewardUnits, + string calldata packUri, + uint128 openStartTimestamp, + uint128 amountDistributedPerOpen, + address recipient +) external +``` + +| Parameter | Description | +| ------------------------ | -------------------------------------------------------------------------------------------------------------- | +| contents | Tokens/assets packed in the set of pack. | +| numOfRewardUnits | Number of reward units for each asset, where each reward unit contains per unit amount of corresponding asset. | +| packUri | The (metadata) URI assigned to the packs created. | +| openStartTimestamp | The timestamp after which packs can be opened. | +| amountDistributedPerOpen | The number of reward units distributed per open. | +| recipient | The recipient of the packs created. | + +### Packs are ERC1155 tokens i.e. NFTs + +Packs themselves are ERC1155 tokens. And so, a set of packs created with your tokens is itself identified by a unique tokenId, has an associated metadata URI and a variable supply. + +In the example given in the previous section — ‘Non technical overview’, there is a set of 100 packs created, where that entire set of packs is identified by a unique tokenId. + +Since packs are ERC1155 tokens, you can publish multiple sets of packs using the same `Pack` contract. + +### Supply of packs + +When creating packs, you can specify the number of reward units to distribute to the opener on opening a pack. And so, when creating a set of packs, the total number of packs in that set is calculated as: + +`total_supply_of_packs = (total_reward_units) / (reward_units_to_distribute_per_open)` + +This guarantees that each pack can be opened to retrieve the intended _n_ reward units from inside the set of packs. + +## Updating packs + +You can add more contents to a created pack, up till the first transfer of packs. No addition can be made post that. + +```solidity +/// @dev Add contents to an existing packId. +function addPackContents( + uint256 packId, + Token[] calldata contents, + uint256[] calldata numOfRewardUnits, + address recipient +) external +``` + +| Parameter | Description | +| ---------------- | -------------------------------------------------------------------------------------------------------------- | +| PackId | The identifier of the pack to add contents to. | +| contents | Tokens/assets packed in the set of pack. | +| numOfRewardUnits | Number of reward units for each asset, where each reward unit contains per unit amount of corresponding asset. | +| recipient | The recipient of the new supply added. Should be the same address used during creation of packs. | + +## Opening packs + +Packs can be opened by owners of packs. A pack owner can open multiple packs at once. ‘Opening a pack’ essentially means burning the pack and receiving the intended _n_ number of reward units from inside the set of packs, in exchange. + +```solidity +function openPack(uint256 packId, uint256 amountToOpen) external; + +``` + +| Parameter | Description | +| ------------ | ------------------------------------ | +| packId | The identifier of the pack to open. | +| amountToOpen | The number of packs to open at once. | + +### How reward units are selected to distribute on opening packs + +We build on the example in the previous section — ‘Non-technical overview’. + +Each single **square**, **circle** or **star** is considered as a ‘reward unit’. For example, the 5 **stars** in the packs may be “5 units of 1000 USDC”, which is represented in the `Pack` contract by the following information + +```solidity +struct Token { + address assetContract; // USDC address + TokenType tokenType; // TokenType.ERC20 + uint256 tokenId; // Not applicable + uint256 totalAmount; // 5000 +} + +uint256 perUnitAmount; // 1000 +``` + +The percentage chance of receiving a particular kind of reward (e.g. a **star**) on opening a pack is calculated as:`(number_of_stars_packed) / (total number of packs)`. Here, `number_of_stars_packed` refers to the total number of reward units of the **star** kind inside the set of packs e.g. a total of 5 units of 1000 USDC. + +Going back to the example in the previous section — ‘Non-technical overview’. — the supply of the reward units in the relevant set of packs - 80 **circles**, 15 **squares**, and 5 **stars -** can be represented on a number line, from zero to the total supply of packs - in this case, 100. + +![pack-diag-2.png](/assets/pack-diag-2.png) + +Whenever a pack is opened, the `Pack` contract uses a new _random_ number in the range of the total supply of packs to determine what reward unit will be distributed to the pack opener. + +In our example case, the `Pack` contract uses a random number less than 100 to determine whether the pack opener will receive a **circle**, **square** or a **star**. + +So e.g. if the random number `num` is such that `0 <= num < 5`, the pack opener will receive a **star**. Similarly, if `5 <= num < 20`, the opener will receive a **square**, and if `20 <= num < 100`, the opener will receive a **circle**. + +Note that given this design, the opener truly has a 5% chance of receiving a **star**, a 15% chance of receiving a **square**, and an 80% chance of receiving a **circle**, as long as the random number used in the selection of the reward unit(s) to distribute is truly random. + +## The problem with random numbers + +From the previous section — ‘How reward units are selected to distribute on opening packs’: + +> Note that given this design, the opener truly has a 5% chance of receiving a **star**, a 15% chance of receiving a **square**, and an 80% chance of receiving a **circle**, as long as the random number used in the selection of the reward unit(s) to distribute is truly random. + +In the event of a pack opening, the random number used in the process affects what unit of reward is selected by the `Pack` contract to be distributed to the pack owner. + +If a pack owner can predict, at any moment, what random number will be used in this process of the contract selecting what unit of reward to distribute on opening a pack at that moment, the pack owner can selectively open their pack at a moment where they’ll receive the reward they want from the pack. + +This is a **possible** **critical vulnerability** since a core feature of the `Pack` product offering is the guarantee that each reward unit in a pack has a % probability of being distributed on opening a pack, and that this probability has some integrity (in the common sense way). Being able to predict the random numbers, as described above, overturns this guarantee. + +### Sourcing random numbers — solution + +The `Pack` contract requires a design where a pack owner _cannot possibly_ predict the random number that will be used in the process of their pack opening. + +To ensure the above, we make a simple check in the `openPack` function: + +```solidity +require(isTrustedForwarder(msg.sender) || _msgSender() == tx.origin, "opener cannot be smart contract"); +``` + +`tx.origin` returns the address of the external account that initiated the transaction, of which the `openPack` function call is a part of. + +The above check essentially means that only an external account i.e. an end user wallet, and no smart contract, can open packs. This lets us generate a pseudo random number using block variables, for the purpose of `openPack`: + +```solidity +uint256 random = uint256(keccak256(abi.encodePacked(_msgSender(), blockhash(block.number - 1), block.difficulty))); +``` + +Since only end user wallets can open packs, a pack owner _cannot possibly_ predict the random number that will be used in the process of their pack opening. That is because a pack opener cannot query the result of the random number calculation during a given block, and call `openPack` within that same block. + +We now list the single most important advantage, and consequent trade-off of using this solution: + +| Advantage | Trade-off | +| -------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | +| A pack owner cannot possibly predict the random number that will be used in the process of their pack opening. | Only external accounts / EOAs can open packs. Smart contracts cannot open packs. | + +### Sourcing random numbers — discarded solutions + +We’ll now discuss some possible solutions for this design problem along with their trade-offs / why we do not use these solutions: + +- **Using an oracle (e.g. Chainlink VRF)** + + Using an oracle like Chainlink VRF enables the original design for the `Pack` contract: a pack owner can open _n_ number of packs, whenever they want, independent of when the other pack owners choose to open their own packs. All in all — opening _n_ packs becomes a closed isolated event performed by a single pack owner. + + ![pack-diag-3.png](/assets/pack-diag-3.png) + + **Why we’re not using this solution:** + + - Chainlink VRF v1 is only on Ethereum and Polygon, and Chainlink VRF v2 (current version) is only on Ethereum and Binance. As a result, this solution cannot be used by itself across all the chains thirdweb supports (and wants to support). + - Each random number request costs an end user Chainlink’s LINK token — it is costly, and seems like a random requirement for using a thirdweb offering. + +- **Delayed-reveal randomness: rewards for all packs in a set of packs visible all at once** + By ‘delayed-reveal’ randomness, we mean the following — + - When creating a set of packs, the creator provides (1) an encrypted seed i.e. integer (see the [encryption pattern used in thirdweb’s delayed-reveal NFTs](https://blog.thirdweb.com/delayed-reveal-nfts#step-1-encryption)), and (2) a future block number. + - The created packs are _non-transferrable_ by any address except the (1) pack creator, or (2) addresses manually approved by the pack creator. This is to let the creator distribute packs as they desire, _and_ is essential for the next step. + - After the specified future block number passes, the creator submits the unencrypted seed to the `Pack` contract. Whenever a pack owner now opens a pack, we calculate the random number to be used in the opening process as follows: + ```solidity + uint256 random = uint(keccak256(seed, msg.sender, blockhash(storedBlockNumber))); + ``` + - No one can predict the block hash of the stored future block unless the pack creator is the miner of the block with that block number (highly unlikely). + - The seed is controlled by the creator, submitted at the time of pack creation, and cannot be changed after submission. + - Since packs are non-transferrable in the way described above, as long as the pack opener is not approved to transfer packs, the opener cannot manipulate the value of `random` by transferring packs to a desirable address and then opening the pack from that address. + **Why we’re not using this solution:** + - Active involvement from the pack creator. They’re trusted to reveal the unencrypted seed once packs are eligible to be opened. + - Packs _must_ be non-transferrable in the way described above, which means they can’t be purchased on a marketplace, etc. Lack of a built-in distribution mechanism for the packs. diff --git a/contracts/prebuilts/signature-drop/SignatureDrop.sol b/contracts/prebuilts/signature-drop/SignatureDrop.sol new file mode 100644 index 000000000..b505940bb --- /dev/null +++ b/contracts/prebuilts/signature-drop/SignatureDrop.sol @@ -0,0 +1,371 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/// @author thirdweb + +// $$\ $$\ $$\ $$\ $$\ +// $$ | $$ | \__| $$ | $$ | +// $$$$$$\ $$$$$$$\ $$\ $$$$$$\ $$$$$$$ |$$\ $$\ $$\ $$$$$$\ $$$$$$$\ +// \_$$ _| $$ __$$\ $$ |$$ __$$\ $$ __$$ |$$ | $$ | $$ |$$ __$$\ $$ __$$\ +// $$ | $$ | $$ |$$ |$$ | \__|$$ / $$ |$$ | $$ | $$ |$$$$$$$$ |$$ | $$ | +// $$ |$$\ $$ | $$ |$$ |$$ | $$ | $$ |$$ | $$ | $$ |$$ ____|$$ | $$ | +// \$$$$ |$$ | $$ |$$ |$$ | \$$$$$$$ |\$$$$$\$$$$ |\$$$$$$$\ $$$$$$$ | +// \____/ \__| \__|\__|\__| \_______| \_____\____/ \_______|\_______/ + +// ========== External imports ========== + +import "../../extension/Multicall.sol"; +import "@openzeppelin/contracts-upgradeable/utils/StringsUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/interfaces/IERC2981Upgradeable.sol"; + +import "erc721a-upgradeable/contracts/ERC721AUpgradeable.sol"; + +// ========== Internal imports ========== + +import "../../external-deps/openzeppelin/metatx/ERC2771ContextUpgradeable.sol"; +import "../../lib/CurrencyTransferLib.sol"; + +// ========== Features ========== + +import "../../extension/ContractMetadata.sol"; +import "../../legacy-contracts/extension/PlatformFee_V1.sol"; +import "../../extension/Royalty.sol"; +import "../../legacy-contracts/extension/PrimarySale_V1.sol"; +import "../../extension/Ownable.sol"; +import "../../extension/DelayedReveal.sol"; +import "../../extension/LazyMint.sol"; +import "../../extension/PermissionsEnumerable.sol"; +import "../../extension/DropSinglePhase.sol"; +import "../../extension/SignatureMintERC721Upgradeable.sol"; + +contract SignatureDrop is + Initializable, + ContractMetadata, + PlatformFee, + Royalty, + PrimarySale, + Ownable, + DelayedReveal, + LazyMint, + PermissionsEnumerable, + DropSinglePhase, + SignatureMintERC721Upgradeable, + ERC2771ContextUpgradeable, + Multicall, + ERC721AUpgradeable +{ + using StringsUpgradeable for uint256; + + /*/////////////////////////////////////////////////////////////// + State variables + //////////////////////////////////////////////////////////////*/ + + /// @dev Only transfers to or from TRANSFER_ROLE holders are valid, when transfers are restricted. + bytes32 private transferRole; + /// @dev Only MINTER_ROLE holders can sign off on `MintRequest`s and lazy mint tokens. + bytes32 private minterRole; + + /// @dev Max bps in the thirdweb system. + uint256 private constant MAX_BPS = 10_000; + + /*/////////////////////////////////////////////////////////////// + Constructor + initializer logic + //////////////////////////////////////////////////////////////*/ + + /// @dev Initializes the contract, like a constructor. + function initialize( + address _defaultAdmin, + string memory _name, + string memory _symbol, + string memory _contractURI, + address[] memory _trustedForwarders, + address _saleRecipient, + address _royaltyRecipient, + uint128 _royaltyBps, + uint128 _platformFeeBps, + address _platformFeeRecipient + ) external initializer { + bytes32 _transferRole = keccak256("TRANSFER_ROLE"); + bytes32 _minterRole = keccak256("MINTER_ROLE"); + + // Initialize inherited contracts, most base-like -> most derived. + __ERC2771Context_init(_trustedForwarders); + __ERC721A_init(_name, _symbol); + __SignatureMintERC721_init(); + + _setupContractURI(_contractURI); + _setupOwner(_defaultAdmin); + + _setupRole(DEFAULT_ADMIN_ROLE, _defaultAdmin); + _setupRole(_minterRole, _defaultAdmin); + _setupRole(_transferRole, _defaultAdmin); + _setupRole(_transferRole, address(0)); + + _setupPlatformFeeInfo(_platformFeeRecipient, _platformFeeBps); + _setupDefaultRoyaltyInfo(_royaltyRecipient, _royaltyBps); + _setupPrimarySaleRecipient(_saleRecipient); + + transferRole = _transferRole; + minterRole = _minterRole; + } + + /*/////////////////////////////////////////////////////////////// + ERC 165 / 721 / 2981 logic + //////////////////////////////////////////////////////////////*/ + + /// @dev Returns the URI for a given tokenId. + function tokenURI(uint256 _tokenId) public view override returns (string memory) { + (uint256 batchId, ) = _getBatchId(_tokenId); + string memory batchUri = _getBaseURI(_tokenId); + + if (isEncryptedBatch(batchId)) { + return string(abi.encodePacked(batchUri, "0")); + } else { + return string(abi.encodePacked(batchUri, _tokenId.toString())); + } + } + + /// @dev See ERC 165 + function supportsInterface( + bytes4 interfaceId + ) public view virtual override(ERC721AUpgradeable, IERC165) returns (bool) { + return super.supportsInterface(interfaceId) || type(IERC2981Upgradeable).interfaceId == interfaceId; + } + + function contractType() external pure returns (bytes32) { + return bytes32("SignatureDrop"); + } + + function contractVersion() external pure returns (uint8) { + return uint8(5); + } + + /*/////////////////////////////////////////////////////////////// + Lazy minting + delayed-reveal logic + //////////////////////////////////////////////////////////////*/ + + /** + * @dev Lets an account with `MINTER_ROLE` lazy mint 'n' NFTs. + * The URIs for each token is the provided `_baseURIForTokens` + `{tokenId}`. + */ + function lazyMint( + uint256 _amount, + string calldata _baseURIForTokens, + bytes calldata _data + ) public override returns (uint256 batchId) { + if (_data.length > 0) { + (bytes memory encryptedURI, bytes32 provenanceHash) = abi.decode(_data, (bytes, bytes32)); + if (encryptedURI.length != 0 && provenanceHash != "") { + _setEncryptedData(nextTokenIdToLazyMint + _amount, _data); + } + } + + return super.lazyMint(_amount, _baseURIForTokens, _data); + } + + /// @dev Lets an account with `MINTER_ROLE` reveal the URI for a batch of 'delayed-reveal' NFTs. + function reveal( + uint256 _index, + bytes calldata _key + ) external onlyRole(minterRole) returns (string memory revealedURI) { + uint256 batchId = getBatchIdAtIndex(_index); + revealedURI = getRevealURI(batchId, _key); + + _setEncryptedData(batchId, ""); + _setBaseURI(batchId, revealedURI); + + emit TokenURIRevealed(_index, revealedURI); + } + + /*/////////////////////////////////////////////////////////////// + Claiming lazy minted tokens logic + //////////////////////////////////////////////////////////////*/ + + /// @dev Claim lazy minted tokens via signature. + function mintWithSignature( + MintRequest calldata _req, + bytes calldata _signature + ) external payable returns (address signer) { + uint256 tokenIdToMint = _currentIndex; + if (tokenIdToMint + _req.quantity > nextTokenIdToLazyMint) { + revert("!Tokens"); + } + + // Verify and process payload. + signer = _processRequest(_req, _signature); + + address receiver = _req.to; + + // Collect price + _collectPriceOnClaim(_req.primarySaleRecipient, _req.quantity, _req.currency, _req.pricePerToken); + + // Set royalties, if applicable. + if (_req.royaltyRecipient != address(0) && _req.royaltyBps != 0) { + _setupRoyaltyInfoForToken(tokenIdToMint, _req.royaltyRecipient, _req.royaltyBps); + } + + // Mint tokens. + _safeMint(receiver, _req.quantity); + + emit TokensMintedWithSignature(signer, receiver, tokenIdToMint, _req); + } + + /*/////////////////////////////////////////////////////////////// + Internal functions + //////////////////////////////////////////////////////////////*/ + + /// @dev Runs before every `claim` function call. + function _beforeClaim( + address, + uint256 _quantity, + address, + uint256, + AllowlistProof calldata, + bytes memory + ) internal view override { + require(_currentIndex + _quantity <= nextTokenIdToLazyMint, "!Tokens"); + } + + /// @dev Collects and distributes the primary sale value of NFTs being claimed. + function _collectPriceOnClaim( + address _primarySaleRecipient, + uint256 _quantityToClaim, + address _currency, + uint256 _pricePerToken + ) internal override { + if (_pricePerToken == 0) { + require(msg.value == 0, "!Value"); + return; + } + + (address platformFeeRecipient, uint16 platformFeeBps) = getPlatformFeeInfo(); + + address saleRecipient = _primarySaleRecipient == address(0) ? primarySaleRecipient() : _primarySaleRecipient; + + uint256 totalPrice = _quantityToClaim * _pricePerToken; + uint256 platformFees = (totalPrice * platformFeeBps) / MAX_BPS; + + if (_currency == CurrencyTransferLib.NATIVE_TOKEN) { + require(msg.value == totalPrice, "!Price"); + } else { + require(msg.value == 0, "!Value"); + } + + CurrencyTransferLib.transferCurrency(_currency, _msgSender(), platformFeeRecipient, platformFees); + CurrencyTransferLib.transferCurrency(_currency, _msgSender(), saleRecipient, totalPrice - platformFees); + } + + /// @dev Transfers the NFTs being claimed. + function _transferTokensOnClaim( + address _to, + uint256 _quantityBeingClaimed + ) internal override returns (uint256 startTokenId) { + startTokenId = _currentIndex; + _safeMint(_to, _quantityBeingClaimed); + } + + /// @dev Returns whether a given address is authorized to sign mint requests. + function _isAuthorizedSigner(address _signer) internal view override returns (bool) { + return hasRole(minterRole, _signer); + } + + /// @dev Checks whether platform fee info can be set in the given execution context. + function _canSetPlatformFeeInfo() internal view override returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + /// @dev Checks whether primary sale recipient can be set in the given execution context. + function _canSetPrimarySaleRecipient() internal view override returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + /// @dev Checks whether owner can be set in the given execution context. + function _canSetOwner() internal view override returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + /// @dev Checks whether royalty info can be set in the given execution context. + function _canSetRoyaltyInfo() internal view override returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + /// @dev Checks whether contract metadata can be set in the given execution context. + function _canSetContractURI() internal view override returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + /// @dev Checks whether platform fee info can be set in the given execution context. + function _canSetClaimConditions() internal view override returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + /// @dev Returns whether lazy minting can be done in the given execution context. + function _canLazyMint() internal view virtual override returns (bool) { + return hasRole(minterRole, _msgSender()); + } + + /*/////////////////////////////////////////////////////////////// + Miscellaneous + //////////////////////////////////////////////////////////////*/ + + /** + * Returns the total amount of tokens minted in the contract. + */ + function totalMinted() external view returns (uint256) { + unchecked { + return _currentIndex - _startTokenId(); + } + } + + /// @dev The tokenId of the next NFT that will be minted / lazy minted. + function nextTokenIdToMint() external view returns (uint256) { + return nextTokenIdToLazyMint; + } + + /// @dev Burns `tokenId`. See {ERC721-_burn}. + function burn(uint256 tokenId) external virtual { + // note: ERC721AUpgradeable's `_burn(uint256,bool)` internally checks for token approvals. + _burn(tokenId, true); + } + + /// @dev See {ERC721-_beforeTokenTransfer}. + function _beforeTokenTransfers( + address from, + address to, + uint256 startTokenId, + uint256 quantity + ) internal virtual override { + super._beforeTokenTransfers(from, to, startTokenId, quantity); + + // if transfer is restricted on the contract, we still want to allow burning and minting + if (!hasRole(transferRole, address(0)) && from != address(0) && to != address(0)) { + if (!hasRole(transferRole, from) && !hasRole(transferRole, to)) { + revert("!Transfer-Role"); + } + } + } + + function _dropMsgSender() internal view virtual override returns (address) { + return _msgSender(); + } + + function _msgSender() + internal + view + virtual + override(ContextUpgradeable, ERC2771ContextUpgradeable, Multicall) + returns (address sender) + { + return ERC2771ContextUpgradeable._msgSender(); + } + + function _msgData() + internal + view + virtual + override(ContextUpgradeable, ERC2771ContextUpgradeable) + returns (bytes calldata) + { + return ERC2771ContextUpgradeable._msgData(); + } +} diff --git a/contracts/prebuilts/signature-drop/signatureDrop.md b/contracts/prebuilts/signature-drop/signatureDrop.md new file mode 100644 index 000000000..826b4c556 --- /dev/null +++ b/contracts/prebuilts/signature-drop/signatureDrop.md @@ -0,0 +1,232 @@ +# SignatureDrop design document. + +This is a live document that explains what the [thirdweb](https://thirdweb.com/) `SignatureDrop` smart contract is, how it works and can be used, and why it is designed the way it is. + +The document is written for technical and non-technical readers. To ask further questions about thirdweb’s `SignatureDrop` contract, please join the [thirdweb discord](https://discord.gg/thirdweb) or create a [github issue](https://github.com/thirdweb-dev/contracts/issues). + +--- + +## Background + +The thirdweb [`Drop`](https://portal.thirdweb.com/contracts/design/Drop) and [signature minting](https://portal.thirdweb.com/contracts/design/SignatureMint) are distribution mechanisms for tokens. + +The `Drop` contracts are meant to be used when the goal of the contract creator is for an audience to come in and claim tokens within certain restrictions e.g. — ‘only addresses in an allowlist can mint tokens’, or ‘minters must pay **x** amount of price in **y** currency to mint’, etc. + +Built-in contracts that implement [signature minting](https://portal.thirdweb.com/contracts/design/SignatureMint) are meant to be used when the restrictions around a wallet's minting tokens are not pre-defined, like in `Drop`. With signature minting, a contract creator can set custom restrictions around a token's minting, such as a price, at the very time that a wallet wants to mint tokens. + +The `SignatureDrop` contract supports both distribution mechanisms - of drop and signature minting - in the same contract. + +The contract creator 'lazy mints' i.e. defines the content for a batch of NFTs (yet un-minted). An NFT from this batch is distributed to a wallet in one of two ways: +1. claiming tokens under the restrictions defined in the time's active claim phase, as in `Drop`. +2. claiming tokens via a signed payload from a contract admin, as in 'signature minting'. + +### How `SignatureDrop` works + +![signature-drop-diag.png](/assets/signature-drop-diag.png) + +The `SignatureDrop` contract supports both distribution mechanisms - of drop and signature minting - in the same contract. The following is an end-to-end flow, from the contract admin actions, to an end user wallet's actions when minting tokens: + +- A contract admin (particularly, a wallet with `MINTER_ROLE`) 'lazy mints' i.e. defines the content for a batch of NFTs. This batch of NFTs can optionally be a batch of [delayed-reveal](https://blog.thirdweb.com/delayed-reveal-nfts) NFTs. +- A contract admin (particularly, a wallet with `DEFAULT_ADMIN_ROLE`) sets a claim phase, which defines restrictions around minting NFTs from the lazy minted batch of NFTs. + - **Note:** unlike the `NFT Drop` or `Edition Drop` contracts, where the contract admin can set a series of claim phases at once, the `SignatureDrop` contract lets the contract admin set only *one* claim phase at a time. +- A wallet claims tokens from the batch of lazy minted tokens in one of two ways: + - claiming tokens under the restrictions defined in the claim phase, as in `Drop`. + - claiming tokens via a signed payload from a contract admin, as in 'signature minting'. + +### Use cases for `SignatureDrop` + +We built our `Drop` contracts for the following [reason](https://portal.thirdweb.com/contracts/design/Drop#why-were-building-drop). The limitation of our `Drop` contracts is that all wallets in an audience attempting to claim tokens are subject to the same restrictions in the single, active claim phase at any moment. + +In the `SignatureDrop` contract, a wallet can now claim tokens [via a signature](https://portal.thirdweb.com/contracts/design/SignatureMint#background) from an authorized wallet, from the same pool of lazy minted tokens which can be claimed via the `Drop` mechanism. This means a contract owner can now set custom restrictions for a wallet to claim tokens, that may be different from the active claim phase at the time. + +An example of using this added feature of the `SignatureDrop` contract is when you want to maintain an allowlist off-chain i.e. not in the claim phase details, which are stored on-chain, and difficult to update once set. The contract's claim phase can define a common set of restrictions that any wallet not in your allowlist will mint tokens under. And using [signature minting](https://portal.thirdweb.com/contracts/design/SignatureMint), you can apply custom restrictions around minting, such as a price, currency and so on, on a per wallet basis, for wallets in your off-chain allowlist. + +## Technical Details + +`SignatureDrop` is an ERC721 contract. + +A contract admin can lazy mint tokens, and establish phases for an audience to come claim those tokens under the restrictions of the active phase at the time. On a per wallet basis, a contract admin can let a wallet claim those tokens under restrictions different than the active claim phase, via signature minting. + +### Batch upload of NFTs metadata: LazyMint + +The contract creator or an address with `MINTER_ROLE` mints *n* NFTs, by providing base URI for the tokens or an encrypted URI. +```solidity +function lazyMint( + uint256 _amount, + string calldata _baseURIForTokens, + bytes calldata _encryptedBaseURI +) external onlyRole(MINTER_ROLE) returns (uint256 batchId) +``` +| Parameters | Type | Description | +| --- | --- | --- | +| _amount | uint256 | Amount of tokens to lazy-mint. | +| _baseURIForTokens | string | The metadata URI for the batch of tokens. | +| _encryptedBaseURI | bytes | Encrypted URI for the batch of tokens. | + +### Delayed reveal + +An account with `MINTER_ROLE` can reveal the URI for a batch of ‘delayed-reveal’ NFTs. The URI can be revealed by calling the following function: +```solidity +function reveal(uint256 _index, bytes calldata _key) + external + onlyRole(MINTER_ROLE) + returns (string memory revealedURI) +``` +| Parameters | Type | Description | +| --- | --- | --- | +| _index | uint256 | Index of the batch for which URI is to be revealed. | +| _key | bytes | Key for decrypting the URI. | + +### Claiming tokens via signature + +An account with `MINTER_ROLE` signs the mint request for a user. The mint request is then submitted for claiming the tokens. The mint request is specified in the following format: +```solidity +struct MintRequest { + address to; + address royaltyRecipient; + uint256 royaltyBps; + address primarySaleRecipient; + string uri; + uint256 quantity; + uint256 pricePerToken; + address currency; + uint128 validityStartTimestamp; + uint128 validityEndTimestamp; + bytes32 uid; +} +``` +| Parameters | Type | Description | +| --- | --- | --- | +| to | address | The recipient of the tokens to mint. | +| royaltyRecipient | address | The recipient of the minted token's secondary sales royalties. | +| royaltyBps | uint256 | The percentage of the minted token's secondary sales to take as royalties. | +| primarySaleRecipient | address | The recipient of the minted token's primary sales proceeds. | +| uri | string | The metadata URI of the token to mint. | +| quantity | uint256 | The quantity of tokens to mint. | +| pricePerToken | uint256 | The price to pay per quantity of tokens minted. | +| currency | address | The currency in which to pay the price per token minted. | +| validityStartTimestamp | uint128 | The unix timestamp after which the payload is valid. | +| validityEndTimestamp | uint128 | The unix timestamp at which the payload expires. | +| uid | bytes32 | A unique identifier for the payload. | + +The authorized external party can mint the tokens by submitting mint-request and contract owner’s signature to the following function: +```solidity +function mintWithSignature( + ISignatureMintERC721.MintRequest calldata _req, + bytes calldata _signature +) external payable +``` +| Parameters | Type | Description | +| --- | --- | --- | +| _req | ISignatureMintERC721.MintRequest | Mint request in the format specified above. | +| _signature | bytes | Contact owner’s signature for the mint request. | + +### Setting claim conditions + +A contract admin (i.e. a holder of `DEFAULT_ADMIN_ROLE`) can set a *single* claim condition; this defines restrictions around claiming from the batch of lazy minted tokens. An active claim condition can be completely overwritten, or updated, by the contract admin. At any moment, there is only one active claim condition. + +A claim condition is specified in the following format: +```solidity +struct ClaimCondition { + uint256 startTimestamp; + uint256 maxClaimableSupply; + uint256 supplyClaimed; + uint256 quantityLimitPerTransaction; + uint256 waitTimeInSecondsBetweenClaims; + bytes32 merkleRoot; + uint256 pricePerToken; + address currency; +} +``` +| Parameters | Type | Description | +| --- | --- | --- | +| startTimestamp | uint256 | The unix timestamp after which the claim condition applies. The same claim condition applies until the startTimestamp of the next claim condition. | +| maxClaimableSupply | uint256 | The maximum total number of tokens that can be claimed under the claim condition. | +| supplyClaimed | uint256 | At any given point, the number of tokens that have been claimed under the claim condition. | +| quantityLimitPerTransaction | uint256 | The maximum number of tokens that can be claimed in a single transaction. | +| waitTimeInSecondsBetweenClaims | uint256 | The least number of seconds an account must wait after claiming tokens, to be able to claim tokens again.. | +| merkleRoot | bytes32 | The allowlist of addresses that can claim tokens under the claim condition. | +| pricePerToken | uint256 | The price required to pay per token claimed. | +| currency | address | The currency in which the pricePerToken must be paid. | + +Per wallet restrictions related to the claim condition are stored as follows: +```solidity +/** + * @dev Map from an account and uid for a claim condition, to the last timestamp + * at which the account claimed tokens under that claim condition. + */ + mapping(bytes32 => mapping(address => uint256)) private lastClaimTimestamp; + +/** + * @dev Map from a claim condition uid to whether an address in an allowlist + * has already claimed tokens i.e. used their place in the allowlist. + */ + mapping(bytes32 => BitMapsUpgradeable.BitMap) private usedAllowlistSpot; +``` +| Parameters | Type | Description | +| --- | --- | --- | +| lastClaimTimestamp | mapping(bytes32 => mapping(address => uint256)) | Map from an account and uid for a claim condition, to the last timestamp at which the account claimed tokens under that claim condition. | +| usedAllowlistSpot | mapping(bytes32 => BitMapsUpgradeable.BitMap) | Map from a uid for a claim condition to whether an address in an allowlist has already claimed tokens i.e. used their place in the allowlist. | + +**Note:** if a claim condition has an allowlist, a wallet can only use their spot in the condition's allowlist *once*. Allowlists can optionally specify the max amount of tokens each wallet in the allowlist can claim. A wallet in such an allowlist, too, can use their allowlist spot only *once*, regardless of the number of tokens they end up claiming. + +A contract admin sets claim conditions by calling the following function: +```solidity +/// @dev Lets a contract admin set claim conditions. +function setClaimConditions( + ClaimCondition calldata _condition, + bool _resetClaimEligibility, + bytes memory +) external override; +``` +| Parameter | Type | Description | +| --- | --- | --- | +| _condition | ClaimCondition | Defines restrictions around claiming lazy minted tokens. | +| resetClaimEligibility | bool | Whether to reset lastClaimTimestamp and usedAllowlistSpot values when setting a claim conditions. | + +You can read into the technical details of setting claim conditions in the [`Drop` design document](https://portal.thirdweb.com/contracts/design/Drop#setting-claim-conditions). + + +### Claiming tokens via `Drop` +An account can claim the tokens by calling the following function: +```solidity +function claim( + address _receiver, + uint256 _quantity, + address _currency, + uint256 _pricePerToken, + AllowlistProof calldata _allowlistProof, + bytes memory _data +) public payable; +``` +| Parameters | Type | Description | +| --- | --- | --- | +| _receiver | address | Mint request in the format specified above. | +| _quantity | uint256 | Contact owner’s signature for the mint request. | +| _currency | address | The currency in which the price must be paid. | +| _pricePerToken | uint256 | The price required to pay per token claimed. | +| _allowlistProof | AllowlistProof | The proof of the claimer's inclusion in the merkle root allowlist of the claim conditions that apply. | +| _data | bytes | Arbitrary bytes data that can be leveraged in the implementation of this interface. | + +## Permissions + +| Role name | Type (Switch / !Switch) | Purpose | +| -- | -- | -- | +| TRANSFER_ROLE | Switch | Only token transfers to or from role holders are allowed. Minting and burning are not affected. | +| MINTER_ROLE | !Switch | Only MINTER_ROLE holders can sign off on MintRequests and lazy mint tokens. | + +What does **Type (Switch / !Switch)** mean? +- **Switch:** If `address(0)` has `ROLE`, then the `ROLE` restrictions don't apply. +- **!Switch:** `ROLE` restrictions always apply. + +## Relevant EIPs + +| EIP | Link | Relation to SignatureDrop | +| --- | --- | --- | +| 721 | https://eips.ethereum.org/EIPS/eip-721 | `SignatureDrop` is an ERC721 contract. | +| 2981 | https://eips.ethereum.org/EIPS/eip-2981 | `SignatureDrop` implements ERC 2981 for distributing royalties for sales of the wrapped NFTs. | +| 2771 | https://eips.ethereum.org/EIPS/eip-2771 | `SignatureDrop` implements ERC 2771 to support meta-transactions (aka “gasless” transactions). | + +## Authors +- [kumaryash90](https://github.com/kumaryash90) +- [thirdweb team](https://github.com/thirdweb-dev) \ No newline at end of file diff --git a/contracts/prebuilts/split/Split.sol b/contracts/prebuilts/split/Split.sol new file mode 100644 index 000000000..028f5a0ee --- /dev/null +++ b/contracts/prebuilts/split/Split.sol @@ -0,0 +1,182 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/// @author thirdweb + +// $$\ $$\ $$\ $$\ $$\ +// $$ | $$ | \__| $$ | $$ | +// $$$$$$\ $$$$$$$\ $$\ $$$$$$\ $$$$$$$ |$$\ $$\ $$\ $$$$$$\ $$$$$$$\ +// \_$$ _| $$ __$$\ $$ |$$ __$$\ $$ __$$ |$$ | $$ | $$ |$$ __$$\ $$ __$$\ +// $$ | $$ | $$ |$$ |$$ | \__|$$ / $$ |$$ | $$ | $$ |$$$$$$$$ |$$ | $$ | +// $$ |$$\ $$ | $$ |$$ |$$ | $$ | $$ |$$ | $$ | $$ |$$ ____|$$ | $$ | +// \$$$$ |$$ | $$ |$$ |$$ | \$$$$$$$ |\$$$$$\$$$$ |\$$$$$$$\ $$$$$$$ | +// \____/ \__| \__|\__|\__| \_______| \_____\____/ \_______|\_______/ + +// Base +import "../../external-deps/openzeppelin/finance/PaymentSplitterUpgradeable.sol"; +import "../../infra/interface/IThirdwebContract.sol"; + +// Meta-tx +import "../../external-deps/openzeppelin/metatx/ERC2771ContextUpgradeable.sol"; + +// Access +import "@openzeppelin/contracts-upgradeable/access/AccessControlEnumerableUpgradeable.sol"; + +// Utils +import "../../extension/Multicall.sol"; +import "../../lib/FeeType.sol"; +import "../../extension/upgradeable/ReentrancyGuard.sol"; + +contract Split is + IThirdwebContract, + Initializable, + Multicall, + ERC2771ContextUpgradeable, + AccessControlEnumerableUpgradeable, + PaymentSplitterUpgradeable, + ReentrancyGuard +{ + bytes32 private constant MODULE_TYPE = bytes32("Split"); + uint128 private constant VERSION = 1; + + /// @dev Max bps in the thirdweb system + uint128 private constant MAX_BPS = 10_000; + + /// @dev Contract level metadata. + string public contractURI; + + constructor() initializer {} + + /// @dev Performs the job of the constructor. + /// @dev shares_ are scaled by 10,000 to prevent precision loss when including fees + function initialize( + address _defaultAdmin, + string memory _contractURI, + address[] memory _trustedForwarders, + address[] memory _payees, + uint256[] memory _shares + ) external initializer { + // Initialize inherited contracts: most base -> most derived + __ERC2771Context_init(_trustedForwarders); + __PaymentSplitter_init(_payees, _shares); + + contractURI = _contractURI; + _setupRole(DEFAULT_ADMIN_ROLE, _defaultAdmin); + } + + /// @dev Returns the module type of the contract. + function contractType() external pure returns (bytes32) { + return MODULE_TYPE; + } + + /// @dev Returns the version of the contract. + function contractVersion() external pure returns (uint8) { + return uint8(VERSION); + } + + /** + * @dev Triggers a transfer to `account` of the amount of Ether they are owed, according to their percentage of the + * total shares and their previous withdrawals. + */ + function release(address payable account) public virtual override nonReentrant { + uint256 payment = _release(account); + require(payment != 0, "PaymentSplitter: account is not due payment"); + } + + /** + * @dev Triggers a transfer to `account` of the amount of `token` tokens they are owed, according to their + * percentage of the total shares and their previous withdrawals. `token` must be the address of an IERC20 + * contract. + */ + function release(IERC20Upgradeable token, address account) public virtual override nonReentrant { + uint256 payment = _release(token, account); + require(payment != 0, "PaymentSplitter: account is not due payment"); + } + + /// @dev Returns the amount of Ether that `account` is owed, according to their percentage of the total shares and returns the payment + function _release(address payable account) internal returns (uint256) { + require(shares(account) > 0, "PaymentSplitter: account has no shares"); + + uint256 totalReceived = address(this).balance + totalReleased(); + uint256 payment = _pendingPayment(account, totalReceived, released(account)); + + if (payment == 0) { + return 0; + } + + _released[account] += payment; + _totalReleased += payment; + + AddressUpgradeable.sendValue(account, payment); + emit PaymentReleased(account, payment); + + return payment; + } + + /// @dev Returns the amount of `token` that `account` is owed, according to their percentage of the total shares and returns the payment + function _release(IERC20Upgradeable token, address account) internal returns (uint256) { + require(shares(account) > 0, "PaymentSplitter: account has no shares"); + + uint256 totalReceived = token.balanceOf(address(this)) + totalReleased(token); + uint256 payment = _pendingPayment(account, totalReceived, released(token, account)); + + if (payment == 0) { + return 0; + } + + _erc20Released[token][account] += payment; + _erc20TotalReleased[token] += payment; + + SafeERC20Upgradeable.safeTransfer(token, account, payment); + emit ERC20PaymentReleased(token, account, payment); + + return payment; + } + + /** + * @dev Release the owed amount of token to all of the payees. + */ + function distribute() public virtual nonReentrant { + uint256 count = payeeCount(); + for (uint256 i = 0; i < count; i++) { + _release(payable(payee(i))); + } + } + + /** + * @dev Release owed amount of the `token` to all of the payees. + */ + function distribute(IERC20Upgradeable token) public virtual nonReentrant { + uint256 count = payeeCount(); + for (uint256 i = 0; i < count; i++) { + _release(token, payee(i)); + } + } + + /// @dev See ERC2771 + function _msgSender() + internal + view + virtual + override(ContextUpgradeable, ERC2771ContextUpgradeable, Multicall) + returns (address sender) + { + return ERC2771ContextUpgradeable._msgSender(); + } + + /// @dev See ERC2771 + function _msgData() + internal + view + virtual + override(ContextUpgradeable, ERC2771ContextUpgradeable) + returns (bytes calldata) + { + return ERC2771ContextUpgradeable._msgData(); + } + + /// @dev Sets contract URI for the contract-level metadata of the contract. + function setContractURI(string calldata _uri) external onlyRole(DEFAULT_ADMIN_ROLE) { + contractURI = _uri; + } +} diff --git a/contracts/prebuilts/staking/EditionStake.sol b/contracts/prebuilts/staking/EditionStake.sol new file mode 100644 index 000000000..9f5375bbc --- /dev/null +++ b/contracts/prebuilts/staking/EditionStake.sol @@ -0,0 +1,211 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/// @author thirdweb + +// $$\ $$\ $$\ $$\ $$\ +// $$ | $$ | \__| $$ | $$ | +// $$$$$$\ $$$$$$$\ $$\ $$$$$$\ $$$$$$$ |$$\ $$\ $$\ $$$$$$\ $$$$$$$\ +// \_$$ _| $$ __$$\ $$ |$$ __$$\ $$ __$$ |$$ | $$ | $$ |$$ __$$\ $$ __$$\ +// $$ | $$ | $$ |$$ |$$ | \__|$$ / $$ |$$ | $$ | $$ |$$$$$$$$ |$$ | $$ | +// $$ |$$\ $$ | $$ |$$ |$$ | $$ | $$ |$$ | $$ | $$ |$$ ____|$$ | $$ | +// \$$$$ |$$ | $$ |$$ |$$ | \$$$$$$$ |\$$$$$\$$$$ |\$$$$$$$\ $$$$$$$ | +// \____/ \__| \__|\__|\__| \_______| \_____\____/ \_______|\_______/ + +// Token +import "@openzeppelin/contracts/token/ERC1155/IERC1155.sol"; +import "@openzeppelin/contracts-upgradeable/utils/introspection/ERC165Upgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/token/ERC1155/IERC1155ReceiverUpgradeable.sol"; + +// Meta transactions +import "../../external-deps/openzeppelin/metatx/ERC2771ContextUpgradeable.sol"; + +// Utils +import "../../extension/Multicall.sol"; +import "../../lib/CurrencyTransferLib.sol"; + +// ========== Features ========== + +import "../../extension/ContractMetadata.sol"; +import "../../extension/PermissionsEnumerable.sol"; +import { Staking1155Upgradeable } from "../../extension/Staking1155Upgradeable.sol"; +import "../interface/staking/IEditionStake.sol"; + +contract EditionStake is + Initializable, + ContractMetadata, + PermissionsEnumerable, + ERC2771ContextUpgradeable, + Multicall, + Staking1155Upgradeable, + ERC165Upgradeable, + IERC1155ReceiverUpgradeable, + IEditionStake +{ + bytes32 private constant MODULE_TYPE = bytes32("EditionStake"); + uint256 private constant VERSION = 1; + + /// @dev The address of the native token wrapper contract. + address internal immutable nativeTokenWrapper; + + /// @dev ERC20 Reward Token address. See {_mintRewards} below. + address public rewardToken; + + /// @dev Total amount of reward tokens in the contract. + uint256 private rewardTokenBalance; + + constructor(address _nativeTokenWrapper) initializer { + nativeTokenWrapper = _nativeTokenWrapper; + } + + /// @dev Initializes the contract, like a constructor. + function initialize( + address _defaultAdmin, + string memory _contractURI, + address[] memory _trustedForwarders, + address _rewardToken, + address _stakingToken, + uint80 _defaultTimeUnit, + uint256 _defaultRewardsPerUnitTime + ) external initializer { + __ERC2771Context_init_unchained(_trustedForwarders); + + rewardToken = _rewardToken; + __Staking1155_init(_stakingToken); + _setDefaultStakingCondition(_defaultTimeUnit, _defaultRewardsPerUnitTime); + + _setupContractURI(_contractURI); + _setupRole(DEFAULT_ADMIN_ROLE, _defaultAdmin); + } + + /// @dev Returns the module type of the contract. + function contractType() external pure virtual returns (bytes32) { + return MODULE_TYPE; + } + + /// @dev Returns the version of the contract. + function contractVersion() external pure virtual returns (uint8) { + return uint8(VERSION); + } + + /// @dev Lets the contract receive ether to unwrap native tokens. + receive() external payable { + require(msg.sender == nativeTokenWrapper, "caller not native token wrapper."); + } + + /// @dev Admin deposits reward tokens. + function depositRewardTokens(uint256 _amount) external payable nonReentrant { + require(hasRole(DEFAULT_ADMIN_ROLE, _msgSender()), "Not authorized"); + + address _rewardToken = rewardToken == CurrencyTransferLib.NATIVE_TOKEN ? nativeTokenWrapper : rewardToken; + + uint256 balanceBefore = IERC20(_rewardToken).balanceOf(address(this)); + CurrencyTransferLib.transferCurrencyWithWrapper( + rewardToken, + _msgSender(), + address(this), + _amount, + nativeTokenWrapper + ); + uint256 actualAmount = IERC20(_rewardToken).balanceOf(address(this)) - balanceBefore; + + rewardTokenBalance += actualAmount; + + emit RewardTokensDepositedByAdmin(actualAmount); + } + + /// @dev Admin can withdraw excess reward tokens. + function withdrawRewardTokens(uint256 _amount) external nonReentrant { + require(hasRole(DEFAULT_ADMIN_ROLE, _msgSender()), "Not authorized"); + + // to prevent locking of direct-transferred tokens + rewardTokenBalance = _amount > rewardTokenBalance ? 0 : rewardTokenBalance - _amount; + + CurrencyTransferLib.transferCurrencyWithWrapper( + rewardToken, + address(this), + _msgSender(), + _amount, + nativeTokenWrapper + ); + + emit RewardTokensWithdrawnByAdmin(_amount); + } + + /// @notice View total rewards available in the staking contract. + function getRewardTokenBalance() external view override returns (uint256 _rewardsAvailableInContract) { + return rewardTokenBalance; + } + + /*/////////////////////////////////////////////////////////////// + ERC 165 / 721 logic + //////////////////////////////////////////////////////////////*/ + + function onERC1155Received(address, address, uint256, uint256, bytes calldata) external view returns (bytes4) { + require(isStaking == 2, "Direct transfer"); + return this.onERC1155Received.selector; + } + + function onERC1155BatchReceived( + address operator, + address from, + uint256[] calldata ids, + uint256[] calldata values, + bytes calldata data + ) external returns (bytes4) {} + + function supportsInterface( + bytes4 interfaceId + ) public view override(ERC165Upgradeable, IERC165Upgradeable) returns (bool) { + return interfaceId == type(IERC1155ReceiverUpgradeable).interfaceId || super.supportsInterface(interfaceId); + } + + /*/////////////////////////////////////////////////////////////// + Transfer Staking Rewards + //////////////////////////////////////////////////////////////*/ + + /// @dev Mint/Transfer ERC20 rewards to the staker. + function _mintRewards(address _staker, uint256 _rewards) internal override { + require(_rewards <= rewardTokenBalance, "Not enough reward tokens"); + rewardTokenBalance -= _rewards; + CurrencyTransferLib.transferCurrencyWithWrapper( + rewardToken, + address(this), + _staker, + _rewards, + nativeTokenWrapper + ); + } + + /*/////////////////////////////////////////////////////////////// + Internal functions + //////////////////////////////////////////////////////////////*/ + + /// @dev Returns whether staking related restrictions can be set in the given execution context. + function _canSetStakeConditions() internal view override returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + /// @dev Checks whether contract metadata can be set in the given execution context. + function _canSetContractURI() internal view override returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + /*/////////////////////////////////////////////////////////////// + Miscellaneous + //////////////////////////////////////////////////////////////*/ + + function _stakeMsgSender() internal view virtual override returns (address) { + return _msgSender(); + } + + function _msgSender() + internal + view + virtual + override(ERC2771ContextUpgradeable, Multicall) + returns (address sender) + { + return ERC2771ContextUpgradeable._msgSender(); + } +} diff --git a/contracts/prebuilts/staking/NFTStake.sol b/contracts/prebuilts/staking/NFTStake.sol new file mode 100644 index 000000000..c50794ffc --- /dev/null +++ b/contracts/prebuilts/staking/NFTStake.sol @@ -0,0 +1,201 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/// @author thirdweb + +// $$\ $$\ $$\ $$\ $$\ +// $$ | $$ | \__| $$ | $$ | +// $$$$$$\ $$$$$$$\ $$\ $$$$$$\ $$$$$$$ |$$\ $$\ $$\ $$$$$$\ $$$$$$$\ +// \_$$ _| $$ __$$\ $$ |$$ __$$\ $$ __$$ |$$ | $$ | $$ |$$ __$$\ $$ __$$\ +// $$ | $$ | $$ |$$ |$$ | \__|$$ / $$ |$$ | $$ | $$ |$$$$$$$$ |$$ | $$ | +// $$ |$$\ $$ | $$ |$$ |$$ | $$ | $$ |$$ | $$ | $$ |$$ ____|$$ | $$ | +// \$$$$ |$$ | $$ |$$ |$$ | \$$$$$$$ |\$$$$$\$$$$ |\$$$$$$$\ $$$$$$$ | +// \____/ \__| \__|\__|\__| \_______| \_____\____/ \_______|\_______/ + +// Token +import "../../eip/interface/IERC721.sol"; +import "@openzeppelin/contracts-upgradeable/utils/introspection/ERC165Upgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/token/ERC721/IERC721ReceiverUpgradeable.sol"; + +// Meta transactions +import "../../external-deps/openzeppelin/metatx/ERC2771ContextUpgradeable.sol"; + +// Utils +import "../../extension/Multicall.sol"; +import "../../lib/CurrencyTransferLib.sol"; + +// ========== Features ========== + +import "../../extension/ContractMetadata.sol"; +import "../../extension/PermissionsEnumerable.sol"; +import { Staking721Upgradeable } from "../../extension/Staking721Upgradeable.sol"; +import "../interface/staking/INFTStake.sol"; + +contract NFTStake is + Initializable, + ContractMetadata, + PermissionsEnumerable, + ERC2771ContextUpgradeable, + Multicall, + Staking721Upgradeable, + ERC165Upgradeable, + IERC721ReceiverUpgradeable, + INFTStake +{ + bytes32 private constant MODULE_TYPE = bytes32("NFTStake"); + uint256 private constant VERSION = 1; + + /// @dev The address of the native token wrapper contract. + address internal immutable nativeTokenWrapper; + + /// @dev ERC20 Reward Token address. See {_mintRewards} below. + address public rewardToken; + + /// @dev Total amount of reward tokens in the contract. + uint256 private rewardTokenBalance; + + constructor(address _nativeTokenWrapper) initializer { + nativeTokenWrapper = _nativeTokenWrapper; + } + + /// @dev Initializes the contract, like a constructor. + function initialize( + address _defaultAdmin, + string memory _contractURI, + address[] memory _trustedForwarders, + address _rewardToken, + address _stakingToken, + uint256 _timeUnit, + uint256 _rewardsPerUnitTime + ) external initializer { + __ERC2771Context_init_unchained(_trustedForwarders); + + rewardToken = _rewardToken; + __Staking721_init(_stakingToken); + _setStakingCondition(_timeUnit, _rewardsPerUnitTime); + + _setupContractURI(_contractURI); + _setupRole(DEFAULT_ADMIN_ROLE, _defaultAdmin); + } + + /// @dev Returns the module type of the contract. + function contractType() external pure virtual returns (bytes32) { + return MODULE_TYPE; + } + + /// @dev Returns the version of the contract. + function contractVersion() external pure virtual returns (uint8) { + return uint8(VERSION); + } + + /// @dev Lets the contract receive ether to unwrap native tokens. + receive() external payable { + require(msg.sender == nativeTokenWrapper, "caller not native token wrapper."); + } + + /// @dev Admin deposits reward tokens. + function depositRewardTokens(uint256 _amount) external payable nonReentrant { + require(hasRole(DEFAULT_ADMIN_ROLE, _msgSender()), "Not authorized"); + + address _rewardToken = rewardToken == CurrencyTransferLib.NATIVE_TOKEN ? nativeTokenWrapper : rewardToken; + + uint256 balanceBefore = IERC20(_rewardToken).balanceOf(address(this)); + CurrencyTransferLib.transferCurrencyWithWrapper( + rewardToken, + _msgSender(), + address(this), + _amount, + nativeTokenWrapper + ); + uint256 actualAmount = IERC20(_rewardToken).balanceOf(address(this)) - balanceBefore; + + rewardTokenBalance += actualAmount; + + emit RewardTokensDepositedByAdmin(actualAmount); + } + + /// @dev Admin can withdraw excess reward tokens. + function withdrawRewardTokens(uint256 _amount) external nonReentrant { + require(hasRole(DEFAULT_ADMIN_ROLE, _msgSender()), "Not authorized"); + + // to prevent locking of direct-transferred tokens + rewardTokenBalance = _amount > rewardTokenBalance ? 0 : rewardTokenBalance - _amount; + + CurrencyTransferLib.transferCurrencyWithWrapper( + rewardToken, + address(this), + _msgSender(), + _amount, + nativeTokenWrapper + ); + + emit RewardTokensWithdrawnByAdmin(_amount); + } + + /// @notice View total rewards available in the staking contract. + function getRewardTokenBalance() external view override returns (uint256) { + return rewardTokenBalance; + } + + /*/////////////////////////////////////////////////////////////// + ERC 165 / 721 logic + //////////////////////////////////////////////////////////////*/ + + function onERC721Received(address, address, uint256, bytes calldata) external view override returns (bytes4) { + require(isStaking == 2, "Direct transfer"); + return this.onERC721Received.selector; + } + + function supportsInterface(bytes4 interfaceId) public view override returns (bool) { + return interfaceId == type(IERC721ReceiverUpgradeable).interfaceId || super.supportsInterface(interfaceId); + } + + /*/////////////////////////////////////////////////////////////// + Transfer Staking Rewards + //////////////////////////////////////////////////////////////*/ + + /// @dev Mint/Transfer ERC20 rewards to the staker. + function _mintRewards(address _staker, uint256 _rewards) internal override { + require(_rewards <= rewardTokenBalance, "Not enough reward tokens"); + rewardTokenBalance -= _rewards; + CurrencyTransferLib.transferCurrencyWithWrapper( + rewardToken, + address(this), + _staker, + _rewards, + nativeTokenWrapper + ); + } + + /*/////////////////////////////////////////////////////////////// + Internal functions + //////////////////////////////////////////////////////////////*/ + + /// @dev Returns whether staking related restrictions can be set in the given execution context. + function _canSetStakeConditions() internal view override returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + /// @dev Checks whether contract metadata can be set in the given execution context. + function _canSetContractURI() internal view override returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + /*/////////////////////////////////////////////////////////////// + Miscellaneous + //////////////////////////////////////////////////////////////*/ + + function _stakeMsgSender() internal view virtual override returns (address) { + return _msgSender(); + } + + function _msgSender() + internal + view + virtual + override(ERC2771ContextUpgradeable, Multicall) + returns (address sender) + { + return ERC2771ContextUpgradeable._msgSender(); + } +} diff --git a/contracts/prebuilts/staking/TokenStake.sol b/contracts/prebuilts/staking/TokenStake.sol new file mode 100644 index 000000000..d908db791 --- /dev/null +++ b/contracts/prebuilts/staking/TokenStake.sol @@ -0,0 +1,197 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/// @author thirdweb + +// $$\ $$\ $$\ $$\ $$\ +// $$ | $$ | \__| $$ | $$ | +// $$$$$$\ $$$$$$$\ $$\ $$$$$$\ $$$$$$$ |$$\ $$\ $$\ $$$$$$\ $$$$$$$\ +// \_$$ _| $$ __$$\ $$ |$$ __$$\ $$ __$$ |$$ | $$ | $$ |$$ __$$\ $$ __$$\ +// $$ | $$ | $$ |$$ |$$ | \__|$$ / $$ |$$ | $$ | $$ |$$$$$$$$ |$$ | $$ | +// $$ |$$\ $$ | $$ |$$ |$$ | $$ | $$ |$$ | $$ | $$ |$$ ____|$$ | $$ | +// \$$$$ |$$ | $$ |$$ |$$ | \$$$$$$$ |\$$$$$\$$$$ |\$$$$$$$\ $$$$$$$ | +// \____/ \__| \__|\__|\__| \_______| \_____\____/ \_______|\_______/ + +// Token +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +// Meta transactions +import "../../external-deps/openzeppelin/metatx/ERC2771ContextUpgradeable.sol"; + +// Utils +import "../../extension/Multicall.sol"; +import { CurrencyTransferLib } from "../../lib/CurrencyTransferLib.sol"; +import "../../eip/interface/IERC20Metadata.sol"; + +// ========== Features ========== + +import "../../extension/ContractMetadata.sol"; +import "../../extension/PermissionsEnumerable.sol"; +import { Staking20Upgradeable } from "../../extension/Staking20Upgradeable.sol"; +import "../interface/staking/ITokenStake.sol"; + +contract TokenStake is + Initializable, + ContractMetadata, + PermissionsEnumerable, + ERC2771ContextUpgradeable, + Multicall, + Staking20Upgradeable, + ITokenStake +{ + bytes32 private constant MODULE_TYPE = bytes32("TokenStake"); + uint256 private constant VERSION = 1; + + /// @dev ERC20 Reward Token address. See {_mintRewards} below. + address public rewardToken; + + /// @dev Total amount of reward tokens in the contract. + uint256 private rewardTokenBalance; + + constructor(address _nativeTokenWrapper) initializer Staking20Upgradeable(_nativeTokenWrapper) {} + + /// @dev Initializes the contract, like a constructor. + function initialize( + address _defaultAdmin, + string memory _contractURI, + address[] memory _trustedForwarders, + address _rewardToken, + address _stakingToken, + uint80 _timeUnit, + uint256 _rewardRatioNumerator, + uint256 _rewardRatioDenominator + ) external initializer { + __ERC2771Context_init_unchained(_trustedForwarders); + + require(_rewardToken != _stakingToken, "Reward Token and Staking Token can't be same."); + rewardToken = _rewardToken; + + uint16 _stakingTokenDecimals = _stakingToken == CurrencyTransferLib.NATIVE_TOKEN + ? 18 + : IERC20Metadata(_stakingToken).decimals(); + uint16 _rewardTokenDecimals = _rewardToken == CurrencyTransferLib.NATIVE_TOKEN + ? 18 + : IERC20Metadata(_rewardToken).decimals(); + + __Staking20_init(_stakingToken, _stakingTokenDecimals, _rewardTokenDecimals); + _setStakingCondition(_timeUnit, _rewardRatioNumerator, _rewardRatioDenominator); + + _setupContractURI(_contractURI); + _setupRole(DEFAULT_ADMIN_ROLE, _defaultAdmin); + } + + /// @dev Returns the module type of the contract. + function contractType() external pure virtual returns (bytes32) { + return MODULE_TYPE; + } + + /// @dev Returns the version of the contract. + function contractVersion() external pure virtual returns (uint8) { + return uint8(VERSION); + } + + /// @dev Lets the contract receive ether to unwrap native tokens. + receive() external payable { + require(msg.sender == nativeTokenWrapper, "caller not native token wrapper."); + } + + /// @dev Admin deposits reward tokens. + function depositRewardTokens(uint256 _amount) external payable nonReentrant { + require(hasRole(DEFAULT_ADMIN_ROLE, _msgSender()), "Not authorized"); + + address _rewardToken = rewardToken == CurrencyTransferLib.NATIVE_TOKEN ? nativeTokenWrapper : rewardToken; + + uint256 balanceBefore = IERC20(_rewardToken).balanceOf(address(this)); + CurrencyTransferLib.transferCurrencyWithWrapper( + rewardToken, + _msgSender(), + address(this), + _amount, + nativeTokenWrapper + ); + uint256 actualAmount = IERC20(_rewardToken).balanceOf(address(this)) - balanceBefore; + + rewardTokenBalance += actualAmount; + + emit RewardTokensDepositedByAdmin(actualAmount); + } + + /// @dev Admin can withdraw excess reward tokens. + function withdrawRewardTokens(uint256 _amount) external nonReentrant { + require(hasRole(DEFAULT_ADMIN_ROLE, _msgSender()), "Not authorized"); + + // to prevent locking of direct-transferred tokens + rewardTokenBalance = _amount > rewardTokenBalance ? 0 : rewardTokenBalance - _amount; + + CurrencyTransferLib.transferCurrencyWithWrapper( + rewardToken, + address(this), + _msgSender(), + _amount, + nativeTokenWrapper + ); + + // The withdrawal shouldn't reduce staking token balance. `>=` accounts for any accidental transfers. + address _stakingToken = stakingToken == CurrencyTransferLib.NATIVE_TOKEN ? nativeTokenWrapper : stakingToken; + require( + IERC20(_stakingToken).balanceOf(address(this)) >= stakingTokenBalance, + "Staking token balance reduced." + ); + + emit RewardTokensWithdrawnByAdmin(_amount); + } + + /// @notice View total rewards available in the staking contract. + function getRewardTokenBalance() external view override returns (uint256) { + return rewardTokenBalance; + } + + /*/////////////////////////////////////////////////////////////// + Transfer Staking Rewards + //////////////////////////////////////////////////////////////*/ + + /// @dev Mint/Transfer ERC20 rewards to the staker. + function _mintRewards(address _staker, uint256 _rewards) internal override { + require(_rewards <= rewardTokenBalance, "Not enough reward tokens"); + rewardTokenBalance -= _rewards; + CurrencyTransferLib.transferCurrencyWithWrapper( + rewardToken, + address(this), + _staker, + _rewards, + nativeTokenWrapper + ); + } + + /*/////////////////////////////////////////////////////////////// + Internal functions + //////////////////////////////////////////////////////////////*/ + + /// @dev Returns whether staking related restrictions can be set in the given execution context. + function _canSetStakeConditions() internal view override returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + /// @dev Checks whether contract metadata can be set in the given execution context. + function _canSetContractURI() internal view override returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + /*/////////////////////////////////////////////////////////////// + Miscellaneous + //////////////////////////////////////////////////////////////*/ + + function _stakeMsgSender() internal view virtual override returns (address) { + return _msgSender(); + } + + function _msgSender() + internal + view + virtual + override(ERC2771ContextUpgradeable, Multicall) + returns (address sender) + { + return ERC2771ContextUpgradeable._msgSender(); + } +} diff --git a/contracts/prebuilts/tiered-drop/TieredDrop.sol b/contracts/prebuilts/tiered-drop/TieredDrop.sol new file mode 100644 index 000000000..f52c82b17 --- /dev/null +++ b/contracts/prebuilts/tiered-drop/TieredDrop.sol @@ -0,0 +1,607 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/// @author thirdweb + +// $$\ $$\ $$\ $$\ $$\ +// $$ | $$ | \__| $$ | $$ | +// $$$$$$\ $$$$$$$\ $$\ $$$$$$\ $$$$$$$ |$$\ $$\ $$\ $$$$$$\ $$$$$$$\ +// \_$$ _| $$ __$$\ $$ |$$ __$$\ $$ __$$ |$$ | $$ | $$ |$$ __$$\ $$ __$$\ +// $$ | $$ | $$ |$$ |$$ | \__|$$ / $$ |$$ | $$ | $$ |$$$$$$$$ |$$ | $$ | +// $$ |$$\ $$ | $$ |$$ |$$ | $$ | $$ |$$ | $$ | $$ |$$ ____|$$ | $$ | +// \$$$$ |$$ | $$ |$$ |$$ | \$$$$$$$ |\$$$$$\$$$$ |\$$$$$$$\ $$$$$$$ | +// \____/ \__| \__|\__|\__| \_______| \_____\____/ \_______|\_______/ + +// ========== External imports ========== + +import "../../extension/Multicall.sol"; +import "@openzeppelin/contracts-upgradeable/utils/StringsUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/interfaces/IERC2981Upgradeable.sol"; + +import "erc721a-upgradeable/contracts/ERC721AUpgradeable.sol"; + +// ========== Internal imports ========== + +import "../../external-deps/openzeppelin/metatx/ERC2771ContextUpgradeable.sol"; +import "../../lib/CurrencyTransferLib.sol"; + +// ========== Features ========== + +import "../../extension/ContractMetadata.sol"; +import "../../extension/PlatformFee.sol"; +import "../../extension/Royalty.sol"; +import "../../extension/PrimarySale.sol"; +import "../../extension/Ownable.sol"; +import "../../extension/DelayedReveal.sol"; +import "../../extension/PermissionsEnumerable.sol"; + +// ========== New Features ========== + +import "../../extension/LazyMintWithTier.sol"; +import "../../extension/SignatureActionUpgradeable.sol"; + +contract TieredDrop is + Initializable, + ContractMetadata, + Royalty, + PrimarySale, + Ownable, + DelayedReveal, + LazyMintWithTier, + PermissionsEnumerable, + SignatureActionUpgradeable, + ERC2771ContextUpgradeable, + Multicall, + ERC721AUpgradeable +{ + using StringsUpgradeable for uint256; + + /*/////////////////////////////////////////////////////////////// + State variables + //////////////////////////////////////////////////////////////*/ + + /// @dev Only transfers to or from TRANSFER_ROLE holders are valid, when transfers are restricted. + bytes32 private transferRole; + /// @dev Only MINTER_ROLE holders can sign off on `MintRequest`s and lazy mint tokens. + bytes32 private minterRole; + + /** + * @dev Conceptually, tokens are minted on this contract one-batch-of-a-tier at a time. Each batch is comprised of + * a given range of tokenIds [startId, endId). + * + * This array stores each such endId, in chronological order of minting. + */ + uint256 private lengthEndIdsAtMint; + mapping(uint256 => uint256) private endIdsAtMint; + + /** + * @dev Conceptually, tokens are minted on this contract one-batch-of-a-tier at a time. Each batch is comprised of + * a given range of tokenIds [startId, endId). + * + * This is a mapping from such an `endId` -> the tier that tokenIds [startId, endId) belong to. + * Together with `endIdsAtMint`, this mapping is used to return the tokenIds that belong to a given tier. + */ + mapping(uint256 => string) private tierAtEndId; + + /** + * @dev This contract lets an admin lazy mint batches of metadata at once, for a given tier. E.g. an admin may lazy mint + * the metadata of 5000 tokens that will actually be minted in the future. + * + * Lazy minting of NFT metafata happens from a start metadata ID (inclusive) to an end metadata ID (non-inclusive), + * where the lazy minted metadata lives at `providedBaseURI/${metadataId}` for each unit metadata. + * + * At the time of actual minting, the minter specifies the tier of NFTs they're minting. So, the order in which lazy minted + * metadata for a tier is assigned integer IDs may differ from the actual tokenIds minted for a tier. + * + * This is a mapping from an actually minted end tokenId -> the range of lazy minted metadata that now belongs + * to NFTs of [start tokenId, end tokenid). + */ + mapping(uint256 => TokenRange) private proxyTokenRange; + + /// @dev Mapping from tier -> the metadata ID up till which metadata IDs have been mapped to minted NFTs' tokenIds. + mapping(string => uint256) private nextMetadataIdToMapFromTier; + + /// @dev Mapping from tier -> how many units of lazy minted metadata have not yet been mapped to minted NFTs' tokenIds. + mapping(string => uint256) private totalRemainingInTier; + + /// @dev Mapping from batchId => tokenId offset for that batchId. + mapping(uint256 => bytes32) private tokenIdOffset; + + /// @dev Mapping from hash(tier, "minted") -> total minted in tier. + mapping(bytes32 => uint256) private totalsForTier; + + /*/////////////////////////////////////////////////////////////// + Events + //////////////////////////////////////////////////////////////*/ + + /// @notice Emitted when tokens are claimed via `claimWithSignature`. + event TokensClaimed( + address indexed claimer, + address indexed receiver, + uint256 startTokenId, + uint256 quantityClaimed, + string[] tiersInPriority + ); + + /*/////////////////////////////////////////////////////////////// + Constructor + initializer logic + //////////////////////////////////////////////////////////////*/ + + /// @dev Initializes the contract, like a constructor. + function initialize( + address _defaultAdmin, + string memory _name, + string memory _symbol, + string memory _contractURI, + address[] memory _trustedForwarders, + address _saleRecipient, + address _royaltyRecipient, + uint16 _royaltyBps + ) external initializer { + bytes32 _transferRole = keccak256("TRANSFER_ROLE"); + bytes32 _minterRole = keccak256("MINTER_ROLE"); + + // Initialize inherited contracts, most base-like -> most derived. + __ERC2771Context_init(_trustedForwarders); + __ERC721A_init(_name, _symbol); + __SignatureAction_init(); + + _setupContractURI(_contractURI); + _setupOwner(_defaultAdmin); + + _setupRole(DEFAULT_ADMIN_ROLE, _defaultAdmin); + _setupRole(_minterRole, _defaultAdmin); + _setupRole(_transferRole, _defaultAdmin); + _setupRole(_transferRole, address(0)); + + _setupDefaultRoyaltyInfo(_royaltyRecipient, _royaltyBps); + _setupPrimarySaleRecipient(_saleRecipient); + + transferRole = _transferRole; + minterRole = _minterRole; + } + + /*/////////////////////////////////////////////////////////////// + ERC 165 / 721 / 2981 logic + //////////////////////////////////////////////////////////////*/ + + /// @dev Returns the URI for a given tokenId. + function tokenURI(uint256 _tokenId) public view override returns (string memory) { + // Retrieve metadata ID for token. + uint256 metadataId = _getMetadataId(_tokenId); + + // Use metadata ID to return token metadata. + (uint256 batchId, uint256 index) = _getBatchId(metadataId); + string memory batchUri = _getBaseURI(metadataId); + + if (isEncryptedBatch(batchId)) { + return string(abi.encodePacked(batchUri, "0")); + } else { + uint256 fairMetadataId = _getFairMetadataId(metadataId, batchId, index); + return string(abi.encodePacked(batchUri, fairMetadataId.toString())); + } + } + + /// @dev See ERC 165 + function supportsInterface( + bytes4 interfaceId + ) public view virtual override(ERC721AUpgradeable, IERC165) returns (bool) { + return super.supportsInterface(interfaceId) || type(IERC2981Upgradeable).interfaceId == interfaceId; + } + + /*/////////////////////////////////////////////////////////////// + Lazy minting + delayed-reveal logic + //////////////////////////////////////////////////////////////* + + /** + * @dev Lets an account with `MINTER_ROLE` lazy mint 'n' NFTs. + * The URIs for each token is the provided `_baseURIForTokens` + `{tokenId}`. + */ + function lazyMint( + uint256 _amount, + string calldata _baseURIForTokens, + string calldata _tier, + bytes calldata _data + ) public override returns (uint256 batchId) { + if (_data.length > 0) { + (bytes memory encryptedURI, bytes32 provenanceHash) = abi.decode(_data, (bytes, bytes32)); + if (encryptedURI.length != 0 && provenanceHash != "") { + _setEncryptedData(nextTokenIdToLazyMint + _amount, _data); + } + } + + totalRemainingInTier[_tier] += _amount; + + uint256 startId = nextTokenIdToLazyMint; + if (isTierEmpty(_tier) || nextMetadataIdToMapFromTier[_tier] == type(uint256).max) { + nextMetadataIdToMapFromTier[_tier] = startId; + } + + return super.lazyMint(_amount, _baseURIForTokens, _tier, _data); + } + + /// @dev Lets an account with `MINTER_ROLE` reveal the URI for a batch of 'delayed-reveal' NFTs. + function reveal( + uint256 _index, + bytes calldata _key + ) external onlyRole(minterRole) returns (string memory revealedURI) { + uint256 batchId = getBatchIdAtIndex(_index); + revealedURI = getRevealURI(batchId, _key); + + _setEncryptedData(batchId, ""); + _setBaseURI(batchId, revealedURI); + + _scrambleOffset(batchId, _key); + + emit TokenURIRevealed(_index, revealedURI); + } + + /*/////////////////////////////////////////////////////////////// + Claiming lazy minted tokens logic + //////////////////////////////////////////////////////////////*/ + + /// @dev Claim lazy minted tokens via signature. + function claimWithSignature( + GenericRequest calldata _req, + bytes calldata _signature + ) external payable returns (address signer) { + ( + string[] memory tiersInPriority, + address to, + address royaltyRecipient, + uint256 royaltyBps, + address primarySaleRecipient, + uint256 quantity, + uint256 totalPrice, + address currency + ) = abi.decode(_req.data, (string[], address, address, uint256, address, uint256, uint256, address)); + + if (quantity == 0) { + revert("0 qty"); + } + + uint256 tokenIdToMint = _currentIndex; + if (tokenIdToMint + quantity > nextTokenIdToLazyMint) { + revert("!Tokens"); + } + + // Verify and process payload. + signer = _processRequest(_req, _signature); + + // Collect price + collectPriceOnClaim(primarySaleRecipient, currency, totalPrice); + + // Set royalties, if applicable. + if (royaltyRecipient != address(0) && royaltyBps != 0) { + _setupRoyaltyInfoForToken(tokenIdToMint, royaltyRecipient, royaltyBps); + } + + // Mint tokens. + transferTokensOnClaim(to, quantity, tiersInPriority); + + emit TokensClaimed(_msgSender(), to, tokenIdToMint, quantity, tiersInPriority); + } + + /*/////////////////////////////////////////////////////////////// + Internal functions + //////////////////////////////////////////////////////////////*/ + /// @dev Collects and distributes the primary sale value of NFTs being claimed. + function collectPriceOnClaim(address _primarySaleRecipient, address _currency, uint256 _totalPrice) internal { + if (_totalPrice == 0) { + require(msg.value == 0, "!Value"); + return; + } + + address saleRecipient = _primarySaleRecipient == address(0) ? primarySaleRecipient() : _primarySaleRecipient; + + bool validMsgValue; + if (_currency == CurrencyTransferLib.NATIVE_TOKEN) { + validMsgValue = msg.value == _totalPrice; + } else { + validMsgValue = msg.value == 0; + } + require(validMsgValue, "Invalid msg value"); + + CurrencyTransferLib.transferCurrency(_currency, _msgSender(), saleRecipient, _totalPrice); + } + + /// @dev Transfers the NFTs being claimed. + function transferTokensOnClaim(address _to, uint256 _totalQuantityBeingClaimed, string[] memory _tiers) internal { + uint256 startTokenIdToMint = _currentIndex; + + uint256 startIdToMap = startTokenIdToMint; + uint256 remaningToDistribute = _totalQuantityBeingClaimed; + + for (uint256 i = 0; i < _tiers.length; i += 1) { + string memory tier = _tiers[i]; + + uint256 qtyFulfilled = _getQuantityFulfilledByTier(tier, remaningToDistribute); + + if (qtyFulfilled == 0) { + continue; + } + + remaningToDistribute -= qtyFulfilled; + + _mapTokensToTier(tier, startIdToMap, qtyFulfilled); + + totalRemainingInTier[tier] -= qtyFulfilled; + totalsForTier[keccak256(abi.encodePacked(tier, "minted"))] += qtyFulfilled; + + if (remaningToDistribute > 0) { + startIdToMap += qtyFulfilled; + } else { + break; + } + } + + require(remaningToDistribute == 0, "Insufficient tokens in tiers."); + + _safeMint(_to, _totalQuantityBeingClaimed); + } + + /// @dev Maps lazy minted metadata to NFT tokenIds. + function _mapTokensToTier(string memory _tier, uint256 _startIdToMap, uint256 _quantity) private { + uint256 nextIdFromTier = nextMetadataIdToMapFromTier[_tier]; + uint256 startTokenId = _startIdToMap; + + TokenRange[] memory tokensInTier = tokensInTier[_tier]; + uint256 len = tokensInTier.length; + + uint256 qtyRemaining = _quantity; + + for (uint256 i = 0; i < len; i += 1) { + TokenRange memory range = tokensInTier[i]; + uint256 gap = 0; + + if (range.startIdInclusive <= nextIdFromTier && nextIdFromTier < range.endIdNonInclusive) { + uint256 proxyStartId = nextIdFromTier; + uint256 proxyEndId = proxyStartId + qtyRemaining <= range.endIdNonInclusive + ? proxyStartId + qtyRemaining + : range.endIdNonInclusive; + + gap = proxyEndId - proxyStartId; + + uint256 endTokenId = startTokenId + gap; + + endIdsAtMint[lengthEndIdsAtMint] = endTokenId; + lengthEndIdsAtMint += 1; + + tierAtEndId[endTokenId] = _tier; + proxyTokenRange[endTokenId] = TokenRange(proxyStartId, proxyEndId); + + startTokenId += gap; + qtyRemaining -= gap; + + if (nextIdFromTier + gap < range.endIdNonInclusive) { + nextIdFromTier += gap; + } else if (i < (len - 1)) { + nextIdFromTier = tokensInTier[i + 1].startIdInclusive; + } else { + nextIdFromTier = type(uint256).max; + } + } + + if (qtyRemaining == 0) { + nextMetadataIdToMapFromTier[_tier] = nextIdFromTier; + break; + } + } + } + + /// @dev Returns how much of the total-quantity-to-distribute can come from the given tier. + function _getQuantityFulfilledByTier( + string memory _tier, + uint256 _quantity + ) private view returns (uint256 fulfilled) { + uint256 total = totalRemainingInTier[_tier]; + + if (total >= _quantity) { + fulfilled = _quantity; + } else { + fulfilled = total; + } + } + + /// @dev Returns the tier that the given token is associated with. + function getTierForToken(uint256 _tokenId) external view returns (string memory) { + uint256 len = lengthEndIdsAtMint; + + for (uint256 i = 0; i < len; i += 1) { + uint256 endId = endIdsAtMint[i]; + + if (_tokenId < endId) { + return tierAtEndId[endId]; + } + } + + revert("!Tier"); + } + + /// @dev Returns the max `endIndex` that can be used with getTokensInTier. + function getTokensInTierLen() external view returns (uint256) { + return lengthEndIdsAtMint; + } + + /// @dev Returns all tokenIds that belong to the given tier. + function getTokensInTier( + string memory _tier, + uint256 _startIdx, + uint256 _endIdx + ) external view returns (TokenRange[] memory ranges) { + uint256 len = lengthEndIdsAtMint; + + require(_startIdx < _endIdx && _endIdx <= len, "TieredDrop: invalid indices."); + + uint256 numOfRangesForTier = 0; + bytes32 hashOfTier = keccak256(abi.encodePacked(_tier)); + + for (uint256 i = _startIdx; i < _endIdx; i += 1) { + bytes32 hashOfStoredTier = keccak256(abi.encodePacked(tierAtEndId[endIdsAtMint[i]])); + + if (hashOfStoredTier == hashOfTier) { + numOfRangesForTier += 1; + } + } + + ranges = new TokenRange[](numOfRangesForTier); + uint256 idx = 0; + + for (uint256 i = _startIdx; i < _endIdx; i += 1) { + bytes32 hashOfStoredTier = keccak256(abi.encodePacked(tierAtEndId[endIdsAtMint[i]])); + + if (hashOfStoredTier == hashOfTier) { + uint256 end = endIdsAtMint[i]; + + uint256 start = 0; + if (i > 0) { + start = endIdsAtMint[i - 1]; + } + + ranges[idx] = TokenRange(start, end); + idx += 1; + } + } + } + + /// @dev Returns the metadata ID for the given tokenID. + function _getMetadataId(uint256 _tokenId) private view returns (uint256) { + uint256 len = lengthEndIdsAtMint; + + for (uint256 i = 0; i < len; i += 1) { + if (_tokenId < endIdsAtMint[i]) { + uint256 targetEndId = endIdsAtMint[i]; + uint256 diff = targetEndId - _tokenId; + + TokenRange memory range = proxyTokenRange[targetEndId]; + + return range.endIdNonInclusive - diff; + } + } + + revert("!Metadata-ID"); + } + + /// @dev Returns the fair metadata ID for a given tokenId. + function _getFairMetadataId( + uint256 _metadataId, + uint256 _batchId, + uint256 _indexOfBatchId + ) private view returns (uint256 fairMetadataId) { + bytes32 bytesRandom = tokenIdOffset[_batchId]; + if (bytesRandom == bytes32(0)) { + return _metadataId; + } + + uint256 randomness = uint256(bytesRandom); + uint256 prevBatchId; + if (_indexOfBatchId > 0) { + prevBatchId = getBatchIdAtIndex(_indexOfBatchId - 1); + } + + uint256 batchSize = _batchId - prevBatchId; + uint256 offset = randomness % batchSize; + fairMetadataId = prevBatchId + ((_metadataId + offset) % batchSize); + } + + /// @dev Scrambles tokenId offset for a given batchId. + function _scrambleOffset(uint256 _batchId, bytes calldata _seed) private { + tokenIdOffset[_batchId] = keccak256(abi.encodePacked(_seed, block.timestamp, blockhash(block.number - 1))); + } + + /// @dev Returns whether a given address is authorized to sign mint requests. + function _isAuthorizedSigner(address _signer) internal view override returns (bool) { + return hasRole(minterRole, _signer); + } + + /// @dev Checks whether primary sale recipient can be set in the given execution context. + function _canSetPrimarySaleRecipient() internal view override returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + /// @dev Checks whether owner can be set in the given execution context. + function _canSetOwner() internal view override returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + /// @dev Checks whether royalty info can be set in the given execution context. + function _canSetRoyaltyInfo() internal view override returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + /// @dev Checks whether contract metadata can be set in the given execution context. + function _canSetContractURI() internal view override returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + /// @dev Returns whether lazy minting can be done in the given execution context. + function _canLazyMint() internal view virtual override returns (bool) { + return hasRole(minterRole, _msgSender()); + } + + /*/////////////////////////////////////////////////////////////// + Miscellaneous + //////////////////////////////////////////////////////////////*/ + + /** + * @dev Returns the total amount of tokens minted in the contract. + */ + function totalMinted() external view returns (uint256) { + unchecked { + return _currentIndex - _startTokenId(); + } + } + + /// @dev Returns the total number of tokens minted from the given tier. + function totalMintedInTier(string memory _tier) external view returns (uint256) { + return totalsForTier[keccak256(abi.encodePacked(_tier, "minted"))]; + } + + /// @dev The tokenId of the next NFT that will be minted / lazy minted. + function nextTokenIdToMint() external view returns (uint256) { + return nextTokenIdToLazyMint; + } + + /// @dev Burns `tokenId`. See {ERC721-_burn}. + function burn(uint256 tokenId) external virtual { + // note: ERC721AUpgradeable's `_burn(uint256,bool)` internally checks for token approvals. + _burn(tokenId, true); + } + + /// @dev See {ERC721-_beforeTokenTransfer}. + function _beforeTokenTransfers( + address from, + address to, + uint256 startTokenId, + uint256 quantity + ) internal virtual override { + super._beforeTokenTransfers(from, to, startTokenId, quantity); + + // if transfer is restricted on the contract, we still want to allow burning and minting + if (!hasRole(transferRole, address(0)) && from != address(0) && to != address(0)) { + if (!hasRole(transferRole, from) && !hasRole(transferRole, to)) { + revert("!TRANSFER"); + } + } + } + + function _msgSender() + internal + view + virtual + override(ContextUpgradeable, ERC2771ContextUpgradeable, Multicall) + returns (address sender) + { + return ERC2771ContextUpgradeable._msgSender(); + } + + function _msgData() + internal + view + virtual + override(ContextUpgradeable, ERC2771ContextUpgradeable) + returns (bytes calldata) + { + return ERC2771ContextUpgradeable._msgData(); + } +} diff --git a/contracts/prebuilts/token/TokenERC1155.sol b/contracts/prebuilts/token/TokenERC1155.sol new file mode 100644 index 000000000..8856ebf64 --- /dev/null +++ b/contracts/prebuilts/token/TokenERC1155.sol @@ -0,0 +1,566 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/// @author thirdweb + +// $$\ $$\ $$\ $$\ $$\ +// $$ | $$ | \__| $$ | $$ | +// $$$$$$\ $$$$$$$\ $$\ $$$$$$\ $$$$$$$ |$$\ $$\ $$\ $$$$$$\ $$$$$$$\ +// \_$$ _| $$ __$$\ $$ |$$ __$$\ $$ __$$ |$$ | $$ | $$ |$$ __$$\ $$ __$$\ +// $$ | $$ | $$ |$$ |$$ | \__|$$ / $$ |$$ | $$ | $$ |$$$$$$$$ |$$ | $$ | +// $$ |$$\ $$ | $$ |$$ |$$ | $$ | $$ |$$ | $$ | $$ |$$ ____|$$ | $$ | +// \$$$$ |$$ | $$ |$$ |$$ | \$$$$$$$ |\$$$$$\$$$$ |\$$$$$$$\ $$$$$$$ | +// \____/ \__| \__|\__|\__| \_______| \_____\____/ \_______|\_______/ + +// Interface +import { ITokenERC1155 } from "../interface/token/ITokenERC1155.sol"; + +import "../../infra/interface/IThirdwebContract.sol"; +import "../../extension/interface/IPlatformFee.sol"; +import "../../extension/interface/IPrimarySale.sol"; +import "../../extension/interface/IRoyalty.sol"; +import "../../extension/interface/IOwnable.sol"; + +import "../../extension/NFTMetadata.sol"; + +// Token +import "@openzeppelin/contracts-upgradeable/token/ERC1155/ERC1155Upgradeable.sol"; + +// Signature utils +import "@openzeppelin/contracts-upgradeable/utils/cryptography/ECDSAUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/utils/cryptography/draft-EIP712Upgradeable.sol"; + +// Access Control + security +import "@openzeppelin/contracts-upgradeable/access/AccessControlEnumerableUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/security/ReentrancyGuardUpgradeable.sol"; + +// Utils +import "@openzeppelin/contracts-upgradeable/utils/StringsUpgradeable.sol"; +import "../../extension/Multicall.sol"; +import "../../lib/CurrencyTransferLib.sol"; +import "../../lib/FeeType.sol"; +import "../../external-deps/openzeppelin/metatx/ERC2771ContextUpgradeable.sol"; + +// Helper interfaces +import "@openzeppelin/contracts-upgradeable/interfaces/IERC2981Upgradeable.sol"; + +contract TokenERC1155 is + Initializable, + IThirdwebContract, + IOwnable, + IRoyalty, + IPrimarySale, + IPlatformFee, + EIP712Upgradeable, + ReentrancyGuardUpgradeable, + ERC2771ContextUpgradeable, + Multicall, + AccessControlEnumerableUpgradeable, + ERC1155Upgradeable, + ITokenERC1155, + NFTMetadata +{ + using ECDSAUpgradeable for bytes32; + using StringsUpgradeable for uint256; + + bytes32 private constant MODULE_TYPE = bytes32("TokenERC1155"); + uint256 private constant VERSION = 1; + + // Token name + string public name; + + // Token symbol + string public symbol; + + bytes32 private constant TYPEHASH = + keccak256( + "MintRequest(address to,address royaltyRecipient,uint256 royaltyBps,address primarySaleRecipient,uint256 tokenId,string uri,uint256 quantity,uint256 pricePerToken,address currency,uint128 validityStartTimestamp,uint128 validityEndTimestamp,bytes32 uid)" + ); + + /// @dev Only TRANSFER_ROLE holders can have tokens transferred from or to them, during restricted transfers. + bytes32 private constant TRANSFER_ROLE = keccak256("TRANSFER_ROLE"); + /// @dev Only MINTER_ROLE holders can sign off on `MintRequest`s. + bytes32 private constant MINTER_ROLE = keccak256("MINTER_ROLE"); + /// @dev Only METADATA_ROLE holders can update NFT metadata. + bytes32 private constant METADATA_ROLE = keccak256("METADATA_ROLE"); + + /// @dev Max bps in the thirdweb system + uint256 private constant MAX_BPS = 10_000; + + /// @dev Owner of the contract (purpose: OpenSea compatibility, etc.) + address private _owner; + + /// @dev The next token ID of the NFT to mint. + uint256 public nextTokenIdToMint; + + /// @dev The adress that receives all primary sales value. + address public primarySaleRecipient; + + /// @dev The adress that receives all primary sales value. + address public platformFeeRecipient; + + /// @dev The recipient of who gets the royalty. + address private royaltyRecipient; + + /// @dev The percentage of royalty how much royalty in basis points. + uint128 private royaltyBps; + + /// @dev The % of primary sales collected by the contract as fees. + uint128 private platformFeeBps; + + /// @dev The flat amount collected by the contract as fees on primary sales. + uint256 private flatPlatformFee; + + /// @dev Fee type variants: percentage fee and flat fee + PlatformFeeType private platformFeeType; + + /// @dev Contract level metadata. + string public contractURI; + + /// @dev Mapping from mint request UID => whether the mint request is processed. + mapping(bytes32 => bool) private minted; + + /// @dev Token ID => total circulating supply of tokens with that ID. + mapping(uint256 => uint256) public totalSupply; + + /// @dev Token ID => the address of the recipient of primary sales. + mapping(uint256 => address) public saleRecipientForToken; + + /// @dev Token ID => royalty recipient and bps for token + mapping(uint256 => RoyaltyInfo) private royaltyInfoForToken; + + constructor() initializer {} + + /// @dev Initializes the contract, like a constructor. + function initialize( + address _defaultAdmin, + string memory _name, + string memory _symbol, + string memory _contractURI, + address[] memory _trustedForwarders, + address _primarySaleRecipient, + address _royaltyRecipient, + uint128 _royaltyBps, + uint128 _platformFeeBps, + address _platformFeeRecipient + ) external initializer { + // Initialize inherited contracts, most base-like -> most derived. + __ReentrancyGuard_init(); + __EIP712_init("TokenERC1155", "1"); + __ERC2771Context_init(_trustedForwarders); + __ERC1155_init(""); + + // Initialize this contract's state. + name = _name; + symbol = _symbol; + royaltyRecipient = _royaltyRecipient; + royaltyBps = _royaltyBps; + platformFeeRecipient = _platformFeeRecipient; + primarySaleRecipient = _primarySaleRecipient; + contractURI = _contractURI; + + require(_platformFeeBps <= MAX_BPS, "exceeds MAX_BPS"); + platformFeeBps = _platformFeeBps; + + // Fee type Bps by default + platformFeeType = PlatformFeeType.Bps; + + _owner = _defaultAdmin; + _setupRole(DEFAULT_ADMIN_ROLE, _defaultAdmin); + _setupRole(MINTER_ROLE, _defaultAdmin); + _setupRole(TRANSFER_ROLE, _defaultAdmin); + _setupRole(TRANSFER_ROLE, address(0)); + + _setupRole(METADATA_ROLE, _defaultAdmin); + _setRoleAdmin(METADATA_ROLE, METADATA_ROLE); + + emit PrimarySaleRecipientUpdated(_primarySaleRecipient); + emit PlatformFeeInfoUpdated(_platformFeeRecipient, _platformFeeBps); + emit DefaultRoyalty(_royaltyRecipient, _royaltyBps); + } + + /// ===== Public functions ===== + + /// @dev Returns the module type of the contract. + function contractType() external pure returns (bytes32) { + return MODULE_TYPE; + } + + /// @dev Returns the version of the contract. + function contractVersion() external pure returns (uint8) { + return uint8(VERSION); + } + + /** + * @dev Returns the address of the current owner. + */ + function owner() public view returns (address) { + return hasRole(DEFAULT_ADMIN_ROLE, _owner) ? _owner : address(0); + } + + /// @dev Verifies that a mint request is signed by an account holding MINTER_ROLE (at the time of the function call). + function verify(MintRequest calldata _req, bytes calldata _signature) public view returns (bool, address) { + address signer = recoverAddress(_req, _signature); + return (!minted[_req.uid] && hasRole(MINTER_ROLE, signer), signer); + } + + /// @dev Returns the URI for a tokenId + function uri(uint256 _tokenId) public view override returns (string memory) { + return _tokenURI[_tokenId]; + } + + /// @dev Lets an account with MINTER_ROLE mint an NFT. + function mintTo( + address _to, + uint256 _tokenId, + string calldata _uri, + uint256 _amount + ) external nonReentrant onlyRole(MINTER_ROLE) { + uint256 tokenIdToMint; + if (_tokenId == type(uint256).max) { + tokenIdToMint = nextTokenIdToMint; + nextTokenIdToMint += 1; + } else { + require(_tokenId < nextTokenIdToMint, "invalid id"); + tokenIdToMint = _tokenId; + } + + // `_mintTo` is re-used. `mintTo` just adds a minter role check. + _mintTo(_to, _uri, tokenIdToMint, _amount); + } + + /// ===== External functions ===== + + /// @dev See EIP-2981 + function royaltyInfo( + uint256 tokenId, + uint256 salePrice + ) external view virtual returns (address receiver, uint256 royaltyAmount) { + (address recipient, uint256 bps) = getRoyaltyInfoForToken(tokenId); + receiver = recipient; + royaltyAmount = (salePrice * bps) / MAX_BPS; + } + + /// @dev Mints an NFT according to the provided mint request. + function mintWithSignature(MintRequest calldata _req, bytes calldata _signature) external payable nonReentrant { + address signer = verifyRequest(_req, _signature); + address receiver = _req.to; + + uint256 tokenIdToMint; + if (_req.tokenId == type(uint256).max) { + tokenIdToMint = nextTokenIdToMint; + nextTokenIdToMint += 1; + } else { + require(_req.tokenId < nextTokenIdToMint, "invalid id"); + tokenIdToMint = _req.tokenId; + } + + if (_req.royaltyRecipient != address(0)) { + royaltyInfoForToken[tokenIdToMint] = RoyaltyInfo({ + recipient: _req.royaltyRecipient, + bps: _req.royaltyBps + }); + } + + _mintTo(receiver, _req.uri, tokenIdToMint, _req.quantity); + + collectPrice(_req); + + emit TokensMintedWithSignature(signer, receiver, tokenIdToMint, _req); + } + + // ===== Setter functions ===== + + /// @dev Lets a module admin set the default recipient of all primary sales. + function setPrimarySaleRecipient(address _saleRecipient) external onlyRole(DEFAULT_ADMIN_ROLE) { + primarySaleRecipient = _saleRecipient; + emit PrimarySaleRecipientUpdated(_saleRecipient); + } + + /// @dev Lets a module admin update the royalty bps and recipient. + function setDefaultRoyaltyInfo( + address _royaltyRecipient, + uint256 _royaltyBps + ) external onlyRole(DEFAULT_ADMIN_ROLE) { + require(_royaltyBps <= MAX_BPS, "exceed royalty bps"); + + royaltyRecipient = _royaltyRecipient; + royaltyBps = uint128(_royaltyBps); + + emit DefaultRoyalty(_royaltyRecipient, _royaltyBps); + } + + /// @dev Lets a module admin set the royalty recipient for a particular token Id. + function setRoyaltyInfoForToken( + uint256 _tokenId, + address _recipient, + uint256 _bps + ) external onlyRole(DEFAULT_ADMIN_ROLE) { + require(_bps <= MAX_BPS, "exceed royalty bps"); + + royaltyInfoForToken[_tokenId] = RoyaltyInfo({ recipient: _recipient, bps: _bps }); + + emit RoyaltyForToken(_tokenId, _recipient, _bps); + } + + /// @dev Lets a module admin update the fees on primary sales. + function setPlatformFeeInfo( + address _platformFeeRecipient, + uint256 _platformFeeBps + ) external onlyRole(DEFAULT_ADMIN_ROLE) { + require(_platformFeeBps <= MAX_BPS, "exceeds MAX_BPS"); + + platformFeeBps = uint64(_platformFeeBps); + platformFeeRecipient = _platformFeeRecipient; + + emit PlatformFeeInfoUpdated(_platformFeeRecipient, _platformFeeBps); + } + + /// @dev Lets a module admin set a flat fee on primary sales. + function setFlatPlatformFeeInfo( + address _platformFeeRecipient, + uint256 _flatFee + ) external onlyRole(DEFAULT_ADMIN_ROLE) { + flatPlatformFee = _flatFee; + platformFeeRecipient = _platformFeeRecipient; + + emit FlatPlatformFeeUpdated(_platformFeeRecipient, _flatFee); + } + + /// @dev Lets a module admin set a flat fee on primary sales. + function setPlatformFeeType(PlatformFeeType _feeType) external onlyRole(DEFAULT_ADMIN_ROLE) { + platformFeeType = _feeType; + + emit PlatformFeeTypeUpdated(_feeType); + } + + /// @dev Lets a module admin set a new owner for the contract. The new owner must be a module admin. + function setOwner(address _newOwner) external onlyRole(DEFAULT_ADMIN_ROLE) { + require(hasRole(DEFAULT_ADMIN_ROLE, _newOwner), "new owner not module admin."); + address _prevOwner = _owner; + _owner = _newOwner; + + emit OwnerUpdated(_prevOwner, _newOwner); + } + + /// @dev Lets a module admin set the URI for contract-level metadata. + function setContractURI(string calldata _uri) external onlyRole(DEFAULT_ADMIN_ROLE) { + contractURI = _uri; + } + + /// ===== Getter functions ===== + + /// @dev Returns the platform fee bps and recipient. + function getPlatformFeeInfo() external view returns (address, uint16) { + return (platformFeeRecipient, uint16(platformFeeBps)); + } + + /// @dev Returns the flat platform fee and recipient. + function getFlatPlatformFeeInfo() external view returns (address, uint256) { + return (platformFeeRecipient, flatPlatformFee); + } + + /// @dev Returns the platform fee type. + function getPlatformFeeType() external view returns (PlatformFeeType) { + return platformFeeType; + } + + /// @dev Returns default royalty info. + function getDefaultRoyaltyInfo() external view returns (address, uint16) { + return (royaltyRecipient, uint16(royaltyBps)); + } + + /// @dev Returns the royalty recipient for a particular token Id. + function getRoyaltyInfoForToken(uint256 _tokenId) public view returns (address, uint16) { + RoyaltyInfo memory royaltyForToken = royaltyInfoForToken[_tokenId]; + + return + royaltyForToken.recipient == address(0) + ? (royaltyRecipient, uint16(royaltyBps)) + : (royaltyForToken.recipient, uint16(royaltyForToken.bps)); + } + + /// ===== Internal functions ===== + + /// @dev Mints an NFT to `to` + function _mintTo(address _to, string calldata _uri, uint256 _tokenId, uint256 _amount) internal { + if (bytes(_tokenURI[_tokenId]).length == 0) { + _setTokenURI(_tokenId, _uri); + } + + _mint(_to, _tokenId, _amount, ""); + + emit TokensMinted(_to, _tokenId, _tokenURI[_tokenId], _amount); + } + + /// @dev Returns the address of the signer of the mint request. + function recoverAddress(MintRequest calldata _req, bytes calldata _signature) internal view returns (address) { + return _hashTypedDataV4(keccak256(_encodeRequest(_req))).recover(_signature); + } + + /// @dev Resolves 'stack too deep' error in `recoverAddress`. + function _encodeRequest(MintRequest calldata _req) internal pure returns (bytes memory) { + return + bytes.concat( + abi.encode( + TYPEHASH, + _req.to, + _req.royaltyRecipient, + _req.royaltyBps, + _req.primarySaleRecipient, + _req.tokenId, + keccak256(bytes(_req.uri)) + ), + abi.encode( + _req.quantity, + _req.pricePerToken, + _req.currency, + _req.validityStartTimestamp, + _req.validityEndTimestamp, + _req.uid + ) + ); + } + + /// @dev Verifies that a mint request is valid. + function verifyRequest(MintRequest calldata _req, bytes calldata _signature) internal returns (address) { + (bool success, address signer) = verify(_req, _signature); + require(success, "invalid signature"); + + require( + _req.validityStartTimestamp <= block.timestamp && _req.validityEndTimestamp >= block.timestamp, + "request expired" + ); + require(_req.to != address(0), "recipient undefined"); + require(_req.quantity > 0, "zero quantity"); + + minted[_req.uid] = true; + + return signer; + } + + /// @dev Collects and distributes the primary sale value of tokens being claimed. + function collectPrice(MintRequest calldata _req) internal { + if (_req.pricePerToken == 0) { + require(msg.value == 0, "!Value"); + return; + } + + uint256 totalPrice = _req.pricePerToken * _req.quantity; + uint256 platformFees = platformFeeType == PlatformFeeType.Flat + ? flatPlatformFee + : ((totalPrice * platformFeeBps) / MAX_BPS); + require(totalPrice >= platformFees, "price less than platform fee"); + + if (_req.currency == CurrencyTransferLib.NATIVE_TOKEN) { + require(msg.value == totalPrice, "must send total price."); + } else { + require(msg.value == 0, "msg value not zero"); + } + + address saleRecipient = _req.primarySaleRecipient == address(0) + ? primarySaleRecipient + : _req.primarySaleRecipient; + + CurrencyTransferLib.transferCurrency(_req.currency, _msgSender(), platformFeeRecipient, platformFees); + CurrencyTransferLib.transferCurrency(_req.currency, _msgSender(), saleRecipient, totalPrice - platformFees); + } + + /// ===== Low-level overrides ===== + + /// @dev Lets a token owner burn the tokens they own (i.e. destroy for good) + function burn(address account, uint256 id, uint256 value) public virtual { + require( + account == _msgSender() || isApprovedForAll(account, _msgSender()), + "ERC1155: caller is not owner nor approved." + ); + + _burn(account, id, value); + } + + /// @dev Lets a token owner burn multiple tokens they own at once (i.e. destroy for good) + function burnBatch(address account, uint256[] memory ids, uint256[] memory values) public virtual { + require( + account == _msgSender() || isApprovedForAll(account, _msgSender()), + "ERC1155: caller is not owner nor approved." + ); + + _burnBatch(account, ids, values); + } + + /** + * @dev See {ERC1155-_beforeTokenTransfer}. + */ + function _beforeTokenTransfer( + address operator, + address from, + address to, + uint256[] memory ids, + uint256[] memory amounts, + bytes memory data + ) internal virtual override { + super._beforeTokenTransfer(operator, from, to, ids, amounts, data); + + // if transfer is restricted on the contract, we still want to allow burning and minting + if (!hasRole(TRANSFER_ROLE, address(0)) && from != address(0) && to != address(0)) { + require(hasRole(TRANSFER_ROLE, from) || hasRole(TRANSFER_ROLE, to), "restricted to TRANSFER_ROLE holders."); + } + + if (from == address(0)) { + for (uint256 i = 0; i < ids.length; ++i) { + totalSupply[ids[i]] += amounts[i]; + } + } + + if (to == address(0)) { + for (uint256 i = 0; i < ids.length; ++i) { + totalSupply[ids[i]] -= amounts[i]; + } + } + } + + function supportsInterface( + bytes4 interfaceId + ) + public + view + virtual + override(AccessControlEnumerableUpgradeable, ERC1155Upgradeable, IERC165Upgradeable, IERC165) + returns (bool) + { + return + super.supportsInterface(interfaceId) || + interfaceId == type(IERC1155Upgradeable).interfaceId || + interfaceId == type(IERC2981Upgradeable).interfaceId; + } + + /// @dev Returns whether metadata can be set in the given execution context. + function _canSetMetadata() internal view virtual override returns (bool) { + return hasRole(METADATA_ROLE, _msgSender()); + } + + /// @dev Returns whether metadata can be frozen in the given execution context. + function _canFreezeMetadata() internal view virtual override returns (bool) { + return hasRole(METADATA_ROLE, _msgSender()); + } + + function _msgSender() + internal + view + virtual + override(ContextUpgradeable, ERC2771ContextUpgradeable, Multicall) + returns (address sender) + { + return ERC2771ContextUpgradeable._msgSender(); + } + + function _msgData() + internal + view + virtual + override(ContextUpgradeable, ERC2771ContextUpgradeable) + returns (bytes calldata) + { + return ERC2771ContextUpgradeable._msgData(); + } +} diff --git a/contracts/prebuilts/token/TokenERC20.sol b/contracts/prebuilts/token/TokenERC20.sol new file mode 100644 index 000000000..79f747378 --- /dev/null +++ b/contracts/prebuilts/token/TokenERC20.sol @@ -0,0 +1,302 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/// @author thirdweb + +// $$\ $$\ $$\ $$\ $$\ +// $$ | $$ | \__| $$ | $$ | +// $$$$$$\ $$$$$$$\ $$\ $$$$$$\ $$$$$$$ |$$\ $$\ $$\ $$$$$$\ $$$$$$$\ +// \_$$ _| $$ __$$\ $$ |$$ __$$\ $$ __$$ |$$ | $$ | $$ |$$ __$$\ $$ __$$\ +// $$ | $$ | $$ |$$ |$$ | \__|$$ / $$ |$$ | $$ | $$ |$$$$$$$$ |$$ | $$ | +// $$ |$$\ $$ | $$ |$$ |$$ | $$ | $$ |$$ | $$ | $$ |$$ ____|$$ | $$ | +// \$$$$ |$$ | $$ |$$ |$$ | \$$$$$$$ |\$$$$$\$$$$ |\$$$$$$$\ $$$$$$$ | +// \____/ \__| \__|\__|\__| \_______| \_____\____/ \_______|\_______/ + +//Interface +import { ITokenERC20 } from "../interface/token/ITokenERC20.sol"; + +import "../../infra/interface/IThirdwebContract.sol"; +import "../../extension/interface/IPlatformFee.sol"; +import "../../extension/interface/IPrimarySale.sol"; + +// Token +import "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20BurnableUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20VotesUpgradeable.sol"; + +// Security +import "@openzeppelin/contracts-upgradeable/access/AccessControlEnumerableUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/security/ReentrancyGuardUpgradeable.sol"; + +// Signature utils +import "@openzeppelin/contracts-upgradeable/utils/cryptography/ECDSAUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/utils/cryptography/draft-EIP712Upgradeable.sol"; + +// Meta transactions +import "../../external-deps/openzeppelin/metatx/ERC2771ContextUpgradeable.sol"; + +// Utils +import "../../extension/Multicall.sol"; +import "../../lib/CurrencyTransferLib.sol"; +import "../../lib/FeeType.sol"; + +contract TokenERC20 is + Initializable, + IThirdwebContract, + IPrimarySale, + IPlatformFee, + ReentrancyGuardUpgradeable, + ERC2771ContextUpgradeable, + Multicall, + ERC20BurnableUpgradeable, + ERC20VotesUpgradeable, + ITokenERC20, + AccessControlEnumerableUpgradeable +{ + using ECDSAUpgradeable for bytes32; + + bytes32 private constant MODULE_TYPE = bytes32("TokenERC20"); + uint256 private constant VERSION = 1; + + bytes32 private constant TYPEHASH = + keccak256( + "MintRequest(address to,address primarySaleRecipient,uint256 quantity,uint256 price,address currency,uint128 validityStartTimestamp,uint128 validityEndTimestamp,bytes32 uid)" + ); + + bytes32 internal constant MINTER_ROLE = keccak256("MINTER_ROLE"); + bytes32 internal constant TRANSFER_ROLE = keccak256("TRANSFER_ROLE"); + + /// @dev Returns the URI for the storefront-level metadata of the contract. + string public contractURI; + + /// @dev Max bps in the thirdweb system + uint128 internal constant MAX_BPS = 10_000; + + /// @dev The % of primary sales collected by the contract as fees. + uint128 private platformFeeBps; + + /// @dev The adress that receives all primary sales value. + address internal platformFeeRecipient; + + /// @dev The adress that receives all primary sales value. + address public primarySaleRecipient; + + /// @dev Mapping from mint request UID => whether the mint request is processed. + mapping(bytes32 => bool) private minted; + + constructor() initializer {} + + /// @dev Initializes the contract, like a constructor. + function initialize( + address _defaultAdmin, + string memory _name, + string memory _symbol, + string memory _contractURI, + address[] memory _trustedForwarders, + address _primarySaleRecipient, + address _platformFeeRecipient, + uint256 _platformFeeBps + ) external initializer { + __ReentrancyGuard_init(); + __ERC2771Context_init_unchained(_trustedForwarders); + __ERC20Permit_init(_name); + __ERC20_init_unchained(_name, _symbol); + + contractURI = _contractURI; + primarySaleRecipient = _primarySaleRecipient; + platformFeeRecipient = _platformFeeRecipient; + + require(_platformFeeBps <= MAX_BPS, "exceeds MAX_BPS"); + platformFeeBps = uint128(_platformFeeBps); + + _setupRole(DEFAULT_ADMIN_ROLE, _defaultAdmin); + _setupRole(TRANSFER_ROLE, _defaultAdmin); + _setupRole(MINTER_ROLE, _defaultAdmin); + _setupRole(TRANSFER_ROLE, address(0)); + + emit PrimarySaleRecipientUpdated(_primarySaleRecipient); + emit PlatformFeeInfoUpdated(_platformFeeRecipient, _platformFeeBps); + } + + /// @dev Returns the module type of the contract. + function contractType() external pure virtual returns (bytes32) { + return MODULE_TYPE; + } + + /// @dev Returns the version of the contract. + function contractVersion() external pure virtual returns (uint8) { + return uint8(VERSION); + } + + function _afterTokenTransfer( + address from, + address to, + uint256 amount + ) internal virtual override(ERC20Upgradeable, ERC20VotesUpgradeable) { + super._afterTokenTransfer(from, to, amount); + } + + /// @dev Runs on every transfer. + function _beforeTokenTransfer(address from, address to, uint256 amount) internal override { + super._beforeTokenTransfer(from, to, amount); + + if (!hasRole(TRANSFER_ROLE, address(0)) && from != address(0) && to != address(0)) { + require(hasRole(TRANSFER_ROLE, from) || hasRole(TRANSFER_ROLE, to), "transfers restricted."); + } + } + + function _mint(address account, uint256 amount) internal virtual override(ERC20Upgradeable, ERC20VotesUpgradeable) { + super._mint(account, amount); + } + + function _burn(address account, uint256 amount) internal virtual override(ERC20Upgradeable, ERC20VotesUpgradeable) { + super._burn(account, amount); + } + + /** + * @dev Creates `amount` new tokens for `to`. + * + * See {ERC20-_mint}. + * + * Requirements: + * + * - the caller must have the `MINTER_ROLE`. + */ + function mintTo(address to, uint256 amount) public virtual nonReentrant { + require(hasRole(MINTER_ROLE, _msgSender()), "not minter."); + _mintTo(to, amount); + } + + /// @dev Verifies that a mint request is signed by an account holding MINTER_ROLE (at the time of the function call). + function verify(MintRequest calldata _req, bytes calldata _signature) public view returns (bool, address) { + address signer = recoverAddress(_req, _signature); + return (!minted[_req.uid] && hasRole(MINTER_ROLE, signer), signer); + } + + /// @dev Mints tokens according to the provided mint request. + function mintWithSignature(MintRequest calldata _req, bytes calldata _signature) external payable nonReentrant { + address signer = verifyRequest(_req, _signature); + address receiver = _req.to; + + collectPrice(_req); + + _mintTo(receiver, _req.quantity); + + emit TokensMintedWithSignature(signer, receiver, _req); + } + + /// @dev Lets a module admin set the default recipient of all primary sales. + function setPrimarySaleRecipient(address _saleRecipient) external onlyRole(DEFAULT_ADMIN_ROLE) { + primarySaleRecipient = _saleRecipient; + emit PrimarySaleRecipientUpdated(_saleRecipient); + } + + /// @dev Lets a module admin update the fees on primary sales. + function setPlatformFeeInfo( + address _platformFeeRecipient, + uint256 _platformFeeBps + ) external onlyRole(DEFAULT_ADMIN_ROLE) { + require(_platformFeeBps <= MAX_BPS, "exceeds MAX_BPS"); + + platformFeeBps = uint64(_platformFeeBps); + platformFeeRecipient = _platformFeeRecipient; + + emit PlatformFeeInfoUpdated(_platformFeeRecipient, _platformFeeBps); + } + + /// @dev Returns the platform fee bps and recipient. + function getPlatformFeeInfo() external view returns (address, uint16) { + return (platformFeeRecipient, uint16(platformFeeBps)); + } + + /// @dev Collects and distributes the primary sale value of tokens being claimed. + function collectPrice(MintRequest calldata _req) internal { + if (_req.price == 0) { + require(msg.value == 0, "!Value"); + return; + } + + uint256 platformFees = (_req.price * platformFeeBps) / MAX_BPS; + + if (_req.currency == CurrencyTransferLib.NATIVE_TOKEN) { + require(msg.value == _req.price, "must send total price."); + } else { + require(msg.value == 0, "msg value not zero"); + } + + address saleRecipient = _req.primarySaleRecipient == address(0) + ? primarySaleRecipient + : _req.primarySaleRecipient; + + CurrencyTransferLib.transferCurrency(_req.currency, _msgSender(), platformFeeRecipient, platformFees); + CurrencyTransferLib.transferCurrency(_req.currency, _msgSender(), saleRecipient, _req.price - platformFees); + } + + /// @dev Mints `amount` of tokens to `to` + function _mintTo(address _to, uint256 _amount) internal { + _mint(_to, _amount); + emit TokensMinted(_to, _amount); + } + + /// @dev Verifies that a mint request is valid. + function verifyRequest(MintRequest calldata _req, bytes calldata _signature) internal returns (address) { + (bool success, address signer) = verify(_req, _signature); + require(success, "invalid signature"); + + require( + _req.validityStartTimestamp <= block.timestamp && _req.validityEndTimestamp >= block.timestamp, + "request expired" + ); + require(_req.to != address(0), "recipient undefined"); + require(_req.quantity > 0, "zero quantity"); + + minted[_req.uid] = true; + + return signer; + } + + /// @dev Returns the address of the signer of the mint request. + function recoverAddress(MintRequest calldata _req, bytes calldata _signature) internal view returns (address) { + return _hashTypedDataV4(keccak256(_encodeRequest(_req))).recover(_signature); + } + + /// @dev Resolves 'stack too deep' error in `recoverAddress`. + function _encodeRequest(MintRequest calldata _req) internal pure returns (bytes memory) { + return + abi.encode( + TYPEHASH, + _req.to, + _req.primarySaleRecipient, + _req.quantity, + _req.price, + _req.currency, + _req.validityStartTimestamp, + _req.validityEndTimestamp, + _req.uid + ); + } + + /// @dev Sets contract URI for the storefront-level metadata of the contract. + function setContractURI(string calldata _uri) external onlyRole(DEFAULT_ADMIN_ROLE) { + contractURI = _uri; + } + + function _msgSender() + internal + view + virtual + override(ContextUpgradeable, ERC2771ContextUpgradeable, Multicall) + returns (address sender) + { + return ERC2771ContextUpgradeable._msgSender(); + } + + function _msgData() + internal + view + virtual + override(ContextUpgradeable, ERC2771ContextUpgradeable) + returns (bytes calldata) + { + return ERC2771ContextUpgradeable._msgData(); + } +} diff --git a/contracts/prebuilts/token/TokenERC721.sol b/contracts/prebuilts/token/TokenERC721.sol new file mode 100644 index 000000000..f8011785e --- /dev/null +++ b/contracts/prebuilts/token/TokenERC721.sol @@ -0,0 +1,459 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/// @author thirdweb + +// $$\ $$\ $$\ $$\ $$\ +// $$ | $$ | \__| $$ | $$ | +// $$$$$$\ $$$$$$$\ $$\ $$$$$$\ $$$$$$$ |$$\ $$\ $$\ $$$$$$\ $$$$$$$\ +// \_$$ _| $$ __$$\ $$ |$$ __$$\ $$ __$$ |$$ | $$ | $$ |$$ __$$\ $$ __$$\ +// $$ | $$ | $$ |$$ |$$ | \__|$$ / $$ |$$ | $$ | $$ |$$$$$$$$ |$$ | $$ | +// $$ |$$\ $$ | $$ |$$ |$$ | $$ | $$ |$$ | $$ | $$ |$$ ____|$$ | $$ | +// \$$$$ |$$ | $$ |$$ |$$ | \$$$$$$$ |\$$$$$\$$$$ |\$$$$$$$\ $$$$$$$ | +// \____/ \__| \__|\__|\__| \_______| \_____\____/ \_______|\_______/ + +// Interface +import { ITokenERC721 } from "../interface/token/ITokenERC721.sol"; + +import "../../infra/interface/IThirdwebContract.sol"; +import "../../extension/interface/IPlatformFee.sol"; +import "../../extension/interface/IPrimarySale.sol"; +import "../../extension/interface/IRoyalty.sol"; +import "../../extension/interface/IOwnable.sol"; + +//Extensions +import "../../extension/NFTMetadata.sol"; + +// Token +import "@openzeppelin/contracts-upgradeable/token/ERC721/extensions/ERC721EnumerableUpgradeable.sol"; + +// Signature utils +import "@openzeppelin/contracts-upgradeable/utils/cryptography/ECDSAUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/utils/cryptography/draft-EIP712Upgradeable.sol"; + +// Access Control + security +import "@openzeppelin/contracts-upgradeable/access/AccessControlEnumerableUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/security/ReentrancyGuardUpgradeable.sol"; + +// Meta transactions +import "../../external-deps/openzeppelin/metatx/ERC2771ContextUpgradeable.sol"; + +// Utils +import "@openzeppelin/contracts-upgradeable/utils/StringsUpgradeable.sol"; +import "../../extension/Multicall.sol"; +import "../../lib/CurrencyTransferLib.sol"; +import "../../lib/FeeType.sol"; + +// Helper interfaces +import "@openzeppelin/contracts-upgradeable/interfaces/IERC2981Upgradeable.sol"; + +contract TokenERC721 is + Initializable, + IThirdwebContract, + IOwnable, + IRoyalty, + IPrimarySale, + IPlatformFee, + ReentrancyGuardUpgradeable, + EIP712Upgradeable, + ERC2771ContextUpgradeable, + Multicall, + AccessControlEnumerableUpgradeable, + ERC721EnumerableUpgradeable, + ITokenERC721, + NFTMetadata +{ + using ECDSAUpgradeable for bytes32; + using StringsUpgradeable for uint256; + + bytes32 private constant MODULE_TYPE = bytes32("TokenERC721"); + uint256 private constant VERSION = 1; + + bytes32 private constant TYPEHASH = + keccak256( + "MintRequest(address to,address royaltyRecipient,uint256 royaltyBps,address primarySaleRecipient,string uri,uint256 price,address currency,uint128 validityStartTimestamp,uint128 validityEndTimestamp,bytes32 uid)" + ); + + /// @dev Only TRANSFER_ROLE holders can have tokens transferred from or to them, during restricted transfers. + bytes32 private constant TRANSFER_ROLE = keccak256("TRANSFER_ROLE"); + /// @dev Only MINTER_ROLE holders can sign off on `MintRequest`s. + bytes32 private constant MINTER_ROLE = keccak256("MINTER_ROLE"); + /// @dev Only METADATA_ROLE holders can update NFT metadata. + bytes32 private constant METADATA_ROLE = keccak256("METADATA_ROLE"); + + /// @dev Max bps in the thirdweb system + uint256 private constant MAX_BPS = 10_000; + + /// @dev Owner of the contract (purpose: OpenSea compatibility, etc.) + address private _owner; + + /// @dev The token ID of the next token to mint. + uint256 public nextTokenIdToMint; + + /// @dev The adress that receives all primary sales value. + address public primarySaleRecipient; + + /// @dev The adress that receives all primary sales value. + address public platformFeeRecipient; + + /// @dev The recipient of who gets the royalty. + address private royaltyRecipient; + + /// @dev The percentage of royalty how much royalty in basis points. + uint128 private royaltyBps; + + /// @dev The % of primary sales collected by the contract as fees. + uint128 private platformFeeBps; + + /// @dev Contract level metadata. + string public contractURI; + + /// @dev Mapping from mint request UID => whether the mint request is processed. + mapping(bytes32 => bool) private minted; + + /// @dev Token ID => royalty recipient and bps for token + mapping(uint256 => RoyaltyInfo) private royaltyInfoForToken; + + constructor() initializer {} + + /// @dev Initializes the contract, like a constructor. + function initialize( + address _defaultAdmin, + string memory _name, + string memory _symbol, + string memory _contractURI, + address[] memory _trustedForwarders, + address _saleRecipient, + address _royaltyRecipient, + uint128 _royaltyBps, + uint128 _platformFeeBps, + address _platformFeeRecipient + ) external initializer { + // Initialize inherited contracts, most base-like -> most derived. + __ReentrancyGuard_init(); + __EIP712_init("TokenERC721", "1"); + __ERC2771Context_init(_trustedForwarders); + __ERC721_init(_name, _symbol); + + // Initialize this contract's state. + royaltyRecipient = _royaltyRecipient; + royaltyBps = _royaltyBps; + platformFeeRecipient = _platformFeeRecipient; + primarySaleRecipient = _saleRecipient; + contractURI = _contractURI; + + require(_platformFeeBps <= MAX_BPS, "exceeds MAX_BPS"); + platformFeeBps = _platformFeeBps; + + _owner = _defaultAdmin; + _setupRole(DEFAULT_ADMIN_ROLE, _defaultAdmin); + _setupRole(MINTER_ROLE, _defaultAdmin); + + _setupRole(METADATA_ROLE, _defaultAdmin); + _setRoleAdmin(METADATA_ROLE, METADATA_ROLE); + + _setupRole(TRANSFER_ROLE, _defaultAdmin); + _setupRole(TRANSFER_ROLE, address(0)); + + emit PrimarySaleRecipientUpdated(_saleRecipient); + emit PlatformFeeInfoUpdated(_platformFeeRecipient, _platformFeeBps); + emit DefaultRoyalty(_royaltyRecipient, _royaltyBps); + } + + /// ===== Public functions ===== + + /// @dev Returns the module type of the contract. + function contractType() external pure returns (bytes32) { + return MODULE_TYPE; + } + + /// @dev Returns the version of the contract. + function contractVersion() external pure returns (uint8) { + return uint8(VERSION); + } + + /** + * @dev Returns the address of the current owner. + */ + function owner() public view returns (address) { + return hasRole(DEFAULT_ADMIN_ROLE, _owner) ? _owner : address(0); + } + + /// @dev Verifies that a mint request is signed by an account holding MINTER_ROLE (at the time of the function call). + function verify(MintRequest calldata _req, bytes calldata _signature) public view returns (bool, address) { + address signer = recoverAddress(_req, _signature); + return (!minted[_req.uid] && hasRole(MINTER_ROLE, signer), signer); + } + + /// @dev Returns the URI for a tokenId + function tokenURI(uint256 _tokenId) public view override returns (string memory) { + return _tokenURI[_tokenId]; + } + + /// @dev Lets an account with MINTER_ROLE mint an NFT. + function mintTo(address _to, string calldata _uri) external nonReentrant onlyRole(MINTER_ROLE) returns (uint256) { + // `_mintTo` is re-used. `mintTo` just adds a minter role check. + return _mintTo(_to, _uri); + } + + /// ===== External functions ===== + + /// @dev See EIP-2981 + function royaltyInfo( + uint256 tokenId, + uint256 salePrice + ) external view virtual returns (address receiver, uint256 royaltyAmount) { + (address recipient, uint256 bps) = getRoyaltyInfoForToken(tokenId); + receiver = recipient; + royaltyAmount = (salePrice * bps) / MAX_BPS; + } + + /// @dev Mints an NFT according to the provided mint request. + function mintWithSignature( + MintRequest calldata _req, + bytes calldata _signature + ) external payable nonReentrant returns (uint256 tokenIdMinted) { + address signer = verifyRequest(_req, _signature); + address receiver = _req.to; + + tokenIdMinted = _mintTo(receiver, _req.uri); + + if (_req.royaltyRecipient != address(0)) { + royaltyInfoForToken[tokenIdMinted] = RoyaltyInfo({ + recipient: _req.royaltyRecipient, + bps: _req.royaltyBps + }); + } + + collectPrice(_req); + + emit TokensMintedWithSignature(signer, receiver, tokenIdMinted, _req); + } + + // ===== Setter functions ===== + + /// @dev Lets a module admin set the default recipient of all primary sales. + function setPrimarySaleRecipient(address _saleRecipient) external onlyRole(DEFAULT_ADMIN_ROLE) { + primarySaleRecipient = _saleRecipient; + emit PrimarySaleRecipientUpdated(_saleRecipient); + } + + /// @dev Lets a module admin update the royalty bps and recipient. + function setDefaultRoyaltyInfo( + address _royaltyRecipient, + uint256 _royaltyBps + ) external onlyRole(DEFAULT_ADMIN_ROLE) { + require(_royaltyBps <= MAX_BPS, "exceed royalty bps"); + + royaltyRecipient = _royaltyRecipient; + royaltyBps = uint128(_royaltyBps); + + emit DefaultRoyalty(_royaltyRecipient, _royaltyBps); + } + + /// @dev Lets a module admin set the royalty recipient for a particular token Id. + function setRoyaltyInfoForToken( + uint256 _tokenId, + address _recipient, + uint256 _bps + ) external onlyRole(DEFAULT_ADMIN_ROLE) { + require(_bps <= MAX_BPS, "exceed royalty bps"); + + royaltyInfoForToken[_tokenId] = RoyaltyInfo({ recipient: _recipient, bps: _bps }); + + emit RoyaltyForToken(_tokenId, _recipient, _bps); + } + + /// @dev Lets a module admin update the fees on primary sales. + function setPlatformFeeInfo( + address _platformFeeRecipient, + uint256 _platformFeeBps + ) external onlyRole(DEFAULT_ADMIN_ROLE) { + require(_platformFeeBps <= MAX_BPS, "exceeds MAX_BPS"); + + platformFeeBps = uint64(_platformFeeBps); + platformFeeRecipient = _platformFeeRecipient; + + emit PlatformFeeInfoUpdated(_platformFeeRecipient, _platformFeeBps); + } + + /// @dev Lets a module admin set a new owner for the contract. The new owner must be a module admin. + function setOwner(address _newOwner) external onlyRole(DEFAULT_ADMIN_ROLE) { + require(hasRole(DEFAULT_ADMIN_ROLE, _newOwner), "new owner not module admin."); + address _prevOwner = _owner; + _owner = _newOwner; + + emit OwnerUpdated(_prevOwner, _newOwner); + } + + /// @dev Lets a module admin set the URI for contract-level metadata. + function setContractURI(string calldata _uri) external onlyRole(DEFAULT_ADMIN_ROLE) { + contractURI = _uri; + } + + /// ===== Getter functions ===== + + /// @dev Returns the platform fee bps and recipient. + function getPlatformFeeInfo() external view returns (address, uint16) { + return (platformFeeRecipient, uint16(platformFeeBps)); + } + + /// @dev Returns the platform fee bps and recipient. + function getDefaultRoyaltyInfo() external view returns (address, uint16) { + return (royaltyRecipient, uint16(royaltyBps)); + } + + /// @dev Returns the royalty recipient for a particular token Id. + function getRoyaltyInfoForToken(uint256 _tokenId) public view returns (address, uint16) { + RoyaltyInfo memory royaltyForToken = royaltyInfoForToken[_tokenId]; + + return + royaltyForToken.recipient == address(0) + ? (royaltyRecipient, uint16(royaltyBps)) + : (royaltyForToken.recipient, uint16(royaltyForToken.bps)); + } + + /// ===== Internal functions ===== + + /// @dev Mints an NFT to `to` + function _mintTo(address _to, string calldata _uri) internal returns (uint256 tokenIdToMint) { + tokenIdToMint = nextTokenIdToMint; + nextTokenIdToMint += 1; + + require(bytes(_uri).length > 0, "empty uri."); + _setTokenURI(tokenIdToMint, _uri); + + _safeMint(_to, tokenIdToMint); + + emit TokensMinted(_to, tokenIdToMint, _uri); + } + + /// @dev Returns the address of the signer of the mint request. + function recoverAddress(MintRequest calldata _req, bytes calldata _signature) private view returns (address) { + return _hashTypedDataV4(keccak256(_encodeRequest(_req))).recover(_signature); + } + + /// @dev Resolves 'stack too deep' error in `recoverAddress`. + function _encodeRequest(MintRequest calldata _req) private pure returns (bytes memory) { + return + abi.encode( + TYPEHASH, + _req.to, + _req.royaltyRecipient, + _req.royaltyBps, + _req.primarySaleRecipient, + keccak256(bytes(_req.uri)), + _req.price, + _req.currency, + _req.validityStartTimestamp, + _req.validityEndTimestamp, + _req.uid + ); + } + + /// @dev Verifies that a mint request is valid. + function verifyRequest(MintRequest calldata _req, bytes calldata _signature) internal returns (address) { + (bool success, address signer) = verify(_req, _signature); + require(success, "invalid signature"); + + require( + _req.validityStartTimestamp <= block.timestamp && _req.validityEndTimestamp >= block.timestamp, + "request expired" + ); + require(_req.to != address(0), "recipient undefined"); + + minted[_req.uid] = true; + + return signer; + } + + /// @dev Collects and distributes the primary sale value of tokens being claimed. + function collectPrice(MintRequest calldata _req) internal { + if (_req.price == 0) { + require(msg.value == 0, "!Value"); + return; + } + + uint256 totalPrice = _req.price; + uint256 platformFees = (totalPrice * platformFeeBps) / MAX_BPS; + + if (_req.currency == CurrencyTransferLib.NATIVE_TOKEN) { + require(msg.value == totalPrice, "must send total price."); + } else { + require(msg.value == 0, "msg value not zero"); + } + + address saleRecipient = _req.primarySaleRecipient == address(0) + ? primarySaleRecipient + : _req.primarySaleRecipient; + + CurrencyTransferLib.transferCurrency(_req.currency, _msgSender(), platformFeeRecipient, platformFees); + CurrencyTransferLib.transferCurrency(_req.currency, _msgSender(), saleRecipient, totalPrice - platformFees); + } + + /// ===== Low-level overrides ===== + + /// @dev Burns `tokenId`. See {ERC721-_burn}. + function burn(uint256 tokenId) public virtual { + //solhint-disable-next-line max-line-length + require(_isApprovedOrOwner(_msgSender(), tokenId), "ERC721Burnable: caller is not owner nor approved"); + _burn(tokenId); + } + + /// @dev See {ERC721-_beforeTokenTransfer}. + function _beforeTokenTransfer( + address from, + address to, + uint256 tokenId, + uint256 batchSize + ) internal virtual override(ERC721EnumerableUpgradeable) { + super._beforeTokenTransfer(from, to, tokenId, batchSize); + + // if transfer is restricted on the contract, we still want to allow burning and minting + if (!hasRole(TRANSFER_ROLE, address(0)) && from != address(0) && to != address(0)) { + require(hasRole(TRANSFER_ROLE, from) || hasRole(TRANSFER_ROLE, to), "restricted to TRANSFER_ROLE holders"); + } + } + + /// @dev Returns whether metadata can be set in the given execution context. + function _canSetMetadata() internal view virtual override returns (bool) { + return hasRole(METADATA_ROLE, _msgSender()); + } + + /// @dev Returns whether metadata can be frozen in the given execution context. + function _canFreezeMetadata() internal view virtual override returns (bool) { + return hasRole(METADATA_ROLE, _msgSender()); + } + + function supportsInterface( + bytes4 interfaceId + ) + public + view + virtual + override(AccessControlEnumerableUpgradeable, ERC721EnumerableUpgradeable, IERC165Upgradeable, IERC165) + returns (bool) + { + return super.supportsInterface(interfaceId) || interfaceId == type(IERC2981Upgradeable).interfaceId; + } + + function _msgSender() + internal + view + virtual + override(ContextUpgradeable, ERC2771ContextUpgradeable, Multicall) + returns (address sender) + { + return ERC2771ContextUpgradeable._msgSender(); + } + + function _msgData() + internal + view + virtual + override(ContextUpgradeable, ERC2771ContextUpgradeable) + returns (bytes calldata) + { + return ERC2771ContextUpgradeable._msgData(); + } +} diff --git a/contracts/prebuilts/token/signatureMint.md b/contracts/prebuilts/token/signatureMint.md new file mode 100644 index 000000000..e30ba9d0f --- /dev/null +++ b/contracts/prebuilts/token/signatureMint.md @@ -0,0 +1,100 @@ +# Signature minting design document. + +This is a live document that explains the 'signature minting' mechanism used in [thirdweb](https://thirdweb.com/) `Token` smart contracts. + +The document is written for technical and non-technical readers. To ask further questions about any of thirdweb’s `Drop`, please join the [thirdweb discord](https://discord.gg/thirdweb) or create a github issue. + +--- + +## Background + +The 'signature minting' mechanism used in [thirdweb](https://thirdweb.com/) `Token` smart contracts is a way for a contract admin to +authorize an external party's request to mint tokens on the admin's contract. At a high level, this means you can authorize some external party to mint tokens on your contract, and specify what exactly will be minted by that external party. + +A contract admin signs a 'payload' or 'mint request' which specifies parameters around a mint e.g. which address should tokens be minted to, what price should be collected in exchange for the minted tokens, etc. + +Any external party can then present a smart contract implementing the 'signature minting' mechanism with a payload, along with the signature generated from a contract admin signing the payload. Tokens will then be minted according to the information specified in the payload. + +The following diagram illustrates how the 'signature minting' flow looks like. For example, consider a webapp like [wombo.art](https://www.wombo.art/) which lets a user generate artwork on its website. Once a user has generated artwork on the wombo website, wombo can allow the user to mint their generated artwork as an NFT on wombo's own contract, where wombo can ensure that what the user will mint on wombo's contract is the intended generated artwork. + +![signaute-minting-diagram-1.png](/assets/signature-minting-diag-1.png) + +### Why we're developing `Signature Minting` + +We’ve observed that there are largely three distinct contexts under which one mints tokens: + +1. Minting tokens for yourself on a contract you own. E.g. a person wants to mint their Twitter profile picture as an NFT. +2. Having an audience mint tokens on a contract you own. + 1. The nature of tokens to be minted by the audience is pre-determined by the contract admin. E.g. a 10k NFT drop where the contents of the NFTs to be minted by the audience is already known and determined by the contract admin before the audience comes in to mint NFTs. + 2. The nature of tokens to be minted by the audience is *not* pre-determined by the contract admin. E.g. a course ‘certificate’ dynamically generated with the name of the course participant, to be minted by the course participant at the time of course completion. + +The thirdweb `Drop` contracts serve the cases described in 2(i). + +The thirdweb `Token` contracts serve the cases described in (1) and 2(ii). And the 'signature minting' mechanism is particularly designed to serve the case described in 2(ii). + +## Technical Details + +We'll now go over the technical details involved in the 'signature minting' mechanism illustrated in the diagram in the preceding section. + +### Payload / Mint request + +We'll now cover what makes up a payload, or 'mint request': + +```solidity +struct MintRequest { + address to; + address royaltyRecipient; + uint256 royaltyBps; + address primarySaleRecipient; + uint256 tokenId; + string uri; + uint256 quantity; + uint256 pricePerToken; + address currency; + uint128 validityStartTimestamp; + uint128 validityEndTimestamp; + bytes32 uid; +} +``` + +| Parameter | Description | +| --- | --- | +| to | The receiver of the tokens to mint. | +| royaltyRecipient | The recipient of the minted token's secondary sales royalties. (Not applicable for ERC20 tokens) | +| royaltyBps | The percentage of the minted token's secondary sales to take as royalties. (Not applicable for ERC20 tokens) | +| primarySaleRecipient | The recipient of the minted token's primary sales proceeds. | +| tokenId | The tokenId of the token to mint. (Only applicable for ERC1155 tokens)| +| uri | The metadata URI of the token to mint. (Not applicable for ERC20 tokens)| +| quantity | The quantity of tokens to mint.| +| pricePerToken | The price to pay per quantity of tokens minted. (For TokenERC20, this parameter is `price`, indicating the total price of all tokens)| +| currency | The currency in which to pay the price per token minted.| +| validityStartTimestamp | The unix timestamp after which the payload is valid.| +| validityEndTimestamp | The unix timestamp at which the payload expires.| +| uid | A unique identifier for the payload.| + +The described fields in `MintRequest` are what make up a payload or 'mint request'. This is the payload that a contract admin signs off, to be used by an external party to mint tokens on the admin's contract. + +When any external party presents a payload to the contract implementing the 'signature minting' mechanism, tokens are minted exactly according to the information specified in the presented `MintRequest`. + +### Minting tokens with a payload / 'mint request' + +Any external party can present a smart contract implementing the 'signature minting' mechanism with a payload, along with the signature generated from a contract admin signing the payload. Tokens will then be minted according to the information specified in the payload. + +To mint tokens with a payload, the following function is called: + +```solidity +function mintWithSignature(MintRequest calldata req, bytes calldata signature) external payable; +``` + +| Parameter | Description | +| --- | --- | +| req | The payload / mint request. | +| signature | The signature produced by an account signing the mint request. | + +The contract implementing the 'signature minting' mechanism first recover's the address of the signer from the given payload i.e. `req` and the `signature`, and verifies that an authorized address has signed off this incoming mint request. + +Once verified, tokens are minted according to the information specified in the payload. + +## Authors +- [nkrishang](https://github.com/nkrishang) +- [thirdweb team](https://github.com/thirdweb-dev) \ No newline at end of file diff --git a/contracts/prebuilts/unaudited/airdrop/AirdropERC1155.sol b/contracts/prebuilts/unaudited/airdrop/AirdropERC1155.sol new file mode 100644 index 000000000..b27a0afcc --- /dev/null +++ b/contracts/prebuilts/unaudited/airdrop/AirdropERC1155.sol @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/// @author thirdweb + +// $$\ $$\ $$\ $$\ $$\ +// $$ | $$ | \__| $$ | $$ | +// $$$$$$\ $$$$$$$\ $$\ $$$$$$\ $$$$$$$ |$$\ $$\ $$\ $$$$$$\ $$$$$$$\ +// \_$$ _| $$ __$$\ $$ |$$ __$$\ $$ __$$ |$$ | $$ | $$ |$$ __$$\ $$ __$$\ +// $$ | $$ | $$ |$$ |$$ | \__|$$ / $$ |$$ | $$ | $$ |$$$$$$$$ |$$ | $$ | +// $$ |$$\ $$ | $$ |$$ |$$ | $$ | $$ |$$ | $$ | $$ |$$ ____|$$ | $$ | +// \$$$$ |$$ | $$ |$$ |$$ | \$$$$$$$ |\$$$$$\$$$$ |\$$$$$$$\ $$$$$$$ | +// \____/ \__| \__|\__|\__| \_______| \_____\____/ \_______|\_______/ + +// ========== External imports ========== +import "@openzeppelin/contracts/token/ERC1155/IERC1155.sol"; + +import "@openzeppelin/contracts-upgradeable/security/ReentrancyGuardUpgradeable.sol"; +import "../../../extension/Multicall.sol"; + +// ========== Internal imports ========== + +import "../../interface/airdrop/IAirdropERC1155.sol"; +import "../../../external-deps/openzeppelin/metatx/ERC2771ContextUpgradeable.sol"; + +// ========== Features ========== +import "../../../extension/PermissionsEnumerable.sol"; +import "../../../extension/ContractMetadata.sol"; + +contract AirdropERC1155 is + Initializable, + ContractMetadata, + PermissionsEnumerable, + ReentrancyGuardUpgradeable, + ERC2771ContextUpgradeable, + Multicall, + IAirdropERC1155 +{ + /*/////////////////////////////////////////////////////////////// + State variables + //////////////////////////////////////////////////////////////*/ + + bytes32 private constant MODULE_TYPE = bytes32("AirdropERC1155"); + uint256 private constant VERSION = 2; + + /*/////////////////////////////////////////////////////////////// + Constructor + initializer logic + //////////////////////////////////////////////////////////////*/ + + constructor() { + _disableInitializers(); + } + + /// @dev Initializes the contract, like a constructor. + function initialize( + address _defaultAdmin, + string memory _contractURI, + address[] memory _trustedForwarders + ) external initializer { + __ERC2771Context_init_unchained(_trustedForwarders); + + _setupContractURI(_contractURI); + _setupRole(DEFAULT_ADMIN_ROLE, _defaultAdmin); + __ReentrancyGuard_init(); + } + + /*/////////////////////////////////////////////////////////////// + Generic contract logic + //////////////////////////////////////////////////////////////*/ + + /// @dev Returns the type of the contract. + function contractType() external pure returns (bytes32) { + return MODULE_TYPE; + } + + /// @dev Returns the version of the contract. + function contractVersion() external pure returns (uint8) { + return uint8(VERSION); + } + + /*/////////////////////////////////////////////////////////////// + Airdrop logic + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Lets contract-owner send ERC1155 tokens to a list of addresses. + * @dev The token-owner should approve target tokens to Airdrop contract, + * which acts as operator for the tokens. + * + * @param _tokenAddress The contract address of the tokens to transfer. + * @param _tokenOwner The owner of the tokens to transfer. + * @param _contents List containing recipient, tokenId and amounts to airdrop. + */ + function airdropERC1155( + address _tokenAddress, + address _tokenOwner, + AirdropContent[] calldata _contents + ) external nonReentrant { + require(hasRole(DEFAULT_ADMIN_ROLE, _msgSender()), "Not authorized."); + + uint256 len = _contents.length; + + for (uint256 i = 0; i < len; ) { + try + IERC1155(_tokenAddress).safeTransferFrom( + _tokenOwner, + _contents[i].recipient, + _contents[i].tokenId, + _contents[i].amount, + "" + ) + {} catch { + // revert if failure is due to unapproved tokens + require( + IERC1155(_tokenAddress).balanceOf(_tokenOwner, _contents[i].tokenId) >= _contents[i].amount && + IERC1155(_tokenAddress).isApprovedForAll(_tokenOwner, address(this)), + "Not balance or approved" + ); + + emit AirdropFailed( + _tokenAddress, + _tokenOwner, + _contents[i].recipient, + _contents[i].tokenId, + _contents[i].amount + ); + } + + unchecked { + i += 1; + } + } + } + + /*/////////////////////////////////////////////////////////////// + Miscellaneous + //////////////////////////////////////////////////////////////*/ + + /// @dev Checks whether contract metadata can be set in the given execution context. + function _canSetContractURI() internal view override returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + /// @dev See ERC2771 + function _msgSender() + internal + view + virtual + override(ERC2771ContextUpgradeable, Multicall) + returns (address sender) + { + return ERC2771ContextUpgradeable._msgSender(); + } +} diff --git a/contracts/prebuilts/unaudited/airdrop/AirdropERC1155Claimable.sol b/contracts/prebuilts/unaudited/airdrop/AirdropERC1155Claimable.sol new file mode 100644 index 000000000..7d42b469a --- /dev/null +++ b/contracts/prebuilts/unaudited/airdrop/AirdropERC1155Claimable.sol @@ -0,0 +1,199 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/// @author thirdweb + +// $$\ $$\ $$\ $$\ $$\ +// $$ | $$ | \__| $$ | $$ | +// $$$$$$\ $$$$$$$\ $$\ $$$$$$\ $$$$$$$ |$$\ $$\ $$\ $$$$$$\ $$$$$$$\ +// \_$$ _| $$ __$$\ $$ |$$ __$$\ $$ __$$ |$$ | $$ | $$ |$$ __$$\ $$ __$$\ +// $$ | $$ | $$ |$$ |$$ | \__|$$ / $$ |$$ | $$ | $$ |$$$$$$$$ |$$ | $$ | +// $$ |$$\ $$ | $$ |$$ |$$ | $$ | $$ |$$ | $$ | $$ |$$ ____|$$ | $$ | +// \$$$$ |$$ | $$ |$$ |$$ | \$$$$$$$ |\$$$$$\$$$$ |\$$$$$$$\ $$$$$$$ | +// \____/ \__| \__|\__|\__| \_______| \_____\____/ \_______|\_______/ + +// ========== External imports ========== +import "@openzeppelin/contracts/token/ERC1155/IERC1155.sol"; + +import "@openzeppelin/contracts-upgradeable/security/ReentrancyGuardUpgradeable.sol"; +import { Multicall } from "../../../extension/Multicall.sol"; + +// ========== Internal imports ========== + +import "../../interface/airdrop/IAirdropERC1155Claimable.sol"; + +// ========== Features ========== + +import "../../../external-deps/openzeppelin/metatx/ERC2771ContextUpgradeable.sol"; +import "../../../lib/MerkleProof.sol"; + +contract AirdropERC1155Claimable is + Initializable, + ReentrancyGuardUpgradeable, + ERC2771ContextUpgradeable, + Multicall, + IAirdropERC1155Claimable +{ + /*/////////////////////////////////////////////////////////////// + State variables + //////////////////////////////////////////////////////////////*/ + + /// @dev address of token being airdropped. + address public airdropTokenAddress; + + /// @dev address of owner of tokens being airdropped. + address public tokenOwner; + + /// @dev list of tokens to airdrop. + uint256[] public tokenIds; + + /// @dev airdrop expiration timestamp. + uint256 public expirationTimestamp; + + /*/////////////////////////////////////////////////////////////// + Mappings + //////////////////////////////////////////////////////////////*/ + + /// @dev Mapping from tokenId and claimer address to total number of tokens claimed. + mapping(uint256 => mapping(address => uint256)) public supplyClaimedByWallet; + + /// @dev claim limit for open/public claiming without allowlist. + mapping(uint256 => uint256) public openClaimLimitPerWallet; + + /// @dev number tokens available to claim for a tokenId. + mapping(uint256 => uint256) public availableAmount; + + /// @dev mapping of tokenId to merkle root of the allowlist of addresses eligible to claim. + mapping(uint256 => bytes32) public merkleRoot; + + /*/////////////////////////////////////////////////////////////// + Constructor + initializer logic + //////////////////////////////////////////////////////////////*/ + + constructor() { + _disableInitializers(); + } + + /// @dev Initializes the contract, like a constructor. + function initialize( + address[] memory _trustedForwarders, + address _tokenOwner, + address _airdropTokenAddress, + uint256[] memory _tokenIds, + uint256[] memory _availableAmounts, + uint256 _expirationTimestamp, + uint256[] memory _openClaimLimitPerWallet, + bytes32[] memory _merkleRoot + ) external initializer { + __ReentrancyGuard_init(); + __ERC2771Context_init(_trustedForwarders); + + tokenOwner = _tokenOwner; + airdropTokenAddress = _airdropTokenAddress; + tokenIds = _tokenIds; + expirationTimestamp = _expirationTimestamp; + + require( + _openClaimLimitPerWallet.length == _tokenIds.length && + _merkleRoot.length == _tokenIds.length && + _availableAmounts.length == _tokenIds.length, + "length mismatch." + ); + + for (uint256 i = 0; i < _tokenIds.length; i++) { + merkleRoot[_tokenIds[i]] = _merkleRoot[i]; + openClaimLimitPerWallet[_tokenIds[i]] = _openClaimLimitPerWallet[i]; + availableAmount[_tokenIds[i]] = _availableAmounts[i]; + } + } + + /*/////////////////////////////////////////////////////////////// + Claim logic + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Lets an account claim a given quantity of ERC1155 tokens. + * + * @param _receiver The receiver of the tokens to claim. + * @param _quantity The quantity of tokens to claim. + * @param _tokenId Token Id to claim. + * @param _proofs The proof of the claimer's inclusion in the merkle root allowlist + * of the claim conditions that apply. + * @param _proofMaxQuantityForWallet The maximum number of tokens an address included in an + * allowlist can claim. + */ + function claim( + address _receiver, + uint256 _quantity, + uint256 _tokenId, + bytes32[] calldata _proofs, + uint256 _proofMaxQuantityForWallet + ) external nonReentrant { + address claimer = _msgSender(); + + verifyClaim(claimer, _quantity, _tokenId, _proofs, _proofMaxQuantityForWallet); + + _transferClaimedTokens(_receiver, _quantity, _tokenId); + + emit TokensClaimed(_msgSender(), _receiver, _tokenId, _quantity); + } + + /// @dev Transfers the tokens being claimed. + function _transferClaimedTokens(address _to, uint256 _quantityBeingClaimed, uint256 _tokenId) internal { + // if transfer claimed tokens is called when `to != msg.sender`, it'd use msg.sender's limits. + // behavior would be similar to `msg.sender` mint for itself, then transfer to `_to`. + supplyClaimedByWallet[_tokenId][_msgSender()] += _quantityBeingClaimed; + availableAmount[_tokenId] -= _quantityBeingClaimed; + + IERC1155(airdropTokenAddress).safeTransferFrom(tokenOwner, _to, _tokenId, _quantityBeingClaimed, ""); + } + + /// @dev Checks a request to claim tokens against the active claim condition's criteria. + function verifyClaim( + address _claimer, + uint256 _quantity, + uint256 _tokenId, + bytes32[] calldata _proofs, + uint256 _proofMaxQuantityForWallet + ) public view { + bool isOverride; + + /* + * Here `isOverride` implies that if the merkle proof verification fails, + * the claimer would claim through open claim limit instead of allowlisted limit. + */ + bytes32 mroot = merkleRoot[_tokenId]; + if (mroot != bytes32(0)) { + (isOverride, ) = MerkleProof.verify( + _proofs, + mroot, + keccak256(abi.encodePacked(_claimer, _proofMaxQuantityForWallet)) + ); + } + + uint256 supplyClaimedAlready = supplyClaimedByWallet[_tokenId][_claimer]; + + require(_quantity > 0, "Claiming zero tokens"); + require(_quantity <= availableAmount[_tokenId], "exceeds available tokens."); + + uint256 expTimestamp = expirationTimestamp; + require(expTimestamp == 0 || block.timestamp < expTimestamp, "airdrop expired."); + + uint256 claimLimitForWallet = isOverride ? _proofMaxQuantityForWallet : openClaimLimitPerWallet[_tokenId]; + require(_quantity + supplyClaimedAlready <= claimLimitForWallet, "invalid quantity."); + } + + /*/////////////////////////////////////////////////////////////// + Miscellaneous + //////////////////////////////////////////////////////////////*/ + + function _msgSender() + internal + view + virtual + override(ERC2771ContextUpgradeable, Multicall) + returns (address sender) + { + return ERC2771ContextUpgradeable._msgSender(); + } +} diff --git a/contracts/prebuilts/unaudited/airdrop/AirdropERC20.sol b/contracts/prebuilts/unaudited/airdrop/AirdropERC20.sol new file mode 100644 index 000000000..b5829896f --- /dev/null +++ b/contracts/prebuilts/unaudited/airdrop/AirdropERC20.sol @@ -0,0 +1,194 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/// @author thirdweb + +// $$\ $$\ $$\ $$\ $$\ +// $$ | $$ | \__| $$ | $$ | +// $$$$$$\ $$$$$$$\ $$\ $$$$$$\ $$$$$$$ |$$\ $$\ $$\ $$$$$$\ $$$$$$$\ +// \_$$ _| $$ __$$\ $$ |$$ __$$\ $$ __$$ |$$ | $$ | $$ |$$ __$$\ $$ __$$\ +// $$ | $$ | $$ |$$ |$$ | \__|$$ / $$ |$$ | $$ | $$ |$$$$$$$$ |$$ | $$ | +// $$ |$$\ $$ | $$ |$$ |$$ | $$ | $$ |$$ | $$ | $$ |$$ ____|$$ | $$ | +// \$$$$ |$$ | $$ |$$ |$$ | \$$$$$$$ |\$$$$$\$$$$ |\$$$$$$$\ $$$$$$$ | +// \____/ \__| \__|\__|\__| \_______| \_____\____/ \_______|\_______/ + +// ========== External imports ========== + +import "@openzeppelin/contracts-upgradeable/security/ReentrancyGuardUpgradeable.sol"; +import "../../../extension/Multicall.sol"; + +// ========== Internal imports ========== + +import "../../interface/airdrop/IAirdropERC20.sol"; +import { CurrencyTransferLib } from "../../../lib/CurrencyTransferLib.sol"; +import "../../../eip/interface/IERC20.sol"; +import "../../../external-deps/openzeppelin/metatx/ERC2771ContextUpgradeable.sol"; + +// ========== Features ========== +import "../../../extension/PermissionsEnumerable.sol"; +import "../../../extension/ContractMetadata.sol"; + +contract AirdropERC20 is + Initializable, + ContractMetadata, + PermissionsEnumerable, + ReentrancyGuardUpgradeable, + ERC2771ContextUpgradeable, + Multicall, + IAirdropERC20 +{ + /*/////////////////////////////////////////////////////////////// + State variables + //////////////////////////////////////////////////////////////*/ + + bytes32 private constant MODULE_TYPE = bytes32("AirdropERC20"); + uint256 private constant VERSION = 2; + + /*/////////////////////////////////////////////////////////////// + Constructor + initializer logic + //////////////////////////////////////////////////////////////*/ + + constructor() { + _disableInitializers(); + } + + /// @dev Initializes the contract, like a constructor. + function initialize( + address _defaultAdmin, + string memory _contractURI, + address[] memory _trustedForwarders + ) external initializer { + __ERC2771Context_init_unchained(_trustedForwarders); + + _setupContractURI(_contractURI); + _setupRole(DEFAULT_ADMIN_ROLE, _defaultAdmin); + __ReentrancyGuard_init(); + } + + /*/////////////////////////////////////////////////////////////// + Generic contract logic + //////////////////////////////////////////////////////////////*/ + + /// @dev Returns the type of the contract. + function contractType() external pure returns (bytes32) { + return MODULE_TYPE; + } + + /// @dev Returns the version of the contract. + function contractVersion() external pure returns (uint8) { + return uint8(VERSION); + } + + /*/////////////////////////////////////////////////////////////// + Airdrop logic + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Lets contract-owner send ERC20 tokens to a list of addresses. + * @dev The token-owner should approve target tokens to Airdrop contract, + * which acts as operator for the tokens. + * + * @param _tokenAddress The contract address of the tokens to transfer. + * @param _tokenOwner The owner of the tokens to transfer. + * @param _contents List containing recipient, tokenId and amounts to airdrop. + */ + function airdropERC20( + address _tokenAddress, + address _tokenOwner, + AirdropContent[] calldata _contents + ) external payable nonReentrant { + require(hasRole(DEFAULT_ADMIN_ROLE, _msgSender()), "Not authorized."); + + uint256 len = _contents.length; + uint256 nativeTokenAmount; + uint256 refundAmount; + + for (uint256 i = 0; i < len; ) { + bool success = _transferCurrencyWithReturnVal( + _tokenAddress, + _tokenOwner, + _contents[i].recipient, + _contents[i].amount + ); + + if (!success) { + emit AirdropFailed(_tokenAddress, _tokenOwner, _contents[i].recipient, _contents[i].amount); + } + + if (_tokenAddress == CurrencyTransferLib.NATIVE_TOKEN) { + nativeTokenAmount += _contents[i].amount; + + require(nativeTokenAmount <= msg.value, "Insufficient native token amount"); + + if (!success) { + refundAmount += _contents[i].amount; + } + } + + unchecked { + i += 1; + } + } + + require(nativeTokenAmount == msg.value, "Incorrect native token amount"); + + if (refundAmount > 0) { + // refund failed payments' amount to contract admin address + CurrencyTransferLib.safeTransferNativeToken(msg.sender, refundAmount); + } + } + + /*/////////////////////////////////////////////////////////////// + Miscellaneous + //////////////////////////////////////////////////////////////*/ + + /// @dev Transfers ERC20 tokens and returns a boolean i.e. the status of the transfer. + function _transferCurrencyWithReturnVal( + address _currency, + address _from, + address _to, + uint256 _amount + ) internal returns (bool success) { + if (_amount == 0) { + success = true; + return success; + } + + if (_currency == CurrencyTransferLib.NATIVE_TOKEN) { + // solhint-disable avoid-low-level-calls + // slither-disable-next-line low-level-calls + (success, ) = _to.call{ value: _amount }(""); + } else { + (bool success_, bytes memory data_) = _currency.call( + abi.encodeWithSelector(IERC20.transferFrom.selector, _from, _to, _amount) + ); + + success = success_; + if (!success || (data_.length > 0 && !abi.decode(data_, (bool)))) { + success = false; + + require( + IERC20(_currency).balanceOf(_from) >= _amount && + IERC20(_currency).allowance(_from, address(this)) >= _amount, + "Not balance or allowance" + ); + } + } + } + + /// @dev Checks whether contract metadata can be set in the given execution context. + function _canSetContractURI() internal view override returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + /// @dev See ERC2771 + function _msgSender() + internal + view + virtual + override(ERC2771ContextUpgradeable, Multicall) + returns (address sender) + { + return ERC2771ContextUpgradeable._msgSender(); + } +} diff --git a/contracts/prebuilts/unaudited/airdrop/AirdropERC20Claimable.sol b/contracts/prebuilts/unaudited/airdrop/AirdropERC20Claimable.sol new file mode 100644 index 000000000..22840d247 --- /dev/null +++ b/contracts/prebuilts/unaudited/airdrop/AirdropERC20Claimable.sol @@ -0,0 +1,180 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/// @author thirdweb + +// $$\ $$\ $$\ $$\ $$\ +// $$ | $$ | \__| $$ | $$ | +// $$$$$$\ $$$$$$$\ $$\ $$$$$$\ $$$$$$$ |$$\ $$\ $$\ $$$$$$\ $$$$$$$\ +// \_$$ _| $$ __$$\ $$ |$$ __$$\ $$ __$$ |$$ | $$ | $$ |$$ __$$\ $$ __$$\ +// $$ | $$ | $$ |$$ |$$ | \__|$$ / $$ |$$ | $$ | $$ |$$$$$$$$ |$$ | $$ | +// $$ |$$\ $$ | $$ |$$ |$$ | $$ | $$ |$$ | $$ | $$ |$$ ____|$$ | $$ | +// \$$$$ |$$ | $$ |$$ |$$ | \$$$$$$$ |\$$$$$\$$$$ |\$$$$$$$\ $$$$$$$ | +// \____/ \__| \__|\__|\__| \_______| \_____\____/ \_______|\_______/ + +// ========== External imports ========== +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +import "@openzeppelin/contracts-upgradeable/security/ReentrancyGuardUpgradeable.sol"; +import "../../../extension/Multicall.sol"; + +// ========== Internal imports ========== + +import "../../interface/airdrop/IAirdropERC20Claimable.sol"; + +// ========== Features ========== + +import "../../../external-deps/openzeppelin/metatx/ERC2771ContextUpgradeable.sol"; +import "../../../lib/MerkleProof.sol"; + +contract AirdropERC20Claimable is + Initializable, + ReentrancyGuardUpgradeable, + ERC2771ContextUpgradeable, + Multicall, + IAirdropERC20Claimable +{ + /*/////////////////////////////////////////////////////////////// + State variables + //////////////////////////////////////////////////////////////*/ + + /// @dev address of token being airdropped. + address public airdropTokenAddress; + + /// @dev address of owner of tokens being airdropped. + address public tokenOwner; + + /// @dev number tokens available to claim. + uint256 public availableAmount; + + /// @dev airdrop expiration timestamp. + uint256 public expirationTimestamp; + + /// @dev claim limit for open/public claiming without allowlist. + uint256 public openClaimLimitPerWallet; + + /// @dev merkle root of the allowlist of addresses eligible to claim. + bytes32 public merkleRoot; + + /*/////////////////////////////////////////////////////////////// + Mappings + //////////////////////////////////////////////////////////////*/ + + /// @dev Mapping from address => total number of tokens a wallet has claimed. + mapping(address => uint256) public supplyClaimedByWallet; + + /*/////////////////////////////////////////////////////////////// + Constructor + initializer logic + //////////////////////////////////////////////////////////////*/ + + constructor() { + _disableInitializers(); + } + + /// @dev Initializes the contract, like a constructor. + function initialize( + address[] memory _trustedForwarders, + address _tokenOwner, + address _airdropTokenAddress, + uint256 _airdropAmount, + uint256 _expirationTimestamp, + uint256 _openClaimLimitPerWallet, + bytes32 _merkleRoot + ) external initializer { + __ReentrancyGuard_init(); + __ERC2771Context_init(_trustedForwarders); + + tokenOwner = _tokenOwner; + airdropTokenAddress = _airdropTokenAddress; + availableAmount = _airdropAmount; + expirationTimestamp = _expirationTimestamp; + openClaimLimitPerWallet = _openClaimLimitPerWallet; + merkleRoot = _merkleRoot; + } + + /*/////////////////////////////////////////////////////////////// + Claim logic + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Lets an account claim a given quantity of NFTs. + * + * @param _receiver The receiver of the NFTs to claim. + * @param _quantity The quantity of NFTs to claim. + * @param _proofs The proof of the claimer's inclusion in the merkle root allowlist + * of the claim conditions that apply. + * @param _proofMaxQuantityForWallet The maximum number of NFTs an address included in an + * allowlist can claim. + */ + function claim( + address _receiver, + uint256 _quantity, + bytes32[] calldata _proofs, + uint256 _proofMaxQuantityForWallet + ) external nonReentrant { + address claimer = _msgSender(); + + verifyClaim(claimer, _quantity, _proofs, _proofMaxQuantityForWallet); + + _transferClaimedTokens(_receiver, _quantity); + + emit TokensClaimed(_msgSender(), _receiver, _quantity); + } + + /// @dev Checks a request to claim tokens against the active claim condition's criteria. + function verifyClaim( + address _claimer, + uint256 _quantity, + bytes32[] calldata _proofs, + uint256 _proofMaxQuantityForWallet + ) public view { + bool isOverride; + + /* + * Here `isOverride` implies that if the merkle proof verification fails, + * the claimer would claim through open claim limit instead of allowlisted limit. + */ + if (merkleRoot != bytes32(0)) { + (isOverride, ) = MerkleProof.verify( + _proofs, + merkleRoot, + keccak256(abi.encodePacked(_claimer, _proofMaxQuantityForWallet)) + ); + } + + uint256 supplyClaimedAlready = supplyClaimedByWallet[_claimer]; + + require(_quantity > 0, "Claiming zero tokens"); + require(_quantity <= availableAmount, "exceeds available tokens."); + + uint256 expTimestamp = expirationTimestamp; + require(expTimestamp == 0 || block.timestamp < expTimestamp, "airdrop expired."); + + uint256 claimLimitForWallet = isOverride ? _proofMaxQuantityForWallet : openClaimLimitPerWallet; + require(_quantity + supplyClaimedAlready <= claimLimitForWallet, "invalid quantity."); + } + + /// @dev Transfers the tokens being claimed. + function _transferClaimedTokens(address _to, uint256 _quantityBeingClaimed) internal { + // if transfer claimed tokens is called when `to != msg.sender`, it'd use msg.sender's limits. + // behavior would be similar to `msg.sender` mint for itself, then transfer to `_to`. + supplyClaimedByWallet[_msgSender()] += _quantityBeingClaimed; + availableAmount -= _quantityBeingClaimed; + + require(IERC20(airdropTokenAddress).transferFrom(tokenOwner, _to, _quantityBeingClaimed), "transfer failed"); + } + + /*/////////////////////////////////////////////////////////////// + Miscellaneous + //////////////////////////////////////////////////////////////*/ + + function _msgSender() + internal + view + virtual + override(ERC2771ContextUpgradeable, Multicall) + returns (address sender) + { + return ERC2771ContextUpgradeable._msgSender(); + } +} diff --git a/contracts/prebuilts/unaudited/airdrop/AirdropERC721.sol b/contracts/prebuilts/unaudited/airdrop/AirdropERC721.sol new file mode 100644 index 000000000..97a99f63f --- /dev/null +++ b/contracts/prebuilts/unaudited/airdrop/AirdropERC721.sol @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/// @author thirdweb + +// $$\ $$\ $$\ $$\ $$\ +// $$ | $$ | \__| $$ | $$ | +// $$$$$$\ $$$$$$$\ $$\ $$$$$$\ $$$$$$$ |$$\ $$\ $$\ $$$$$$\ $$$$$$$\ +// \_$$ _| $$ __$$\ $$ |$$ __$$\ $$ __$$ |$$ | $$ | $$ |$$ __$$\ $$ __$$\ +// $$ | $$ | $$ |$$ |$$ | \__|$$ / $$ |$$ | $$ | $$ |$$$$$$$$ |$$ | $$ | +// $$ |$$\ $$ | $$ |$$ |$$ | $$ | $$ |$$ | $$ | $$ |$$ ____|$$ | $$ | +// \$$$$ |$$ | $$ |$$ |$$ | \$$$$$$$ |\$$$$$\$$$$ |\$$$$$$$\ $$$$$$$ | +// \____/ \__| \__|\__|\__| \_______| \_____\____/ \_______|\_______/ + +// ========== External imports ========== +import "../../../eip/interface/IERC721.sol"; + +import "@openzeppelin/contracts-upgradeable/security/ReentrancyGuardUpgradeable.sol"; +import "../../../extension/Multicall.sol"; + +// ========== Internal imports ========== + +import "../../interface/airdrop/IAirdropERC721.sol"; +import "../../../external-deps/openzeppelin/metatx/ERC2771ContextUpgradeable.sol"; + +// ========== Features ========== +import "../../../extension/PermissionsEnumerable.sol"; +import "../../../extension/ContractMetadata.sol"; + +contract AirdropERC721 is + Initializable, + ContractMetadata, + PermissionsEnumerable, + ReentrancyGuardUpgradeable, + ERC2771ContextUpgradeable, + Multicall, + IAirdropERC721 +{ + /*/////////////////////////////////////////////////////////////// + State variables + //////////////////////////////////////////////////////////////*/ + + bytes32 private constant MODULE_TYPE = bytes32("AirdropERC721"); + uint256 private constant VERSION = 2; + + /*/////////////////////////////////////////////////////////////// + Constructor + initializer logic + //////////////////////////////////////////////////////////////*/ + + constructor() { + _disableInitializers(); + } + + /// @dev Initializes the contract, like a constructor. + function initialize( + address _defaultAdmin, + string memory _contractURI, + address[] memory _trustedForwarders + ) external initializer { + __ERC2771Context_init_unchained(_trustedForwarders); + + _setupContractURI(_contractURI); + _setupRole(DEFAULT_ADMIN_ROLE, _defaultAdmin); + __ReentrancyGuard_init(); + } + + /*/////////////////////////////////////////////////////////////// + Generic contract logic + //////////////////////////////////////////////////////////////*/ + + /// @dev Returns the type of the contract. + function contractType() external pure returns (bytes32) { + return MODULE_TYPE; + } + + /// @dev Returns the version of the contract. + function contractVersion() external pure returns (uint8) { + return uint8(VERSION); + } + + /*/////////////////////////////////////////////////////////////// + Airdrop logic + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Lets contract-owner send ERC721 tokens to a list of addresses. + * @dev The token-owner should approve target tokens to Airdrop contract, + * which acts as operator for the tokens. + * + * @param _tokenAddress The contract address of the tokens to transfer. + * @param _tokenOwner The owner of the tokens to transfer. + * @param _contents List containing recipient, tokenId to airdrop. + */ + function airdropERC721( + address _tokenAddress, + address _tokenOwner, + AirdropContent[] calldata _contents + ) external nonReentrant { + require(hasRole(DEFAULT_ADMIN_ROLE, _msgSender()), "Not authorized."); + + uint256 len = _contents.length; + + for (uint256 i = 0; i < len; ) { + try + IERC721(_tokenAddress).safeTransferFrom(_tokenOwner, _contents[i].recipient, _contents[i].tokenId) + {} catch { + // revert if failure is due to unapproved tokens + require( + (IERC721(_tokenAddress).ownerOf(_contents[i].tokenId) == _tokenOwner && + address(this) == IERC721(_tokenAddress).getApproved(_contents[i].tokenId)) || + IERC721(_tokenAddress).isApprovedForAll(_tokenOwner, address(this)), + "Not owner or approved" + ); + + emit AirdropFailed(_tokenAddress, _tokenOwner, _contents[i].recipient, _contents[i].tokenId); + } + + unchecked { + i += 1; + } + } + } + + /*/////////////////////////////////////////////////////////////// + Miscellaneous + //////////////////////////////////////////////////////////////*/ + + /// @dev Checks whether contract metadata can be set in the given execution context. + function _canSetContractURI() internal view override returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + /// @dev See ERC2771 + function _msgSender() + internal + view + virtual + override(ERC2771ContextUpgradeable, Multicall) + returns (address sender) + { + return ERC2771ContextUpgradeable._msgSender(); + } +} diff --git a/contracts/prebuilts/unaudited/airdrop/AirdropERC721Claimable.sol b/contracts/prebuilts/unaudited/airdrop/AirdropERC721Claimable.sol new file mode 100644 index 000000000..16f38d3b7 --- /dev/null +++ b/contracts/prebuilts/unaudited/airdrop/AirdropERC721Claimable.sol @@ -0,0 +1,197 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/// @author thirdweb + +// $$\ $$\ $$\ $$\ $$\ +// $$ | $$ | \__| $$ | $$ | +// $$$$$$\ $$$$$$$\ $$\ $$$$$$\ $$$$$$$ |$$\ $$\ $$\ $$$$$$\ $$$$$$$\ +// \_$$ _| $$ __$$\ $$ |$$ __$$\ $$ __$$ |$$ | $$ | $$ |$$ __$$\ $$ __$$\ +// $$ | $$ | $$ |$$ |$$ | \__|$$ / $$ |$$ | $$ | $$ |$$$$$$$$ |$$ | $$ | +// $$ |$$\ $$ | $$ |$$ |$$ | $$ | $$ |$$ | $$ | $$ |$$ ____|$$ | $$ | +// \$$$$ |$$ | $$ |$$ |$$ | \$$$$$$$ |\$$$$$\$$$$ |\$$$$$$$\ $$$$$$$ | +// \____/ \__| \__|\__|\__| \_______| \_____\____/ \_______|\_______/ + +// ========== External imports ========== +import "../../../eip/interface/IERC721.sol"; + +import "@openzeppelin/contracts-upgradeable/security/ReentrancyGuardUpgradeable.sol"; +import "../../../extension/Multicall.sol"; + +// ========== Internal imports ========== + +import "../../interface/airdrop/IAirdropERC721Claimable.sol"; + +// ========== Features ========== + +import "../../../external-deps/openzeppelin/metatx/ERC2771ContextUpgradeable.sol"; +import "../../../lib/MerkleProof.sol"; + +contract AirdropERC721Claimable is + Initializable, + ReentrancyGuardUpgradeable, + ERC2771ContextUpgradeable, + Multicall, + IAirdropERC721Claimable +{ + /*/////////////////////////////////////////////////////////////// + State variables + //////////////////////////////////////////////////////////////*/ + + /// @dev address of token being airdropped. + address public airdropTokenAddress; + + /// @dev address of owner of tokens being airdropped. + address public tokenOwner; + + /// @dev list of tokens to airdrop. + uint256[] public tokenIds; + + /// @dev next index in tokenIds[] to claim in the airdrop. + uint256 public nextIndex; + + /// @dev number tokens available to claim in tokenIds[]. + uint256 public availableAmount; + + /// @dev airdrop expiration timestamp. + uint256 public expirationTimestamp; + + /// @dev claim limit for open/public claiming without allowlist. + uint256 public openClaimLimitPerWallet; + + /// @dev merkle root of the allowlist of addresses eligible to claim. + bytes32 public merkleRoot; + + /*/////////////////////////////////////////////////////////////// + Mappings + //////////////////////////////////////////////////////////////*/ + + /// @dev Mapping from address => total number of tokens a wallet has claimed. + mapping(address => uint256) public supplyClaimedByWallet; + + /*/////////////////////////////////////////////////////////////// + Constructor + initializer logic + //////////////////////////////////////////////////////////////*/ + + constructor() { + _disableInitializers(); + } + + /// @dev Initializes the contract, like a constructor. + function initialize( + address[] memory _trustedForwarders, + address _tokenOwner, + address _airdropTokenAddress, + uint256[] memory _tokenIds, + uint256 _expirationTimestamp, + uint256 _openClaimLimitPerWallet, + bytes32 _merkleRoot + ) external initializer { + __ReentrancyGuard_init(); + __ERC2771Context_init(_trustedForwarders); + + tokenOwner = _tokenOwner; + airdropTokenAddress = _airdropTokenAddress; + tokenIds = _tokenIds; + expirationTimestamp = _expirationTimestamp; + openClaimLimitPerWallet = _openClaimLimitPerWallet; + merkleRoot = _merkleRoot; + + availableAmount = _tokenIds.length; + } + + /*/////////////////////////////////////////////////////////////// + Claim logic + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Lets an account claim a given quantity of NFTs. + * + * @param _receiver The receiver of the NFTs to claim. + * @param _quantity The quantity of NFTs to claim. + * @param _proofs The proof of the claimer's inclusion in the merkle root allowlist + * of the claim conditions that apply. + * @param _proofMaxQuantityForWallet The maximum number of NFTs an address included in an + * allowlist can claim. + */ + function claim( + address _receiver, + uint256 _quantity, + bytes32[] calldata _proofs, + uint256 _proofMaxQuantityForWallet + ) external nonReentrant { + address claimer = _msgSender(); + + verifyClaim(claimer, _quantity, _proofs, _proofMaxQuantityForWallet); + + _transferClaimedTokens(_receiver, _quantity); + + emit TokensClaimed(_msgSender(), _receiver, _quantity); + } + + /// @dev Checks a request to claim tokens against the active claim condition's criteria. + function verifyClaim( + address _claimer, + uint256 _quantity, + bytes32[] calldata _proofs, + uint256 _proofMaxQuantityForWallet + ) public view { + bool isOverride; + + /* + * Here `isOverride` implies that if the merkle proof verification fails, + * the claimer would claim through open claim limit instead of allowlisted limit. + */ + if (merkleRoot != bytes32(0)) { + (isOverride, ) = MerkleProof.verify( + _proofs, + merkleRoot, + keccak256(abi.encodePacked(_claimer, _proofMaxQuantityForWallet)) + ); + } + + uint256 supplyClaimedAlready = supplyClaimedByWallet[_claimer]; + + require(_quantity > 0, "Claiming zero tokens"); + require(_quantity <= availableAmount, "exceeds available tokens."); + + uint256 expTimestamp = expirationTimestamp; + require(expTimestamp == 0 || block.timestamp < expTimestamp, "airdrop expired."); + + uint256 claimLimitForWallet = isOverride ? _proofMaxQuantityForWallet : openClaimLimitPerWallet; + require(_quantity + supplyClaimedAlready <= claimLimitForWallet, "invalid quantity."); + } + + /// @dev Transfers the tokens being claimed. + function _transferClaimedTokens(address _to, uint256 _quantityBeingClaimed) internal { + // if transfer claimed tokens is called when `to != msg.sender`, it'd use msg.sender's limits. + // behavior would be similar to `msg.sender` mint for itself, then transfer to `_to`. + supplyClaimedByWallet[_msgSender()] += _quantityBeingClaimed; + availableAmount -= _quantityBeingClaimed; + + uint256 index = nextIndex; + uint256[] memory _tokenIds = tokenIds; + address _tokenAddress = airdropTokenAddress; + address _tokenOwner = tokenOwner; + + for (uint256 i = 0; i < _quantityBeingClaimed; i += 1) { + IERC721(_tokenAddress).safeTransferFrom(_tokenOwner, _to, _tokenIds[index]); + index += 1; + } + nextIndex = index; + } + + /*/////////////////////////////////////////////////////////////// + Miscellaneous + //////////////////////////////////////////////////////////////*/ + + function _msgSender() + internal + view + virtual + override(ERC2771ContextUpgradeable, Multicall) + returns (address sender) + { + return ERC2771ContextUpgradeable._msgSender(); + } +} diff --git a/contracts/prebuilts/unaudited/loyalty/LoyaltyPoints.sol b/contracts/prebuilts/unaudited/loyalty/LoyaltyPoints.sol new file mode 100644 index 000000000..81e32da1a --- /dev/null +++ b/contracts/prebuilts/unaudited/loyalty/LoyaltyPoints.sol @@ -0,0 +1,249 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/// @author thirdweb + +// $$\ $$\ $$\ $$\ $$\ +// $$ | $$ | \__| $$ | $$ | +// $$$$$$\ $$$$$$$\ $$\ $$$$$$\ $$$$$$$ |$$\ $$\ $$\ $$$$$$\ $$$$$$$\ +// \_$$ _| $$ __$$\ $$ |$$ __$$\ $$ __$$ |$$ | $$ | $$ |$$ __$$\ $$ __$$\ +// $$ | $$ | $$ |$$ |$$ | \__|$$ / $$ |$$ | $$ | $$ |$$$$$$$$ |$$ | $$ | +// $$ |$$\ $$ | $$ |$$ |$$ | $$ | $$ |$$ | $$ | $$ |$$ ____|$$ | $$ | +// \$$$$ |$$ | $$ |$$ |$$ | \$$$$$$$ |\$$$$$\$$$$ |\$$$$$$$\ $$$$$$$ | +// \____/ \__| \__|\__|\__| \_______| \_____\____/ \_______|\_______/ + +// Interface +import "../../interface/ILoyaltyPoints.sol"; + +// Base +import "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; + +// Lib +import "../../../lib/CurrencyTransferLib.sol"; + +// Extensions +import "../../../extension/SignatureMintERC20Upgradeable.sol"; +import "../../../extension/ContractMetadata.sol"; +import "../../../extension/PrimarySale.sol"; +import "../../../extension/PlatformFee.sol"; +import "../../../extension/PermissionsEnumerable.sol"; +import "../../../external-deps/openzeppelin/metatx/ERC2771ContextUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/security/ReentrancyGuardUpgradeable.sol"; + +/** + * @title LoyaltyPoints + * + * @custom:description This contract is a loyalty points contract. Each token represents a loyalty point. Loyalty points can + * be cancelled (i.e. 'burned') by its owner or an approved operator. Loyalty points can be revoked + * (i.e. 'burned') without its owner's approval, by an admin of the contract. + */ + +contract LoyaltyPoints is + ILoyaltyPoints, + ContractMetadata, + PrimarySale, + PlatformFee, + PermissionsEnumerable, + ReentrancyGuardUpgradeable, + ERC2771ContextUpgradeable, + SignatureMintERC20Upgradeable, + ERC20Upgradeable +{ + /*/////////////////////////////////////////////////////////////// + State variables + //////////////////////////////////////////////////////////////*/ + + /// @dev Only TRANSFER_ROLE holders can have tokens transferred from or to them, during restricted transfers. + bytes32 private constant TRANSFER_ROLE = keccak256("TRANSFER_ROLE"); + /// @dev Only MINTER_ROLE holders can sign off on `MintRequest`s. + bytes32 private constant MINTER_ROLE = keccak256("MINTER_ROLE"); + /// @dev Only REVOKE_ROLE holders can revoke a loyalty card. + bytes32 private constant REVOKE_ROLE = keccak256("REVOKE_ROLE"); + + /// @dev Max bps in the thirdweb system. + uint256 private constant MAX_BPS = 10_000; + + /// @dev Mapping from token owner => total tokens minted to them in the contract's lifetime. + mapping(address => uint256) private _mintedToInLifetime; + + /*/////////////////////////////////////////////////////////////// + Constructor + initializer + //////////////////////////////////////////////////////////////*/ + + constructor() initializer {} + + /// @dev Initializes the contract, like a constructor. + function initialize( + address _defaultAdmin, + string memory _name, + string memory _symbol, + string memory _contractURI, + address[] memory _trustedForwarders, + address _saleRecipient, + uint128 _platformFeeBps, + address _platformFeeRecipient + ) external initializer { + // Initialize inherited contracts, most base-like -> most derived. + __ERC2771Context_init(_trustedForwarders); + __ERC20_init_unchained(_name, _symbol); + __SignatureMintERC20_init(_name); + __ReentrancyGuard_init(); + + _setupContractURI(_contractURI); + + _setupRole(DEFAULT_ADMIN_ROLE, _defaultAdmin); + _setupRole(MINTER_ROLE, _defaultAdmin); + _setupRole(TRANSFER_ROLE, _defaultAdmin); + + _setupRole(REVOKE_ROLE, _defaultAdmin); + _setRoleAdmin(REVOKE_ROLE, REVOKE_ROLE); + + _setupPlatformFeeInfo(_platformFeeRecipient, _platformFeeBps); + _setupPrimarySaleRecipient(_saleRecipient); + } + + /*/////////////////////////////////////////////////////////////// + View functions + //////////////////////////////////////////////////////////////*/ + + /// @notice Returns the total tokens minted to `owner` in the contract's lifetime. + function getTotalMintedInLifetime(address _owner) external view returns (uint256) { + return _mintedToInLifetime[_owner]; + } + + /*/////////////////////////////////////////////////////////////// + External functions + //////////////////////////////////////////////////////////////*/ + + /// @notice Mints tokens to a recipient using a signature from an authorized party. + function mintWithSignature( + MintRequest calldata _req, + bytes calldata _signature + ) external payable nonReentrant returns (address signer) { + signer = _processRequest(_req, _signature); + address receiver = _req.to; + + _collectPriceOnClaim(_req.primarySaleRecipient, _req.currency, _req.price); + _mintTo(receiver, _req.quantity); + + emit TokensMintedWithSignature(signer, receiver, _req); + } + + /// @notice Mints `amount` of tokens to the recipient `to`. + function mintTo(address to, uint256 amount) public virtual { + require(hasRole(MINTER_ROLE, _msgSender()), "not minter."); + _mintTo(to, amount); + } + + /// @notice Burns `amount` of tokens. See {ERC20-_burn}. + function cancel(address _owner, uint256 _amount) external virtual { + address caller = _msgSender(); + if (caller != _owner) { + _spendAllowance(_owner, caller, _amount); + } + _burn(_owner, _amount); + } + + /// @notice Burns `amount` of tokens from `owner`'s balance (without requiring approval from owner). See {ERC20-_burn}. + function revoke(address _owner, uint256 _amount) external virtual onlyRole(REVOKE_ROLE) { + _burn(_owner, _amount); + } + + /*/////////////////////////////////////////////////////////////// + Internal functions + //////////////////////////////////////////////////////////////*/ + + /// @dev Mints `amount` of tokens to `to` + function _mintTo(address _to, uint256 _amount) internal { + _mint(_to, _amount); + emit TokensMinted(_to, _amount); + } + + /// @dev Collects and distributes the primary sale value of tokens being minted. + function _collectPriceOnClaim(address _primarySaleRecipient, address _currency, uint256 _price) internal { + if (_price == 0) { + require(msg.value == 0, "!Value"); + return; + } + + bool validMsgValue; + if (_currency == CurrencyTransferLib.NATIVE_TOKEN) { + validMsgValue = msg.value == _price; + } else { + validMsgValue = msg.value == 0; + } + require(validMsgValue, "Invalid msg value"); + + address saleRecipient = _primarySaleRecipient == address(0) ? primarySaleRecipient() : _primarySaleRecipient; + + uint256 fees; + address feeRecipient; + + PlatformFeeType feeType = getPlatformFeeType(); + if (feeType == PlatformFeeType.Flat) { + (feeRecipient, fees) = getFlatPlatformFeeInfo(); + } else { + uint16 platformFeeBps; + (feeRecipient, platformFeeBps) = getPlatformFeeInfo(); + fees = (_price * platformFeeBps) / MAX_BPS; + } + + require(_price >= fees, "!F"); + + CurrencyTransferLib.transferCurrency(_currency, _msgSender(), feeRecipient, fees); + CurrencyTransferLib.transferCurrency(_currency, _msgSender(), saleRecipient, _price - fees); + } + + /// @dev Runs on every transfer. + function _beforeTokenTransfer(address from, address to, uint256 amount) internal override { + super._beforeTokenTransfer(from, to, amount); + + if (!hasRole(TRANSFER_ROLE, address(0)) && from != address(0) && to != address(0)) { + require(hasRole(TRANSFER_ROLE, from) || hasRole(TRANSFER_ROLE, to), "transfers restricted."); + } + + if (from == address(0)) { + _mintedToInLifetime[to] += amount; + } + } + + /// @dev Checks whether platform fee info can be set in the given execution context. + function _canSetPlatformFeeInfo() internal view override returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + /// @dev Checks whether primary sale recipient can be set in the given execution context. + function _canSetPrimarySaleRecipient() internal view override returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + /// @dev Checks whether contract metadata can be set in the given execution context. + function _canSetContractURI() internal view override returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + + /// @dev Returns whether a given address is authorized to sign mint requests. + function _isAuthorizedSigner(address _signer) internal view override returns (bool) { + return hasRole(MINTER_ROLE, _signer); + } + + function _msgSender() + internal + view + virtual + override(ContextUpgradeable, ERC2771ContextUpgradeable) + returns (address sender) + { + return ERC2771ContextUpgradeable._msgSender(); + } + + function _msgData() + internal + view + virtual + override(ContextUpgradeable, ERC2771ContextUpgradeable) + returns (bytes calldata) + { + return ERC2771ContextUpgradeable._msgData(); + } +} diff --git a/contracts/prebuilts/vote/VoteERC20.sol b/contracts/prebuilts/vote/VoteERC20.sol new file mode 100644 index 000000000..31360b91f --- /dev/null +++ b/contracts/prebuilts/vote/VoteERC20.sol @@ -0,0 +1,167 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/// @author thirdweb + +// $$\ $$\ $$\ $$\ $$\ +// $$ | $$ | \__| $$ | $$ | +// $$$$$$\ $$$$$$$\ $$\ $$$$$$\ $$$$$$$ |$$\ $$\ $$\ $$$$$$\ $$$$$$$\ +// \_$$ _| $$ __$$\ $$ |$$ __$$\ $$ __$$ |$$ | $$ | $$ |$$ __$$\ $$ __$$\ +// $$ | $$ | $$ |$$ |$$ | \__|$$ / $$ |$$ | $$ | $$ |$$$$$$$$ |$$ | $$ | +// $$ |$$\ $$ | $$ |$$ |$$ | $$ | $$ |$$ | $$ | $$ |$$ ____|$$ | $$ | +// \$$$$ |$$ | $$ |$$ |$$ | \$$$$$$$ |\$$$$$\$$$$ |\$$$$$$$\ $$$$$$$ | +// \____/ \__| \__|\__|\__| \_______| \_____\____/ \_______|\_______/ + +// Base +import "../../infra/interface/IThirdwebContract.sol"; + +// Governance +import "@openzeppelin/contracts-upgradeable/governance/GovernorUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/governance/utils/IVotesUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/governance/extensions/GovernorSettingsUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/governance/extensions/GovernorCountingSimpleUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/governance/extensions/GovernorVotesUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/governance/extensions/GovernorVotesQuorumFractionUpgradeable.sol"; + +// Meta transactions +import "../../external-deps/openzeppelin/metatx/ERC2771ContextUpgradeable.sol"; + +contract VoteERC20 is + Initializable, + IThirdwebContract, + ERC2771ContextUpgradeable, + GovernorUpgradeable, + GovernorSettingsUpgradeable, + GovernorCountingSimpleUpgradeable, + GovernorVotesUpgradeable, + GovernorVotesQuorumFractionUpgradeable +{ + bytes32 private constant MODULE_TYPE = bytes32("VoteERC20"); + uint256 private constant VERSION = 1; + + string public contractURI; + uint256 public proposalIndex; + + struct Proposal { + uint256 proposalId; + address proposer; + address[] targets; + uint256[] values; + string[] signatures; + bytes[] calldatas; + uint256 startBlock; + uint256 endBlock; + string description; + } + + /// @dev proposal index => Proposal + mapping(uint256 => Proposal) public proposals; + + // solhint-disable-next-line no-empty-blocks + constructor() initializer {} + + /// @dev Initializes the contract, like a constructor. + function initialize( + string memory _name, + string memory _contractURI, + address[] memory _trustedForwarders, + address _token, + uint256 _initialVotingDelay, + uint256 _initialVotingPeriod, + uint256 _initialProposalThreshold, + uint256 _initialVoteQuorumFraction + ) external initializer { + // Initialize inherited contracts, most base-like -> most derived. + __ERC2771Context_init(_trustedForwarders); + __Governor_init(_name); + __GovernorSettings_init(_initialVotingDelay, _initialVotingPeriod, _initialProposalThreshold); + __GovernorVotes_init(IVotesUpgradeable(_token)); + __GovernorVotesQuorumFraction_init(_initialVoteQuorumFraction); + + // Initialize this contract's state. + contractURI = _contractURI; + } + + /// @dev Returns the module type of the contract. + function contractType() external pure returns (bytes32) { + return MODULE_TYPE; + } + + /// @dev Returns the version of the contract. + function contractVersion() public pure override returns (uint8) { + return uint8(VERSION); + } + + /** + * @dev See {IGovernor-propose}. + */ + function propose( + address[] memory targets, + uint256[] memory values, + bytes[] memory calldatas, + string memory description + ) public virtual override returns (uint256 proposalId) { + proposalId = super.propose(targets, values, calldatas, description); + + proposals[proposalIndex] = Proposal({ + proposalId: proposalId, + proposer: _msgSender(), + targets: targets, + values: values, + signatures: new string[](targets.length), + calldatas: calldatas, + startBlock: proposalSnapshot(proposalId), + endBlock: proposalDeadline(proposalId), + description: description + }); + + proposalIndex += 1; + } + + /// @dev Returns all proposals made. + function getAllProposals() external view returns (Proposal[] memory allProposals) { + uint256 nextProposalIndex = proposalIndex; + + allProposals = new Proposal[](nextProposalIndex); + for (uint256 i = 0; i < nextProposalIndex; i += 1) { + allProposals[i] = proposals[i]; + } + } + + function setContractURI(string calldata uri) external onlyGovernance { + contractURI = uri; + } + + function proposalThreshold() + public + view + override(GovernorUpgradeable, GovernorSettingsUpgradeable) + returns (uint256) + { + return GovernorSettingsUpgradeable.proposalThreshold(); + } + + function supportsInterface(bytes4 interfaceId) public view override returns (bool) { + return interfaceId == type(IERC721ReceiverUpgradeable).interfaceId || super.supportsInterface(interfaceId); + } + + function _msgSender() + internal + view + virtual + override(ContextUpgradeable, ERC2771ContextUpgradeable) + returns (address sender) + { + return ERC2771ContextUpgradeable._msgSender(); + } + + function _msgData() + internal + view + virtual + override(ContextUpgradeable, ERC2771ContextUpgradeable) + returns (bytes calldata) + { + return ERC2771ContextUpgradeable._msgData(); + } +} diff --git a/foundry.toml b/foundry.toml new file mode 100644 index 000000000..55e866609 --- /dev/null +++ b/foundry.toml @@ -0,0 +1,64 @@ +[profile.default] +solc-version = "0.8.23" +#auto_detect_solc = false +cache = true +evm_version = 'london' +force = false +gas_reports = [ + "DropERC721Benchmark", + "DropERC20Benchmark", + "DropERC1155Benchmark", + "TokenERC20Benchmark", + "TokenERC721Benchmark", + "TokenERC1155Benchmark", + "MultiwrapBenchmark", + "SignatureDropBenchmark", + "AirdropERC20Benchmark", + "AirdropERC721Benchmark", + "AirdropERC1155Benchmark", + "NFTStakeBenchmark", + "EditionStakeBenchmark", + "TokenStakeBenchmark", + "PackBenchmark", + "PackVRFDirectBenchmark", + "AccountBenchmark", +] +libraries = [] +libs = ['lib'] +optimizer = true +optimizer_runs = 20 +out = 'artifacts_forge' +remappings = [ + '@chainlink/=lib/chainlink/', + '@openzeppelin/contracts=lib/openzeppelin-contracts/contracts', + '@openzeppelin/contracts-upgradeable/=lib/openzeppelin-contracts-upgradeable/contracts/', + '@ds-test=lib/ds-test/src/', + '@std=lib/forge-std/src/', + 'contracts/=contracts/', + 'erc721a-upgradeable/=lib/ERC721A-Upgradeable/', + 'erc721a/=lib/ERC721A/', + '@thirdweb-dev/dynamic-contracts/=lib/dynamic-contracts/', + 'lib/sstore2=lib/dynamic-contracts/lib/sstore2/', + 'solady/=lib/solady/', + '@seaport/=lib/seaport/contracts/', + 'seaport-types/=lib/seaport/lib/seaport-types/', + 'seaport-core/=lib/seaport/lib/seaport-core/' +] +fs_permissions = [{ access = "read-write", path = "./src/test/smart-wallet/utils"}] +src = 'contracts' +test = 'src/test' +verbosity = 0 +#ignored_error_codes = [] +#fuzz_runs = 100 +ffi = true +#sender = '0x00a329c0648769a73afac7f9381e08fb43dbea72' +#tx_origin = '0x00a329c0648769a73afac7f9381e08fb43dbea72' +#initial_balance = '0xffffffffffffffffffffffff' +#block_number = 0 +#chain_id = 1 +#gas_limit = 9223372036854775807 +#gas_price = 0 +#block_base_fee_per_gas = 0 +#block_coinbase = '0x0000000000000000000000000000000000000000' +#block_timestamp = 0 +#block_difficulty = 0 diff --git a/gasreport.txt b/gasreport.txt new file mode 100644 index 000000000..ce02c209e --- /dev/null +++ b/gasreport.txt @@ -0,0 +1,207 @@ +No files changed, compilation skipped + +Ran 2 tests for src/test/benchmark/MultiwrapBenchmark.t.sol:MultiwrapBenchmarkTest +[PASS] test_benchmark_multiwrap_unwrap() (gas: 152040) +[PASS] test_benchmark_multiwrap_wrap() (gas: 480722) +Suite result: ok. 2 passed; 0 failed; 0 skipped; finished in 665.73ms (584.46µs CPU time) + +Ran 5 tests for src/test/benchmark/SignatureDropBenchmark.t.sol:SignatureDropBenchmarkTest +[PASS] test_benchmark_signatureDrop_claim_five_tokens() (gas: 185688) +[PASS] test_benchmark_signatureDrop_lazyMint() (gas: 147153) +[PASS] test_benchmark_signatureDrop_lazyMint_for_delayed_reveal() (gas: 249057) +[PASS] test_benchmark_signatureDrop_reveal() (gas: 49802) +[PASS] test_benchmark_signatureDrop_setClaimConditions() (gas: 100719) +Suite result: ok. 5 passed; 0 failed; 0 skipped; finished in 665.92ms (942.96µs CPU time) + +Ran 3 tests for src/test/benchmark/EditionStakeBenchmark.t.sol:EditionStakeBenchmarkTest +[PASS] test_benchmark_editionStake_claimRewards() (gas: 98765) +[PASS] test_benchmark_editionStake_stake() (gas: 203676) +[PASS] test_benchmark_editionStake_withdraw() (gas: 94296) +Suite result: ok. 3 passed; 0 failed; 0 skipped; finished in 666.96ms (533.96µs CPU time) + +Ran 3 tests for src/test/benchmark/NFTStakeBenchmark.t.sol:NFTStakeBenchmarkTest +[PASS] test_benchmark_nftStake_claimRewards() (gas: 99831) +[PASS] test_benchmark_nftStake_stake_five_tokens() (gas: 553577) +[PASS] test_benchmark_nftStake_withdraw() (gas: 96144) +Suite result: ok. 3 passed; 0 failed; 0 skipped; finished in 669.06ms (710.17µs CPU time) + +Ran 1 test for src/test/smart-wallet/utils/AABenchmarkPrepare.sol:AABenchmarkPrepare +[PASS] test_prepareBenchmarkFile() (gas: 2955770) +Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 677.51ms (13.32ms CPU time) + +Ran 1 test for src/test/benchmark/AirdropERC20Benchmark.t.sol:AirdropERC20BenchmarkTest +[PASS] test_benchmark_airdropERC20_airdrop() (gas: 32443785) +Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 688.85ms (17.93ms CPU time) + +Ran 1 test for src/test/benchmark/AirdropERC721Benchmark.t.sol:AirdropERC721BenchmarkTest +[PASS] test_benchmark_airdropERC721_airdrop() (gas: 42241588) +Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 701.05ms (26.57ms CPU time) + +Ran 3 tests for src/test/benchmark/PackBenchmark.t.sol:PackBenchmarkTest +[PASS] test_benchmark_pack_addPackContents() (gas: 312595) +[PASS] test_benchmark_pack_createPack() (gas: 1419379) +[PASS] test_benchmark_pack_openPack() (gas: 302612) +Suite result: ok. 3 passed; 0 failed; 0 skipped; finished in 181.87ms (2.36ms CPU time) + +Ran 3 tests for src/test/benchmark/TokenERC20Benchmark.t.sol:TokenERC20BenchmarkTest +[PASS] test_benchmark_tokenERC20_mintTo() (gas: 139513) +[PASS] test_benchmark_tokenERC20_mintWithSignature_pay_with_ERC20() (gas: 221724) +[PASS] test_benchmark_tokenERC20_mintWithSignature_pay_with_native_token() (gas: 228786) +Suite result: ok. 3 passed; 0 failed; 0 skipped; finished in 188.65ms (1.10ms CPU time) + +Ran 4 tests for src/test/benchmark/TokenERC721Benchmark.t.sol:TokenERC721BenchmarkTest +[PASS] test_benchmark_tokenERC721_burn() (gas: 40392) +[PASS] test_benchmark_tokenERC721_mintTo() (gas: 172834) +[PASS] test_benchmark_tokenERC721_mintWithSignature_pay_with_ERC20() (gas: 301844) +[PASS] test_benchmark_tokenERC721_mintWithSignature_pay_with_native_token() (gas: 308814) +Suite result: ok. 4 passed; 0 failed; 0 skipped; finished in 192.61ms (1.31ms CPU time) + +Ran 4 tests for src/test/benchmark/TokenERC1155Benchmark.t.sol:TokenERC1155BenchmarkTest +[PASS] test_benchmark_tokenERC1155_burn() (gas: 30352) +[PASS] test_benchmark_tokenERC1155_mintTo() (gas: 144229) +[PASS] test_benchmark_tokenERC1155_mintWithSignature_pay_with_ERC20() (gas: 307291) +[PASS] test_benchmark_tokenERC1155_mintWithSignature_pay_with_native_token() (gas: 318712) +Suite result: ok. 4 passed; 0 failed; 0 skipped; finished in 225.96ms (1.36ms CPU time) + +Ran 3 tests for src/test/benchmark/TokenStakeBenchmark.t.sol:TokenStakeBenchmarkTest +[PASS] test_benchmark_tokenStake_claimRewards() (gas: 101098) +[PASS] test_benchmark_tokenStake_stake() (gas: 195556) +[PASS] test_benchmark_tokenStake_withdraw() (gas: 104792) +Suite result: ok. 3 passed; 0 failed; 0 skipped; finished in 196.93ms (479.46µs CPU time) + +Ran 1 test for src/test/benchmark/AirdropERC1155Benchmark.t.sol:AirdropERC1155BenchmarkTest +[PASS] test_benchmark_airdropERC1155_airdrop() (gas: 38536544) +Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 341.15ms (19.66ms CPU time) + +Ran 3 tests for src/test/benchmark/PackVRFDirectBenchmark.t.sol:PackVRFDirectBenchmarkTest +[PASS] test_benchmark_packvrf_createPack() (gas: 1392387) +[PASS] test_benchmark_packvrf_openPack() (gas: 150677) +[PASS] test_benchmark_packvrf_openPackAndClaimRewards() (gas: 3621) +Suite result: ok. 3 passed; 0 failed; 0 skipped; finished in 201.65ms (2.27ms CPU time) + +Ran 3 tests for src/test/benchmark/DropERC1155Benchmark.t.sol:DropERC1155BenchmarkTest +[PASS] test_benchmark_dropERC1155_claim() (gas: 245552) +[PASS] test_benchmark_dropERC1155_lazyMint() (gas: 146425) +[PASS] test_benchmark_dropERC1155_setClaimConditions_five_conditions() (gas: 525725) +Suite result: ok. 3 passed; 0 failed; 0 skipped; finished in 1.20s (706.01ms CPU time) + +Ran 2 tests for src/test/benchmark/DropERC20Benchmark.t.sol:DropERC20BenchmarkTest +[PASS] test_benchmark_dropERC20_claim() (gas: 291508) +[PASS] test_benchmark_dropERC20_setClaimConditions_five_conditions() (gas: 530026) +Suite result: ok. 2 passed; 0 failed; 0 skipped; finished in 510.75ms (589.86ms CPU time) + +Ran 23 tests for src/test/benchmark/AirdropBenchmark.t.sol:AirdropBenchmarkTest +[PASS] test_benchmark_airdropClaim_erc1155() (gas: 105358) +[PASS] test_benchmark_airdropClaim_erc20() (gas: 109724) +[PASS] test_benchmark_airdropClaim_erc721() (gas: 108870) +[PASS] test_benchmark_airdropPush_erc1155ReceiverCompliant() (gas: 82427) +[PASS] test_benchmark_airdropPush_erc1155_10() (gas: 370062) +[PASS] test_benchmark_airdropPush_erc1155_100() (gas: 3266571) +[PASS] test_benchmark_airdropPush_erc1155_1000() (gas: 32348198) +[PASS] test_benchmark_airdropPush_erc20_10() (gas: 345649) +[PASS] test_benchmark_airdropPush_erc20_100() (gas: 2976236) +[PASS] test_benchmark_airdropPush_erc20_1000() (gas: 29352084) +[PASS] test_benchmark_airdropPush_erc721ReceiverCompliant() (gas: 86434) +[PASS] test_benchmark_airdropPush_erc721_10() (gas: 426498) +[PASS] test_benchmark_airdropPush_erc721_100() (gas: 3837162) +[PASS] test_benchmark_airdropPush_erc721_1000() (gas: 38107847) +[PASS] test_benchmark_airdropSignature_erc115_10() (gas: 416712) +[PASS] test_benchmark_airdropSignature_erc115_100() (gas: 3458091) +[PASS] test_benchmark_airdropSignature_erc115_1000() (gas: 34334256) +[PASS] test_benchmark_airdropSignature_erc20_10() (gas: 389286) +[PASS] test_benchmark_airdropSignature_erc20_100() (gas: 3138882) +[PASS] test_benchmark_airdropSignature_erc20_1000() (gas: 30936576) +[PASS] test_benchmark_airdropSignature_erc721_10() (gas: 470201) +[PASS] test_benchmark_airdropSignature_erc721_100() (gas: 4009643) +[PASS] test_benchmark_airdropSignature_erc721_1000() (gas: 39692110) +Suite result: ok. 23 passed; 0 failed; 0 skipped; finished in 1.21s (1.25s CPU time) + +Ran 5 tests for src/test/benchmark/DropERC721Benchmark.t.sol:DropERC721BenchmarkTest +[PASS] test_benchmark_dropERC721_claim_five_tokens() (gas: 273303) +[PASS] test_benchmark_dropERC721_lazyMint() (gas: 147052) +[PASS] test_benchmark_dropERC721_lazyMint_for_delayed_reveal() (gas: 248985) +[PASS] test_benchmark_dropERC721_reveal() (gas: 49433) +[PASS] test_benchmark_dropERC721_setClaimConditions_five_conditions() (gas: 529470) +Suite result: ok. 5 passed; 0 failed; 0 skipped; finished in 466.63ms (512.92ms CPU time) + +Ran 14 tests for src/test/benchmark/AccountBenchmark.t.sol:AccountBenchmarkTest +[PASS] test_state_accountReceivesNativeTokens() (gas: 34537) +[PASS] test_state_addAndWithdrawDeposit() (gas: 148780) +[PASS] test_state_contractMetadata() (gas: 114307) +[PASS] test_state_createAccount_viaEntrypoint() (gas: 458192) +[PASS] test_state_createAccount_viaFactory() (gas: 355822) +[PASS] test_state_executeBatchTransaction() (gas: 76066) +[PASS] test_state_executeBatchTransaction_viaAccountSigner() (gas: 488470) +[PASS] test_state_executeBatchTransaction_viaEntrypoint() (gas: 138443) +[PASS] test_state_executeTransaction() (gas: 68891) +[PASS] test_state_executeTransaction_viaAccountSigner() (gas: 471272) +[PASS] test_state_executeTransaction_viaEntrypoint() (gas: 128073) +[PASS] test_state_receiveERC1155NFT() (gas: 66043) +[PASS] test_state_receiveERC721NFT() (gas: 100196) +[PASS] test_state_transferOutsNativeTokens() (gas: 133673) +Suite result: ok. 14 passed; 0 failed; 0 skipped; finished in 1.32s (26.86ms CPU time) + + +Ran 19 test suites in 1.45s (10.97s CPU time): 84 tests passed, 0 failed, 0 skipped (84 total tests) +test_benchmark_packvrf_openPackAndClaimRewards() (gas: 0 (0.000%)) +test_benchmark_pack_createPack() (gas: 6511 (0.461%)) +test_benchmark_airdropERC721_airdrop() (gas: 329052 (0.785%)) +test_benchmark_packvrf_createPack() (gas: 12783 (0.927%)) +test_prepareBenchmarkFile() (gas: 29400 (1.005%)) +test_benchmark_airdropERC20_airdrop() (gas: 375372 (1.171%)) +test_benchmark_airdropERC1155_airdrop() (gas: 452972 (1.189%)) +test_benchmark_multiwrap_wrap() (gas: 7260 (1.533%)) +test_benchmark_nftStake_stake_five_tokens() (gas: 14432 (2.677%)) +test_benchmark_dropERC721_setClaimConditions_five_conditions() (gas: 28976 (5.789%)) +test_benchmark_dropERC20_setClaimConditions_five_conditions() (gas: 29168 (5.824%)) +test_state_createAccount_viaEntrypoint() (gas: 26152 (6.053%)) +test_state_createAccount_viaFactory() (gas: 21700 (6.495%)) +test_benchmark_dropERC1155_setClaimConditions_five_conditions() (gas: 33604 (6.828%)) +test_benchmark_tokenERC1155_mintWithSignature_pay_with_native_token() (gas: 22540 (7.610%)) +test_benchmark_tokenERC721_mintWithSignature_pay_with_native_token() (gas: 21900 (7.633%)) +test_benchmark_editionStake_stake() (gas: 18532 (10.010%)) +test_benchmark_dropERC721_lazyMint_for_delayed_reveal() (gas: 22836 (10.098%)) +test_benchmark_tokenERC20_mintWithSignature_pay_with_native_token() (gas: 21092 (10.155%)) +test_benchmark_signatureDrop_lazyMint_for_delayed_reveal() (gas: 23166 (10.255%)) +test_benchmark_tokenStake_stake() (gas: 18376 (10.371%)) +test_benchmark_tokenERC721_mintTo() (gas: 21282 (14.043%)) +test_benchmark_tokenERC1155_mintWithSignature_pay_with_ERC20() (gas: 40116 (15.015%)) +test_benchmark_tokenERC721_mintWithSignature_pay_with_ERC20() (gas: 39500 (15.057%)) +test_benchmark_tokenERC20_mintTo() (gas: 20927 (17.647%)) +test_benchmark_tokenERC1155_mintTo() (gas: 21943 (17.944%)) +test_benchmark_dropERC721_lazyMint() (gas: 22512 (18.076%)) +test_benchmark_dropERC1155_lazyMint() (gas: 22512 (18.168%)) +test_benchmark_signatureDrop_lazyMint() (gas: 22842 (18.375%)) +test_benchmark_tokenERC20_mintWithSignature_pay_with_ERC20() (gas: 38692 (21.139%)) +test_state_executeBatchTransaction_viaAccountSigner() (gas: 95688 (24.362%)) +test_state_executeTransaction_viaAccountSigner() (gas: 92640 (24.467%)) +test_benchmark_packvrf_openPack() (gas: 30724 (25.613%)) +test_benchmark_dropERC20_claim() (gas: 61003 (26.465%)) +test_state_receiveERC721NFT() (gas: 21572 (27.437%)) +test_benchmark_dropERC721_claim_five_tokens() (gas: 62336 (29.548%)) +test_benchmark_signatureDrop_claim_five_tokens() (gas: 45171 (32.146%)) +test_benchmark_dropERC1155_claim() (gas: 60520 (32.708%)) +test_benchmark_signatureDrop_setClaimConditions() (gas: 27020 (36.663%)) +test_benchmark_pack_addPackContents() (gas: 93407 (42.615%)) +test_benchmark_nftStake_claimRewards() (gas: 31544 (46.193%)) +test_benchmark_tokenStake_claimRewards() (gas: 33544 (49.655%)) +test_benchmark_editionStake_claimRewards() (gas: 33684 (51.757%)) +test_state_transferOutsNativeTokens() (gas: 51960 (63.588%)) +test_state_executeBatchTransaction_viaEntrypoint() (gas: 55528 (66.970%)) +test_state_receiveERC1155NFT() (gas: 26700 (67.865%)) +test_state_executeTransaction_viaEntrypoint() (gas: 52480 (69.424%)) +test_benchmark_multiwrap_unwrap() (gas: 63090 (70.927%)) +test_state_addAndWithdrawDeposit() (gas: 65448 (78.539%)) +test_state_executeBatchTransaction() (gas: 36192 (90.766%)) +test_state_executeTransaction() (gas: 33156 (92.783%)) +test_state_contractMetadata() (gas: 57800 (102.288%)) +test_benchmark_editionStake_withdraw() (gas: 47932 (103.382%)) +test_benchmark_pack_openPack() (gas: 160752 (113.317%)) +test_benchmark_tokenStake_withdraw() (gas: 57396 (121.099%)) +test_benchmark_nftStake_withdraw() (gas: 58068 (152.506%)) +test_state_accountReceivesNativeTokens() (gas: 23500 (212.920%)) +test_benchmark_dropERC721_reveal() (gas: 35701 (259.984%)) +test_benchmark_tokenERC721_burn() (gas: 31438 (351.106%)) +test_benchmark_signatureDrop_reveal() (gas: 39155 (367.756%)) +test_benchmark_tokenERC1155_burn() (gas: 24624 (429.888%)) +Overall gas change: 3375923 (2.652%) diff --git a/hardhat.config.ts b/hardhat.config.ts new file mode 100644 index 000000000..4c6621e9a --- /dev/null +++ b/hardhat.config.ts @@ -0,0 +1,30 @@ +/** @type import('hardhat/config').HardhatUserConfig */ +import "@nomicfoundation/hardhat-toolbox"; +import "@nomicfoundation/hardhat-foundry"; +import "@matterlabs/hardhat-zksync-deploy"; +import "@matterlabs/hardhat-zksync-solc"; +import { HardhatUserConfig } from "hardhat/config"; + +const config: HardhatUserConfig = { + zksolc: { + version: "latest", // Uses latest available in https://github.com/matter-labs/zksolc-bin/ + settings: {}, + }, + defaultNetwork: "zkSyncTestnet", + networks: { + hardhat: { + zksync: false, + }, + zkSyncTestnet: { + url: "https://sepolia.era.zksync.dev", + ethNetwork: "sepolia", // or a Sepolia RPC endpoint from Infura/Alchemy/Chainstack etc. + zksync: true, + }, + }, + solidity: { + version: "0.8.23", + }, + // OTHER SETTINGS... +}; + +export default config; diff --git a/lib/ERC721A b/lib/ERC721A new file mode 160000 index 000000000..17fb77ffc --- /dev/null +++ b/lib/ERC721A @@ -0,0 +1 @@ +Subproject commit 17fb77ffce10bb9a2bb94cac1fea17e2bf9e8a27 diff --git a/lib/ERC721A-Upgradeable b/lib/ERC721A-Upgradeable new file mode 160000 index 000000000..80b4afb37 --- /dev/null +++ b/lib/ERC721A-Upgradeable @@ -0,0 +1 @@ +Subproject commit 80b4afb376ba1e886053c5aa82af852b3a09ba58 diff --git a/lib/chainlink b/lib/chainlink new file mode 160000 index 000000000..5d44bd4e8 --- /dev/null +++ b/lib/chainlink @@ -0,0 +1 @@ +Subproject commit 5d44bd4e8fa2bdc80228a0df891960d72246b645 diff --git a/lib/ds-test b/lib/ds-test new file mode 160000 index 000000000..e282159d5 --- /dev/null +++ b/lib/ds-test @@ -0,0 +1 @@ +Subproject commit e282159d5170298eb2455a6c05280ab5a73a4ef0 diff --git a/lib/dynamic-contracts b/lib/dynamic-contracts new file mode 160000 index 000000000..14af36f8f --- /dev/null +++ b/lib/dynamic-contracts @@ -0,0 +1 @@ +Subproject commit 14af36f8f3af50d7d4ccfe6f16df589b27edd662 diff --git a/lib/forge-std b/lib/forge-std new file mode 160000 index 000000000..2f1126975 --- /dev/null +++ b/lib/forge-std @@ -0,0 +1 @@ +Subproject commit 2f112697506eab12d433a65fdc31a639548fe365 diff --git a/lib/murky b/lib/murky new file mode 160000 index 000000000..40de6e801 --- /dev/null +++ b/lib/murky @@ -0,0 +1 @@ +Subproject commit 40de6e80117f39cda69d71b07b7c824adac91b29 diff --git a/lib/openzeppelin-contracts b/lib/openzeppelin-contracts new file mode 160000 index 000000000..fd81a96f0 --- /dev/null +++ b/lib/openzeppelin-contracts @@ -0,0 +1 @@ +Subproject commit fd81a96f01cc42ef1c9a5399364968d0e07e9e90 diff --git a/lib/openzeppelin-contracts-upgradeable b/lib/openzeppelin-contracts-upgradeable new file mode 160000 index 000000000..3d4c0d574 --- /dev/null +++ b/lib/openzeppelin-contracts-upgradeable @@ -0,0 +1 @@ +Subproject commit 3d4c0d5741b131c231e558d7a6213392ab3672a5 diff --git a/lib/seaport b/lib/seaport new file mode 160000 index 000000000..1d12e33b7 --- /dev/null +++ b/lib/seaport @@ -0,0 +1 @@ +Subproject commit 1d12e33b71b6988cbbe955373ddbc40a87bd5b16 diff --git a/lib/seaport-core b/lib/seaport-core new file mode 160000 index 000000000..d4e8c74ad --- /dev/null +++ b/lib/seaport-core @@ -0,0 +1 @@ +Subproject commit d4e8c74adc472b311ab64b5c9f9757b5bba57a15 diff --git a/lib/seaport-sol b/lib/seaport-sol new file mode 160000 index 000000000..040d00576 --- /dev/null +++ b/lib/seaport-sol @@ -0,0 +1 @@ +Subproject commit 040d005768abafe3308b5f996aca3fd843d9c20e diff --git a/lib/seaport-types b/lib/seaport-types new file mode 160000 index 000000000..25bae8ddf --- /dev/null +++ b/lib/seaport-types @@ -0,0 +1 @@ +Subproject commit 25bae8ddfa8709e5c51ab429fe06024e46a18f15 diff --git a/lib/solady b/lib/solady new file mode 160000 index 000000000..c6738e402 --- /dev/null +++ b/lib/solady @@ -0,0 +1 @@ +Subproject commit c6738e40225288842ce890cd265a305684e52c3d diff --git a/package.json b/package.json new file mode 100644 index 000000000..09f8ca953 --- /dev/null +++ b/package.json @@ -0,0 +1,82 @@ +{ + "name": "@thirdweb-dev/contracts", + "description": "", + "version": "3.11.4", + "license": "Apache-2.0", + "source": "typechain/index.ts", + "files": [ + "/contracts/**/*.sol" + ], + "devDependencies": { + "@matterlabs/hardhat-zksync-deploy": "^1.3.0", + "@matterlabs/hardhat-zksync-solc": "^1.1.4", + "@nomicfoundation/hardhat-chai-matchers": "^2.0.0", + "@nomicfoundation/hardhat-ethers": "^3.0.0", + "@nomicfoundation/hardhat-foundry": "^1.1.1", + "@nomicfoundation/hardhat-network-helpers": "^1.0.0", + "@nomicfoundation/hardhat-toolbox": "^4.0.0", + "@nomicfoundation/hardhat-verify": "^2.0.0", + "@openzeppelin/contracts": "^4.9.3", + "@openzeppelin/contracts-upgradeable": "^4.9.3", + "@thirdweb-dev/dynamic-contracts": "^1.2.4", + "@thirdweb-dev/merkletree": "^0.2.2", + "@typechain/ethers-v5": "^10.2.1", + "@typechain/ethers-v6": "^0.5.0", + "@typechain/hardhat": "^9.0.0", + "@types/chai": "^4.2.0", + "@types/fs-extra": "^9.0.13", + "@types/mocha": "^9.1.1", + "@types/node": "^17.0.45", + "@typescript-eslint/eslint-plugin": "^5.62.0", + "@typescript-eslint/parser": "^5.62.0", + "chai": "^4.2.0", + "dotenv": "^16.3.1", + "erc721a": "3.3.0", + "erc721a-upgradeable": "^3.3.0", + "eslint": "^8.54.0", + "eslint-config-prettier": "^8.10.0", + "ethers": "^5.7.2", + "fs-extra": "^10.1.0", + "hardhat": "^2.19.1", + "hardhat-gas-reporter": "^1.0.8", + "keccak256": "^1.0.6", + "mocha": "^9.2.2", + "prettier": "^2.8.8", + "prettier-plugin-solidity": "^1.2.0", + "solady": "0.0.180", + "solhint": "^3.6.2", + "solhint-plugin-prettier": "^0.0.5", + "solidity-coverage": "^0.8.1", + "ts-node": "^10.9.1", + "tslib": "^2.6.2", + "tsup": "^5.12.9", + "typechain": "^8.3.2", + "typescript": "^4.9.5", + "@matterlabs/hardhat-zksync": "^0.1.0", + "@matterlabs/zksync-contracts": "^0.6.1", + "@nomiclabs/hardhat-etherscan": "^3.1.7", + "zksync-ethers": "^5.7.0" + }, + "peerDependencies": { + "ethers": "^5.0.0" + }, + "resolutions": { + "typescript": "^5.3.2" + }, + "scripts": { + "clean": "forge clean && rm -rf abi/ && rm -rf artifacts_forge/ && rm -rf contract_artifacts && rm -rf dist/ && rm -rf typechain/", + "compile": "forge build && npx ts-node scripts/package-release.ts", + "lint": "solhint \"contracts/**/*.sol\"", + "prettier": "prettier --config .prettierrc --write --plugin=prettier-plugin-solidity '{contracts,src}/**/*.sol'", + "prettier:list-different": "prettier --config .prettierrc --plugin=prettier-plugin-solidity --list-different '**/*.sol'", + "prettier:contracts": "prettier --config .prettierrc --plugin=prettier-plugin-solidity --list-different '{contracts,src}/**/*.sol'", + "test": "forge test", + "typechain": "typechain --target ethers-v5 --out-dir ./typechain artifacts_forge/**/*.json", + "build": "yarn clean && yarn compile", + "forge:build": "forge build", + "forge:test": "forge test", + "gas": "forge snapshot --isolate --mc Benchmark --gas-report --diff .gas-snapshot > gasreport.txt", + "forge:snapshot": "forge snapshot --check", + "aabenchmark": "forge test --mc AABenchmarkPrepare && forge test --mc ProfileThirdwebAccount -vvv" + } +} diff --git a/release.sh b/release.sh new file mode 100755 index 000000000..4ecc4eb49 --- /dev/null +++ b/release.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash + +set -e + +POSITIONAL_ARGS=() + +while [[ $# -gt 0 ]]; do + case $1 in + --local) + local=1 + shift # past argument + ;; + --no-build) + skip_build=1 + shift # past argument + ;; + -*|--*) + echo "Unknown option $1" + exit 1 + ;; + *) + POSITIONAL_ARGS+=("$1") # save positional arg + shift # past argument + ;; + esac +done + +set -- "${POSITIONAL_ARGS[@]}" # restore positional parameters + +echo "### Release script started..." +if [[ $skip_build -eq 0 ]]; then +yarn build +fi +echo "### Build finished. Copying abis." +rm -rf contracts/abi +mkdir -p contracts/abi +# copy all abis to contracts/abi +find contract_artifacts ! -iregex ".*([a-zA-Z0-9_]).json" -exec cp {} contracts/abi 2>/dev/null \; +echo "### Copying README." +# copy root README to contracts folder +cp README.md contracts/README.md +# publish from contracts folder +cd contracts +echo "### Publishing..." +if [[ $local -eq 1 ]]; then +yalc push +else +np --any-branch --no-tests +fi +# delete copied README +rm README.md +# delete copied README +rm -rf node_modules +# delete copied README +rm -rf abi +# back to root folder +cd - +echo "### Done." \ No newline at end of file diff --git a/scripts/deploy-prebuilt-deterministic/bootstrap-on-a-chain.ts b/scripts/deploy-prebuilt-deterministic/bootstrap-on-a-chain.ts new file mode 100644 index 000000000..6d38efed3 --- /dev/null +++ b/scripts/deploy-prebuilt-deterministic/bootstrap-on-a-chain.ts @@ -0,0 +1,173 @@ +import "dotenv/config"; +import { + ThirdwebSDK, + computeCloneFactoryAddress, + deployContractDeterministic, + deployCreate2Factory, + deployWithThrowawayDeployer, + fetchAndCacheDeployMetadata, + getCreate2FactoryAddress, + getDeploymentInfo, + getThirdwebContractAddress, + isContractDeployed, + resolveAddress, +} from "@thirdweb-dev/sdk"; +import { Signer } from "ethers"; +import { apiMap, chainIdApiKey, contractsToDeploy } from "./constants"; + +////// To run this script: `npx ts-node scripts/deploy-prebuilt-deterministic/bootstrap-on-a-chain.ts` ////// +///// MAKE SURE TO PUT IN THE RIGHT CONTRACT NAME HERE AFTER PUBLISHING IT ///// +//// THE CONTRACT SHOULD BE PUBLISHED WITH THE NEW PUBLISH FLOW //// + +const publisherKey: string = process.env.THIRDWEB_PUBLISHER_PRIVATE_KEY as string; +const deployerKey: string = process.env.PRIVATE_KEY as string; + +const polygonSDK = ThirdwebSDK.fromPrivateKey(publisherKey, "polygon"); + +const chainId = "8453"; // update here +const networkName = "base"; // update here + +async function main() { + const publisher = await polygonSDK.wallet.getAddress(); + + const sdk = ThirdwebSDK.fromPrivateKey(deployerKey, chainId); // can also hardcode the chain here + const signer = sdk.getSigner() as Signer; + + console.log("balance: ", await sdk.wallet.balance()); + + // Deploy CREATE2 factory (if not already exists) + const create2FactoryAddress = await getCreate2FactoryAddress(sdk.getProvider()); + if (await isContractDeployed(create2FactoryAddress, sdk.getProvider())) { + console.log(`-- Create2 factory already present at ${create2FactoryAddress}\n`); + } else { + console.log(`-- Deploying Create2 factory at ${create2FactoryAddress}\n`); + await deployCreate2Factory(signer, {}); + } + + // TWStatelessFactory (Clone factory) + const cloneFactoryAddress = await computeCloneFactoryAddress(sdk.getProvider(), sdk.storage, create2FactoryAddress); + if (await isContractDeployed(cloneFactoryAddress, sdk.getProvider())) { + console.log(`-- TWCloneFactory present at ${cloneFactoryAddress}\n`); + } + + for (const publishedContractName of contractsToDeploy) { + const latest = await polygonSDK.getPublisher().getLatest(publisher, publishedContractName); + + if (latest && latest.metadataUri) { + const { extendedMetadata } = await fetchAndCacheDeployMetadata(latest?.metadataUri, polygonSDK.storage); + + const isNetworkEnabled = + extendedMetadata?.networksForDeployment?.networksEnabled.includes(parseInt(chainId)) || + extendedMetadata?.networksForDeployment?.allNetworks; + + if (extendedMetadata?.networksForDeployment && !isNetworkEnabled) { + console.log(`Deployment of ${publishedContractName} disabled on ${networkName}\n`); + continue; + } + + console.log(`Deploying ${publishedContractName} on ${networkName}`); + + // const chainId = (await sdk.getProvider().getNetwork()).chainId; + + try { + const implAddr = await getThirdwebContractAddress(publishedContractName, parseInt(chainId), sdk.storage); + if (implAddr) { + console.log(`implementation ${implAddr} already deployed on chainId: ${chainId}`); + console.log(); + continue; + } + } catch (error) {} + + try { + // any evm deployment flow + + // get deployment info for any evm + const deploymentInfo = await getDeploymentInfo( + latest.metadataUri, + sdk.storage, + sdk.getProvider(), + create2FactoryAddress, + ); + + const implementationAddress = deploymentInfo.find(i => i.type === "implementation")?.transaction + .predictedAddress as string; + + const isDeployed = await isContractDeployed(implementationAddress, sdk.getProvider()); + if (isDeployed) { + console.log(`implementation ${implementationAddress} already deployed on chainId: ${chainId}`); + console.log(); + continue; + } + + console.log("Deploying as", await signer?.getAddress()); + // filter out already deployed contracts (data is empty) + const transactionsToSend = deploymentInfo.filter(i => i.transaction.data && i.transaction.data.length > 0); + const transactionsforDirectDeploy = transactionsToSend + .filter(i => { + return i.type !== "infra"; + }) + .map(i => i.transaction); + const transactionsForThrowawayDeployer = transactionsToSend + .filter(i => { + return i.type === "infra"; + }) + .map(i => i.transaction); + + // deploy via throwaway deployer, multiple infra contracts in one transaction + if (transactionsForThrowawayDeployer.length > 0) { + console.log("-- Deploying Infra"); + await deployWithThrowawayDeployer(signer, transactionsForThrowawayDeployer, {}); + } + + const resolvedImplementationAddress = await resolveAddress(implementationAddress); + + console.log(`-- Deploying ${publishedContractName} at ${resolvedImplementationAddress}`); + // send each transaction directly to Create2 factory + // process txns one at a time + for (const tx of transactionsforDirectDeploy) { + try { + await deployContractDeterministic(signer, tx, {}); + } catch (e) { + console.debug(`Error deploying contract at ${tx.predictedAddress}`, (e as any)?.message); + } + } + console.log(); + } catch (e) { + console.log("Error while deploying: ", e); + console.log(); + continue; + } + } else { + console.log("No previous release found"); + return; + } + } + + console.log("Deployments done."); + console.log(); + + console.log("---------- Verification ---------"); + console.log(); + for (const publishedContractName of contractsToDeploy) { + try { + await sdk.verifier.verifyThirdwebContract( + publishedContractName, + apiMap[parseInt(chainId)], + chainIdApiKey[parseInt(chainId)] as string, + ); + console.log(); + } catch (error) { + console.log(error); + console.log(); + } + } + + console.log("All done."); +} + +main() + .then(() => process.exit(0)) + .catch(e => { + console.error(e); + process.exit(1); + }); diff --git a/scripts/deploy-prebuilt-deterministic/bootstrap-verify.ts b/scripts/deploy-prebuilt-deterministic/bootstrap-verify.ts new file mode 100644 index 000000000..992bc0fc5 --- /dev/null +++ b/scripts/deploy-prebuilt-deterministic/bootstrap-verify.ts @@ -0,0 +1,35 @@ +import { ThirdwebSDK } from "@thirdweb-dev/sdk"; + +import { apiMap, chainIdApiKey, contractsToDeploy } from "./constants"; + +////// To run this script: `npx ts-node scripts/deploy-prebuilt-deterministic/bootstrap-verify.ts` ////// +const chainId = "8453"; // update here + +async function main() { + console.log("---------- Verification ---------"); + console.log(); + + const sdk = new ThirdwebSDK(chainId); + for (const publishedContractName of contractsToDeploy) { + try { + await sdk.verifier.verifyThirdwebContract( + publishedContractName, + apiMap[parseInt(chainId)], + chainIdApiKey[parseInt(chainId)] as string, + ); + console.log(); + } catch (error) { + console.log(error); + console.log(); + } + } + + console.log("All done."); +} + +main() + .then(() => process.exit(0)) + .catch(e => { + console.error(e); + process.exit(1); + }); diff --git a/scripts/deploy-prebuilt-deterministic/constants.ts b/scripts/deploy-prebuilt-deterministic/constants.ts new file mode 100644 index 000000000..d17207c71 --- /dev/null +++ b/scripts/deploy-prebuilt-deterministic/constants.ts @@ -0,0 +1,125 @@ +import { + Arbitrum, + ArbitrumGoerli, + Avalanche, + AvalancheFuji, + Base, + BaseGoerli, + CeloAlfajoresTestnet, + Ethereum, + Goerli, + Linea, + LineaTestnet, + Mumbai, + Optimism, + OptimismGoerli, + Polygon, + Sepolia, +} from "@thirdweb-dev/chains"; +import { ChainId } from "@thirdweb-dev/sdk"; +import dotenv from "dotenv"; + +dotenv.config(); + +export const DEFAULT_CHAINS = [ + Ethereum, + Goerli, + Sepolia, + Polygon, + Mumbai, + Optimism, + OptimismGoerli, + Arbitrum, + ArbitrumGoerli, + Avalanche, + AvalancheFuji, + Base, + BaseGoerli, + Linea, + LineaTestnet, + CeloAlfajoresTestnet, +]; + +export const chainIdToName: Record = { + [ChainId.Mumbai]: "mumbai", + [ChainId.Goerli]: "goerli", + [ChainId.Polygon]: "polygon", + [ChainId.Mainnet]: "mainnet", + [ChainId.Optimism]: "optimism", + [ChainId.OptimismGoerli]: "optimism-goerli", + [ChainId.Arbitrum]: "arbitrum", + [ChainId.ArbitrumGoerli]: "arbitrum-goerli", + [ChainId.Avalanche]: "avalanche", + [ChainId.AvalancheFujiTestnet]: "avalanche-testnet", + [84531]: "base-goerli", + [8453]: "base", +}; + +export const chainIdApiKey: Record = { + [ChainId.Mumbai]: process.env.POLYGONSCAN_API_KEY || process.env.SCAN_API_KEY, + [ChainId.Goerli]: process.env.ETHERSCAN_API_KEY || process.env.SCAN_API_KEY, + [Sepolia.chainId]: process.env.ETHERSCAN_API_KEY || process.env.SCAN_API_KEY, + [ChainId.Polygon]: process.env.POLYGONSCAN_API_KEY || process.env.SCAN_API_KEY, + [ChainId.Mainnet]: process.env.ETHERSCAN_API_KEY || process.env.SCAN_API_KEY, + [ChainId.Optimism]: process.env.OPTIMISM_SCAN_API_KEY || process.env.SCAN_API_KEY, + [ChainId.OptimismGoerli]: process.env.OPTIMISM_SCAN_API_KEY || process.env.SCAN_API_KEY, + [ChainId.Arbitrum]: process.env.ARBITRUM_SCAN_API_KEY || process.env.SCAN_API_KEY, + [ChainId.ArbitrumGoerli]: process.env.ARBITRUM_SCAN_API_KEY || process.env.SCAN_API_KEY, + [ChainId.Fantom]: process.env.FANTOMSCAN_API_KEY || process.env.SCAN_API_KEY, + [ChainId.FantomTestnet]: process.env.FANTOMSCAN_API_KEY || process.env.SCAN_API_KEY, + [ChainId.Avalanche]: process.env.SNOWTRACE_API_KEY || process.env.SCAN_API_KEY, + [ChainId.AvalancheFujiTestnet]: process.env.SNOWTRACE_API_KEY || process.env.SCAN_API_KEY, + [ChainId.BinanceSmartChainMainnet]: process.env.BINANCE_SCAN_API_KEY || process.env.SCAN_API_KEY, + [ChainId.BinanceSmartChainTestnet]: process.env.BINANCE_SCAN_API_KEY || process.env.SCAN_API_KEY, + [Base.chainId]: process.env.BASE_SCAN_API_KEY || process.env.SCAN_API_KEY, + [BaseGoerli.chainId]: process.env.BASE_SCAN_API_KEY || process.env.SCAN_API_KEY, + [Linea.chainId]: process.env.LINEA_SCAN_API_KEY || process.env.SCAN_API_KEY, + [LineaTestnet.chainId]: process.env.LINEA_SCAN_API_KEY || process.env.SCAN_API_KEY, +}; + +export const apiMap: Record = { + 1: "https://api.etherscan.io/api", + 5: "https://api-goerli.etherscan.io/api", + [Sepolia.chainId]: "https://api-sepolia.etherscan.io/api", + 10: "https://api-optimistic.etherscan.io/api", + 56: "https://api.bscscan.com/api", + 97: "https://api-testnet.bscscan.com/api", + 137: "https://api.polygonscan.com/api", + 250: "https://api.ftmscan.com/api", + 420: "https://api-goerli-optimistic.etherscan.io/api", + 4002: "https://api-testnet.ftmscan.com/api", + 42161: "https://api.arbiscan.io/api", + 43113: "https://api-testnet.snowtrace.io/api", + 43114: "https://api.snowtrace.io/api", + 421613: "https://api-goerli.arbiscan.io/api", + 80001: "https://api-testnet.polygonscan.com/api", + 84531: "https://api-goerli.basescan.org/api", + 8453: "https://api.basescan.org/api", + [Linea.chainId]: "https://api.lineascan.build/api", + [LineaTestnet.chainId]: "https://api-testnet.lineascan.build/api", +}; + +export const contractsToDeploy = [ + "OpenEditionERC721", + "DropERC721", + "DropERC1155", + "DropERC20", + "TokenERC20", + "TokenERC721", + "TokenERC1155", + "Split", + "VoteERC20", + "NFTStake", + "TokenStake", + "EditionStake", + "AirdropERC20", + "AirdropERC721", + "AirdropERC1155", + "DirectListingsLogic", + "EnglishAuctionsLogic", + "OffersLogic", + "MarketplaceV3", + // "Forwarder", + // "TWCloneFactory", + // "PluginMap", +]; diff --git a/scripts/deploy-prebuilt-deterministic/deploy-deterministic-std-chains.ts b/scripts/deploy-prebuilt-deterministic/deploy-deterministic-std-chains.ts new file mode 100644 index 000000000..2e961fa87 --- /dev/null +++ b/scripts/deploy-prebuilt-deterministic/deploy-deterministic-std-chains.ts @@ -0,0 +1,179 @@ +import "dotenv/config"; +import { + ThirdwebSDK, + computeCloneFactoryAddress, + deployContractDeterministic, + deployCreate2Factory, + deployWithThrowawayDeployer, + fetchAndCacheDeployMetadata, + getCreate2FactoryAddress, + getDeploymentInfo, + getThirdwebContractAddress, + isContractDeployed, + resolveAddress, +} from "@thirdweb-dev/sdk"; +import { Signer } from "ethers"; +import { DEFAULT_CHAINS, apiMap, chainIdApiKey } from "./constants"; + +////// To run this script: `npx ts-node scripts/deploy-prebuilt-deterministic/deploy-deterministic-std-chains.ts` ////// +///// MAKE SURE TO PUT IN THE RIGHT CONTRACT NAME HERE AFTER PUBLISHING IT ///// +//// THE CONTRACT SHOULD BE PUBLISHED WITH THE NEW PUBLISH FLOW //// +const publishedContractName = "MarketplaceV3"; +const publisherAddress: string = "deployer.thirdweb.eth"; +const deployerKey: string = process.env.PRIVATE_KEY as string; +const secretKey: string = process.env.THIRDWEB_SECRET_KEY as string; + +const polygonSDK = new ThirdwebSDK("polygon", { secretKey }); + +async function main() { + const latest = await polygonSDK.getPublisher().getLatest(publisherAddress, publishedContractName); + + if (latest && latest.metadataUri) { + const { extendedMetadata } = await fetchAndCacheDeployMetadata(latest?.metadataUri, polygonSDK.storage); + + for (const chain of DEFAULT_CHAINS) { + const isNetworkEnabled = + extendedMetadata?.networksForDeployment?.networksEnabled.includes(chain.chainId) || + extendedMetadata?.networksForDeployment?.allNetworks; + + if (extendedMetadata?.networksForDeployment && !isNetworkEnabled) { + console.log(`Deployment of ${publishedContractName} disabled on ${chain.slug}\n`); + continue; + } + + console.log(`Deploying ${publishedContractName} on ${chain.slug}`); + const sdk = ThirdwebSDK.fromPrivateKey(deployerKey, chain, { secretKey }); // can also hardcode the chain here + const signer = sdk.getSigner() as Signer; + // const chainId = (await sdk.getProvider().getNetwork()).chainId; + + try { + const implAddr = await getThirdwebContractAddress( + publishedContractName, + chain.chainId, + sdk.storage, + "latest", + sdk.options.clientId, + sdk.options.secretKey, + ); + if (implAddr) { + console.log(`implementation ${implAddr} already deployed on chainId: ${chain.slug}`); + console.log(); + continue; + } + } catch (error) { + // no-op + } + + try { + console.log("Deploying as", await sdk.wallet.getAddress()); + console.log("Balance", await sdk.wallet.balance().then(b => b.displayValue)); + // any evm deployment flow + + // Deploy CREATE2 factory (if not already exists) + const create2FactoryAddress = await getCreate2FactoryAddress(sdk.getProvider()); + if (await isContractDeployed(create2FactoryAddress, sdk.getProvider())) { + console.log(`-- Create2 factory already present at ${create2FactoryAddress}`); + } else { + console.log(`-- Deploying Create2 factory at ${create2FactoryAddress}`); + await deployCreate2Factory(signer, {}); + } + + // TWStatelessFactory (Clone factory) + const cloneFactoryAddress = await computeCloneFactoryAddress( + sdk.getProvider(), + sdk.storage, + create2FactoryAddress, + sdk.options.clientId, + sdk.options.secretKey, + ); + if (await isContractDeployed(cloneFactoryAddress, sdk.getProvider())) { + console.log(`-- TWCloneFactory already present at ${cloneFactoryAddress}`); + } + + // get deployment info for any evm + const deploymentInfo = await getDeploymentInfo( + latest.metadataUri, + sdk.storage, + sdk.getProvider(), + create2FactoryAddress, + sdk.options.clientId, + sdk.options.secretKey, + ); + + const implementationAddress = deploymentInfo.find(i => i.type === "implementation")?.transaction + .predictedAddress as string; + + // filter out already deployed contracts (data is empty) + const transactionsToSend = deploymentInfo.filter(i => i.transaction.data && i.transaction.data.length > 0); + const transactionsforDirectDeploy = transactionsToSend + .filter(i => { + return i.type !== "infra"; + }) + .map(i => i.transaction); + const transactionsForThrowawayDeployer = transactionsToSend + .filter(i => { + return i.type === "infra"; + }) + .map(i => i.transaction); + + // deploy via throwaway deployer, multiple infra contracts in one transaction + if (transactionsForThrowawayDeployer.length > 0) { + console.log("-- Deploying Infra"); + await deployWithThrowawayDeployer(signer, transactionsForThrowawayDeployer, {}); + } + + const resolvedImplementationAddress = await resolveAddress(implementationAddress); + + console.log(`-- Deploying ${publishedContractName} at ${resolvedImplementationAddress}`); + // send each transaction directly to Create2 factory + // process txns one at a time + for (const tx of transactionsforDirectDeploy) { + try { + await deployContractDeterministic(signer, tx, {}); + } catch (e) { + console.debug(`Error deploying contract at ${tx.predictedAddress}`, (e as any)?.message); + } + } + console.log(); + } catch (e) { + console.log("Error while deploying: ", e); + console.log(); + continue; + } + } + + console.log("Deployments done."); + console.log(); + console.log("---------- Verification ---------"); + console.log(); + for (const chain of DEFAULT_CHAINS) { + const sdk = new ThirdwebSDK(chain, { + secretKey, + }); + console.log("Verifying on: ", chain.slug); + try { + await sdk.verifier.verifyThirdwebContract( + publishedContractName, + apiMap[chain.chainId], + chainIdApiKey[chain.chainId] as string, + ); + console.log(); + } catch (error) { + console.log(error); + console.log(); + } + } + } else { + console.log("No previous release found"); + return; + } + + console.log("All done."); +} + +main() + .then(() => process.exit(0)) + .catch(e => { + console.error(e); + process.exit(1); + }); diff --git a/scripts/deploy-prebuilt-deterministic/verify.ts b/scripts/deploy-prebuilt-deterministic/verify.ts new file mode 100644 index 000000000..e8ba37f4d --- /dev/null +++ b/scripts/deploy-prebuilt-deterministic/verify.ts @@ -0,0 +1,42 @@ +import { ThirdwebSDK } from "@thirdweb-dev/sdk"; + +import { DEFAULT_CHAINS, apiMap, chainIdApiKey } from "./constants"; + +////// To run this script: `npx ts-node scripts/deploy-prebuilt-deterministic/verify.ts` ////// +const deployedContractName = "AccountExtension"; +const secretKey: string = process.env.THIRDWEB_SECRET_KEY as string; + +async function main() { + console.log("---------- Verification ---------"); + console.log(); + for (const chain of DEFAULT_CHAINS) { + const sdk = new ThirdwebSDK(chain, { + secretKey, + }); + console.log("Network: ", chain.slug); + try { + await sdk.verifier.verifyThirdwebContract( + deployedContractName, + apiMap[chain.chainId], + chainIdApiKey[chain.chainId] as string, + ); + console.log(); + } catch (error) { + if ((error as Error)?.message?.includes("already verified")) { + console.log("Already verified"); + } else { + console.log(error); + } + console.log(); + } + } + + console.log("All done."); +} + +main() + .then(() => process.exit(0)) + .catch(e => { + console.error(e); + process.exit(1); + }); diff --git a/scripts/package-release.ts b/scripts/package-release.ts new file mode 100644 index 000000000..8babd386d --- /dev/null +++ b/scripts/package-release.ts @@ -0,0 +1,59 @@ +import * as fs from "fs-extra"; +import * as path from "path"; + +// Define the paths for the directories +const artifactsForgeDir = path.join(__dirname, "..", "artifacts_forge"); +const contractsDir = path.join(__dirname, "..", "contracts"); +const contractArtifactsDir = path.join(__dirname, "..", "contract_artifacts"); + +const specialCases: string[] = [ + "IRouterState.sol", + "BaseRouter.sol", + "ExtensionManager.sol", + "MockContractPublisher.sol", +]; + +async function getAllSolidityFiles(dir: string): Promise { + const dirents = await fs.readdir(dir, { withFileTypes: true }); + const files = await Promise.all( + dirents.map(dirent => { + const res = path.join(dir, dirent.name); + return dirent.isDirectory() ? getAllSolidityFiles(res) : res; + }), + ); + // Flatten the array and filter for .sol files + return files + .flat() + .filter(file => file.endsWith(".sol")) + .map(file => path.basename(file)); +} + +async function main() { + // Create the contract_artifacts directory + await fs.ensureDir(contractArtifactsDir); + + // Get all directories within artifacts_forge that match *.sol + const artifactDirs = await fs.readdir(artifactsForgeDir); + const validArtifactDirs = artifactDirs.filter(dir => dir.endsWith(".sol")); + + // Get all .sol filenames within contracts (recursively) + const validContractFiles = await getAllSolidityFiles(contractsDir); + + // Check if directory-name matches any Solidity file name from contracts + for (const artifactDir of validArtifactDirs) { + // Removing the .sol extension from the directory name to match with file names + const artifactName = path.basename(artifactDir, ".sol"); + + if (validContractFiles.includes(artifactName + ".sol") || specialCases.includes(artifactName + ".sol")) { + const sourcePath = path.join(artifactsForgeDir, artifactDir); + const destinationPath = path.join(contractArtifactsDir, artifactDir); + await fs.copy(sourcePath, destinationPath); + } + } + + console.log("Done copying matching directories."); +} + +main().catch(error => { + console.error("An error occurred:", error); +}); diff --git a/scripts/release/add_implementations_from_release.ts b/scripts/release/add_implementations_from_release.ts new file mode 100644 index 000000000..a1ee0e31b --- /dev/null +++ b/scripts/release/add_implementations_from_release.ts @@ -0,0 +1,81 @@ +import "dotenv/config"; +import { SUPPORTED_CHAIN_ID, ThirdwebSDK } from "@thirdweb-dev/sdk"; +import { readFileSync } from "fs"; +import { chainIdToName } from "./constants"; + +////// To run this script: `npx ts-node scripts/release/add_implementations_from_release.ts` ////// +///// MAKE SURE TO PUT IN THE RIGHT CONTRACT NAME HERE AFTER CREATING A RELEASE FOR IT ///// +//// THE RELEASE SHOULD HAVE THE IMPLEMENTATIONS ALREADY DEPLOYED AND RECORDED (via dashboard) //// +const releasedContractName = "Multiwrap"; +const privateKey: string = process.env.THIRDWEB_PUBLISHER_PRIVATE_KEY as string; + +const polygonSDK = ThirdwebSDK.fromPrivateKey(privateKey, "polygon"); + +async function main() { + const releaser = await polygonSDK.wallet.getAddress(); + console.log("Releasing as", releaser); + + const latest = await polygonSDK.getPublisher().getLatest(releaser, releasedContractName); + + if (latest && latest.metadataUri) { + console.log(latest); + const prev = await polygonSDK.getPublisher().fetchPublishedContractInfo(latest); + + console.log("Fetched latest version", prev); + const prevReleaseMetadata = prev.publishedMetadata; + + const implementations = prev.publishedMetadata.factoryDeploymentData?.implementationAddresses; + console.log("Implementations", implementations); + + if (!implementations) { + console.log("No implementations to approve"); + return; + } + + // Adding implementations + console.log("Adding implementations..."); + for (const [chainId, implementation] of Object.entries(implementations)) { + const chainName = chainIdToName[parseInt(chainId) as SUPPORTED_CHAIN_ID]; + + if (!chainName) { + console.log("No chainName found for chain: ", chainId); + continue; + } + + const chainSDK = ThirdwebSDK.fromPrivateKey(privateKey, chainName); + const factoryAddr = prevReleaseMetadata?.factoryDeploymentData?.factoryAddresses?.[chainId]; + if (implementation && factoryAddr) { + const factory = await chainSDK.getContractFromAbi( + factoryAddr, + JSON.parse(readFileSync("artifacts_forge/TWFactory.sol/TWFactory.json", "utf-8")).abi, + ); + const approved = await factory.call("approval", implementation); + if (!approved) { + try { + console.log("Adding implementation", implementation, "on", chainName, "to", factoryAddr); + await factory.call("addImplementation", implementation); + } catch (e) { + console.log("Failed to add implementation on", chainName, e); + } + } else { + console.log("Implementation", implementation, "already approved on", chainName); + } + } else { + console.log("No implementation or factory address for", chainName); + } + } + } else { + console.log("No previous release found"); + return; + } + + console.log("All done."); + console.log("Release page:", `https://thirdweb.com/${releaser}/${releasedContractName}`); +} + +main() + .then(() => process.exit(0)) + .catch(e => { + console.error(e); + process.exit(1); + }); diff --git a/scripts/release/approve_implementations_from_release.ts b/scripts/release/approve_implementations_from_release.ts new file mode 100644 index 000000000..862057795 --- /dev/null +++ b/scripts/release/approve_implementations_from_release.ts @@ -0,0 +1,81 @@ +import "dotenv/config"; +import { SUPPORTED_CHAIN_ID, ThirdwebSDK } from "@thirdweb-dev/sdk"; +import { readFileSync } from "fs"; +import { chainIdToName } from "./constants"; + +////// To run this script: `npx ts-node scripts/release/approve_implementations_from_release.ts` ////// +///// MAKE SURE TO PUT IN THE RIGHT CONTRACT NAME HERE AFTER CREATING A RELEASE FOR IT ///// +//// THE RELEASE SHOULD HAVE THE IMPLEMENTATIONS ALREADY DEPLOYED AND RECORDED (via dashboard) //// +const releasedContractName = "TokenERC721"; +const privateKey: string = process.env.THIRDWEB_PUBLISHER_PRIVATE_KEY as string; + +const polygonSDK = ThirdwebSDK.fromPrivateKey(privateKey, "polygon"); + +async function main() { + const releaser = await polygonSDK.wallet.getAddress(); + console.log("Releasing as", releaser); + + const latest = await polygonSDK.getPublisher().getLatest(releaser, releasedContractName); + + if (latest && latest.metadataUri) { + console.log(latest); + const prev = await polygonSDK.getPublisher().fetchPublishedContractInfo(latest); + + console.log("Fetched latest version", prev); + const prevReleaseMetadata = prev.publishedMetadata; + + const implementations = prev.publishedMetadata.factoryDeploymentData?.implementationAddresses; + console.log("Implementations", implementations); + + if (!implementations) { + console.log("No implementations to approve"); + return; + } + + // Approving implementations + console.log("Approving implementations..."); + for (const [chainId, implementation] of Object.entries(implementations)) { + const chainName = chainIdToName[parseInt(chainId) as SUPPORTED_CHAIN_ID]; + + if (!chainName) { + console.log("No chainName found for chain: ", chainId); + continue; + } + + const chainSDK = ThirdwebSDK.fromPrivateKey(privateKey, chainName); + const factoryAddr = prevReleaseMetadata?.factoryDeploymentData?.factoryAddresses?.[chainId]; + if (implementation && factoryAddr) { + const factory = await chainSDK.getContractFromAbi( + factoryAddr, + JSON.parse(readFileSync("artifacts_forge/TWFactory.sol/TWFactory.json", "utf-8")).abi, + ); + const approved = await factory.call("approval", implementation); + if (!approved) { + try { + console.log("Approving implementation", implementation, "on", chainName, "to", factoryAddr); + await factory.call("approveImplementation", implementation, true); + } catch (e) { + console.log("Failed to approve implementation on", chainName, e); + } + } else { + console.log("Implementation", implementation, "already approved on", chainName); + } + } else { + console.log("No implementation or factory address for", chainName); + } + } + } else { + console.log("No previous release found"); + return; + } + + console.log("All done."); + console.log("Release page:", `https://thirdweb.com/${releaser}/${releasedContractName}`); +} + +main() + .then(() => process.exit(0)) + .catch(e => { + console.error(e); + process.exit(1); + }); diff --git a/scripts/release/constants.ts b/scripts/release/constants.ts new file mode 100644 index 000000000..d61d9d9cd --- /dev/null +++ b/scripts/release/constants.ts @@ -0,0 +1,50 @@ +import { ChainId, CONTRACT_ADDRESSES } from "@thirdweb-dev/sdk"; + +export const nativeTokenWrapper: Record = { + 1: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", // mainnet + 5: "0xB4FBF271143F4FBf7B91A5ded31805e42b2208d6", // goerli + 137: "0x0d500B1d8E8eF31E21C99d1Db9A6444d3ADf1270", // polygon + 80001: "0x9c3C9283D3e44854697Cd22D3Faa240Cfb032889", // mumbai + 43114: "0xB31f66AA3C1e785363F0875A1B74E27b85FD66c7", // avalanche + 43113: "0xd00ae08403B9bbb9124bB305C09058E32C39A48c", // avalanche fuji testnet + 250: "0x21be370D5312f44cB42ce377BC9b8a0cEF1A4C83", // fantom + 4002: "0xf1277d1Ed8AD466beddF92ef448A132661956621", // fantom testnet + 10: "0x4200000000000000000000000000000000000006", // optimism + 420: "0x4200000000000000000000000000000000000006", // optimism goerli + 42161: "0x82af49447d8a07e3bd95bd0d56f35241523fbab1", // arbitrum + 421613: "0xe39Ab88f8A4777030A534146A9Ca3B52bd5D43A3", // arbitrum goerli +}; + +export const chainIdToName: Record = { + [ChainId.Mumbai]: "mumbai", + [ChainId.Goerli]: "goerli", + [ChainId.Polygon]: "polygon", + [ChainId.Mainnet]: "mainnet", + [ChainId.Optimism]: "optimism", + [ChainId.OptimismGoerli]: "optimism-goerli", + [ChainId.Arbitrum]: "arbitrum", + [ChainId.ArbitrumGoerli]: "arbitrum-goerli", + [ChainId.Fantom]: "fantom", + [ChainId.FantomTestnet]: "fantom-testnet", + [ChainId.Avalanche]: "avalanche", + [ChainId.AvalancheFujiTestnet]: "avalanche-testnet", + [ChainId.BinanceSmartChainMainnet]: "binance", + [ChainId.BinanceSmartChainTestnet]: "binance-testnet", +}; + +export const defaultFactories: Record = { + [ChainId.Mainnet]: CONTRACT_ADDRESSES[ChainId.Mainnet].twFactory, + [ChainId.Goerli]: CONTRACT_ADDRESSES[ChainId.Goerli].twFactory, + [ChainId.Polygon]: CONTRACT_ADDRESSES[ChainId.Polygon].twFactory, + [ChainId.Mumbai]: CONTRACT_ADDRESSES[ChainId.Mumbai].twFactory, + [ChainId.Fantom]: CONTRACT_ADDRESSES[ChainId.Fantom].twFactory, + [ChainId.FantomTestnet]: CONTRACT_ADDRESSES[ChainId.FantomTestnet].twFactory, + [ChainId.Optimism]: CONTRACT_ADDRESSES[ChainId.Optimism].twFactory, + [ChainId.OptimismGoerli]: CONTRACT_ADDRESSES[ChainId.OptimismGoerli].twFactory, + [ChainId.Arbitrum]: CONTRACT_ADDRESSES[ChainId.Arbitrum].twFactory, + [ChainId.ArbitrumGoerli]: CONTRACT_ADDRESSES[ChainId.ArbitrumGoerli].twFactory, + [ChainId.Avalanche]: CONTRACT_ADDRESSES[ChainId.Avalanche].twFactory, + [ChainId.AvalancheFujiTestnet]: CONTRACT_ADDRESSES[ChainId.AvalancheFujiTestnet].twFactory, + [ChainId.BinanceSmartChainMainnet]: CONTRACT_ADDRESSES[ChainId.BinanceSmartChainMainnet].twFactory, + [ChainId.BinanceSmartChainTestnet]: CONTRACT_ADDRESSES[ChainId.BinanceSmartChainTestnet].twFactory, +}; diff --git a/slither.config.json b/slither.config.json new file mode 100644 index 000000000..8454a6ca2 --- /dev/null +++ b/slither.config.json @@ -0,0 +1,10 @@ +{ + "solc_remaps": ["@openzeppelin=node_modules/@openzeppelin", "@chainlink=node_modules/@chainlink"], + "detectors_to_exclude": "external-function,naming-convention,solc-version,dead-code,block-timestamp,missing-zero-check,delegatecall-loop,msg-value-loop", + "filter_paths": "node_modules|openzeppelin-presets", + "exclude_informational": false, + "exclude_low": false, + "exclude_medium": false, + "exclude_high": false, + "disable_color": false +} diff --git a/src/test/ContractPublisher.t.sol b/src/test/ContractPublisher.t.sol new file mode 100644 index 000000000..e80fe7d43 --- /dev/null +++ b/src/test/ContractPublisher.t.sol @@ -0,0 +1,487 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +// Target contracts +import { ContractPublisher } from "contracts/infra/ContractPublisher.sol"; +import "contracts/infra/interface/IContractPublisher.sol"; +import "contracts/infra/TWRegistry.sol"; + +// Test helpers +import { BaseTest, MockContractPublisher } from "./utils/BaseTest.sol"; +import "@openzeppelin/contracts/utils/Create2.sol"; + +contract MockCustomContract { + uint256 public num; + + constructor(uint256 _num) { + num = _num; + } +} + +contract IContractPublisherData { + /// @dev Emitted when the registry is paused. + event Paused(bool isPaused); + + /// @dev Emitted when a publisher's approval of an operator is updated. + event Approved(address indexed publisher, address indexed operator, bool isApproved); + + /// @dev Emitted when a contract is published. + event ContractPublished( + address indexed operator, + address indexed publisher, + IContractPublisher.CustomContractInstance publishedContract + ); + + /// @dev Emitted when a contract is unpublished. + event ContractUnpublished(address indexed operator, address indexed publisher, string indexed contractId); + + /// @dev Emitted when a published contract is added to the public list. + event AddedContractToPublicList(address indexed publisher, string indexed contractId); + + /// @dev Emitted when a published contract is removed from the public list. + event RemovedContractToPublicList(address indexed publisher, string indexed contractId); +} + +contract ContractPublisherTest is BaseTest, IContractPublisherData { + ContractPublisher internal byoc; + TWRegistry internal twRegistry; + + address internal publisher; + address internal operator; + address internal deployerOfPublished; + + string internal publishMetadataUri = "ipfs://QmeXyz"; + string internal compilerMetadataUri = "ipfs://QmeXyz"; + + function setUp() public override { + super.setUp(); + + byoc = ContractPublisher(contractPublisher); + twRegistry = TWRegistry(registry); + + publisher = getActor(0); + operator = getActor(1); + deployerOfPublished = getActor(2); + } + + function test_publish() public { + string memory contractId = "MyContract"; + vm.prank(publisher); + byoc.publishContract( + publisher, + contractId, + publishMetadataUri, + compilerMetadataUri, + keccak256(type(MockCustomContract).creationCode), + address(0) + ); + + IContractPublisher.CustomContractInstance memory customContract = byoc.getPublishedContract( + publisher, + contractId + ); + + assertEq(customContract.contractId, contractId); + assertEq(customContract.publishMetadataUri, publishMetadataUri); + assertEq(customContract.bytecodeHash, keccak256(type(MockCustomContract).creationCode)); + assertEq(customContract.implementation, address(0)); + } + + // Deprecated + // function test_publish_viaOperator() public { + // string memory contractId = "MyContract"; + + // vm.prank(publisher); + // byoc.approveOperator(operator, true); + + // vm.prank(operator); + // byoc.publishContract( + // publisher, + // publishMetadataUri, + // keccak256(type(MockCustomContract).creationCode), + // address(0), + // contractId + // ); + + // IContractPublisher.CustomContractInstance memory customContract = byoc.getPublishedContract( + // publisher, + // contractId + // ); + + // assertEq(customContract.contractId, contractId); + // assertEq(customContract.publishMetadataUri, publishMetadataUri); + // assertEq(customContract.bytecodeHash, keccak256(type(MockCustomContract).creationCode)); + // assertEq(customContract.implementation, address(0)); + // } + + function test_state_setPrevPublisher() public { + // === when prevPublisher address is address(0) + vm.prank(factoryAdmin); + byoc.setPrevPublisher(IContractPublisher(address(0))); + + assertEq(byoc.getAllPublishedContracts(publisher).length, 0); + assertEq(address(byoc.prevPublisher()), address(0)); + + string memory contractId = "MyContract"; + vm.prank(publisher); + byoc.publishContract( + publisher, + contractId, + publishMetadataUri, + compilerMetadataUri, + keccak256(type(MockCustomContract).creationCode), + address(0) + ); + IContractPublisher.CustomContractInstance[] memory contracts = byoc.getAllPublishedContracts(publisher); + assertEq(contracts.length, 1); + assertEq(contracts[0].contractId, "MyContract"); + + // === when prevPublisher address is set to MockPublisher + address mock = address(new MockContractPublisher()); + vm.prank(factoryAdmin); + byoc.setPrevPublisher(IContractPublisher(mock)); + + contracts = byoc.getAllPublishedContracts(publisher); + assertEq(contracts.length, 2); + assertEq(address(byoc.prevPublisher()), mock); + assertEq(contracts[0].contractId, "MockContract"); + assertEq(contracts[1].contractId, "MyContract"); + } + + function test_revert_setPrevPublisher() public { + vm.expectRevert("Not authorized"); + byoc.setPrevPublisher(IContractPublisher(address(0))); + } + + function test_state_setPublisherProfileUri() public { + address user = address(0x123); + string memory uriOne = "ipfs://one"; + string memory uriTwo = "ipfs://two"; + + // user updating for self + vm.prank(user); + byoc.setPublisherProfileUri(user, uriOne); + assertEq(byoc.getPublisherProfileUri(user), uriOne); + + // random caller + vm.prank(address(0x345)); + vm.expectRevert("Registry paused or caller not authorized"); + byoc.setPublisherProfileUri(user, uriOne); + + // MIGRATION_ROLE holder updating for a user + vm.prank(factoryAdmin); + byoc.setPublisherProfileUri(user, uriTwo); + assertEq(byoc.getPublisherProfileUri(user), uriTwo); + } + + function test_state_setPublisherProfileUri_whenPaused() public { + vm.prank(factoryAdmin); + byoc.setPause(true); + address user = address(0x123); + string memory uriOne = "ipfs://one"; + string memory uriTwo = "ipfs://two"; + + // user updating for self + vm.prank(user); + vm.expectRevert("Registry paused or caller not authorized"); + byoc.setPublisherProfileUri(user, uriOne); + + // MIGRATION_ROLE holder updating for a user + vm.prank(factoryAdmin); + byoc.setPublisherProfileUri(user, uriTwo); + assertEq(byoc.getPublisherProfileUri(user), uriTwo); + } + + function test_publish_revert_unapprovedCaller() public { + string memory contractId = "MyContract"; + + vm.expectRevert("unapproved caller"); + + vm.prank(operator); + byoc.publishContract( + publisher, + contractId, + publishMetadataUri, + compilerMetadataUri, + keccak256(type(MockCustomContract).creationCode), + address(0) + ); + } + + function test_publish_revert_registryPaused() public { + string memory contractId = "MyContract"; + + vm.prank(factoryAdmin); + byoc.setPause(true); + + vm.expectRevert("registry paused"); + + vm.prank(publisher); + byoc.publishContract( + publisher, + contractId, + publishMetadataUri, + compilerMetadataUri, + keccak256(type(MockCustomContract).creationCode), + address(0) + ); + } + + function test_publish_multiple_versions() public { + string memory contractId = "MyContract"; + + vm.prank(publisher); + byoc.publishContract( + publisher, + contractId, + publishMetadataUri, + compilerMetadataUri, + keccak256(type(MockCustomContract).creationCode), + address(0) + ); + string[] memory resolved = byoc.getPublishedUriFromCompilerUri(compilerMetadataUri); + assertEq(resolved.length, 1); + assertEq(resolved[0], publishMetadataUri); + + string memory otherUri = "ipfs://abcd"; + vm.prank(publisher); + byoc.publishContract( + publisher, + contractId, + publishMetadataUri, + otherUri, + keccak256(type(MockCustomContract).creationCode), + address(0) + ); + + string[] memory resolved2 = byoc.getPublishedUriFromCompilerUri(otherUri); + assertEq(resolved2.length, 1); + assertEq(resolved2[0], publishMetadataUri); + } + + function test_read_from_linked_publisher() public { + IContractPublisher.CustomContractInstance[] memory contracts = byoc.getAllPublishedContracts(publisher); + assertEq(contracts.length, 1); + assertEq(contracts[0].contractId, "MockContract"); + + string memory contractId = "MyContract"; + vm.prank(publisher); + byoc.publishContract( + publisher, + contractId, + publishMetadataUri, + compilerMetadataUri, + keccak256(type(MockCustomContract).creationCode), + address(0) + ); + IContractPublisher.CustomContractInstance[] memory contracts2 = byoc.getAllPublishedContracts(publisher); + assertEq(contracts2.length, 2); + assertEq(contracts2[0].contractId, "MockContract"); + assertEq(contracts2[1].contractId, "MyContract"); + } + + // Deprecated + // function test_publish_emit_ContractPublished() public { + // string memory contractId = "MyContract"; + + // vm.prank(publisher); + // byoc.approveOperator(operator, true); + + // IContractPublisher.CustomContractInstance memory expectedCustomContract = IContractPublisher + // .CustomContractInstance({ + // contractId: contractId, + // publishTimestamp: 100, + // publishMetadataUri: publishMetadataUri, + // bytecodeHash: keccak256(type(MockCustomContract).creationCode), + // implementation: address(0) + // }); + + // vm.expectEmit(true, true, true, true); + // emit ContractPublished(operator, publisher, expectedCustomContract); + + // vm.warp(100); + // vm.prank(operator); + // byoc.publishContract( + // publisher, + // publishMetadataUri, + // keccak256(type(MockCustomContract).creationCode), + // address(0), + // contractId + // ); + // } + + function test_unpublish_state() public { + string memory contractId = "MyContract"; + + vm.startPrank(publisher); + byoc.publishContract( + publisher, + contractId, + "publish URI 1", + compilerMetadataUri, + keccak256(type(MockCustomContract).creationCode), + address(0) + ); + byoc.publishContract( + publisher, + contractId, + "publish URI 2", + compilerMetadataUri, + keccak256(type(MockCustomContract).creationCode), + address(0) + ); + byoc.publishContract( + publisher, + contractId, + "publish URI 3", + compilerMetadataUri, + keccak256(type(MockCustomContract).creationCode), + address(0) + ); + + vm.stopPrank(); + + IContractPublisher.CustomContractInstance[] memory allCustomContractsBefore = byoc.getPublishedContractVersions( + publisher, + contractId + ); + assertEq(allCustomContractsBefore.length, 3); + + vm.prank(publisher); + byoc.unpublishContract(publisher, contractId); + + IContractPublisher.CustomContractInstance memory customContract = byoc.getPublishedContract( + publisher, + contractId + ); + + assertEq(customContract.contractId, ""); + assertEq(customContract.publishMetadataUri, ""); + assertEq(customContract.bytecodeHash, bytes32(0)); + assertEq(customContract.implementation, address(0)); + + IContractPublisher.CustomContractInstance[] memory allCustomContracts = byoc.getPublishedContractVersions( + publisher, + contractId + ); + + assertEq(allCustomContracts.length, 0); + + vm.prank(publisher); + byoc.publishContract( + publisher, + contractId, + "publish URI 4", + compilerMetadataUri, + keccak256(type(MockCustomContract).creationCode), + address(0) + ); + + IContractPublisher.CustomContractInstance memory customContractRepublish = byoc.getPublishedContract( + publisher, + contractId + ); + + assertEq(customContractRepublish.contractId, contractId); + assertEq(customContractRepublish.publishMetadataUri, "publish URI 4"); + + IContractPublisher.CustomContractInstance[] memory allCustomContractsRepublish = byoc + .getPublishedContractVersions(publisher, contractId); + + assertEq(allCustomContractsRepublish.length, 1); + } + + // Deprecated + // function test_unpublish_viaOperator() public { + // string memory contractId = "MyContract"; + + // vm.prank(publisher); + // byoc.publishContract( + // publisher, + // publishMetadataUri, + // keccak256(type(MockCustomContract).creationCode), + // address(0), + // contractId + // ); + + // vm.prank(publisher); + // byoc.approveOperator(operator, true); + + // vm.prank(operator); + // byoc.unpublishContract(publisher, contractId); + + // IContractPublisher.CustomContractInstance memory customContract = byoc.getPublishedContract( + // publisher, + // contractId + // ); + + // assertEq(customContract.contractId, ""); + // assertEq(customContract.publishMetadataUri, ""); + // assertEq(customContract.bytecodeHash, bytes32(0)); + // assertEq(customContract.implementation, address(0)); + // } + + function test_unpublish_revert_unapprovedCaller() public { + string memory contractId = "MyContract"; + + vm.prank(publisher); + byoc.publishContract( + publisher, + contractId, + publishMetadataUri, + compilerMetadataUri, + keccak256(type(MockCustomContract).creationCode), + address(0) + ); + + vm.expectRevert("unapproved caller"); + + vm.prank(operator); + byoc.unpublishContract(publisher, contractId); + } + + function test_unpublish_revert_registryPaused() public { + string memory contractId = "MyContract"; + + vm.prank(publisher); + byoc.publishContract( + publisher, + contractId, + publishMetadataUri, + compilerMetadataUri, + keccak256(type(MockCustomContract).creationCode), + address(0) + ); + + vm.prank(factoryAdmin); + byoc.setPause(true); + + vm.expectRevert("registry paused"); + + vm.prank(publisher); + byoc.unpublishContract(publisher, contractId); + } + + // Deprecated + // function test_unpublish_emit_ContractUnpublished() public { + // string memory contractId = "MyContract"; + + // vm.prank(publisher); + // byoc.publishContract( + // publisher, + // publishMetadataUri, + // keccak256(type(MockCustomContract).creationCode), + // address(0), + // contractId + // ); + + // vm.prank(publisher); + // byoc.approveOperator(operator, true); + + // vm.expectEmit(true, true, true, true); + // emit ContractUnpublished(operator, publisher, contractId); + + // vm.prank(operator); + // byoc.unpublishContract(publisher, contractId); + // } +} diff --git a/src/test/EvolvingNFT.t.sol b/src/test/EvolvingNFT.t.sol new file mode 100644 index 000000000..8a695b420 --- /dev/null +++ b/src/test/EvolvingNFT.t.sol @@ -0,0 +1,1208 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { IExtension } from "@thirdweb-dev/dynamic-contracts/src/interface/IExtension.sol"; + +import { EvolvingNFT } from "contracts/prebuilts/evolving-nfts/EvolvingNFT.sol"; +import { EvolvingNFTLogic } from "contracts/prebuilts/evolving-nfts/EvolvingNFTLogic.sol"; +import { RulesEngineExtension } from "contracts/prebuilts/evolving-nfts/extension/RulesEngineExtension.sol"; + +import { IDrop } from "contracts/extension/interface/IDrop.sol"; +import { Drop } from "contracts/extension/upgradeable/Drop.sol"; +import { SharedMetadataBatch } from "contracts/extension/upgradeable/SharedMetadataBatch.sol"; +import { ISharedMetadataBatch } from "contracts/extension/interface/ISharedMetadataBatch.sol"; +import { RulesEngine, IRulesEngine } from "contracts/extension/upgradeable/RulesEngine.sol"; +import { NFTMetadataRenderer } from "contracts/lib/NFTMetadataRenderer.sol"; +import { TWProxy } from "contracts/infra/TWProxy.sol"; +import { PermissionsEnumerable as DynamicPermissionsEnumerable } from "contracts/extension/upgradeable/PermissionsEnumerable.sol"; + +// Test imports +import "erc721a-upgradeable/contracts/IERC721AUpgradeable.sol"; +import { Strings } from "contracts/lib/Strings.sol"; +import { Permissions } from "contracts/extension/Permissions.sol"; +import { IERC721 } from "./mocks/MockERC721.sol"; +import "./utils/BaseTest.sol"; + +contract EvolvingNFTTest is BaseTest { + using Strings for uint256; + using Strings for address; + + event SharedMetadataUpdated( + bytes32 indexed id, + string name, + string description, + string imageURI, + string animationURI + ); + + address public evolvingNFT; + + mapping(uint256 => ISharedMetadataBatch.SharedMetadataInfo) public sharedMetadataBatch; + + bytes private emptyEncodedBytes = abi.encode("", ""); + + // Scores + uint256 private score1 = 10; + uint256 private score2 = 40; + uint256 private score3 = 100; + + using stdStorage for StdStorage; + + function setUp() public override { + super.setUp(); + + // Setting up default extension. + IExtension.Extension memory evolvingNftExtension; + IExtension.Extension memory permissionsExtension; + IExtension.Extension memory rulesEngineExtension; + + evolvingNftExtension.metadata = IExtension.ExtensionMetadata({ + name: "EvolvingNFTLogic", + metadataURI: "ipfs://EvolvingNFTLogic", + implementation: address(new EvolvingNFTLogic()) + }); + permissionsExtension.metadata = IExtension.ExtensionMetadata({ + name: "Permissions", + metadataURI: "ipfs://Permissions", + implementation: address(new DynamicPermissionsEnumerable()) + }); + rulesEngineExtension.metadata = IExtension.ExtensionMetadata({ + name: "RulesEngine", + metadataURI: "ipfs://RulesEngine", + implementation: address(new RulesEngineExtension()) + }); + + evolvingNftExtension.functions = new IExtension.ExtensionFunction[](11); + rulesEngineExtension.functions = new IExtension.ExtensionFunction[](4); + permissionsExtension.functions = new IExtension.ExtensionFunction[](4); + + rulesEngineExtension.functions[0] = IExtension.ExtensionFunction( + RulesEngine.getScore.selector, + "getScore(address)" + ); + rulesEngineExtension.functions[1] = IExtension.ExtensionFunction( + RulesEngine.createRuleThreshold.selector, + "createRuleThreshold((address,uint8,uint256,uint256,uint256))" + ); + rulesEngineExtension.functions[2] = IExtension.ExtensionFunction( + RulesEngine.deleteRule.selector, + "deleteRule(bytes32)" + ); + rulesEngineExtension.functions[3] = IExtension.ExtensionFunction( + RulesEngine.getRulesEngineOverride.selector, + "getRulesEngineOverride()" + ); + evolvingNftExtension.functions[0] = IExtension.ExtensionFunction( + IDrop.claim.selector, + "claim(address,uint256,address,uint256,(bytes32[],uint256,uint256,address),bytes)" + ); + evolvingNftExtension.functions[1] = IExtension.ExtensionFunction( + SharedMetadataBatch.setSharedMetadata.selector, + "setSharedMetadata((string,string,string,string),bytes32)" + ); + evolvingNftExtension.functions[2] = IExtension.ExtensionFunction( + IDrop.setClaimConditions.selector, + "setClaimConditions((uint256,uint256,uint256,uint256,bytes32,uint256,address,string)[],bool)" + ); + evolvingNftExtension.functions[3] = IExtension.ExtensionFunction( + EvolvingNFTLogic.tokenURI.selector, + "tokenURI(uint256)" + ); + evolvingNftExtension.functions[4] = IExtension.ExtensionFunction( + IERC721Upgradeable.transferFrom.selector, + "transferFrom(address,address,uint256)" + ); + evolvingNftExtension.functions[5] = IExtension.ExtensionFunction(IERC721.ownerOf.selector, "ownerOf(uint256)"); + evolvingNftExtension.functions[6] = IExtension.ExtensionFunction( + Drop.getSupplyClaimedByWallet.selector, + "getSupplyClaimedByWallet(uint256,address)" + ); + evolvingNftExtension.functions[7] = IExtension.ExtensionFunction( + Drop.getActiveClaimConditionId.selector, + "getActiveClaimConditionId()" + ); + evolvingNftExtension.functions[8] = IExtension.ExtensionFunction( + Drop.getClaimConditionById.selector, + "getClaimConditionById(uint256)" + ); + evolvingNftExtension.functions[9] = IExtension.ExtensionFunction( + Drop.claimCondition.selector, + "claimCondition()" + ); + evolvingNftExtension.functions[10] = IExtension.ExtensionFunction( + SharedMetadataBatch.deleteSharedMetadata.selector, + "deleteSharedMetadata(bytes32)" + ); + permissionsExtension.functions[0] = IExtension.ExtensionFunction( + Permissions.renounceRole.selector, + "renounceRole(bytes32,address)" + ); + permissionsExtension.functions[1] = IExtension.ExtensionFunction( + Permissions.revokeRole.selector, + "revokeRole(bytes32,address)" + ); + permissionsExtension.functions[2] = IExtension.ExtensionFunction( + Permissions.grantRole.selector, + "grantRole(bytes32,address)" + ); + permissionsExtension.functions[3] = IExtension.ExtensionFunction( + Permissions.hasRole.selector, + "hasRole(bytes32,address)" + ); + + IExtension.Extension[] memory extensions = new IExtension.Extension[](3); + extensions[0] = evolvingNftExtension; + extensions[1] = permissionsExtension; + extensions[2] = rulesEngineExtension; + + address evolvingNftImpl = address(new EvolvingNFT(extensions)); + + vm.prank(deployer); + evolvingNFT = address( + new TWProxy( + evolvingNftImpl, + abi.encodeCall( + EvolvingNFT.initialize, + (deployer, NAME, SYMBOL, CONTRACT_URI, forwarders(), saleRecipient, royaltyRecipient, royaltyBps) + ) + ) + ); + + assertEq(Permissions(evolvingNFT).hasRole(0x00, deployer), true); + + sharedMetadataBatch[0] = ISharedMetadataBatch.SharedMetadataInfo({ + name: "Default", + description: "Default metadata", + imageURI: "https://default.com/1", + animationURI: "https://default.com/1" + }); + + sharedMetadataBatch[score1] = ISharedMetadataBatch.SharedMetadataInfo({ + name: "Test 1", + description: "Test 1", + imageURI: "https://test.com/1", + animationURI: "https://test.com/1" + }); + + sharedMetadataBatch[score1 + score2] = ISharedMetadataBatch.SharedMetadataInfo({ + name: "Test 2", + description: "Test 2", + imageURI: "https://test.com/2", + animationURI: "https://test.com/2" + }); + + sharedMetadataBatch[score1 + score2 + score3] = ISharedMetadataBatch.SharedMetadataInfo({ + name: "Test 3", + description: "Test 3", + imageURI: "https://test.com/3", + animationURI: "https://test.com/3" + }); + + vm.deal(deployer, 1_000 ether); + } + + /*/////////////////////////////////////////////////////////////// + Rules test + //////////////////////////////////////////////////////////////*/ + + function test_state_evolvingNFT() public { + /** + * Set shared metadata for the following scores: + * + * default: `0` + * NFT owner owns no relevant tokens. + * score_1: `10` + * NFT owner owns 10 `MockERC20` tokens. + * score_1 + score_2: `50` + * NFT owner additionally owns 1 `MockERC721` NFT. + * score_1 + score_2 + score_3: `150` + * NFT owner addtionally owns 5 `MockERC1155` NFTs of tokenID 3. + */ + + // Set shared metadata + vm.startPrank(deployer); + SharedMetadataBatch(evolvingNFT).setSharedMetadata(sharedMetadataBatch[0], bytes32(0)); + SharedMetadataBatch(evolvingNFT).setSharedMetadata(sharedMetadataBatch[score1], bytes32(score1)); + SharedMetadataBatch(evolvingNFT).setSharedMetadata( + sharedMetadataBatch[score1 + score2], + bytes32(score1 + score2) + ); + SharedMetadataBatch(evolvingNFT).setSharedMetadata( + sharedMetadataBatch[score1 + score2 + score3], + bytes32(score1 + score2 + score3) + ); + vm.stopPrank(); + + // Set rules + vm.prank(deployer); + RulesEngine(evolvingNFT).createRuleThreshold( + IRulesEngine.RuleTypeThreshold({ + token: address(erc20), + tokenType: IRulesEngine.TokenType.ERC20, + tokenId: 0, + balance: 10, + score: score1 + }) + ); + vm.prank(deployer); + RulesEngine(evolvingNFT).createRuleThreshold( + IRulesEngine.RuleTypeThreshold({ + token: address(erc721), + tokenType: IRulesEngine.TokenType.ERC721, + tokenId: 0, + balance: 1, + score: score2 + }) + ); + vm.prank(deployer); + RulesEngine(evolvingNFT).createRuleThreshold( + IRulesEngine.RuleTypeThreshold({ + token: address(erc1155), + tokenType: IRulesEngine.TokenType.ERC1155, + tokenId: 3, + balance: 5, + score: score3 + }) + ); + + // `Receiver` mints token + string[] memory inputs = new string[](5); + + inputs[0] = "node"; + inputs[1] = "src/test/scripts/generateRoot.ts"; + inputs[2] = "300"; + inputs[3] = "0"; + inputs[4] = Strings.toHexString(uint160(address(erc20))); // address of erc20 + + bytes memory result = vm.ffi(inputs); + // revert(); + bytes32 root = abi.decode(result, (bytes32)); + + inputs[1] = "src/test/scripts/getProof.ts"; + result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + IDrop.AllowlistProof memory alp; + alp.proof = proofs; + alp.quantityLimitPerWallet = 300; + alp.pricePerToken = 0; + alp.currency = address(erc20); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); // in allowlist + + IDrop.ClaimCondition[] memory conditions = new IDrop.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 500; + conditions[0].quantityLimitPerWallet = 10; + conditions[0].merkleRoot = root; + conditions[0].pricePerToken = 0; + conditions[0].currency = address(erc20); + vm.prank(deployer); + IDrop(evolvingNFT).setClaimConditions(conditions, false); + + vm.prank(receiver, receiver); + IDrop(evolvingNFT).claim(receiver, 1, address(erc20), 0, alp, ""); // claims for free, because allowlist price is 0 + + // NFT should return default metadata. + string memory uri0 = EvolvingNFTLogic(evolvingNFT).tokenURI(1); + assertEq( + uri0, + NFTMetadataRenderer.createMetadataEdition({ + name: sharedMetadataBatch[0].name, + description: sharedMetadataBatch[0].description, + imageURI: sharedMetadataBatch[0].imageURI, + animationURI: sharedMetadataBatch[0].animationURI, + tokenOfEdition: 1 + }) + ); + + // NFT should return 1st tier of metadata. + vm.prank(deployer); + erc20.mint(receiver, 10 ether); + assertEq(RulesEngine(evolvingNFT).getScore(receiver), uint256(bytes32(score1))); + + string memory uri1 = EvolvingNFTLogic(evolvingNFT).tokenURI(1); + assertEq( + uri1, + NFTMetadataRenderer.createMetadataEdition({ + name: sharedMetadataBatch[score1].name, + description: sharedMetadataBatch[score1].description, + imageURI: sharedMetadataBatch[score1].imageURI, + animationURI: sharedMetadataBatch[score1].animationURI, + tokenOfEdition: 1 + }) + ); + + // NFT should return 2nd tier of metadata. + vm.prank(deployer); + erc721.mint(receiver, 1); + assertEq(RulesEngine(evolvingNFT).getScore(receiver), uint256(bytes32(score1 + score2))); + + string memory uri2 = EvolvingNFTLogic(evolvingNFT).tokenURI(1); + assertEq( + uri2, + NFTMetadataRenderer.createMetadataEdition({ + name: sharedMetadataBatch[score1 + score2].name, + description: sharedMetadataBatch[score1 + score2].description, + imageURI: sharedMetadataBatch[score1 + score2].imageURI, + animationURI: sharedMetadataBatch[score1 + score2].animationURI, + tokenOfEdition: 1 + }) + ); + + // NFT should return 3rd tier of metadata. + vm.prank(deployer); + erc1155.mint(receiver, 3, 5, ""); + assertEq(RulesEngine(evolvingNFT).getScore(receiver), uint256(bytes32(score1 + score2 + score3))); + + string memory uri3 = EvolvingNFTLogic(evolvingNFT).tokenURI(1); + assertEq( + uri3, + NFTMetadataRenderer.createMetadataEdition({ + name: sharedMetadataBatch[score1 + score2 + score3].name, + description: sharedMetadataBatch[score1 + score2 + score3].description, + imageURI: sharedMetadataBatch[score1 + score2 + score3].imageURI, + animationURI: sharedMetadataBatch[score1 + score2 + score3].animationURI, + tokenOfEdition: 1 + }) + ); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: misc. + //////////////////////////////////////////////////////////////*/ + + /** + * note: Tests whether contract reverts when a non-holder renounces a role. + */ + function test_revert_nonHolder_renounceRole() public { + address caller = address(0x123); + bytes32 role = keccak256("MINTER_ROLE"); + + vm.prank(caller); + vm.expectRevert( + abi.encodePacked( + "Permissions: account ", + Strings.toHexString(uint160(caller), 20), + " is missing role ", + Strings.toHexString(uint256(role), 32) + ) + ); + + Permissions(evolvingNFT).renounceRole(role, caller); + } + + /** + * note: Tests whether contract reverts when a role admin revokes a role for a non-holder. + */ + function test_revert_revokeRoleForNonHolder() public { + address target = address(0x123); + bytes32 role = keccak256("MINTER_ROLE"); + + vm.prank(deployer); + vm.expectRevert( + abi.encodePacked( + "Permissions: account ", + Strings.toHexString(uint160(target), 20), + " is missing role ", + Strings.toHexString(uint256(role), 32) + ) + ); + + Permissions(evolvingNFT).revokeRole(role, target); + } + + /** + * @dev Tests whether contract reverts when a role is granted to an existent role holder. + */ + function test_revert_grant_role_to_account_with_role() public { + bytes32 role = keccak256("ABC_ROLE"); + address receiver = getActor(0); + + vm.startPrank(deployer); + + Permissions(evolvingNFT).grantRole(role, receiver); + + vm.expectRevert("Can only grant to non holders"); + Permissions(evolvingNFT).grantRole(role, receiver); + + vm.stopPrank(); + } + + /** + * @dev Tests contract state for Transfer role. + */ + function test_state_grant_transferRole() public { + bytes32 role = keccak256("TRANSFER_ROLE"); + + // check if admin and address(0) have transfer role in the beginning + bool checkAddressZero = Permissions(evolvingNFT).hasRole(role, address(0)); + bool checkAdmin = Permissions(evolvingNFT).hasRole(role, deployer); + assertTrue(checkAddressZero); + assertTrue(checkAdmin); + + // check if transfer role can be granted to a non-holder + address receiver = getActor(0); + vm.startPrank(deployer); + Permissions(evolvingNFT).grantRole(role, receiver); + + // expect revert when granting to a holder + vm.expectRevert("Can only grant to non holders"); + Permissions(evolvingNFT).grantRole(role, receiver); + + // check if receiver has transfer role + bool checkReceiver = Permissions(evolvingNFT).hasRole(role, receiver); + assertTrue(checkReceiver); + + // check if role is correctly revoked + Permissions(evolvingNFT).revokeRole(role, receiver); + checkReceiver = Permissions(evolvingNFT).hasRole(role, receiver); + assertFalse(checkReceiver); + Permissions(evolvingNFT).revokeRole(role, address(0)); + checkAddressZero = Permissions(evolvingNFT).hasRole(role, address(0)); + assertFalse(checkAddressZero); + + vm.stopPrank(); + } + + /** + * note: Testing transfer of tokens when transfer-role is restricted + */ + function test_claim_transferRole() public { + assertEq(Permissions(evolvingNFT).hasRole(0x00, deployer), true); + + vm.warp(1); + + address receiver = getActor(0); + bytes32[] memory proofs = new bytes32[](0); + + IDrop.AllowlistProof memory alp; + alp.proof = proofs; + + IDrop.ClaimCondition[] memory conditions = new IDrop.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 100; + conditions[0].quantityLimitPerWallet = 100; + + vm.prank(deployer); + SharedMetadataBatch(evolvingNFT).setSharedMetadata(sharedMetadataBatch[0], bytes32(0)); + vm.prank(deployer); + IDrop(evolvingNFT).setClaimConditions(conditions, false); + + vm.prank(getActor(5), getActor(5)); + IDrop(evolvingNFT).claim(receiver, 1, address(0), 0, alp, ""); + + // revoke transfer role from address(0) + vm.prank(deployer); + Permissions(evolvingNFT).revokeRole(keccak256("TRANSFER_ROLE"), address(0)); + vm.prank(receiver); + vm.expectRevert(bytes("!T")); + IERC721(evolvingNFT).transferFrom(receiver, address(123), 1); + } + + function test_claimCondition_with_startTimestamp() public { + vm.warp(1); + + address receiver = getActor(0); + bytes32[] memory proofs = new bytes32[](0); + + IDrop.AllowlistProof memory alp; + alp.proof = proofs; + + IDrop.ClaimCondition[] memory conditions = new IDrop.ClaimCondition[](1); + conditions[0].startTimestamp = 100; + conditions[0].maxClaimableSupply = 100; + conditions[0].quantityLimitPerWallet = 100; + + vm.prank(deployer); + SharedMetadataBatch(evolvingNFT).setSharedMetadata(sharedMetadataBatch[0], bytes32(0)); + + vm.prank(deployer); + IDrop(evolvingNFT).setClaimConditions(conditions, false); + + vm.warp(99); + vm.prank(getActor(5), getActor(5)); + vm.expectRevert("!CONDITION."); + IDrop(evolvingNFT).claim(receiver, 1, address(0), 0, alp, ""); + + vm.warp(100); + vm.prank(getActor(4), getActor(4)); + IDrop(evolvingNFT).claim(receiver, 1, address(0), 0, alp, ""); + } + + /*/////////////////////////////////////////////////////////////// + Set Shared Metadata Tests + //////////////////////////////////////////////////////////////*/ + + /* + * note: Testing state changes; set shared metadata for tokens. + */ + function test_state_sharedMetadata() public { + // SET METADATA + vm.prank(deployer); + SharedMetadataBatch(evolvingNFT).setSharedMetadata(sharedMetadataBatch[score1], bytes32(0)); + + // CLAIM 1 TOKEN + string[] memory inputs = new string[](5); + + inputs[0] = "node"; + inputs[1] = "src/test/scripts/generateRoot.ts"; + inputs[2] = "300"; + inputs[3] = "0"; + inputs[4] = Strings.toHexString(uint160(address(erc20))); // address of erc20 + + bytes memory result = vm.ffi(inputs); + // revert(); + bytes32 root = abi.decode(result, (bytes32)); + + inputs[1] = "src/test/scripts/getProof.ts"; + result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + IDrop.AllowlistProof memory alp; + alp.proof = proofs; + alp.quantityLimitPerWallet = 300; + alp.pricePerToken = 0; + alp.currency = address(erc20); + + vm.warp(1); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); // in allowlist + + IDrop.ClaimCondition[] memory conditions = new IDrop.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 500; + conditions[0].quantityLimitPerWallet = 10; + conditions[0].merkleRoot = root; + conditions[0].pricePerToken = 10; + conditions[0].currency = address(erc20); + + vm.prank(deployer); + IDrop(evolvingNFT).setClaimConditions(conditions, false); + + // vm.prank(getActor(5), getActor(5)); + vm.prank(receiver, receiver); + IDrop(evolvingNFT).claim(receiver, 100, address(erc20), 0, alp, ""); + + string memory uri = EvolvingNFTLogic(evolvingNFT).tokenURI(1); + assertEq( + uri, + NFTMetadataRenderer.createMetadataEdition({ + name: sharedMetadataBatch[score1].name, + description: sharedMetadataBatch[score1].description, + imageURI: sharedMetadataBatch[score1].imageURI, + animationURI: sharedMetadataBatch[score1].animationURI, + tokenOfEdition: 1 + }) + ); + } + + /** + * note: Testing revert condition; an address without MINTER_ROLE calls setSharedMetadata function. + */ + function test_revert_setSharedMetadata_MINTER_ROLE() public { + vm.expectRevert(); + SharedMetadataBatch(evolvingNFT).setSharedMetadata(sharedMetadataBatch[0], bytes32(0)); + } + + /** + * note: Testing event emission; shared metadata set. + */ + function test_event_setSharedMetadata_SharedMetadataUpdated() public { + vm.startPrank(deployer); + + vm.expectEmit(false, false, false, false); + emit SharedMetadataUpdated( + bytes32(0), + sharedMetadataBatch[score1].name, + sharedMetadataBatch[score1].description, + sharedMetadataBatch[score1].imageURI, + sharedMetadataBatch[score1].animationURI + ); + SharedMetadataBatch(evolvingNFT).setSharedMetadata(sharedMetadataBatch[score1], bytes32(0)); + + vm.stopPrank(); + } + + /*/////////////////////////////////////////////////////////////// + Claim Tests + //////////////////////////////////////////////////////////////*/ + + /** + * note: Testing revert condition; exceed max claimable supply. + */ + function test_revert_claimCondition_exceedMaxClaimableSupply() public { + vm.warp(1); + + address receiver = getActor(0); + bytes32[] memory proofs = new bytes32[](0); + + IDrop.AllowlistProof memory alp; + alp.proof = proofs; + + IDrop.ClaimCondition[] memory conditions = new IDrop.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 100; + conditions[0].quantityLimitPerWallet = 200; + + vm.prank(deployer); + SharedMetadataBatch(evolvingNFT).setSharedMetadata(sharedMetadataBatch[0], bytes32(0)); + vm.prank(deployer); + IDrop(evolvingNFT).setClaimConditions(conditions, false); + + vm.prank(getActor(5), getActor(5)); + IDrop(evolvingNFT).claim(receiver, 100, address(0), 0, alp, ""); + + vm.expectRevert("!MaxSupply"); + vm.prank(getActor(6), getActor(6)); + IDrop(evolvingNFT).claim(receiver, 1, address(0), 0, alp, ""); + } + + /** + * note: Testing quantity limit restriction when no allowlist present. + */ + function test_fuzz_claim_noAllowlist(uint256 x) public { + vm.assume(x != 0); + vm.warp(1); + + address receiver = getActor(0); + bytes32[] memory proofs = new bytes32[](0); + + IDrop.AllowlistProof memory alp; + alp.proof = proofs; + alp.quantityLimitPerWallet = x; + + IDrop.ClaimCondition[] memory conditions = new IDrop.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 500; + conditions[0].quantityLimitPerWallet = 100; + + vm.prank(deployer); + SharedMetadataBatch(evolvingNFT).setSharedMetadata(sharedMetadataBatch[0], bytes32(0)); + + vm.prank(deployer); + IDrop(evolvingNFT).setClaimConditions(conditions, false); + + bytes memory errorQty = "!Qty"; + + vm.prank(getActor(5), getActor(5)); + vm.expectRevert(errorQty); + IDrop(evolvingNFT).claim(receiver, 0, address(0), 0, alp, ""); + + vm.prank(getActor(5), getActor(5)); + vm.expectRevert(errorQty); + IDrop(evolvingNFT).claim(receiver, 101, address(0), 0, alp, ""); + + vm.prank(deployer); + IDrop(evolvingNFT).setClaimConditions(conditions, true); + + vm.prank(getActor(5), getActor(5)); + vm.expectRevert(errorQty); + IDrop(evolvingNFT).claim(receiver, 101, address(0), 0, alp, ""); + } + + /** + * note: Testing quantity limit restriction + * - allowlist quantity set to some value different than general limit + * - allowlist price set to 0 + */ + function test_state_claim_allowlisted_SetQuantityZeroPrice() public { + string[] memory inputs = new string[](5); + + inputs[0] = "node"; + inputs[1] = "src/test/scripts/generateRoot.ts"; + inputs[2] = "300"; + inputs[3] = "0"; + inputs[4] = Strings.toHexString(uint160(address(erc20))); // address of erc20 + + bytes memory result = vm.ffi(inputs); + // revert(); + bytes32 root = abi.decode(result, (bytes32)); + + inputs[1] = "src/test/scripts/getProof.ts"; + result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + IDrop.AllowlistProof memory alp; + alp.proof = proofs; + alp.quantityLimitPerWallet = 300; + alp.pricePerToken = 0; + alp.currency = address(erc20); + + vm.warp(1); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); // in allowlist + + IDrop.ClaimCondition[] memory conditions = new IDrop.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 500; + conditions[0].quantityLimitPerWallet = 10; + conditions[0].merkleRoot = root; + conditions[0].pricePerToken = 10; + conditions[0].currency = address(erc20); + + vm.prank(deployer); + SharedMetadataBatch(evolvingNFT).setSharedMetadata(sharedMetadataBatch[0], bytes32(0)); + vm.prank(deployer); + IDrop(evolvingNFT).setClaimConditions(conditions, false); + + // vm.prank(getActor(5), getActor(5)); + vm.prank(receiver, receiver); + IDrop(evolvingNFT).claim(receiver, 100, address(erc20), 0, alp, ""); // claims for free, because allowlist price is 0 + assertEq( + Drop(evolvingNFT).getSupplyClaimedByWallet(Drop(evolvingNFT).getActiveClaimConditionId(), receiver), + 100 + ); + } + + /** + * note: Testing quantity limit restriction + * - allowlist quantity set to some value different than general limit + * - allowlist price set to non-zero value + */ + function test_state_claim_allowlisted_SetQuantityPrice() public { + string[] memory inputs = new string[](5); + + inputs[0] = "node"; + inputs[1] = "src/test/scripts/generateRoot.ts"; + inputs[2] = "300"; + inputs[3] = "5"; + inputs[4] = Strings.toHexString(uint160(address(erc20))); // address of erc20 + + bytes memory result = vm.ffi(inputs); + // revert(); + bytes32 root = abi.decode(result, (bytes32)); + + inputs[1] = "src/test/scripts/getProof.ts"; + result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + IDrop.AllowlistProof memory alp; + alp.proof = proofs; + alp.quantityLimitPerWallet = 300; + alp.pricePerToken = 5; + alp.currency = address(erc20); + + vm.warp(1); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); // in allowlist + + IDrop.ClaimCondition[] memory conditions = new IDrop.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 500; + conditions[0].quantityLimitPerWallet = 10; + conditions[0].merkleRoot = root; + conditions[0].pricePerToken = 10; + conditions[0].currency = address(erc20); + + vm.prank(deployer); + SharedMetadataBatch(evolvingNFT).setSharedMetadata(sharedMetadataBatch[0], bytes32(0)); + vm.prank(deployer); + IDrop(evolvingNFT).setClaimConditions(conditions, false); + + vm.prank(receiver, receiver); + vm.expectRevert("!PriceOrCurrency"); + IDrop(evolvingNFT).claim(receiver, 100, address(erc20), 0, alp, ""); + + erc20.mint(receiver, 10000); + vm.prank(receiver); + erc20.approve(evolvingNFT, 10000); + + // vm.prank(getActor(5), getActor(5)); + vm.prank(receiver, receiver); + IDrop(evolvingNFT).claim(receiver, 100, address(erc20), 5, alp, ""); + assertEq( + Drop(evolvingNFT).getSupplyClaimedByWallet(Drop(evolvingNFT).getActiveClaimConditionId(), receiver), + 100 + ); + assertEq(erc20.balanceOf(receiver), 10000 - 500); + } + + /** + * note: Testing quantity limit restriction + * - allowlist quantity set to some value different than general limit + * - allowlist price not set; should default to general price and currency + */ + function test_state_claim_allowlisted_SetQuantityDefaultPrice() public { + string[] memory inputs = new string[](5); + + inputs[0] = "node"; + inputs[1] = "src/test/scripts/generateRoot.ts"; + inputs[2] = "300"; + inputs[3] = Strings.toString(type(uint256).max); // this implies that general price is applicable + inputs[4] = "0x0000000000000000000000000000000000000000"; + + bytes memory result = vm.ffi(inputs); + // revert(); + bytes32 root = abi.decode(result, (bytes32)); + + inputs[1] = "src/test/scripts/getProof.ts"; + result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + IDrop.AllowlistProof memory alp; + alp.proof = proofs; + alp.quantityLimitPerWallet = 300; + alp.pricePerToken = type(uint256).max; + alp.currency = address(0); + + vm.warp(1); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); // in allowlist + + IDrop.ClaimCondition[] memory conditions = new IDrop.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 500; + conditions[0].quantityLimitPerWallet = 10; + conditions[0].merkleRoot = root; + conditions[0].pricePerToken = 10; + conditions[0].currency = address(erc20); + + vm.prank(deployer); + SharedMetadataBatch(evolvingNFT).setSharedMetadata(sharedMetadataBatch[0], bytes32(0)); + vm.prank(deployer); + IDrop(evolvingNFT).setClaimConditions(conditions, false); + + erc20.mint(receiver, 10000); + vm.prank(receiver); + erc20.approve(evolvingNFT, 10000); + + // vm.prank(getActor(5), getActor(5)); + vm.prank(receiver, receiver); + IDrop(evolvingNFT).claim(receiver, 100, address(erc20), 10, alp, ""); + assertEq( + Drop(evolvingNFT).getSupplyClaimedByWallet(Drop(evolvingNFT).getActiveClaimConditionId(), receiver), + 100 + ); + assertEq(erc20.balanceOf(receiver), 10000 - 1000); + } + + /** + * note: Testing quantity limit restriction + * - allowlist quantity set to 0 => should default to general limit + * - allowlist price set to some value different than general price + */ + function test_state_claim_allowlisted_DefaultQuantitySomePrice() public { + string[] memory inputs = new string[](5); + + inputs[0] = "node"; + inputs[1] = "src/test/scripts/generateRoot.ts"; + inputs[2] = "0"; // this implies that general limit is applicable + inputs[3] = "5"; + inputs[4] = "0x0000000000000000000000000000000000000000"; // general currency will be applicable + + bytes memory result = vm.ffi(inputs); + // revert(); + bytes32 root = abi.decode(result, (bytes32)); + + inputs[1] = "src/test/scripts/getProof.ts"; + result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + IDrop.AllowlistProof memory alp; + alp.proof = proofs; + alp.quantityLimitPerWallet = 0; + alp.pricePerToken = 5; + alp.currency = address(0); + + vm.warp(1); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); // in allowlist + + IDrop.ClaimCondition[] memory conditions = new IDrop.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 500; + conditions[0].quantityLimitPerWallet = 10; + conditions[0].merkleRoot = root; + conditions[0].pricePerToken = 10; + conditions[0].currency = address(erc20); + + vm.prank(deployer); + SharedMetadataBatch(evolvingNFT).setSharedMetadata(sharedMetadataBatch[0], bytes32(0)); + vm.prank(deployer); + IDrop(evolvingNFT).setClaimConditions(conditions, false); + + erc20.mint(receiver, 10000); + vm.prank(receiver); + erc20.approve(evolvingNFT, 10000); + + bytes memory errorQty = "!Qty"; + vm.prank(receiver, receiver); + vm.expectRevert(errorQty); + IDrop(evolvingNFT).claim(receiver, 100, address(erc20), 5, alp, ""); // trying to claim more than general limit + + // vm.prank(getActor(5), getActor(5)); + vm.prank(receiver, receiver); + IDrop(evolvingNFT).claim(receiver, 10, address(erc20), 5, alp, ""); + assertEq( + Drop(evolvingNFT).getSupplyClaimedByWallet(Drop(evolvingNFT).getActiveClaimConditionId(), receiver), + 10 + ); + assertEq(erc20.balanceOf(receiver), 10000 - 50); + } + + function test_fuzz_claim_merkleProof(uint256 x) public { + vm.assume(x > 10 && x < 500); + string[] memory inputs = new string[](5); + + inputs[0] = "node"; + inputs[1] = "src/test/scripts/generateRoot.ts"; + inputs[2] = Strings.toString(x); + inputs[3] = "0"; + inputs[4] = "0x0000000000000000000000000000000000000000"; + + bytes memory result = vm.ffi(inputs); + // revert(); + bytes32 root = abi.decode(result, (bytes32)); + + inputs[1] = "src/test/scripts/getProof.ts"; + result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + IDrop.AllowlistProof memory alp; + alp.proof = proofs; + alp.quantityLimitPerWallet = x; + alp.pricePerToken = 0; + alp.currency = address(0); + + vm.warp(1); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); + + // bytes32[] memory proofs = new bytes32[](0); + + IDrop.ClaimCondition[] memory conditions = new IDrop.ClaimCondition[](1); + conditions[0].maxClaimableSupply = x; + conditions[0].quantityLimitPerWallet = 1; + conditions[0].merkleRoot = root; + + vm.prank(deployer); + SharedMetadataBatch(evolvingNFT).setSharedMetadata(sharedMetadataBatch[0], bytes32(0)); + vm.prank(deployer); + IDrop(evolvingNFT).setClaimConditions(conditions, false); + + // vm.prank(getActor(5), getActor(5)); + vm.prank(receiver, receiver); + IDrop(evolvingNFT).claim(receiver, x - 5, address(0), 0, alp, ""); + assertEq( + Drop(evolvingNFT).getSupplyClaimedByWallet(Drop(evolvingNFT).getActiveClaimConditionId(), receiver), + x - 5 + ); + + bytes memory errorQty = "!Qty"; + + vm.prank(receiver, receiver); + vm.expectRevert(errorQty); + IDrop(evolvingNFT).claim(receiver, 6, address(0), 0, alp, ""); + + vm.prank(receiver, receiver); + IDrop(evolvingNFT).claim(receiver, 5, address(0), 0, alp, ""); + assertEq( + Drop(evolvingNFT).getSupplyClaimedByWallet(Drop(evolvingNFT).getActiveClaimConditionId(), receiver), + x + ); + + vm.prank(receiver, receiver); + vm.expectRevert(errorQty); + IDrop(evolvingNFT).claim(receiver, 5, address(0), 0, alp, ""); // quantity limit already claimed + } + + /** + * note: Testing state changes; reset eligibility of claim conditions and claiming again for same condition id. + */ + function test_state_claimCondition_resetEligibility() public { + vm.warp(1); + + address receiver = getActor(0); + bytes32[] memory proofs = new bytes32[](0); + + IDrop.AllowlistProof memory alp; + alp.proof = proofs; + + IDrop.ClaimCondition[] memory conditions = new IDrop.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 500; + conditions[0].quantityLimitPerWallet = 100; + + vm.prank(deployer); + SharedMetadataBatch(evolvingNFT).setSharedMetadata(sharedMetadataBatch[0], bytes32(0)); + + vm.prank(deployer); + IDrop(evolvingNFT).setClaimConditions(conditions, false); + + vm.prank(getActor(5), getActor(5)); + IDrop(evolvingNFT).claim(receiver, 100, address(0), 0, alp, ""); + + bytes memory errorQty = "!Qty"; + + vm.prank(getActor(5), getActor(5)); + vm.expectRevert(errorQty); + IDrop(evolvingNFT).claim(receiver, 100, address(0), 0, alp, ""); + + vm.prank(deployer); + IDrop(evolvingNFT).setClaimConditions(conditions, true); + + vm.prank(getActor(5), getActor(5)); + IDrop(evolvingNFT).claim(receiver, 100, address(0), 0, alp, ""); + } + + /*/////////////////////////////////////////////////////////////// + setClaimConditions + //////////////////////////////////////////////////////////////*/ + + function test_claimCondition_startIdAndCount() public { + vm.startPrank(deployer); + + uint256 currentStartId = 0; + uint256 count = 0; + + IDrop.ClaimCondition[] memory conditions = new IDrop.ClaimCondition[](2); + conditions[0].startTimestamp = 0; + conditions[0].maxClaimableSupply = 10; + conditions[1].startTimestamp = 1; + conditions[1].maxClaimableSupply = 10; + + IDrop(evolvingNFT).setClaimConditions(conditions, false); + (currentStartId, count) = Drop(evolvingNFT).claimCondition(); + assertEq(currentStartId, 0); + assertEq(count, 2); + + IDrop(evolvingNFT).setClaimConditions(conditions, false); + (currentStartId, count) = Drop(evolvingNFT).claimCondition(); + assertEq(currentStartId, 0); + assertEq(count, 2); + + IDrop(evolvingNFT).setClaimConditions(conditions, true); + (currentStartId, count) = Drop(evolvingNFT).claimCondition(); + assertEq(currentStartId, 2); + assertEq(count, 2); + + IDrop(evolvingNFT).setClaimConditions(conditions, true); + (currentStartId, count) = Drop(evolvingNFT).claimCondition(); + assertEq(currentStartId, 4); + assertEq(count, 2); + } + + function test_claimCondition_startPhase() public { + vm.startPrank(deployer); + + uint256 activeConditionId = 0; + + IDrop.ClaimCondition[] memory conditions = new IDrop.ClaimCondition[](3); + conditions[0].startTimestamp = 10; + conditions[0].maxClaimableSupply = 11; + conditions[0].quantityLimitPerWallet = 12; + conditions[1].startTimestamp = 20; + conditions[1].maxClaimableSupply = 21; + conditions[1].quantityLimitPerWallet = 22; + conditions[2].startTimestamp = 30; + conditions[2].maxClaimableSupply = 31; + conditions[2].quantityLimitPerWallet = 32; + IDrop(evolvingNFT).setClaimConditions(conditions, false); + + vm.expectRevert("!CONDITION."); + Drop(evolvingNFT).getActiveClaimConditionId(); + + vm.warp(10); + activeConditionId = Drop(evolvingNFT).getActiveClaimConditionId(); + assertEq(activeConditionId, 0); + assertEq(Drop(evolvingNFT).getClaimConditionById(activeConditionId).startTimestamp, 10); + assertEq(Drop(evolvingNFT).getClaimConditionById(activeConditionId).maxClaimableSupply, 11); + assertEq(Drop(evolvingNFT).getClaimConditionById(activeConditionId).quantityLimitPerWallet, 12); + + vm.warp(20); + activeConditionId = Drop(evolvingNFT).getActiveClaimConditionId(); + assertEq(activeConditionId, 1); + assertEq(Drop(evolvingNFT).getClaimConditionById(activeConditionId).startTimestamp, 20); + assertEq(Drop(evolvingNFT).getClaimConditionById(activeConditionId).maxClaimableSupply, 21); + assertEq(Drop(evolvingNFT).getClaimConditionById(activeConditionId).quantityLimitPerWallet, 22); + + vm.warp(30); + activeConditionId = Drop(evolvingNFT).getActiveClaimConditionId(); + assertEq(activeConditionId, 2); + assertEq(Drop(evolvingNFT).getClaimConditionById(activeConditionId).startTimestamp, 30); + assertEq(Drop(evolvingNFT).getClaimConditionById(activeConditionId).maxClaimableSupply, 31); + assertEq(Drop(evolvingNFT).getClaimConditionById(activeConditionId).quantityLimitPerWallet, 32); + + vm.warp(40); + assertEq(Drop(evolvingNFT).getActiveClaimConditionId(), 2); + } + + /*/////////////////////////////////////////////////////////////// + Audit POC tests + //////////////////////////////////////////////////////////////*/ + + function test_state_incorrectTokenUri() public { + // Set shared metadata + vm.startPrank(deployer); + SharedMetadataBatch(evolvingNFT).setSharedMetadata(sharedMetadataBatch[0], bytes32(0)); + SharedMetadataBatch(evolvingNFT).setSharedMetadata(sharedMetadataBatch[score1], bytes32(score1)); + SharedMetadataBatch(evolvingNFT).setSharedMetadata( + sharedMetadataBatch[score1 + score2], + bytes32(score1 + score2) + ); + SharedMetadataBatch(evolvingNFT).setSharedMetadata( + sharedMetadataBatch[score1 + score2 + score3], + bytes32(score1 + score2 + score3) + ); + + // Delete metadata at index "score1" + // Now the order of metadata ids is: 0, 150, 50 + SharedMetadataBatch(evolvingNFT).deleteSharedMetadata(bytes32(score1)); + vm.stopPrank(); + + // Set rules + vm.prank(deployer); + RulesEngine(evolvingNFT).createRuleThreshold( + IRulesEngine.RuleTypeThreshold({ + token: address(erc20), + tokenType: IRulesEngine.TokenType.ERC20, + tokenId: 0, + balance: 10, + score: score1 + score2 + score3 + }) + ); + + // `Receiver` mints token + string[] memory inputs = new string[](5); + + inputs[0] = "node"; + inputs[1] = "src/test/scripts/generateRoot.ts"; + inputs[2] = "300"; + inputs[3] = "0"; + inputs[4] = Strings.toHexString(uint160(address(erc20))); // address of erc20 + + bytes memory result = vm.ffi(inputs); + // revert(); + bytes32 root = abi.decode(result, (bytes32)); + + inputs[1] = "src/test/scripts/getProof.ts"; + result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + IDrop.AllowlistProof memory alp; + alp.proof = proofs; + alp.quantityLimitPerWallet = 300; + alp.pricePerToken = 0; + alp.currency = address(erc20); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); // in allowlist + + IDrop.ClaimCondition[] memory conditions = new IDrop.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 500; + conditions[0].quantityLimitPerWallet = 10; + conditions[0].merkleRoot = root; + conditions[0].pricePerToken = 0; + conditions[0].currency = address(erc20); + vm.prank(deployer); + IDrop(evolvingNFT).setClaimConditions(conditions, false); + + vm.prank(receiver, receiver); + IDrop(evolvingNFT).claim(receiver, 1, address(erc20), 0, alp, ""); // claims for free, because allowlist price is 0 + + // NFT should return metadata of a rule at "score1 + score2 + score3" + // It used to return metadata for "score1 + score2", but now this is fixed. + erc20.mint(receiver, 10 ether); + string memory uri = EvolvingNFTLogic(evolvingNFT).tokenURI(1); + assertEq( + uri, + NFTMetadataRenderer.createMetadataEdition({ + name: sharedMetadataBatch[score1 + score2 + score3].name, + description: sharedMetadataBatch[score1 + score2 + score3].description, + imageURI: sharedMetadataBatch[score1 + score2 + score3].imageURI, + animationURI: sharedMetadataBatch[score1 + score2 + score3].animationURI, + tokenOfEdition: 1 + }) + ); + } +} diff --git a/src/test/Forwarder.t.sol b/src/test/Forwarder.t.sol new file mode 100644 index 000000000..8ff96ca51 --- /dev/null +++ b/src/test/Forwarder.t.sol @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { Forwarder } from "contracts/infra/forwarder/Forwarder.sol"; +import { ForwarderConsumer } from "contracts/infra/forwarder/ForwarderConsumer.sol"; + +import "./utils/BaseTest.sol"; + +contract ForwarderTest is BaseTest { + ForwarderConsumer public consumer; + + uint256 public userPKey = 1020; + address public user; + address public relayer = address(0x4567); + + bytes32 internal typehashForwardRequest; + bytes32 internal nameHash; + bytes32 internal versionHash; + + bytes32 internal typehashEip712; + bytes32 internal domainSeparator; + + using stdStorage for StdStorage; + + function setUp() public override { + super.setUp(); + user = vm.addr(userPKey); + consumer = new ForwarderConsumer(forwarder); + + typehashForwardRequest = keccak256( + "ForwardRequest(address from,address to,uint256 value,uint256 gas,uint256 nonce,bytes data)" + ); + nameHash = keccak256(bytes("GSNv2 Forwarder")); + versionHash = keccak256(bytes("0.0.1")); + typehashEip712 = keccak256( + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" + ); + domainSeparator = keccak256(abi.encode(typehashEip712, nameHash, versionHash, block.chainid, forwarder)); + + vm.label(user, "End user"); + vm.label(forwarder, "Forwarder"); + vm.label(relayer, "Relayer"); + vm.label(address(consumer), "Consumer"); + } + + /*/////////////////////////////////////////////////////////////// + Regular `Forwarder`: chainId in typehash + //////////////////////////////////////////////////////////////*/ + + function signForwarderRequest( + Forwarder.ForwardRequest memory forwardRequest, + uint256 privateKey + ) internal view returns (bytes memory) { + bytes memory encodedRequest = abi.encode( + typehashForwardRequest, + forwardRequest.from, + forwardRequest.to, + forwardRequest.value, + forwardRequest.gas, + forwardRequest.nonce, + keccak256(forwardRequest.data) + ); + bytes32 structHash = keccak256(encodedRequest); + bytes32 typedDataHash = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, typedDataHash); + bytes memory signature = abi.encodePacked(r, s, v); + + return signature; + } + + function test_state_forwarder() public { + Forwarder.ForwardRequest memory forwardRequest; + + forwardRequest.from = user; + forwardRequest.to = address(consumer); + forwardRequest.value = 0; + forwardRequest.gas = 100_000; + forwardRequest.nonce = Forwarder(forwarder).getNonce(user); + forwardRequest.data = abi.encodeCall(ForwarderConsumer.setCaller, ()); + + bytes memory signature = signForwarderRequest(forwardRequest, userPKey); + vm.prank(relayer); + Forwarder(forwarder).execute(forwardRequest, signature); + + assertEq(consumer.caller(), user); + } +} diff --git a/src/test/ForwarderChainlessDomain.t.sol b/src/test/ForwarderChainlessDomain.t.sol new file mode 100644 index 000000000..e293b7419 --- /dev/null +++ b/src/test/ForwarderChainlessDomain.t.sol @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { ForwarderConsumer } from "contracts/infra/forwarder/ForwarderConsumer.sol"; +import { ForwarderChainlessDomain } from "contracts/infra/forwarder/ForwarderChainlessDomain.sol"; + +import "./utils/BaseTest.sol"; + +contract ForwarderChainlessDomainTest is BaseTest { + address public forwarderChainlessDomain; + ForwarderConsumer public consumer; + + uint256 public userPKey = 1020; + address public user; + address public relayer = address(0x4567); + + bytes32 internal typehashForwardRequest; + bytes32 internal nameHash; + bytes32 internal versionHash; + bytes32 internal typehashEip712; + bytes32 internal domainSeparator; + + using stdStorage for StdStorage; + + function setUp() public override { + super.setUp(); + user = vm.addr(userPKey); + consumer = new ForwarderConsumer(forwarder); + + forwarderChainlessDomain = address(new ForwarderChainlessDomain()); + consumer = new ForwarderConsumer(forwarderChainlessDomain); + + typehashForwardRequest = keccak256( + "ForwardRequest(address from,address to,uint256 value,uint256 gas,uint256 nonce,bytes data,uint256 chainid)" + ); + nameHash = keccak256(bytes("GSNv2 Forwarder")); + versionHash = keccak256(bytes("0.0.1")); + typehashEip712 = keccak256("EIP712Domain(string name,string version,address verifyingContract)"); + domainSeparator = keccak256(abi.encode(typehashEip712, nameHash, versionHash, forwarderChainlessDomain)); + + vm.label(user, "End user"); + vm.label(forwarder, "Forwarder"); + vm.label(relayer, "Relayer"); + vm.label(address(consumer), "Consumer"); + } + + /*/////////////////////////////////////////////////////////////// + Updated `Forwarder`: chainId in ForwardRequest, not typehash. + //////////////////////////////////////////////////////////////*/ + + function signForwarderRequest( + ForwarderChainlessDomain.ForwardRequest memory forwardRequest, + uint256 privateKey + ) internal view returns (bytes memory) { + bytes memory encodedRequest = abi.encode( + typehashForwardRequest, + forwardRequest.from, + forwardRequest.to, + forwardRequest.value, + forwardRequest.gas, + forwardRequest.nonce, + keccak256(forwardRequest.data), + forwardRequest.chainid + ); + bytes32 structHash = keccak256(encodedRequest); + bytes32 typedDataHash = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, typedDataHash); + bytes memory signature = abi.encodePacked(r, s, v); + + return signature; + } + + function test_state_forwarderChainlessDomain() public { + ForwarderChainlessDomain.ForwardRequest memory forwardRequest; + + forwardRequest.from = user; + forwardRequest.to = address(consumer); + forwardRequest.value = 0; + forwardRequest.gas = 100_000; + forwardRequest.nonce = ForwarderChainlessDomain(forwarderChainlessDomain).getNonce(user); + forwardRequest.data = abi.encodeCall(ForwarderConsumer.setCaller, ()); + forwardRequest.chainid = block.chainid; + + bytes memory signature = signForwarderRequest(forwardRequest, userPKey); + vm.prank(relayer); + ForwarderChainlessDomain(forwarderChainlessDomain).execute(forwardRequest, signature); + + assertEq(consumer.caller(), user); + } +} diff --git a/src/test/LoyaltyCard.t.sol b/src/test/LoyaltyCard.t.sol new file mode 100644 index 000000000..f9e26498b --- /dev/null +++ b/src/test/LoyaltyCard.t.sol @@ -0,0 +1,356 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import "./utils/BaseTest.sol"; +import "contracts/infra/TWProxy.sol"; +import { Strings } from "contracts/lib/Strings.sol"; +import { LoyaltyCard, NFTMetadata } from "contracts/prebuilts/loyalty/LoyaltyCard.sol"; + +contract LoyaltyCardTest is BaseTest { + LoyaltyCard internal loyaltyCard; + using Strings for uint256; + + bytes32 internal typehashMintRequest; + bytes32 internal nameHash; + bytes32 internal versionHash; + bytes32 internal typehashEip712; + bytes32 internal domainSeparator; + + LoyaltyCard.MintRequest _mintrequest; + bytes _signature; + + address recipient; + + function setUp() public override { + super.setUp(); + + address loyaltyCardImpl = address(new LoyaltyCard()); + + vm.prank(signer); + loyaltyCard = LoyaltyCard( + address( + new TWProxy( + loyaltyCardImpl, + abi.encodeCall( + LoyaltyCard.initialize, + ( + signer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ) + ) + ); + + recipient = address(0x123); + erc20.mint(recipient, 1_000); + vm.deal(recipient, 1_000); + + typehashMintRequest = keccak256( + "MintRequest(address to,address royaltyRecipient,uint256 royaltyBps,address primarySaleRecipient,string uri,uint256 quantity,uint256 pricePerToken,address currency,uint128 validityStartTimestamp,uint128 validityEndTimestamp,bytes32 uid)" + ); + nameHash = keccak256(bytes("SignatureMintERC721")); + versionHash = keccak256(bytes("1")); + typehashEip712 = keccak256( + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" + ); + domainSeparator = keccak256( + abi.encode(typehashEip712, nameHash, versionHash, block.chainid, address(loyaltyCard)) + ); + + _mintrequest.to = recipient; + _mintrequest.royaltyRecipient = royaltyRecipient; + _mintrequest.royaltyBps = royaltyBps; + _mintrequest.primarySaleRecipient = saleRecipient; + _mintrequest.uri = "ipfs://"; + _mintrequest.quantity = 1; + _mintrequest.pricePerToken = 0; + _mintrequest.currency = address(0); + _mintrequest.validityStartTimestamp = 1000; + _mintrequest.validityEndTimestamp = 2000; + _mintrequest.uid = bytes32(0); + + _signature = signMintRequest(_mintrequest, privateKey); + } + + function signMintRequest( + LoyaltyCard.MintRequest memory _request, + uint256 _privateKey + ) internal view returns (bytes memory) { + bytes memory encodedRequest = abi.encode( + typehashMintRequest, + _request.to, + _request.royaltyRecipient, + _request.royaltyBps, + _request.primarySaleRecipient, + keccak256(bytes(_request.uri)), + _request.quantity, + _request.pricePerToken, + _request.currency, + _request.validityStartTimestamp, + _request.validityEndTimestamp, + _request.uid + ); + bytes32 structHash = keccak256(encodedRequest); + bytes32 typedDataHash = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(_privateKey, typedDataHash); + bytes memory sig = abi.encodePacked(r, s, v); + + return sig; + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `mintTo` + //////////////////////////////////////////////////////////////*/ + + function test_state_mintTo() public { + string memory _tokenURI = "tokenURI"; + + uint256 nextTokenId = loyaltyCard.nextTokenIdToMint(); + uint256 currentTotalSupply = loyaltyCard.totalSupply(); + uint256 currentBalanceOfRecipient = loyaltyCard.balanceOf(recipient); + + vm.prank(signer); + loyaltyCard.mintTo(recipient, _tokenURI); + + assertEq(loyaltyCard.nextTokenIdToMint(), nextTokenId + 1); + assertEq(loyaltyCard.tokenURI(nextTokenId), _tokenURI); + assertEq(loyaltyCard.totalSupply(), currentTotalSupply + 1); + assertEq(loyaltyCard.balanceOf(recipient), currentBalanceOfRecipient + 1); + assertEq(loyaltyCard.ownerOf(nextTokenId), recipient); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `setTokenURI` + //////////////////////////////////////////////////////////////*/ + + function test_state_setTokenURI() public { + string memory _tokenURI = "tokenURI"; + + vm.prank(signer); + uint256 tokenIdMinted = loyaltyCard.mintTo(recipient, _tokenURI); + + assertEq(_tokenURI, loyaltyCard.tokenURI(tokenIdMinted)); + + assertEq(loyaltyCard.hasRole(keccak256("METADATA_ROLE"), signer), true); + + string memory newURI = "newURI"; + + vm.prank(signer); + loyaltyCard.setTokenURI(tokenIdMinted, newURI); + + assertEq(newURI, loyaltyCard.tokenURI(tokenIdMinted)); + + vm.prank(signer); + loyaltyCard.renounceRole(keccak256("METADATA_ROLE"), signer); + + vm.expectRevert(); + vm.prank(signer); + loyaltyCard.setTokenURI(tokenIdMinted, _tokenURI); + + vm.expectRevert(); + vm.prank(signer); + loyaltyCard.grantRole(keccak256("METADATA_ROLE"), signer); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: cancel / revoke loyalty + //////////////////////////////////////////////////////////////*/ + + function test_state_cancelLoyalty() public { + string memory _tokenURI = "tokenURI"; + + vm.prank(signer); + uint256 tokenIdMinted = loyaltyCard.mintTo(recipient, _tokenURI); + + assertEq(loyaltyCard.ownerOf(tokenIdMinted), recipient); + + vm.prank(recipient); + loyaltyCard.setApprovalForAll(signer, true); + + vm.prank(signer); + loyaltyCard.cancel(tokenIdMinted); + + vm.expectRevert(); + loyaltyCard.ownerOf(tokenIdMinted); + } + + function test_state_revokeLoyalty() public { + string memory _tokenURI = "tokenURI"; + + vm.prank(signer); + uint256 tokenIdMinted = loyaltyCard.mintTo(recipient, _tokenURI); + + assertEq(loyaltyCard.ownerOf(tokenIdMinted), recipient); + + address burner = address(0x123456); + vm.prank(signer); + loyaltyCard.grantRole(keccak256("REVOKE_ROLE"), burner); + + vm.prank(signer); + loyaltyCard.renounceRole(keccak256("REVOKE_ROLE"), signer); + + vm.expectRevert(); + vm.prank(signer); + loyaltyCard.revoke(tokenIdMinted); + + vm.prank(burner); + loyaltyCard.revoke(tokenIdMinted); + + vm.expectRevert(); + loyaltyCard.ownerOf(tokenIdMinted); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `mintWithSignature` + //////////////////////////////////////////////////////////////*/ + + function test_state_mintWithSignature_ZeroPrice() public { + vm.warp(1000); + + uint256 nextTokenId = loyaltyCard.nextTokenIdToMint(); + uint256 currentTotalSupply = loyaltyCard.totalSupply(); + uint256 currentBalanceOfRecipient = loyaltyCard.balanceOf(recipient); + + loyaltyCard.mintWithSignature(_mintrequest, _signature); + + assertEq(loyaltyCard.nextTokenIdToMint(), nextTokenId + _mintrequest.quantity); + assertEq(loyaltyCard.tokenURI(nextTokenId), string(abi.encodePacked(_mintrequest.uri))); + assertEq(loyaltyCard.totalSupply(), currentTotalSupply + _mintrequest.quantity); + assertEq(loyaltyCard.balanceOf(recipient), currentBalanceOfRecipient + _mintrequest.quantity); + assertEq(loyaltyCard.ownerOf(nextTokenId), recipient); + } + + function test_state_mintWithSignature_NonZeroPrice_ERC20() public { + vm.warp(1000); + + _mintrequest.pricePerToken = 1; + _mintrequest.currency = address(erc20); + _signature = signMintRequest(_mintrequest, privateKey); + + vm.prank(recipient); + erc20.approve(address(loyaltyCard), 1); + + uint256 nextTokenId = loyaltyCard.nextTokenIdToMint(); + uint256 currentTotalSupply = loyaltyCard.totalSupply(); + uint256 currentBalanceOfRecipient = loyaltyCard.balanceOf(recipient); + + vm.prank(recipient); + loyaltyCard.mintWithSignature(_mintrequest, _signature); + + assertEq(loyaltyCard.nextTokenIdToMint(), nextTokenId + _mintrequest.quantity); + assertEq(loyaltyCard.tokenURI(nextTokenId), string(abi.encodePacked(_mintrequest.uri))); + assertEq(loyaltyCard.totalSupply(), currentTotalSupply + _mintrequest.quantity); + assertEq(loyaltyCard.balanceOf(recipient), currentBalanceOfRecipient + _mintrequest.quantity); + assertEq(loyaltyCard.ownerOf(nextTokenId), recipient); + } + + function test_state_mintWithSignature_NonZeroPrice_NativeToken() public { + vm.warp(1000); + + _mintrequest.pricePerToken = 1; + _mintrequest.currency = address(NATIVE_TOKEN); + _signature = signMintRequest(_mintrequest, privateKey); + + uint256 nextTokenId = loyaltyCard.nextTokenIdToMint(); + uint256 currentTotalSupply = loyaltyCard.totalSupply(); + uint256 currentBalanceOfRecipient = loyaltyCard.balanceOf(recipient); + + vm.deal(recipient, 1); + + vm.prank(recipient); + loyaltyCard.mintWithSignature{ value: 1 }(_mintrequest, _signature); + + assertEq(loyaltyCard.nextTokenIdToMint(), nextTokenId + _mintrequest.quantity); + assertEq(loyaltyCard.tokenURI(nextTokenId), string(abi.encodePacked(_mintrequest.uri))); + assertEq(loyaltyCard.totalSupply(), currentTotalSupply + _mintrequest.quantity); + assertEq(loyaltyCard.balanceOf(recipient), currentBalanceOfRecipient + _mintrequest.quantity); + assertEq(loyaltyCard.ownerOf(nextTokenId), recipient); + } + + function test_revert_mintWithSignature_InvalidMsgValue() public { + vm.warp(1000); + + _mintrequest.pricePerToken = 1; + _mintrequest.currency = address(NATIVE_TOKEN); + _signature = signMintRequest(_mintrequest, privateKey); + + vm.prank(recipient); + vm.expectRevert("Invalid msg value"); + loyaltyCard.mintWithSignature{ value: 0 }(_mintrequest, _signature); + } + + function test_revert_mintWithSignature_ZeroQty() public { + vm.warp(1000); + + _mintrequest.quantity = 0; + _signature = signMintRequest(_mintrequest, privateKey); + + vm.expectRevert("LoyaltyCard: only 1 NFT can be minted at a time."); + loyaltyCard.mintWithSignature(_mintrequest, _signature); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `setTokenURI` + //////////////////////////////////////////////////////////////*/ + + function test_setTokenURI_state() public { + string memory uri = "uri_string"; + + vm.prank(signer); + loyaltyCard.setTokenURI(0, uri); + + string memory _tokenURI = loyaltyCard.tokenURI(0); + + assertEq(_tokenURI, uri); + } + + function test_setTokenURI_revert_NotAuthorized() public { + string memory uri = "uri_string"; + + vm.expectRevert(abi.encodeWithSelector(NFTMetadata.NFTMetadataUnauthorized.selector)); + vm.prank(address(0x1)); + loyaltyCard.setTokenURI(0, uri); + } + + function test_setTokenURI_revert_Frozen() public { + string memory uri = "uri_string"; + + vm.startPrank(signer); + loyaltyCard.freezeMetadata(); + + vm.expectRevert(abi.encodeWithSelector(NFTMetadata.NFTMetadataFrozen.selector, 0)); + loyaltyCard.setTokenURI(0, uri); + } + + /*/////////////////////////////////////////////////////////////// + Audit fixes tests + //////////////////////////////////////////////////////////////*/ + + function test_audit_quantity_not_1() public { + vm.warp(1000); + _mintrequest.pricePerToken = 1; + _mintrequest.quantity = 5; + _mintrequest.currency = address(erc20); + _signature = signMintRequest(_mintrequest, privateKey); + + vm.prank(recipient); + erc20.approve(address(loyaltyCard), 5); + + vm.prank(recipient); + vm.expectRevert("LoyaltyCard: only 1 NFT can be minted at a time."); + loyaltyCard.mintWithSignature(_mintrequest, _signature); + } +} diff --git a/src/test/LoyaltyPoints.t.sol b/src/test/LoyaltyPoints.t.sol new file mode 100644 index 000000000..c557091bf --- /dev/null +++ b/src/test/LoyaltyPoints.t.sol @@ -0,0 +1,265 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import "./utils/BaseTest.sol"; +import "contracts/infra/TWProxy.sol"; +import { Strings } from "contracts/lib/Strings.sol"; +import { LoyaltyPoints } from "contracts/prebuilts/unaudited/loyalty/LoyaltyPoints.sol"; + +contract LoyaltyPointsTest is BaseTest { + LoyaltyPoints internal loyaltyPoints; + using Strings for uint256; + + bytes32 internal typehashMintRequest; + bytes32 internal nameHash; + bytes32 internal versionHash; + bytes32 internal typehashEip712; + bytes32 internal domainSeparator; + + LoyaltyPoints.MintRequest _mintrequest; + bytes _signature; + + address recipient; + + function setUp() public override { + super.setUp(); + + address loyaltyPointsImpl = address(new LoyaltyPoints()); + + vm.prank(signer); + loyaltyPoints = LoyaltyPoints( + address( + new TWProxy( + loyaltyPointsImpl, + abi.encodeCall( + LoyaltyPoints.initialize, + ( + signer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + platformFeeBps, + platformFeeRecipient + ) + ) + ) + ) + ); + + recipient = address(0x123); + erc20.mint(recipient, 1_000); + vm.deal(recipient, 1_000); + + typehashMintRequest = keccak256( + "MintRequest(address to,address primarySaleRecipient,uint256 quantity,uint256 price,address currency,uint128 validityStartTimestamp,uint128 validityEndTimestamp,bytes32 uid)" + ); + nameHash = keccak256(bytes(NAME)); + versionHash = keccak256(bytes("1")); + typehashEip712 = keccak256( + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" + ); + domainSeparator = keccak256( + abi.encode(typehashEip712, nameHash, versionHash, block.chainid, address(loyaltyPoints)) + ); + + _mintrequest.to = recipient; + _mintrequest.primarySaleRecipient = saleRecipient; + _mintrequest.quantity = 1 ether; + _mintrequest.price = 0; + _mintrequest.currency = address(0); + _mintrequest.validityStartTimestamp = 1000; + _mintrequest.validityEndTimestamp = 2000; + _mintrequest.uid = bytes32(0); + + _signature = signMintRequest(_mintrequest, privateKey); + } + + function signMintRequest( + LoyaltyPoints.MintRequest memory _request, + uint256 _privateKey + ) internal view returns (bytes memory) { + bytes memory encodedRequest = abi.encode( + typehashMintRequest, + _request.to, + _request.primarySaleRecipient, + _request.quantity, + _request.price, + _request.currency, + _request.validityStartTimestamp, + _request.validityEndTimestamp, + _request.uid + ); + bytes32 structHash = keccak256(encodedRequest); + bytes32 typedDataHash = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(_privateKey, typedDataHash); + bytes memory sig = abi.encodePacked(r, s, v); + + return sig; + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `mintTo` + //////////////////////////////////////////////////////////////*/ + + function test_state_mintTo() public { + uint256 amount = 1 ether; + + uint256 currentTotalSupply = loyaltyPoints.totalSupply(); + uint256 currentBalanceOfRecipient = loyaltyPoints.balanceOf(recipient); + + vm.prank(signer); + loyaltyPoints.mintTo(recipient, amount); + + assertEq(loyaltyPoints.totalSupply(), currentTotalSupply + amount); + assertEq(loyaltyPoints.balanceOf(recipient), currentBalanceOfRecipient + amount); + + assertEq(loyaltyPoints.getTotalMintedInLifetime(recipient), amount); + + vm.prank(signer); + loyaltyPoints.mintTo(recipient, amount); + assertEq(loyaltyPoints.getTotalMintedInLifetime(recipient), amount * 2); + + vm.prank(recipient); + loyaltyPoints.cancel(recipient, amount); + assertEq(loyaltyPoints.getTotalMintedInLifetime(recipient), amount * 2); + + vm.prank(signer); + loyaltyPoints.revoke(recipient, amount); + assertEq(loyaltyPoints.getTotalMintedInLifetime(recipient), amount * 2); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: cancel / revoke loyalty + //////////////////////////////////////////////////////////////*/ + + function test_state_cancelLoyalty() public { + uint256 amount = 10 ether; + + vm.prank(signer); + loyaltyPoints.mintTo(recipient, amount); + + assertEq(loyaltyPoints.balanceOf(recipient), amount); + + uint256 amountToCancel = 1 ether; + + vm.prank(recipient); + loyaltyPoints.approve(signer, amountToCancel); + + vm.prank(signer); + loyaltyPoints.cancel(recipient, amountToCancel); + assertEq(loyaltyPoints.balanceOf(recipient), amount - amountToCancel); + } + + function test_state_revokeLoyalty() public { + uint256 amount = 10 ether; + + vm.prank(signer); + loyaltyPoints.mintTo(recipient, amount); + + assertEq(loyaltyPoints.balanceOf(recipient), amount); + + address burner = address(0x123456); + vm.prank(signer); + loyaltyPoints.grantRole(keccak256("REVOKE_ROLE"), burner); + + vm.prank(signer); + loyaltyPoints.renounceRole(keccak256("REVOKE_ROLE"), signer); + + uint256 amountToRevoke = 1 ether; + + vm.expectRevert(); + vm.prank(signer); + loyaltyPoints.revoke(recipient, amountToRevoke); + + vm.prank(burner); + loyaltyPoints.revoke(recipient, amountToRevoke); + assertEq(loyaltyPoints.balanceOf(recipient), amount - amountToRevoke); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `mintWithSignature` + //////////////////////////////////////////////////////////////*/ + + function test_state_mintWithSignature_ZeroPrice() public { + vm.warp(1000); + + uint256 currentTotalSupply = loyaltyPoints.totalSupply(); + uint256 currentBalanceOfRecipient = loyaltyPoints.balanceOf(recipient); + + loyaltyPoints.mintWithSignature(_mintrequest, _signature); + + assertEq(loyaltyPoints.totalSupply(), currentTotalSupply + _mintrequest.quantity); + assertEq(loyaltyPoints.balanceOf(recipient), currentBalanceOfRecipient + _mintrequest.quantity); + } + + function test_state_mintWithSignature_NonZeroPrice_ERC20() public { + vm.warp(1000); + + _mintrequest.price = 1; + _mintrequest.currency = address(erc20); + _signature = signMintRequest(_mintrequest, privateKey); + + vm.prank(recipient); + erc20.approve(address(loyaltyPoints), 1); + + uint256 currentTotalSupply = loyaltyPoints.totalSupply(); + uint256 currentBalanceOfRecipient = loyaltyPoints.balanceOf(recipient); + uint256 currentCurrencyBalOfRecipient = erc20.balanceOf(recipient); + + vm.prank(recipient); + loyaltyPoints.mintWithSignature(_mintrequest, _signature); + + assertEq(loyaltyPoints.totalSupply(), currentTotalSupply + _mintrequest.quantity); + assertEq(loyaltyPoints.balanceOf(recipient), currentBalanceOfRecipient + _mintrequest.quantity); + assertEq(erc20.balanceOf(recipient), currentCurrencyBalOfRecipient - _mintrequest.price); + } + + function test_state_mintWithSignature_NonZeroPrice_NativeToken() public { + vm.warp(1000); + + _mintrequest.price = 1; + _mintrequest.currency = address(NATIVE_TOKEN); + _signature = signMintRequest(_mintrequest, privateKey); + + uint256 currentTotalSupply = loyaltyPoints.totalSupply(); + uint256 currentBalanceOfRecipient = loyaltyPoints.balanceOf(recipient); + + vm.deal(recipient, 1); + uint256 currentCurrencyBalOfRecipient = recipient.balance; + + vm.prank(recipient); + loyaltyPoints.mintWithSignature{ value: 1 }(_mintrequest, _signature); + + assertEq(loyaltyPoints.totalSupply(), currentTotalSupply + _mintrequest.quantity); + assertEq(loyaltyPoints.balanceOf(recipient), currentBalanceOfRecipient + _mintrequest.quantity); + assertEq(recipient.balance, currentCurrencyBalOfRecipient - _mintrequest.price); + } + + function test_revert_mintWithSignature_InvalidMsgValue() public { + vm.warp(1000); + + _mintrequest.price = 1; + _mintrequest.currency = address(NATIVE_TOKEN); + _signature = signMintRequest(_mintrequest, privateKey); + + vm.prank(recipient); + vm.expectRevert("Invalid msg value"); + loyaltyPoints.mintWithSignature{ value: 0 }(_mintrequest, _signature); + } + + function test_revert_mintWithSignature_ZeroQty() public { + vm.warp(1000); + + _mintrequest.quantity = 0; + _signature = signMintRequest(_mintrequest, privateKey); + + vm.expectRevert("Minting zero qty"); + loyaltyPoints.mintWithSignature(_mintrequest, _signature); + } +} diff --git a/src/test/Multicall.t.sol b/src/test/Multicall.t.sol new file mode 100644 index 000000000..3b9bbfb93 --- /dev/null +++ b/src/test/Multicall.t.sol @@ -0,0 +1,260 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "@std/Test.sol"; + +import { Multicall } from "contracts/extension/Multicall.sol"; +import { Forwarder } from "contracts/infra/forwarder/Forwarder.sol"; +import { ERC2771Context } from "contracts/extension/upgradeable/ERC2771Context.sol"; +import { TokenERC721 } from "contracts/prebuilts/token/TokenERC721.sol"; +import { Strings } from "contracts/lib/Strings.sol"; +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +contract MockMulticallForwarderConsumer is Multicall, ERC2771Context { + event Increment(address caller); + mapping(address => uint256) public counter; + + constructor(address[] memory trustedForwarders) ERC2771Context(trustedForwarders) {} + + function increment() external { + counter[_msgSender()]++; + emit Increment(_msgSender()); + } + + function _msgSender() internal view override(Multicall, ERC2771Context) returns (address sender) { + return ERC2771Context._msgSender(); + } +} + +contract MulticallTest is Test { + // Target (mock) contract + address internal consumer; + TokenERC721 internal token; + + address internal user1; + uint256 internal user1Pkey = 100; + + address internal user2; + uint256 internal user2Pkey = 200; + + // Forwarder details + Forwarder internal forwarder; + + bytes32 internal typehashForwardRequest; + bytes32 internal nameHash; + bytes32 internal versionHash; + + bytes32 internal typehashEip712; + bytes32 internal domainSeparator; + + function setUp() public { + user1 = vm.addr(user1Pkey); + user2 = vm.addr(user2Pkey); + + // Deploy forwarder + forwarder = new Forwarder(); + + // Deploy consumer + address[] memory forwarders = new address[](1); + forwarders[0] = address(forwarder); + consumer = address(new MockMulticallForwarderConsumer(forwarders)); + + // Deploy `TokenERC721` + address impl = address(new TokenERC721()); + token = TokenERC721( + address( + new TWProxy( + impl, + abi.encodeWithSelector( + TokenERC721.initialize.selector, + user1, + "name", + "SYMBOL", + "ipfs://", + forwarders, + user1, + user1, + 0, + 0, + user1 + ) + ) + ) + ); + + // Setup forwarder details + typehashForwardRequest = keccak256( + "ForwardRequest(address from,address to,uint256 value,uint256 gas,uint256 nonce,bytes data)" + ); + nameHash = keccak256(bytes("GSNv2 Forwarder")); + versionHash = keccak256(bytes("0.0.1")); + typehashEip712 = keccak256( + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" + ); + domainSeparator = keccak256( + abi.encode(typehashEip712, nameHash, versionHash, block.chainid, address(forwarder)) + ); + + vm.label(user1, "USER_1"); + vm.label(user2, "USER_2"); + vm.label(address(forwarder), "FORWARDER"); + vm.label(address(consumer), "CONSUMER"); + } + + function _signForwarderRequest( + Forwarder.ForwardRequest memory forwardRequest, + uint256 privateKey + ) internal view returns (bytes memory) { + bytes memory encodedRequest = abi.encode( + typehashForwardRequest, + forwardRequest.from, + forwardRequest.to, + forwardRequest.value, + forwardRequest.gas, + forwardRequest.nonce, + keccak256(forwardRequest.data) + ); + bytes32 structHash = keccak256(encodedRequest); + bytes32 typedDataHash = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, typedDataHash); + bytes memory signature = abi.encodePacked(r, s, v); + + return signature; + } + + function test_multicall_viaDirectCall() public { + // Make 3 calls to `increment` within a multicall + bytes[] memory calls = new bytes[](3); + calls[0] = abi.encodeWithSelector(MockMulticallForwarderConsumer.increment.selector); + calls[1] = abi.encodeWithSelector(MockMulticallForwarderConsumer.increment.selector); + calls[2] = abi.encodeWithSelector(MockMulticallForwarderConsumer.increment.selector); + + // CASE 1: multicall without using forwarder. Should increment counter for the caller i.e. `msg.sender`. + + assertEq(MockMulticallForwarderConsumer(consumer).counter(user1), 0); + + vm.prank(user1); + Multicall(consumer).multicall(calls); + + assertEq(MockMulticallForwarderConsumer(consumer).counter(user1), 3); // counter incremented! + } + + function test_multicall_viaForwarder() public { + // Make 3 calls to `increment` within a multicall + bytes[] memory calls = new bytes[](3); + calls[0] = abi.encodeWithSelector(MockMulticallForwarderConsumer.increment.selector); + calls[1] = abi.encodeWithSelector(MockMulticallForwarderConsumer.increment.selector); + calls[2] = abi.encodeWithSelector(MockMulticallForwarderConsumer.increment.selector); + + // CASE 2: multicall with using forwarder. Should increment counter for the signer of the forwarder request. + + bytes memory multicallData = abi.encodeWithSelector(Multicall.multicall.selector, calls); + + Forwarder.ForwardRequest memory forwardRequest; + + forwardRequest.from = user1; + forwardRequest.to = address(consumer); + forwardRequest.value = 0; + forwardRequest.gas = 100_000; + forwardRequest.nonce = Forwarder(forwarder).getNonce(user1); + forwardRequest.data = multicallData; + + bytes memory signature = _signForwarderRequest(forwardRequest, user1Pkey); + + Forwarder(forwarder).execute(forwardRequest, signature); + + assertEq(MockMulticallForwarderConsumer(consumer).counter(user1), 3); // counter incremented! + } + + function test_multicall_viaForwarder_attemptSpoof() public { + // Make 3 calls to `increment` within a multicall + bytes[] memory callsSpoof = new bytes[](3); + callsSpoof[0] = abi.encodePacked( + abi.encodeWithSelector(MockMulticallForwarderConsumer.increment.selector), + user1 + ); + callsSpoof[1] = abi.encodePacked( + abi.encodeWithSelector(MockMulticallForwarderConsumer.increment.selector), + user1 + ); + callsSpoof[2] = abi.encodePacked( + abi.encodeWithSelector(MockMulticallForwarderConsumer.increment.selector), + user1 + ); + + // CASE 3: attempting to spoof address by manually appending address to multicall data arg. + // + // This attempt fails because `multicall` enforces original forwarder request signer + // as the `_msgSender()`. + + bytes memory multicallDataSpoof = abi.encodeWithSelector(Multicall.multicall.selector, callsSpoof); + + // user2 spoofing as user1 + Forwarder.ForwardRequest memory forwardRequestSpoof; + + forwardRequestSpoof.from = user2; + forwardRequestSpoof.to = address(consumer); + forwardRequestSpoof.value = 0; + forwardRequestSpoof.gas = 100_000; + forwardRequestSpoof.nonce = Forwarder(forwarder).getNonce(user2); + forwardRequestSpoof.data = multicallDataSpoof; + + bytes memory signatureSpoof = _signForwarderRequest(forwardRequestSpoof, user2Pkey); + + // vm.expectRevert(); + Forwarder(forwarder).execute(forwardRequestSpoof, signatureSpoof); + + assertEq(MockMulticallForwarderConsumer(consumer).counter(user1), 0); // counter unchanged! + assertEq(MockMulticallForwarderConsumer(consumer).counter(user2), 3); // counter incremented for forwarder request signer! + } + + function test_multicall_tokenerc721_viaForwarder_attemptSpoof() public { + // User1 is admin on `token` + assertTrue(token.hasRole(keccak256("MINTER_ROLE"), user1)); + + // token ID `0` has no owner + vm.expectRevert("ERC721: invalid token ID"); + token.ownerOf(0); + + // Make call to `mintTo` within a multicall + bytes[] memory callsSpoof = new bytes[](1); + callsSpoof[0] = abi.encodePacked( + abi.encodeWithSelector(TokenERC721.mintTo.selector, user2, "metadataURI"), + user1 + ); + // CASE: attempting to spoof address by manually appending address to multicall data arg. + // + // This attempt fails because `multicall` enforces original forwarder request signer + // as the `_msgSender()`. + + bytes memory multicallDataSpoof = abi.encodeWithSelector(Multicall.multicall.selector, callsSpoof); + + // user2 spoofing as user1 + Forwarder.ForwardRequest memory forwardRequestSpoof; + + forwardRequestSpoof.from = user2; + forwardRequestSpoof.to = address(token); + forwardRequestSpoof.value = 0; + forwardRequestSpoof.gas = 100_000; + forwardRequestSpoof.nonce = Forwarder(forwarder).getNonce(user2); + forwardRequestSpoof.data = multicallDataSpoof; + + bytes memory signatureSpoof = _signForwarderRequest(forwardRequestSpoof, user2Pkey); + + // Minter role check occurs on user2 i.e. signer of the forwarder request, and not user1 i.e. the address user2 attempts to spoof. + vm.expectRevert( + abi.encodePacked( + "AccessControl: account ", + Strings.toHexString(uint160(user2), 20), + " is missing role ", + Strings.toHexString(uint256(keccak256("MINTER_ROLE")), 32) + ) + ); + Forwarder(forwarder).execute(forwardRequestSpoof, signatureSpoof); + + // token ID `0` still has no owner + vm.expectRevert("ERC721: invalid token ID"); + token.ownerOf(0); + } +} diff --git a/src/test/Multiwrap.t.sol b/src/test/Multiwrap.t.sol new file mode 100644 index 000000000..de0dd7bf8 --- /dev/null +++ b/src/test/Multiwrap.t.sol @@ -0,0 +1,844 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { Multiwrap } from "contracts/prebuilts/multiwrap/Multiwrap.sol"; +import { ITokenBundle } from "contracts/extension/interface/ITokenBundle.sol"; +import { CurrencyTransferLib } from "contracts/lib/CurrencyTransferLib.sol"; + +// Test imports +import { MockERC20 } from "./mocks/MockERC20.sol"; +import { Strings } from "contracts/lib/Strings.sol"; +import { Wallet } from "./utils/Wallet.sol"; +import "./utils/BaseTest.sol"; + +contract MultiwrapReentrant is MockERC20, ITokenBundle { + Multiwrap internal multiwrap; + uint256 internal tokenIdOfWrapped = 0; + + constructor(address payable _multiwrap) { + multiwrap = Multiwrap(_multiwrap); + } + + function transferFrom(address from, address to, uint256 amount) public override returns (bool) { + multiwrap.unwrap(0, address(this)); + return super.transferFrom(from, to, amount); + } +} + +contract MultiwrapTest is BaseTest { + /// @dev Emitted when tokens are wrapped. + event TokensWrapped( + address indexed wrapper, + address indexed recipientOfWrappedToken, + uint256 indexed tokenIdOfWrappedToken, + ITokenBundle.Token[] wrappedContents + ); + + /// @dev Emitted when tokens are unwrapped. + event TokensUnwrapped( + address indexed unwrapper, + address indexed recipientOfWrappedContents, + uint256 indexed tokenIdOfWrappedToken + ); + + /*/////////////////////////////////////////////////////////////// + Setup + //////////////////////////////////////////////////////////////*/ + + Multiwrap internal multiwrap; + + Wallet internal tokenOwner; + string internal uriForWrappedToken; + ITokenBundle.Token[] internal wrappedContent; + + function setUp() public override { + super.setUp(); + + // Get target contract + multiwrap = Multiwrap(payable(getContract("Multiwrap"))); + + // Set test vars + tokenOwner = getWallet(); + uriForWrappedToken = "ipfs://baseURI/"; + + wrappedContent.push( + ITokenBundle.Token({ + assetContract: address(erc20), + tokenType: ITokenBundle.TokenType.ERC20, + tokenId: 0, + totalAmount: 10 ether + }) + ); + wrappedContent.push( + ITokenBundle.Token({ + assetContract: address(erc721), + tokenType: ITokenBundle.TokenType.ERC721, + tokenId: 0, + totalAmount: 1 + }) + ); + wrappedContent.push( + ITokenBundle.Token({ + assetContract: address(erc1155), + tokenType: ITokenBundle.TokenType.ERC1155, + tokenId: 0, + totalAmount: 100 + }) + ); + + // Mint tokens-to-wrap to `tokenOwner` + erc20.mint(address(tokenOwner), 10 ether); + erc721.mint(address(tokenOwner), 1); + erc1155.mint(address(tokenOwner), 0, 100); + + // Token owner approves `Multiwrap` to transfer tokens. + tokenOwner.setAllowanceERC20(address(erc20), address(multiwrap), type(uint256).max); + tokenOwner.setApprovalForAllERC721(address(erc721), address(multiwrap), true); + tokenOwner.setApprovalForAllERC1155(address(erc1155), address(multiwrap), true); + + // Grant MINTER_ROLE / requisite wrapping permissions to `tokenOwer` + vm.prank(deployer); + multiwrap.grantRole(keccak256("MINTER_ROLE"), address(tokenOwner)); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: misc. + //////////////////////////////////////////////////////////////*/ + + /** + * note: Tests whether contract revert when a non-holder renounces a role. + */ + function test_revert_nonHolder_renounceRole() public { + address caller = address(0x123); + bytes32 role = keccak256("MINTER_ROLE"); + + vm.prank(caller); + vm.expectRevert(abi.encodeWithSelector(Permissions.PermissionsUnauthorizedAccount.selector, caller, role)); + + multiwrap.renounceRole(role, caller); + } + + /** + * note: Tests whether contract revert when a role admin revokes a role for a non-holder. + */ + function test_revert_revokeRoleForNonHolder() public { + address target = address(0x123); + bytes32 role = keccak256("MINTER_ROLE"); + + vm.prank(deployer); + vm.expectRevert(abi.encodeWithSelector(Permissions.PermissionsUnauthorizedAccount.selector, target, role)); + + multiwrap.revokeRole(role, target); + } + + /** + * Unit tests for relevant functions: + * - `wrap` + * - `unwrap` + */ + + /*/////////////////////////////////////////////////////////////// + Unit tests: `wrap` + //////////////////////////////////////////////////////////////*/ + + /** + * note: Testing state changes; token owner calls `wrap` to wrap owned tokens. + */ + function test_state_wrap() public { + uint256 expectedIdForWrappedToken = multiwrap.nextTokenIdToMint(); + address recipient = address(0x123); + + vm.prank(address(tokenOwner)); + multiwrap.wrap(wrappedContent, uriForWrappedToken, recipient); + + assertEq(expectedIdForWrappedToken + 1, multiwrap.nextTokenIdToMint()); + + ITokenBundle.Token[] memory contentsOfWrappedToken = multiwrap.getWrappedContents(expectedIdForWrappedToken); + assertEq(contentsOfWrappedToken.length, wrappedContent.length); + for (uint256 i = 0; i < contentsOfWrappedToken.length; i += 1) { + assertEq(contentsOfWrappedToken[i].assetContract, wrappedContent[i].assetContract); + assertEq(uint256(contentsOfWrappedToken[i].tokenType), uint256(wrappedContent[i].tokenType)); + assertEq(contentsOfWrappedToken[i].tokenId, wrappedContent[i].tokenId); + assertEq(contentsOfWrappedToken[i].totalAmount, wrappedContent[i].totalAmount); + } + + assertEq(uriForWrappedToken, multiwrap.tokenURI(expectedIdForWrappedToken)); + } + + /* + * note: Testing state changes; token owner calls `wrap` to wrap native tokens. + */ + function test_state_wrap_nativeTokens() public { + uint256 expectedIdForWrappedToken = multiwrap.nextTokenIdToMint(); + address recipient = address(0x123); + + ITokenBundle.Token[] memory nativeTokenContentToWrap = new ITokenBundle.Token[](1); + + vm.deal(address(tokenOwner), 100 ether); + nativeTokenContentToWrap[0] = ITokenBundle.Token({ + assetContract: 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE, + tokenType: ITokenBundle.TokenType.ERC20, + tokenId: 0, + totalAmount: 10 ether + }); + + vm.prank(address(tokenOwner)); + multiwrap.wrap{ value: 10 ether }(nativeTokenContentToWrap, uriForWrappedToken, recipient); + + assertEq(expectedIdForWrappedToken + 1, multiwrap.nextTokenIdToMint()); + + ITokenBundle.Token[] memory contentsOfWrappedToken = multiwrap.getWrappedContents(expectedIdForWrappedToken); + assertEq(contentsOfWrappedToken.length, nativeTokenContentToWrap.length); + for (uint256 i = 0; i < contentsOfWrappedToken.length; i += 1) { + assertEq(contentsOfWrappedToken[i].assetContract, nativeTokenContentToWrap[i].assetContract); + assertEq(uint256(contentsOfWrappedToken[i].tokenType), uint256(nativeTokenContentToWrap[i].tokenType)); + assertEq(contentsOfWrappedToken[i].tokenId, nativeTokenContentToWrap[i].tokenId); + assertEq(contentsOfWrappedToken[i].totalAmount, nativeTokenContentToWrap[i].totalAmount); + } + + assertEq(uriForWrappedToken, multiwrap.tokenURI(expectedIdForWrappedToken)); + } + + /** + * note: Testing state changes; token owner calls `wrap` to wrap owned tokens. + * Only assets with ASSET_ROLE can be wrapped. + */ + function test_state_wrap_withAssetRoleRestriction() public { + // ===== setup ===== + + vm.startPrank(deployer); + multiwrap.revokeRole(keccak256("ASSET_ROLE"), address(0)); + + for (uint256 i = 0; i < wrappedContent.length; i += 1) { + multiwrap.grantRole(keccak256("ASSET_ROLE"), wrappedContent[i].assetContract); + } + + vm.stopPrank(); + + // ===== target test content ===== + uint256 expectedIdForWrappedToken = multiwrap.nextTokenIdToMint(); + address recipient = address(0x123); + + vm.prank(address(tokenOwner)); + multiwrap.wrap(wrappedContent, uriForWrappedToken, recipient); + + assertEq(expectedIdForWrappedToken + 1, multiwrap.nextTokenIdToMint()); + + ITokenBundle.Token[] memory contentsOfWrappedToken = multiwrap.getWrappedContents(expectedIdForWrappedToken); + assertEq(contentsOfWrappedToken.length, wrappedContent.length); + for (uint256 i = 0; i < contentsOfWrappedToken.length; i += 1) { + assertEq(contentsOfWrappedToken[i].assetContract, wrappedContent[i].assetContract); + assertEq(uint256(contentsOfWrappedToken[i].tokenType), uint256(wrappedContent[i].tokenType)); + assertEq(contentsOfWrappedToken[i].tokenId, wrappedContent[i].tokenId); + assertEq(contentsOfWrappedToken[i].totalAmount, wrappedContent[i].totalAmount); + } + + assertEq(uriForWrappedToken, multiwrap.tokenURI(expectedIdForWrappedToken)); + } + + /** + * note: Testing event emission; token owner calls `wrap` to wrap owned tokens. + */ + function test_event_wrap_TokensWrapped() public { + uint256 expectedIdForWrappedToken = multiwrap.nextTokenIdToMint(); + address recipient = address(0x123); + + vm.prank(address(tokenOwner)); + + vm.expectEmit(true, true, true, true); + emit TokensWrapped(address(tokenOwner), recipient, expectedIdForWrappedToken, wrappedContent); + + multiwrap.wrap(wrappedContent, uriForWrappedToken, recipient); + } + + /** + * note: Testing token balances; token owner calls `wrap` to wrap owned tokens. + */ + function test_balances_wrap() public { + // ERC20 balance + assertEq(erc20.balanceOf(address(tokenOwner)), 10 ether); + assertEq(erc20.balanceOf(address(multiwrap)), 0); + + // ERC721 balance + assertEq(erc721.ownerOf(0), address(tokenOwner)); + + // ERC1155 balance + assertEq(erc1155.balanceOf(address(tokenOwner), 0), 100); + assertEq(erc1155.balanceOf(address(multiwrap), 0), 0); + + uint256 expectedIdForWrappedToken = multiwrap.nextTokenIdToMint(); + address recipient = address(0x123); + + vm.prank(address(tokenOwner)); + multiwrap.wrap(wrappedContent, uriForWrappedToken, recipient); + + // ERC20 balance + assertEq(erc20.balanceOf(address(tokenOwner)), 0); + assertEq(erc20.balanceOf(address(multiwrap)), 10 ether); + + // ERC721 balance + assertEq(erc721.ownerOf(0), address(multiwrap)); + + // ERC1155 balance + assertEq(erc1155.balanceOf(address(tokenOwner), 0), 0); + assertEq(erc1155.balanceOf(address(multiwrap), 0), 100); + + // Multiwrap wrapped token balance + assertEq(multiwrap.ownerOf(expectedIdForWrappedToken), recipient); + } + + /** + * note: Testing revert condition; token owner calls `wrap` to wrap owned tokens. + */ + function test_revert_wrap_reentrancy() public { + MultiwrapReentrant reentrant = new MultiwrapReentrant(payable(address(multiwrap))); + ITokenBundle.Token[] memory reentrantContentToWrap = new ITokenBundle.Token[](1); + + reentrant.mint(address(tokenOwner), 10 ether); + reentrantContentToWrap[0] = ITokenBundle.Token({ + assetContract: address(reentrant), + tokenType: ITokenBundle.TokenType.ERC20, + tokenId: 0, + totalAmount: 10 ether + }); + + tokenOwner.setAllowanceERC20(address(reentrant), address(multiwrap), 10 ether); + + address recipient = address(0x123); + + vm.prank(address(tokenOwner)); + vm.expectRevert("ReentrancyGuard: reentrant call"); + multiwrap.wrap(reentrantContentToWrap, uriForWrappedToken, recipient); + } + + /** + * note: Testing revert condition; token owner calls `wrap` to wrap owned tokens. + * Only assets with ASSET_ROLE can be wrapped, but assets being wrapped don't have that role. + */ + function test_revert_wrap_access_ASSET_ROLE() public { + vm.prank(deployer); + multiwrap.revokeRole(keccak256("ASSET_ROLE"), address(0)); + + address recipient = address(0x123); + + vm.prank(address(tokenOwner)); + vm.expectRevert( + abi.encodeWithSelector( + Permissions.PermissionsUnauthorizedAccount.selector, + address(erc20), + keccak256("ASSET_ROLE") + ) + ); + multiwrap.wrap(wrappedContent, uriForWrappedToken, recipient); + } + + /** + * note: Testing revert condition; token owner calls `wrap` to wrap owned tokens, without MINTER_ROLE. + */ + function test_revert_wrap_access_MINTER_ROLE() public { + vm.prank(address(tokenOwner)); + multiwrap.renounceRole(keccak256("MINTER_ROLE"), address(tokenOwner)); + + address recipient = address(0x123); + + vm.prank(address(tokenOwner)); + vm.expectRevert( + abi.encodeWithSelector( + Permissions.PermissionsUnauthorizedAccount.selector, + address(tokenOwner), + keccak256("MINTER_ROLE") + ) + ); + multiwrap.wrap(wrappedContent, uriForWrappedToken, recipient); + } + + /** + * note: Testing revert condition; token owner calls `wrap` with insufficient value when wrapping native tokens. + */ + function test_revert_wrap_nativeTokens_insufficientValue() public { + address recipient = address(0x123); + + ITokenBundle.Token[] memory nativeTokenContentToWrap = new ITokenBundle.Token[](1); + + vm.deal(address(tokenOwner), 100 ether); + nativeTokenContentToWrap[0] = ITokenBundle.Token({ + assetContract: 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE, + tokenType: ITokenBundle.TokenType.ERC20, + tokenId: 0, + totalAmount: 10 ether + }); + + vm.prank(address(tokenOwner)); + vm.expectRevert( + abi.encodeWithSelector(CurrencyTransferLib.CurrencyTransferLibMismatchedValue.selector, 0, 10 ether) + ); + multiwrap.wrap(nativeTokenContentToWrap, uriForWrappedToken, recipient); + } + + /** + * note: Testing revert condition; token owner calls `wrap` to wrap native tokens, but with multiple instances in `tokensToWrap` array. + */ + function test_balances_wrap_nativeTokens_multipleInstances() public { + address recipient = address(0x123); + + ITokenBundle.Token[] memory nativeTokenContentToWrap = new ITokenBundle.Token[](2); + + vm.deal(address(tokenOwner), 100 ether); + nativeTokenContentToWrap[0] = ITokenBundle.Token({ + assetContract: 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE, + tokenType: ITokenBundle.TokenType.ERC20, + tokenId: 0, + totalAmount: 5 ether + }); + nativeTokenContentToWrap[1] = ITokenBundle.Token({ + assetContract: 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE, + tokenType: ITokenBundle.TokenType.ERC20, + tokenId: 0, + totalAmount: 5 ether + }); + + vm.prank(address(tokenOwner)); + multiwrap.wrap{ value: 10 ether }(nativeTokenContentToWrap, uriForWrappedToken, recipient); + + assertEq(weth.balanceOf(address(multiwrap)), 10 ether); + } + + /** + * note: Testing revert condition; token owner calls `wrap` to wrap un-owned ERC20 tokens. + */ + function test_revert_wrap_notOwner_ERC20() public { + tokenOwner.transferERC20(address(erc20), address(0x12), 10 ether); + + address recipient = address(0x123); + + vm.prank(address(tokenOwner)); + vm.expectRevert("ERC20: transfer amount exceeds balance"); + multiwrap.wrap(wrappedContent, uriForWrappedToken, recipient); + } + + /** + * note: Testing revert condition; token owner calls `wrap` to wrap un-owned ERC721 tokens. + */ + function test_revert_wrap_notOwner_ERC721() public { + tokenOwner.transferERC721(address(erc721), address(0x12), 0); + + address recipient = address(0x123); + + vm.prank(address(tokenOwner)); + vm.expectRevert("ERC721: caller is not token owner or approved"); + multiwrap.wrap(wrappedContent, uriForWrappedToken, recipient); + } + + /** + * note: Testing revert condition; token owner calls `wrap` to wrap un-owned ERC1155 tokens. + */ + function test_revert_wrap_notOwner_ERC1155() public { + tokenOwner.transferERC1155(address(erc1155), address(0x12), 0, 100, ""); + + address recipient = address(0x123); + + vm.prank(address(tokenOwner)); + vm.expectRevert("ERC1155: insufficient balance for transfer"); + multiwrap.wrap(wrappedContent, uriForWrappedToken, recipient); + } + + /** + * note: Testing revert condition; token owner calls `wrap` to wrap un-owned ERC20 tokens. + */ + function test_revert_wrap_notApprovedTransfer_ERC20() public { + tokenOwner.setAllowanceERC20(address(erc20), address(multiwrap), 0); + + address recipient = address(0x123); + + vm.prank(address(tokenOwner)); + vm.expectRevert("ERC20: insufficient allowance"); + multiwrap.wrap(wrappedContent, uriForWrappedToken, recipient); + } + + /** + * note: Testing revert condition; token owner calls `wrap` to wrap un-owned ERC721 tokens. + */ + function test_revert_wrap_notApprovedTransfer_ERC721() public { + tokenOwner.setApprovalForAllERC721(address(erc721), address(multiwrap), false); + + address recipient = address(0x123); + + vm.prank(address(tokenOwner)); + vm.expectRevert("ERC721: caller is not token owner or approved"); + multiwrap.wrap(wrappedContent, uriForWrappedToken, recipient); + } + + /** + * note: Testing revert condition; token owner calls `wrap` to wrap un-owned ERC1155 tokens. + */ + function test_revert_wrap_notApprovedTransfer_ERC1155() public { + tokenOwner.setApprovalForAllERC1155(address(erc1155), address(multiwrap), false); + + address recipient = address(0x123); + + vm.prank(address(tokenOwner)); + vm.expectRevert("ERC1155: caller is not token owner or approved"); + multiwrap.wrap(wrappedContent, uriForWrappedToken, recipient); + } + + function test_revert_wrap_noTokensToWrap() public { + ITokenBundle.Token[] memory emptyContent; + + address recipient = address(0x123); + + vm.prank(address(tokenOwner)); + vm.expectRevert("!Tokens"); + multiwrap.wrap(emptyContent, uriForWrappedToken, recipient); + } + + function test_revert_wrap_nativeTokens_insufficientValueProvided_multipleInstances() public { + address recipient = address(0x123); + + ITokenBundle.Token[] memory nativeTokenContentToWrap = new ITokenBundle.Token[](2); + + vm.deal(address(tokenOwner), 100 ether); + vm.deal(address(multiwrap), 10 ether); + nativeTokenContentToWrap[0] = ITokenBundle.Token({ + assetContract: 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE, + tokenType: ITokenBundle.TokenType.ERC20, + tokenId: 0, + totalAmount: 10 ether + }); + nativeTokenContentToWrap[1] = ITokenBundle.Token({ + assetContract: 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE, + tokenType: ITokenBundle.TokenType.ERC20, + tokenId: 0, + totalAmount: 10 ether + }); + + vm.prank(address(tokenOwner)); + vm.expectRevert( + abi.encodeWithSelector(CurrencyTransferLib.CurrencyTransferLibMismatchedValue.selector, 10 ether, 20 ether) + ); + multiwrap.wrap{ value: 10 ether }(nativeTokenContentToWrap, uriForWrappedToken, recipient); + + assertEq(address(multiwrap).balance, 10 ether); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `unwrap` + //////////////////////////////////////////////////////////////*/ + + /** + * note: Testing state changes; wrapped token owner calls `unwrap` to unwrap underlying tokens. + */ + function test_state_unwrap() public { + // ===== setup: wrap tokens ===== + uint256 expectedIdForWrappedToken = multiwrap.nextTokenIdToMint(); + address recipient = address(0x123); + + vm.prank(address(tokenOwner)); + multiwrap.wrap(wrappedContent, uriForWrappedToken, recipient); + + // ===== target test content ===== + + vm.prank(recipient); + multiwrap.unwrap(expectedIdForWrappedToken, recipient); + + vm.expectRevert("ERC721: invalid token ID"); + multiwrap.ownerOf(expectedIdForWrappedToken); + + assertEq(uriForWrappedToken, multiwrap.tokenURI(expectedIdForWrappedToken)); + assertEq(0, multiwrap.getTokenCountOfBundle(expectedIdForWrappedToken)); + + ITokenBundle.Token[] memory contentsOfWrappedToken = multiwrap.getWrappedContents(expectedIdForWrappedToken); + assertEq(contentsOfWrappedToken.length, 0); + } + + /** + * note: Testing state changes; wrapped token owner calls `unwrap` to unwrap native tokens. + */ + function test_state_unwrap_nativeTokens() public { + // ===== setup: wrap tokens ===== + uint256 expectedIdForWrappedToken = multiwrap.nextTokenIdToMint(); + address recipient = address(0x123); + + ITokenBundle.Token[] memory nativeTokenContentToWrap = new ITokenBundle.Token[](1); + + vm.deal(address(tokenOwner), 100 ether); + nativeTokenContentToWrap[0] = ITokenBundle.Token({ + assetContract: 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE, + tokenType: ITokenBundle.TokenType.ERC20, + tokenId: 0, + totalAmount: 10 ether + }); + + vm.prank(address(tokenOwner)); + multiwrap.wrap{ value: 10 ether }(nativeTokenContentToWrap, uriForWrappedToken, recipient); + + // ===== target test content ===== + + assertEq(address(recipient).balance, 0); + + vm.prank(recipient); + // it fails here and it shouldn't + multiwrap.unwrap(expectedIdForWrappedToken, recipient); + + assertEq(address(recipient).balance, 10 ether); + } + + /** + * note: Testing state changes; wrapped token owner calls `unwrap` to unwrap underlying tokens. + */ + function test_state_unwrap_approvedCaller() public { + // ===== setup: wrap tokens ===== + uint256 expectedIdForWrappedToken = multiwrap.nextTokenIdToMint(); + address recipient = address(0x123); + + vm.prank(address(tokenOwner)); + multiwrap.wrap(wrappedContent, uriForWrappedToken, recipient); + + // ===== target test content ===== + + address approvedCaller = address(0x12); + + vm.prank(recipient); + multiwrap.setApprovalForAll(approvedCaller, true); + + vm.prank(approvedCaller); + multiwrap.unwrap(expectedIdForWrappedToken, recipient); + + vm.expectRevert("ERC721: invalid token ID"); + multiwrap.ownerOf(expectedIdForWrappedToken); + + assertEq(uriForWrappedToken, multiwrap.tokenURI(expectedIdForWrappedToken)); + assertEq(0, multiwrap.getTokenCountOfBundle(expectedIdForWrappedToken)); + + ITokenBundle.Token[] memory contentsOfWrappedToken = multiwrap.getWrappedContents(expectedIdForWrappedToken); + assertEq(contentsOfWrappedToken.length, 0); + } + + function test_event_unwrap_TokensUnwrapped() public { + // ===== setup: wrap tokens ===== + uint256 expectedIdForWrappedToken = multiwrap.nextTokenIdToMint(); + address recipient = address(0x123); + + vm.prank(address(tokenOwner)); + multiwrap.wrap(wrappedContent, uriForWrappedToken, recipient); + + // ===== target test content ===== + + vm.prank(recipient); + + vm.expectEmit(true, true, true, true); + emit TokensUnwrapped(recipient, recipient, expectedIdForWrappedToken); + + multiwrap.unwrap(expectedIdForWrappedToken, recipient); + } + + function test_balances_unwrap() public { + // ===== setup: wrap tokens ===== + uint256 expectedIdForWrappedToken = multiwrap.nextTokenIdToMint(); + address recipient = address(0x123); + + vm.prank(address(tokenOwner)); + multiwrap.wrap(wrappedContent, uriForWrappedToken, recipient); + + // ===== target test content ===== + + // ERC20 balance + assertEq(erc20.balanceOf(address(recipient)), 0); + assertEq(erc20.balanceOf(address(multiwrap)), 10 ether); + + // ERC721 balance + assertEq(erc721.ownerOf(0), address(multiwrap)); + + // ERC1155 balance + assertEq(erc1155.balanceOf(address(recipient), 0), 0); + assertEq(erc1155.balanceOf(address(multiwrap), 0), 100); + + vm.prank(recipient); + multiwrap.unwrap(expectedIdForWrappedToken, recipient); + + // ERC20 balance + assertEq(erc20.balanceOf(address(recipient)), 10 ether); + assertEq(erc20.balanceOf(address(multiwrap)), 0); + + // ERC721 balance + assertEq(erc721.ownerOf(0), address(recipient)); + + // ERC1155 balance + assertEq(erc1155.balanceOf(address(recipient), 0), 100); + assertEq(erc1155.balanceOf(address(multiwrap), 0), 0); + } + + function test_revert_unwrap_invalidTokenId() public { + // ===== setup: wrap tokens ===== + uint256 expectedIdForWrappedToken = multiwrap.nextTokenIdToMint(); + address recipient = address(0x123); + + vm.prank(address(tokenOwner)); + multiwrap.wrap(wrappedContent, uriForWrappedToken, recipient); + + // ===== target test content ===== + + vm.prank(recipient); + vm.expectRevert("wrapped NFT DNE."); + multiwrap.unwrap(expectedIdForWrappedToken + 1, recipient); + } + + function test_revert_unwrap_unapprovedCaller() public { + // ===== setup: wrap tokens ===== + uint256 expectedIdForWrappedToken = multiwrap.nextTokenIdToMint(); + address recipient = address(0x123); + + vm.prank(address(tokenOwner)); + multiwrap.wrap(wrappedContent, uriForWrappedToken, recipient); + + // ===== target test content ===== + + vm.prank(address(0x12)); + vm.expectRevert("caller not approved for unwrapping."); + multiwrap.unwrap(expectedIdForWrappedToken, recipient); + } + + function test_revert_unwrap_notOwner() public { + // ===== setup: wrap tokens ===== + uint256 expectedIdForWrappedToken = multiwrap.nextTokenIdToMint(); + address recipient = address(0x123); + + vm.prank(address(tokenOwner)); + multiwrap.wrap(wrappedContent, uriForWrappedToken, recipient); + + // ===== target test content ===== + + vm.prank(recipient); + multiwrap.transferFrom(recipient, address(0x12), 0); + + vm.prank(recipient); + vm.expectRevert("caller not approved for unwrapping."); + multiwrap.unwrap(expectedIdForWrappedToken, recipient); + } + + function test_revert_unwrap_access_UNWRAP_ROLE() public { + // ===== setup: wrap tokens ===== + uint256 expectedIdForWrappedToken = multiwrap.nextTokenIdToMint(); + address recipient = address(0x123); + + vm.prank(address(tokenOwner)); + multiwrap.wrap(wrappedContent, uriForWrappedToken, recipient); + + // ===== target test content ===== + + vm.prank(deployer); + multiwrap.revokeRole(keccak256("UNWRAP_ROLE"), address(0)); + + vm.prank(recipient); + vm.expectRevert( + abi.encodeWithSelector( + Permissions.PermissionsUnauthorizedAccount.selector, + recipient, + keccak256("UNWRAP_ROLE") + ) + ); + multiwrap.unwrap(expectedIdForWrappedToken, recipient); + } + + /** + * Fuzz testing: + * - Wrapping and unwrapping arbitrary kinds of tokens + */ + + uint256 internal constant MAX_TOKENS = 1000; + + function getTokensToWrap(uint256 x) internal returns (ITokenBundle.Token[] memory tokensToWrap) { + uint256 len = x % MAX_TOKENS; + tokensToWrap = new ITokenBundle.Token[](len); + + for (uint256 i = 0; i < len; i += 1) { + uint256 random = uint256(keccak256(abi.encodePacked(len + i))) % MAX_TOKENS; + uint256 selector = random % 3; + + if (selector == 0) { + tokensToWrap[i] = ITokenBundle.Token({ + assetContract: address(erc20), + tokenType: ITokenBundle.TokenType.ERC20, + tokenId: 0, + totalAmount: random + }); + + erc20.mint(address(tokenOwner), tokensToWrap[i].totalAmount); + } else if (selector == 1) { + uint256 tokenId = erc721.nextTokenIdToMint(); + + tokensToWrap[i] = ITokenBundle.Token({ + assetContract: address(erc721), + tokenType: ITokenBundle.TokenType.ERC721, + tokenId: tokenId, + totalAmount: 1 + }); + + erc721.mint(address(tokenOwner), 1); + } else if (selector == 2) { + tokensToWrap[i] = ITokenBundle.Token({ + assetContract: address(erc1155), + tokenType: ITokenBundle.TokenType.ERC1155, + tokenId: random, + totalAmount: random + }); + + erc1155.mint(address(tokenOwner), tokensToWrap[i].tokenId, tokensToWrap[i].totalAmount); + } + } + } + + function test_fuzz_state_wrap(uint256 x) public { + ITokenBundle.Token[] memory tokensToWrap = getTokensToWrap(x); + if (tokensToWrap.length == 0) { + return; + } + + uint256 expectedIdForWrappedToken = multiwrap.nextTokenIdToMint(); + address recipient = address(0x123); + + vm.prank(address(tokenOwner)); + multiwrap.wrap(tokensToWrap, uriForWrappedToken, recipient); + + assertEq(expectedIdForWrappedToken + 1, multiwrap.nextTokenIdToMint()); + + ITokenBundle.Token[] memory contentsOfWrappedToken = multiwrap.getWrappedContents(expectedIdForWrappedToken); + assertEq(contentsOfWrappedToken.length, tokensToWrap.length); + for (uint256 i = 0; i < contentsOfWrappedToken.length; i += 1) { + assertEq(contentsOfWrappedToken[i].assetContract, tokensToWrap[i].assetContract); + assertEq(uint256(contentsOfWrappedToken[i].tokenType), uint256(tokensToWrap[i].tokenType)); + assertEq(contentsOfWrappedToken[i].tokenId, tokensToWrap[i].tokenId); + assertEq(contentsOfWrappedToken[i].totalAmount, tokensToWrap[i].totalAmount); + } + + assertEq(uriForWrappedToken, multiwrap.tokenURI(expectedIdForWrappedToken)); + } + + function test_fuzz_state_unwrap(uint256 x) public { + // ===== setup: wrap tokens ===== + + ITokenBundle.Token[] memory tokensToWrap = getTokensToWrap(x); + if (tokensToWrap.length == 0) { + return; + } + + uint256 expectedIdForWrappedToken = multiwrap.nextTokenIdToMint(); + address recipient = address(0x123); + + vm.prank(address(tokenOwner)); + multiwrap.wrap(tokensToWrap, uriForWrappedToken, recipient); + + // ===== target test content ===== + + vm.prank(recipient); + multiwrap.unwrap(expectedIdForWrappedToken, recipient); + + vm.expectRevert("ERC721: invalid token ID"); + multiwrap.ownerOf(expectedIdForWrappedToken); + + assertEq(uriForWrappedToken, multiwrap.tokenURI(expectedIdForWrappedToken)); + assertEq(0, multiwrap.getTokenCountOfBundle(expectedIdForWrappedToken)); + + ITokenBundle.Token[] memory contentsOfWrappedToken = multiwrap.getWrappedContents(expectedIdForWrappedToken); + assertEq(contentsOfWrappedToken.length, 0); + } +} diff --git a/src/test/OpenEditionERC721.t.sol b/src/test/OpenEditionERC721.t.sol new file mode 100644 index 000000000..2b9a63db7 --- /dev/null +++ b/src/test/OpenEditionERC721.t.sol @@ -0,0 +1,790 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { ERC721AUpgradeable, OpenEditionERC721, ISharedMetadata } from "contracts/prebuilts/open-edition/OpenEditionERC721.sol"; +import { Drop } from "contracts/extension/Drop.sol"; +import { LazyMint } from "contracts/extension/LazyMint.sol"; +import { OpenEditionERC721 } from "contracts/prebuilts/open-edition/OpenEditionERC721.sol"; +import { NFTMetadataRenderer } from "contracts/lib/NFTMetadataRenderer.sol"; +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +// Test imports +import "erc721a-upgradeable/contracts/IERC721AUpgradeable.sol"; +import "./utils/BaseTest.sol"; +import "@openzeppelin/contracts-upgradeable/utils/StringsUpgradeable.sol"; + +contract OpenEditionERC721Test is BaseTest { + using Strings for uint256; + using Strings for address; + + event SharedMetadataUpdated(string name, string description, string imageURI, string animationURI); + + OpenEditionERC721 public openEdition; + ISharedMetadata.SharedMetadataInfo public sharedMetadata; + + bytes private emptyEncodedBytes = abi.encode("", ""); + + using stdStorage for StdStorage; + + function setUp() public override { + super.setUp(); + address openEditionImpl = address(new OpenEditionERC721()); + + vm.prank(deployer); + openEdition = OpenEditionERC721( + address( + new TWProxy( + openEditionImpl, + abi.encodeCall( + OpenEditionERC721.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps + ) + ) + ) + ) + ); + + sharedMetadata = ISharedMetadata.SharedMetadataInfo({ + name: "Test", + description: "Test", + imageURI: "https://test.com", + animationURI: "https://test.com" + }); + + erc20.mint(deployer, 1_000 ether); + vm.deal(deployer, 1_000 ether); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: misc. + //////////////////////////////////////////////////////////////*/ + + /** + * note: Tests whether contract reverts when a non-holder renounces a role. + */ + function test_revert_nonHolder_renounceRole() public { + address caller = address(0x123); + bytes32 role = keccak256("MINTER_ROLE"); + + vm.prank(caller); + vm.expectRevert(abi.encodeWithSelector(Permissions.PermissionsUnauthorizedAccount.selector, caller, role)); + + openEdition.renounceRole(role, caller); + } + + /** + * note: Tests whether contract reverts when a role admin revokes a role for a non-holder. + */ + function test_revert_revokeRoleForNonHolder() public { + address target = address(0x123); + bytes32 role = keccak256("MINTER_ROLE"); + + vm.prank(deployer); + vm.expectRevert(abi.encodeWithSelector(Permissions.PermissionsUnauthorizedAccount.selector, target, role)); + + openEdition.revokeRole(role, target); + } + + /** + * @dev Tests whether contract reverts when a role is granted to an existent role holder. + */ + function test_revert_grant_role_to_account_with_role() public { + bytes32 role = keccak256("ABC_ROLE"); + address receiver = getActor(0); + + vm.startPrank(deployer); + + openEdition.grantRole(role, receiver); + + vm.expectRevert(abi.encodeWithSelector(Permissions.PermissionsAlreadyGranted.selector, receiver, role)); + openEdition.grantRole(role, receiver); + + vm.stopPrank(); + } + + /** + * @dev Tests contract state for Transfer role. + */ + function test_state_grant_transferRole() public { + bytes32 role = keccak256("TRANSFER_ROLE"); + + // check if admin and address(0) have transfer role in the beginning + bool checkAddressZero = openEdition.hasRole(role, address(0)); + bool checkAdmin = openEdition.hasRole(role, deployer); + assertTrue(checkAddressZero); + assertTrue(checkAdmin); + + // check if transfer role can be granted to a non-holder + address receiver = getActor(0); + vm.startPrank(deployer); + openEdition.grantRole(role, receiver); + + // expect revert when granting to a holder + vm.expectRevert(abi.encodeWithSelector(Permissions.PermissionsAlreadyGranted.selector, receiver, role)); + openEdition.grantRole(role, receiver); + + // check if receiver has transfer role + bool checkReceiver = openEdition.hasRole(role, receiver); + assertTrue(checkReceiver); + + // check if role is correctly revoked + openEdition.revokeRole(role, receiver); + checkReceiver = openEdition.hasRole(role, receiver); + assertFalse(checkReceiver); + openEdition.revokeRole(role, address(0)); + checkAddressZero = openEdition.hasRole(role, address(0)); + assertFalse(checkAddressZero); + + vm.stopPrank(); + } + + /** + * note: Testing transfer of tokens when transfer-role is restricted + */ + function test_claim_transferRole() public { + vm.warp(1); + + address receiver = getActor(0); + bytes32[] memory proofs = new bytes32[](0); + + OpenEditionERC721.AllowlistProof memory alp; + alp.proof = proofs; + + OpenEditionERC721.ClaimCondition[] memory conditions = new OpenEditionERC721.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 100; + conditions[0].quantityLimitPerWallet = 100; + + vm.prank(deployer); + openEdition.setSharedMetadata(sharedMetadata); + vm.prank(deployer); + openEdition.setClaimConditions(conditions, false); + + vm.prank(getActor(5), getActor(5)); + openEdition.claim(receiver, 1, address(0), 0, alp, ""); + + // revoke transfer role from address(0) + vm.prank(deployer); + openEdition.revokeRole(keccak256("TRANSFER_ROLE"), address(0)); + vm.startPrank(receiver); + vm.expectRevert(bytes("!T")); + openEdition.transferFrom(receiver, address(123), 1); + } + + function test_claimCondition_with_startTimestamp() public { + vm.warp(1); + + address receiver = getActor(0); + bytes32[] memory proofs = new bytes32[](0); + + OpenEditionERC721.AllowlistProof memory alp; + alp.proof = proofs; + + OpenEditionERC721.ClaimCondition[] memory conditions = new OpenEditionERC721.ClaimCondition[](1); + conditions[0].startTimestamp = 100; + conditions[0].maxClaimableSupply = 100; + conditions[0].quantityLimitPerWallet = 100; + + vm.prank(deployer); + openEdition.setSharedMetadata(sharedMetadata); + + vm.prank(deployer); + openEdition.setClaimConditions(conditions, false); + + vm.warp(99); + vm.prank(getActor(5), getActor(5)); + vm.expectRevert(abi.encodeWithSelector(Drop.DropNoActiveCondition.selector)); + openEdition.claim(receiver, 1, address(0), 0, alp, ""); + + vm.warp(100); + vm.prank(getActor(4), getActor(4)); + openEdition.claim(receiver, 1, address(0), 0, alp, ""); + } + + /*/////////////////////////////////////////////////////////////// + Set Shared Metadata Tests + //////////////////////////////////////////////////////////////*/ + + /* + * note: Testing state changes; set shared metadata for tokens. + */ + function test_state_sharedMetadata() public { + // SET METADATA + vm.prank(deployer); + openEdition.setSharedMetadata(sharedMetadata); + + // CLAIM 1 TOKEN + string[] memory inputs = new string[](5); + + inputs[0] = "node"; + inputs[1] = "src/test/scripts/generateRoot.ts"; + inputs[2] = "300"; + inputs[3] = "0"; + inputs[4] = Strings.toHexString(uint160(address(erc20))); // address of erc20 + + bytes memory result = vm.ffi(inputs); + // revert(); + bytes32 root = abi.decode(result, (bytes32)); + + inputs[1] = "src/test/scripts/getProof.ts"; + result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + OpenEditionERC721.AllowlistProof memory alp; + alp.proof = proofs; + alp.quantityLimitPerWallet = 300; + alp.pricePerToken = 0; + alp.currency = address(erc20); + + vm.warp(1); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); // in allowlist + + OpenEditionERC721.ClaimCondition[] memory conditions = new OpenEditionERC721.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 500; + conditions[0].quantityLimitPerWallet = 10; + conditions[0].merkleRoot = root; + conditions[0].pricePerToken = 10; + conditions[0].currency = address(erc20); + + vm.prank(deployer); + openEdition.setSharedMetadata(sharedMetadata); + vm.prank(deployer); + openEdition.setClaimConditions(conditions, false); + + // vm.prank(getActor(5), getActor(5)); + vm.prank(receiver, receiver); + openEdition.claim(receiver, 100, address(erc20), 0, alp, ""); + + string memory uri = openEdition.tokenURI(1); + assertEq( + uri, + NFTMetadataRenderer.createMetadataEdition({ + name: sharedMetadata.name, + description: sharedMetadata.description, + imageURI: sharedMetadata.imageURI, + animationURI: sharedMetadata.animationURI, + tokenOfEdition: 1 + }) + ); + } + + /** + * note: Testing revert condition; an address without MINTER_ROLE calls setSharedMetadata function. + */ + function test_revert_setSharedMetadata_MINTER_ROLE() public { + vm.expectRevert(); + openEdition.setSharedMetadata(sharedMetadata); + } + + /** + * note: Testing event emission; shared metadata set. + */ + function test_event_setSharedMetadata_SharedMetadataUpdated() public { + vm.startPrank(deployer); + + vm.expectEmit(true, false, false, true); + emit SharedMetadataUpdated( + sharedMetadata.name, + sharedMetadata.description, + sharedMetadata.imageURI, + sharedMetadata.animationURI + ); + openEdition.setSharedMetadata(sharedMetadata); + + vm.stopPrank(); + } + + /*/////////////////////////////////////////////////////////////// + Claim Tests + //////////////////////////////////////////////////////////////*/ + + /** + * note: Testing revert condition; exceed max claimable supply. + */ + function test_revert_claimCondition_exceedMaxClaimableSupply() public { + vm.warp(1); + + address receiver = getActor(0); + bytes32[] memory proofs = new bytes32[](0); + + OpenEditionERC721.AllowlistProof memory alp; + alp.proof = proofs; + + OpenEditionERC721.ClaimCondition[] memory conditions = new OpenEditionERC721.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 100; + conditions[0].quantityLimitPerWallet = 200; + + vm.prank(deployer); + openEdition.setSharedMetadata(sharedMetadata); + vm.prank(deployer); + openEdition.setClaimConditions(conditions, false); + + vm.prank(getActor(5), getActor(5)); + openEdition.claim(receiver, 100, address(0), 0, alp, ""); + + vm.expectRevert( + abi.encodeWithSelector(Drop.DropClaimExceedMaxSupply.selector, conditions[0].maxClaimableSupply, 101) + ); + vm.prank(getActor(6), getActor(6)); + openEdition.claim(receiver, 1, address(0), 0, alp, ""); + } + + /** + * note: Testing quantity limit restriction when no allowlist present. + */ + function test_fuzz_claim_noAllowlist(uint256 x) public { + vm.assume(x != 0); + vm.warp(1); + + address receiver = getActor(0); + bytes32[] memory proofs = new bytes32[](0); + + OpenEditionERC721.AllowlistProof memory alp; + alp.proof = proofs; + alp.quantityLimitPerWallet = x; + + OpenEditionERC721.ClaimCondition[] memory conditions = new OpenEditionERC721.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 500; + conditions[0].quantityLimitPerWallet = 100; + + vm.prank(deployer); + openEdition.setSharedMetadata(sharedMetadata); + + vm.prank(deployer); + openEdition.setClaimConditions(conditions, false); + + vm.prank(getActor(5), getActor(5)); + vm.expectRevert( + abi.encodeWithSelector(Drop.DropClaimExceedLimit.selector, conditions[0].quantityLimitPerWallet, 0) + ); + openEdition.claim(receiver, 0, address(0), 0, alp, ""); + + vm.prank(getActor(5), getActor(5)); + vm.expectRevert( + abi.encodeWithSelector(Drop.DropClaimExceedLimit.selector, conditions[0].quantityLimitPerWallet, 101) + ); + openEdition.claim(receiver, 101, address(0), 0, alp, ""); + + vm.prank(deployer); + openEdition.setClaimConditions(conditions, true); + + vm.prank(getActor(5), getActor(5)); + vm.expectRevert( + abi.encodeWithSelector(Drop.DropClaimExceedLimit.selector, conditions[0].quantityLimitPerWallet, 101) + ); + openEdition.claim(receiver, 101, address(0), 0, alp, ""); + } + + /** + * note: Testing quantity limit restriction + * - allowlist quantity set to some value different than general limit + * - allowlist price set to 0 + */ + function test_state_claim_allowlisted_SetQuantityZeroPrice() public { + string[] memory inputs = new string[](5); + + inputs[0] = "node"; + inputs[1] = "src/test/scripts/generateRoot.ts"; + inputs[2] = "300"; + inputs[3] = "0"; + inputs[4] = Strings.toHexString(uint160(address(erc20))); // address of erc20 + + bytes memory result = vm.ffi(inputs); + // revert(); + bytes32 root = abi.decode(result, (bytes32)); + + inputs[1] = "src/test/scripts/getProof.ts"; + result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + OpenEditionERC721.AllowlistProof memory alp; + alp.proof = proofs; + alp.quantityLimitPerWallet = 300; + alp.pricePerToken = 0; + alp.currency = address(erc20); + + vm.warp(1); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); // in allowlist + + OpenEditionERC721.ClaimCondition[] memory conditions = new OpenEditionERC721.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 500; + conditions[0].quantityLimitPerWallet = 10; + conditions[0].merkleRoot = root; + conditions[0].pricePerToken = 10; + conditions[0].currency = address(erc20); + + vm.prank(deployer); + openEdition.setSharedMetadata(sharedMetadata); + vm.prank(deployer); + openEdition.setClaimConditions(conditions, false); + + // vm.prank(getActor(5), getActor(5)); + vm.prank(receiver, receiver); + openEdition.claim(receiver, 100, address(erc20), 0, alp, ""); // claims for free, because allowlist price is 0 + assertEq(openEdition.getSupplyClaimedByWallet(openEdition.getActiveClaimConditionId(), receiver), 100); + } + + /** + * note: Testing quantity limit restriction + * - allowlist quantity set to some value different than general limit + * - allowlist price set to non-zero value + */ + function test_state_claim_allowlisted_SetQuantityPrice() public { + string[] memory inputs = new string[](5); + + inputs[0] = "node"; + inputs[1] = "src/test/scripts/generateRoot.ts"; + inputs[2] = "300"; + inputs[3] = "5"; + inputs[4] = Strings.toHexString(uint160(address(erc20))); // address of erc20 + + bytes memory result = vm.ffi(inputs); + // revert(); + bytes32 root = abi.decode(result, (bytes32)); + + inputs[1] = "src/test/scripts/getProof.ts"; + result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + OpenEditionERC721.AllowlistProof memory alp; + alp.proof = proofs; + alp.quantityLimitPerWallet = 300; + alp.pricePerToken = 5; + alp.currency = address(erc20); + + vm.warp(1); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); // in allowlist + + OpenEditionERC721.ClaimCondition[] memory conditions = new OpenEditionERC721.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 500; + conditions[0].quantityLimitPerWallet = 10; + conditions[0].merkleRoot = root; + conditions[0].pricePerToken = 10; + conditions[0].currency = address(erc20); + + vm.prank(deployer); + openEdition.setSharedMetadata(sharedMetadata); + vm.prank(deployer); + openEdition.setClaimConditions(conditions, false); + + vm.prank(receiver, receiver); + vm.expectRevert( + abi.encodeWithSelector(Drop.DropClaimInvalidTokenPrice.selector, address(erc20), 0, address(erc20), 5) + ); + openEdition.claim(receiver, 100, address(erc20), 0, alp, ""); + + erc20.mint(receiver, 10000); + vm.prank(receiver); + erc20.approve(address(openEdition), 10000); + + // vm.prank(getActor(5), getActor(5)); + vm.prank(receiver, receiver); + openEdition.claim(receiver, 100, address(erc20), 5, alp, ""); + assertEq(openEdition.getSupplyClaimedByWallet(openEdition.getActiveClaimConditionId(), receiver), 100); + assertEq(erc20.balanceOf(receiver), 10000 - 500); + } + + /** + * note: Testing quantity limit restriction + * - allowlist quantity set to some value different than general limit + * - allowlist price not set; should default to general price and currency + */ + function test_state_claim_allowlisted_SetQuantityDefaultPrice() public { + string[] memory inputs = new string[](5); + + inputs[0] = "node"; + inputs[1] = "src/test/scripts/generateRoot.ts"; + inputs[2] = "300"; + inputs[3] = Strings.toString(type(uint256).max); // this implies that general price is applicable + inputs[4] = "0x0000000000000000000000000000000000000000"; + + bytes memory result = vm.ffi(inputs); + // revert(); + bytes32 root = abi.decode(result, (bytes32)); + + inputs[1] = "src/test/scripts/getProof.ts"; + result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + OpenEditionERC721.AllowlistProof memory alp; + alp.proof = proofs; + alp.quantityLimitPerWallet = 300; + alp.pricePerToken = type(uint256).max; + alp.currency = address(0); + + vm.warp(1); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); // in allowlist + + OpenEditionERC721.ClaimCondition[] memory conditions = new OpenEditionERC721.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 500; + conditions[0].quantityLimitPerWallet = 10; + conditions[0].merkleRoot = root; + conditions[0].pricePerToken = 10; + conditions[0].currency = address(erc20); + + vm.prank(deployer); + openEdition.setSharedMetadata(sharedMetadata); + vm.prank(deployer); + openEdition.setClaimConditions(conditions, false); + + erc20.mint(receiver, 10000); + vm.prank(receiver); + erc20.approve(address(openEdition), 10000); + + // vm.prank(getActor(5), getActor(5)); + vm.prank(receiver, receiver); + openEdition.claim(receiver, 100, address(erc20), 10, alp, ""); + assertEq(openEdition.getSupplyClaimedByWallet(openEdition.getActiveClaimConditionId(), receiver), 100); + assertEq(erc20.balanceOf(receiver), 10000 - 1000); + } + + /** + * note: Testing quantity limit restriction + * - allowlist quantity set to 0 => should default to general limit + * - allowlist price set to some value different than general price + */ + function test_state_claim_allowlisted_DefaultQuantitySomePrice() public { + string[] memory inputs = new string[](5); + + inputs[0] = "node"; + inputs[1] = "src/test/scripts/generateRoot.ts"; + inputs[2] = "0"; // this implies that general limit is applicable + inputs[3] = "5"; + inputs[4] = "0x0000000000000000000000000000000000000000"; // general currency will be applicable + + bytes memory result = vm.ffi(inputs); + // revert(); + bytes32 root = abi.decode(result, (bytes32)); + + inputs[1] = "src/test/scripts/getProof.ts"; + result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + OpenEditionERC721.AllowlistProof memory alp; + alp.proof = proofs; + alp.quantityLimitPerWallet = 0; + alp.pricePerToken = 5; + alp.currency = address(0); + + vm.warp(1); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); // in allowlist + + OpenEditionERC721.ClaimCondition[] memory conditions = new OpenEditionERC721.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 500; + conditions[0].quantityLimitPerWallet = 10; + conditions[0].merkleRoot = root; + conditions[0].pricePerToken = 10; + conditions[0].currency = address(erc20); + + vm.prank(deployer); + openEdition.setSharedMetadata(sharedMetadata); + vm.prank(deployer); + openEdition.setClaimConditions(conditions, false); + + erc20.mint(receiver, 10000); + vm.prank(receiver); + erc20.approve(address(openEdition), 10000); + + vm.prank(receiver, receiver); + vm.expectRevert( + abi.encodeWithSelector(Drop.DropClaimExceedLimit.selector, conditions[0].quantityLimitPerWallet, 100) + ); + openEdition.claim(receiver, 100, address(erc20), 5, alp, ""); // trying to claim more than general limit + + // vm.prank(getActor(5), getActor(5)); + vm.prank(receiver, receiver); + openEdition.claim(receiver, 10, address(erc20), 5, alp, ""); + assertEq(openEdition.getSupplyClaimedByWallet(openEdition.getActiveClaimConditionId(), receiver), 10); + assertEq(erc20.balanceOf(receiver), 10000 - 50); + } + + function test_fuzz_claim_merkleProof(uint256 x) public { + vm.assume(x > 10 && x < 500); + string[] memory inputs = new string[](5); + + inputs[0] = "node"; + inputs[1] = "src/test/scripts/generateRoot.ts"; + inputs[2] = Strings.toString(x); + inputs[3] = "0"; + inputs[4] = "0x0000000000000000000000000000000000000000"; + + bytes memory result = vm.ffi(inputs); + // revert(); + bytes32 root = abi.decode(result, (bytes32)); + + inputs[1] = "src/test/scripts/getProof.ts"; + result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + OpenEditionERC721.AllowlistProof memory alp; + alp.proof = proofs; + alp.quantityLimitPerWallet = x; + alp.pricePerToken = 0; + alp.currency = address(0); + + vm.warp(1); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); + + // bytes32[] memory proofs = new bytes32[](0); + + OpenEditionERC721.ClaimCondition[] memory conditions = new OpenEditionERC721.ClaimCondition[](1); + conditions[0].maxClaimableSupply = x; + conditions[0].quantityLimitPerWallet = 1; + conditions[0].merkleRoot = root; + + vm.prank(deployer); + openEdition.setSharedMetadata(sharedMetadata); + vm.prank(deployer); + openEdition.setClaimConditions(conditions, false); + + // vm.prank(getActor(5), getActor(5)); + vm.prank(receiver, receiver); + openEdition.claim(receiver, x - 5, address(0), 0, alp, ""); + assertEq(openEdition.getSupplyClaimedByWallet(openEdition.getActiveClaimConditionId(), receiver), x - 5); + + vm.prank(receiver, receiver); + vm.expectRevert(abi.encodeWithSelector(Drop.DropClaimExceedLimit.selector, x, x + 1)); + openEdition.claim(receiver, 6, address(0), 0, alp, ""); + + vm.prank(receiver, receiver); + openEdition.claim(receiver, 5, address(0), 0, alp, ""); + assertEq(openEdition.getSupplyClaimedByWallet(openEdition.getActiveClaimConditionId(), receiver), x); + + vm.prank(receiver, receiver); + vm.expectRevert(abi.encodeWithSelector(Drop.DropClaimExceedLimit.selector, x, x + 5)); + openEdition.claim(receiver, 5, address(0), 0, alp, ""); // quantity limit already claimed + } + + /** + * note: Testing state changes; reset eligibility of claim conditions and claiming again for same condition id. + */ + function test_state_claimCondition_resetEligibility() public { + vm.warp(1); + + address receiver = getActor(0); + bytes32[] memory proofs = new bytes32[](0); + + OpenEditionERC721.AllowlistProof memory alp; + alp.proof = proofs; + + OpenEditionERC721.ClaimCondition[] memory conditions = new OpenEditionERC721.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 500; + conditions[0].quantityLimitPerWallet = 100; + + vm.prank(deployer); + openEdition.setSharedMetadata(sharedMetadata); + + vm.prank(deployer); + openEdition.setClaimConditions(conditions, false); + + vm.prank(getActor(5), getActor(5)); + openEdition.claim(receiver, 100, address(0), 0, alp, ""); + + vm.prank(getActor(5), getActor(5)); + vm.expectRevert( + abi.encodeWithSelector(Drop.DropClaimExceedLimit.selector, conditions[0].quantityLimitPerWallet, 200) + ); + openEdition.claim(receiver, 100, address(0), 0, alp, ""); + + vm.prank(deployer); + openEdition.setClaimConditions(conditions, true); + + vm.prank(getActor(5), getActor(5)); + openEdition.claim(receiver, 100, address(0), 0, alp, ""); + } + + /*/////////////////////////////////////////////////////////////// + setClaimConditions + //////////////////////////////////////////////////////////////*/ + + function test_claimCondition_startIdAndCount() public { + vm.startPrank(deployer); + + uint256 currentStartId = 0; + uint256 count = 0; + + OpenEditionERC721.ClaimCondition[] memory conditions = new OpenEditionERC721.ClaimCondition[](2); + conditions[0].startTimestamp = 0; + conditions[0].maxClaimableSupply = 10; + conditions[1].startTimestamp = 1; + conditions[1].maxClaimableSupply = 10; + + openEdition.setClaimConditions(conditions, false); + (currentStartId, count) = openEdition.claimCondition(); + assertEq(currentStartId, 0); + assertEq(count, 2); + + openEdition.setClaimConditions(conditions, false); + (currentStartId, count) = openEdition.claimCondition(); + assertEq(currentStartId, 0); + assertEq(count, 2); + + openEdition.setClaimConditions(conditions, true); + (currentStartId, count) = openEdition.claimCondition(); + assertEq(currentStartId, 2); + assertEq(count, 2); + + openEdition.setClaimConditions(conditions, true); + (currentStartId, count) = openEdition.claimCondition(); + assertEq(currentStartId, 4); + assertEq(count, 2); + } + + function test_claimCondition_startPhase() public { + vm.startPrank(deployer); + + uint256 activeConditionId = 0; + + OpenEditionERC721.ClaimCondition[] memory conditions = new OpenEditionERC721.ClaimCondition[](3); + conditions[0].startTimestamp = 10; + conditions[0].maxClaimableSupply = 11; + conditions[0].quantityLimitPerWallet = 12; + conditions[1].startTimestamp = 20; + conditions[1].maxClaimableSupply = 21; + conditions[1].quantityLimitPerWallet = 22; + conditions[2].startTimestamp = 30; + conditions[2].maxClaimableSupply = 31; + conditions[2].quantityLimitPerWallet = 32; + openEdition.setClaimConditions(conditions, false); + + vm.expectRevert(abi.encodeWithSelector(Drop.DropNoActiveCondition.selector)); + openEdition.getActiveClaimConditionId(); + + vm.warp(10); + activeConditionId = openEdition.getActiveClaimConditionId(); + assertEq(activeConditionId, 0); + assertEq(openEdition.getClaimConditionById(activeConditionId).startTimestamp, 10); + assertEq(openEdition.getClaimConditionById(activeConditionId).maxClaimableSupply, 11); + assertEq(openEdition.getClaimConditionById(activeConditionId).quantityLimitPerWallet, 12); + + vm.warp(20); + activeConditionId = openEdition.getActiveClaimConditionId(); + assertEq(activeConditionId, 1); + assertEq(openEdition.getClaimConditionById(activeConditionId).startTimestamp, 20); + assertEq(openEdition.getClaimConditionById(activeConditionId).maxClaimableSupply, 21); + assertEq(openEdition.getClaimConditionById(activeConditionId).quantityLimitPerWallet, 22); + + vm.warp(30); + activeConditionId = openEdition.getActiveClaimConditionId(); + assertEq(activeConditionId, 2); + assertEq(openEdition.getClaimConditionById(activeConditionId).startTimestamp, 30); + assertEq(openEdition.getClaimConditionById(activeConditionId).maxClaimableSupply, 31); + assertEq(openEdition.getClaimConditionById(activeConditionId).quantityLimitPerWallet, 32); + + vm.warp(40); + assertEq(openEdition.getActiveClaimConditionId(), 2); + } +} diff --git a/src/test/OpenEditionERC721FlatFee.t.sol b/src/test/OpenEditionERC721FlatFee.t.sol new file mode 100644 index 000000000..38de7326c --- /dev/null +++ b/src/test/OpenEditionERC721FlatFee.t.sol @@ -0,0 +1,792 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { ERC721AUpgradeable, OpenEditionERC721FlatFee, ISharedMetadata } from "contracts/prebuilts/open-edition/OpenEditionERC721FlatFee.sol"; +import { Drop } from "contracts/extension/Drop.sol"; +import { LazyMint } from "contracts/extension/LazyMint.sol"; +import { OpenEditionERC721FlatFee } from "contracts/prebuilts/open-edition/OpenEditionERC721FlatFee.sol"; +import { NFTMetadataRenderer } from "contracts/lib/NFTMetadataRenderer.sol"; +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +// Test imports +import "erc721a-upgradeable/contracts/IERC721AUpgradeable.sol"; +import "./utils/BaseTest.sol"; +import "@openzeppelin/contracts-upgradeable/utils/StringsUpgradeable.sol"; + +contract OpenEditionERC721FlatFeeTest is BaseTest { + using Strings for uint256; + using Strings for address; + + event SharedMetadataUpdated(string name, string description, string imageURI, string animationURI); + + OpenEditionERC721FlatFee public openEdition; + ISharedMetadata.SharedMetadataInfo public sharedMetadata; + + bytes private emptyEncodedBytes = abi.encode("", ""); + + using stdStorage for StdStorage; + + function setUp() public override { + super.setUp(); + address openEditionImpl = address(new OpenEditionERC721FlatFee()); + + vm.prank(deployer); + openEdition = OpenEditionERC721FlatFee( + address( + new TWProxy( + openEditionImpl, + abi.encodeCall( + OpenEditionERC721FlatFee.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ) + ) + ); + + sharedMetadata = ISharedMetadata.SharedMetadataInfo({ + name: "Test", + description: "Test", + imageURI: "https://test.com", + animationURI: "https://test.com" + }); + + erc20.mint(deployer, 1_000 ether); + vm.deal(deployer, 1_000 ether); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: misc. + //////////////////////////////////////////////////////////////*/ + + /** + * note: Tests whether contract reverts when a non-holder renounces a role. + */ + function test_revert_nonHolder_renounceRole() public { + address caller = address(0x123); + bytes32 role = keccak256("MINTER_ROLE"); + + vm.prank(caller); + vm.expectRevert(abi.encodeWithSelector(Permissions.PermissionsUnauthorizedAccount.selector, caller, role)); + + openEdition.renounceRole(role, caller); + } + + /** + * note: Tests whether contract reverts when a role admin revokes a role for a non-holder. + */ + function test_revert_revokeRoleForNonHolder() public { + address target = address(0x123); + bytes32 role = keccak256("MINTER_ROLE"); + + vm.prank(deployer); + vm.expectRevert(abi.encodeWithSelector(Permissions.PermissionsUnauthorizedAccount.selector, target, role)); + + openEdition.revokeRole(role, target); + } + + /** + * @dev Tests whether contract reverts when a role is granted to an existent role holder. + */ + function test_revert_grant_role_to_account_with_role() public { + bytes32 role = keccak256("ABC_ROLE"); + address receiver = getActor(0); + + vm.startPrank(deployer); + + openEdition.grantRole(role, receiver); + + vm.expectRevert(abi.encodeWithSelector(Permissions.PermissionsAlreadyGranted.selector, receiver, role)); + openEdition.grantRole(role, receiver); + + vm.stopPrank(); + } + + /** + * @dev Tests contract state for Transfer role. + */ + function test_state_grant_transferRole() public { + bytes32 role = keccak256("TRANSFER_ROLE"); + + // check if admin and address(0) have transfer role in the beginning + bool checkAddressZero = openEdition.hasRole(role, address(0)); + bool checkAdmin = openEdition.hasRole(role, deployer); + assertTrue(checkAddressZero); + assertTrue(checkAdmin); + + // check if transfer role can be granted to a non-holder + address receiver = getActor(0); + vm.startPrank(deployer); + openEdition.grantRole(role, receiver); + + // expect revert when granting to a holder + vm.expectRevert(abi.encodeWithSelector(Permissions.PermissionsAlreadyGranted.selector, receiver, role)); + openEdition.grantRole(role, receiver); + + // check if receiver has transfer role + bool checkReceiver = openEdition.hasRole(role, receiver); + assertTrue(checkReceiver); + + // check if role is correctly revoked + openEdition.revokeRole(role, receiver); + checkReceiver = openEdition.hasRole(role, receiver); + assertFalse(checkReceiver); + openEdition.revokeRole(role, address(0)); + checkAddressZero = openEdition.hasRole(role, address(0)); + assertFalse(checkAddressZero); + + vm.stopPrank(); + } + + /** + * note: Testing transfer of tokens when transfer-role is restricted + */ + function test_claim_transferRole() public { + vm.warp(1); + + address receiver = getActor(0); + bytes32[] memory proofs = new bytes32[](0); + + OpenEditionERC721FlatFee.AllowlistProof memory alp; + alp.proof = proofs; + + OpenEditionERC721FlatFee.ClaimCondition[] memory conditions = new OpenEditionERC721FlatFee.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 100; + conditions[0].quantityLimitPerWallet = 100; + + vm.prank(deployer); + openEdition.setSharedMetadata(sharedMetadata); + vm.prank(deployer); + openEdition.setClaimConditions(conditions, false); + + vm.prank(getActor(5), getActor(5)); + openEdition.claim(receiver, 1, address(0), 0, alp, ""); + + // revoke transfer role from address(0) + vm.prank(deployer); + openEdition.revokeRole(keccak256("TRANSFER_ROLE"), address(0)); + vm.startPrank(receiver); + vm.expectRevert(bytes("!T")); + openEdition.transferFrom(receiver, address(123), 1); + } + + function test_claimCondition_with_startTimestamp() public { + vm.warp(1); + + address receiver = getActor(0); + bytes32[] memory proofs = new bytes32[](0); + + OpenEditionERC721FlatFee.AllowlistProof memory alp; + alp.proof = proofs; + + OpenEditionERC721FlatFee.ClaimCondition[] memory conditions = new OpenEditionERC721FlatFee.ClaimCondition[](1); + conditions[0].startTimestamp = 100; + conditions[0].maxClaimableSupply = 100; + conditions[0].quantityLimitPerWallet = 100; + + vm.prank(deployer); + openEdition.setSharedMetadata(sharedMetadata); + + vm.prank(deployer); + openEdition.setClaimConditions(conditions, false); + + vm.warp(99); + vm.prank(getActor(5), getActor(5)); + vm.expectRevert(abi.encodeWithSelector(Drop.DropNoActiveCondition.selector)); + openEdition.claim(receiver, 1, address(0), 0, alp, ""); + + vm.warp(100); + vm.prank(getActor(4), getActor(4)); + openEdition.claim(receiver, 1, address(0), 0, alp, ""); + } + + /*/////////////////////////////////////////////////////////////// + Set Shared Metadata Tests + //////////////////////////////////////////////////////////////*/ + + /* + * note: Testing state changes; set shared metadata for tokens. + */ + function test_state_sharedMetadata() public { + // SET METADATA + vm.prank(deployer); + openEdition.setSharedMetadata(sharedMetadata); + + // CLAIM 1 TOKEN + string[] memory inputs = new string[](5); + + inputs[0] = "node"; + inputs[1] = "src/test/scripts/generateRoot.ts"; + inputs[2] = "300"; + inputs[3] = "0"; + inputs[4] = Strings.toHexString(uint160(address(erc20))); // address of erc20 + + bytes memory result = vm.ffi(inputs); + // revert(); + bytes32 root = abi.decode(result, (bytes32)); + + inputs[1] = "src/test/scripts/getProof.ts"; + result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + OpenEditionERC721FlatFee.AllowlistProof memory alp; + alp.proof = proofs; + alp.quantityLimitPerWallet = 300; + alp.pricePerToken = 0; + alp.currency = address(erc20); + + vm.warp(1); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); // in allowlist + + OpenEditionERC721FlatFee.ClaimCondition[] memory conditions = new OpenEditionERC721FlatFee.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 500; + conditions[0].quantityLimitPerWallet = 10; + conditions[0].merkleRoot = root; + conditions[0].pricePerToken = 10; + conditions[0].currency = address(erc20); + + vm.prank(deployer); + openEdition.setSharedMetadata(sharedMetadata); + vm.prank(deployer); + openEdition.setClaimConditions(conditions, false); + + // vm.prank(getActor(5), getActor(5)); + vm.prank(receiver, receiver); + openEdition.claim(receiver, 100, address(erc20), 0, alp, ""); + + string memory uri = openEdition.tokenURI(1); + assertEq( + uri, + NFTMetadataRenderer.createMetadataEdition({ + name: sharedMetadata.name, + description: sharedMetadata.description, + imageURI: sharedMetadata.imageURI, + animationURI: sharedMetadata.animationURI, + tokenOfEdition: 1 + }) + ); + } + + /** + * note: Testing revert condition; an address without MINTER_ROLE calls setSharedMetadata function. + */ + function test_revert_setSharedMetadata_MINTER_ROLE() public { + vm.expectRevert(); + openEdition.setSharedMetadata(sharedMetadata); + } + + /** + * note: Testing event emission; shared metadata set. + */ + function test_event_setSharedMetadata_SharedMetadataUpdated() public { + vm.startPrank(deployer); + + vm.expectEmit(true, false, false, true); + emit SharedMetadataUpdated( + sharedMetadata.name, + sharedMetadata.description, + sharedMetadata.imageURI, + sharedMetadata.animationURI + ); + openEdition.setSharedMetadata(sharedMetadata); + + vm.stopPrank(); + } + + /*/////////////////////////////////////////////////////////////// + Claim Tests + //////////////////////////////////////////////////////////////*/ + + /** + * note: Testing revert condition; exceed max claimable supply. + */ + function test_revert_claimCondition_exceedMaxClaimableSupply() public { + vm.warp(1); + + address receiver = getActor(0); + bytes32[] memory proofs = new bytes32[](0); + + OpenEditionERC721FlatFee.AllowlistProof memory alp; + alp.proof = proofs; + + OpenEditionERC721FlatFee.ClaimCondition[] memory conditions = new OpenEditionERC721FlatFee.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 100; + conditions[0].quantityLimitPerWallet = 200; + + vm.prank(deployer); + openEdition.setSharedMetadata(sharedMetadata); + vm.prank(deployer); + openEdition.setClaimConditions(conditions, false); + + vm.prank(getActor(5), getActor(5)); + openEdition.claim(receiver, 100, address(0), 0, alp, ""); + + vm.expectRevert( + abi.encodeWithSelector(Drop.DropClaimExceedMaxSupply.selector, conditions[0].maxClaimableSupply, 101) + ); + vm.prank(getActor(6), getActor(6)); + openEdition.claim(receiver, 1, address(0), 0, alp, ""); + } + + /** + * note: Testing quantity limit restriction when no allowlist present. + */ + function test_fuzz_claim_noAllowlist(uint256 x) public { + vm.assume(x != 0); + vm.warp(1); + + address receiver = getActor(0); + bytes32[] memory proofs = new bytes32[](0); + + OpenEditionERC721FlatFee.AllowlistProof memory alp; + alp.proof = proofs; + alp.quantityLimitPerWallet = x; + + OpenEditionERC721FlatFee.ClaimCondition[] memory conditions = new OpenEditionERC721FlatFee.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 500; + conditions[0].quantityLimitPerWallet = 100; + + vm.prank(deployer); + openEdition.setSharedMetadata(sharedMetadata); + + vm.prank(deployer); + openEdition.setClaimConditions(conditions, false); + + vm.prank(getActor(5), getActor(5)); + vm.expectRevert( + abi.encodeWithSelector(Drop.DropClaimExceedLimit.selector, conditions[0].quantityLimitPerWallet, 0) + ); + openEdition.claim(receiver, 0, address(0), 0, alp, ""); + + vm.prank(getActor(5), getActor(5)); + vm.expectRevert( + abi.encodeWithSelector(Drop.DropClaimExceedLimit.selector, conditions[0].quantityLimitPerWallet, 101) + ); + openEdition.claim(receiver, 101, address(0), 0, alp, ""); + + vm.prank(deployer); + openEdition.setClaimConditions(conditions, true); + + vm.prank(getActor(5), getActor(5)); + vm.expectRevert( + abi.encodeWithSelector(Drop.DropClaimExceedLimit.selector, conditions[0].quantityLimitPerWallet, 101) + ); + openEdition.claim(receiver, 101, address(0), 0, alp, ""); + } + + /** + * note: Testing quantity limit restriction + * - allowlist quantity set to some value different than general limit + * - allowlist price set to 0 + */ + function test_state_claim_allowlisted_SetQuantityZeroPrice() public { + string[] memory inputs = new string[](5); + + inputs[0] = "node"; + inputs[1] = "src/test/scripts/generateRoot.ts"; + inputs[2] = "300"; + inputs[3] = "0"; + inputs[4] = Strings.toHexString(uint160(address(erc20))); // address of erc20 + + bytes memory result = vm.ffi(inputs); + // revert(); + bytes32 root = abi.decode(result, (bytes32)); + + inputs[1] = "src/test/scripts/getProof.ts"; + result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + OpenEditionERC721FlatFee.AllowlistProof memory alp; + alp.proof = proofs; + alp.quantityLimitPerWallet = 300; + alp.pricePerToken = 0; + alp.currency = address(erc20); + + vm.warp(1); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); // in allowlist + + OpenEditionERC721FlatFee.ClaimCondition[] memory conditions = new OpenEditionERC721FlatFee.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 500; + conditions[0].quantityLimitPerWallet = 10; + conditions[0].merkleRoot = root; + conditions[0].pricePerToken = 10; + conditions[0].currency = address(erc20); + + vm.prank(deployer); + openEdition.setSharedMetadata(sharedMetadata); + vm.prank(deployer); + openEdition.setClaimConditions(conditions, false); + + // vm.prank(getActor(5), getActor(5)); + vm.prank(receiver, receiver); + openEdition.claim(receiver, 100, address(erc20), 0, alp, ""); // claims for free, because allowlist price is 0 + assertEq(openEdition.getSupplyClaimedByWallet(openEdition.getActiveClaimConditionId(), receiver), 100); + } + + /** + * note: Testing quantity limit restriction + * - allowlist quantity set to some value different than general limit + * - allowlist price set to non-zero value + */ + function test_state_claim_allowlisted_SetQuantityPrice() public { + string[] memory inputs = new string[](5); + + inputs[0] = "node"; + inputs[1] = "src/test/scripts/generateRoot.ts"; + inputs[2] = "300"; + inputs[3] = "5"; + inputs[4] = Strings.toHexString(uint160(address(erc20))); // address of erc20 + + bytes memory result = vm.ffi(inputs); + // revert(); + bytes32 root = abi.decode(result, (bytes32)); + + inputs[1] = "src/test/scripts/getProof.ts"; + result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + OpenEditionERC721FlatFee.AllowlistProof memory alp; + alp.proof = proofs; + alp.quantityLimitPerWallet = 300; + alp.pricePerToken = 5; + alp.currency = address(erc20); + + vm.warp(1); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); // in allowlist + + OpenEditionERC721FlatFee.ClaimCondition[] memory conditions = new OpenEditionERC721FlatFee.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 500; + conditions[0].quantityLimitPerWallet = 10; + conditions[0].merkleRoot = root; + conditions[0].pricePerToken = 10; + conditions[0].currency = address(erc20); + + vm.prank(deployer); + openEdition.setSharedMetadata(sharedMetadata); + vm.prank(deployer); + openEdition.setClaimConditions(conditions, false); + + vm.prank(receiver, receiver); + vm.expectRevert( + abi.encodeWithSelector(Drop.DropClaimInvalidTokenPrice.selector, address(erc20), 0, address(erc20), 5) + ); + openEdition.claim(receiver, 100, address(erc20), 0, alp, ""); + + erc20.mint(receiver, 10000); + vm.prank(receiver); + erc20.approve(address(openEdition), 10000); + + // vm.prank(getActor(5), getActor(5)); + vm.prank(receiver, receiver); + openEdition.claim(receiver, 100, address(erc20), 5, alp, ""); + assertEq(openEdition.getSupplyClaimedByWallet(openEdition.getActiveClaimConditionId(), receiver), 100); + assertEq(erc20.balanceOf(receiver), 10000 - 500); + } + + /** + * note: Testing quantity limit restriction + * - allowlist quantity set to some value different than general limit + * - allowlist price not set; should default to general price and currency + */ + function test_state_claim_allowlisted_SetQuantityDefaultPrice() public { + string[] memory inputs = new string[](5); + + inputs[0] = "node"; + inputs[1] = "src/test/scripts/generateRoot.ts"; + inputs[2] = "300"; + inputs[3] = Strings.toString(type(uint256).max); // this implies that general price is applicable + inputs[4] = "0x0000000000000000000000000000000000000000"; + + bytes memory result = vm.ffi(inputs); + // revert(); + bytes32 root = abi.decode(result, (bytes32)); + + inputs[1] = "src/test/scripts/getProof.ts"; + result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + OpenEditionERC721FlatFee.AllowlistProof memory alp; + alp.proof = proofs; + alp.quantityLimitPerWallet = 300; + alp.pricePerToken = type(uint256).max; + alp.currency = address(0); + + vm.warp(1); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); // in allowlist + + OpenEditionERC721FlatFee.ClaimCondition[] memory conditions = new OpenEditionERC721FlatFee.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 500; + conditions[0].quantityLimitPerWallet = 10; + conditions[0].merkleRoot = root; + conditions[0].pricePerToken = 10; + conditions[0].currency = address(erc20); + + vm.prank(deployer); + openEdition.setSharedMetadata(sharedMetadata); + vm.prank(deployer); + openEdition.setClaimConditions(conditions, false); + + erc20.mint(receiver, 10000); + vm.prank(receiver); + erc20.approve(address(openEdition), 10000); + + // vm.prank(getActor(5), getActor(5)); + vm.prank(receiver, receiver); + openEdition.claim(receiver, 100, address(erc20), 10, alp, ""); + assertEq(openEdition.getSupplyClaimedByWallet(openEdition.getActiveClaimConditionId(), receiver), 100); + assertEq(erc20.balanceOf(receiver), 10000 - 1000); + } + + /** + * note: Testing quantity limit restriction + * - allowlist quantity set to 0 => should default to general limit + * - allowlist price set to some value different than general price + */ + function test_state_claim_allowlisted_DefaultQuantitySomePrice() public { + string[] memory inputs = new string[](5); + + inputs[0] = "node"; + inputs[1] = "src/test/scripts/generateRoot.ts"; + inputs[2] = "0"; // this implies that general limit is applicable + inputs[3] = "5"; + inputs[4] = "0x0000000000000000000000000000000000000000"; // general currency will be applicable + + bytes memory result = vm.ffi(inputs); + // revert(); + bytes32 root = abi.decode(result, (bytes32)); + + inputs[1] = "src/test/scripts/getProof.ts"; + result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + OpenEditionERC721FlatFee.AllowlistProof memory alp; + alp.proof = proofs; + alp.quantityLimitPerWallet = 0; + alp.pricePerToken = 5; + alp.currency = address(0); + + vm.warp(1); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); // in allowlist + + OpenEditionERC721FlatFee.ClaimCondition[] memory conditions = new OpenEditionERC721FlatFee.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 500; + conditions[0].quantityLimitPerWallet = 10; + conditions[0].merkleRoot = root; + conditions[0].pricePerToken = 10; + conditions[0].currency = address(erc20); + + vm.prank(deployer); + openEdition.setSharedMetadata(sharedMetadata); + vm.prank(deployer); + openEdition.setClaimConditions(conditions, false); + + erc20.mint(receiver, 10000); + vm.prank(receiver); + erc20.approve(address(openEdition), 10000); + + vm.prank(receiver, receiver); + vm.expectRevert( + abi.encodeWithSelector(Drop.DropClaimExceedLimit.selector, conditions[0].quantityLimitPerWallet, 100) + ); + openEdition.claim(receiver, 100, address(erc20), 5, alp, ""); // trying to claim more than general limit + + // vm.prank(getActor(5), getActor(5)); + vm.prank(receiver, receiver); + openEdition.claim(receiver, 10, address(erc20), 5, alp, ""); + assertEq(openEdition.getSupplyClaimedByWallet(openEdition.getActiveClaimConditionId(), receiver), 10); + assertEq(erc20.balanceOf(receiver), 10000 - 50); + } + + function test_fuzz_claim_merkleProof(uint256 x) public { + vm.assume(x > 10 && x < 500); + string[] memory inputs = new string[](5); + + inputs[0] = "node"; + inputs[1] = "src/test/scripts/generateRoot.ts"; + inputs[2] = Strings.toString(x); + inputs[3] = "0"; + inputs[4] = "0x0000000000000000000000000000000000000000"; + + bytes memory result = vm.ffi(inputs); + // revert(); + bytes32 root = abi.decode(result, (bytes32)); + + inputs[1] = "src/test/scripts/getProof.ts"; + result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + OpenEditionERC721FlatFee.AllowlistProof memory alp; + alp.proof = proofs; + alp.quantityLimitPerWallet = x; + alp.pricePerToken = 0; + alp.currency = address(0); + + vm.warp(1); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); + + // bytes32[] memory proofs = new bytes32[](0); + + OpenEditionERC721FlatFee.ClaimCondition[] memory conditions = new OpenEditionERC721FlatFee.ClaimCondition[](1); + conditions[0].maxClaimableSupply = x; + conditions[0].quantityLimitPerWallet = 1; + conditions[0].merkleRoot = root; + + vm.prank(deployer); + openEdition.setSharedMetadata(sharedMetadata); + vm.prank(deployer); + openEdition.setClaimConditions(conditions, false); + + // vm.prank(getActor(5), getActor(5)); + vm.prank(receiver, receiver); + openEdition.claim(receiver, x - 5, address(0), 0, alp, ""); + assertEq(openEdition.getSupplyClaimedByWallet(openEdition.getActiveClaimConditionId(), receiver), x - 5); + + vm.prank(receiver, receiver); + vm.expectRevert(abi.encodeWithSelector(Drop.DropClaimExceedLimit.selector, x, x + 1)); + openEdition.claim(receiver, 6, address(0), 0, alp, ""); + + vm.prank(receiver, receiver); + openEdition.claim(receiver, 5, address(0), 0, alp, ""); + assertEq(openEdition.getSupplyClaimedByWallet(openEdition.getActiveClaimConditionId(), receiver), x); + + vm.prank(receiver, receiver); + vm.expectRevert(abi.encodeWithSelector(Drop.DropClaimExceedLimit.selector, x, x + 5)); + openEdition.claim(receiver, 5, address(0), 0, alp, ""); // quantity limit already claimed + } + + /** + * note: Testing state changes; reset eligibility of claim conditions and claiming again for same condition id. + */ + function test_state_claimCondition_resetEligibility() public { + vm.warp(1); + + address receiver = getActor(0); + bytes32[] memory proofs = new bytes32[](0); + + OpenEditionERC721FlatFee.AllowlistProof memory alp; + alp.proof = proofs; + + OpenEditionERC721FlatFee.ClaimCondition[] memory conditions = new OpenEditionERC721FlatFee.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 500; + conditions[0].quantityLimitPerWallet = 100; + + vm.prank(deployer); + openEdition.setSharedMetadata(sharedMetadata); + + vm.prank(deployer); + openEdition.setClaimConditions(conditions, false); + + vm.prank(getActor(5), getActor(5)); + openEdition.claim(receiver, 100, address(0), 0, alp, ""); + + vm.prank(getActor(5), getActor(5)); + vm.expectRevert( + abi.encodeWithSelector(Drop.DropClaimExceedLimit.selector, conditions[0].quantityLimitPerWallet, 200) + ); + openEdition.claim(receiver, 100, address(0), 0, alp, ""); + + vm.prank(deployer); + openEdition.setClaimConditions(conditions, true); + + vm.prank(getActor(5), getActor(5)); + openEdition.claim(receiver, 100, address(0), 0, alp, ""); + } + + /*/////////////////////////////////////////////////////////////// + setClaimConditions + //////////////////////////////////////////////////////////////*/ + + function test_claimCondition_startIdAndCount() public { + vm.startPrank(deployer); + + uint256 currentStartId = 0; + uint256 count = 0; + + OpenEditionERC721FlatFee.ClaimCondition[] memory conditions = new OpenEditionERC721FlatFee.ClaimCondition[](2); + conditions[0].startTimestamp = 0; + conditions[0].maxClaimableSupply = 10; + conditions[1].startTimestamp = 1; + conditions[1].maxClaimableSupply = 10; + + openEdition.setClaimConditions(conditions, false); + (currentStartId, count) = openEdition.claimCondition(); + assertEq(currentStartId, 0); + assertEq(count, 2); + + openEdition.setClaimConditions(conditions, false); + (currentStartId, count) = openEdition.claimCondition(); + assertEq(currentStartId, 0); + assertEq(count, 2); + + openEdition.setClaimConditions(conditions, true); + (currentStartId, count) = openEdition.claimCondition(); + assertEq(currentStartId, 2); + assertEq(count, 2); + + openEdition.setClaimConditions(conditions, true); + (currentStartId, count) = openEdition.claimCondition(); + assertEq(currentStartId, 4); + assertEq(count, 2); + } + + function test_claimCondition_startPhase() public { + vm.startPrank(deployer); + + uint256 activeConditionId = 0; + + OpenEditionERC721FlatFee.ClaimCondition[] memory conditions = new OpenEditionERC721FlatFee.ClaimCondition[](3); + conditions[0].startTimestamp = 10; + conditions[0].maxClaimableSupply = 11; + conditions[0].quantityLimitPerWallet = 12; + conditions[1].startTimestamp = 20; + conditions[1].maxClaimableSupply = 21; + conditions[1].quantityLimitPerWallet = 22; + conditions[2].startTimestamp = 30; + conditions[2].maxClaimableSupply = 31; + conditions[2].quantityLimitPerWallet = 32; + openEdition.setClaimConditions(conditions, false); + + vm.expectRevert(abi.encodeWithSelector(Drop.DropNoActiveCondition.selector)); + openEdition.getActiveClaimConditionId(); + + vm.warp(10); + activeConditionId = openEdition.getActiveClaimConditionId(); + assertEq(activeConditionId, 0); + assertEq(openEdition.getClaimConditionById(activeConditionId).startTimestamp, 10); + assertEq(openEdition.getClaimConditionById(activeConditionId).maxClaimableSupply, 11); + assertEq(openEdition.getClaimConditionById(activeConditionId).quantityLimitPerWallet, 12); + + vm.warp(20); + activeConditionId = openEdition.getActiveClaimConditionId(); + assertEq(activeConditionId, 1); + assertEq(openEdition.getClaimConditionById(activeConditionId).startTimestamp, 20); + assertEq(openEdition.getClaimConditionById(activeConditionId).maxClaimableSupply, 21); + assertEq(openEdition.getClaimConditionById(activeConditionId).quantityLimitPerWallet, 22); + + vm.warp(30); + activeConditionId = openEdition.getActiveClaimConditionId(); + assertEq(activeConditionId, 2); + assertEq(openEdition.getClaimConditionById(activeConditionId).startTimestamp, 30); + assertEq(openEdition.getClaimConditionById(activeConditionId).maxClaimableSupply, 31); + assertEq(openEdition.getClaimConditionById(activeConditionId).quantityLimitPerWallet, 32); + + vm.warp(40); + assertEq(openEdition.getActiveClaimConditionId(), 2); + } +} diff --git a/src/test/SignatureDrop.t.sol b/src/test/SignatureDrop.t.sol new file mode 100644 index 000000000..393799338 --- /dev/null +++ b/src/test/SignatureDrop.t.sol @@ -0,0 +1,1371 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { SignatureDrop, DropSinglePhase, Permissions, LazyMint, BatchMintMetadata, DelayedReveal, IDropSinglePhase, IDelayedReveal, ISignatureMintERC721, ERC721AUpgradeable, IPermissions, ILazyMint } from "contracts/prebuilts/signature-drop/SignatureDrop.sol"; +import { SignatureMintERC721 } from "contracts/extension/SignatureMintERC721.sol"; + +// Test imports +import "erc721a-upgradeable/contracts/IERC721AUpgradeable.sol"; +import "./utils/BaseTest.sol"; +import "@openzeppelin/contracts-upgradeable/utils/StringsUpgradeable.sol"; + +contract SignatureDropTest is BaseTest { + using Strings for uint256; + using Strings for address; + + event TokensLazyMinted(uint256 indexed startTokenId, uint256 endTokenId, string baseURI, bytes encryptedBaseURI); + event TokenURIRevealed(uint256 indexed index, string revealedURI); + event TokensMintedWithSignature( + address indexed signer, + address indexed mintedTo, + uint256 indexed tokenIdMinted, + SignatureDrop.MintRequest mintRequest + ); + + SignatureDrop public sigdrop; + address internal deployerSigner; + bytes32 internal typehashMintRequest; + bytes32 internal nameHash; + bytes32 internal versionHash; + bytes32 internal typehashEip712; + bytes32 internal domainSeparator; + + bytes private emptyEncodedBytes = abi.encode("", ""); + + using stdStorage for StdStorage; + + function setUp() public override { + super.setUp(); + deployerSigner = signer; + sigdrop = SignatureDrop(getContract("SignatureDrop")); + + erc20.mint(deployerSigner, 1_000 ether); + vm.deal(deployerSigner, 1_000 ether); + + typehashMintRequest = keccak256( + "MintRequest(address to,address royaltyRecipient,uint256 royaltyBps,address primarySaleRecipient,string uri,uint256 quantity,uint256 pricePerToken,address currency,uint128 validityStartTimestamp,uint128 validityEndTimestamp,bytes32 uid)" + ); + nameHash = keccak256(bytes("SignatureMintERC721")); + versionHash = keccak256(bytes("1")); + typehashEip712 = keccak256( + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" + ); + domainSeparator = keccak256(abi.encode(typehashEip712, nameHash, versionHash, block.chainid, address(sigdrop))); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: misc. + //////////////////////////////////////////////////////////////*/ + + /** + * note: Tests whether contract reverts when a non-holder renounces a role. + */ + function test_revert_nonHolder_renounceRole() public { + address caller = address(0x123); + bytes32 role = keccak256("MINTER_ROLE"); + + vm.prank(caller); + vm.expectRevert(); + + sigdrop.renounceRole(role, caller); + } + + /** + * note: Tests whether contract reverts when a role admin revokes a role for a non-holder. + */ + function test_revert_revokeRoleForNonHolder() public { + address target = address(0x123); + bytes32 role = keccak256("MINTER_ROLE"); + + vm.prank(deployerSigner); + vm.expectRevert(abi.encodeWithSelector(Permissions.PermissionsUnauthorizedAccount.selector, target, role)); + + sigdrop.revokeRole(role, target); + } + + /** + * @dev Tests whether contract reverts when a role is granted to an existent role holder. + */ + function test_revert_grant_role_to_account_with_role() public { + bytes32 role = keccak256("ABC_ROLE"); + address receiver = getActor(0); + + vm.startPrank(deployerSigner); + + sigdrop.grantRole(role, receiver); + + vm.expectRevert(); + sigdrop.grantRole(role, receiver); + + vm.stopPrank(); + } + + /** + * @dev Tests contract state for Transfer role. + */ + function test_state_grant_transferRole() public { + bytes32 role = keccak256("TRANSFER_ROLE"); + + // check if admin and address(0) have transfer role in the beginning + bool checkAddressZero = sigdrop.hasRole(role, address(0)); + bool checkAdmin = sigdrop.hasRole(role, deployerSigner); + assertTrue(checkAddressZero); + assertTrue(checkAdmin); + + // check if transfer role can be granted to a non-holder + address receiver = getActor(0); + vm.startPrank(deployerSigner); + sigdrop.grantRole(role, receiver); + + // expect revert when granting to a holder + vm.expectRevert(abi.encodeWithSelector(Permissions.PermissionsAlreadyGranted.selector, receiver, role)); + sigdrop.grantRole(role, receiver); + + // check if receiver has transfer role + bool checkReceiver = sigdrop.hasRole(role, receiver); + assertTrue(checkReceiver); + + // check if role is correctly revoked + sigdrop.revokeRole(role, receiver); + checkReceiver = sigdrop.hasRole(role, receiver); + assertFalse(checkReceiver); + sigdrop.revokeRole(role, address(0)); + checkAddressZero = sigdrop.hasRole(role, address(0)); + assertFalse(checkAddressZero); + + vm.stopPrank(); + } + + /** + * @dev Tests contract state for Transfer role. + */ + function test_state_getRoleMember_transferRole() public { + bytes32 role = keccak256("TRANSFER_ROLE"); + + uint256 roleMemberCount = sigdrop.getRoleMemberCount(role); + assertEq(roleMemberCount, 2); + + address roleMember = sigdrop.getRoleMember(role, 1); + assertEq(roleMember, address(0)); + + vm.startPrank(deployerSigner); + sigdrop.grantRole(role, address(2)); + sigdrop.grantRole(role, address(3)); + sigdrop.grantRole(role, address(4)); + + roleMemberCount = sigdrop.getRoleMemberCount(role); + console.log(roleMemberCount); + for (uint256 i = 0; i < roleMemberCount; i++) { + console.log(sigdrop.getRoleMember(role, i)); + } + console.log(""); + + sigdrop.revokeRole(role, address(2)); + roleMemberCount = sigdrop.getRoleMemberCount(role); + console.log(roleMemberCount); + for (uint256 i = 0; i < roleMemberCount; i++) { + console.log(sigdrop.getRoleMember(role, i)); + } + console.log(""); + + sigdrop.revokeRole(role, address(0)); + roleMemberCount = sigdrop.getRoleMemberCount(role); + console.log(roleMemberCount); + for (uint256 i = 0; i < roleMemberCount; i++) { + console.log(sigdrop.getRoleMember(role, i)); + } + console.log(""); + + sigdrop.grantRole(role, address(5)); + roleMemberCount = sigdrop.getRoleMemberCount(role); + console.log(roleMemberCount); + for (uint256 i = 0; i < roleMemberCount; i++) { + console.log(sigdrop.getRoleMember(role, i)); + } + console.log(""); + + sigdrop.grantRole(role, address(0)); + roleMemberCount = sigdrop.getRoleMemberCount(role); + console.log(roleMemberCount); + for (uint256 i = 0; i < roleMemberCount; i++) { + console.log(sigdrop.getRoleMember(role, i)); + } + console.log(""); + + sigdrop.grantRole(role, address(6)); + roleMemberCount = sigdrop.getRoleMemberCount(role); + console.log(roleMemberCount); + for (uint256 i = 0; i < roleMemberCount; i++) { + console.log(sigdrop.getRoleMember(role, i)); + } + console.log(""); + } + + /** + * note: Testing transfer of tokens when transfer-role is restricted + */ + function test_claim_transferRole() public { + vm.warp(1); + + address receiver = getActor(0); + bytes32[] memory proofs = new bytes32[](0); + + SignatureDrop.AllowlistProof memory alp; + alp.proof = proofs; + + SignatureDrop.ClaimCondition[] memory conditions = new SignatureDrop.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 100; + conditions[0].quantityLimitPerWallet = 100; + + vm.prank(deployerSigner); + sigdrop.lazyMint(100, "ipfs://", emptyEncodedBytes); + vm.prank(deployerSigner); + sigdrop.setClaimConditions(conditions[0], false); + + vm.prank(getActor(5), getActor(5)); + sigdrop.claim(receiver, 1, address(0), 0, alp, ""); + + // revoke transfer role from address(0) + vm.prank(deployerSigner); + sigdrop.revokeRole(keccak256("TRANSFER_ROLE"), address(0)); + vm.startPrank(receiver); + vm.expectRevert("!Transfer-Role"); + sigdrop.transferFrom(receiver, address(123), 0); + } + + /** + * @dev Tests whether role member count is incremented correctly. + */ + function test_member_count_incremented_properly_when_role_granted() public { + bytes32 role = keccak256("ABC_ROLE"); + address receiver = getActor(0); + + vm.startPrank(deployerSigner); + uint256 roleMemberCount = sigdrop.getRoleMemberCount(role); + + assertEq(roleMemberCount, 0); + + sigdrop.grantRole(role, receiver); + + assertEq(sigdrop.getRoleMemberCount(role), 1); + + vm.stopPrank(); + } + + function test_claimCondition_with_startTimestamp() public { + vm.warp(1); + + address receiver = getActor(0); + bytes32[] memory proofs = new bytes32[](0); + + SignatureDrop.AllowlistProof memory alp; + alp.proof = proofs; + + SignatureDrop.ClaimCondition[] memory conditions = new SignatureDrop.ClaimCondition[](1); + conditions[0].startTimestamp = 100; + conditions[0].maxClaimableSupply = 100; + conditions[0].quantityLimitPerWallet = 100; + + vm.prank(deployerSigner); + sigdrop.lazyMint(100, "ipfs://", emptyEncodedBytes); + + vm.prank(deployerSigner); + sigdrop.setClaimConditions(conditions[0], false); + + vm.warp(100); + vm.prank(getActor(4), getActor(4)); + sigdrop.claim(receiver, 1, address(0), 0, alp, ""); + + vm.warp(99); + vm.prank(getActor(5), getActor(5)); + vm.expectRevert( + abi.encodeWithSelector( + DropSinglePhase.DropClaimNotStarted.selector, + conditions[0].startTimestamp, + block.timestamp + ) + ); + sigdrop.claim(receiver, 1, address(0), 0, alp, ""); + } + + /*/////////////////////////////////////////////////////////////// + Lazy Mint Tests + //////////////////////////////////////////////////////////////*/ + + /* + * note: Testing state changes; lazy mint a batch of tokens with no encrypted base URI. + */ + function test_state_lazyMint_noEncryptedURI() public { + uint256 amountToLazyMint = 100; + string memory baseURI = "ipfs://"; + + uint256 nextTokenIdToMintBefore = sigdrop.nextTokenIdToMint(); + + vm.startPrank(deployerSigner); + uint256 batchId = sigdrop.lazyMint(amountToLazyMint, baseURI, emptyEncodedBytes); + + assertEq(nextTokenIdToMintBefore + amountToLazyMint, sigdrop.nextTokenIdToMint()); + assertEq(nextTokenIdToMintBefore + amountToLazyMint, batchId); + + for (uint256 i = 0; i < amountToLazyMint; i += 1) { + string memory uri = sigdrop.tokenURI(i); + console.log(uri); + assertEq(uri, string(abi.encodePacked(baseURI, i.toString()))); + } + + vm.stopPrank(); + } + + /* + * note: Testing state changes; lazy mint a batch of tokens with encrypted base URI. + */ + function test_state_lazyMint_withEncryptedURI() public { + uint256 amountToLazyMint = 100; + string memory baseURI = "ipfs://"; + bytes memory encryptedBaseURI = "encryptedBaseURI://"; + bytes32 provenanceHash = bytes32("whatever"); + + uint256 nextTokenIdToMintBefore = sigdrop.nextTokenIdToMint(); + + vm.startPrank(deployerSigner); + uint256 batchId = sigdrop.lazyMint(amountToLazyMint, baseURI, abi.encode(encryptedBaseURI, provenanceHash)); + + assertEq(nextTokenIdToMintBefore + amountToLazyMint, sigdrop.nextTokenIdToMint()); + assertEq(nextTokenIdToMintBefore + amountToLazyMint, batchId); + + for (uint256 i = 0; i < amountToLazyMint; i += 1) { + string memory uri = sigdrop.tokenURI(1); + assertEq(uri, string(abi.encodePacked(baseURI, "0"))); + } + + vm.stopPrank(); + } + + /** + * note: Testing revert condition; an address without MINTER_ROLE calls lazyMint function. + */ + function test_revert_lazyMint_MINTER_ROLE() public { + bytes32 _minterRole = keccak256("MINTER_ROLE"); + + vm.prank(deployerSigner); + sigdrop.grantRole(_minterRole, address(0x345)); + + vm.prank(address(0x345)); + sigdrop.lazyMint(100, "ipfs://", emptyEncodedBytes); + + vm.prank(address(0x567)); + vm.expectRevert(abi.encodeWithSelector(LazyMint.LazyMintUnauthorized.selector)); + sigdrop.lazyMint(100, "ipfs://", emptyEncodedBytes); + } + + /* + * note: Testing revert condition; calling tokenURI for invalid batch id. + */ + function test_revert_lazyMint_URIForNonLazyMintedToken() public { + vm.startPrank(deployerSigner); + + sigdrop.lazyMint(100, "ipfs://", emptyEncodedBytes); + + vm.expectRevert(abi.encodeWithSelector(BatchMintMetadata.BatchMintInvalidTokenId.selector, 100)); + sigdrop.tokenURI(100); + + vm.stopPrank(); + } + + /** + * note: Testing event emission; tokens lazy minted. + */ + function test_event_lazyMint_TokensLazyMinted() public { + vm.startPrank(deployerSigner); + + vm.expectEmit(true, false, false, true); + emit TokensLazyMinted(0, 99, "ipfs://", emptyEncodedBytes); + sigdrop.lazyMint(100, "ipfs://", emptyEncodedBytes); + + vm.stopPrank(); + } + + /* + * note: Fuzz testing state changes; lazy mint a batch of tokens with no encrypted base URI. + */ + function test_fuzz_lazyMint_noEncryptedURI(uint256 x) public { + vm.assume(x > 0); + + uint256 amountToLazyMint = x; + string memory baseURI = "ipfs://"; + + uint256 nextTokenIdToMintBefore = sigdrop.nextTokenIdToMint(); + + vm.startPrank(deployerSigner); + uint256 batchId = sigdrop.lazyMint(amountToLazyMint, baseURI, emptyEncodedBytes); + + assertEq(nextTokenIdToMintBefore + amountToLazyMint, sigdrop.nextTokenIdToMint()); + assertEq(nextTokenIdToMintBefore + amountToLazyMint, batchId); + + string memory uri = sigdrop.tokenURI(0); + assertEq(uri, string(abi.encodePacked(baseURI, uint256(0).toString()))); + + uri = sigdrop.tokenURI(x - 1); + assertEq(uri, string(abi.encodePacked(baseURI, uint256(x - 1).toString()))); + + /** + * note: this loop takes too long to run with fuzz tests. + */ + // for(uint256 i = 0; i < amountToLazyMint; i += 1) { + // string memory uri = sigdrop.tokenURI(i); + // console.log(uri); + // assertEq(uri, string(abi.encodePacked(baseURI, i.toString()))); + // } + + vm.stopPrank(); + } + + /* + * note: Fuzz testing state changes; lazy mint a batch of tokens with encrypted base URI. + */ + function test_fuzz_lazyMint_withEncryptedURI(uint256 x) public { + vm.assume(x > 0); + + uint256 amountToLazyMint = x; + string memory baseURI = "ipfs://"; + bytes memory encryptedBaseURI = "encryptedBaseURI://"; + bytes32 provenanceHash = bytes32("whatever"); + + uint256 nextTokenIdToMintBefore = sigdrop.nextTokenIdToMint(); + + vm.startPrank(deployerSigner); + uint256 batchId = sigdrop.lazyMint(amountToLazyMint, baseURI, abi.encode(encryptedBaseURI, provenanceHash)); + + assertEq(nextTokenIdToMintBefore + amountToLazyMint, sigdrop.nextTokenIdToMint()); + assertEq(nextTokenIdToMintBefore + amountToLazyMint, batchId); + + string memory uri = sigdrop.tokenURI(0); + assertEq(uri, string(abi.encodePacked(baseURI, "0"))); + + uri = sigdrop.tokenURI(x - 1); + assertEq(uri, string(abi.encodePacked(baseURI, "0"))); + + /** + * note: this loop takes too long to run with fuzz tests. + */ + // for(uint256 i = 0; i < amountToLazyMint; i += 1) { + // string memory uri = sigdrop.tokenURI(1); + // assertEq(uri, string(abi.encodePacked(baseURI, "0"))); + // } + + vm.stopPrank(); + } + + /* + * note: Fuzz testing; a batch of tokens, and nextTokenIdToMint + */ + function test_fuzz_lazyMint_batchMintAndNextTokenIdToMint(uint256 x) public { + vm.assume(x > 0); + vm.startPrank(deployerSigner); + + if (x == 0) { + vm.expectRevert("Zero amount"); + } + sigdrop.lazyMint(x, "ipfs://", emptyEncodedBytes); + + uint256 slot = stdstore.target(address(sigdrop)).sig("nextTokenIdToMint()").find(); + bytes32 loc = bytes32(slot); + uint256 nextTokenIdToMint = uint256(vm.load(address(sigdrop), loc)); + + assertEq(nextTokenIdToMint, x); + vm.stopPrank(); + } + + /*/////////////////////////////////////////////////////////////// + Delayed Reveal Tests + //////////////////////////////////////////////////////////////*/ + + /* + * note: Testing state changes; URI revealed for a batch of tokens. + */ + function test_state_reveal() public { + vm.startPrank(deployerSigner); + + bytes memory key = "key"; + uint256 amountToLazyMint = 100; + bytes memory secretURI = "ipfs://"; + string memory placeholderURI = "ipfs://"; + bytes memory encryptedURI = sigdrop.encryptDecrypt(secretURI, key); + bytes32 provenanceHash = keccak256(abi.encodePacked(secretURI, key, block.chainid)); + + sigdrop.lazyMint(amountToLazyMint, placeholderURI, abi.encode(encryptedURI, provenanceHash)); + + for (uint256 i = 0; i < amountToLazyMint; i += 1) { + string memory uri = sigdrop.tokenURI(i); + assertEq(uri, string(abi.encodePacked(placeholderURI, "0"))); + } + + string memory revealedURI = sigdrop.reveal(0, key); + assertEq(revealedURI, string(secretURI)); + + for (uint256 i = 0; i < amountToLazyMint; i += 1) { + string memory uri = sigdrop.tokenURI(i); + assertEq(uri, string(abi.encodePacked(secretURI, i.toString()))); + } + + vm.stopPrank(); + } + + /** + * note: Testing revert condition; an address without MINTER_ROLE calls reveal function. + */ + function test_revert_reveal_MINTER_ROLE() public { + bytes memory key = "key"; + bytes memory encryptedURI = sigdrop.encryptDecrypt("ipfs://", key); + bytes32 provenanceHash = keccak256(abi.encodePacked("ipfs://", key, block.chainid)); + vm.prank(deployerSigner); + sigdrop.lazyMint(100, "", abi.encode(encryptedURI, provenanceHash)); + + vm.prank(deployerSigner); + sigdrop.reveal(0, "key"); + + vm.expectRevert( + abi.encodeWithSelector( + Permissions.PermissionsUnauthorizedAccount.selector, + address(this), + keccak256("MINTER_ROLE") + ) + ); + sigdrop.reveal(0, "key"); + } + + /* + * note: Testing revert condition; trying to reveal URI for non-existent batch. + */ + function test_revert_reveal_revealingNonExistentBatch() public { + vm.startPrank(deployerSigner); + + bytes memory key = "key"; + bytes memory encryptedURI = sigdrop.encryptDecrypt("ipfs://", key); + bytes32 provenanceHash = keccak256(abi.encodePacked("ipfs://", key, block.chainid)); + sigdrop.lazyMint(100, "", abi.encode(encryptedURI, provenanceHash)); + sigdrop.reveal(0, "key"); + + console.log(sigdrop.getBaseURICount()); + + sigdrop.lazyMint(100, "", abi.encode(encryptedURI, provenanceHash)); + vm.expectRevert(abi.encodeWithSelector(BatchMintMetadata.BatchMintInvalidBatchId.selector, 2)); + sigdrop.reveal(2, "key"); + + vm.stopPrank(); + } + + /* + * note: Testing revert condition; already revealed URI. + */ + function test_revert_delayedReveal_alreadyRevealed() public { + vm.startPrank(deployerSigner); + + bytes memory key = "key"; + bytes memory encryptedURI = sigdrop.encryptDecrypt("ipfs://", key); + bytes32 provenanceHash = keccak256(abi.encodePacked("ipfs://", key, block.chainid)); + sigdrop.lazyMint(100, "", abi.encode(encryptedURI, provenanceHash)); + sigdrop.reveal(0, "key"); + + vm.expectRevert(abi.encodeWithSelector(DelayedReveal.DelayedRevealNothingToReveal.selector)); + sigdrop.reveal(0, "key"); + + vm.stopPrank(); + } + + /* + * note: Testing state changes; revealing URI with an incorrect key. + */ + function testFail_reveal_incorrectKey() public { + vm.startPrank(deployerSigner); + + bytes memory key = "key"; + bytes memory encryptedURI = sigdrop.encryptDecrypt("ipfs://", key); + bytes32 provenanceHash = keccak256(abi.encodePacked("ipfs://", key, block.chainid)); + sigdrop.lazyMint(100, "", abi.encode(encryptedURI, provenanceHash)); + + string memory revealedURI = sigdrop.reveal(0, "keyy"); + assertEq(revealedURI, "ipfs://"); + + vm.stopPrank(); + } + + /** + * note: Testing event emission; TokenURIRevealed. + */ + function test_event_reveal_TokenURIRevealed() public { + vm.startPrank(deployerSigner); + + bytes memory key = "key"; + bytes memory encryptedURI = sigdrop.encryptDecrypt("ipfs://", key); + bytes32 provenanceHash = keccak256(abi.encodePacked("ipfs://", key, block.chainid)); + sigdrop.lazyMint(100, "", abi.encode(encryptedURI, provenanceHash)); + + vm.expectEmit(true, false, false, true); + emit TokenURIRevealed(0, "ipfs://"); + sigdrop.reveal(0, "key"); + + vm.stopPrank(); + } + + /*/////////////////////////////////////////////////////////////// + Signature Mint Tests + //////////////////////////////////////////////////////////////*/ + + function signMintRequest( + SignatureDrop.MintRequest memory mintrequest, + uint256 privateKey + ) internal view returns (bytes memory) { + bytes memory encodedRequest = abi.encode( + typehashMintRequest, + mintrequest.to, + mintrequest.royaltyRecipient, + mintrequest.royaltyBps, + mintrequest.primarySaleRecipient, + keccak256(bytes(mintrequest.uri)), + mintrequest.quantity, + mintrequest.pricePerToken, + mintrequest.currency, + mintrequest.validityStartTimestamp, + mintrequest.validityEndTimestamp, + mintrequest.uid + ); + bytes32 structHash = keccak256(encodedRequest); + bytes32 typedDataHash = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, typedDataHash); + bytes memory signature = abi.encodePacked(r, s, v); + + return signature; + } + + /* + * note: Testing state changes; minting with signature, for a given price and currency. + */ + function test_state_mintWithSignature() public { + vm.prank(deployerSigner); + sigdrop.lazyMint(100, "ipfs://", emptyEncodedBytes); + uint256 id = 0; + SignatureDrop.MintRequest memory mintrequest; + + mintrequest.to = address(0x567); + mintrequest.royaltyRecipient = address(2); + mintrequest.royaltyBps = 0; + mintrequest.primarySaleRecipient = address(deployer); + mintrequest.uri = "ipfs://"; + mintrequest.quantity = 1; + mintrequest.pricePerToken = 1; + mintrequest.currency = address(erc20); + mintrequest.validityStartTimestamp = 1000; + mintrequest.validityEndTimestamp = 2000; + mintrequest.uid = bytes32(id); + + // Test with ERC20 currency + { + uint256 totalSupplyBefore = sigdrop.totalSupply(); + + bytes memory signature = signMintRequest(mintrequest, privateKey); + vm.startPrank(deployerSigner); + vm.warp(1000); + erc20.approve(address(sigdrop), 1); + vm.expectEmit(true, true, true, false); + emit TokensMintedWithSignature(deployerSigner, address(0x567), 0, mintrequest); + sigdrop.mintWithSignature(mintrequest, signature); + vm.stopPrank(); + + assertEq(totalSupplyBefore + mintrequest.quantity, sigdrop.totalSupply()); + } + + // Test with native token currency + { + uint256 totalSupplyBefore = sigdrop.totalSupply(); + + mintrequest.currency = address(NATIVE_TOKEN); + id = 1; + mintrequest.uid = bytes32(id); + + bytes memory signature = signMintRequest(mintrequest, privateKey); + vm.startPrank(address(deployerSigner)); + vm.warp(1000); + sigdrop.mintWithSignature{ value: mintrequest.pricePerToken }(mintrequest, signature); + vm.stopPrank(); + + assertEq(totalSupplyBefore + mintrequest.quantity, sigdrop.totalSupply()); + } + } + + /* + * note: Testing state changes; minting with signature, for a given price and currency. + */ + function test_state_mintWithSignature_UpdateRoyaltyAndSaleInfo() public { + vm.prank(deployerSigner); + sigdrop.lazyMint(100, "ipfs://", emptyEncodedBytes); + uint256 id = 0; + SignatureDrop.MintRequest memory mintrequest; + + mintrequest.to = address(0x567); + mintrequest.royaltyRecipient = address(0x567); + mintrequest.royaltyBps = 100; + mintrequest.primarySaleRecipient = address(0x567); + mintrequest.uri = "ipfs://"; + mintrequest.quantity = 1; + mintrequest.pricePerToken = 1 ether; + mintrequest.currency = address(erc20); + mintrequest.validityStartTimestamp = 1000; + mintrequest.validityEndTimestamp = 2000; + mintrequest.uid = bytes32(id); + + // Test with ERC20 currency + { + erc20.mint(address(0x345), 1 ether); + uint256 totalSupplyBefore = sigdrop.totalSupply(); + + bytes memory signature = signMintRequest(mintrequest, privateKey); + vm.startPrank(address(0x345)); + vm.warp(1000); + erc20.approve(address(sigdrop), 1 ether); + vm.expectEmit(true, true, true, true); + emit TokensMintedWithSignature(deployerSigner, address(0x567), 0, mintrequest); + sigdrop.mintWithSignature(mintrequest, signature); + vm.stopPrank(); + + assertEq(totalSupplyBefore + mintrequest.quantity, sigdrop.totalSupply()); + + (address _royaltyRecipient, uint16 _royaltyBps) = sigdrop.getRoyaltyInfoForToken(0); + assertEq(_royaltyRecipient, address(0x567)); + assertEq(_royaltyBps, 100); + + uint256 totalPrice = 1 * 1 ether; + uint256 platformFees = (totalPrice * platformFeeBps) / MAX_BPS; + assertEq(erc20.balanceOf(address(0x567)), totalPrice - platformFees); + } + + // Test with native token currency + { + vm.deal(address(0x345), 1 ether); + uint256 totalSupplyBefore = sigdrop.totalSupply(); + + mintrequest.currency = address(NATIVE_TOKEN); + id = 1; + mintrequest.uid = bytes32(id); + + bytes memory signature = signMintRequest(mintrequest, privateKey); + vm.startPrank(address(0x345)); + vm.warp(1000); + sigdrop.mintWithSignature{ value: mintrequest.pricePerToken }(mintrequest, signature); + vm.stopPrank(); + + assertEq(totalSupplyBefore + mintrequest.quantity, sigdrop.totalSupply()); + + (address _royaltyRecipient, uint16 _royaltyBps) = sigdrop.getRoyaltyInfoForToken(0); + assertEq(_royaltyRecipient, address(0x567)); + assertEq(_royaltyBps, 100); + + uint256 totalPrice = 1 * 1 ether; + uint256 platformFees = (totalPrice * platformFeeBps) / MAX_BPS; + assertEq(address(0x567).balance, totalPrice - platformFees); + } + } + + /** + * note: Testing revert condition; invalid signature. + */ + function test_revert_mintWithSignature_unapprovedSigner() public { + vm.prank(deployerSigner); + sigdrop.lazyMint(100, "ipfs://", emptyEncodedBytes); + uint256 id = 0; + + SignatureDrop.MintRequest memory mintrequest; + mintrequest.to = address(0x567); + mintrequest.royaltyRecipient = address(2); + mintrequest.royaltyBps = 0; + mintrequest.primarySaleRecipient = address(deployer); + mintrequest.uri = "ipfs://"; + mintrequest.quantity = 1; + mintrequest.pricePerToken = 0; + mintrequest.currency = address(3); + mintrequest.validityStartTimestamp = 1000; + mintrequest.validityEndTimestamp = 2000; + mintrequest.uid = bytes32(id); + + bytes memory signature = signMintRequest(mintrequest, privateKey); + vm.warp(1000); + vm.prank(deployerSigner); + sigdrop.mintWithSignature(mintrequest, signature); + + signature = signMintRequest(mintrequest, 4321); + vm.expectRevert(abi.encodeWithSelector(SignatureMintERC721.SignatureMintInvalidSigner.selector)); + sigdrop.mintWithSignature(mintrequest, signature); + } + + /** + * note: Testing revert condition; minting zero tokens. + */ + function test_revert_mintWithSignature_zeroQuantity() public { + vm.prank(deployerSigner); + sigdrop.lazyMint(100, "ipfs://", emptyEncodedBytes); + uint256 id = 0; + + SignatureDrop.MintRequest memory mintrequest; + mintrequest.to = address(0x567); + mintrequest.royaltyRecipient = address(2); + mintrequest.royaltyBps = 0; + mintrequest.primarySaleRecipient = address(deployer); + mintrequest.uri = "ipfs://"; + mintrequest.quantity = 0; + mintrequest.pricePerToken = 0; + mintrequest.currency = address(3); + mintrequest.validityStartTimestamp = 1000; + mintrequest.validityEndTimestamp = 2000; + mintrequest.uid = bytes32(id); + + bytes memory signature = signMintRequest(mintrequest, privateKey); + vm.warp(1000); + + vm.prank(deployerSigner); + vm.expectRevert(abi.encodeWithSelector(SignatureMintERC721.SignatureMintInvalidQuantity.selector)); + sigdrop.mintWithSignature(mintrequest, signature); + } + + /** + * note: Testing revert condition; not enough minted tokens. + */ + function test_revert_mintWithSignature_notEnoughMintedTokens() public { + vm.prank(deployerSigner); + sigdrop.lazyMint(100, "ipfs://", emptyEncodedBytes); + uint256 id = 0; + + SignatureDrop.MintRequest memory mintrequest; + mintrequest.to = address(0); + mintrequest.royaltyRecipient = address(2); + mintrequest.royaltyBps = 0; + mintrequest.primarySaleRecipient = address(deployer); + mintrequest.uri = "ipfs://"; + mintrequest.quantity = 101; + mintrequest.pricePerToken = 0; + mintrequest.currency = address(3); + mintrequest.validityStartTimestamp = 1000; + mintrequest.validityEndTimestamp = 2000; + mintrequest.uid = bytes32(id); + + bytes memory signature = signMintRequest(mintrequest, privateKey); + vm.warp(1000); + vm.expectRevert("!Tokens"); + sigdrop.mintWithSignature(mintrequest, signature); + } + + /** + * note: Testing revert condition; sent value is not equal to price. + */ + function test_revert_mintWithSignature_notSentAmountRequired() public { + vm.prank(deployerSigner); + sigdrop.lazyMint(100, "ipfs://", emptyEncodedBytes); + uint256 id = 0; + SignatureDrop.MintRequest memory mintrequest; + + mintrequest.to = address(0x567); + mintrequest.royaltyRecipient = address(2); + mintrequest.royaltyBps = 0; + mintrequest.primarySaleRecipient = address(deployer); + mintrequest.uri = "ipfs://"; + mintrequest.quantity = 1; + mintrequest.pricePerToken = 1; + mintrequest.currency = address(3); + mintrequest.validityStartTimestamp = 1000; + mintrequest.validityEndTimestamp = 2000; + mintrequest.uid = bytes32(id); + { + mintrequest.currency = address(NATIVE_TOKEN); + bytes memory signature = signMintRequest(mintrequest, privateKey); + vm.startPrank(address(deployerSigner)); + vm.warp(mintrequest.validityStartTimestamp); + vm.expectRevert("!Price"); + sigdrop.mintWithSignature{ value: 2 }(mintrequest, signature); + vm.stopPrank(); + } + } + + /** + * note: Testing token balances; checking balance and owner of tokens after minting with signature. + */ + function test_balances_mintWithSignature() public { + vm.prank(deployerSigner); + sigdrop.lazyMint(100, "ipfs://", emptyEncodedBytes); + uint256 id = 0; + SignatureDrop.MintRequest memory mintrequest; + + mintrequest.to = address(0x567); + mintrequest.royaltyRecipient = address(2); + mintrequest.royaltyBps = 0; + mintrequest.primarySaleRecipient = address(deployer); + mintrequest.uri = "ipfs://"; + mintrequest.quantity = 1; + mintrequest.pricePerToken = 1; + mintrequest.currency = address(erc20); + mintrequest.validityStartTimestamp = 1000; + mintrequest.validityEndTimestamp = 2000; + mintrequest.uid = bytes32(id); + + { + uint256 currencyBalBefore = erc20.balanceOf(deployerSigner); + + bytes memory signature = signMintRequest(mintrequest, privateKey); + vm.startPrank(deployerSigner); + vm.warp(1000); + erc20.approve(address(sigdrop), 1); + sigdrop.mintWithSignature(mintrequest, signature); + vm.stopPrank(); + + uint256 balance = sigdrop.balanceOf(address(0x567)); + assertEq(balance, 1); + + address owner = sigdrop.ownerOf(0); + assertEq(address(0x567), owner); + + assertEq( + currencyBalBefore - mintrequest.pricePerToken * mintrequest.quantity, + erc20.balanceOf(deployerSigner) + ); + + vm.expectRevert(abi.encodeWithSelector(IERC721AUpgradeable.OwnerQueryForNonexistentToken.selector)); + owner = sigdrop.ownerOf(1); + } + } + + /* + * note: Testing state changes; minting with signature, for a given price and currency. + */ + function mintWithSignature(SignatureDrop.MintRequest memory mintrequest) internal { + vm.prank(deployerSigner); + sigdrop.lazyMint(100, "ipfs://", emptyEncodedBytes); + uint256 id = 0; + + { + bytes memory signature = signMintRequest(mintrequest, privateKey); + vm.startPrank(deployerSigner); + vm.warp(mintrequest.validityStartTimestamp); + erc20.approve(address(sigdrop), 1); + sigdrop.mintWithSignature(mintrequest, signature); + vm.stopPrank(); + } + + { + mintrequest.currency = address(NATIVE_TOKEN); + id = 1; + mintrequest.uid = bytes32(id); + bytes memory signature = signMintRequest(mintrequest, privateKey); + vm.startPrank(address(deployerSigner)); + vm.warp(mintrequest.validityStartTimestamp); + sigdrop.mintWithSignature{ value: mintrequest.pricePerToken }(mintrequest, signature); + vm.stopPrank(); + } + } + + function test_fuzz_mintWithSignature(uint128 x, uint128 y) public { + if (x < y) { + uint256 id = 0; + SignatureDrop.MintRequest memory mintrequest; + + mintrequest.to = address(0x567); + mintrequest.royaltyRecipient = address(2); + mintrequest.royaltyBps = 0; + mintrequest.primarySaleRecipient = address(deployer); + mintrequest.uri = "ipfs://"; + mintrequest.quantity = 1; + mintrequest.pricePerToken = 1; + mintrequest.currency = address(erc20); + mintrequest.validityStartTimestamp = x; + mintrequest.validityEndTimestamp = y; + mintrequest.uid = bytes32(id); + + mintWithSignature(mintrequest); + } + } + + /*/////////////////////////////////////////////////////////////// + Claim Tests + //////////////////////////////////////////////////////////////*/ + + /** + * note: Testing revert condition; not enough minted tokens. + */ + function test_revert_claimCondition_notEnoughMintedTokens() public { + vm.warp(1); + + address receiver = getActor(0); + bytes32[] memory proofs = new bytes32[](0); + + SignatureDrop.AllowlistProof memory alp; + alp.proof = proofs; + + SignatureDrop.ClaimCondition[] memory conditions = new SignatureDrop.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 100; + conditions[0].quantityLimitPerWallet = 100; + + vm.prank(deployerSigner); + sigdrop.lazyMint(100, "ipfs://", emptyEncodedBytes); + vm.prank(deployerSigner); + sigdrop.setClaimConditions(conditions[0], false); + + vm.expectRevert("!Tokens"); + vm.prank(getActor(6), getActor(6)); + sigdrop.claim(receiver, 101, address(0), 0, alp, ""); + } + + /** + * note: Testing revert condition; exceed max claimable supply. + */ + function test_revert_claimCondition_exceedMaxClaimableSupply() public { + vm.warp(1); + + address receiver = getActor(0); + bytes32[] memory proofs = new bytes32[](0); + + SignatureDrop.AllowlistProof memory alp; + alp.proof = proofs; + + SignatureDrop.ClaimCondition[] memory conditions = new SignatureDrop.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 100; + conditions[0].quantityLimitPerWallet = 100; + + vm.prank(deployerSigner); + sigdrop.lazyMint(200, "ipfs://", emptyEncodedBytes); + vm.prank(deployerSigner); + sigdrop.setClaimConditions(conditions[0], false); + + vm.prank(getActor(5), getActor(5)); + sigdrop.claim(receiver, 100, address(0), 0, alp, ""); + + vm.expectRevert( + abi.encodeWithSelector( + DropSinglePhase.DropClaimExceedMaxSupply.selector, + conditions[0].maxClaimableSupply, + 101 + ) + ); + vm.prank(getActor(6), getActor(6)); + sigdrop.claim(receiver, 1, address(0), 0, alp, ""); + } + + /** + * note: Testing quantity limit restriction when no allowlist present. + */ + function test_fuzz_claim_noAllowlist(uint256 x) public { + vm.assume(x != 0); + vm.warp(1); + + address receiver = getActor(0); + bytes32[] memory proofs = new bytes32[](0); + + SignatureDrop.AllowlistProof memory alp; + alp.proof = proofs; + alp.quantityLimitPerWallet = x; + + SignatureDrop.ClaimCondition[] memory conditions = new SignatureDrop.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 500; + conditions[0].quantityLimitPerWallet = 100; + + vm.prank(deployerSigner); + sigdrop.lazyMint(500, "ipfs://", emptyEncodedBytes); + + vm.prank(deployerSigner); + sigdrop.setClaimConditions(conditions[0], false); + + vm.prank(getActor(5), getActor(5)); + vm.expectRevert( + abi.encodeWithSelector( + DropSinglePhase.DropClaimExceedLimit.selector, + conditions[0].quantityLimitPerWallet, + 101 + ) + ); + sigdrop.claim(receiver, 101, address(0), 0, alp, ""); + + vm.prank(deployerSigner); + sigdrop.setClaimConditions(conditions[0], true); + + vm.prank(getActor(5), getActor(5)); + vm.expectRevert( + abi.encodeWithSelector( + DropSinglePhase.DropClaimExceedLimit.selector, + conditions[0].quantityLimitPerWallet, + 101 + ) + ); + sigdrop.claim(receiver, 101, address(0), 0, alp, ""); + } + + function test_fuzz_claim_merkleProof(uint256 x) public { + vm.assume(x > 10 && x < 500); + string[] memory inputs = new string[](5); + + inputs[0] = "node"; + inputs[1] = "src/test/scripts/generateRoot.ts"; + inputs[2] = Strings.toString(x); + inputs[3] = "0"; + inputs[4] = "0x0000000000000000000000000000000000000000"; + + bytes memory result = vm.ffi(inputs); + // revert(); + bytes32 root = abi.decode(result, (bytes32)); + + inputs[1] = "src/test/scripts/getProof.ts"; + result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + SignatureDrop.AllowlistProof memory alp; + alp.proof = proofs; + alp.quantityLimitPerWallet = x; + alp.pricePerToken = 0; + alp.currency = address(0); + + vm.warp(1); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); + + // bytes32[] memory proofs = new bytes32[](0); + + SignatureDrop.ClaimCondition[] memory conditions = new SignatureDrop.ClaimCondition[](1); + conditions[0].maxClaimableSupply = x; + conditions[0].quantityLimitPerWallet = 1; + conditions[0].merkleRoot = root; + + vm.prank(deployerSigner); + sigdrop.lazyMint(2 * x, "ipfs://", emptyEncodedBytes); + vm.prank(deployerSigner); + sigdrop.setClaimConditions(conditions[0], false); + + // vm.prank(getActor(5), getActor(5)); + vm.prank(receiver, receiver); + sigdrop.claim(receiver, x - 5, address(0), 0, alp, ""); + assertEq(sigdrop.getSupplyClaimedByWallet(receiver), x - 5); + + vm.prank(receiver, receiver); + vm.expectRevert(abi.encodeWithSelector(DropSinglePhase.DropClaimExceedLimit.selector, x, x + 1)); + sigdrop.claim(receiver, 6, address(0), 0, alp, ""); + + vm.prank(receiver, receiver); + sigdrop.claim(receiver, 5, address(0), 0, alp, ""); + assertEq(sigdrop.getSupplyClaimedByWallet(receiver), x); + + vm.prank(receiver, receiver); + vm.expectRevert(abi.encodeWithSelector(DropSinglePhase.DropClaimExceedLimit.selector, x, x + 5)); + sigdrop.claim(receiver, 5, address(0), 0, alp, ""); + } + + /** + * note: Testing state changes; reset eligibility of claim conditions and claiming again for same condition id. + */ + function test_state_claimCondition_resetEligibility() public { + vm.warp(1); + + address receiver = getActor(0); + bytes32[] memory proofs = new bytes32[](0); + + SignatureDrop.AllowlistProof memory alp; + alp.proof = proofs; + + SignatureDrop.ClaimCondition[] memory conditions = new SignatureDrop.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 100; + conditions[0].quantityLimitPerWallet = 100; + + vm.prank(deployerSigner); + sigdrop.lazyMint(100, "ipfs://", emptyEncodedBytes); + + vm.prank(deployerSigner); + sigdrop.setClaimConditions(conditions[0], false); + + vm.prank(getActor(5), getActor(5)); + sigdrop.claim(receiver, 1, address(0), 0, alp, ""); + + vm.prank(deployerSigner); + sigdrop.setClaimConditions(conditions[0], true); + + vm.prank(getActor(5), getActor(5)); + sigdrop.claim(receiver, 1, address(0), 0, alp, ""); + } + + /*/////////////////////////////////////////////////////////////// + Miscellaneous + //////////////////////////////////////////////////////////////*/ + + function test_delayedReveal_withNewLazyMintedEmptyBatch() public { + vm.startPrank(deployerSigner); + + bytes memory encryptedURI = sigdrop.encryptDecrypt("ipfs://", "key"); + bytes32 provenanceHash = keccak256(abi.encodePacked("ipfs://", "key", block.chainid)); + sigdrop.lazyMint(100, "", abi.encode(encryptedURI, provenanceHash)); + sigdrop.reveal(0, "key"); + + string memory uri = sigdrop.tokenURI(1); + assertEq(uri, string(abi.encodePacked("ipfs://", "1"))); + + bytes memory newEncryptedURI = sigdrop.encryptDecrypt("ipfs://secret", "key"); + vm.expectRevert(abi.encodeWithSelector(LazyMint.LazyMintInvalidAmount.selector)); + sigdrop.lazyMint(0, "", abi.encode(newEncryptedURI, provenanceHash)); + + vm.stopPrank(); + } + + /*/////////////////////////////////////////////////////////////// + Reentrancy related Tests + //////////////////////////////////////////////////////////////*/ + + function testFail_reentrancy_mintWithSignature() public { + vm.prank(deployerSigner); + sigdrop.lazyMint(100, "ipfs://", emptyEncodedBytes); + uint256 id = 0; + SignatureDrop.MintRequest memory mintrequest; + + mintrequest.to = address(0); + mintrequest.royaltyRecipient = address(2); + mintrequest.royaltyBps = 0; + mintrequest.primarySaleRecipient = address(deployer); + mintrequest.uri = "ipfs://"; + mintrequest.quantity = 1; + mintrequest.pricePerToken = 1; + mintrequest.currency = address(NATIVE_TOKEN); + mintrequest.validityStartTimestamp = 1000; + mintrequest.validityEndTimestamp = 2000; + mintrequest.uid = bytes32(id); + + // Test with native token currency + { + uint256 totalSupplyBefore = sigdrop.totalSupply(); + + mintrequest.uid = bytes32(id); + bytes memory signature = signMintRequest(mintrequest, privateKey); + + MaliciousReceiver mal = new MaliciousReceiver(address(sigdrop)); + vm.deal(address(mal), 100 ether); + vm.warp(1000); + mal.attackMintWithSignature(mintrequest, signature, false); + + assertEq(totalSupplyBefore + mintrequest.quantity, sigdrop.totalSupply()); + } + } + + function testFail_reentrancy_claim() public { + vm.warp(1); + bytes32[] memory proofs = new bytes32[](0); + + SignatureDrop.AllowlistProof memory alp; + alp.proof = proofs; + + SignatureDrop.ClaimCondition[] memory conditions = new SignatureDrop.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 100; + conditions[0].quantityLimitPerWallet = 100; + + vm.prank(deployerSigner); + sigdrop.lazyMint(100, "ipfs://", emptyEncodedBytes); + + vm.prank(deployerSigner); + sigdrop.setClaimConditions(conditions[0], false); + + MaliciousReceiver mal = new MaliciousReceiver(address(sigdrop)); + vm.deal(address(mal), 100 ether); + mal.attackClaim(alp, false); + } + + function testFail_combination_signatureAndClaim() public { + vm.warp(1); + bytes32[] memory proofs = new bytes32[](0); + + SignatureDrop.AllowlistProof memory alp; + alp.proof = proofs; + + SignatureDrop.ClaimCondition[] memory conditions = new SignatureDrop.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 100; + conditions[0].quantityLimitPerWallet = 100; + + vm.prank(deployerSigner); + sigdrop.lazyMint(100, "ipfs://", emptyEncodedBytes); + vm.prank(deployerSigner); + sigdrop.setClaimConditions(conditions[0], false); + + uint256 id = 0; + SignatureDrop.MintRequest memory mintrequest; + + mintrequest.to = address(0); + mintrequest.royaltyRecipient = address(2); + mintrequest.royaltyBps = 0; + mintrequest.primarySaleRecipient = address(deployer); + mintrequest.uri = "ipfs://"; + mintrequest.quantity = 1; + mintrequest.pricePerToken = 1; + mintrequest.currency = address(NATIVE_TOKEN); + mintrequest.validityStartTimestamp = 1000; + mintrequest.validityEndTimestamp = 2000; + mintrequest.uid = bytes32(id); + + // Test with native token currency + { + uint256 totalSupplyBefore = sigdrop.totalSupply(); + + mintrequest.uid = bytes32(id); + bytes memory signature = signMintRequest(mintrequest, privateKey); + + MaliciousReceiver mal = new MaliciousReceiver(address(sigdrop)); + vm.deal(address(mal), 100 ether); + vm.warp(1000); + mal.saveCombination(mintrequest, signature, alp); + mal.attackMintWithSignature(mintrequest, signature, true); + // mal.attackClaim(alp, true); + + assertEq(totalSupplyBefore + mintrequest.quantity, sigdrop.totalSupply()); + } + } +} + +contract MaliciousReceiver { + SignatureDrop public sigdrop; + + SignatureDrop.MintRequest public mintrequest; + SignatureDrop.AllowlistProof public alp; + bytes public signature; + bool public claim; + bool public loop = true; + + constructor(address _sigdrop) { + sigdrop = SignatureDrop(_sigdrop); + } + + function attackMintWithSignature( + SignatureDrop.MintRequest calldata _mintrequest, + bytes calldata _signature, + bool swap + ) external { + claim = swap; + mintrequest = _mintrequest; + signature = _signature; + sigdrop.mintWithSignature{ value: _mintrequest.pricePerToken }(_mintrequest, _signature); + } + + function attackClaim(SignatureDrop.AllowlistProof calldata _alp, bool swap) external { + claim = !swap; + alp = _alp; + sigdrop.claim(address(this), 1, address(0), 0, _alp, ""); + } + + function saveCombination( + SignatureDrop.MintRequest calldata _mintrequest, + bytes calldata _signature, + SignatureDrop.AllowlistProof calldata _alp + ) external { + mintrequest = _mintrequest; + signature = _signature; + alp = _alp; + } + + function onERC721Received(address, address, uint256, bytes calldata) external returns (bytes4) { + if (claim && loop) { + loop = false; + claim = false; + sigdrop.claim(address(this), 1, address(0), 0, alp, ""); + } else if (!claim && loop) { + loop = false; + sigdrop.mintWithSignature{ value: mintrequest.pricePerToken }(mintrequest, signature); + } + return this.onERC721Received.selector; + } +} diff --git a/src/test/TWFactory.t.sol b/src/test/TWFactory.t.sol new file mode 100644 index 000000000..474286352 --- /dev/null +++ b/src/test/TWFactory.t.sol @@ -0,0 +1,326 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +// Test imports +import "./utils/BaseTest.sol"; +import { TWFactory } from "contracts/infra/TWFactory.sol"; +import { TWRegistry } from "contracts/infra/TWRegistry.sol"; + +// Helpers +import "@openzeppelin/contracts/utils/Create2.sol"; +import "@openzeppelin/contracts/proxy/Clones.sol"; +import "contracts/infra/TWProxy.sol"; +// import "./utils/Console.sol"; +import "./mocks/MockThirdwebContract.sol"; + +interface ITWFactoryData { + event ProxyDeployed(address indexed implementation, address proxy, address indexed deployer); + event ImplementationAdded(address implementation, bytes32 indexed contractType, uint256 version); + event ImplementationApproved(address implementation, bool isApproved); +} + +contract TWFactoryTest is ITWFactoryData, BaseTest { + // Target contract + TWFactory internal _factory; + + // Actors + address internal proxyDeployer; + address internal proxyDeployer2; + + // Test params + MockThirdwebContract internal mockModule; + address internal mockUnapprovedImplementation = address(0x5); + + // ===== Set up ===== + + function setUp() public override { + super.setUp(); + + _factory = TWFactory(factory); + proxyDeployer = getActor(10); + proxyDeployer2 = getActor(11); + + vm.prank(factoryAdmin); + mockModule = new MockThirdwebContract(); + } + + // ===== Initial state ===== + + /** + * @dev Tests the relevant initial state of the contract. + * + * - Deployer of the contract has `FACTORY_ROLE` + */ + function test_initialState() public { + assertTrue(_factory.hasRole(_factory.FACTORY_ROLE(), factoryAdmin)); + } + + // ===== Functionality tests ===== + + /// @dev Test `addImplementation` + + function test_addImplementation() public { + bytes32 contractType = mockModule.contractType(); + uint256 moduleVersion = mockModule.contractVersion(); + uint256 moduleVersionOnFactory = _factory.currentVersion(contractType); + + vm.prank(factoryAdmin); + _factory.addImplementation(address(mockModule)); + + assertTrue(_factory.approval(address(mockModule))); + assertEq(address(mockModule), _factory.implementation(contractType, moduleVersion)); + assertEq(_factory.currentVersion(contractType), moduleVersionOnFactory + 1); + assertEq(_factory.getImplementation(contractType, moduleVersion), address(mockModule)); + } + + function test_addImplementation_directV2() public { + MockThirdwebContractV2 mockModuleV2 = new MockThirdwebContractV2(); + + bytes32 contractType = mockModuleV2.contractType(); + uint256 moduleVersion = mockModuleV2.contractVersion(); + uint256 moduleVersionOnFactory = _factory.currentVersion(contractType); + + vm.prank(factoryAdmin); + _factory.addImplementation(address(mockModuleV2)); + + assertTrue(_factory.approval(address(mockModuleV2))); + assertEq(address(mockModuleV2), _factory.implementation(contractType, moduleVersion)); + assertEq(_factory.currentVersion(contractType), moduleVersionOnFactory + 2); + assertEq(_factory.getImplementation(contractType, moduleVersion), address(mockModuleV2)); + } + + function test_addImplementation_newImpl() public { + vm.prank(factoryAdmin); + _factory.addImplementation(address(mockModule)); + + MockThirdwebContractV2 mockModuleV2 = new MockThirdwebContractV2(); + + bytes32 contractType = mockModuleV2.contractType(); + uint256 moduleVersion = mockModuleV2.contractVersion(); + uint256 moduleVersionOnFactory = _factory.currentVersion(contractType); + + vm.prank(factoryAdmin); + _factory.addImplementation(address(mockModuleV2)); + + assertTrue(_factory.approval(address(mockModuleV2))); + assertEq(address(mockModuleV2), _factory.implementation(contractType, moduleVersion)); + assertEq(_factory.currentVersion(contractType), moduleVersionOnFactory + 1); + assertEq(_factory.getImplementation(contractType, moduleVersion), address(mockModuleV2)); + } + + function test_addImplementation_revert_invalidCaller() public { + vm.expectRevert("not admin."); + + vm.prank(proxyDeployer); + _factory.addImplementation(address(mockModule)); + } + + function test_addImplementation_emit_ImplementationAdded() public { + bytes32 contractType = mockModule.contractType(); + uint256 moduleVersion = mockModule.contractVersion(); + + vm.expectEmit(true, false, false, true); + emit ImplementationAdded(address(mockModule), contractType, moduleVersion); + + vm.prank(factoryAdmin); + _factory.addImplementation(address(mockModule)); + } + + /// @dev Test `approveImplementation` + + function test_approveImplementation() public { + assertTrue(_factory.approval(address(mockModule)) == false); + assertTrue(_factory.currentVersion(mockModule.contractType()) == 0); + + vm.prank(factoryAdmin); + _factory.approveImplementation(address(mockModule), true); + + assertTrue(_factory.approval(address(mockModule))); + assertTrue(_factory.currentVersion(mockModule.contractType()) == 0); + } + + function test_approveImplementation_revert_invalidCaller() public { + vm.expectRevert("not admin."); + + vm.prank(proxyDeployer); + _factory.approveImplementation(address(mockModule), true); + } + + function test_approveImplementation_emit_ImplementationApproved() public { + vm.expectEmit(false, false, false, true); + emit ImplementationApproved(address(mockModule), true); + + vm.prank(factoryAdmin); + _factory.approveImplementation(address(mockModule), true); + } + + /// @dev Test `deployProxyByImplementation` + + function setUp_deployProxyByImplementation() internal { + vm.prank(factoryAdmin); + _factory.approveImplementation(address(mockModule), true); + } + + function test_deployProxyByImplementation(bytes32 _salt) public { + setUp_deployProxyByImplementation(); + + address computedProxyAddr = Clones.predictDeterministicAddress( + address(mockModule), + keccak256(abi.encodePacked(proxyDeployer, _salt)), + factory + ); + + vm.prank(proxyDeployer); + address deployedAddr = _factory.deployProxyByImplementation(address(mockModule), "", _salt); + + assertEq(deployedAddr, computedProxyAddr); + assertEq(mockModule.contractType(), MockThirdwebContract(computedProxyAddr).contractType()); + } + + function test_deployProxyByImplementation_revert_invalidImpl() public { + vm.expectRevert("implementation not approved"); + + vm.prank(proxyDeployer); + _factory.deployProxyByImplementation(address(mockModule), "", ""); + } + + function skiptest_deployProxyByImplementation_emit_ProxyDeployed() public { + setUp_deployProxyByImplementation(); + + bytes32 salt = bytes32("Random"); + address computedProxyAddr = Clones.predictDeterministicAddress( + address(mockModule), + keccak256(abi.encodePacked(proxyDeployer, salt)), + factory + ); + + vm.expectEmit(true, true, false, true); + emit ProxyDeployed(address(mockModule), computedProxyAddr, proxyDeployer); + + vm.prank(proxyDeployer); + _factory.deployProxyByImplementation(address(mockModule), "", salt); + } + + /// @dev Test `deployProxyDeterministic` + + function setUp_deployProxyDeterministic() internal { + vm.prank(factoryAdmin); + _factory.addImplementation(address(mockModule)); + } + + function test_deployProxyDeterministic(bytes32 _salt) public { + setUp_deployProxyDeterministic(); + + bytes32 contractType = mockModule.contractType(); + + address computedProxyAddr = Clones.predictDeterministicAddress( + address(mockModule), + keccak256(abi.encodePacked(proxyDeployer, _salt)), + factory + ); + + vm.prank(proxyDeployer); + address proxyAddr = _factory.deployProxyDeterministic(contractType, "", _salt); + + assertEq(proxyAddr, computedProxyAddr); + assertEq(mockModule.contractType(), MockThirdwebContract(computedProxyAddr).contractType()); + } + + function test_deployProxyDeterministic_revert_invalidImpl(bytes32 _salt) public { + bytes32 contractType = mockModule.contractType(); + + vm.expectRevert("implementation not approved"); + + vm.prank(proxyDeployer); + _factory.deployProxyDeterministic(contractType, "", _salt); + } + + function skiptest_deployProxyDeterministic_emit_ProxyDeployed() public { + setUp_deployProxyDeterministic(); + + bytes32 contractType = mockModule.contractType(); + + bytes32 salt = bytes32("Random"); + bytes memory proxyBytecode = abi.encodePacked(type(TWProxy).creationCode, abi.encode(address(mockModule), "")); + address computedProxyAddr = Create2.computeAddress(salt, keccak256(proxyBytecode), address(_factory)); + + vm.expectEmit(true, true, false, true); + emit ProxyDeployed(address(mockModule), computedProxyAddr, proxyDeployer); + + vm.prank(proxyDeployer); + _factory.deployProxyDeterministic(contractType, "", salt); + } + + /// @dev Test `deployProxy` + + function setUp_deployProxy() internal { + vm.prank(factoryAdmin); + _factory.addImplementation(address(mockModule)); + } + + function test_deployProxy() public { + setUp_deployProxy(); + + bytes32 contractType = mockModule.contractType(); + + vm.prank(proxyDeployer); + address proxyAddr = _factory.deployProxy(contractType, ""); + + assertEq(mockModule.contractType(), MockThirdwebContract(proxyAddr).contractType()); + } + + function test_deployProxy_sameBlock() public { + setUp_deployProxy(); + + bytes32 contractType = mockModule.contractType(); + + vm.startPrank(proxyDeployer); + address proxyAddr = _factory.deployProxy(contractType, ""); + address proxyAddr2 = _factory.deployProxy(contractType, ""); + + assertTrue(proxyAddr != proxyAddr2); + assertEq(mockModule.contractType(), MockThirdwebContract(proxyAddr).contractType()); + } + + function test_deployProxy_revert_invalidImpl() public { + bytes32 contractType = mockModule.contractType(); + + vm.expectRevert("implementation not approved"); + + vm.prank(proxyDeployer); + _factory.deployProxy(contractType, ""); + } + + function skiptest_deployProxy_emit_ProxyDeployed() public { + setUp_deployProxy(); + + bytes32 contractType = mockModule.contractType(); + + bytes32 salt = keccak256(abi.encodePacked(contractType, block.number)); + bytes memory proxyBytecode = abi.encodePacked(type(TWProxy).creationCode, abi.encode(address(mockModule), "")); + address computedProxyAddr = Create2.computeAddress(salt, keccak256(proxyBytecode), address(_factory)); + + vm.expectEmit(true, true, false, true); + emit ProxyDeployed(address(mockModule), computedProxyAddr, proxyDeployer); + + vm.prank(proxyDeployer); + _factory.deployProxy(contractType, ""); + } + + /** + * ===== Attack vectors ===== + * + * - No proxy should be able to point to an unapproved implementation. + * - No non-admin should be able to approve an implementation. + **/ + function testNoNonAdmin(address _implementation, address _deployer) public { + bool toApprove = true; + + if (!_factory.hasRole(_factory.FACTORY_ROLE(), _deployer)) { + vm.expectRevert("not admin."); + + vm.prank(_deployer); + _factory.approveImplementation(_implementation, toApprove); + } + } +} diff --git a/src/test/TWMultichainRegistry.t.sol b/src/test/TWMultichainRegistry.t.sol new file mode 100644 index 000000000..b51cabec7 --- /dev/null +++ b/src/test/TWMultichainRegistry.t.sol @@ -0,0 +1,197 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +// Test imports +import "./utils/BaseTest.sol"; +import "contracts/infra/interface/ITWMultichainRegistry.sol"; +import { TWMultichainRegistry } from "contracts/infra/TWMultichainRegistry.sol"; +import "./mocks/MockThirdwebContract.sol"; +import "contracts/extension/interface/plugin/IPluginMap.sol"; + +interface ITWMultichainRegistryData { + event Added(address indexed deployer, address indexed moduleAddress, uint256 indexed chainid, string metadataUri); + event Deleted(address indexed deployer, address indexed moduleAddress, uint256 indexed chainid); +} + +contract TWMultichainRegistryTest is ITWMultichainRegistryData, BaseTest { + // Target contract + TWMultichainRegistry internal _registry; + + // Test params + address internal factoryAdmin_; + address internal factory_; + + uint256[] internal chainIds; + address[] internal deploymentAddresses; + address internal deployer_; + + uint256 total = 1000; + + // ===== Set up ===== + + function setUp() public override { + super.setUp(); + + deployer_ = getActor(100); + factory_ = getActor(101); + factoryAdmin_ = getActor(102); + + for (uint256 i = 0; i < total; i += 1) { + chainIds.push(i); + vm.prank(deployer_); + address depl = address(new MockThirdwebContract()); + deploymentAddresses.push(depl); + } + + vm.startPrank(factoryAdmin_); + _registry = new TWMultichainRegistry(address(0)); + + _registry.grantRole(keccak256("OPERATOR_ROLE"), factory_); + + vm.stopPrank(); + } + + function test_interfaceId() public pure { + console2.logBytes4(type(IPluginMap).interfaceId); + } + + // ===== Functionality tests ===== + + /// @dev Test `add` + + function test_addFromFactory() public { + vm.startPrank(factory_); + for (uint256 i = 0; i < total; i += 1) { + _registry.add(deployer_, deploymentAddresses[i], chainIds[i], ""); + } + vm.stopPrank(); + + ITWMultichainRegistry.Deployment[] memory modules = _registry.getAll(deployer_); + + assertEq(modules.length, total); + assertEq(_registry.count(deployer_), total); + + for (uint256 i = 0; i < total; i += 1) { + assertEq(modules[i].deploymentAddress, deploymentAddresses[i]); + assertEq(modules[i].chainId, chainIds[i]); + } + + vm.prank(factory_); + _registry.add(deployer_, address(0x43), 111, ""); + + modules = _registry.getAll(deployer_); + assertEq(modules.length, total + 1); + assertEq(_registry.count(deployer_), total + 1); + } + + function test_addFromSelf() public { + vm.startPrank(deployer_); + for (uint256 i = 0; i < total; i += 1) { + _registry.add(deployer_, deploymentAddresses[i], chainIds[i], ""); + } + vm.stopPrank(); + + ITWMultichainRegistry.Deployment[] memory modules = _registry.getAll(deployer_); + + assertEq(modules.length, total); + assertEq(_registry.count(deployer_), total); + + for (uint256 i = 0; i < total; i += 1) { + assertEq(modules[i].deploymentAddress, deploymentAddresses[i]); + assertEq(modules[i].chainId, chainIds[i]); + } + + vm.prank(factory_); + _registry.add(deployer_, address(0x43), 111, ""); + + modules = _registry.getAll(deployer_); + assertEq(modules.length, total + 1); + assertEq(_registry.count(deployer_), total + 1); + } + + function test_add_emit_Added() public { + vm.expectEmit(true, true, true, true); + emit Added(deployer_, deploymentAddresses[0], chainIds[0], "uri"); + + vm.prank(factory_); + _registry.add(deployer_, deploymentAddresses[0], chainIds[0], "uri"); + + string memory uri = _registry.getMetadataUri(chainIds[0], deploymentAddresses[0]); + assertEq(uri, "uri"); + } + + // Test `remove` + + function setUp_remove() public { + vm.startPrank(factory_); + for (uint256 i = 0; i < total; i += 1) { + _registry.add(deployer_, deploymentAddresses[i], chainIds[i], ""); + } + vm.stopPrank(); + } + + // ===== Functionality tests ===== + function test_removeFromFactory() public { + setUp_remove(); + vm.prank(factory_); + _registry.remove(deployer_, deploymentAddresses[0], chainIds[0]); + + ITWMultichainRegistry.Deployment[] memory modules = _registry.getAll(deployer_); + assertEq(modules.length, total - 1); + + for (uint256 i = 0; i < total - 1; i += 1) { + assertEq(modules[i].deploymentAddress, deploymentAddresses[i + 1]); + assertEq(modules[i].chainId, chainIds[i + 1]); + } + } + + function test_removeFromSelf() public { + setUp_remove(); + vm.prank(factory_); + _registry.remove(deployer_, deploymentAddresses[0], chainIds[0]); + + ITWMultichainRegistry.Deployment[] memory modules = _registry.getAll(deployer_); + assertEq(modules.length, total - 1); + } + + function test_remove_revert_invalidCaller() public { + setUp_remove(); + address invalidCaller = address(0x123); + assertTrue(invalidCaller != factory_ || invalidCaller != deployer_); + + vm.expectRevert("not operator or deployer."); + + vm.prank(invalidCaller); + _registry.remove(deployer_, deploymentAddresses[0], chainIds[0]); + } + + function test_remove_revert_noModulesToRemove() public { + setUp_remove(); + address actor = getActor(1); + ITWMultichainRegistry.Deployment[] memory modules = _registry.getAll(actor); + assertEq(modules.length, 0); + + vm.expectRevert("failed to remove"); + + vm.prank(actor); + _registry.remove(actor, deploymentAddresses[0], chainIds[0]); + } + + function test_remove_revert_incorrectChainId() public { + setUp_remove(); + + vm.expectRevert("failed to remove"); + + vm.prank(deployer_); + _registry.remove(deployer_, deploymentAddresses[0], 12345); + } + + function test_remove_emit_Deleted() public { + setUp_remove(); + vm.expectEmit(true, true, true, true); + emit Deleted(deployer_, deploymentAddresses[0], chainIds[0]); + + vm.prank(deployer_); + _registry.remove(deployer_, deploymentAddresses[0], chainIds[0]); + } +} diff --git a/src/test/TWRegistry.t.sol b/src/test/TWRegistry.t.sol new file mode 100644 index 000000000..8d9a8c79f --- /dev/null +++ b/src/test/TWRegistry.t.sol @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +// Test imports +import "./utils/BaseTest.sol"; +import { TWRegistry } from "contracts/infra/TWRegistry.sol"; + +interface ITWRegistryData { + event Added(address indexed deployer, address indexed moduleAddress); + event Deleted(address indexed deployer, address indexed moduleAddress); +} + +contract TWRegistryTest is ITWRegistryData, BaseTest { + // Target contract + TWRegistry internal _registry; + + // Test params + address internal mockModuleAddress = address(0x42); + address internal actor; + + // ===== Set up ===== + + function setUp() public override { + super.setUp(); + actor = getActor(0); + _registry = TWRegistry(registry); + } + + // ===== Functionality tests ===== + + /// @dev Test `add` + + function test_addFromFactory() public { + vm.prank(factory); + _registry.add(actor, mockModuleAddress); + + address[] memory modules = _registry.getAll(actor); + assertEq(modules.length, 1); + assertEq(modules[0], mockModuleAddress); + assertEq(_registry.count(actor), 1); + + vm.prank(factory); + _registry.add(actor, address(0x43)); + + modules = _registry.getAll(actor); + assertEq(modules.length, 2); + assertEq(_registry.count(actor), 2); + } + + function test_addFromSelf() public { + vm.prank(actor); + _registry.add(actor, mockModuleAddress); + + address[] memory modules = _registry.getAll(actor); + + assertEq(modules.length, 1); + assertEq(modules[0], mockModuleAddress); + assertEq(_registry.count(actor), 1); + } + + function test_add_emit_Added() public { + vm.expectEmit(true, true, false, true); + emit Added(actor, mockModuleAddress); + + vm.prank(factory); + _registry.add(actor, mockModuleAddress); + } + + // Test `remove` + + function setUp_remove() public { + vm.prank(factory); + _registry.add(actor, mockModuleAddress); + } + + // ===== Functionality tests ===== + function test_removeFromFactory() public { + setUp_remove(); + vm.prank(factory); + _registry.remove(actor, mockModuleAddress); + + address[] memory modules = _registry.getAll(actor); + assertEq(modules.length, 0); + } + + function test_removeFromSelf() public { + setUp_remove(); + vm.prank(actor); + _registry.remove(actor, mockModuleAddress); + + address[] memory modules = _registry.getAll(actor); + assertEq(modules.length, 0); + } + + function test_remove_revert_invalidCaller() public { + setUp_remove(); + address invalidCaller = address(0x123); + assertTrue(invalidCaller != factory || invalidCaller != actor); + + vm.expectRevert("not operator or deployer."); + + vm.prank(invalidCaller); + _registry.remove(actor, mockModuleAddress); + } + + function test_remove_revert_noModulesToRemove() public { + setUp_remove(); + actor = getActor(1); + address[] memory modules = _registry.getAll(actor); + assertEq(modules.length, 0); + + vm.expectRevert("failed to remove"); + + vm.prank(actor); + _registry.remove(actor, mockModuleAddress); + } + + function test_remove_emit_Deleted() public { + setUp_remove(); + vm.expectEmit(true, true, false, true); + emit Deleted(actor, mockModuleAddress); + + vm.prank(actor); + _registry.remove(actor, mockModuleAddress); + } +} diff --git a/src/test/TieredDrop.t.sol b/src/test/TieredDrop.t.sol new file mode 100644 index 000000000..9c04f8422 --- /dev/null +++ b/src/test/TieredDrop.t.sol @@ -0,0 +1,1103 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "./utils/BaseTest.sol"; + +import { TieredDrop } from "contracts/prebuilts/tiered-drop/TieredDrop.sol"; +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +contract TieredDropTest is BaseTest { + using Strings for uint256; + + TieredDrop public tieredDrop; + + address internal dropAdmin; + address internal claimer; + + // Signature params + address internal deployerSigner; + bytes32 internal typehashGenericRequest; + bytes32 internal nameHash; + bytes32 internal versionHash; + bytes32 internal typehashEip712; + bytes32 internal domainSeparator; + + // Lazy mint variables + uint256 internal quantityTier1 = 10; + string internal tier1 = "tier1"; + string internal baseURITier1 = "baseURI1/"; + string internal placeholderURITier1 = "placeholderURI1/"; + bytes internal keyTier1 = "tier1_key"; + + uint256 internal quantityTier2 = 20; + string internal tier2 = "tier2"; + string internal baseURITier2 = "baseURI2/"; + string internal placeholderURITier2 = "placeholderURI2/"; + bytes internal keyTier2 = "tier2_key"; + + uint256 internal quantityTier3 = 30; + string internal tier3 = "tier3"; + string internal baseURITier3 = "baseURI3/"; + string internal placeholderURITier3 = "placeholderURI3/"; + bytes internal keyTier3 = "tier3_key"; + + function setUp() public virtual override { + super.setUp(); + + dropAdmin = getActor(1); + claimer = getActor(2); + + // Deploy implementation. + address tieredDropImpl = address(new TieredDrop()); + + // Deploy proxy pointing to implementaion. + vm.prank(dropAdmin); + tieredDrop = TieredDrop( + address( + new TWProxy( + tieredDropImpl, + abi.encodeCall( + TieredDrop.initialize, + (dropAdmin, "Tiered Drop", "TD", "ipfs://", new address[](0), dropAdmin, dropAdmin, 0) + ) + ) + ) + ); + + // ====== signature params + + deployerSigner = signer; + vm.prank(dropAdmin); + tieredDrop.grantRole(keccak256("MINTER_ROLE"), deployerSigner); + + typehashGenericRequest = keccak256( + "GenericRequest(uint128 validityStartTimestamp,uint128 validityEndTimestamp,bytes32 uid,bytes data)" + ); + nameHash = keccak256(bytes("SignatureAction")); + versionHash = keccak256(bytes("1")); + typehashEip712 = keccak256( + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" + ); + domainSeparator = keccak256( + abi.encode(typehashEip712, nameHash, versionHash, block.chainid, address(tieredDrop)) + ); + + // ====== + } + + TieredDrop.GenericRequest internal claimRequest; + bytes internal claimSignature; + + uint256 internal nonce; + + function _setupClaimSignature(string[] memory _orderedTiers, uint256 _totalQuantity) internal { + claimRequest.validityStartTimestamp = 1000; + claimRequest.validityEndTimestamp = 2000; + claimRequest.uid = keccak256(abi.encodePacked(nonce)); + nonce += 1; + claimRequest.data = abi.encode( + _orderedTiers, + claimer, + address(0), + 0, + dropAdmin, + _totalQuantity, + 0, + NATIVE_TOKEN + ); + + bytes memory encodedRequest = abi.encode( + typehashGenericRequest, + claimRequest.validityStartTimestamp, + claimRequest.validityEndTimestamp, + claimRequest.uid, + keccak256(bytes(claimRequest.data)) + ); + + bytes32 structHash = keccak256(encodedRequest); + bytes32 typedDataHash = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, typedDataHash); + claimSignature = abi.encodePacked(r, s, v); + } + + //////////////////////////////////////////////// + // // + // lazyMintWithTier tests // + // // + //////////////////////////////////////////////// + + // function test_state_lazyMintWithTier() public { + // // Lazy mint tokens: 3 different tiers + // vm.startPrank(dropAdmin); + + // // Tier 1: tokenIds assigned 0 -> 10 non-inclusive. + // tieredDrop.lazyMint(quantityTier1, baseURITier1, tier1, ""); + // // Tier 2: tokenIds assigned 10 -> 30 non-inclusive. + // tieredDrop.lazyMint(quantityTier2, baseURITier2, tier2, ""); + // // Tier 3: tokenIds assigned 30 -> 60 non-inclusive. + // tieredDrop.lazyMint(quantityTier3, baseURITier3, tier3, ""); + + // vm.stopPrank(); + + // TieredDrop.TierMetadata[] memory metadataForAllTiers = tieredDrop.getMetadataForAllTiers(); + // (TieredDrop.TokenRange[] memory tokens_1, string[] memory baseURIs_1) = ( + // metadataForAllTiers[0].ranges, + // metadataForAllTiers[0].baseURIs + // ); + // (TieredDrop.TokenRange[] memory tokens_2, string[] memory baseURIs_2) = ( + // metadataForAllTiers[1].ranges, + // metadataForAllTiers[1].baseURIs + // ); + // (TieredDrop.TokenRange[] memory tokens_3, string[] memory baseURIs_3) = ( + // metadataForAllTiers[2].ranges, + // metadataForAllTiers[2].baseURIs + // ); + + // uint256 cumulativeStart = 0; + + // TieredDrop.TokenRange memory range = tokens_1[0]; + // string memory baseURI = baseURIs_1[0]; + + // assertEq(range.startIdInclusive, cumulativeStart); + // assertEq(range.endIdNonInclusive, cumulativeStart + quantityTier1); + // assertEq(baseURI, baseURITier1); + + // cumulativeStart += quantityTier1; + + // range = tokens_2[0]; + // baseURI = baseURIs_2[0]; + + // assertEq(range.startIdInclusive, cumulativeStart); + // assertEq(range.endIdNonInclusive, cumulativeStart + quantityTier2); + // assertEq(baseURI, baseURITier2); + + // cumulativeStart += quantityTier2; + + // range = tokens_3[0]; + // baseURI = baseURIs_3[0]; + + // assertEq(range.startIdInclusive, cumulativeStart); + // assertEq(range.endIdNonInclusive, cumulativeStart + quantityTier3); + // assertEq(baseURI, baseURITier3); + // } + + // function test_state_lazyMintWithTier_sameTier() public { + // // Lazy mint tokens: 3 different tiers + // vm.startPrank(dropAdmin); + + // // Tier 1: tokenIds assigned 0 -> 10 non-inclusive. + // tieredDrop.lazyMint(quantityTier1, baseURITier1, tier1, ""); + // // Tier 2: tokenIds assigned 10 -> 30 non-inclusive. + // tieredDrop.lazyMint(quantityTier2, baseURITier2, tier2, ""); + // // Tier 1 Again: tokenIds assigned 30 -> 60 non-inclusive. + // tieredDrop.lazyMint(quantityTier3, baseURITier3, tier1, ""); + + // TieredDrop.TierMetadata[] memory metadataForAllTiers = tieredDrop.getMetadataForAllTiers(); + // (TieredDrop.TokenRange[] memory tokens_1, string[] memory baseURIs_1) = ( + // metadataForAllTiers[0].ranges, + // metadataForAllTiers[0].baseURIs + // ); + // (TieredDrop.TokenRange[] memory tokens_2, string[] memory baseURIs_2) = ( + // metadataForAllTiers[1].ranges, + // metadataForAllTiers[1].baseURIs + // ); + + // vm.stopPrank(); + + // uint256 cumulativeStart = 0; + + // TieredDrop.TokenRange memory range = tokens_1[0]; + // string memory baseURI = baseURIs_1[0]; + + // assertEq(range.startIdInclusive, cumulativeStart); + // assertEq(range.endIdNonInclusive, cumulativeStart + quantityTier1); + // assertEq(baseURI, baseURITier1); + + // cumulativeStart += quantityTier1; + + // range = tokens_2[0]; + // baseURI = baseURIs_2[0]; + + // assertEq(range.startIdInclusive, cumulativeStart); + // assertEq(range.endIdNonInclusive, cumulativeStart + quantityTier2); + // assertEq(baseURI, baseURITier2); + + // cumulativeStart += quantityTier2; + + // range = tokens_1[1]; + // baseURI = baseURIs_1[1]; + + // assertEq(range.startIdInclusive, cumulativeStart); + // assertEq(range.endIdNonInclusive, cumulativeStart + quantityTier3); + // assertEq(baseURI, baseURITier3); + // } + + function test_revert_lazyMintWithTier_notMinterRole() public { + vm.expectRevert("Not authorized"); + tieredDrop.lazyMint(quantityTier1, baseURITier1, tier1, ""); + } + + function test_revert_lazyMintWithTier_mintingZeroAmount() public { + vm.prank(dropAdmin); + vm.expectRevert("0 amt"); + tieredDrop.lazyMint(0, baseURITier1, tier1, ""); + } + + //////////////////////////////////////////////// + // // + // claimWithSignature tests // + // // + //////////////////////////////////////////////// + + function test_state_claimWithSignature() public { + // Lazy mint tokens: 3 different tiers + vm.startPrank(dropAdmin); + + // Tier 1: tokenIds assigned 0 -> 10 non-inclusive. + tieredDrop.lazyMint(quantityTier1, baseURITier1, tier1, ""); + // Tier 2: tokenIds assigned 10 -> 30 non-inclusive. + tieredDrop.lazyMint(quantityTier2, baseURITier2, tier2, ""); + // Tier 3: tokenIds assigned 30 -> 60 non-inclusive. + tieredDrop.lazyMint(quantityTier3, baseURITier3, tier3, ""); + + vm.stopPrank(); + + /** + * Claim tokens. + * - Order of priority: [tier2, tier1] + * - Total quantity: 25. [20 from tier2, 5 from tier1] + */ + + string[] memory tiers = new string[](2); + tiers[0] = tier2; + tiers[1] = tier1; + + uint256 claimQuantity = 25; + + _setupClaimSignature(tiers, claimQuantity); + + assertEq(tieredDrop.hasRole(keccak256("MINTER_ROLE"), deployerSigner), true); + + vm.warp(claimRequest.validityStartTimestamp); + vm.prank(claimer); + tieredDrop.claimWithSignature(claimRequest, claimSignature); + + /** + * Check token URIs for tokens of tiers: + * - Tier 2: token IDs 0 -> 19 mapped one-to-one to metadata IDs 10 -> 29 + * - Tier 1: token IDs 20 -> 24 mapped one-to-one to metadata IDs 0 -> 4 + */ + + uint256 tier2Id = 10; + uint256 tier1Id = 0; + + for (uint256 i = 0; i < claimQuantity; i += 1) { + if (i < 20) { + assertEq(tieredDrop.tokenURI(i), string(abi.encodePacked(baseURITier2, tier2Id.toString()))); + tier2Id += 1; + } else { + assertEq(tieredDrop.tokenURI(i), string(abi.encodePacked(baseURITier1, tier1Id.toString()))); + tier1Id += 1; + } + } + } + + function test_revert_claimWithSignature_invalidEncoding() public { + // Lazy mint tokens: 3 different tiers + vm.startPrank(dropAdmin); + + // Tier 1: tokenIds assigned 0 -> 10 non-inclusive. + tieredDrop.lazyMint(quantityTier1, baseURITier1, tier1, ""); + // Tier 2: tokenIds assigned 10 -> 30 non-inclusive. + tieredDrop.lazyMint(quantityTier2, baseURITier2, tier2, ""); + // Tier 3: tokenIds assigned 30 -> 60 non-inclusive. + tieredDrop.lazyMint(quantityTier3, baseURITier3, tier3, ""); + + vm.stopPrank(); + + /** + * Claim tokens. + * - Order of priority: [tier2, tier1] + * - Total quantity: 25. [20 from tier2, 5 from tier1] + */ + + string[] memory tiers = new string[](2); + tiers[0] = tier2; + tiers[1] = tier1; + + uint256 claimQuantity = 25; + + // Create data with invalid encoding. + claimRequest.data = abi.encode(1, ""); + _setupClaimSignature(tiers, claimQuantity); + + claimRequest.data = abi.encode(1, ""); + + assertEq(tieredDrop.hasRole(keccak256("MINTER_ROLE"), deployerSigner), true); + + vm.warp(claimRequest.validityStartTimestamp); + vm.prank(claimer); + vm.expectRevert(); + tieredDrop.claimWithSignature(claimRequest, claimSignature); + } + + function test_revert_claimWithSignature_mintingZeroQuantity() public { + // Lazy mint tokens: 3 different tiers + vm.startPrank(dropAdmin); + + // Tier 1: tokenIds assigned 0 -> 10 non-inclusive. + tieredDrop.lazyMint(quantityTier1, baseURITier1, tier1, ""); + // Tier 2: tokenIds assigned 10 -> 30 non-inclusive. + tieredDrop.lazyMint(quantityTier2, baseURITier2, tier2, ""); + // Tier 3: tokenIds assigned 30 -> 60 non-inclusive. + tieredDrop.lazyMint(quantityTier3, baseURITier3, tier3, ""); + + vm.stopPrank(); + + /** + * Claim tokens. + * - Order of priority: [tier2, tier1] + * - Total quantity: 25. [20 from tier2, 5 from tier1] + */ + + string[] memory tiers = new string[](2); + tiers[0] = tier2; + tiers[1] = tier1; + + uint256 claimQuantity = 0; + + _setupClaimSignature(tiers, claimQuantity); + + assertEq(tieredDrop.hasRole(keccak256("MINTER_ROLE"), deployerSigner), true); + + vm.warp(claimRequest.validityStartTimestamp); + vm.prank(claimer); + vm.expectRevert("0 qty"); + tieredDrop.claimWithSignature(claimRequest, claimSignature); + } + + function test_revert_claimWithSignature_notEnoughLazyMintedTokens() public { + // Lazy mint tokens: 3 different tiers + vm.startPrank(dropAdmin); + + // Tier 1: tokenIds assigned 0 -> 10 non-inclusive. + tieredDrop.lazyMint(quantityTier1, baseURITier1, tier1, ""); + // Tier 2: tokenIds assigned 10 -> 30 non-inclusive. + tieredDrop.lazyMint(quantityTier2, baseURITier2, tier2, ""); + // Tier 3: tokenIds assigned 30 -> 60 non-inclusive. + tieredDrop.lazyMint(quantityTier3, baseURITier3, tier3, ""); + + vm.stopPrank(); + + /** + * Claim tokens. + * - Order of priority: [tier2, tier1] + * - Total quantity: 25. [20 from tier2, 5 from tier1] + */ + + string[] memory tiers = new string[](2); + tiers[0] = tier2; + tiers[1] = tier1; + + uint256 claimQuantity = quantityTier1 + quantityTier2 + quantityTier3 + 1; + + _setupClaimSignature(tiers, claimQuantity); + + assertEq(tieredDrop.hasRole(keccak256("MINTER_ROLE"), deployerSigner), true); + + vm.warp(claimRequest.validityStartTimestamp); + vm.prank(claimer); + vm.expectRevert("!Tokens"); + tieredDrop.claimWithSignature(claimRequest, claimSignature); + } + + function test_revert_claimWithSignature_insufficientTokensInTiers() public { + // Lazy mint tokens: 3 different tiers + vm.startPrank(dropAdmin); + + // Tier 1: tokenIds assigned 0 -> 10 non-inclusive. + tieredDrop.lazyMint(quantityTier1, baseURITier1, tier1, ""); + // Tier 2: tokenIds assigned 10 -> 30 non-inclusive. + tieredDrop.lazyMint(quantityTier2, baseURITier2, tier2, ""); + // Tier 3: tokenIds assigned 30 -> 60 non-inclusive. + tieredDrop.lazyMint(quantityTier3, baseURITier3, tier3, ""); + + vm.stopPrank(); + + /** + * Claim tokens. + * - Order of priority: [tier2, tier1] + * - Total quantity: 25. [20 from tier2, 5 from tier1] + */ + + string[] memory tiers = new string[](2); + tiers[0] = "non-exsitent tier 1"; + tiers[1] = "non-exsitent tier 2"; + + uint256 claimQuantity = 25; + + _setupClaimSignature(tiers, claimQuantity); + + assertEq(tieredDrop.hasRole(keccak256("MINTER_ROLE"), deployerSigner), true); + + vm.warp(claimRequest.validityStartTimestamp); + vm.prank(claimer); + vm.expectRevert("Insufficient tokens in tiers."); + tieredDrop.claimWithSignature(claimRequest, claimSignature); + } + + //////////////////////////////////////////////// + // // + // reveal tests // + // // + //////////////////////////////////////////////// + + function _getProvenanceHash(string memory _revealURI, bytes memory _key) private view returns (bytes32) { + return keccak256(abi.encodePacked(_revealURI, _key, block.chainid)); + } + + function test_state_revealWithScrambleOffset() public { + // Lazy mint tokens: 3 different tiers: with delayed reveal + bytes memory encryptedURITier1 = tieredDrop.encryptDecrypt(bytes(baseURITier1), keyTier1); + bytes memory encryptedURITier2 = tieredDrop.encryptDecrypt(bytes(baseURITier2), keyTier2); + bytes memory encryptedURITier3 = tieredDrop.encryptDecrypt(bytes(baseURITier3), keyTier3); + + vm.startPrank(dropAdmin); + + // Tier 1: tokenIds assigned 0 -> 10 non-inclusive. + tieredDrop.lazyMint( + quantityTier1, + placeholderURITier1, + tier1, + abi.encode(encryptedURITier1, _getProvenanceHash(baseURITier1, keyTier1)) + ); + // Tier 2: tokenIds assigned 10 -> 30 non-inclusive. + tieredDrop.lazyMint( + quantityTier2, + placeholderURITier2, + tier2, + abi.encode(encryptedURITier2, _getProvenanceHash(baseURITier2, keyTier2)) + ); + // Tier 3: tokenIds assigned 30 -> 60 non-inclusive. + tieredDrop.lazyMint( + quantityTier3, + placeholderURITier3, + tier3, + abi.encode(encryptedURITier3, _getProvenanceHash(baseURITier3, keyTier3)) + ); + + vm.stopPrank(); + + /** + * Claim tokens. + * - Order of priority: [tier2, tier1] + * - Total quantity: 25. [20 from tier2, 5 from tier1] + */ + + string[] memory tiers = new string[](2); + tiers[0] = tier2; + tiers[1] = tier1; + + uint256 claimQuantity = 25; + + _setupClaimSignature(tiers, claimQuantity); + + assertEq(tieredDrop.hasRole(keccak256("MINTER_ROLE"), deployerSigner), true); + + vm.warp(claimRequest.validityStartTimestamp); + vm.prank(claimer); + tieredDrop.claimWithSignature(claimRequest, claimSignature); + + /** + * Check token URIs for tokens of tiers: + * - Tier 2: token IDs 0 -> 19 mapped one-to-one to metadata IDs 10 -> 29 + * - Tier 1: token IDs 20 -> 24 mapped one-to-one to metadata IDs 0 -> 4 + */ + + uint256 tier2Id = 10; + uint256 tier1Id = 0; + + for (uint256 i = 0; i < claimQuantity; i += 1) { + // console.log(i); + if (i < 20) { + assertEq(tieredDrop.tokenURI(i), string(abi.encodePacked(placeholderURITier2, uint256(0).toString()))); + tier2Id += 1; + } else { + assertEq(tieredDrop.tokenURI(i), string(abi.encodePacked(placeholderURITier1, uint256(0).toString()))); + tier1Id += 1; + } + } + + // Reveal tokens. + vm.startPrank(dropAdmin); + tieredDrop.reveal(0, keyTier1); + tieredDrop.reveal(1, keyTier2); + tieredDrop.reveal(2, keyTier3); + + uint256 tier2IdStart = 10; + uint256 tier2IdEnd = 30; + + uint256 tier1IdStart = 0; + uint256 tier1IdEnd = 10; + + for (uint256 i = 0; i < claimQuantity; i += 1) { + bytes32 tokenURIHash = keccak256(abi.encodePacked(tieredDrop.tokenURI(i))); + bool detected = false; + + if (i < 20) { + for (uint256 j = tier2IdStart; j < tier2IdEnd; j += 1) { + bytes32 expectedURIHash = keccak256(abi.encodePacked(baseURITier2, j.toString())); + + if (tokenURIHash == expectedURIHash) { + detected = true; + } + + if (detected) { + break; + } + } + } else { + for (uint256 k = tier1IdStart; k < tier1IdEnd; k += 1) { + bytes32 expectedURIHash = keccak256(abi.encodePacked(baseURITier1, k.toString())); + + if (tokenURIHash == expectedURIHash) { + detected = true; + } + + if (detected) { + break; + } + } + } + + assertEq(detected, true); + } + } + + event URIReveal(uint256 tokenId, string uri); + + //////////////////////////////////////////////// + // // + // getTokensInTierLen tests // + // // + //////////////////////////////////////////////// + + function test_state_getTokensInTierLen() public { + // Lazy mint tokens: 3 different tiers + vm.startPrank(dropAdmin); + + // Tier 1: tokenIds assigned 0 -> 10 non-inclusive. + tieredDrop.lazyMint(quantityTier1, baseURITier1, tier1, ""); + // Tier 2: tokenIds assigned 10 -> 30 non-inclusive. + tieredDrop.lazyMint(quantityTier2, baseURITier2, tier2, ""); + // Tier 3: tokenIds assigned 30 -> 60 non-inclusive. + tieredDrop.lazyMint(quantityTier3, baseURITier3, tier3, ""); + + vm.stopPrank(); + + /** + * Claim tokens. + * - Order of priority: [tier2, tier1] + * - Total quantity: 25. [20 from tier2, 5 from tier1] + */ + + string[] memory tiers = new string[](2); + tiers[0] = tier2; + tiers[1] = tier1; + + uint256 claimQuantity = 25; + + _setupClaimSignature(tiers, claimQuantity); + + vm.warp(claimRequest.validityStartTimestamp); + + vm.prank(claimer); + tieredDrop.claimWithSignature(claimRequest, claimSignature); + + assertEq(tieredDrop.getTokensInTierLen(), 2); + + for (uint256 i = 0; i < 5; i += 1) { + _setupClaimSignature(tiers, 1); + + vm.warp(claimRequest.validityStartTimestamp); + + vm.prank(claimer); + tieredDrop.claimWithSignature(claimRequest, claimSignature); + } + + assertEq(tieredDrop.getTokensInTierLen(), 7); + } + + //////////////////////////////////////////////// + // // + // getTokensInTier tests // + // // + //////////////////////////////////////////////// + + function test_state_getTokensInTier() public { + // Lazy mint tokens: 3 different tiers + vm.startPrank(dropAdmin); + + // Tier 1: tokenIds assigned 0 -> 10 non-inclusive. + tieredDrop.lazyMint(quantityTier1, baseURITier1, tier1, ""); + // Tier 2: tokenIds assigned 10 -> 30 non-inclusive. + tieredDrop.lazyMint(quantityTier2, baseURITier2, tier2, ""); + // Tier 3: tokenIds assigned 30 -> 60 non-inclusive. + tieredDrop.lazyMint(quantityTier3, baseURITier3, tier3, ""); + + vm.stopPrank(); + + /** + * Claim tokens. + * - Order of priority: [tier2, tier1] + * - Total quantity: 25. [20 from tier2, 5 from tier1] + */ + + string[] memory tiers = new string[](2); + tiers[0] = tier2; + tiers[1] = tier1; + + uint256 claimQuantity = 25; + + _setupClaimSignature(tiers, claimQuantity); + + vm.warp(claimRequest.validityStartTimestamp); + + vm.prank(claimer); + tieredDrop.claimWithSignature(claimRequest, claimSignature); + + TieredDrop.TokenRange[] memory rangesTier1 = tieredDrop.getTokensInTier(tier1, 0, 2); + assertEq(rangesTier1.length, 1); + + TieredDrop.TokenRange[] memory rangesTier2 = tieredDrop.getTokensInTier(tier2, 0, 2); + assertEq(rangesTier2.length, 1); + + assertEq(rangesTier1[0].startIdInclusive, 20); + assertEq(rangesTier1[0].endIdNonInclusive, 25); + assertEq(rangesTier2[0].startIdInclusive, 0); + assertEq(rangesTier2[0].endIdNonInclusive, 20); + } + + //////////////////////////////////////////////// + // // + // getTierForToken tests // + // // + //////////////////////////////////////////////// + + function test_state_getTierForToken() public { + // Lazy mint tokens: 3 different tiers + vm.startPrank(dropAdmin); + + // Tier 1: tokenIds assigned 0 -> 10 non-inclusive. + tieredDrop.lazyMint(quantityTier1, baseURITier1, tier1, ""); + // Tier 2: tokenIds assigned 10 -> 30 non-inclusive. + tieredDrop.lazyMint(quantityTier2, baseURITier2, tier2, ""); + // Tier 3: tokenIds assigned 30 -> 60 non-inclusive. + tieredDrop.lazyMint(quantityTier3, baseURITier3, tier3, ""); + + vm.stopPrank(); + + /** + * Claim tokens. + * - Order of priority: [tier2, tier1] + * - Total quantity: 25. [20 from tier2, 5 from tier1] + */ + + string[] memory tiers = new string[](2); + tiers[0] = tier2; + tiers[1] = tier1; + + uint256 claimQuantity = 25; + + _setupClaimSignature(tiers, claimQuantity); + + vm.warp(claimRequest.validityStartTimestamp); + + vm.prank(claimer); + tieredDrop.claimWithSignature(claimRequest, claimSignature); + + /** + * Check token URIs for tokens of tiers: + * - Tier 2: token IDs 0 -> 19 mapped one-to-one to metadata IDs 10 -> 29 + * - Tier 1: token IDs 20 -> 24 mapped one-to-one to metadata IDs 0 -> 4 + */ + + uint256 tier2Id = 10; + uint256 tier1Id = 0; + + for (uint256 i = 0; i < claimQuantity; i += 1) { + if (i < 20) { + string memory tierForToken = tieredDrop.getTierForToken(i); + assertEq(tierForToken, tier2); + + tier2Id += 1; + } else { + string memory tierForToken = tieredDrop.getTierForToken(i); + assertEq(tierForToken, tier1); + + tier1Id += 1; + } + } + } + + //////////////////////////////////////////////// + // // + // getMetadataForAllTiers tests // + // // + //////////////////////////////////////////////// + + // function test_state_getMetadataForAllTiers() public { + // // Lazy mint tokens: 3 different tiers + // vm.startPrank(dropAdmin); + + // // Tier 1: tokenIds assigned 0 -> 10 non-inclusive. + // tieredDrop.lazyMint(quantityTier1, baseURITier1, tier1, ""); + // // Tier 2: tokenIds assigned 10 -> 30 non-inclusive. + // tieredDrop.lazyMint(quantityTier2, baseURITier2, tier2, ""); + // // Tier 3: tokenIds assigned 30 -> 60 non-inclusive. + // tieredDrop.lazyMint(quantityTier3, baseURITier3, tier3, ""); + + // vm.stopPrank(); + + // TieredDrop.TierMetadata[] memory metadataForAllTiers = tieredDrop.getMetadataForAllTiers(); + + // // Tier 1 + // assertEq(metadataForAllTiers[0].tier, tier1); + + // TieredDrop.TokenRange[] memory ranges1 = metadataForAllTiers[0].ranges; + // assertEq(ranges1.length, 1); + // assertEq(ranges1[0].startIdInclusive, 0); + // assertEq(ranges1[0].endIdNonInclusive, 10); + + // string[] memory baseURIs1 = metadataForAllTiers[0].baseURIs; + // assertEq(baseURIs1.length, 1); + // assertEq(baseURIs1[0], baseURITier1); + + // // Tier 2 + // assertEq(metadataForAllTiers[1].tier, tier2); + + // TieredDrop.TokenRange[] memory ranges2 = metadataForAllTiers[1].ranges; + // assertEq(ranges2.length, 1); + // assertEq(ranges2[0].startIdInclusive, 10); + // assertEq(ranges2[0].endIdNonInclusive, 30); + + // string[] memory baseURIs2 = metadataForAllTiers[1].baseURIs; + // assertEq(baseURIs2.length, 1); + // assertEq(baseURIs2[0], baseURITier2); + + // // Tier 3 + // assertEq(metadataForAllTiers[2].tier, tier3); + + // TieredDrop.TokenRange[] memory ranges3 = metadataForAllTiers[2].ranges; + // assertEq(ranges3.length, 1); + // assertEq(ranges3[0].startIdInclusive, 30); + // assertEq(ranges3[0].endIdNonInclusive, 60); + + // string[] memory baseURIs3 = metadataForAllTiers[2].baseURIs; + // assertEq(baseURIs3.length, 1); + // assertEq(baseURIs3[0], baseURITier3); + // } + + //////////////////////////////////////////////// + // // + // audit tests // + // // + //////////////////////////////////////////////// + + function test_state_claimWithSignature_IssueH1() public { + // Lazy mint tokens: 3 different tiers + vm.startPrank(dropAdmin); + + // Tier 1: tokenIds assigned 0 -> 10 non-inclusive. + tieredDrop.lazyMint(quantityTier1, baseURITier1, tier1, ""); + // Tier 2: tokenIds assigned 10 -> 20 non-inclusive. + tieredDrop.lazyMint(10, baseURITier2, tier2, ""); + // Tier 3: tokenIds assigned 20 -> 50 non-inclusive. + tieredDrop.lazyMint(quantityTier3, baseURITier3, tier3, ""); + + // Tier 2: tokenIds assigned 50 -> 60 non-inclusive. + tieredDrop.lazyMint(quantityTier2 - 10, baseURITier2, tier2, ""); + + vm.stopPrank(); + + string[] memory tiers = new string[](2); + tiers[0] = tier2; + tiers[1] = tier1; + + uint256 claimQuantity = 25; + + _setupClaimSignature(tiers, claimQuantity); + + assertEq(tieredDrop.hasRole(keccak256("MINTER_ROLE"), deployerSigner), true); + + vm.warp(claimRequest.validityStartTimestamp); + vm.prank(claimer); + tieredDrop.claimWithSignature(claimRequest, claimSignature); + assertEq(tieredDrop.balanceOf(claimer), claimQuantity); + + for (uint256 i = 0; i < claimQuantity; i += 1) { + // Outputs: + // Checking 0 baseURI2/10 + // Checking 1 baseURI2/11 + // Checking 2 baseURI2/12 + // Checking 3 baseURI2/13 + // Checking 4 baseURI2/14 + // Checking 5 baseURI2/15 + // Checking 6 baseURI2/16 + // Checking 7 baseURI2/17 + // Checking 8 baseURI2/18 + // Checking 9 baseURI2/19 + // Checking 10 baseURI3/50 + // Checking 11 baseURI3/51 + // Checking 12 baseURI3/52 + // Checking 13 baseURI3/53 + // Checking 14 baseURI3/54 + // Checking 15 baseURI3/55 + // Checking 16 baseURI3/56 + // Checking 17 baseURI3/57 + // Checking 18 baseURI3/58 + // Checking 19 baseURI3/59 + // Checking 20 baseURI1/0 + // Checking 21 baseURI1/1 + // Checking 22 baseURI1/2 + // Checking 23 baseURI1/3 + // Checking 24 baseURI1/4 + console.log("Checking", i, tieredDrop.tokenURI(i)); + } + } + + function test_state_claimWithSignature_IssueH1_2() public { + // Lazy mint tokens: 3 different tiers + vm.startPrank(dropAdmin); + + // Tier 1: tokenIds assigned 0 -> 10 non-inclusive. + tieredDrop.lazyMint(quantityTier1, baseURITier1, tier1, ""); + // Tier 2: tokenIds assigned 10 -> 20 non-inclusive. + tieredDrop.lazyMint(1, baseURITier2, tier2, ""); // 10 -> 11 + tieredDrop.lazyMint(9, baseURITier2, tier2, ""); // 11 -> 20 + // Tier 3: tokenIds assigned 20 -> 50 non-inclusive. + tieredDrop.lazyMint(quantityTier3, baseURITier3, tier3, ""); + + // Tier 2: tokenIds assigned 50 -> 60 non-inclusive. + tieredDrop.lazyMint(quantityTier2 - 10, baseURITier2, tier2, ""); + + vm.stopPrank(); + + string[] memory tiers = new string[](2); + tiers[0] = tier2; + tiers[1] = tier1; + + uint256[3] memory claimQuantities = [uint256(1), uint256(3), uint256(21)]; + uint256 claimedCount = 0; + for (uint256 loop = 0; loop < 3; loop++) { + uint256 claimQuantity = claimQuantities[loop]; + uint256 offset = claimedCount; + + _setupClaimSignature(tiers, claimQuantity); + + assertEq(tieredDrop.hasRole(keccak256("MINTER_ROLE"), deployerSigner), true); + + vm.warp(claimRequest.validityStartTimestamp); + vm.prank(claimer); + tieredDrop.claimWithSignature(claimRequest, claimSignature); + + claimedCount += claimQuantity; + assertEq(tieredDrop.balanceOf(claimer), claimedCount); + + for (uint256 i = offset; i < claimQuantity + (offset); i += 1) { + // Outputs: + // Checking 0 baseURI2/10 + // Checking 1 baseURI2/11 + // Checking 2 baseURI2/12 + // Checking 3 baseURI2/13 + // Checking 4 baseURI2/14 + // Checking 5 baseURI2/15 + // Checking 6 baseURI2/16 + // Checking 7 baseURI2/17 + // Checking 8 baseURI2/18 + // Checking 9 baseURI2/19 + // Checking 10 baseURI3/50 + // Checking 11 baseURI3/51 + // Checking 12 baseURI3/52 + // Checking 13 baseURI3/53 + // Checking 14 baseURI3/54 + // Checking 15 baseURI3/55 + // Checking 16 baseURI3/56 + // Checking 17 baseURI3/57 + // Checking 18 baseURI3/58 + // Checking 19 baseURI3/59 + // Checking 20 baseURI1/0 + // Checking 21 baseURI1/1 + // Checking 22 baseURI1/2 + // Checking 23 baseURI1/3 + // Checking 24 baseURI1/4 + console.log("Checking", i, tieredDrop.tokenURI(i)); + } + } + } +} + +contract TieredDropBechmarkTest is BaseTest { + using Strings for uint256; + + TieredDrop public tieredDrop; + + address internal dropAdmin; + address internal claimer; + + // Signature params + address internal deployerSigner; + bytes32 internal typehashGenericRequest; + bytes32 internal nameHash; + bytes32 internal versionHash; + bytes32 internal typehashEip712; + bytes32 internal domainSeparator; + + // Lazy mint variables + uint256 internal quantityTier1 = 10; + string internal tier1 = "tier1"; + string internal baseURITier1 = "baseURI1/"; + string internal placeholderURITier1 = "placeholderURI1/"; + bytes internal keyTier1 = "tier1_key"; + + uint256 internal quantityTier2 = 20; + string internal tier2 = "tier2"; + string internal baseURITier2 = "baseURI2/"; + string internal placeholderURITier2 = "placeholderURI2/"; + bytes internal keyTier2 = "tier2_key"; + + uint256 internal quantityTier3 = 30; + string internal tier3 = "tier3"; + string internal baseURITier3 = "baseURI3/"; + string internal placeholderURITier3 = "placeholderURI3/"; + bytes internal keyTier3 = "tier3_key"; + + function setUp() public virtual override { + super.setUp(); + + dropAdmin = getActor(1); + claimer = getActor(2); + + // Deploy implementation. + address tieredDropImpl = address(new TieredDrop()); + + // Deploy proxy pointing to implementaion. + vm.prank(dropAdmin); + tieredDrop = TieredDrop( + address( + new TWProxy( + tieredDropImpl, + abi.encodeCall( + TieredDrop.initialize, + (dropAdmin, "Tiered Drop", "TD", "ipfs://", new address[](0), dropAdmin, dropAdmin, 0) + ) + ) + ) + ); + + // ====== signature params + + deployerSigner = signer; + vm.prank(dropAdmin); + tieredDrop.grantRole(keccak256("MINTER_ROLE"), deployerSigner); + + typehashGenericRequest = keccak256( + "GenericRequest(uint128 validityStartTimestamp,uint128 validityEndTimestamp,bytes32 uid,bytes data)" + ); + nameHash = keccak256(bytes("SignatureAction")); + versionHash = keccak256(bytes("1")); + typehashEip712 = keccak256( + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" + ); + domainSeparator = keccak256( + abi.encode(typehashEip712, nameHash, versionHash, block.chainid, address(tieredDrop)) + ); + + // ====== + + // Lazy mint tokens: 3 different tiers + vm.startPrank(dropAdmin); + + // Tier 1: tokenIds assigned 0 -> 10 non-inclusive. + tieredDrop.lazyMint(totalQty, baseURITier1, tier1, ""); + // Tier 2: tokenIds assigned 10 -> 30 non-inclusive. + tieredDrop.lazyMint(totalQty, baseURITier2, tier2, ""); + + vm.stopPrank(); + + /** + * Claim tokens. + * - Order of priority: [tier2, tier1] + * - Total quantity: 25. [20 from tier2, 5 from tier1] + */ + + string[] memory tiers = new string[](2); + tiers[0] = tier2; + tiers[1] = tier1; + + uint256 claimQuantity = totalQty; + + for (uint256 i = 0; i < claimQuantity; i += 1) { + _setupClaimSignature(tiers, 1); + + vm.warp(claimRequest.validityStartTimestamp); + + vm.prank(claimer); + tieredDrop.claimWithSignature(claimRequest, claimSignature); + } + } + + TieredDrop.GenericRequest internal claimRequest; + bytes internal claimSignature; + + uint256 internal nonce; + + function _setupClaimSignature(string[] memory _orderedTiers, uint256 _totalQuantity) internal { + claimRequest.validityStartTimestamp = 1000; + claimRequest.validityEndTimestamp = 2000; + claimRequest.uid = keccak256(abi.encodePacked(nonce)); + nonce += 1; + claimRequest.data = abi.encode( + _orderedTiers, + claimer, + address(0), + 0, + dropAdmin, + _totalQuantity, + 0, + NATIVE_TOKEN + ); + + bytes memory encodedRequest = abi.encode( + typehashGenericRequest, + claimRequest.validityStartTimestamp, + claimRequest.validityEndTimestamp, + claimRequest.uid, + keccak256(bytes(claimRequest.data)) + ); + + bytes32 structHash = keccak256(encodedRequest); + bytes32 typedDataHash = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, typedDataHash); + claimSignature = abi.encodePacked(r, s, v); + } + + // What does it take to exhaust the 550mil RPC view fn gas limit ? + + // 10_000: 67 mil gas (67,536,754) + uint256 internal totalQty = 10_000; + + function test_banchmark_getTokensInTier() public view { + tieredDrop.getTokensInTier(tier1, 0, totalQty); + } + + function test_banchmark_getTokensInTier_ten() public view { + tieredDrop.getTokensInTier(tier1, 0, 10); + } + + function test_banchmark_getTokensInTier_hundred() public view { + tieredDrop.getTokensInTier(tier1, 0, 100); + } +} diff --git a/src/test/airdrop/Airdrop.t.sol b/src/test/airdrop/Airdrop.t.sol new file mode 100644 index 000000000..0d0ebbf60 --- /dev/null +++ b/src/test/airdrop/Airdrop.t.sol @@ -0,0 +1,1065 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { Airdrop, SafeTransferLib, ECDSA } from "contracts/prebuilts/airdrop/Airdrop.sol"; + +// Test imports +import { TWProxy } from "contracts/infra/TWProxy.sol"; +import "../utils/BaseTest.sol"; + +contract MockSmartWallet { + using ECDSA for bytes32; + + bytes4 private constant EIP1271_MAGIC_VALUE = 0x1626ba7e; + address private admin; + + constructor(address _admin) { + admin = _admin; + } + + function isValidSignature(bytes32 _hash, bytes memory _signature) public view returns (bytes4) { + if (_hash.recover(_signature) == admin) { + return EIP1271_MAGIC_VALUE; + } + } + + function onERC721Received(address, address, uint256, bytes memory) external pure returns (bytes4) { + return this.onERC721Received.selector; + } + + function onERC1155Received(address, address, uint256, uint256, bytes memory) external pure returns (bytes4) { + return this.onERC1155Received.selector; + } + + function onERC1155BatchReceived( + address, + address, + uint256[] memory, + uint256[] memory, + bytes memory + ) external pure returns (bytes4) { + return this.onERC1155BatchReceived.selector; + } +} + +contract AirdropTest is BaseTest { + Airdrop internal airdrop; + MockSmartWallet internal mockSmartWallet; + + bytes32 private constant CONTENT_TYPEHASH_ERC20 = + keccak256("AirdropContentERC20(address recipient,uint256 amount)"); + bytes32 private constant REQUEST_TYPEHASH_ERC20 = + keccak256( + "AirdropRequestERC20(bytes32 uid,address tokenAddress,uint256 expirationTimestamp,AirdropContentERC20[] contents)AirdropContentERC20(address recipient,uint256 amount)" + ); + + bytes32 private constant CONTENT_TYPEHASH_ERC721 = + keccak256("AirdropContentERC721(address recipient,uint256 tokenId)"); + bytes32 private constant REQUEST_TYPEHASH_ERC721 = + keccak256( + "AirdropRequestERC721(bytes32 uid,address tokenAddress,uint256 expirationTimestamp,AirdropContentERC721[] contents)AirdropContentERC721(address recipient,uint256 tokenId)" + ); + + bytes32 private constant CONTENT_TYPEHASH_ERC1155 = + keccak256("AirdropContentERC1155(address recipient,uint256 tokenId,uint256 amount)"); + bytes32 private constant REQUEST_TYPEHASH_ERC1155 = + keccak256( + "AirdropRequestERC1155(bytes32 uid,address tokenAddress,uint256 expirationTimestamp,AirdropContentERC1155[] contents)AirdropContentERC1155(address recipient,uint256 tokenId,uint256 amount)" + ); + + bytes32 private constant NAME_HASH = keccak256(bytes("Airdrop")); + bytes32 private constant VERSION_HASH = keccak256(bytes("1")); + bytes32 private constant TYPE_HASH_EIP712 = + keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"); + + bytes32 internal domainSeparator; + + function setUp() public override { + super.setUp(); + + address impl = address(new Airdrop()); + + airdrop = Airdrop(payable(address(new TWProxy(impl, abi.encodeCall(Airdrop.initialize, (signer, "")))))); + + domainSeparator = keccak256( + abi.encode(TYPE_HASH_EIP712, NAME_HASH, VERSION_HASH, block.chainid, address(airdrop)) + ); + + mockSmartWallet = new MockSmartWallet(signer); + } + + function _getContentsERC20(uint256 length) internal pure returns (Airdrop.AirdropContentERC20[] memory contents) { + contents = new Airdrop.AirdropContentERC20[](length); + for (uint256 i = 0; i < length; i++) { + contents[i].recipient = address(uint160(i + 10)); + contents[i].amount = i + 10; + } + } + + function _getContentsERC721(uint256 length) internal pure returns (Airdrop.AirdropContentERC721[] memory contents) { + contents = new Airdrop.AirdropContentERC721[](length); + for (uint256 i = 0; i < length; i++) { + contents[i].recipient = address(uint160(i + 10)); + contents[i].tokenId = i; + } + } + + function _getContentsERC1155( + uint256 length + ) internal pure returns (Airdrop.AirdropContentERC1155[] memory contents) { + contents = new Airdrop.AirdropContentERC1155[](length); + for (uint256 i = 0; i < length; i++) { + contents[i].recipient = address(uint160(i + 10)); + contents[i].tokenId = 0; + contents[i].amount = i + 10; + } + } + + function _signReqERC20( + Airdrop.AirdropRequestERC20 memory req, + uint256 privateKey + ) internal view returns (bytes memory signature) { + bytes32[] memory contentHashes = new bytes32[](req.contents.length); + for (uint i = 0; i < req.contents.length; i++) { + contentHashes[i] = keccak256( + abi.encode(CONTENT_TYPEHASH_ERC20, req.contents[i].recipient, req.contents[i].amount) + ); + } + bytes32 contentHash = keccak256(abi.encodePacked(contentHashes)); + + bytes memory dataToHash; + { + dataToHash = abi.encode( + REQUEST_TYPEHASH_ERC20, + req.uid, + req.tokenAddress, + req.expirationTimestamp, + contentHash + ); + } + + { + bytes32 _structHash = keccak256(dataToHash); + bytes32 typedDataHash = keccak256(abi.encodePacked("\x19\x01", domainSeparator, _structHash)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, typedDataHash); + + signature = abi.encodePacked(r, s, v); + } + } + + function _signReqERC721( + Airdrop.AirdropRequestERC721 memory req, + uint256 privateKey + ) internal view returns (bytes memory signature) { + bytes32[] memory contentHashes = new bytes32[](req.contents.length); + for (uint i = 0; i < req.contents.length; i++) { + contentHashes[i] = keccak256( + abi.encode(CONTENT_TYPEHASH_ERC721, req.contents[i].recipient, req.contents[i].tokenId) + ); + } + bytes32 contentHash = keccak256(abi.encodePacked(contentHashes)); + + bytes memory dataToHash; + { + dataToHash = abi.encode( + REQUEST_TYPEHASH_ERC721, + req.uid, + req.tokenAddress, + req.expirationTimestamp, + contentHash + ); + } + + { + bytes32 _structHash = keccak256(dataToHash); + bytes32 typedDataHash = keccak256(abi.encodePacked("\x19\x01", domainSeparator, _structHash)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, typedDataHash); + + signature = abi.encodePacked(r, s, v); + } + } + + function _signReqERC1155( + Airdrop.AirdropRequestERC1155 memory req, + uint256 privateKey + ) internal view returns (bytes memory signature) { + bytes32[] memory contentHashes = new bytes32[](req.contents.length); + for (uint i = 0; i < req.contents.length; i++) { + contentHashes[i] = keccak256( + abi.encode( + CONTENT_TYPEHASH_ERC1155, + req.contents[i].recipient, + req.contents[i].tokenId, + req.contents[i].amount + ) + ); + } + bytes32 contentHash = keccak256(abi.encodePacked(contentHashes)); + + bytes memory dataToHash; + { + dataToHash = abi.encode( + REQUEST_TYPEHASH_ERC1155, + req.uid, + req.tokenAddress, + req.expirationTimestamp, + contentHash + ); + } + + { + bytes32 _structHash = keccak256(dataToHash); + bytes32 typedDataHash = keccak256(abi.encodePacked("\x19\x01", domainSeparator, _structHash)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, typedDataHash); + + signature = abi.encodePacked(r, s, v); + } + } + + /*/////////////////////////////////////////////////////////////// + Benchmark: Airdrop Push ERC20 + //////////////////////////////////////////////////////////////*/ + + function test_state_airdropPush_erc20() public { + erc20.mint(signer, 100 ether); + vm.prank(signer); + erc20.approve(address(airdrop), 100 ether); + + Airdrop.AirdropContentERC20[] memory contents = _getContentsERC20(10); + + vm.prank(signer); + + airdrop.airdropERC20(address(erc20), contents); + + uint256 totalAmount; + for (uint256 i = 0; i < contents.length; i++) { + totalAmount += contents[i].amount; + assertEq(erc20.balanceOf(contents[i].recipient), contents[i].amount); + } + assertEq(erc20.balanceOf(signer), 100 ether - totalAmount); + } + + function test_state_airdropPush_nativeToken() public { + vm.deal(signer, 100 ether); + + Airdrop.AirdropContentERC20[] memory contents = _getContentsERC20(10); + + uint256 totalAmount; + for (uint256 i = 0; i < contents.length; i++) { + totalAmount += contents[i].amount; + assertEq(contents[i].recipient.balance, 0); + } + + vm.prank(signer); + airdrop.airdropNativeToken{ value: totalAmount }(contents); + + for (uint256 i = 0; i < contents.length; i++) { + assertEq(contents[i].recipient.balance, contents[i].amount); + } + + assertEq(signer.balance, 100 ether - totalAmount); + } + + function test_revert_airdropPush_nativeToken() public { + vm.deal(signer, 100 ether); + + Airdrop.AirdropContentERC20[] memory contents = _getContentsERC20(10); + + vm.prank(signer); + vm.expectRevert(abi.encodeWithSelector(SafeTransferLib.ETHTransferFailed.selector)); + airdrop.airdropNativeToken{ value: 0 }(contents); + } + + /*/////////////////////////////////////////////////////////////// + Benchmark: Airdrop Signature ERC20 + //////////////////////////////////////////////////////////////*/ + + function test_state_airdropSignature_erc20() public { + erc20.mint(signer, 100 ether); + vm.prank(signer); + erc20.approve(address(airdrop), 100 ether); + + Airdrop.AirdropContentERC20[] memory contents = _getContentsERC20(10); + Airdrop.AirdropRequestERC20 memory req = Airdrop.AirdropRequestERC20({ + uid: bytes32(uint256(1)), + tokenAddress: address(erc20), + expirationTimestamp: 1000, + contents: contents + }); + bytes memory signature = _signReqERC20(req, privateKey); + + vm.prank(signer); + + airdrop.airdropERC20WithSignature(req, signature); + + uint256 totalAmount; + for (uint256 i = 0; i < contents.length; i++) { + totalAmount += contents[i].amount; + assertEq(erc20.balanceOf(contents[i].recipient), contents[i].amount); + } + assertEq(erc20.balanceOf(signer), 100 ether - totalAmount); + } + + function test_state_airdropSignature_erc20_eip1271() public { + // set mockSmartWallet as contract owner + vm.prank(signer); + airdrop.setOwner(address(mockSmartWallet)); + + // mint tokens to mockSmartWallet + erc20.mint(address(mockSmartWallet), 100 ether); + vm.prank(address(mockSmartWallet)); + erc20.approve(address(airdrop), 100 ether); + + Airdrop.AirdropContentERC20[] memory contents = _getContentsERC20(10); + Airdrop.AirdropRequestERC20 memory req = Airdrop.AirdropRequestERC20({ + uid: bytes32(uint256(1)), + tokenAddress: address(erc20), + expirationTimestamp: 1000, + contents: contents + }); + + // sign with original EOA signer private key + bytes memory signature = _signReqERC20(req, privateKey); + + airdrop.airdropERC20WithSignature(req, signature); + + uint256 totalAmount; + for (uint256 i = 0; i < contents.length; i++) { + totalAmount += contents[i].amount; + assertEq(erc20.balanceOf(contents[i].recipient), contents[i].amount); + } + assertEq(erc20.balanceOf(address(mockSmartWallet)), 100 ether - totalAmount); + } + + function test_revert_airdropSignature_erc20_eip1271_invalidSignature() public { + // set mockSmartWallet as contract owner + vm.prank(signer); + airdrop.setOwner(address(mockSmartWallet)); + + // mint tokens to mockSmartWallet + erc20.mint(address(mockSmartWallet), 100 ether); + vm.prank(address(mockSmartWallet)); + erc20.approve(address(airdrop), 100 ether); + + Airdrop.AirdropContentERC20[] memory contents = _getContentsERC20(10); + Airdrop.AirdropRequestERC20 memory req = Airdrop.AirdropRequestERC20({ + uid: bytes32(uint256(1)), + tokenAddress: address(erc20), + expirationTimestamp: 1000, + contents: contents + }); + + // sign with random private key + bytes memory signature = _signReqERC20(req, 123); + + vm.expectRevert(abi.encodeWithSelector(Airdrop.AirdropRequestInvalidSigner.selector)); + airdrop.airdropERC20WithSignature(req, signature); + } + + function test_revert_airdropSignature_erc20_expired() public { + erc20.mint(signer, 100 ether); + vm.prank(signer); + erc20.approve(address(airdrop), 100 ether); + + Airdrop.AirdropContentERC20[] memory contents = _getContentsERC20(10); + Airdrop.AirdropRequestERC20 memory req = Airdrop.AirdropRequestERC20({ + uid: bytes32(uint256(1)), + tokenAddress: address(erc20), + expirationTimestamp: 1000, + contents: contents + }); + bytes memory signature = _signReqERC20(req, privateKey); + + vm.warp(1001); + + vm.prank(signer); + vm.expectRevert(abi.encodeWithSelector(Airdrop.AirdropRequestExpired.selector, req.expirationTimestamp)); + airdrop.airdropERC20WithSignature(req, signature); + } + + function test_revert_airdropSignature_erc20_alreadyProcessed() public { + erc20.mint(signer, 100 ether); + vm.prank(signer); + erc20.approve(address(airdrop), 100 ether); + + Airdrop.AirdropContentERC20[] memory contents = _getContentsERC20(10); + Airdrop.AirdropRequestERC20 memory req = Airdrop.AirdropRequestERC20({ + uid: bytes32(uint256(1)), + tokenAddress: address(erc20), + expirationTimestamp: 1000, + contents: contents + }); + bytes memory signature = _signReqERC20(req, privateKey); + + vm.prank(signer); + + airdrop.airdropERC20WithSignature(req, signature); + + // try re-sending same request/signature + vm.expectRevert(abi.encodeWithSelector(Airdrop.AirdropRequestAlreadyProcessed.selector)); + airdrop.airdropERC20WithSignature(req, signature); + } + + function test_revert_airdropSignature_erc20_invalidSigner() public { + erc20.mint(signer, 100 ether); + vm.prank(signer); + erc20.approve(address(airdrop), 100 ether); + + Airdrop.AirdropContentERC20[] memory contents = _getContentsERC20(10); + Airdrop.AirdropRequestERC20 memory req = Airdrop.AirdropRequestERC20({ + uid: bytes32(uint256(1)), + tokenAddress: address(erc20), + expirationTimestamp: 1000, + contents: contents + }); + bytes memory signature = _signReqERC20(req, 123); + + vm.prank(signer); + vm.expectRevert(abi.encodeWithSelector(Airdrop.AirdropRequestInvalidSigner.selector)); + airdrop.airdropERC20WithSignature(req, signature); + } + + /*/////////////////////////////////////////////////////////////// + Benchmark: Airdrop Claim ERC20 + //////////////////////////////////////////////////////////////*/ + + function test_state_airdropClaim_erc20() public { + erc20.mint(signer, 100 ether); + vm.prank(signer); + erc20.approve(address(airdrop), 100 ether); + + string[] memory inputs = new string[](3); + inputs[0] = "node"; + inputs[1] = "src/test/scripts/generateRootAirdrop.ts"; + inputs[2] = Strings.toString(uint256(5)); + bytes memory result = vm.ffi(inputs); + bytes32 root = abi.decode(result, (bytes32)); + + // set merkle root + vm.prank(signer); + airdrop.setMerkleRoot(address(erc20), root, true); + + // generate proof + inputs[1] = "src/test/scripts/getProofAirdrop.ts"; + result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); + uint256 quantity = 5; + + airdrop.claimERC20(address(erc20), receiver, quantity, proofs); + + assertEq(erc20.balanceOf(receiver), quantity); + assertEq(erc20.balanceOf(signer), 100 ether - quantity); + } + + function test_revert_airdropClaim_erc20_alreadyClaimed() public { + erc20.mint(signer, 100 ether); + vm.prank(signer); + erc20.approve(address(airdrop), 100 ether); + + string[] memory inputs = new string[](3); + inputs[0] = "node"; + inputs[1] = "src/test/scripts/generateRootAirdrop.ts"; + inputs[2] = Strings.toString(uint256(5)); + bytes memory result = vm.ffi(inputs); + bytes32 root = abi.decode(result, (bytes32)); + + // set merkle root + vm.prank(signer); + airdrop.setMerkleRoot(address(erc20), root, true); + + // generate proof + inputs[1] = "src/test/scripts/getProofAirdrop.ts"; + result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); + uint256 quantity = 5; + + airdrop.claimERC20(address(erc20), receiver, quantity, proofs); + + // revert when claiming again + vm.expectRevert(abi.encodeWithSelector(Airdrop.AirdropAlreadyClaimed.selector)); + airdrop.claimERC20(address(erc20), receiver, quantity, proofs); + } + + function test_revert_airdropClaim_erc20_noMerkleRoot() public { + erc20.mint(signer, 100 ether); + vm.prank(signer); + erc20.approve(address(airdrop), 100 ether); + + bytes32[] memory proofs; + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); + uint256 quantity = 5; + + // revert when claiming again + vm.expectRevert(abi.encodeWithSelector(Airdrop.AirdropNoMerkleRoot.selector)); + airdrop.claimERC20(address(erc20), receiver, quantity, proofs); + } + + function test_revert_airdropClaim_erc20_invalidProof() public { + erc20.mint(signer, 100 ether); + vm.prank(signer); + erc20.approve(address(airdrop), 100 ether); + + string[] memory inputs = new string[](3); + inputs[0] = "node"; + inputs[1] = "src/test/scripts/generateRootAirdrop.ts"; + inputs[2] = Strings.toString(uint256(5)); + bytes memory result = vm.ffi(inputs); + bytes32 root = abi.decode(result, (bytes32)); + + // set merkle root + vm.prank(signer); + airdrop.setMerkleRoot(address(erc20), root, true); + + // generate proof + inputs[1] = "src/test/scripts/getProofAirdrop.ts"; + result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + address receiver = address(0x12345); + uint256 quantity = 5; + + vm.expectRevert(abi.encodeWithSelector(Airdrop.AirdropInvalidProof.selector)); + airdrop.claimERC20(address(erc20), receiver, quantity, proofs); + } + + /*/////////////////////////////////////////////////////////////// + Benchmark: Airdrop Push ERC721 + //////////////////////////////////////////////////////////////*/ + + function test_state_airdropPush_erc721() public { + erc721.mint(signer, 100); + vm.prank(signer); + erc721.setApprovalForAll(address(airdrop), true); + + Airdrop.AirdropContentERC721[] memory contents = _getContentsERC721(10); + + vm.prank(signer); + + airdrop.airdropERC721(address(erc721), contents); + + for (uint256 i = 0; i < contents.length; i++) { + assertEq(erc721.ownerOf(contents[i].tokenId), contents[i].recipient); + } + } + + /*/////////////////////////////////////////////////////////////// + Benchmark: Airdrop Signature ERC721 + //////////////////////////////////////////////////////////////*/ + + function test_state_airdropSignature_erc721() public { + erc721.mint(signer, 1000); + vm.prank(signer); + erc721.setApprovalForAll(address(airdrop), true); + + Airdrop.AirdropContentERC721[] memory contents = _getContentsERC721(10); + Airdrop.AirdropRequestERC721 memory req = Airdrop.AirdropRequestERC721({ + uid: bytes32(uint256(1)), + tokenAddress: address(erc721), + expirationTimestamp: 1000, + contents: contents + }); + bytes memory signature = _signReqERC721(req, privateKey); + + vm.prank(signer); + + airdrop.airdropERC721WithSignature(req, signature); + + for (uint256 i = 0; i < contents.length; i++) { + assertEq(erc721.ownerOf(contents[i].tokenId), contents[i].recipient); + } + } + + function test_state_airdropSignature_erc721_eip1271() public { + // set mockSmartWallet as contract owner + vm.prank(signer); + airdrop.setOwner(address(mockSmartWallet)); + + // mint tokens to mockSmartWallet + erc721.mint(address(mockSmartWallet), 1000); + vm.prank(address(mockSmartWallet)); + erc721.setApprovalForAll(address(airdrop), true); + + Airdrop.AirdropContentERC721[] memory contents = _getContentsERC721(10); + Airdrop.AirdropRequestERC721 memory req = Airdrop.AirdropRequestERC721({ + uid: bytes32(uint256(1)), + tokenAddress: address(erc721), + expirationTimestamp: 1000, + contents: contents + }); + + // sign with original EOA signer private key + bytes memory signature = _signReqERC721(req, privateKey); + + airdrop.airdropERC721WithSignature(req, signature); + + for (uint256 i = 0; i < contents.length; i++) { + assertEq(erc721.ownerOf(contents[i].tokenId), contents[i].recipient); + } + } + + function test_revert_airdropSignature_erc721_eip1271_invalidSignature() public { + // set mockSmartWallet as contract owner + vm.prank(signer); + airdrop.setOwner(address(mockSmartWallet)); + + // mint tokens to mockSmartWallet + erc721.mint(address(mockSmartWallet), 1000); + vm.prank(address(mockSmartWallet)); + erc721.setApprovalForAll(address(airdrop), true); + + Airdrop.AirdropContentERC721[] memory contents = _getContentsERC721(10); + Airdrop.AirdropRequestERC721 memory req = Airdrop.AirdropRequestERC721({ + uid: bytes32(uint256(1)), + tokenAddress: address(erc721), + expirationTimestamp: 1000, + contents: contents + }); + + // sign with random private key + bytes memory signature = _signReqERC721(req, 123); + + vm.expectRevert(abi.encodeWithSelector(Airdrop.AirdropRequestInvalidSigner.selector)); + airdrop.airdropERC721WithSignature(req, signature); + } + + function test_revert_airdropSignature_erc721_expired() public { + erc721.mint(signer, 1000); + vm.prank(signer); + erc721.setApprovalForAll(address(airdrop), true); + + Airdrop.AirdropContentERC721[] memory contents = _getContentsERC721(10); + Airdrop.AirdropRequestERC721 memory req = Airdrop.AirdropRequestERC721({ + uid: bytes32(uint256(1)), + tokenAddress: address(erc721), + expirationTimestamp: 1000, + contents: contents + }); + bytes memory signature = _signReqERC721(req, privateKey); + + vm.warp(1001); + + vm.prank(signer); + vm.expectRevert(abi.encodeWithSelector(Airdrop.AirdropRequestExpired.selector, req.expirationTimestamp)); + airdrop.airdropERC721WithSignature(req, signature); + } + + function test_revert_airdropSignature_erc721_alreadyProcessed() public { + erc721.mint(signer, 1000); + vm.prank(signer); + erc721.setApprovalForAll(address(airdrop), true); + + Airdrop.AirdropContentERC721[] memory contents = _getContentsERC721(10); + Airdrop.AirdropRequestERC721 memory req = Airdrop.AirdropRequestERC721({ + uid: bytes32(uint256(1)), + tokenAddress: address(erc721), + expirationTimestamp: 1000, + contents: contents + }); + bytes memory signature = _signReqERC721(req, privateKey); + + vm.prank(signer); + airdrop.airdropERC721WithSignature(req, signature); + + // send it again + vm.expectRevert(abi.encodeWithSelector(Airdrop.AirdropRequestAlreadyProcessed.selector)); + airdrop.airdropERC721WithSignature(req, signature); + } + + function test_revert_airdropSignature_erc721_invalidSigner() public { + erc721.mint(signer, 1000); + vm.prank(signer); + erc721.setApprovalForAll(address(airdrop), true); + + Airdrop.AirdropContentERC721[] memory contents = _getContentsERC721(10); + Airdrop.AirdropRequestERC721 memory req = Airdrop.AirdropRequestERC721({ + uid: bytes32(uint256(1)), + tokenAddress: address(erc721), + expirationTimestamp: 1000, + contents: contents + }); + bytes memory signature = _signReqERC721(req, 123); + + vm.expectRevert(abi.encodeWithSelector(Airdrop.AirdropRequestInvalidSigner.selector)); + airdrop.airdropERC721WithSignature(req, signature); + } + + /*/////////////////////////////////////////////////////////////// + Benchmark: Airdrop Claim ERC721 + //////////////////////////////////////////////////////////////*/ + + function test_state_airdropClaim_erc721() public { + erc721.mint(signer, 100); + vm.prank(signer); + erc721.setApprovalForAll(address(airdrop), true); + + string[] memory inputs = new string[](3); + inputs[0] = "node"; + inputs[1] = "src/test/scripts/generateRootAirdrop.ts"; + inputs[2] = Strings.toString(uint256(5)); + bytes memory result = vm.ffi(inputs); + bytes32 root = abi.decode(result, (bytes32)); + + // set merkle root + vm.prank(signer); + airdrop.setMerkleRoot(address(erc721), root, true); + + // generate proof + inputs[1] = "src/test/scripts/getProofAirdrop.ts"; + result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); + uint256 tokenId = 5; + + airdrop.claimERC721(address(erc721), receiver, tokenId, proofs); + + assertEq(erc721.ownerOf(tokenId), receiver); + } + + function test_revert_airdropClaim_erc721_alreadyClaimed() public { + erc721.mint(signer, 100); + vm.prank(signer); + erc721.setApprovalForAll(address(airdrop), true); + + string[] memory inputs = new string[](3); + inputs[0] = "node"; + inputs[1] = "src/test/scripts/generateRootAirdrop.ts"; + inputs[2] = Strings.toString(uint256(5)); + bytes memory result = vm.ffi(inputs); + bytes32 root = abi.decode(result, (bytes32)); + + // set merkle root + vm.prank(signer); + airdrop.setMerkleRoot(address(erc721), root, true); + + // generate proof + inputs[1] = "src/test/scripts/getProofAirdrop.ts"; + result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); + uint256 tokenId = 5; + + airdrop.claimERC721(address(erc721), receiver, tokenId, proofs); + + // revert when claiming again + vm.expectRevert(abi.encodeWithSelector(Airdrop.AirdropAlreadyClaimed.selector)); + airdrop.claimERC721(address(erc721), receiver, tokenId, proofs); + } + + function test_revert_airdropClaim_erc721_noMerkleRoot() public { + erc721.mint(signer, 100); + vm.prank(signer); + erc721.setApprovalForAll(address(airdrop), true); + + bytes32[] memory proofs; + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); + uint256 tokenId = 5; + + vm.expectRevert(abi.encodeWithSelector(Airdrop.AirdropNoMerkleRoot.selector)); + airdrop.claimERC721(address(erc721), receiver, tokenId, proofs); + } + + function test_revert_airdropClaim_erc721_invalidProof() public { + erc721.mint(signer, 100); + vm.prank(signer); + erc721.setApprovalForAll(address(airdrop), true); + + string[] memory inputs = new string[](3); + inputs[0] = "node"; + inputs[1] = "src/test/scripts/generateRootAirdrop.ts"; + inputs[2] = Strings.toString(uint256(5)); + bytes memory result = vm.ffi(inputs); + bytes32 root = abi.decode(result, (bytes32)); + + // set merkle root + vm.prank(signer); + airdrop.setMerkleRoot(address(erc721), root, true); + + // generate proof + inputs[1] = "src/test/scripts/getProofAirdrop.ts"; + result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + address receiver = address(0x12345); + uint256 tokenId = 5; + + vm.expectRevert(abi.encodeWithSelector(Airdrop.AirdropInvalidProof.selector)); + airdrop.claimERC721(address(erc721), receiver, tokenId, proofs); + } + + /*/////////////////////////////////////////////////////////////// + Benchmark: Airdrop Push ERC1155 + //////////////////////////////////////////////////////////////*/ + + function test_state_airdropPush_erc1155() public { + erc1155.mint(signer, 0, 100 ether); + vm.prank(signer); + erc1155.setApprovalForAll(address(airdrop), true); + + Airdrop.AirdropContentERC1155[] memory contents = _getContentsERC1155(10); + + vm.prank(signer); + + airdrop.airdropERC1155(address(erc1155), contents); + + for (uint256 i = 0; i < contents.length; i++) { + assertEq(erc1155.balanceOf(contents[i].recipient, contents[i].tokenId), contents[i].amount); + } + } + + /*/////////////////////////////////////////////////////////////// + Benchmark: Airdrop Signature ERC1155 + //////////////////////////////////////////////////////////////*/ + + function test_state_airdropSignature_erc115() public { + erc1155.mint(signer, 0, 100 ether); + vm.prank(signer); + erc1155.setApprovalForAll(address(airdrop), true); + + Airdrop.AirdropContentERC1155[] memory contents = _getContentsERC1155(10); + Airdrop.AirdropRequestERC1155 memory req = Airdrop.AirdropRequestERC1155({ + uid: bytes32(uint256(1)), + tokenAddress: address(erc1155), + expirationTimestamp: 1000, + contents: contents + }); + bytes memory signature = _signReqERC1155(req, privateKey); + + vm.prank(signer); + + airdrop.airdropERC1155WithSignature(req, signature); + + for (uint256 i = 0; i < contents.length; i++) { + assertEq(erc1155.balanceOf(contents[i].recipient, contents[i].tokenId), contents[i].amount); + } + } + + function test_state_airdropSignature_erc1155_eip1271() public { + // set mockSmartWallet as contract owner + vm.prank(signer); + airdrop.setOwner(address(mockSmartWallet)); + + // mint tokens to mockSmartWallet + erc1155.mint(address(mockSmartWallet), 0, 100 ether); + vm.prank(address(mockSmartWallet)); + erc1155.setApprovalForAll(address(airdrop), true); + + Airdrop.AirdropContentERC1155[] memory contents = _getContentsERC1155(10); + Airdrop.AirdropRequestERC1155 memory req = Airdrop.AirdropRequestERC1155({ + uid: bytes32(uint256(1)), + tokenAddress: address(erc1155), + expirationTimestamp: 1000, + contents: contents + }); + + // sign with original EOA signer private key + bytes memory signature = _signReqERC1155(req, privateKey); + + airdrop.airdropERC1155WithSignature(req, signature); + + for (uint256 i = 0; i < contents.length; i++) { + assertEq(erc1155.balanceOf(contents[i].recipient, contents[i].tokenId), contents[i].amount); + } + } + + function test_revert_airdropSignature_erc1155_eip1271_invalidSignature() public { + // set mockSmartWallet as contract owner + vm.prank(signer); + airdrop.setOwner(address(mockSmartWallet)); + + // mint tokens to mockSmartWallet + erc1155.mint(address(mockSmartWallet), 0, 100 ether); + vm.prank(address(mockSmartWallet)); + erc1155.setApprovalForAll(address(airdrop), true); + + Airdrop.AirdropContentERC1155[] memory contents = _getContentsERC1155(10); + Airdrop.AirdropRequestERC1155 memory req = Airdrop.AirdropRequestERC1155({ + uid: bytes32(uint256(1)), + tokenAddress: address(erc1155), + expirationTimestamp: 1000, + contents: contents + }); + + // sign with random private key + bytes memory signature = _signReqERC1155(req, 123); + + vm.expectRevert(abi.encodeWithSelector(Airdrop.AirdropRequestInvalidSigner.selector)); + airdrop.airdropERC1155WithSignature(req, signature); + } + + function test_revert_airdropSignature_erc115_expired() public { + erc1155.mint(signer, 0, 100 ether); + vm.prank(signer); + erc1155.setApprovalForAll(address(airdrop), true); + + Airdrop.AirdropContentERC1155[] memory contents = _getContentsERC1155(10); + Airdrop.AirdropRequestERC1155 memory req = Airdrop.AirdropRequestERC1155({ + uid: bytes32(uint256(1)), + tokenAddress: address(erc1155), + expirationTimestamp: 1000, + contents: contents + }); + bytes memory signature = _signReqERC1155(req, privateKey); + + vm.warp(1001); + + vm.expectRevert(abi.encodeWithSelector(Airdrop.AirdropRequestExpired.selector, req.expirationTimestamp)); + airdrop.airdropERC1155WithSignature(req, signature); + } + + function test_revert_airdropSignature_erc115_alreadyProcessed() public { + erc1155.mint(signer, 0, 100 ether); + vm.prank(signer); + erc1155.setApprovalForAll(address(airdrop), true); + + Airdrop.AirdropContentERC1155[] memory contents = _getContentsERC1155(10); + Airdrop.AirdropRequestERC1155 memory req = Airdrop.AirdropRequestERC1155({ + uid: bytes32(uint256(1)), + tokenAddress: address(erc1155), + expirationTimestamp: 1000, + contents: contents + }); + bytes memory signature = _signReqERC1155(req, privateKey); + + airdrop.airdropERC1155WithSignature(req, signature); + + // send it again + vm.expectRevert(abi.encodeWithSelector(Airdrop.AirdropRequestAlreadyProcessed.selector)); + airdrop.airdropERC1155WithSignature(req, signature); + } + + function test_revert_airdropSignature_erc115_invalidSigner() public { + erc1155.mint(signer, 0, 100 ether); + vm.prank(signer); + erc1155.setApprovalForAll(address(airdrop), true); + + Airdrop.AirdropContentERC1155[] memory contents = _getContentsERC1155(10); + Airdrop.AirdropRequestERC1155 memory req = Airdrop.AirdropRequestERC1155({ + uid: bytes32(uint256(1)), + tokenAddress: address(erc1155), + expirationTimestamp: 1000, + contents: contents + }); + bytes memory signature = _signReqERC1155(req, 123); + + vm.expectRevert(abi.encodeWithSelector(Airdrop.AirdropRequestInvalidSigner.selector)); + airdrop.airdropERC1155WithSignature(req, signature); + } + /*/////////////////////////////////////////////////////////////// + Benchmark: Airdrop Claim ERC1155 + //////////////////////////////////////////////////////////////*/ + + function test_state_airdropClaim_erc1155() public { + erc1155.mint(signer, 0, 100 ether); + vm.prank(signer); + erc1155.setApprovalForAll(address(airdrop), true); + + string[] memory inputs = new string[](4); + inputs[0] = "node"; + inputs[1] = "src/test/scripts/generateRootAirdrop1155.ts"; + inputs[2] = Strings.toString(uint256(0)); + inputs[3] = Strings.toString(uint256(5)); + bytes memory result = vm.ffi(inputs); + bytes32 root = abi.decode(result, (bytes32)); + + // set merkle root + vm.prank(signer); + airdrop.setMerkleRoot(address(erc1155), root, true); + + // generate proof + inputs[1] = "src/test/scripts/getProofAirdrop1155.ts"; + result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); + uint256 quantity = 5; + + airdrop.claimERC1155(address(erc1155), receiver, 0, quantity, proofs); + + assertEq(erc1155.balanceOf(receiver, 0), quantity); + assertEq(erc1155.balanceOf(signer, 0), 100 ether - quantity); + } + + function test_revert_airdropClaim_erc1155_alreadyClaimed() public { + erc1155.mint(signer, 0, 100 ether); + vm.prank(signer); + erc1155.setApprovalForAll(address(airdrop), true); + + string[] memory inputs = new string[](4); + inputs[0] = "node"; + inputs[1] = "src/test/scripts/generateRootAirdrop1155.ts"; + inputs[2] = Strings.toString(uint256(0)); + inputs[3] = Strings.toString(uint256(5)); + bytes memory result = vm.ffi(inputs); + bytes32 root = abi.decode(result, (bytes32)); + + // set merkle root + vm.prank(signer); + airdrop.setMerkleRoot(address(erc1155), root, true); + + // generate proof + inputs[1] = "src/test/scripts/getProofAirdrop1155.ts"; + result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); + uint256 quantity = 5; + + airdrop.claimERC1155(address(erc1155), receiver, 0, quantity, proofs); + + // revert when claiming again + vm.expectRevert(abi.encodeWithSelector(Airdrop.AirdropAlreadyClaimed.selector)); + airdrop.claimERC1155(address(erc1155), receiver, 0, quantity, proofs); + } + + function test_revert_airdropClaim_erc1155_noMerkleRoot() public { + erc1155.mint(signer, 0, 100 ether); + vm.prank(signer); + erc1155.setApprovalForAll(address(airdrop), true); + + // generate proof + bytes32[] memory proofs; + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); + uint256 quantity = 5; + + vm.expectRevert(abi.encodeWithSelector(Airdrop.AirdropNoMerkleRoot.selector)); + airdrop.claimERC1155(address(erc1155), receiver, 0, quantity, proofs); + } + + function test_revert_airdropClaim_erc1155_invalidProof() public { + erc1155.mint(signer, 0, 100 ether); + vm.prank(signer); + erc1155.setApprovalForAll(address(airdrop), true); + + string[] memory inputs = new string[](4); + inputs[0] = "node"; + inputs[1] = "src/test/scripts/generateRootAirdrop1155.ts"; + inputs[2] = Strings.toString(uint256(0)); + inputs[3] = Strings.toString(uint256(5)); + bytes memory result = vm.ffi(inputs); + bytes32 root = abi.decode(result, (bytes32)); + + // set merkle root + vm.prank(signer); + airdrop.setMerkleRoot(address(erc1155), root, true); + + // generate proof + inputs[1] = "src/test/scripts/getProofAirdrop1155.ts"; + result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + address receiver = address(0x12345); + uint256 quantity = 5; + + vm.expectRevert(abi.encodeWithSelector(Airdrop.AirdropInvalidProof.selector)); + airdrop.claimERC1155(address(erc1155), receiver, 0, quantity, proofs); + } +} diff --git a/src/test/airdrop/AirdropERC1155.t.sol b/src/test/airdrop/AirdropERC1155.t.sol new file mode 100644 index 000000000..161e9d364 --- /dev/null +++ b/src/test/airdrop/AirdropERC1155.t.sol @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { AirdropERC1155, IAirdropERC1155 } from "contracts/prebuilts/unaudited/airdrop/AirdropERC1155.sol"; + +// Test imports +import { Wallet } from "../utils/Wallet.sol"; +import "../utils/BaseTest.sol"; + +contract AirdropERC1155Test is BaseTest { + AirdropERC1155 internal drop; + + Wallet internal tokenOwner; + + IAirdropERC1155.AirdropContent[] internal _contentsOne; + IAirdropERC1155.AirdropContent[] internal _contentsTwo; + + uint256 countOne; + uint256 countTwo; + + function setUp() public override { + super.setUp(); + + drop = AirdropERC1155(getContract("AirdropERC1155")); + + tokenOwner = getWallet(); + + erc1155.mint(address(tokenOwner), 0, 1000); + erc1155.mint(address(tokenOwner), 1, 2000); + erc1155.mint(address(tokenOwner), 2, 3000); + erc1155.mint(address(tokenOwner), 3, 4000); + erc1155.mint(address(tokenOwner), 4, 5000); + + tokenOwner.setApprovalForAllERC1155(address(erc1155), address(drop), true); + + countOne = 1000; + countTwo = 200; + + for (uint256 i = 0; i < countOne; i++) { + _contentsOne.push( + IAirdropERC1155.AirdropContent({ recipient: getActor(uint160(i)), tokenId: i % 5, amount: 5 }) + ); + } + + for (uint256 i = countOne; i < countOne + countTwo; i++) { + _contentsTwo.push( + IAirdropERC1155.AirdropContent({ recipient: getActor(uint160(i)), tokenId: i % 5, amount: 5 }) + ); + } + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: stateless airdrop + //////////////////////////////////////////////////////////////*/ + + function test_state_airdrop() public { + vm.prank(deployer); + drop.airdropERC1155(address(erc1155), address(tokenOwner), _contentsOne); + + for (uint256 i = 0; i < countOne; i++) { + assertEq(erc1155.balanceOf(_contentsOne[i].recipient, i % 5), 5); + } + + assertEq(erc1155.balanceOf(address(tokenOwner), 0), 0); + assertEq(erc1155.balanceOf(address(tokenOwner), 1), 1000); + assertEq(erc1155.balanceOf(address(tokenOwner), 2), 2000); + assertEq(erc1155.balanceOf(address(tokenOwner), 3), 3000); + assertEq(erc1155.balanceOf(address(tokenOwner), 4), 4000); + } + + function test_revert_airdrop_notOwner() public { + vm.prank(address(25)); + vm.expectRevert("Not authorized."); + drop.airdropERC1155(address(erc1155), address(tokenOwner), _contentsOne); + } + + function test_revert_airdrop_notApproved() public { + tokenOwner.setApprovalForAllERC1155(address(erc1155), address(drop), false); + + vm.startPrank(deployer); + vm.expectRevert("Not balance or approved"); + drop.airdropERC1155(address(erc1155), address(tokenOwner), _contentsOne); + vm.stopPrank(); + } +} + +contract AirdropERC1155GasTest is BaseTest { + AirdropERC1155 internal drop; + + Wallet internal tokenOwner; + + function setUp() public override { + super.setUp(); + + drop = AirdropERC1155(getContract("AirdropERC1155")); + + tokenOwner = getWallet(); + + erc1155.mint(address(tokenOwner), 0, 1000); + + tokenOwner.setApprovalForAllERC1155(address(erc1155), address(drop), true); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: gas benchmarks, etc. + //////////////////////////////////////////////////////////////*/ + + function test_safeTransferFrom_toEOA() public { + vm.prank(address(tokenOwner)); + erc1155.safeTransferFrom(address(tokenOwner), address(0x123), 0, 10, ""); + } + + function test_safeTransferFrom_toContract() public { + vm.prank(address(tokenOwner)); + erc1155.safeTransferFrom(address(tokenOwner), address(this), 0, 10, ""); + } + + function test_safeTransferFrom_toEOA_gasOverride() public { + vm.prank(address(tokenOwner)); + console.log(gasleft()); + erc1155.safeTransferFrom{ gas: 100_000 }(address(tokenOwner), address(this), 0, 10, ""); + console.log(gasleft()); + } + + function test_safeTransferFrom_toContract_gasOverride() public { + vm.prank(address(tokenOwner)); + console.log(gasleft()); + erc1155.safeTransferFrom{ gas: 100_000 }(address(tokenOwner), address(this), 0, 10, ""); + console.log(gasleft()); + } + + function onERC1155Received(address, address, uint256, uint256, bytes calldata) external pure returns (bytes4) { + return this.onERC1155Received.selector; + } +} diff --git a/src/test/airdrop/AirdropERC1155Claimable.t.sol b/src/test/airdrop/AirdropERC1155Claimable.t.sol new file mode 100644 index 000000000..4ce582d16 --- /dev/null +++ b/src/test/airdrop/AirdropERC1155Claimable.t.sol @@ -0,0 +1,218 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "contracts/prebuilts/unaudited/airdrop/AirdropERC1155Claimable.sol"; + +// Test imports +import { Wallet } from "../utils/Wallet.sol"; +import { TWProxy } from "contracts/infra/TWProxy.sol"; +import { BaseTest } from "../utils/BaseTest.sol"; +import { Strings } from "contracts/lib/Strings.sol"; + +contract AirdropERC1155ClaimableTest is BaseTest { + address public implementation; + AirdropERC1155Claimable internal drop; + + function setUp() public override { + super.setUp(); + + address implementation = address(new AirdropERC1155Claimable()); + + // Deploy proxy pointing to implementaion. + vm.prank(deployer); + drop = AirdropERC1155Claimable( + address( + new TWProxy( + implementation, + abi.encodeCall( + AirdropERC1155Claimable.initialize, + ( + forwarders(), + address(airdropTokenOwner), + address(erc1155), + _airdropTokenIdsERC1155, + _airdropAmountsERC1155, + 1000, + _airdropWalletClaimCountERC1155, + _airdropMerkleRootERC1155 + ) + ) + ) + ) + ); + + erc1155.mint(address(airdropTokenOwner), 0, 100); + erc1155.mint(address(airdropTokenOwner), 1, 100); + erc1155.mint(address(airdropTokenOwner), 2, 100); + erc1155.mint(address(airdropTokenOwner), 3, 100); + erc1155.mint(address(airdropTokenOwner), 4, 100); + + airdropTokenOwner.setApprovalForAllERC1155(address(erc1155), address(drop), true); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `claim` -- for allowlisted claimers + //////////////////////////////////////////////////////////////*/ + + function test_state_claim_allowlistedClaimer() public { + string[] memory inputs = new string[](3); + inputs[0] = "node"; + inputs[1] = "src/test/scripts/getProofAirdrop.ts"; + inputs[2] = Strings.toString(uint256(5)); + + bytes memory result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + vm.warp(1); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); + uint256 quantity = 2; + uint256 id = 0; + + uint256 _availableAmount = drop.availableAmount(id); + + vm.prank(receiver); + drop.claim(receiver, quantity, id, proofs, 5); + + assertEq(erc1155.balanceOf(receiver, id), quantity); + assertEq(drop.supplyClaimedByWallet(id, receiver), quantity); + assertEq(drop.availableAmount(id), _availableAmount - quantity); + } + + function test_revert_claim_notInAllowlist_invalidQty() public { + string[] memory inputs = new string[](3); + inputs[0] = "node"; + inputs[1] = "src/test/scripts/getProofAirdrop.ts"; + inputs[2] = Strings.toString(uint256(4)); // generate proof with incorrect amount + + bytes memory result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + vm.warp(1); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); + uint256 quantity = 2; + uint256 id = 0; + + vm.prank(receiver); + vm.expectRevert("invalid quantity."); + drop.claim(receiver, quantity, id, proofs, 5); + } + + function test_revert_claim_allowlistedClaimer_proofClaimed() public { + string[] memory inputs = new string[](3); + inputs[0] = "node"; + inputs[1] = "src/test/scripts/getProofAirdrop.ts"; + inputs[2] = Strings.toString(uint256(5)); + + bytes memory result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + vm.warp(1); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); + uint256 quantity = 2; + uint256 id = 0; + + vm.prank(receiver); + drop.claim(receiver, quantity, id, proofs, 5); + + quantity = 3; + + vm.prank(receiver); + drop.claim(receiver, quantity, id, proofs, 5); + + vm.prank(receiver); + vm.expectRevert("invalid quantity."); + drop.claim(receiver, quantity, id, proofs, 5); + } + + function test_state_claim_allowlistedClaimer_invalidQuantity() public { + string[] memory inputs = new string[](3); + inputs[0] = "node"; + inputs[1] = "src/test/scripts/getProofAirdrop.ts"; + inputs[2] = Strings.toString(uint256(5)); + + bytes memory result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + vm.warp(1); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); + uint256 quantity = 6; + uint256 id = 0; + + vm.prank(receiver); + vm.expectRevert("invalid quantity."); + drop.claim(receiver, quantity, id, proofs, 5); + } + + function test_revert_claim_allowlistedClaimer_airdropExpired() public { + string[] memory inputs = new string[](3); + inputs[0] = "node"; + inputs[1] = "src/test/scripts/getProofAirdrop.ts"; + inputs[2] = Strings.toString(uint256(5)); + + bytes memory result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + vm.warp(1001); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); + uint256 quantity = 5; + uint256 id = 0; + + vm.prank(receiver); + vm.expectRevert("airdrop expired."); + drop.claim(receiver, quantity, id, proofs, 5); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `claim` -- for open claiming + //////////////////////////////////////////////////////////////*/ + + function test_state_claim_nonAllowlistedClaimer() public { + address receiver = address(0x123); + uint256 quantity = 1; + bytes32[] memory proofs; + uint256 id = 0; + + uint256 _availableAmount = drop.availableAmount(id); + + vm.prank(receiver); + drop.claim(receiver, quantity, id, proofs, 0); + + assertEq(erc1155.balanceOf(receiver, id), quantity); + assertEq(drop.supplyClaimedByWallet(id, receiver), quantity); + assertEq(drop.availableAmount(id), _availableAmount - quantity); + } + + function test_revert_claim_nonAllowlistedClaimer_invalidQuantity() public { + address receiver = address(0x123); + uint256 quantity = 2; + bytes32[] memory proofs; + uint256 id = 0; + + vm.prank(receiver); + vm.expectRevert("invalid quantity."); + drop.claim(receiver, quantity, id, proofs, 0); + } + + function test_revert_claim_nonAllowlistedClaimer_exceedsAvailable() public { + uint256 id = 0; + uint256 _availableAmount = drop.availableAmount(id); + bytes32[] memory proofs; + + uint256 i = 0; + for (; i < _availableAmount; i++) { + address receiver = getActor(uint160(i)); + vm.prank(receiver); + drop.claim(receiver, 1, id, proofs, 0); + } + + address receiver = getActor(uint160(i)); + vm.prank(receiver); + vm.expectRevert("exceeds available tokens."); + drop.claim(receiver, 1, id, proofs, 0); + } +} diff --git a/src/test/airdrop/AirdropERC20.t.sol b/src/test/airdrop/AirdropERC20.t.sol new file mode 100644 index 000000000..54d0c4997 --- /dev/null +++ b/src/test/airdrop/AirdropERC20.t.sol @@ -0,0 +1,187 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { AirdropERC20, IAirdropERC20 } from "contracts/prebuilts/unaudited/airdrop/AirdropERC20.sol"; +import { CurrencyTransferLib } from "contracts/lib/CurrencyTransferLib.sol"; + +// Test imports +import { Wallet } from "../utils/Wallet.sol"; +import "../utils/BaseTest.sol"; + +import "../mocks/MockERC20NonCompliant.sol"; + +contract AirdropERC20Test is BaseTest { + AirdropERC20 internal drop; + + Wallet internal tokenOwner; + + IAirdropERC20.AirdropContent[] internal _contentsOne; + IAirdropERC20.AirdropContent[] internal _contentsTwo; + + uint256 countOne; + uint256 countTwo; + + function setUp() public override { + super.setUp(); + + drop = AirdropERC20(getContract("AirdropERC20")); + + tokenOwner = getWallet(); + + erc20.mint(address(tokenOwner), 10_000 ether); + tokenOwner.setAllowanceERC20(address(erc20), address(drop), type(uint256).max); + + countOne = 1000; + countTwo = 200; + + for (uint256 i = 0; i < countOne; i++) { + _contentsOne.push(IAirdropERC20.AirdropContent({ recipient: getActor(uint160(i)), amount: 10 ether })); + } + + for (uint256 i = countOne; i < countOne + countTwo; i++) { + _contentsTwo.push(IAirdropERC20.AirdropContent({ recipient: getActor(uint160(i)), amount: 10 ether })); + } + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: stateless airdrop + //////////////////////////////////////////////////////////////*/ + + function test_state_airdrop() public { + vm.prank(deployer); + drop.airdropERC20(address(erc20), address(tokenOwner), _contentsOne); + + for (uint256 i = 0; i < countOne; i++) { + assertEq(erc20.balanceOf(_contentsOne[i].recipient), _contentsOne[i].amount); + } + assertEq(erc20.balanceOf(address(tokenOwner)), 0); + } + + function test_revert_airdrop_insufficientValue() public { + vm.prank(deployer); + vm.expectRevert("Insufficient native token amount"); + drop.airdropERC20(CurrencyTransferLib.NATIVE_TOKEN, address(tokenOwner), _contentsOne); + } + + function test_revert_airdrop_notOwner() public { + vm.startPrank(address(25)); + vm.expectRevert("Not authorized."); + drop.airdropERC20(address(erc20), address(tokenOwner), _contentsOne); + vm.stopPrank(); + } + + function test_revert_airdrop_notApproved() public { + tokenOwner.setAllowanceERC20(address(erc20), address(drop), 0); + + vm.startPrank(deployer); + vm.expectRevert("Not balance or allowance"); + drop.airdropERC20(address(erc20), address(tokenOwner), _contentsOne); + vm.stopPrank(); + } +} + +contract AirdropERC20AuditTest is BaseTest { + AirdropERC20 internal drop; + + Wallet internal tokenOwner; + + IAirdropERC20.AirdropContent[] internal _contentsOne; + IAirdropERC20.AirdropContent[] internal _contentsTwo; + + uint256 countOne; + uint256 countTwo; + + MockERC20NonCompliant public erc20_nonCompliant; + + function setUp() public override { + super.setUp(); + + erc20_nonCompliant = new MockERC20NonCompliant(); + drop = AirdropERC20(getContract("AirdropERC20")); + + tokenOwner = getWallet(); + + erc20_nonCompliant.mint(address(tokenOwner), 10_000 ether); + tokenOwner.setAllowanceERC20(address(erc20_nonCompliant), address(drop), type(uint256).max); + + countOne = 1000; + countTwo = 200; + + for (uint256 i = 0; i < countOne; i++) { + _contentsOne.push(IAirdropERC20.AirdropContent({ recipient: getActor(uint160(i)), amount: 10 ether })); + } + + for (uint256 i = countOne; i < countOne + countTwo; i++) { + _contentsTwo.push(IAirdropERC20.AirdropContent({ recipient: getActor(uint160(i)), amount: 10 ether })); + } + } + + function test_process_payments_with_non_compliant_token() public { + vm.prank(deployer); + drop.airdropERC20(address(erc20_nonCompliant), address(tokenOwner), _contentsOne); + + // check balances after airdrop + for (uint256 i = 0; i < countOne; i++) { + assertEq(erc20_nonCompliant.balanceOf(_contentsOne[i].recipient), _contentsOne[i].amount); + } + assertEq(erc20_nonCompliant.balanceOf(address(tokenOwner)), 0); + } +} + +contract AirdropERC20GasTest is BaseTest { + AirdropERC20 internal drop; + + Wallet internal tokenOwner; + + function setUp() public override { + super.setUp(); + + drop = AirdropERC20(getContract("AirdropERC20")); + + tokenOwner = getWallet(); + + erc20.mint(address(tokenOwner), 10_000 ether); + tokenOwner.setAllowanceERC20(address(erc20), address(drop), type(uint256).max); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: gas benchmarks, etc. + //////////////////////////////////////////////////////////////*/ + + function test_transferNativeToken_toEOA() public { + vm.prank(address(tokenOwner)); + (bool success, bytes memory data) = address(0x123).call{ value: 1 ether }(""); + + // Silence warning: Return value of low-level calls not used. + (success, data) = (success, data); + } + + function test_transferNativeToken_toContract() public { + vm.prank(address(tokenOwner)); + (bool success, bytes memory data) = address(this).call{ value: 1 ether }(""); + + // Silence warning: Return value of low-level calls not used. + (success, data) = (success, data); + } + + function test_transferNativeToken_toEOA_gasOverride() public { + vm.prank(address(tokenOwner)); + console.log(gasleft()); + (bool success, bytes memory data) = address(0x123).call{ value: 1 ether, gas: 100_000 }(""); + + // Silence warning: Return value of low-level calls not used. + (success, data) = (success, data); + + console.log(gasleft()); + } + + function test_transferNativeToken_toContract_gasOverride() public { + vm.prank(address(tokenOwner)); + console.log(gasleft()); + (bool success, bytes memory data) = address(this).call{ value: 1 ether, gas: 100_000 }(""); + console.log(gasleft()); + + // Silence warning: Return value of low-level calls not used. + (success, data) = (success, data); + } +} diff --git a/src/test/airdrop/AirdropERC20Claimable.t.sol b/src/test/airdrop/AirdropERC20Claimable.t.sol new file mode 100644 index 000000000..efc5029a8 --- /dev/null +++ b/src/test/airdrop/AirdropERC20Claimable.t.sol @@ -0,0 +1,199 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "contracts/prebuilts/unaudited/airdrop/AirdropERC20Claimable.sol"; + +// Test imports +import { Wallet } from "../utils/Wallet.sol"; +import { TWProxy } from "contracts/infra/TWProxy.sol"; +import "../utils/BaseTest.sol"; + +contract AirdropERC20ClaimableTest is BaseTest { + address public implementation; + AirdropERC20Claimable internal drop; + + function setUp() public override { + super.setUp(); + + address implementation = address(new AirdropERC20Claimable()); + + // Deploy proxy pointing to implementaion. + vm.prank(deployer); + drop = AirdropERC20Claimable( + address( + new TWProxy( + implementation, + abi.encodeCall( + AirdropERC20Claimable.initialize, + ( + forwarders(), + address(airdropTokenOwner), + address(erc20), + 10_000 ether, + 1000, + 1, + _airdropMerkleRootERC20 + ) + ) + ) + ) + ); + + erc20.mint(address(airdropTokenOwner), 10_000 ether); + airdropTokenOwner.setAllowanceERC20(address(erc20), address(drop), type(uint256).max); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `claim` -- for allowlisted claimers + //////////////////////////////////////////////////////////////*/ + + function test_state_claim_allowlistedClaimer() public { + string[] memory inputs = new string[](3); + inputs[0] = "node"; + inputs[1] = "src/test/scripts/getProofAirdrop.ts"; + inputs[2] = Strings.toString(uint256(5)); + + bytes memory result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + vm.warp(1); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); + uint256 quantity = 2; + + uint256 _availableAmount = drop.availableAmount(); + + vm.prank(receiver); + drop.claim(receiver, quantity, proofs, 5); + + assertEq(erc20.balanceOf(receiver), quantity); + assertEq(erc20.balanceOf(address(airdropTokenOwner)), _availableAmount - quantity); + assertEq(drop.supplyClaimedByWallet(receiver), quantity); + assertEq(drop.availableAmount(), _availableAmount - quantity); + } + + function test_revert_claim_notInAllowlist() public { + string[] memory inputs = new string[](3); + inputs[0] = "node"; + inputs[1] = "src/test/scripts/getProofAirdrop.ts"; + inputs[2] = Strings.toString(uint256(4)); // generate proof with incorrect amount + + bytes memory result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + vm.warp(1); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); + uint256 quantity = 2; + + vm.prank(receiver); + vm.expectRevert("invalid quantity."); + drop.claim(receiver, quantity, proofs, 4); + } + + function test_state_claim_allowlistedClaimer_maxAmountClaimed() public { + string[] memory inputs = new string[](3); + inputs[0] = "node"; + inputs[1] = "src/test/scripts/getProofAirdrop.ts"; + inputs[2] = Strings.toString(uint256(5)); + + bytes memory result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + vm.warp(1); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); + uint256 quantity = 2; + + vm.prank(receiver); + drop.claim(receiver, quantity, proofs, 5); + + quantity = 3; + + vm.prank(receiver); + drop.claim(receiver, quantity, proofs, 5); + + // claiming again after exhausting claim limit + vm.prank(receiver); + vm.expectRevert("invalid quantity."); + drop.claim(receiver, quantity, proofs, 5); + } + + function test_state_claim_allowlistedClaimer_invalidQuantity() public { + string[] memory inputs = new string[](3); + inputs[0] = "node"; + inputs[1] = "src/test/scripts/getProofAirdrop.ts"; + inputs[2] = Strings.toString(uint256(5)); + + bytes memory result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + vm.warp(1); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); + uint256 quantity = 6; + + vm.prank(receiver); + vm.expectRevert("invalid quantity."); + drop.claim(receiver, quantity, proofs, 5); + } + + function test_state_claim_allowlistedClaimer_airdropExpired() public { + string[] memory inputs = new string[](3); + inputs[0] = "node"; + inputs[1] = "src/test/scripts/getProofAirdrop.ts"; + inputs[2] = Strings.toString(uint256(5)); + + bytes memory result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + vm.warp(1001); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); + uint256 quantity = 5; + + vm.prank(receiver); + vm.expectRevert("airdrop expired."); + drop.claim(receiver, quantity, proofs, 5); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `claim` -- for open claiming + //////////////////////////////////////////////////////////////*/ + + function test_state_claim_nonAllowlistedClaimer() public { + address receiver = address(0x123); + uint256 quantity = 1; + bytes32[] memory proofs; + + uint256 _availableAmount = drop.availableAmount(); + + vm.prank(receiver); + drop.claim(receiver, quantity, proofs, 0); + + assertEq(erc20.balanceOf(receiver), quantity); + assertEq(erc20.balanceOf(address(airdropTokenOwner)), _availableAmount - quantity); + assertEq(drop.supplyClaimedByWallet(receiver), quantity); + assertEq(drop.availableAmount(), _availableAmount - quantity); + } + + function test_revert_claim_nonAllowlistedClaimer_invalidQuantity() public { + address receiver = address(0x123); + uint256 quantity = 2; + bytes32[] memory proofs; + + vm.prank(receiver); + vm.expectRevert("invalid quantity."); + drop.claim(receiver, quantity, proofs, 0); + } + + function test_revert_claim_nonAllowlistedClaimer_exceedsAvailable() public { + uint256 _availableAmount = drop.availableAmount(); + bytes32[] memory proofs; + + address receiver = getActor(uint160(2)); + vm.prank(receiver); + vm.expectRevert("exceeds available tokens."); + drop.claim(receiver, 10_001 ether, proofs, 0); + } +} diff --git a/src/test/airdrop/AirdropERC721.t.sol b/src/test/airdrop/AirdropERC721.t.sol new file mode 100644 index 000000000..6b579eb46 --- /dev/null +++ b/src/test/airdrop/AirdropERC721.t.sol @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { AirdropERC721, IAirdropERC721 } from "contracts/prebuilts/unaudited/airdrop/AirdropERC721.sol"; + +// Test imports +import { Wallet } from "../utils/Wallet.sol"; +import "../utils/BaseTest.sol"; + +contract AirdropERC721Test is BaseTest { + AirdropERC721 internal drop; + + Wallet internal tokenOwner; + + IAirdropERC721.AirdropContent[] internal _contentsOne; + IAirdropERC721.AirdropContent[] internal _contentsTwo; + + uint256 countOne; + uint256 countTwo; + + function setUp() public override { + super.setUp(); + + drop = AirdropERC721(getContract("AirdropERC721")); + + tokenOwner = getWallet(); + + erc721.mint(address(tokenOwner), 1500); + tokenOwner.setApprovalForAllERC721(address(erc721), address(drop), true); + + countOne = 1000; + countTwo = 200; + + for (uint256 i = 0; i < countOne; i++) { + _contentsOne.push(IAirdropERC721.AirdropContent({ recipient: getActor(uint160(i)), tokenId: i })); + } + + for (uint256 i = countOne; i < countOne + countTwo; i++) { + _contentsTwo.push(IAirdropERC721.AirdropContent({ recipient: getActor(uint160(i)), tokenId: i })); + } + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: stateless airdrop + //////////////////////////////////////////////////////////////*/ + + function test_state_airdrop() public { + vm.prank(deployer); + drop.airdropERC721(address(erc721), address(tokenOwner), _contentsOne); + + for (uint256 i = 0; i < 1000; i++) { + assertEq(erc721.ownerOf(i), _contentsOne[i].recipient); + } + } + + function test_revert_airdrop_notOwner() public { + vm.prank(address(25)); + vm.expectRevert("Not authorized."); + drop.airdropERC721(address(erc721), address(tokenOwner), _contentsOne); + } + + function test_revert_airdrop_notApproved() public { + tokenOwner.setApprovalForAllERC721(address(erc721), address(drop), false); + + vm.startPrank(deployer); + vm.expectRevert("Not owner or approved"); + drop.airdropERC721(address(erc721), address(tokenOwner), _contentsOne); + vm.stopPrank(); + } +} + +contract AirdropERC721GasTest is BaseTest { + AirdropERC721 internal drop; + + Wallet internal tokenOwner; + + function setUp() public override { + super.setUp(); + + drop = AirdropERC721(getContract("AirdropERC721")); + + tokenOwner = getWallet(); + + erc721.mint(address(tokenOwner), 1500); + tokenOwner.setApprovalForAllERC721(address(erc721), address(drop), true); + + vm.startPrank(address(tokenOwner)); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: gas benchmarks, etc. + //////////////////////////////////////////////////////////////*/ + + function test_safeTransferFrom_toEOA() public { + erc721.safeTransferFrom(address(tokenOwner), address(0x123), 0); + } + + function test_safeTransferFrom_toContract() public { + erc721.safeTransferFrom(address(tokenOwner), address(this), 0); + } + + function test_safeTransferFrom_toEOA_gasOverride() public { + console.log(gasleft()); + erc721.safeTransferFrom{ gas: 100_000 }(address(tokenOwner), address(0x123), 0); + console.log(gasleft()); + } + + function test_safeTransferFrom_toContract_gasOverride() public { + console.log(gasleft()); + erc721.safeTransferFrom{ gas: 100_000 }(address(tokenOwner), address(this), 0); + console.log(gasleft()); + } + + function onERC721Received(address, address, uint256, bytes calldata) external pure returns (bytes4) { + return this.onERC721Received.selector; + } +} diff --git a/src/test/airdrop/AirdropERC721Claimable.t.sol b/src/test/airdrop/AirdropERC721Claimable.t.sol new file mode 100644 index 000000000..917ac4043 --- /dev/null +++ b/src/test/airdrop/AirdropERC721Claimable.t.sol @@ -0,0 +1,220 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "contracts/prebuilts/unaudited/airdrop/AirdropERC721Claimable.sol"; + +// Test imports +import { Wallet } from "../utils/Wallet.sol"; +import { TWProxy } from "contracts/infra/TWProxy.sol"; +import { BaseTest } from "../utils/BaseTest.sol"; +import { Strings } from "contracts/lib/Strings.sol"; + +contract AirdropERC721ClaimableTest is BaseTest { + address public implementation; + AirdropERC721Claimable internal drop; + + function setUp() public override { + super.setUp(); + + address implementation = address(new AirdropERC721Claimable()); + + // Deploy proxy pointing to implementaion. + vm.prank(deployer); + drop = AirdropERC721Claimable( + address( + new TWProxy( + implementation, + abi.encodeCall( + AirdropERC721Claimable.initialize, + ( + forwarders(), + address(airdropTokenOwner), + address(erc721), + _airdropTokenIdsERC721, + 1000, + 1, + _airdropMerkleRootERC721 + ) + ) + ) + ) + ); + + erc721.mint(address(airdropTokenOwner), 1000); + airdropTokenOwner.setApprovalForAllERC721(address(erc721), address(drop), true); + } + + // /*/////////////////////////////////////////////////////////////// + // Unit tests: `claim` -- for allowlisted claimers + // //////////////////////////////////////////////////////////////*/ + + function test_state_claim_allowlistedClaimer() public { + string[] memory inputs = new string[](3); + inputs[0] = "node"; + inputs[1] = "src/test/scripts/getProofAirdrop.ts"; + inputs[2] = Strings.toString(uint256(5)); + + bytes memory result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + vm.warp(1); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); + uint256 quantity = 2; + + uint256 _availableAmount = drop.availableAmount(); + uint256 _nextIndex = drop.nextIndex(); + + vm.prank(receiver); + drop.claim(receiver, quantity, proofs, 5); + + for (uint256 i = 0; i < quantity; i++) { + assertEq(erc721.ownerOf(i), receiver); + } + assertEq(drop.nextIndex(), _nextIndex + quantity); + assertEq(drop.supplyClaimedByWallet(receiver), quantity); + assertEq(drop.availableAmount(), _availableAmount - quantity); + } + + function test_revert_claim_notInAllowlist() public { + string[] memory inputs = new string[](3); + inputs[0] = "node"; + inputs[1] = "src/test/scripts/getProofAirdrop.ts"; + inputs[2] = Strings.toString(uint256(4)); // generate proof with incorrect amount + + bytes memory result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + vm.warp(1); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); + uint256 quantity = 2; + + vm.prank(receiver); + vm.expectRevert("invalid quantity."); + drop.claim(receiver, quantity, proofs, 4); + } + + function test_revert_claim_allowlistedClaimer_proofClaimed() public { + string[] memory inputs = new string[](3); + inputs[0] = "node"; + inputs[1] = "src/test/scripts/getProofAirdrop.ts"; + inputs[2] = Strings.toString(uint256(5)); + + bytes memory result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + vm.warp(1); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); + uint256 quantity = 2; + + uint256 _availableAmount = drop.availableAmount(); + uint256 _nextIndex = drop.nextIndex(); + + vm.prank(receiver); + drop.claim(receiver, quantity, proofs, 5); + + for (uint256 i = 0; i < quantity; i++) { + assertEq(erc721.ownerOf(i), receiver); + } + assertEq(drop.nextIndex(), _nextIndex + quantity); + assertEq(drop.supplyClaimedByWallet(receiver), quantity); + assertEq(drop.availableAmount(), _availableAmount - quantity); + + quantity = 3; + + vm.prank(receiver); + drop.claim(receiver, quantity, proofs, 5); + + vm.prank(receiver); + vm.expectRevert("invalid quantity."); + drop.claim(receiver, quantity, proofs, 5); + } + + function test_state_claim_allowlistedClaimer_invalidQuantity() public { + string[] memory inputs = new string[](3); + inputs[0] = "node"; + inputs[1] = "src/test/scripts/getProofAirdrop.ts"; + inputs[2] = Strings.toString(uint256(5)); + + bytes memory result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + vm.warp(1); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); + uint256 quantity = 6; + + vm.prank(receiver); + vm.expectRevert("invalid quantity."); + drop.claim(receiver, quantity, proofs, 5); + } + + function test_state_claim_allowlistedClaimer_airdropExpired() public { + string[] memory inputs = new string[](3); + inputs[0] = "node"; + inputs[1] = "src/test/scripts/getProofAirdrop.ts"; + inputs[2] = Strings.toString(uint256(5)); + + bytes memory result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + vm.warp(1001); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); + uint256 quantity = 5; + + vm.prank(receiver); + vm.expectRevert("airdrop expired."); + drop.claim(receiver, quantity, proofs, 5); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `claim` -- for open claiming + //////////////////////////////////////////////////////////////*/ + + function test_state_claim_nonAllowlistedClaimer() public { + address receiver = address(0x123); + uint256 quantity = 1; + bytes32[] memory proofs; + + uint256 _availableAmount = drop.availableAmount(); + uint256 _nextIndex = drop.nextIndex(); + + vm.prank(receiver); + drop.claim(receiver, quantity, proofs, 0); + + assertEq(erc721.ownerOf(0), receiver); + assertEq(drop.nextIndex(), _nextIndex + quantity); + assertEq(drop.supplyClaimedByWallet(receiver), quantity); + assertEq(drop.availableAmount(), _availableAmount - quantity); + } + + function test_revert_claim_nonAllowlistedClaimer_invalidQuantity() public { + address receiver = address(0x123); + uint256 quantity = 2; + bytes32[] memory proofs; + + vm.prank(receiver); + vm.expectRevert("invalid quantity."); + drop.claim(receiver, quantity, proofs, 0); + } + + function test_revert_claim_nonAllowlistedClaimer_exceedsAvailable() public { + uint256 _availableAmount = drop.availableAmount(); + bytes32[] memory proofs; + + uint256 i = 0; + for (; i < _availableAmount; i++) { + address receiver = getActor(uint160(i)); + vm.prank(receiver); + drop.claim(receiver, 1, proofs, 0); + } + + address receiver = getActor(uint160(i)); + vm.prank(receiver); + vm.expectRevert("exceeds available tokens."); + drop.claim(receiver, 1, proofs, 0); + } +} diff --git a/src/test/benchmark/AccountBenchmark.t.sol b/src/test/benchmark/AccountBenchmark.t.sol new file mode 100644 index 000000000..6a1db1171 --- /dev/null +++ b/src/test/benchmark/AccountBenchmark.t.sol @@ -0,0 +1,513 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +// Test utils +import "../utils/BaseTest.sol"; +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +// Account Abstraction setup for smart wallets. +import { EntryPoint, IEntryPoint } from "contracts/prebuilts/account/utils/Entrypoint.sol"; +import { UserOperation } from "contracts/prebuilts/account/utils/UserOperation.sol"; + +// Target +import { IAccountPermissions } from "contracts/extension/interface/IAccountPermissions.sol"; +import { AccountFactory } from "contracts/prebuilts/account/non-upgradeable/AccountFactory.sol"; +import { Account as SimpleAccount } from "contracts/prebuilts/account/non-upgradeable/Account.sol"; +import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; + +/// @dev This is a dummy contract to test contract interactions with Account. +contract Number { + uint256 public num; + + function setNum(uint256 _num) public { + num = _num; + } + + function doubleNum() public { + num *= 2; + } + + function incrementNum() public { + num += 1; + } +} + +contract AccountBenchmarkTest is BaseTest { + // Target contracts + EntryPoint private entrypoint; + AccountFactory private accountFactory; + + // Mocks + Number internal numberContract; + + // Test params + uint256 private accountAdminPKey = 100; + address private accountAdmin; + + uint256 private accountSignerPKey = 200; + address private accountSigner; + + uint256 private nonSignerPKey = 300; + address private nonSigner; + + // UserOp terminology: `sender` is the smart wallet. + address private sender = 0x0df2C3523703d165Aa7fA1a552f3F0B56275DfC6; + address payable private beneficiary = payable(address(0x45654)); + + bytes32 private uidCache = bytes32("random uid"); + + event AccountCreated(address indexed account, address indexed accountAdmin); + + function _signSignerPermissionRequest( + IAccountPermissions.SignerPermissionRequest memory _req + ) internal view returns (bytes memory signature) { + bytes32 typehashSignerPermissionRequest = keccak256( + "SignerPermissionRequest(address signer,uint8 isAdmin,address[] approvedTargets,uint256 nativeTokenLimitPerTransaction,uint128 permissionStartTimestamp,uint128 permissionEndTimestamp,uint128 reqValidityStartTimestamp,uint128 reqValidityEndTimestamp,bytes32 uid)" + ); + bytes32 nameHash = keccak256(bytes("Account")); + bytes32 versionHash = keccak256(bytes("1")); + bytes32 typehashEip712 = keccak256( + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" + ); + bytes32 domainSeparator = keccak256(abi.encode(typehashEip712, nameHash, versionHash, block.chainid, sender)); + + bytes memory encodedRequest = bytes.concat( + abi.encode( + typehashSignerPermissionRequest, + _req.signer, + _req.isAdmin, + keccak256(abi.encodePacked(_req.approvedTargets)), + _req.nativeTokenLimitPerTransaction + ), + abi.encode( + _req.permissionStartTimestamp, + _req.permissionEndTimestamp, + _req.reqValidityStartTimestamp, + _req.reqValidityEndTimestamp, + _req.uid + ) + ); + bytes32 structHash = keccak256(encodedRequest); + bytes32 typedDataHash = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(accountAdminPKey, typedDataHash); + signature = abi.encodePacked(r, s, v); + } + + function _setupUserOp( + uint256 _signerPKey, + bytes memory _initCode, + bytes memory _callDataForEntrypoint + ) internal returns (UserOperation[] memory ops) { + uint256 nonce = entrypoint.getNonce(sender, 0); + + // Get user op fields + UserOperation memory op = UserOperation({ + sender: sender, + nonce: nonce, + initCode: _initCode, + callData: _callDataForEntrypoint, + callGasLimit: 500_000, + verificationGasLimit: 500_000, + preVerificationGas: 500_000, + maxFeePerGas: 0, + maxPriorityFeePerGas: 0, + paymasterAndData: bytes(""), + signature: bytes("") + }); + + // Sign UserOp + bytes32 opHash = EntryPoint(entrypoint).getUserOpHash(op); + bytes32 msgHash = ECDSA.toEthSignedMessageHash(opHash); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(_signerPKey, msgHash); + bytes memory userOpSignature = abi.encodePacked(r, s, v); + + address recoveredSigner = ECDSA.recover(msgHash, v, r, s); + address expectedSigner = vm.addr(_signerPKey); + assertEq(recoveredSigner, expectedSigner); + + op.signature = userOpSignature; + + // Store UserOp + ops = new UserOperation[](1); + ops[0] = op; + } + + function _setupUserOpExecute( + uint256 _signerPKey, + bytes memory _initCode, + address _target, + uint256 _value, + bytes memory _callData + ) internal returns (UserOperation[] memory) { + bytes memory callDataForEntrypoint = abi.encodeWithSignature( + "execute(address,uint256,bytes)", + _target, + _value, + _callData + ); + + return _setupUserOp(_signerPKey, _initCode, callDataForEntrypoint); + } + + function _setupUserOpExecuteBatch( + uint256 _signerPKey, + bytes memory _initCode, + address[] memory _target, + uint256[] memory _value, + bytes[] memory _callData + ) internal returns (UserOperation[] memory) { + bytes memory callDataForEntrypoint = abi.encodeWithSignature( + "executeBatch(address[],uint256[],bytes[])", + _target, + _value, + _callData + ); + + return _setupUserOp(_signerPKey, _initCode, callDataForEntrypoint); + } + + function setUp() public override { + super.setUp(); + + // Setup signers. + accountAdmin = vm.addr(accountAdminPKey); + vm.deal(accountAdmin, 100 ether); + + accountSigner = vm.addr(accountSignerPKey); + nonSigner = vm.addr(nonSignerPKey); + + // Setup contracts + entrypoint = new EntryPoint(); + // deploy account factory + accountFactory = new AccountFactory(deployer, IEntryPoint(payable(address(entrypoint)))); + // deploy dummy contract + numberContract = new Number(); + } + + /*/////////////////////////////////////////////////////////////// + Test: creating an account + //////////////////////////////////////////////////////////////*/ + + /// @dev Create an account by directly calling the factory. + function test_state_createAccount_viaFactory() public { + accountFactory.createAccount(accountAdmin, bytes("")); + } + + /// @dev Create an account via Entrypoint. + function test_state_createAccount_viaEntrypoint() public { + vm.pauseGasMetering(); + bytes memory initCallData = abi.encodeWithSignature("createAccount(address,bytes)", accountAdmin, bytes("")); + bytes memory initCode = abi.encodePacked(abi.encodePacked(address(accountFactory)), initCallData); + + vm.resumeGasMetering(); + UserOperation[] memory userOpCreateAccount = _setupUserOpExecute( + accountAdminPKey, + initCode, + address(0), + 0, + bytes("") + ); + + EntryPoint(entrypoint).handleOps(userOpCreateAccount, beneficiary); + } + + /*/////////////////////////////////////////////////////////////// + Test: performing a contract call + //////////////////////////////////////////////////////////////*/ + + function _setup_executeTransaction() internal { + bytes memory initCallData = abi.encodeWithSignature("createAccount(address,bytes)", accountAdmin, bytes("")); + bytes memory initCode = abi.encodePacked(abi.encodePacked(address(accountFactory)), initCallData); + + UserOperation[] memory userOpCreateAccount = _setupUserOpExecute( + accountAdminPKey, + initCode, + address(0), + 0, + bytes("") + ); + + EntryPoint(entrypoint).handleOps(userOpCreateAccount, beneficiary); + } + + /// @dev Perform a state changing transaction directly via account. + function test_state_executeTransaction() public { + vm.pauseGasMetering(); + _setup_executeTransaction(); + + address account = accountFactory.getAddress(accountAdmin, bytes("")); + + vm.resumeGasMetering(); + vm.prank(accountAdmin); + SimpleAccount(payable(account)).execute( + address(numberContract), + 0, + abi.encodeWithSignature("setNum(uint256)", 42) + ); + } + + /// @dev Perform many state changing transactions in a batch directly via account. + function test_state_executeBatchTransaction() public { + vm.pauseGasMetering(); + _setup_executeTransaction(); + + address account = accountFactory.getAddress(accountAdmin, bytes("")); + + uint256 count = 3; + address[] memory targets = new address[](count); + uint256[] memory values = new uint256[](count); + bytes[] memory callData = new bytes[](count); + + for (uint256 i = 0; i < count; i += 1) { + targets[i] = address(numberContract); + values[i] = 0; + callData[i] = abi.encodeWithSignature("incrementNum()", i); + } + + vm.resumeGasMetering(); + vm.prank(accountAdmin); + SimpleAccount(payable(account)).executeBatch(targets, values, callData); + } + + /// @dev Perform a state changing transaction via Entrypoint. + function test_state_executeTransaction_viaEntrypoint() public { + vm.pauseGasMetering(); + _setup_executeTransaction(); + + vm.resumeGasMetering(); + UserOperation[] memory userOp = _setupUserOpExecute( + accountAdminPKey, + bytes(""), + address(numberContract), + 0, + abi.encodeWithSignature("setNum(uint256)", 42) + ); + + EntryPoint(entrypoint).handleOps(userOp, beneficiary); + } + + /// @dev Perform many state changing transactions in a batch via Entrypoint. + function test_state_executeBatchTransaction_viaEntrypoint() public { + vm.pauseGasMetering(); + _setup_executeTransaction(); + + uint256 count = 3; + address[] memory targets = new address[](count); + uint256[] memory values = new uint256[](count); + bytes[] memory callData = new bytes[](count); + + for (uint256 i = 0; i < count; i += 1) { + targets[i] = address(numberContract); + values[i] = 0; + callData[i] = abi.encodeWithSignature("incrementNum()", i); + } + + vm.resumeGasMetering(); + UserOperation[] memory userOp = _setupUserOpExecuteBatch( + accountAdminPKey, + bytes(""), + targets, + values, + callData + ); + + EntryPoint(entrypoint).handleOps(userOp, beneficiary); + } + + /// @dev Perform many state changing transactions in a batch via Entrypoint. + function test_state_executeBatchTransaction_viaAccountSigner() public { + vm.pauseGasMetering(); + _setup_executeTransaction(); + + uint256 count = 3; + address[] memory targets = new address[](count); + uint256[] memory values = new uint256[](count); + bytes[] memory callData = new bytes[](count); + + for (uint256 i = 0; i < count; i += 1) { + targets[i] = address(numberContract); + values[i] = 0; + callData[i] = abi.encodeWithSignature("incrementNum()", i); + } + + address account = accountFactory.getAddress(accountAdmin, bytes("")); + + address[] memory approvedTargets = new address[](1); + approvedTargets[0] = address(numberContract); + + vm.resumeGasMetering(); + IAccountPermissions.SignerPermissionRequest memory permissionsReq = IAccountPermissions.SignerPermissionRequest( + accountSigner, + 0, + approvedTargets, + 1 ether, + 0, + type(uint128).max, + 0, + type(uint128).max, + uidCache + ); + + vm.prank(accountAdmin); + bytes memory sig = _signSignerPermissionRequest(permissionsReq); + SimpleAccount(payable(account)).setPermissionsForSigner(permissionsReq, sig); + + UserOperation[] memory userOp = _setupUserOpExecuteBatch( + accountSignerPKey, + bytes(""), + targets, + values, + callData + ); + + EntryPoint(entrypoint).handleOps(userOp, beneficiary); + } + + /// @dev Perform a state changing transaction via Entrypoint and a SIGNER_ROLE holder. + function test_state_executeTransaction_viaAccountSigner() public { + vm.pauseGasMetering(); + _setup_executeTransaction(); + + address account = accountFactory.getAddress(accountAdmin, bytes("")); + + address[] memory approvedTargets = new address[](1); + approvedTargets[0] = address(numberContract); + + vm.resumeGasMetering(); + IAccountPermissions.SignerPermissionRequest memory permissionsReq = IAccountPermissions.SignerPermissionRequest( + accountSigner, + 0, + approvedTargets, + 1 ether, + 0, + type(uint128).max, + 0, + type(uint128).max, + uidCache + ); + + vm.prank(accountAdmin); + bytes memory sig = _signSignerPermissionRequest(permissionsReq); + SimpleAccount(payable(account)).setPermissionsForSigner(permissionsReq, sig); + + UserOperation[] memory userOp = _setupUserOpExecute( + accountSignerPKey, + bytes(""), + address(numberContract), + 0, + abi.encodeWithSignature("setNum(uint256)", 42) + ); + + EntryPoint(entrypoint).handleOps(userOp, beneficiary); + } + + /*/////////////////////////////////////////////////////////////// + Test: receiving and sending native tokens + //////////////////////////////////////////////////////////////*/ + + /// @dev Send native tokens to an account. + function test_state_accountReceivesNativeTokens() public { + vm.pauseGasMetering(); + _setup_executeTransaction(); + + address account = accountFactory.getAddress(accountAdmin, bytes("")); + + vm.resumeGasMetering(); + vm.prank(accountAdmin); + // solhint-disable-next-line avoid-low-level-calls + (bool success, bytes memory data) = payable(account).call{ value: 1000 }(""); + + // Silence warning: Return value of low-level calls not used. + (success, data) = (success, data); + } + + /// @dev Transfer native tokens out of an account. + function test_state_transferOutsNativeTokens() public { + vm.pauseGasMetering(); + _setup_executeTransaction(); + + uint256 value = 1000; + + address account = accountFactory.getAddress(accountAdmin, bytes("")); + vm.prank(accountAdmin); + // solhint-disable-next-line avoid-low-level-calls + (bool success, bytes memory data) = payable(account).call{ value: value }(""); + + // Silence warning: Return value of low-level calls not used. + (success, data) = (success, data); + + address recipient = address(0x3456); + + vm.resumeGasMetering(); + UserOperation[] memory userOp = _setupUserOpExecute(accountAdminPKey, bytes(""), recipient, value, bytes("")); + + EntryPoint(entrypoint).handleOps(userOp, beneficiary); + } + + /// @dev Add and remove a deposit for the account from the Entrypoint. + + function test_state_addAndWithdrawDeposit() public { + vm.pauseGasMetering(); + _setup_executeTransaction(); + + address account = accountFactory.getAddress(accountAdmin, bytes("")); + + vm.resumeGasMetering(); + vm.startPrank(accountAdmin); + SimpleAccount(payable(account)).addDeposit{ value: 1000 }(); + + SimpleAccount(payable(account)).withdrawDepositTo(payable(accountSigner), 500); + vm.stopPrank(); + } + + /*/////////////////////////////////////////////////////////////// + Test: receiving ERC-721 and ERC-1155 NFTs + //////////////////////////////////////////////////////////////*/ + + /// @dev Send an ERC-721 NFT to an account. + function test_state_receiveERC721NFT() public { + vm.pauseGasMetering(); + _setup_executeTransaction(); + address account = accountFactory.getAddress(accountAdmin, bytes("")); + + vm.resumeGasMetering(); + erc721.mint(account, 1); + } + + /// @dev Send an ERC-1155 NFT to an account. + function test_state_receiveERC1155NFT() public { + vm.pauseGasMetering(); + _setup_executeTransaction(); + address account = accountFactory.getAddress(accountAdmin, bytes("")); + + vm.resumeGasMetering(); + erc1155.mint(account, 0, 1); + } + + /*/////////////////////////////////////////////////////////////// + Test: setting contract metadata + //////////////////////////////////////////////////////////////*/ + + /// @dev Set contract metadata via entrypoint. + function test_state_contractMetadata() public { + vm.pauseGasMetering(); + _setup_executeTransaction(); + address account = accountFactory.getAddress(accountAdmin, bytes("")); + + vm.prank(accountAdmin); + SimpleAccount(payable(account)).setContractURI("https://example.com"); + + vm.resumeGasMetering(); + UserOperation[] memory userOp = _setupUserOpExecute( + accountAdminPKey, + bytes(""), + address(account), + 0, + abi.encodeWithSignature("setContractURI(string)", "https://thirdweb.com") + ); + + EntryPoint(entrypoint).handleOps(userOp, beneficiary); + } +} diff --git a/src/test/benchmark/AirdropBenchmark.t.sol b/src/test/benchmark/AirdropBenchmark.t.sol new file mode 100644 index 000000000..be93c7126 --- /dev/null +++ b/src/test/benchmark/AirdropBenchmark.t.sol @@ -0,0 +1,695 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { Airdrop } from "contracts/prebuilts/airdrop/Airdrop.sol"; + +// Test imports +import { TWProxy } from "contracts/infra/TWProxy.sol"; +import "../utils/BaseTest.sol"; + +contract ERC721ReceiverCompliant is IERC721Receiver { + function onERC721Received( + address, + address, + uint256, + bytes calldata + ) external view virtual override returns (bytes4) { + return this.onERC721Received.selector; + } +} + +contract ERC1155ReceiverCompliant is IERC1155Receiver { + function onERC1155Received( + address operator, + address from, + uint256 id, + uint256 value, + bytes calldata data + ) external view virtual override returns (bytes4) { + return this.onERC1155Received.selector; + } + + function onERC1155BatchReceived( + address operator, + address from, + uint256[] calldata ids, + uint256[] calldata values, + bytes calldata data + ) external returns (bytes4) { + return this.onERC1155BatchReceived.selector; + } + + function supportsInterface(bytes4 interfaceId) external view returns (bool) {} +} + +contract AirdropBenchmarkTest is BaseTest { + Airdrop internal airdrop; + + bytes32 private constant CONTENT_TYPEHASH_ERC20 = + keccak256("AirdropContentERC20(address recipient,uint256 amount)"); + bytes32 private constant REQUEST_TYPEHASH_ERC20 = + keccak256( + "AirdropRequestERC20(bytes32 uid,address tokenAddress,uint256 expirationTimestamp,AirdropContentERC20[] contents)AirdropContentERC20(address recipient,uint256 amount)" + ); + + bytes32 private constant CONTENT_TYPEHASH_ERC721 = + keccak256("AirdropContentERC721(address recipient,uint256 tokenId)"); + bytes32 private constant REQUEST_TYPEHASH_ERC721 = + keccak256( + "AirdropRequestERC721(bytes32 uid,address tokenAddress,uint256 expirationTimestamp,AirdropContentERC721[] contents)AirdropContentERC721(address recipient,uint256 tokenId)" + ); + + bytes32 private constant CONTENT_TYPEHASH_ERC1155 = + keccak256("AirdropContentERC1155(address recipient,uint256 tokenId,uint256 amount)"); + bytes32 private constant REQUEST_TYPEHASH_ERC1155 = + keccak256( + "AirdropRequestERC1155(bytes32 uid,address tokenAddress,uint256 expirationTimestamp,AirdropContentERC1155[] contents)AirdropContentERC1155(address recipient,uint256 tokenId,uint256 amount)" + ); + + bytes32 private constant NAME_HASH = keccak256(bytes("Airdrop")); + bytes32 private constant VERSION_HASH = keccak256(bytes("1")); + bytes32 private constant TYPE_HASH_EIP712 = + keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"); + + bytes32 internal domainSeparator; + + function setUp() public override { + super.setUp(); + + address impl = address(new Airdrop()); + + airdrop = Airdrop(payable(address(new TWProxy(impl, abi.encodeCall(Airdrop.initialize, (signer, "")))))); + + domainSeparator = keccak256( + abi.encode(TYPE_HASH_EIP712, NAME_HASH, VERSION_HASH, block.chainid, address(airdrop)) + ); + } + + function _getContentsERC20(uint256 length) internal pure returns (Airdrop.AirdropContentERC20[] memory contents) { + contents = new Airdrop.AirdropContentERC20[](length); + for (uint256 i = 0; i < length; i++) { + contents[i].recipient = address(uint160(i + 10)); + contents[i].amount = i + 10; + } + } + + function _getContentsERC721(uint256 length) internal pure returns (Airdrop.AirdropContentERC721[] memory contents) { + contents = new Airdrop.AirdropContentERC721[](length); + for (uint256 i = 0; i < length; i++) { + contents[i].recipient = address(uint160(i + 10)); + contents[i].tokenId = i; + } + } + + function _getContentsERC1155( + uint256 length + ) internal pure returns (Airdrop.AirdropContentERC1155[] memory contents) { + contents = new Airdrop.AirdropContentERC1155[](length); + for (uint256 i = 0; i < length; i++) { + contents[i].recipient = address(uint160(i + 10)); + contents[i].tokenId = 0; + contents[i].amount = i + 10; + } + } + + function _signReqERC20( + Airdrop.AirdropRequestERC20 memory req, + uint256 privateKey + ) internal view returns (bytes memory signature) { + bytes32[] memory contentHashes = new bytes32[](req.contents.length); + for (uint i = 0; i < req.contents.length; i++) { + contentHashes[i] = keccak256( + abi.encode(CONTENT_TYPEHASH_ERC20, req.contents[i].recipient, req.contents[i].amount) + ); + } + bytes32 contentHash = keccak256(abi.encodePacked(contentHashes)); + + bytes memory dataToHash; + { + dataToHash = abi.encode( + REQUEST_TYPEHASH_ERC20, + req.uid, + req.tokenAddress, + req.expirationTimestamp, + contentHash + ); + } + + { + bytes32 _structHash = keccak256(dataToHash); + bytes32 typedDataHash = keccak256(abi.encodePacked("\x19\x01", domainSeparator, _structHash)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, typedDataHash); + + signature = abi.encodePacked(r, s, v); + } + } + + function _signReqERC721( + Airdrop.AirdropRequestERC721 memory req, + uint256 privateKey + ) internal view returns (bytes memory signature) { + bytes32[] memory contentHashes = new bytes32[](req.contents.length); + for (uint i = 0; i < req.contents.length; i++) { + contentHashes[i] = keccak256( + abi.encode(CONTENT_TYPEHASH_ERC721, req.contents[i].recipient, req.contents[i].tokenId) + ); + } + bytes32 contentHash = keccak256(abi.encodePacked(contentHashes)); + + bytes memory dataToHash; + { + dataToHash = abi.encode( + REQUEST_TYPEHASH_ERC721, + req.uid, + req.tokenAddress, + req.expirationTimestamp, + contentHash + ); + } + + { + bytes32 _structHash = keccak256(dataToHash); + bytes32 typedDataHash = keccak256(abi.encodePacked("\x19\x01", domainSeparator, _structHash)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, typedDataHash); + + signature = abi.encodePacked(r, s, v); + } + } + + function _signReqERC1155( + Airdrop.AirdropRequestERC1155 memory req, + uint256 privateKey + ) internal view returns (bytes memory signature) { + bytes32[] memory contentHashes = new bytes32[](req.contents.length); + for (uint i = 0; i < req.contents.length; i++) { + contentHashes[i] = keccak256( + abi.encode( + CONTENT_TYPEHASH_ERC1155, + req.contents[i].recipient, + req.contents[i].tokenId, + req.contents[i].amount + ) + ); + } + bytes32 contentHash = keccak256(abi.encodePacked(contentHashes)); + + bytes memory dataToHash; + { + dataToHash = abi.encode( + REQUEST_TYPEHASH_ERC1155, + req.uid, + req.tokenAddress, + req.expirationTimestamp, + contentHash + ); + } + + { + bytes32 _structHash = keccak256(dataToHash); + bytes32 typedDataHash = keccak256(abi.encodePacked("\x19\x01", domainSeparator, _structHash)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, typedDataHash); + + signature = abi.encodePacked(r, s, v); + } + } + + /*/////////////////////////////////////////////////////////////// + Benchmark: Airdrop Push ERC20 + //////////////////////////////////////////////////////////////*/ + + function test_benchmark_airdropPush_erc20_10() public { + vm.pauseGasMetering(); + + erc20.mint(signer, 100 ether); + vm.prank(signer); + erc20.approve(address(airdrop), 100 ether); + + Airdrop.AirdropContentERC20[] memory contents = _getContentsERC20(10); + + vm.prank(signer); + vm.resumeGasMetering(); + airdrop.airdropERC20(address(erc20), contents); + } + + function test_benchmark_airdropPush_erc20_100() public { + vm.pauseGasMetering(); + + erc20.mint(signer, 100 ether); + vm.prank(signer); + erc20.approve(address(airdrop), 100 ether); + + Airdrop.AirdropContentERC20[] memory contents = _getContentsERC20(100); + + vm.prank(signer); + vm.resumeGasMetering(); + airdrop.airdropERC20(address(erc20), contents); + } + + function test_benchmark_airdropPush_erc20_1000() public { + vm.pauseGasMetering(); + + erc20.mint(signer, 100 ether); + vm.prank(signer); + erc20.approve(address(airdrop), 100 ether); + + Airdrop.AirdropContentERC20[] memory contents = _getContentsERC20(1000); + + vm.prank(signer); + vm.resumeGasMetering(); + airdrop.airdropERC20(address(erc20), contents); + } + + /*/////////////////////////////////////////////////////////////// + Benchmark: Airdrop Signature ERC20 + //////////////////////////////////////////////////////////////*/ + + function test_benchmark_airdropSignature_erc20_10() public { + vm.pauseGasMetering(); + + erc20.mint(signer, 100 ether); + vm.prank(signer); + erc20.approve(address(airdrop), 100 ether); + + Airdrop.AirdropContentERC20[] memory contents = _getContentsERC20(10); + Airdrop.AirdropRequestERC20 memory req = Airdrop.AirdropRequestERC20({ + uid: bytes32(uint256(1)), + tokenAddress: address(erc20), + expirationTimestamp: 1000, + contents: contents + }); + bytes memory signature = _signReqERC20(req, privateKey); + + vm.prank(signer); + vm.resumeGasMetering(); + airdrop.airdropERC20WithSignature(req, signature); + } + + function test_benchmark_airdropSignature_erc20_100() public { + vm.pauseGasMetering(); + + erc20.mint(signer, 100 ether); + vm.prank(signer); + erc20.approve(address(airdrop), 100 ether); + + Airdrop.AirdropContentERC20[] memory contents = _getContentsERC20(100); + Airdrop.AirdropRequestERC20 memory req = Airdrop.AirdropRequestERC20({ + uid: bytes32(uint256(1)), + tokenAddress: address(erc20), + expirationTimestamp: 1000, + contents: contents + }); + bytes memory signature = _signReqERC20(req, privateKey); + + vm.prank(signer); + vm.resumeGasMetering(); + airdrop.airdropERC20WithSignature(req, signature); + } + + function test_benchmark_airdropSignature_erc20_1000() public { + vm.pauseGasMetering(); + + erc20.mint(signer, 100 ether); + vm.prank(signer); + erc20.approve(address(airdrop), 100 ether); + + Airdrop.AirdropContentERC20[] memory contents = _getContentsERC20(1000); + Airdrop.AirdropRequestERC20 memory req = Airdrop.AirdropRequestERC20({ + uid: bytes32(uint256(1)), + tokenAddress: address(erc20), + expirationTimestamp: 1000, + contents: contents + }); + bytes memory signature = _signReqERC20(req, privateKey); + + vm.prank(signer); + vm.resumeGasMetering(); + airdrop.airdropERC20WithSignature(req, signature); + } + + /*/////////////////////////////////////////////////////////////// + Benchmark: Airdrop Claim ERC20 + //////////////////////////////////////////////////////////////*/ + + function test_benchmark_airdropClaim_erc20() public { + vm.pauseGasMetering(); + + erc20.mint(signer, 100 ether); + vm.prank(signer); + erc20.approve(address(airdrop), 100 ether); + + string[] memory inputs = new string[](3); + inputs[0] = "node"; + inputs[1] = "src/test/scripts/generateRootAirdrop.ts"; + inputs[2] = Strings.toString(uint256(5)); + bytes memory result = vm.ffi(inputs); + bytes32 root = abi.decode(result, (bytes32)); + + // set merkle root + vm.prank(signer); + airdrop.setMerkleRoot(address(erc20), root, true); + + // generate proof + inputs[1] = "src/test/scripts/getProofAirdrop.ts"; + result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); + uint256 quantity = 5; + + vm.prank(receiver); + vm.resumeGasMetering(); + airdrop.claimERC20(address(erc20), receiver, quantity, proofs); + } + + /*/////////////////////////////////////////////////////////////// + Benchmark: Airdrop Push ERC721 + //////////////////////////////////////////////////////////////*/ + + function test_benchmark_airdropPush_erc721_10() public { + vm.pauseGasMetering(); + + erc721.mint(signer, 100); + vm.prank(signer); + erc721.setApprovalForAll(address(airdrop), true); + + Airdrop.AirdropContentERC721[] memory contents = _getContentsERC721(10); + + vm.prank(signer); + vm.resumeGasMetering(); + airdrop.airdropERC721(address(erc721), contents); + } + + function test_benchmark_airdropPush_erc721_100() public { + vm.pauseGasMetering(); + + erc721.mint(signer, 100); + vm.prank(signer); + erc721.setApprovalForAll(address(airdrop), true); + + Airdrop.AirdropContentERC721[] memory contents = _getContentsERC721(100); + + vm.prank(signer); + vm.resumeGasMetering(); + airdrop.airdropERC721(address(erc721), contents); + } + + function test_benchmark_airdropPush_erc721_1000() public { + vm.pauseGasMetering(); + + erc721.mint(signer, 1000); + vm.prank(signer); + erc721.setApprovalForAll(address(airdrop), true); + + Airdrop.AirdropContentERC721[] memory contents = _getContentsERC721(1000); + + vm.prank(signer); + vm.resumeGasMetering(); + airdrop.airdropERC721(address(erc721), contents); + } + + function test_benchmark_airdropPush_erc721ReceiverCompliant() public { + vm.pauseGasMetering(); + + erc721.mint(signer, 100); + vm.prank(signer); + erc721.setApprovalForAll(address(airdrop), true); + + Airdrop.AirdropContentERC721[] memory contents = new Airdrop.AirdropContentERC721[](1); + + contents[0].recipient = address(new ERC721ReceiverCompliant()); + contents[0].tokenId = 0; + + vm.prank(signer); + vm.resumeGasMetering(); + airdrop.airdropERC721(address(erc721), contents); + } + + /*/////////////////////////////////////////////////////////////// + Benchmark: Airdrop Signature ERC721 + //////////////////////////////////////////////////////////////*/ + + function test_benchmark_airdropSignature_erc721_10() public { + vm.pauseGasMetering(); + + erc721.mint(signer, 1000); + vm.prank(signer); + erc721.setApprovalForAll(address(airdrop), true); + + Airdrop.AirdropContentERC721[] memory contents = _getContentsERC721(10); + Airdrop.AirdropRequestERC721 memory req = Airdrop.AirdropRequestERC721({ + uid: bytes32(uint256(1)), + tokenAddress: address(erc721), + expirationTimestamp: 1000, + contents: contents + }); + bytes memory signature = _signReqERC721(req, privateKey); + + vm.prank(signer); + vm.resumeGasMetering(); + airdrop.airdropERC721WithSignature(req, signature); + } + + function test_benchmark_airdropSignature_erc721_100() public { + vm.pauseGasMetering(); + + erc721.mint(signer, 1000); + vm.prank(signer); + erc721.setApprovalForAll(address(airdrop), true); + + Airdrop.AirdropContentERC721[] memory contents = _getContentsERC721(100); + Airdrop.AirdropRequestERC721 memory req = Airdrop.AirdropRequestERC721({ + uid: bytes32(uint256(1)), + tokenAddress: address(erc721), + expirationTimestamp: 1000, + contents: contents + }); + bytes memory signature = _signReqERC721(req, privateKey); + + vm.prank(signer); + vm.resumeGasMetering(); + airdrop.airdropERC721WithSignature(req, signature); + } + + function test_benchmark_airdropSignature_erc721_1000() public { + vm.pauseGasMetering(); + + erc721.mint(signer, 1000); + vm.prank(signer); + erc721.setApprovalForAll(address(airdrop), true); + + Airdrop.AirdropContentERC721[] memory contents = _getContentsERC721(1000); + Airdrop.AirdropRequestERC721 memory req = Airdrop.AirdropRequestERC721({ + uid: bytes32(uint256(1)), + tokenAddress: address(erc721), + expirationTimestamp: 1000, + contents: contents + }); + bytes memory signature = _signReqERC721(req, privateKey); + + vm.prank(signer); + vm.resumeGasMetering(); + airdrop.airdropERC721WithSignature(req, signature); + } + + /*/////////////////////////////////////////////////////////////// + Benchmark: Airdrop Claim ERC721 + //////////////////////////////////////////////////////////////*/ + + function test_benchmark_airdropClaim_erc721() public { + vm.pauseGasMetering(); + + erc721.mint(signer, 100); + vm.prank(signer); + erc721.setApprovalForAll(address(airdrop), true); + + string[] memory inputs = new string[](3); + inputs[0] = "node"; + inputs[1] = "src/test/scripts/generateRootAirdrop.ts"; + inputs[2] = Strings.toString(uint256(5)); + bytes memory result = vm.ffi(inputs); + bytes32 root = abi.decode(result, (bytes32)); + + // set merkle root + vm.prank(signer); + airdrop.setMerkleRoot(address(erc721), root, true); + + // generate proof + inputs[1] = "src/test/scripts/getProofAirdrop.ts"; + result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); + uint256 tokenId = 5; + + vm.prank(receiver); + vm.resumeGasMetering(); + airdrop.claimERC721(address(erc721), receiver, tokenId, proofs); + } + + /*/////////////////////////////////////////////////////////////// + Benchmark: Airdrop Push ERC1155 + //////////////////////////////////////////////////////////////*/ + + function test_benchmark_airdropPush_erc1155_10() public { + vm.pauseGasMetering(); + + erc1155.mint(signer, 0, 100 ether); + vm.prank(signer); + erc1155.setApprovalForAll(address(airdrop), true); + + Airdrop.AirdropContentERC1155[] memory contents = _getContentsERC1155(10); + + vm.prank(signer); + vm.resumeGasMetering(); + airdrop.airdropERC1155(address(erc1155), contents); + } + + function test_benchmark_airdropPush_erc1155_100() public { + vm.pauseGasMetering(); + + erc1155.mint(signer, 0, 100 ether); + vm.prank(signer); + erc1155.setApprovalForAll(address(airdrop), true); + + Airdrop.AirdropContentERC1155[] memory contents = _getContentsERC1155(100); + + vm.prank(signer); + vm.resumeGasMetering(); + airdrop.airdropERC1155(address(erc1155), contents); + } + + function test_benchmark_airdropPush_erc1155_1000() public { + vm.pauseGasMetering(); + + erc1155.mint(signer, 0, 100 ether); + vm.prank(signer); + erc1155.setApprovalForAll(address(airdrop), true); + + Airdrop.AirdropContentERC1155[] memory contents = _getContentsERC1155(1000); + + vm.prank(signer); + vm.resumeGasMetering(); + airdrop.airdropERC1155(address(erc1155), contents); + } + + function test_benchmark_airdropPush_erc1155ReceiverCompliant() public { + vm.pauseGasMetering(); + + erc1155.mint(signer, 0, 100 ether); + vm.prank(signer); + erc1155.setApprovalForAll(address(airdrop), true); + + Airdrop.AirdropContentERC1155[] memory contents = new Airdrop.AirdropContentERC1155[](1); + + contents[0].recipient = address(new ERC1155ReceiverCompliant()); + contents[0].tokenId = 0; + contents[0].amount = 100; + + vm.prank(signer); + vm.resumeGasMetering(); + airdrop.airdropERC1155(address(erc1155), contents); + } + + /*/////////////////////////////////////////////////////////////// + Benchmark: Airdrop Signature ERC1155 + //////////////////////////////////////////////////////////////*/ + + function test_benchmark_airdropSignature_erc115_10() public { + vm.pauseGasMetering(); + + erc1155.mint(signer, 0, 100 ether); + vm.prank(signer); + erc1155.setApprovalForAll(address(airdrop), true); + + Airdrop.AirdropContentERC1155[] memory contents = _getContentsERC1155(10); + Airdrop.AirdropRequestERC1155 memory req = Airdrop.AirdropRequestERC1155({ + uid: bytes32(uint256(1)), + tokenAddress: address(erc1155), + expirationTimestamp: 1000, + contents: contents + }); + bytes memory signature = _signReqERC1155(req, privateKey); + + vm.prank(signer); + vm.resumeGasMetering(); + airdrop.airdropERC1155WithSignature(req, signature); + } + + function test_benchmark_airdropSignature_erc115_100() public { + vm.pauseGasMetering(); + + erc1155.mint(signer, 0, 100 ether); + vm.prank(signer); + erc1155.setApprovalForAll(address(airdrop), true); + + Airdrop.AirdropContentERC1155[] memory contents = _getContentsERC1155(100); + Airdrop.AirdropRequestERC1155 memory req = Airdrop.AirdropRequestERC1155({ + uid: bytes32(uint256(1)), + tokenAddress: address(erc1155), + expirationTimestamp: 1000, + contents: contents + }); + bytes memory signature = _signReqERC1155(req, privateKey); + + vm.prank(signer); + vm.resumeGasMetering(); + airdrop.airdropERC1155WithSignature(req, signature); + } + + function test_benchmark_airdropSignature_erc115_1000() public { + vm.pauseGasMetering(); + + erc1155.mint(signer, 0, 100 ether); + vm.prank(signer); + erc1155.setApprovalForAll(address(airdrop), true); + + Airdrop.AirdropContentERC1155[] memory contents = _getContentsERC1155(1000); + Airdrop.AirdropRequestERC1155 memory req = Airdrop.AirdropRequestERC1155({ + uid: bytes32(uint256(1)), + tokenAddress: address(erc1155), + expirationTimestamp: 1000, + contents: contents + }); + bytes memory signature = _signReqERC1155(req, privateKey); + + vm.prank(signer); + vm.resumeGasMetering(); + airdrop.airdropERC1155WithSignature(req, signature); + } + + /*/////////////////////////////////////////////////////////////// + Benchmark: Airdrop Claim ERC1155 + //////////////////////////////////////////////////////////////*/ + + function test_benchmark_airdropClaim_erc1155() public { + vm.pauseGasMetering(); + + erc1155.mint(signer, 0, 100 ether); + vm.prank(signer); + erc1155.setApprovalForAll(address(airdrop), true); + + string[] memory inputs = new string[](4); + inputs[0] = "node"; + inputs[1] = "src/test/scripts/generateRootAirdrop1155.ts"; + inputs[2] = Strings.toString(uint256(0)); + inputs[3] = Strings.toString(uint256(5)); + bytes memory result = vm.ffi(inputs); + bytes32 root = abi.decode(result, (bytes32)); + + // set merkle root + vm.prank(signer); + airdrop.setMerkleRoot(address(erc1155), root, true); + + // generate proof + inputs[1] = "src/test/scripts/getProofAirdrop1155.ts"; + result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); + uint256 quantity = 5; + + vm.prank(receiver); + vm.resumeGasMetering(); + airdrop.claimERC1155(address(erc1155), receiver, 0, quantity, proofs); + } +} diff --git a/src/test/benchmark/AirdropERC1155Benchmark.t.sol b/src/test/benchmark/AirdropERC1155Benchmark.t.sol new file mode 100644 index 000000000..389903565 --- /dev/null +++ b/src/test/benchmark/AirdropERC1155Benchmark.t.sol @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { AirdropERC1155, IAirdropERC1155 } from "contracts/prebuilts/unaudited/airdrop/AirdropERC1155.sol"; + +// Test imports +import { Wallet } from "../utils/Wallet.sol"; +import "../utils/BaseTest.sol"; + +contract AirdropERC1155BenchmarkTest is BaseTest { + AirdropERC1155 internal drop; + + Wallet internal tokenOwner; + + IAirdropERC1155.AirdropContent[] internal _contentsOne; + IAirdropERC1155.AirdropContent[] internal _contentsTwo; + + uint256 countOne; + uint256 countTwo; + + function setUp() public override { + super.setUp(); + + drop = AirdropERC1155(getContract("AirdropERC1155")); + + tokenOwner = getWallet(); + + erc1155.mint(address(tokenOwner), 0, 1000); + erc1155.mint(address(tokenOwner), 1, 2000); + erc1155.mint(address(tokenOwner), 2, 3000); + erc1155.mint(address(tokenOwner), 3, 4000); + erc1155.mint(address(tokenOwner), 4, 5000); + + tokenOwner.setApprovalForAllERC1155(address(erc1155), address(drop), true); + + countOne = 1000; + countTwo = 200; + + for (uint256 i = 0; i < countOne; i++) { + _contentsOne.push( + IAirdropERC1155.AirdropContent({ recipient: getActor(uint160(i)), tokenId: i % 5, amount: 5 }) + ); + } + + for (uint256 i = countOne; i < countOne + countTwo; i++) { + _contentsTwo.push( + IAirdropERC1155.AirdropContent({ recipient: getActor(uint160(i)), tokenId: i % 5, amount: 5 }) + ); + } + } + + /*/////////////////////////////////////////////////////////////// + Benchmark: AirdropERC1155 + //////////////////////////////////////////////////////////////*/ + + function test_benchmark_airdropERC1155_airdrop() public { + vm.pauseGasMetering(); + vm.prank(deployer); + vm.resumeGasMetering(); + drop.airdropERC1155(address(erc1155), address(tokenOwner), _contentsOne); + } +} diff --git a/src/test/benchmark/AirdropERC20Benchmark.t.sol b/src/test/benchmark/AirdropERC20Benchmark.t.sol new file mode 100644 index 000000000..d36ba5f49 --- /dev/null +++ b/src/test/benchmark/AirdropERC20Benchmark.t.sol @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { AirdropERC20, IAirdropERC20 } from "contracts/prebuilts/unaudited/airdrop/AirdropERC20.sol"; + +// Test imports +import { Wallet } from "../utils/Wallet.sol"; +import "../utils/BaseTest.sol"; + +import "../mocks/MockERC20NonCompliant.sol"; + +contract AirdropERC20BenchmarkTest is BaseTest { + AirdropERC20 internal drop; + + Wallet internal tokenOwner; + + IAirdropERC20.AirdropContent[] internal _contentsOne; + IAirdropERC20.AirdropContent[] internal _contentsTwo; + + uint256 countOne; + uint256 countTwo; + + function setUp() public override { + super.setUp(); + + drop = AirdropERC20(getContract("AirdropERC20")); + + tokenOwner = getWallet(); + + erc20.mint(address(tokenOwner), 10_000 ether); + tokenOwner.setAllowanceERC20(address(erc20), address(drop), type(uint256).max); + + countOne = 1000; + countTwo = 200; + + for (uint256 i = 0; i < countOne; i++) { + _contentsOne.push(IAirdropERC20.AirdropContent({ recipient: getActor(uint160(i)), amount: 10 ether })); + } + + for (uint256 i = countOne; i < countOne + countTwo; i++) { + _contentsTwo.push(IAirdropERC20.AirdropContent({ recipient: getActor(uint160(i)), amount: 10 ether })); + } + } + + /*/////////////////////////////////////////////////////////////// + Benchmark: AirdropERC20 + //////////////////////////////////////////////////////////////*/ + + function test_benchmark_airdropERC20_airdrop() public { + vm.pauseGasMetering(); + vm.prank(deployer); + vm.resumeGasMetering(); + drop.airdropERC20(address(erc20), address(tokenOwner), _contentsOne); + } +} diff --git a/src/test/benchmark/AirdropERC721Benchmark.t.sol b/src/test/benchmark/AirdropERC721Benchmark.t.sol new file mode 100644 index 000000000..407636572 --- /dev/null +++ b/src/test/benchmark/AirdropERC721Benchmark.t.sol @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { AirdropERC721, IAirdropERC721 } from "contracts/prebuilts/unaudited/airdrop/AirdropERC721.sol"; + +// Test imports +import { Wallet } from "../utils/Wallet.sol"; +import "../utils/BaseTest.sol"; + +contract AirdropERC721BenchmarkTest is BaseTest { + AirdropERC721 internal drop; + + Wallet internal tokenOwner; + + IAirdropERC721.AirdropContent[] internal _contentsOne; + IAirdropERC721.AirdropContent[] internal _contentsTwo; + + uint256 countOne; + uint256 countTwo; + + function setUp() public override { + super.setUp(); + + drop = AirdropERC721(getContract("AirdropERC721")); + + tokenOwner = getWallet(); + + erc721.mint(address(tokenOwner), 1500); + tokenOwner.setApprovalForAllERC721(address(erc721), address(drop), true); + + countOne = 1000; + countTwo = 200; + + for (uint256 i = 0; i < countOne; i++) { + _contentsOne.push(IAirdropERC721.AirdropContent({ recipient: getActor(uint160(i)), tokenId: i })); + } + + for (uint256 i = countOne; i < countOne + countTwo; i++) { + _contentsTwo.push(IAirdropERC721.AirdropContent({ recipient: getActor(uint160(i)), tokenId: i })); + } + } + + /*/////////////////////////////////////////////////////////////// + Benchmark: AirdropERC721 + //////////////////////////////////////////////////////////////*/ + + function test_benchmark_airdropERC721_airdrop() public { + vm.pauseGasMetering(); + vm.prank(deployer); + vm.resumeGasMetering(); + drop.airdropERC721(address(erc721), address(tokenOwner), _contentsOne); + } +} diff --git a/src/test/benchmark/DropERC1155Benchmark.t.sol b/src/test/benchmark/DropERC1155Benchmark.t.sol new file mode 100644 index 000000000..093be4089 --- /dev/null +++ b/src/test/benchmark/DropERC1155Benchmark.t.sol @@ -0,0 +1,288 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { DropERC1155, IPermissions, ILazyMint } from "contracts/prebuilts/drop/DropERC1155.sol"; + +// Test imports +import "../utils/BaseTest.sol"; + +contract DropERC1155BenchmarkTest is BaseTest { + using Strings for uint256; + using Strings for address; + + event TokensLazyMinted(uint256 indexed startTokenId, uint256 endTokenId, string baseURI, bytes encryptedBaseURI); + event TokenURIRevealed(uint256 indexed index, string revealedURI); + + DropERC1155 public drop; + + bytes private emptyEncodedBytes = abi.encode("", ""); + + using stdStorage for StdStorage; + + function setUp() public override { + super.setUp(); + drop = DropERC1155(getContract("DropERC1155")); + + erc20.mint(deployer, 1_000 ether); + vm.deal(deployer, 1_000 ether); + } + + /*/////////////////////////////////////////////////////////////// + DropERC1155 benchmark + //////////////////////////////////////////////////////////////*/ + + function test_benchmark_dropERC1155_claim() public { + vm.pauseGasMetering(); + uint256 _tokenId = 0; + string[] memory inputs = new string[](5); + + inputs[0] = "node"; + inputs[1] = "src/test/scripts/generateRoot.ts"; + inputs[2] = "300"; + inputs[3] = "5"; + inputs[4] = Strings.toHexString(uint160(address(erc20))); // address of erc20 + + bytes memory result = vm.ffi(inputs); + // revert(); + bytes32 root = abi.decode(result, (bytes32)); + + inputs[1] = "src/test/scripts/getProof.ts"; + result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + DropERC1155.AllowlistProof memory alp; + alp.proof = proofs; + alp.quantityLimitPerWallet = 300; + alp.pricePerToken = 5; + alp.currency = address(erc20); + + vm.warp(1); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); // in allowlist + + DropERC1155.ClaimCondition[] memory conditions = new DropERC1155.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 500; + conditions[0].quantityLimitPerWallet = 10; + conditions[0].merkleRoot = root; + conditions[0].pricePerToken = 10; + conditions[0].currency = address(erc20); + + vm.prank(deployer); + drop.lazyMint(500, "ipfs://", emptyEncodedBytes); + vm.prank(deployer); + drop.setClaimConditions(_tokenId, conditions, false); + + erc20.mint(receiver, 10000); + vm.prank(receiver); + erc20.approve(address(drop), 10000); + + vm.prank(receiver, receiver); + vm.resumeGasMetering(); + drop.claim(receiver, _tokenId, 100, address(erc20), 5, alp, ""); + } + + function test_benchmark_dropERC1155_setClaimConditions_five_conditions() public { + vm.pauseGasMetering(); + uint256 _tokenId = 0; + string[] memory inputs = new string[](5); + + inputs[0] = "node"; + inputs[1] = "src/test/scripts/generateRoot.ts"; + inputs[2] = "300"; + inputs[3] = "5"; + inputs[4] = Strings.toHexString(uint160(address(erc20))); // address of erc20 + + bytes memory result = vm.ffi(inputs); + // revert(); + bytes32 root = abi.decode(result, (bytes32)); + + inputs[1] = "src/test/scripts/getProof.ts"; + result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + DropERC1155.AllowlistProof memory alp; + alp.proof = proofs; + alp.quantityLimitPerWallet = 300; + alp.pricePerToken = 5; + alp.currency = address(erc20); + + vm.warp(1); + + DropERC1155.ClaimCondition[] memory conditions = new DropERC1155.ClaimCondition[](5); + conditions[0].maxClaimableSupply = 500; + conditions[0].quantityLimitPerWallet = 10; + conditions[0].merkleRoot = root; + conditions[0].pricePerToken = 10; + conditions[0].currency = address(erc20); + + conditions[1].maxClaimableSupply = 600; + conditions[1].pricePerToken = 20; + conditions[1].startTimestamp = 100000; + + conditions[2].maxClaimableSupply = 700; + conditions[2].pricePerToken = 30; + conditions[2].startTimestamp = 200000; + + conditions[3].maxClaimableSupply = 800; + conditions[3].pricePerToken = 40; + conditions[3].startTimestamp = 300000; + + conditions[4].maxClaimableSupply = 700; + conditions[4].pricePerToken = 30; + conditions[4].startTimestamp = 400000; + + vm.prank(deployer); + drop.lazyMint(500, "ipfs://", emptyEncodedBytes); + vm.prank(deployer); + vm.resumeGasMetering(); + drop.setClaimConditions(_tokenId, conditions, false); + } + + function test_benchmark_dropERC1155_lazyMint() public { + vm.pauseGasMetering(); + vm.prank(deployer); + vm.resumeGasMetering(); + drop.lazyMint(100, "ipfs://", emptyEncodedBytes); + } + + // function test_benchmark_dropERC1155_setClaimConditions_one_condition() public { + // vm.pauseGasMetering(); + // uint256 _tokenId = 0; + // string[] memory inputs = new string[](5); + + // inputs[0] = "node"; + // inputs[1] = "src/test/scripts/generateRoot.ts"; + // inputs[2] = "300"; + // inputs[3] = "5"; + // inputs[4] = Strings.toHexString(uint160(address(erc20))); // address of erc20 + + // bytes memory result = vm.ffi(inputs); + // // revert(); + // bytes32 root = abi.decode(result, (bytes32)); + + // inputs[1] = "src/test/scripts/getProof.ts"; + // result = vm.ffi(inputs); + // bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + // DropERC1155.AllowlistProof memory alp; + // alp.proof = proofs; + // alp.quantityLimitPerWallet = 300; + // alp.pricePerToken = 5; + // alp.currency = address(erc20); + + // vm.warp(1); + + // address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); // in allowlist + + // DropERC1155.ClaimCondition[] memory conditions = new DropERC1155.ClaimCondition[](1); + // conditions[0].maxClaimableSupply = 500; + // conditions[0].quantityLimitPerWallet = 10; + // conditions[0].merkleRoot = root; + // conditions[0].pricePerToken = 10; + // conditions[0].currency = address(erc20); + + // vm.prank(deployer); + // drop.lazyMint(500, "ipfs://", emptyEncodedBytes); + // vm.prank(deployer); + // vm.resumeGasMetering(); + // drop.setClaimConditions(_tokenId, conditions, false); + // } + + // function test_benchmark_dropERC1155_setClaimConditions_two_conditions() public { + // vm.pauseGasMetering(); + // uint256 _tokenId = 0; + // string[] memory inputs = new string[](5); + + // inputs[0] = "node"; + // inputs[1] = "src/test/scripts/generateRoot.ts"; + // inputs[2] = "300"; + // inputs[3] = "5"; + // inputs[4] = Strings.toHexString(uint160(address(erc20))); // address of erc20 + + // bytes memory result = vm.ffi(inputs); + // // revert(); + // bytes32 root = abi.decode(result, (bytes32)); + + // inputs[1] = "src/test/scripts/getProof.ts"; + // result = vm.ffi(inputs); + // bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + // DropERC1155.AllowlistProof memory alp; + // alp.proof = proofs; + // alp.quantityLimitPerWallet = 300; + // alp.pricePerToken = 5; + // alp.currency = address(erc20); + + // vm.warp(1); + + // address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); // in allowlist + + // DropERC1155.ClaimCondition[] memory conditions = new DropERC1155.ClaimCondition[](2); + // conditions[0].maxClaimableSupply = 500; + // conditions[0].quantityLimitPerWallet = 10; + // conditions[0].merkleRoot = root; + // conditions[0].pricePerToken = 10; + // conditions[0].currency = address(erc20); + + // conditions[1].maxClaimableSupply = 600; + // conditions[1].pricePerToken = 20; + // conditions[1].startTimestamp = 100000; + + // vm.prank(deployer); + // drop.lazyMint(500, "ipfs://", emptyEncodedBytes); + // vm.prank(deployer); + // vm.resumeGasMetering(); + // drop.setClaimConditions(_tokenId, conditions, false); + // } + + // function test_benchmark_dropERC1155_setClaimConditions_three_conditions() public { + // vm.pauseGasMetering(); + // uint256 _tokenId = 0; + // string[] memory inputs = new string[](5); + + // inputs[0] = "node"; + // inputs[1] = "src/test/scripts/generateRoot.ts"; + // inputs[2] = "300"; + // inputs[3] = "5"; + // inputs[4] = Strings.toHexString(uint160(address(erc20))); // address of erc20 + + // bytes memory result = vm.ffi(inputs); + // // revert(); + // bytes32 root = abi.decode(result, (bytes32)); + + // inputs[1] = "src/test/scripts/getProof.ts"; + // result = vm.ffi(inputs); + // bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + // DropERC1155.AllowlistProof memory alp; + // alp.proof = proofs; + // alp.quantityLimitPerWallet = 300; + // alp.pricePerToken = 5; + // alp.currency = address(erc20); + + // vm.warp(1); + + // address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); // in allowlist + + // DropERC1155.ClaimCondition[] memory conditions = new DropERC1155.ClaimCondition[](3); + // conditions[0].maxClaimableSupply = 500; + // conditions[0].quantityLimitPerWallet = 10; + // conditions[0].merkleRoot = root; + // conditions[0].pricePerToken = 10; + // conditions[0].currency = address(erc20); + + // conditions[1].maxClaimableSupply = 600; + // conditions[1].pricePerToken = 20; + // conditions[1].startTimestamp = 100000; + + // conditions[2].maxClaimableSupply = 700; + // conditions[2].pricePerToken = 30; + // conditions[2].startTimestamp = 200000; + + // vm.prank(deployer); + // drop.lazyMint(500, "ipfs://", emptyEncodedBytes); + // vm.prank(deployer); + // vm.resumeGasMetering(); + // drop.setClaimConditions(_tokenId, conditions, false); + // } +} diff --git a/src/test/benchmark/DropERC20Benchmark.t.sol b/src/test/benchmark/DropERC20Benchmark.t.sol new file mode 100644 index 000000000..53f432604 --- /dev/null +++ b/src/test/benchmark/DropERC20Benchmark.t.sol @@ -0,0 +1,261 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { DropERC20 } from "contracts/prebuilts/drop/DropERC20.sol"; + +// Test imports +import "../utils/BaseTest.sol"; + +contract DropERC20BenchmarkTest is BaseTest { + using Strings for uint256; + using Strings for address; + + DropERC20 public drop; + + using stdStorage for StdStorage; + + function setUp() public override { + super.setUp(); + drop = DropERC20(getContract("DropERC20")); + + erc20.mint(deployer, 1_000 ether); + vm.deal(deployer, 1_000 ether); + } + + /*/////////////////////////////////////////////////////////////// + DropERC20 benchmark + //////////////////////////////////////////////////////////////*/ + + function test_benchmark_dropERC20_setClaimConditions_five_conditions() public { + vm.pauseGasMetering(); + string[] memory inputs = new string[](5); + + inputs[0] = "node"; + inputs[1] = "src/test/scripts/generateRoot.ts"; + inputs[2] = Strings.toString(uint256(300 ether)); + inputs[3] = Strings.toString(uint256(1 ether)); + inputs[4] = Strings.toHexString(uint160(address(erc20))); // address of erc20 + + bytes memory result = vm.ffi(inputs); + // revert(); + bytes32 root = abi.decode(result, (bytes32)); + + inputs[1] = "src/test/scripts/getProof.ts"; + result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + DropERC20.AllowlistProof memory alp; + alp.proof = proofs; + alp.quantityLimitPerWallet = 300 ether; + alp.pricePerToken = 1 ether; + alp.currency = address(erc20); + + vm.warp(1); + + DropERC20.ClaimCondition[] memory conditions = new DropERC20.ClaimCondition[](5); + conditions[0].maxClaimableSupply = 500 ether; + conditions[0].quantityLimitPerWallet = 10 ether; + conditions[0].merkleRoot = root; + conditions[0].pricePerToken = 5 ether; + conditions[0].currency = address(erc20); + + conditions[1].maxClaimableSupply = 600; + conditions[1].pricePerToken = 20; + conditions[1].startTimestamp = 100000; + + conditions[2].maxClaimableSupply = 700; + conditions[2].pricePerToken = 30; + conditions[2].startTimestamp = 200000; + + conditions[3].maxClaimableSupply = 800; + conditions[3].pricePerToken = 40; + conditions[3].startTimestamp = 300000; + + conditions[4].maxClaimableSupply = 700; + conditions[4].pricePerToken = 30; + conditions[4].startTimestamp = 400000; + + vm.prank(deployer); + vm.resumeGasMetering(); + drop.setClaimConditions(conditions, false); + } + + function test_benchmark_dropERC20_claim() public { + vm.pauseGasMetering(); + string[] memory inputs = new string[](5); + + inputs[0] = "node"; + inputs[1] = "src/test/scripts/generateRoot.ts"; + inputs[2] = Strings.toString(uint256(300 ether)); + inputs[3] = Strings.toString(uint256(1 ether)); + inputs[4] = Strings.toHexString(uint160(address(erc20))); // address of erc20 + + bytes memory result = vm.ffi(inputs); + // revert(); + bytes32 root = abi.decode(result, (bytes32)); + + inputs[1] = "src/test/scripts/getProof.ts"; + result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + DropERC20.AllowlistProof memory alp; + alp.proof = proofs; + alp.quantityLimitPerWallet = 300 ether; + alp.pricePerToken = 1 ether; + alp.currency = address(erc20); + + vm.warp(1); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); // in allowlist + + DropERC20.ClaimCondition[] memory conditions = new DropERC20.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 500 ether; + conditions[0].quantityLimitPerWallet = 10 ether; + conditions[0].merkleRoot = root; + conditions[0].pricePerToken = 5 ether; + conditions[0].currency = address(erc20); + + vm.prank(deployer); + drop.setClaimConditions(conditions, false); + + erc20.mint(receiver, 1000 ether); + vm.prank(receiver); + erc20.approve(address(drop), 1000 ether); + + vm.prank(receiver, receiver); + vm.resumeGasMetering(); + drop.claim(receiver, 100 ether, address(erc20), 1 ether, alp, ""); + } + + // function test_benchmark_dropERC20_setClaimConditions_one_condition() public { + // vm.pauseGasMetering(); + // string[] memory inputs = new string[](5); + + // inputs[0] = "node"; + // inputs[1] = "src/test/scripts/generateRoot.ts"; + // inputs[2] = Strings.toString(300 ether); + // inputs[3] = Strings.toString(1 ether); + // inputs[4] = Strings.toHexString(uint160(address(erc20))); // address of erc20 + + // bytes memory result = vm.ffi(inputs); + // // revert(); + // bytes32 root = abi.decode(result, (bytes32)); + + // inputs[1] = "src/test/scripts/getProof.ts"; + // result = vm.ffi(inputs); + // bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + // DropERC20.AllowlistProof memory alp; + // alp.proof = proofs; + // alp.quantityLimitPerWallet = 300 ether; + // alp.pricePerToken = 1 ether; + // alp.currency = address(erc20); + + // vm.warp(1); + + // address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); // in allowlist + + // DropERC20.ClaimCondition[] memory conditions = new DropERC20.ClaimCondition[](1); + // conditions[0].maxClaimableSupply = 500 ether; + // conditions[0].quantityLimitPerWallet = 10 ether; + // conditions[0].merkleRoot = root; + // conditions[0].pricePerToken = 5 ether; + // conditions[0].currency = address(erc20); + + // vm.prank(deployer); + // vm.resumeGasMetering(); + // drop.setClaimConditions(conditions, false); + // } + + // function test_benchmark_dropERC20_setClaimConditions_two_conditions() public { + // vm.pauseGasMetering(); + // string[] memory inputs = new string[](5); + + // inputs[0] = "node"; + // inputs[1] = "src/test/scripts/generateRoot.ts"; + // inputs[2] = Strings.toString(300 ether); + // inputs[3] = Strings.toString(1 ether); + // inputs[4] = Strings.toHexString(uint160(address(erc20))); // address of erc20 + + // bytes memory result = vm.ffi(inputs); + // // revert(); + // bytes32 root = abi.decode(result, (bytes32)); + + // inputs[1] = "src/test/scripts/getProof.ts"; + // result = vm.ffi(inputs); + // bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + // DropERC20.AllowlistProof memory alp; + // alp.proof = proofs; + // alp.quantityLimitPerWallet = 300 ether; + // alp.pricePerToken = 1 ether; + // alp.currency = address(erc20); + + // vm.warp(1); + + // address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); // in allowlist + + // DropERC20.ClaimCondition[] memory conditions = new DropERC20.ClaimCondition[](2); + // conditions[0].maxClaimableSupply = 500 ether; + // conditions[0].quantityLimitPerWallet = 10 ether; + // conditions[0].merkleRoot = root; + // conditions[0].pricePerToken = 5 ether; + // conditions[0].currency = address(erc20); + + // conditions[1].maxClaimableSupply = 600; + // conditions[1].pricePerToken = 20; + // conditions[1].startTimestamp = 100000; + + // vm.prank(deployer); + // vm.resumeGasMetering(); + // drop.setClaimConditions(conditions, false); + // } + + // function test_benchmark_dropERC20_setClaimConditions_three_conditions() public { + // vm.pauseGasMetering(); + // string[] memory inputs = new string[](5); + + // inputs[0] = "node"; + // inputs[1] = "src/test/scripts/generateRoot.ts"; + // inputs[2] = Strings.toString(300 ether); + // inputs[3] = Strings.toString(1 ether); + // inputs[4] = Strings.toHexString(uint160(address(erc20))); // address of erc20 + + // bytes memory result = vm.ffi(inputs); + // // revert(); + // bytes32 root = abi.decode(result, (bytes32)); + + // inputs[1] = "src/test/scripts/getProof.ts"; + // result = vm.ffi(inputs); + // bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + // DropERC20.AllowlistProof memory alp; + // alp.proof = proofs; + // alp.quantityLimitPerWallet = 300 ether; + // alp.pricePerToken = 1 ether; + // alp.currency = address(erc20); + + // vm.warp(1); + + // address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); // in allowlist + + // DropERC20.ClaimCondition[] memory conditions = new DropERC20.ClaimCondition[](3); + // conditions[0].maxClaimableSupply = 500 ether; + // conditions[0].quantityLimitPerWallet = 10 ether; + // conditions[0].merkleRoot = root; + // conditions[0].pricePerToken = 5 ether; + // conditions[0].currency = address(erc20); + + // conditions[1].maxClaimableSupply = 600; + // conditions[1].pricePerToken = 20; + // conditions[1].startTimestamp = 100000; + + // conditions[2].maxClaimableSupply = 700; + // conditions[2].pricePerToken = 30; + // conditions[2].startTimestamp = 200000; + + // vm.prank(deployer); + // vm.resumeGasMetering(); + // drop.setClaimConditions(conditions, false); + // } +} diff --git a/src/test/benchmark/DropERC721Benchmark.t.sol b/src/test/benchmark/DropERC721Benchmark.t.sol new file mode 100644 index 000000000..d11507c06 --- /dev/null +++ b/src/test/benchmark/DropERC721Benchmark.t.sol @@ -0,0 +1,458 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { DropERC721, IDelayedReveal, ERC721AUpgradeable, IPermissions, ILazyMint } from "contracts/prebuilts/drop/DropERC721.sol"; + +// Test imports +import { IERC721AUpgradeable } from "erc721a-upgradeable/contracts/IERC721AUpgradeable.sol"; +import "../utils/BaseTest.sol"; + +contract DropERC721BenchmarkTest is BaseTest { + using Strings for uint256; + using Strings for address; + + DropERC721 public drop; + + bytes private emptyEncodedBytes = abi.encode("", ""); + + using stdStorage for StdStorage; + + function setUp() public override { + super.setUp(); + drop = DropERC721(getContract("DropERC721")); + + erc20.mint(deployer, 1_000 ether); + vm.deal(deployer, 1_000 ether); + } + + /*/////////////////////////////////////////////////////////////// + DropERC721 benchmark + //////////////////////////////////////////////////////////////*/ + + function test_benchmark_dropERC721_claim_five_tokens() public { + vm.pauseGasMetering(); + string[] memory inputs = new string[](5); + + inputs[0] = "node"; + inputs[1] = "src/test/scripts/generateRoot.ts"; + inputs[2] = "300"; + inputs[3] = "5"; + inputs[4] = Strings.toHexString(uint160(address(erc20))); // address of erc20 + + bytes memory result = vm.ffi(inputs); + // revert(); + bytes32 root = abi.decode(result, (bytes32)); + + inputs[1] = "src/test/scripts/getProof.ts"; + result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + DropERC721.AllowlistProof memory alp; + alp.proof = proofs; + alp.quantityLimitPerWallet = 300; + alp.pricePerToken = 5; + alp.currency = address(erc20); + + vm.warp(1); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); // in allowlist + + DropERC721.ClaimCondition[] memory conditions = new DropERC721.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 500; + conditions[0].quantityLimitPerWallet = 10; + conditions[0].merkleRoot = root; + conditions[0].pricePerToken = 10; + conditions[0].currency = address(erc20); + + vm.prank(deployer); + drop.lazyMint(500, "ipfs://", emptyEncodedBytes); + vm.prank(deployer); + drop.setClaimConditions(conditions, false); + + vm.prank(receiver, receiver); + + erc20.mint(receiver, 10000); + vm.prank(receiver); + erc20.approve(address(drop), 10000); + + vm.prank(receiver, receiver); + vm.resumeGasMetering(); + drop.claim(receiver, 5, address(erc20), 5, alp, ""); + } + + function test_benchmark_dropERC721_setClaimConditions_five_conditions() public { + vm.pauseGasMetering(); + string[] memory inputs = new string[](5); + + inputs[0] = "node"; + inputs[1] = "src/test/scripts/generateRoot.ts"; + inputs[2] = "300"; + inputs[3] = "5"; + inputs[4] = Strings.toHexString(uint160(address(erc20))); // address of erc20 + + bytes memory result = vm.ffi(inputs); + // revert(); + bytes32 root = abi.decode(result, (bytes32)); + + inputs[1] = "src/test/scripts/getProof.ts"; + result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + DropERC721.AllowlistProof memory alp; + alp.proof = proofs; + alp.quantityLimitPerWallet = 300; + alp.pricePerToken = 5; + alp.currency = address(erc20); + + vm.warp(1); + + DropERC721.ClaimCondition[] memory conditions = new DropERC721.ClaimCondition[](5); + conditions[0].maxClaimableSupply = 500; + conditions[0].quantityLimitPerWallet = 10; + conditions[0].merkleRoot = root; + conditions[0].pricePerToken = 10; + conditions[0].currency = address(erc20); + + conditions[1].maxClaimableSupply = 600; + conditions[1].pricePerToken = 20; + conditions[1].startTimestamp = 100000; + + conditions[2].maxClaimableSupply = 700; + conditions[2].pricePerToken = 30; + conditions[2].startTimestamp = 200000; + + conditions[3].maxClaimableSupply = 800; + conditions[3].pricePerToken = 40; + conditions[3].startTimestamp = 300000; + + conditions[4].maxClaimableSupply = 700; + conditions[4].pricePerToken = 30; + conditions[4].startTimestamp = 400000; + + vm.prank(deployer); + vm.resumeGasMetering(); + drop.setClaimConditions(conditions, false); + } + + function test_benchmark_dropERC721_lazyMint() public { + vm.pauseGasMetering(); + vm.prank(deployer); + vm.resumeGasMetering(); + drop.lazyMint(100, "ipfs://", emptyEncodedBytes); + } + + function test_benchmark_dropERC721_lazyMint_for_delayed_reveal() public { + vm.pauseGasMetering(); + uint256 amountToLazyMint = 100; + string memory baseURI = "ipfs://"; + bytes memory encryptedBaseURI = "encryptedBaseURI://"; + bytes32 provenanceHash = bytes32("whatever"); + + vm.prank(deployer); + vm.resumeGasMetering(); + drop.lazyMint(amountToLazyMint, baseURI, abi.encode(encryptedBaseURI, provenanceHash)); + } + + function test_benchmark_dropERC721_reveal() public { + vm.pauseGasMetering(); + + bytes memory key = "key"; + uint256 amountToLazyMint = 100; + bytes memory secretURI = "ipfs://"; + string memory placeholderURI = "abcd://"; + bytes memory encryptedURI = drop.encryptDecrypt(secretURI, key); + bytes32 provenanceHash = keccak256(abi.encodePacked(secretURI, key, block.chainid)); + + vm.prank(deployer); + drop.lazyMint(amountToLazyMint, placeholderURI, abi.encode(encryptedURI, provenanceHash)); + + vm.prank(deployer); + vm.resumeGasMetering(); + drop.reveal(0, key); + } + + // function test_benchmark_dropERC721_claim_one_token() public { + // vm.pauseGasMetering(); + // string[] memory inputs = new string[](5); + + // inputs[0] = "node"; + // inputs[1] = "src/test/scripts/generateRoot.ts"; + // inputs[2] = "300"; + // inputs[3] = "5"; + // inputs[4] = Strings.toHexString(uint160(address(erc20))); // address of erc20 + + // bytes memory result = vm.ffi(inputs); + // // revert(); + // bytes32 root = abi.decode(result, (bytes32)); + + // inputs[1] = "src/test/scripts/getProof.ts"; + // result = vm.ffi(inputs); + // bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + // DropERC721.AllowlistProof memory alp; + // alp.proof = proofs; + // alp.quantityLimitPerWallet = 300; + // alp.pricePerToken = 5; + // alp.currency = address(erc20); + + // vm.warp(1); + + // address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); // in allowlist + + // DropERC721.ClaimCondition[] memory conditions = new DropERC721.ClaimCondition[](1); + // conditions[0].maxClaimableSupply = 500; + // conditions[0].quantityLimitPerWallet = 10; + // conditions[0].merkleRoot = root; + // conditions[0].pricePerToken = 10; + // conditions[0].currency = address(erc20); + + // vm.prank(deployer); + // drop.lazyMint(500, "ipfs://", emptyEncodedBytes); + // vm.prank(deployer); + // drop.setClaimConditions(conditions, false); + + // vm.prank(receiver, receiver); + + // erc20.mint(receiver, 10000); + // vm.prank(receiver); + // erc20.approve(address(drop), 10000); + + // vm.prank(receiver, receiver); + // vm.resumeGasMetering(); + // drop.claim(receiver, 1, address(erc20), 5, alp, ""); + // } + + // function test_benchmark_dropERC721_claim_two_tokens() public { + // vm.pauseGasMetering(); + // string[] memory inputs = new string[](5); + + // inputs[0] = "node"; + // inputs[1] = "src/test/scripts/generateRoot.ts"; + // inputs[2] = "300"; + // inputs[3] = "5"; + // inputs[4] = Strings.toHexString(uint160(address(erc20))); // address of erc20 + + // bytes memory result = vm.ffi(inputs); + // // revert(); + // bytes32 root = abi.decode(result, (bytes32)); + + // inputs[1] = "src/test/scripts/getProof.ts"; + // result = vm.ffi(inputs); + // bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + // DropERC721.AllowlistProof memory alp; + // alp.proof = proofs; + // alp.quantityLimitPerWallet = 300; + // alp.pricePerToken = 5; + // alp.currency = address(erc20); + + // vm.warp(1); + + // address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); // in allowlist + + // DropERC721.ClaimCondition[] memory conditions = new DropERC721.ClaimCondition[](1); + // conditions[0].maxClaimableSupply = 500; + // conditions[0].quantityLimitPerWallet = 10; + // conditions[0].merkleRoot = root; + // conditions[0].pricePerToken = 10; + // conditions[0].currency = address(erc20); + + // vm.prank(deployer); + // drop.lazyMint(500, "ipfs://", emptyEncodedBytes); + // vm.prank(deployer); + // drop.setClaimConditions(conditions, false); + + // vm.prank(receiver, receiver); + + // erc20.mint(receiver, 10000); + // vm.prank(receiver); + // erc20.approve(address(drop), 10000); + + // vm.prank(receiver, receiver); + // vm.resumeGasMetering(); + // drop.claim(receiver, 2, address(erc20), 5, alp, ""); + // } + + // function test_benchmark_dropERC721_claim_three_tokens() public { + // vm.pauseGasMetering(); + // string[] memory inputs = new string[](5); + + // inputs[0] = "node"; + // inputs[1] = "src/test/scripts/generateRoot.ts"; + // inputs[2] = "300"; + // inputs[3] = "5"; + // inputs[4] = Strings.toHexString(uint160(address(erc20))); // address of erc20 + + // bytes memory result = vm.ffi(inputs); + // // revert(); + // bytes32 root = abi.decode(result, (bytes32)); + + // inputs[1] = "src/test/scripts/getProof.ts"; + // result = vm.ffi(inputs); + // bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + // DropERC721.AllowlistProof memory alp; + // alp.proof = proofs; + // alp.quantityLimitPerWallet = 300; + // alp.pricePerToken = 5; + // alp.currency = address(erc20); + + // vm.warp(1); + + // address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); // in allowlist + + // DropERC721.ClaimCondition[] memory conditions = new DropERC721.ClaimCondition[](1); + // conditions[0].maxClaimableSupply = 500; + // conditions[0].quantityLimitPerWallet = 10; + // conditions[0].merkleRoot = root; + // conditions[0].pricePerToken = 10; + // conditions[0].currency = address(erc20); + + // vm.prank(deployer); + // drop.lazyMint(500, "ipfs://", emptyEncodedBytes); + // vm.prank(deployer); + // drop.setClaimConditions(conditions, false); + + // vm.prank(receiver, receiver); + + // erc20.mint(receiver, 10000); + // vm.prank(receiver); + // erc20.approve(address(drop), 10000); + + // vm.prank(receiver, receiver); + // vm.resumeGasMetering(); + // drop.claim(receiver, 3, address(erc20), 5, alp, ""); + // } + + // function test_benchmark_dropERC721_setClaimConditions_one_condition() public { + // vm.pauseGasMetering(); + // string[] memory inputs = new string[](5); + + // inputs[0] = "node"; + // inputs[1] = "src/test/scripts/generateRoot.ts"; + // inputs[2] = "300"; + // inputs[3] = "5"; + // inputs[4] = Strings.toHexString(uint160(address(erc20))); // address of erc20 + + // bytes memory result = vm.ffi(inputs); + // // revert(); + // bytes32 root = abi.decode(result, (bytes32)); + + // inputs[1] = "src/test/scripts/getProof.ts"; + // result = vm.ffi(inputs); + // bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + // DropERC721.AllowlistProof memory alp; + // alp.proof = proofs; + // alp.quantityLimitPerWallet = 300; + // alp.pricePerToken = 5; + // alp.currency = address(erc20); + + // vm.warp(1); + + // address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); // in allowlist + + // DropERC721.ClaimCondition[] memory conditions = new DropERC721.ClaimCondition[](1); + // conditions[0].maxClaimableSupply = 500; + // conditions[0].quantityLimitPerWallet = 10; + // conditions[0].merkleRoot = root; + // conditions[0].pricePerToken = 10; + // conditions[0].currency = address(erc20); + + // vm.prank(deployer); + // vm.resumeGasMetering(); + // drop.setClaimConditions(conditions, false); + // } + + // function test_benchmark_dropERC721_setClaimConditions_two_conditions() public { + // vm.pauseGasMetering(); + // string[] memory inputs = new string[](5); + + // inputs[0] = "node"; + // inputs[1] = "src/test/scripts/generateRoot.ts"; + // inputs[2] = "300"; + // inputs[3] = "5"; + // inputs[4] = Strings.toHexString(uint160(address(erc20))); // address of erc20 + + // bytes memory result = vm.ffi(inputs); + // // revert(); + // bytes32 root = abi.decode(result, (bytes32)); + + // inputs[1] = "src/test/scripts/getProof.ts"; + // result = vm.ffi(inputs); + // bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + // DropERC721.AllowlistProof memory alp; + // alp.proof = proofs; + // alp.quantityLimitPerWallet = 300; + // alp.pricePerToken = 5; + // alp.currency = address(erc20); + + // vm.warp(1); + + // address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); // in allowlist + + // DropERC721.ClaimCondition[] memory conditions = new DropERC721.ClaimCondition[](2); + // conditions[0].maxClaimableSupply = 500; + // conditions[0].quantityLimitPerWallet = 10; + // conditions[0].merkleRoot = root; + // conditions[0].pricePerToken = 10; + // conditions[0].currency = address(erc20); + + // conditions[1].maxClaimableSupply = 600; + // conditions[1].pricePerToken = 20; + // conditions[1].startTimestamp = 100000; + + // vm.prank(deployer); + // vm.resumeGasMetering(); + // drop.setClaimConditions(conditions, false); + // } + + // function test_benchmark_dropERC721_setClaimConditions_three_conditions() public { + // vm.pauseGasMetering(); + // string[] memory inputs = new string[](5); + + // inputs[0] = "node"; + // inputs[1] = "src/test/scripts/generateRoot.ts"; + // inputs[2] = "300"; + // inputs[3] = "5"; + // inputs[4] = Strings.toHexString(uint160(address(erc20))); // address of erc20 + + // bytes memory result = vm.ffi(inputs); + // // revert(); + // bytes32 root = abi.decode(result, (bytes32)); + + // inputs[1] = "src/test/scripts/getProof.ts"; + // result = vm.ffi(inputs); + // bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + // DropERC721.AllowlistProof memory alp; + // alp.proof = proofs; + // alp.quantityLimitPerWallet = 300; + // alp.pricePerToken = 5; + // alp.currency = address(erc20); + + // vm.warp(1); + + // address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); // in allowlist + + // DropERC721.ClaimCondition[] memory conditions = new DropERC721.ClaimCondition[](3); + // conditions[0].maxClaimableSupply = 500; + // conditions[0].quantityLimitPerWallet = 10; + // conditions[0].merkleRoot = root; + // conditions[0].pricePerToken = 10; + // conditions[0].currency = address(erc20); + + // conditions[1].maxClaimableSupply = 600; + // conditions[1].pricePerToken = 20; + // conditions[1].startTimestamp = 100000; + + // conditions[2].maxClaimableSupply = 700; + // conditions[2].pricePerToken = 30; + // conditions[2].startTimestamp = 200000; + + // vm.prank(deployer); + // vm.resumeGasMetering(); + // drop.setClaimConditions(conditions, false); + // } +} diff --git a/src/test/benchmark/EditionStakeBenchmark.t.sol b/src/test/benchmark/EditionStakeBenchmark.t.sol new file mode 100644 index 000000000..f8b604dc4 --- /dev/null +++ b/src/test/benchmark/EditionStakeBenchmark.t.sol @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { EditionStake } from "contracts/prebuilts/staking/EditionStake.sol"; + +// Test imports +import "../utils/BaseTest.sol"; + +contract EditionStakeBenchmarkTest is BaseTest { + EditionStake internal stakeContract; + + address internal stakerOne; + address internal stakerTwo; + + uint256 internal defaultTimeUnit; + uint256 internal defaultRewardsPerUnitTime; + + function setUp() public override { + super.setUp(); + + defaultTimeUnit = 60; + defaultRewardsPerUnitTime = 1; + + stakerOne = address(0x345); + stakerTwo = address(0x567); + + erc1155.mint(stakerOne, 0, 100); // mint 100 tokens with id 0 to stakerOne + erc1155.mint(stakerOne, 1, 100); // mint 100 tokens with id 1 to stakerOne + + erc1155.mint(stakerTwo, 0, 100); // mint 100 tokens with id 0 to stakerTwo + erc1155.mint(stakerTwo, 1, 100); // mint 100 tokens with id 1 to stakerTwo + + erc20.mint(deployer, 1000 ether); // mint reward tokens to contract admin + + stakeContract = EditionStake(payable(getContract("EditionStake"))); + + // set approvals + vm.prank(stakerOne); + erc1155.setApprovalForAll(address(stakeContract), true); + + vm.prank(stakerTwo); + erc1155.setApprovalForAll(address(stakeContract), true); + + vm.startPrank(deployer); + erc20.approve(address(stakeContract), type(uint256).max); + stakeContract.depositRewardTokens(100 ether); + // erc20.transfer(address(stakeContract), 100 ether); + vm.stopPrank(); + assertEq(stakeContract.getRewardTokenBalance(), 100 ether); + } + + /*/////////////////////////////////////////////////////////////// + Benchmark: EditionStake + //////////////////////////////////////////////////////////////*/ + + function test_benchmark_editionStake_stake() public { + vm.pauseGasMetering(); + + vm.warp(1); + vm.prank(stakerOne); + vm.resumeGasMetering(); + stakeContract.stake(0, 50); + } + + function test_benchmark_editionStake_claimRewards() public { + vm.pauseGasMetering(); + vm.warp(1); + + // stake 50 tokens with token-id 0 + vm.prank(stakerOne); + stakeContract.stake(0, 50); + + //=================== warp timestamp to claim rewards + vm.roll(100); + vm.warp(1000); + + vm.prank(stakerOne); + vm.resumeGasMetering(); + stakeContract.claimRewards(0); + } + + function test_benchmark_editionStake_withdraw() public { + vm.pauseGasMetering(); + vm.warp(1); + + vm.prank(stakerOne); + stakeContract.stake(0, 50); + + vm.prank(stakerTwo); + stakeContract.stake(1, 20); + + //========== warp timestamp before withdraw + vm.roll(100); + vm.warp(1000); + + // withdraw partially for stakerOne + vm.prank(stakerOne); + vm.resumeGasMetering(); + stakeContract.withdraw(0, 40); + } +} diff --git a/src/test/benchmark/MultiwrapBenchmark.t.sol b/src/test/benchmark/MultiwrapBenchmark.t.sol new file mode 100644 index 000000000..aef57eccc --- /dev/null +++ b/src/test/benchmark/MultiwrapBenchmark.t.sol @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { Multiwrap } from "contracts/prebuilts/multiwrap/Multiwrap.sol"; +import { ITokenBundle } from "contracts/extension/interface/ITokenBundle.sol"; + +// Test imports +import { MockERC20 } from "../mocks/MockERC20.sol"; +import { Wallet } from "../utils/Wallet.sol"; +import "../utils/BaseTest.sol"; + +contract MultiwrapBenchmarkTest is BaseTest { + /// @dev Emitted when tokens are wrapped. + event TokensWrapped( + address indexed wrapper, + address indexed recipientOfWrappedToken, + uint256 indexed tokenIdOfWrappedToken, + ITokenBundle.Token[] wrappedContents + ); + + /// @dev Emitted when tokens are unwrapped. + event TokensUnwrapped( + address indexed unwrapper, + address indexed recipientOfWrappedContents, + uint256 indexed tokenIdOfWrappedToken + ); + + /*/////////////////////////////////////////////////////////////// + Setup + //////////////////////////////////////////////////////////////*/ + + Multiwrap internal multiwrap; + + Wallet internal tokenOwner; + string internal uriForWrappedToken; + ITokenBundle.Token[] internal wrappedContent; + + function setUp() public override { + super.setUp(); + + // Get target contract + multiwrap = Multiwrap(payable(getContract("Multiwrap"))); + + // Set test vars + tokenOwner = getWallet(); + uriForWrappedToken = "ipfs://baseURI/"; + + wrappedContent.push( + ITokenBundle.Token({ + assetContract: address(erc20), + tokenType: ITokenBundle.TokenType.ERC20, + tokenId: 0, + totalAmount: 10 ether + }) + ); + wrappedContent.push( + ITokenBundle.Token({ + assetContract: address(erc721), + tokenType: ITokenBundle.TokenType.ERC721, + tokenId: 0, + totalAmount: 1 + }) + ); + wrappedContent.push( + ITokenBundle.Token({ + assetContract: address(erc1155), + tokenType: ITokenBundle.TokenType.ERC1155, + tokenId: 0, + totalAmount: 100 + }) + ); + + // Mint tokens-to-wrap to `tokenOwner` + erc20.mint(address(tokenOwner), 10 ether); + erc721.mint(address(tokenOwner), 1); + erc1155.mint(address(tokenOwner), 0, 100); + + // Token owner approves `Multiwrap` to transfer tokens. + tokenOwner.setAllowanceERC20(address(erc20), address(multiwrap), type(uint256).max); + tokenOwner.setApprovalForAllERC721(address(erc721), address(multiwrap), true); + tokenOwner.setApprovalForAllERC1155(address(erc1155), address(multiwrap), true); + + // Grant MINTER_ROLE / requisite wrapping permissions to `tokenOwer` + vm.prank(deployer); + multiwrap.grantRole(keccak256("MINTER_ROLE"), address(tokenOwner)); + } + + /*/////////////////////////////////////////////////////////////// + Multiwrap benchmark + //////////////////////////////////////////////////////////////*/ + + function test_benchmark_multiwrap_wrap() public { + vm.pauseGasMetering(); + address recipient = address(0x123); + vm.prank(address(tokenOwner)); + vm.resumeGasMetering(); + multiwrap.wrap(wrappedContent, uriForWrappedToken, recipient); + } + + function test_benchmark_multiwrap_unwrap() public { + vm.pauseGasMetering(); + // ===== setup: wrap tokens ===== + uint256 expectedIdForWrappedToken = multiwrap.nextTokenIdToMint(); + address recipient = address(0x123); + + vm.prank(address(tokenOwner)); + multiwrap.wrap(wrappedContent, uriForWrappedToken, recipient); + + // ===== target test content ===== + + vm.prank(recipient); + vm.resumeGasMetering(); + multiwrap.unwrap(expectedIdForWrappedToken, recipient); + } +} diff --git a/src/test/benchmark/NFTStakeBenchmark.t.sol b/src/test/benchmark/NFTStakeBenchmark.t.sol new file mode 100644 index 000000000..0f81eef3d --- /dev/null +++ b/src/test/benchmark/NFTStakeBenchmark.t.sol @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { NFTStake } from "contracts/prebuilts/staking/NFTStake.sol"; + +// Test imports +import "../utils/BaseTest.sol"; + +contract NFTStakeBenchmarkTest is BaseTest { + NFTStake internal stakeContract; + + address internal stakerOne; + address internal stakerTwo; + + uint256 internal timeUnit; + uint256 internal rewardsPerUnitTime; + + function setUp() public override { + super.setUp(); + + timeUnit = 60; + rewardsPerUnitTime = 1; + + stakerOne = address(0x345); + stakerTwo = address(0x567); + + erc721.mint(stakerOne, 5); // mint token id 0 to 4 + erc721.mint(stakerTwo, 5); // mint token id 5 to 9 + erc20.mint(deployer, 1000 ether); // mint reward tokens to contract admin + + stakeContract = NFTStake(payable(getContract("NFTStake"))); + + // set approvals + vm.prank(stakerOne); + erc721.setApprovalForAll(address(stakeContract), true); + + vm.prank(stakerTwo); + erc721.setApprovalForAll(address(stakeContract), true); + + vm.startPrank(deployer); + erc20.approve(address(stakeContract), type(uint256).max); + stakeContract.depositRewardTokens(100 ether); + // erc20.transfer(address(stakeContract), 100 ether); + vm.stopPrank(); + assertEq(stakeContract.getRewardTokenBalance(), 100 ether); + } + + /*/////////////////////////////////////////////////////////////// + Benchmark: NFTStake + //////////////////////////////////////////////////////////////*/ + + function test_benchmark_nftStake_stake_five_tokens() public { + vm.pauseGasMetering(); + + vm.warp(1); + uint256[] memory _tokenIdsOne = new uint256[](5); + _tokenIdsOne[0] = 0; + _tokenIdsOne[1] = 1; + _tokenIdsOne[2] = 2; + _tokenIdsOne[3] = 3; + _tokenIdsOne[4] = 4; + + // stake 3 tokens + vm.prank(stakerOne); + vm.resumeGasMetering(); + stakeContract.stake(_tokenIdsOne); + } + + function test_benchmark_nftStake_claimRewards() public { + vm.pauseGasMetering(); + vm.warp(1); + uint256[] memory _tokenIdsOne = new uint256[](3); + _tokenIdsOne[0] = 0; + _tokenIdsOne[1] = 1; + _tokenIdsOne[2] = 2; + + // stake 3 tokens + vm.prank(stakerOne); + stakeContract.stake(_tokenIdsOne); + + //=================== warp timestamp to claim rewards + vm.roll(100); + vm.warp(1000); + vm.prank(stakerOne); + vm.resumeGasMetering(); + stakeContract.claimRewards(); + } + + function test_benchmark_nftStake_withdraw() public { + vm.pauseGasMetering(); + //================ first staker ====================== + vm.warp(1); + uint256[] memory _tokenIdsOne = new uint256[](3); + _tokenIdsOne[0] = 0; + _tokenIdsOne[1] = 1; + _tokenIdsOne[2] = 2; + + // stake 3 tokens + vm.prank(stakerOne); + stakeContract.stake(_tokenIdsOne); + + //========== warp timestamp before withdraw + vm.roll(100); + vm.warp(1000); + + uint256[] memory _tokensToWithdraw = new uint256[](1); + _tokensToWithdraw[0] = 1; + + vm.prank(stakerOne); + vm.resumeGasMetering(); + stakeContract.withdraw(_tokensToWithdraw); + } + + // function test_benchmark_nftStake_stake_one_token() public { + // vm.pauseGasMetering(); + + // vm.warp(1); + // uint256[] memory _tokenIdsOne = new uint256[](1); + // _tokenIdsOne[0] = 0; + + // // stake 3 tokens + // vm.prank(stakerOne); + // vm.resumeGasMetering(); + // stakeContract.stake(_tokenIdsOne); + // } + + // function test_benchmark_nftStake_stake_two_tokens() public { + // vm.pauseGasMetering(); + + // vm.warp(1); + // uint256[] memory _tokenIdsOne = new uint256[](2); + // _tokenIdsOne[0] = 0; + // _tokenIdsOne[1] = 1; + + // // stake 3 tokens + // vm.prank(stakerOne); + // vm.resumeGasMetering(); + // stakeContract.stake(_tokenIdsOne); + // } + + // function test_benchmark_nftStake_stake_three_tokens() public { + // vm.pauseGasMetering(); + + // vm.warp(1); + // uint256[] memory _tokenIdsOne = new uint256[](3); + // _tokenIdsOne[0] = 0; + // _tokenIdsOne[1] = 1; + // _tokenIdsOne[2] = 2; + + // // stake 3 tokens + // vm.prank(stakerOne); + // vm.resumeGasMetering(); + // stakeContract.stake(_tokenIdsOne); + // } +} diff --git a/src/test/benchmark/PackBenchmark.t.sol b/src/test/benchmark/PackBenchmark.t.sol new file mode 100644 index 000000000..5048713b0 --- /dev/null +++ b/src/test/benchmark/PackBenchmark.t.sol @@ -0,0 +1,222 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { Pack, IERC2981Upgradeable, IERC721Receiver, IERC1155Upgradeable } from "contracts/prebuilts/pack/Pack.sol"; +import { IPack } from "contracts/prebuilts/interface/IPack.sol"; +import { ITokenBundle } from "contracts/extension/interface/ITokenBundle.sol"; + +// Test imports +import { MockERC20 } from "../mocks/MockERC20.sol"; +import { Wallet } from "../utils/Wallet.sol"; +import "../utils/BaseTest.sol"; + +contract PackBenchmarkTest is BaseTest { + /// @notice Emitted when a set of packs is created. + event PackCreated(uint256 indexed packId, address recipient, uint256 totalPacksCreated); + + /// @notice Emitted when a pack is opened. + event PackOpened( + uint256 indexed packId, + address indexed opener, + uint256 numOfPacksOpened, + ITokenBundle.Token[] rewardUnitsDistributed + ); + + Pack internal pack; + + Wallet internal tokenOwner; + string internal packUri; + ITokenBundle.Token[] internal packContents; + ITokenBundle.Token[] internal additionalContents; + uint256[] internal numOfRewardUnits; + uint256[] internal additionalContentsRewardUnits; + + function setUp() public override { + super.setUp(); + + pack = Pack(payable(getContract("Pack"))); + + tokenOwner = getWallet(); + packUri = "ipfs://"; + + packContents.push( + ITokenBundle.Token({ + assetContract: address(erc721), + tokenType: ITokenBundle.TokenType.ERC721, + tokenId: 0, + totalAmount: 1 + }) + ); + numOfRewardUnits.push(1); + + packContents.push( + ITokenBundle.Token({ + assetContract: address(erc1155), + tokenType: ITokenBundle.TokenType.ERC1155, + tokenId: 0, + totalAmount: 100 + }) + ); + numOfRewardUnits.push(20); + + packContents.push( + ITokenBundle.Token({ + assetContract: address(erc20), + tokenType: ITokenBundle.TokenType.ERC20, + tokenId: 0, + totalAmount: 1000 ether + }) + ); + numOfRewardUnits.push(50); + + packContents.push( + ITokenBundle.Token({ + assetContract: address(erc721), + tokenType: ITokenBundle.TokenType.ERC721, + tokenId: 1, + totalAmount: 1 + }) + ); + numOfRewardUnits.push(1); + + packContents.push( + ITokenBundle.Token({ + assetContract: address(erc721), + tokenType: ITokenBundle.TokenType.ERC721, + tokenId: 2, + totalAmount: 1 + }) + ); + numOfRewardUnits.push(1); + + packContents.push( + ITokenBundle.Token({ + assetContract: address(erc20), + tokenType: ITokenBundle.TokenType.ERC20, + tokenId: 0, + totalAmount: 1000 ether + }) + ); + numOfRewardUnits.push(100); + + packContents.push( + ITokenBundle.Token({ + assetContract: address(erc721), + tokenType: ITokenBundle.TokenType.ERC721, + tokenId: 3, + totalAmount: 1 + }) + ); + numOfRewardUnits.push(1); + + packContents.push( + ITokenBundle.Token({ + assetContract: address(erc721), + tokenType: ITokenBundle.TokenType.ERC721, + tokenId: 4, + totalAmount: 1 + }) + ); + numOfRewardUnits.push(1); + + packContents.push( + ITokenBundle.Token({ + assetContract: address(erc721), + tokenType: ITokenBundle.TokenType.ERC721, + tokenId: 5, + totalAmount: 1 + }) + ); + numOfRewardUnits.push(1); + + packContents.push( + ITokenBundle.Token({ + assetContract: address(erc1155), + tokenType: ITokenBundle.TokenType.ERC1155, + tokenId: 1, + totalAmount: 500 + }) + ); + numOfRewardUnits.push(50); + + erc20.mint(address(tokenOwner), 2000 ether); + erc721.mint(address(tokenOwner), 6); + erc1155.mint(address(tokenOwner), 0, 100); + erc1155.mint(address(tokenOwner), 1, 500); + + // additional contents, to check `addPackContents` + additionalContents.push( + ITokenBundle.Token({ + assetContract: address(erc1155), + tokenType: ITokenBundle.TokenType.ERC1155, + tokenId: 2, + totalAmount: 200 + }) + ); + additionalContentsRewardUnits.push(50); + + additionalContents.push( + ITokenBundle.Token({ + assetContract: address(erc20), + tokenType: ITokenBundle.TokenType.ERC20, + tokenId: 0, + totalAmount: 1000 ether + }) + ); + additionalContentsRewardUnits.push(100); + + tokenOwner.setAllowanceERC20(address(erc20), address(pack), type(uint256).max); + tokenOwner.setApprovalForAllERC721(address(erc721), address(pack), true); + tokenOwner.setApprovalForAllERC1155(address(erc1155), address(pack), true); + + vm.prank(deployer); + pack.grantRole(keccak256("MINTER_ROLE"), address(tokenOwner)); + } + + /*/////////////////////////////////////////////////////////////// + Benchmark: Pack + //////////////////////////////////////////////////////////////*/ + + function test_benchmark_pack_createPack() public { + vm.pauseGasMetering(); + address recipient = address(1); + + vm.prank(address(tokenOwner)); + vm.resumeGasMetering(); + pack.createPack(packContents, numOfRewardUnits, packUri, 0, 1, recipient); + } + + function test_benchmark_pack_addPackContents() public { + vm.pauseGasMetering(); + uint256 packId = pack.nextTokenIdToMint(); + address recipient = address(1); + + vm.prank(address(tokenOwner)); + pack.createPack(packContents, numOfRewardUnits, packUri, 0, 1, recipient); + + (ITokenBundle.Token[] memory packed, ) = pack.getPackContents(packId); + assertEq(packed.length, packContents.length); + + erc20.mint(address(tokenOwner), 1000 ether); + erc1155.mint(address(tokenOwner), 2, 200); + + vm.prank(address(tokenOwner)); + vm.resumeGasMetering(); + pack.addPackContents(packId, additionalContents, additionalContentsRewardUnits, recipient); + } + + function test_benchmark_pack_openPack() public { + vm.pauseGasMetering(); + vm.warp(1000); + uint256 packId = pack.nextTokenIdToMint(); + uint256 packsToOpen = 3; + address recipient = address(1); + + vm.prank(address(tokenOwner)); + pack.createPack(packContents, numOfRewardUnits, packUri, 0, 2, recipient); + + vm.prank(recipient, recipient); + vm.resumeGasMetering(); + pack.openPack(packId, packsToOpen); + } +} diff --git a/src/test/benchmark/PackVRFDirectBenchmark.t.sol b/src/test/benchmark/PackVRFDirectBenchmark.t.sol new file mode 100644 index 000000000..b20af8f7d --- /dev/null +++ b/src/test/benchmark/PackVRFDirectBenchmark.t.sol @@ -0,0 +1,220 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { PackVRFDirect, IERC2981Upgradeable, IERC721Receiver, IERC1155Upgradeable } from "contracts/prebuilts/pack/PackVRFDirect.sol"; +import { IPack } from "contracts/prebuilts/interface/IPack.sol"; +import { ITokenBundle } from "contracts/extension/interface/ITokenBundle.sol"; + +// Test imports +import { MockERC20 } from "../mocks/MockERC20.sol"; +import { Wallet } from "../utils/Wallet.sol"; +import "../utils/BaseTest.sol"; + +contract PackVRFDirectBenchmarkTest is BaseTest { + /// @notice Emitted when a set of packs is created. + event PackCreated(uint256 indexed packId, address recipient, uint256 totalPacksCreated); + + /// @notice Emitted when the opening of a pack is requested. + event PackOpenRequested(address indexed opener, uint256 indexed packId, uint256 amountToOpen, uint256 requestId); + + /// @notice Emitted when Chainlink VRF fulfills a random number request. + event PackRandomnessFulfilled(uint256 indexed packId, uint256 indexed requestId); + + /// @notice Emitted when a pack is opened. + event PackOpened( + uint256 indexed packId, + address indexed opener, + uint256 numOfPacksOpened, + ITokenBundle.Token[] rewardUnitsDistributed + ); + + PackVRFDirect internal pack; + + Wallet internal tokenOwner; + string internal packUri; + ITokenBundle.Token[] internal packContents; + ITokenBundle.Token[] internal additionalContents; + uint256[] internal numOfRewardUnits; + uint256[] internal additionalContentsRewardUnits; + + function setUp() public virtual override { + super.setUp(); + + pack = PackVRFDirect(payable(getContract("PackVRFDirect"))); + + tokenOwner = getWallet(); + packUri = "ipfs://"; + + packContents.push( + ITokenBundle.Token({ + assetContract: address(erc721), + tokenType: ITokenBundle.TokenType.ERC721, + tokenId: 0, + totalAmount: 1 + }) + ); + numOfRewardUnits.push(1); + + packContents.push( + ITokenBundle.Token({ + assetContract: address(erc1155), + tokenType: ITokenBundle.TokenType.ERC1155, + tokenId: 0, + totalAmount: 100 + }) + ); + numOfRewardUnits.push(20); + + packContents.push( + ITokenBundle.Token({ + assetContract: address(erc20), + tokenType: ITokenBundle.TokenType.ERC20, + tokenId: 0, + totalAmount: 1000 ether + }) + ); + numOfRewardUnits.push(50); + + packContents.push( + ITokenBundle.Token({ + assetContract: address(erc721), + tokenType: ITokenBundle.TokenType.ERC721, + tokenId: 1, + totalAmount: 1 + }) + ); + numOfRewardUnits.push(1); + + packContents.push( + ITokenBundle.Token({ + assetContract: address(erc721), + tokenType: ITokenBundle.TokenType.ERC721, + tokenId: 2, + totalAmount: 1 + }) + ); + numOfRewardUnits.push(1); + + packContents.push( + ITokenBundle.Token({ + assetContract: address(erc20), + tokenType: ITokenBundle.TokenType.ERC20, + tokenId: 0, + totalAmount: 1000 ether + }) + ); + numOfRewardUnits.push(100); + + packContents.push( + ITokenBundle.Token({ + assetContract: address(erc721), + tokenType: ITokenBundle.TokenType.ERC721, + tokenId: 3, + totalAmount: 1 + }) + ); + numOfRewardUnits.push(1); + + packContents.push( + ITokenBundle.Token({ + assetContract: address(erc721), + tokenType: ITokenBundle.TokenType.ERC721, + tokenId: 4, + totalAmount: 1 + }) + ); + numOfRewardUnits.push(1); + + packContents.push( + ITokenBundle.Token({ + assetContract: address(erc721), + tokenType: ITokenBundle.TokenType.ERC721, + tokenId: 5, + totalAmount: 1 + }) + ); + numOfRewardUnits.push(1); + + packContents.push( + ITokenBundle.Token({ + assetContract: address(erc1155), + tokenType: ITokenBundle.TokenType.ERC1155, + tokenId: 1, + totalAmount: 500 + }) + ); + numOfRewardUnits.push(50); + + erc20.mint(address(tokenOwner), 2000 ether); + erc721.mint(address(tokenOwner), 6); + erc1155.mint(address(tokenOwner), 0, 100); + erc1155.mint(address(tokenOwner), 1, 500); + + // additional contents, to check `addPackContents` + additionalContents.push( + ITokenBundle.Token({ + assetContract: address(erc1155), + tokenType: ITokenBundle.TokenType.ERC1155, + tokenId: 2, + totalAmount: 200 + }) + ); + additionalContentsRewardUnits.push(50); + + additionalContents.push( + ITokenBundle.Token({ + assetContract: address(erc20), + tokenType: ITokenBundle.TokenType.ERC20, + tokenId: 0, + totalAmount: 1000 ether + }) + ); + additionalContentsRewardUnits.push(100); + + tokenOwner.setAllowanceERC20(address(erc20), address(pack), type(uint256).max); + tokenOwner.setApprovalForAllERC721(address(erc721), address(pack), true); + tokenOwner.setApprovalForAllERC1155(address(erc1155), address(pack), true); + + vm.prank(deployer); + pack.grantRole(keccak256("MINTER_ROLE"), address(tokenOwner)); + } + + /*/////////////////////////////////////////////////////////////// + Benchmark: PackVRFDirect + //////////////////////////////////////////////////////////////*/ + + function test_benchmark_packvrf_createPack() public { + vm.pauseGasMetering(); + address recipient = address(1); + vm.prank(address(tokenOwner)); + vm.resumeGasMetering(); + pack.createPack(packContents, numOfRewardUnits, packUri, 0, 1, recipient); + } + + function test_benchmark_packvrf_openPackAndClaimRewards() public { + vm.pauseGasMetering(); + vm.warp(1000); + address recipient = address(1); + + vm.prank(address(tokenOwner)); + pack.createPack(packContents, numOfRewardUnits, packUri, 0, 2, recipient); + + vm.prank(recipient, recipient); + vm.resumeGasMetering(); + } + + function test_benchmark_packvrf_openPack() public { + vm.pauseGasMetering(); + vm.warp(1000); + uint256 packId = pack.nextTokenIdToMint(); + uint256 packsToOpen = 3; + address recipient = address(1); + + vm.prank(address(tokenOwner)); + pack.createPack(packContents, numOfRewardUnits, packUri, 0, 2, recipient); + + vm.prank(recipient, recipient); + vm.resumeGasMetering(); + pack.openPack(packId, packsToOpen); + } +} diff --git a/src/test/benchmark/SignatureDropBenchmark.t.sol b/src/test/benchmark/SignatureDropBenchmark.t.sol new file mode 100644 index 000000000..1c6607374 --- /dev/null +++ b/src/test/benchmark/SignatureDropBenchmark.t.sol @@ -0,0 +1,214 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { SignatureDrop, IDropSinglePhase, IDelayedReveal, ISignatureMintERC721, ERC721AUpgradeable, IPermissions, ILazyMint } from "contracts/prebuilts/signature-drop/SignatureDrop.sol"; + +// Test imports +import "erc721a-upgradeable/contracts/IERC721AUpgradeable.sol"; +import "../utils/BaseTest.sol"; + +contract SignatureDropBenchmarkTest is BaseTest { + using Strings for uint256; + using Strings for address; + + event TokensLazyMinted(uint256 indexed startTokenId, uint256 endTokenId, string baseURI, bytes encryptedBaseURI); + event TokenURIRevealed(uint256 indexed index, string revealedURI); + event TokensMintedWithSignature( + address indexed signer, + address indexed mintedTo, + uint256 indexed tokenIdMinted, + SignatureDrop.MintRequest mintRequest + ); + + SignatureDrop public sigdrop; + address internal deployerSigner; + bytes32 internal typehashMintRequest; + bytes32 internal nameHash; + bytes32 internal versionHash; + bytes32 internal typehashEip712; + bytes32 internal domainSeparator; + + bytes private emptyEncodedBytes = abi.encode("", ""); + + using stdStorage for StdStorage; + + function setUp() public override { + super.setUp(); + deployerSigner = signer; + sigdrop = SignatureDrop(getContract("SignatureDrop")); + + erc20.mint(deployerSigner, 1_000 ether); + vm.deal(deployerSigner, 1_000 ether); + + typehashMintRequest = keccak256( + "MintRequest(address to,address royaltyRecipient,uint256 royaltyBps,address primarySaleRecipient,string uri,uint256 quantity,uint256 pricePerToken,address currency,uint128 validityStartTimestamp,uint128 validityEndTimestamp,bytes32 uid)" + ); + nameHash = keccak256(bytes("SignatureMintERC721")); + versionHash = keccak256(bytes("1")); + typehashEip712 = keccak256( + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" + ); + domainSeparator = keccak256(abi.encode(typehashEip712, nameHash, versionHash, block.chainid, address(sigdrop))); + } + + /*/////////////////////////////////////////////////////////////// + SignatureDrop benchmark + //////////////////////////////////////////////////////////////*/ + + function test_benchmark_signatureDrop_claim_five_tokens() public { + vm.pauseGasMetering(); + vm.warp(1); + + address receiver = getActor(0); + bytes32[] memory proofs = new bytes32[](0); + + SignatureDrop.AllowlistProof memory alp; + alp.proof = proofs; + + SignatureDrop.ClaimCondition[] memory conditions = new SignatureDrop.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 100; + conditions[0].quantityLimitPerWallet = 100; + + vm.prank(deployerSigner); + sigdrop.lazyMint(100, "ipfs://", emptyEncodedBytes); + + vm.prank(deployerSigner); + sigdrop.setClaimConditions(conditions[0], false); + + vm.prank(getActor(5), getActor(5)); + vm.resumeGasMetering(); + sigdrop.claim(receiver, 5, address(0), 0, alp, ""); + } + + function test_benchmark_signatureDrop_setClaimConditions() public { + vm.pauseGasMetering(); + vm.warp(1); + bytes32[] memory proofs = new bytes32[](0); + + SignatureDrop.AllowlistProof memory alp; + alp.proof = proofs; + + SignatureDrop.ClaimCondition[] memory conditions = new SignatureDrop.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 100; + conditions[0].quantityLimitPerWallet = 100; + + vm.prank(deployerSigner); + sigdrop.lazyMint(100, "ipfs://", emptyEncodedBytes); + + vm.prank(deployerSigner); + vm.resumeGasMetering(); + sigdrop.setClaimConditions(conditions[0], false); + } + + function test_benchmark_signatureDrop_lazyMint() public { + vm.pauseGasMetering(); + vm.prank(deployerSigner); + vm.resumeGasMetering(); + sigdrop.lazyMint(100, "ipfs://", emptyEncodedBytes); + } + + function test_benchmark_signatureDrop_lazyMint_for_delayed_reveal() public { + vm.pauseGasMetering(); + uint256 amountToLazyMint = 100; + string memory baseURI = "ipfs://"; + bytes memory encryptedBaseURI = "encryptedBaseURI://"; + bytes32 provenanceHash = bytes32("whatever"); + + vm.prank(deployerSigner); + vm.resumeGasMetering(); + sigdrop.lazyMint(amountToLazyMint, baseURI, abi.encode(encryptedBaseURI, provenanceHash)); + } + + function test_benchmark_signatureDrop_reveal() public { + vm.pauseGasMetering(); + + bytes memory key = "key"; + uint256 amountToLazyMint = 100; + bytes memory secretURI = "ipfs://"; + string memory placeholderURI = "abcd://"; + bytes memory encryptedURI = sigdrop.encryptDecrypt(secretURI, key); + bytes32 provenanceHash = keccak256(abi.encodePacked(secretURI, key, block.chainid)); + + vm.prank(deployerSigner); + sigdrop.lazyMint(amountToLazyMint, placeholderURI, abi.encode(encryptedURI, provenanceHash)); + + vm.prank(deployerSigner); + vm.resumeGasMetering(); + sigdrop.reveal(0, key); + } + + // function test_benchmark_signatureDrop_claim_one_token() public { + // vm.pauseGasMetering(); + // vm.warp(1); + + // address receiver = getActor(0); + // bytes32[] memory proofs = new bytes32[](0); + + // SignatureDrop.AllowlistProof memory alp; + // alp.proof = proofs; + + // SignatureDrop.ClaimCondition[] memory conditions = new SignatureDrop.ClaimCondition[](1); + // conditions[0].maxClaimableSupply = 100; + // conditions[0].quantityLimitPerWallet = 100; + + // vm.prank(deployerSigner); + // sigdrop.lazyMint(100, "ipfs://", emptyEncodedBytes); + + // vm.prank(deployerSigner); + // sigdrop.setClaimConditions(conditions[0], false); + + // vm.prank(getActor(5), getActor(5)); + // vm.resumeGasMetering(); + // sigdrop.claim(receiver, 1, address(0), 0, alp, ""); + // } + + // function test_benchmark_signatureDrop_claim_two_tokens() public { + // vm.pauseGasMetering(); + // vm.warp(1); + + // address receiver = getActor(0); + // bytes32[] memory proofs = new bytes32[](0); + + // SignatureDrop.AllowlistProof memory alp; + // alp.proof = proofs; + + // SignatureDrop.ClaimCondition[] memory conditions = new SignatureDrop.ClaimCondition[](1); + // conditions[0].maxClaimableSupply = 100; + // conditions[0].quantityLimitPerWallet = 100; + + // vm.prank(deployerSigner); + // sigdrop.lazyMint(100, "ipfs://", emptyEncodedBytes); + + // vm.prank(deployerSigner); + // sigdrop.setClaimConditions(conditions[0], false); + + // vm.prank(getActor(5), getActor(5)); + // vm.resumeGasMetering(); + // sigdrop.claim(receiver, 2, address(0), 0, alp, ""); + // } + + // function test_benchmark_signatureDrop_claim_three_tokens() public { + // vm.pauseGasMetering(); + // vm.warp(1); + + // address receiver = getActor(0); + // bytes32[] memory proofs = new bytes32[](0); + + // SignatureDrop.AllowlistProof memory alp; + // alp.proof = proofs; + + // SignatureDrop.ClaimCondition[] memory conditions = new SignatureDrop.ClaimCondition[](1); + // conditions[0].maxClaimableSupply = 100; + // conditions[0].quantityLimitPerWallet = 100; + + // vm.prank(deployerSigner); + // sigdrop.lazyMint(100, "ipfs://", emptyEncodedBytes); + + // vm.prank(deployerSigner); + // sigdrop.setClaimConditions(conditions[0], false); + + // vm.prank(getActor(5), getActor(5)); + // vm.resumeGasMetering(); + // sigdrop.claim(receiver, 3, address(0), 0, alp, ""); + // } +} diff --git a/src/test/benchmark/TokenERC1155Benchmark.t.sol b/src/test/benchmark/TokenERC1155Benchmark.t.sol new file mode 100644 index 000000000..4cd2563d0 --- /dev/null +++ b/src/test/benchmark/TokenERC1155Benchmark.t.sol @@ -0,0 +1,181 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { TokenERC1155, IPlatformFee } from "contracts/prebuilts/token/TokenERC1155.sol"; + +// Test imports +import "../utils/BaseTest.sol"; +import "@openzeppelin/contracts-upgradeable/utils/StringsUpgradeable.sol"; + +contract TokenERC1155BenchmarkTest is BaseTest { + using Strings for uint256; + + event TokensMinted(address indexed mintedTo, uint256 indexed tokenIdMinted, string uri, uint256 quantityMinted); + event TokensMintedWithSignature( + address indexed signer, + address indexed mintedTo, + uint256 indexed tokenIdMinted, + TokenERC1155.MintRequest mintRequest + ); + event OwnerUpdated(address indexed prevOwner, address indexed newOwner); + event DefaultRoyalty(address indexed newRoyaltyRecipient, uint256 newRoyaltyBps); + event RoyaltyForToken(uint256 indexed tokenId, address indexed royaltyRecipient, uint256 royaltyBps); + event PrimarySaleRecipientUpdated(address indexed recipient); + event PlatformFeeInfoUpdated(address indexed platformFeeRecipient, uint256 platformFeeBps); + + TokenERC1155 public tokenContract; + bytes32 internal typehashMintRequest; + bytes32 internal nameHash; + bytes32 internal versionHash; + bytes32 internal typehashEip712; + bytes32 internal domainSeparator; + + bytes private emptyEncodedBytes = abi.encode("", ""); + + TokenERC1155.MintRequest _mintrequest; + bytes _signature; + + address internal deployerSigner; + address internal recipient; + + using stdStorage for StdStorage; + + function setUp() public override { + super.setUp(); + deployerSigner = signer; + recipient = address(0x123); + tokenContract = TokenERC1155(getContract("TokenERC1155")); + + erc20.mint(deployerSigner, 1_000); + vm.deal(deployerSigner, 1_000); + + erc20.mint(recipient, 1_000); + vm.deal(recipient, 1_000); + + typehashMintRequest = keccak256( + "MintRequest(address to,address royaltyRecipient,uint256 royaltyBps,address primarySaleRecipient,uint256 tokenId,string uri,uint256 quantity,uint256 pricePerToken,address currency,uint128 validityStartTimestamp,uint128 validityEndTimestamp,bytes32 uid)" + ); + nameHash = keccak256(bytes("TokenERC1155")); + versionHash = keccak256(bytes("1")); + typehashEip712 = keccak256( + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" + ); + domainSeparator = keccak256( + abi.encode(typehashEip712, nameHash, versionHash, block.chainid, address(tokenContract)) + ); + + // construct default mintrequest + _mintrequest.to = recipient; + _mintrequest.royaltyRecipient = royaltyRecipient; + _mintrequest.royaltyBps = royaltyBps; + _mintrequest.primarySaleRecipient = saleRecipient; + _mintrequest.tokenId = type(uint256).max; + _mintrequest.uri = "ipfs://"; + _mintrequest.quantity = 100; + _mintrequest.pricePerToken = 0; + _mintrequest.currency = address(0); + _mintrequest.validityStartTimestamp = 1000; + _mintrequest.validityEndTimestamp = 2000; + _mintrequest.uid = bytes32(0); + + _signature = signMintRequest(_mintrequest, privateKey); + } + + function signMintRequest( + TokenERC1155.MintRequest memory _request, + uint256 _privateKey + ) internal view returns (bytes memory) { + bytes memory encodedRequest = bytes.concat( + abi.encode( + typehashMintRequest, + _request.to, + _request.royaltyRecipient, + _request.royaltyBps, + _request.primarySaleRecipient, + _request.tokenId, + keccak256(bytes(_request.uri)) + ), + abi.encode( + _request.quantity, + _request.pricePerToken, + _request.currency, + _request.validityStartTimestamp, + _request.validityEndTimestamp, + _request.uid + ) + ); + bytes32 structHash = keccak256(encodedRequest); + bytes32 typedDataHash = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(_privateKey, typedDataHash); + bytes memory sig = abi.encodePacked(r, s, v); + + return sig; + } + + /*/////////////////////////////////////////////////////////////// + Benchmark: TokenERC1155 + //////////////////////////////////////////////////////////////*/ + + function test_benchmark_tokenERC1155_mintWithSignature_pay_with_ERC20() public { + vm.pauseGasMetering(); + vm.warp(1000); + + // update mintrequest data + _mintrequest.pricePerToken = 1; + _mintrequest.currency = address(erc20); + _signature = signMintRequest(_mintrequest, privateKey); + + // approve erc20 tokens to tokenContract + vm.prank(recipient); + erc20.approve(address(tokenContract), _mintrequest.pricePerToken * _mintrequest.quantity); + + // mint with signature + vm.prank(recipient); + vm.resumeGasMetering(); + tokenContract.mintWithSignature(_mintrequest, _signature); + } + + function test_benchmark_tokenERC1155_mintWithSignature_pay_with_native_token() public { + vm.pauseGasMetering(); + vm.warp(1000); + + // update mintrequest data + _mintrequest.pricePerToken = 1; + _mintrequest.currency = address(NATIVE_TOKEN); + _signature = signMintRequest(_mintrequest, privateKey); + + // mint with signature + vm.prank(recipient); + vm.resumeGasMetering(); + tokenContract.mintWithSignature{ value: _mintrequest.pricePerToken * _mintrequest.quantity }( + _mintrequest, + _signature + ); + } + + function test_benchmark_tokenERC1155_mintTo() public { + vm.pauseGasMetering(); + string memory _tokenURI = "tokenURI"; + uint256 _amount = 100; + + vm.prank(deployerSigner); + vm.resumeGasMetering(); + tokenContract.mintTo(recipient, type(uint256).max, _tokenURI, _amount); + } + + function test_benchmark_tokenERC1155_burn() public { + vm.pauseGasMetering(); + string memory _tokenURI = "tokenURI"; + uint256 _amount = 100; + + uint256 nextTokenId = tokenContract.nextTokenIdToMint(); + + vm.prank(deployerSigner); + tokenContract.mintTo(recipient, type(uint256).max, _tokenURI, _amount); + + vm.prank(recipient); + vm.resumeGasMetering(); + tokenContract.burn(recipient, nextTokenId, _amount); + } +} diff --git a/src/test/benchmark/TokenERC20Benchmark.t.sol b/src/test/benchmark/TokenERC20Benchmark.t.sol new file mode 100644 index 000000000..6b93bb25a --- /dev/null +++ b/src/test/benchmark/TokenERC20Benchmark.t.sol @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { TokenERC20 } from "contracts/prebuilts/token/TokenERC20.sol"; + +// Test imports +import "../utils/BaseTest.sol"; +import "@openzeppelin/contracts-upgradeable/utils/StringsUpgradeable.sol"; + +contract TokenERC20BenchmarkTest is BaseTest { + using Strings for uint256; + + event TokensMinted(address indexed mintedTo, uint256 quantityMinted); + event TokensMintedWithSignature( + address indexed signer, + address indexed mintedTo, + TokenERC20.MintRequest mintRequest + ); + + event PrimarySaleRecipientUpdated(address indexed recipient); + event PlatformFeeInfoUpdated(address indexed platformFeeRecipient, uint256 platformFeeBps); + + TokenERC20 public tokenContract; + bytes32 internal typehashMintRequest; + bytes32 internal nameHash; + bytes32 internal versionHash; + bytes32 internal typehashEip712; + bytes32 internal domainSeparator; + bytes32 internal permitTypehash; + + bytes private emptyEncodedBytes = abi.encode("", ""); + + TokenERC20.MintRequest _mintrequest; + bytes _signature; + + address internal deployerSigner; + address internal recipient; + + using stdStorage for StdStorage; + + function setUp() public override { + super.setUp(); + deployerSigner = signer; + recipient = address(0x123); + tokenContract = TokenERC20(getContract("TokenERC20")); + + erc20.mint(deployerSigner, 1_000); + vm.deal(deployerSigner, 1_000); + + erc20.mint(recipient, 1_000); + vm.deal(recipient, 1_000); + + typehashMintRequest = keccak256( + "MintRequest(address to,address primarySaleRecipient,uint256 quantity,uint256 price,address currency,uint128 validityStartTimestamp,uint128 validityEndTimestamp,bytes32 uid)" + ); + nameHash = keccak256(bytes(NAME)); + versionHash = keccak256(bytes("1")); + typehashEip712 = keccak256( + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" + ); + domainSeparator = keccak256( + abi.encode(typehashEip712, nameHash, versionHash, block.chainid, address(tokenContract)) + ); + permitTypehash = keccak256( + "Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)" + ); + + // construct default mintrequest + _mintrequest.to = recipient; + _mintrequest.primarySaleRecipient = saleRecipient; + _mintrequest.quantity = 100; + _mintrequest.price = 0; + _mintrequest.currency = address(0); + _mintrequest.validityStartTimestamp = 1000; + _mintrequest.validityEndTimestamp = 2000; + _mintrequest.uid = bytes32(0); + + _signature = signMintRequest(_mintrequest, privateKey); + } + + function signMintRequest( + TokenERC20.MintRequest memory _request, + uint256 _privateKey + ) internal view returns (bytes memory) { + bytes memory encodedRequest = abi.encode( + typehashMintRequest, + _request.to, + _request.primarySaleRecipient, + _request.quantity, + _request.price, + _request.currency, + _request.validityStartTimestamp, + _request.validityEndTimestamp, + _request.uid + ); + bytes32 structHash = keccak256(encodedRequest); + bytes32 typedDataHash = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(_privateKey, typedDataHash); + bytes memory sig = abi.encodePacked(r, s, v); + + return sig; + } + + /*/////////////////////////////////////////////////////////////// + Benchmark: TokenERC20 + //////////////////////////////////////////////////////////////*/ + + function test_benchmark_tokenERC20_mintWithSignature_pay_with_ERC20() public { + vm.pauseGasMetering(); + vm.warp(1000); + + // update mintrequest data + _mintrequest.price = 1; + _mintrequest.currency = address(erc20); + _signature = signMintRequest(_mintrequest, privateKey); + + // approve erc20 tokens to tokenContract + vm.prank(recipient); + erc20.approve(address(tokenContract), _mintrequest.price); + + // mint with signature + vm.prank(recipient); + vm.resumeGasMetering(); + tokenContract.mintWithSignature(_mintrequest, _signature); + } + + function test_benchmark_tokenERC20_mintWithSignature_pay_with_native_token() public { + vm.pauseGasMetering(); + vm.warp(1000); + + // update mintrequest data + _mintrequest.price = 1; + _mintrequest.currency = address(NATIVE_TOKEN); + _signature = signMintRequest(_mintrequest, privateKey); + + // mint with signature + vm.prank(recipient); + vm.resumeGasMetering(); + tokenContract.mintWithSignature{ value: _mintrequest.price }(_mintrequest, _signature); + } + + function test_benchmark_tokenERC20_mintTo() public { + vm.pauseGasMetering(); + uint256 _amount = 100; + + vm.prank(deployerSigner); + vm.resumeGasMetering(); + tokenContract.mintTo(recipient, _amount); + } +} diff --git a/src/test/benchmark/TokenERC721Benchmark.t.sol b/src/test/benchmark/TokenERC721Benchmark.t.sol new file mode 100644 index 000000000..410709462 --- /dev/null +++ b/src/test/benchmark/TokenERC721Benchmark.t.sol @@ -0,0 +1,168 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { TokenERC721 } from "contracts/prebuilts/token/TokenERC721.sol"; + +// Test imports +import "../utils/BaseTest.sol"; +import "@openzeppelin/contracts-upgradeable/utils/StringsUpgradeable.sol"; + +contract TokenERC721BenchmarkTest is BaseTest { + using Strings for uint256; + + event TokensMinted(address indexed mintedTo, uint256 indexed tokenIdMinted, string uri); + event TokensMintedWithSignature( + address indexed signer, + address indexed mintedTo, + uint256 indexed tokenIdMinted, + TokenERC721.MintRequest mintRequest + ); + event OwnerUpdated(address indexed prevOwner, address indexed newOwner); + event DefaultRoyalty(address indexed newRoyaltyRecipient, uint256 newRoyaltyBps); + event RoyaltyForToken(uint256 indexed tokenId, address indexed royaltyRecipient, uint256 royaltyBps); + event PrimarySaleRecipientUpdated(address indexed recipient); + event PlatformFeeInfoUpdated(address indexed platformFeeRecipient, uint256 platformFeeBps); + + TokenERC721 public tokenContract; + bytes32 internal typehashMintRequest; + bytes32 internal nameHash; + bytes32 internal versionHash; + bytes32 internal typehashEip712; + bytes32 internal domainSeparator; + + bytes private emptyEncodedBytes = abi.encode("", ""); + + TokenERC721.MintRequest _mintrequest; + bytes _signature; + + address internal deployerSigner; + address internal recipient; + + using stdStorage for StdStorage; + + function setUp() public override { + super.setUp(); + deployerSigner = signer; + recipient = address(0x123); + tokenContract = TokenERC721(getContract("TokenERC721")); + + erc20.mint(deployerSigner, 1_000); + vm.deal(deployerSigner, 1_000); + + erc20.mint(recipient, 1_000); + vm.deal(recipient, 1_000); + + typehashMintRequest = keccak256( + "MintRequest(address to,address royaltyRecipient,uint256 royaltyBps,address primarySaleRecipient,string uri,uint256 price,address currency,uint128 validityStartTimestamp,uint128 validityEndTimestamp,bytes32 uid)" + ); + nameHash = keccak256(bytes("TokenERC721")); + versionHash = keccak256(bytes("1")); + typehashEip712 = keccak256( + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" + ); + domainSeparator = keccak256( + abi.encode(typehashEip712, nameHash, versionHash, block.chainid, address(tokenContract)) + ); + + // construct default mintrequest + _mintrequest.to = recipient; + _mintrequest.royaltyRecipient = royaltyRecipient; + _mintrequest.royaltyBps = royaltyBps; + _mintrequest.primarySaleRecipient = saleRecipient; + _mintrequest.uri = "ipfs://"; + _mintrequest.price = 0; + _mintrequest.currency = address(0); + _mintrequest.validityStartTimestamp = 1000; + _mintrequest.validityEndTimestamp = 2000; + _mintrequest.uid = bytes32(0); + + _signature = signMintRequest(_mintrequest, privateKey); + } + + function signMintRequest( + TokenERC721.MintRequest memory _request, + uint256 _privateKey + ) internal view returns (bytes memory) { + bytes memory encodedRequest = abi.encode( + typehashMintRequest, + _request.to, + _request.royaltyRecipient, + _request.royaltyBps, + _request.primarySaleRecipient, + keccak256(bytes(_request.uri)), + _request.price, + _request.currency, + _request.validityStartTimestamp, + _request.validityEndTimestamp, + _request.uid + ); + bytes32 structHash = keccak256(encodedRequest); + bytes32 typedDataHash = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(_privateKey, typedDataHash); + bytes memory sig = abi.encodePacked(r, s, v); + + return sig; + } + + /*/////////////////////////////////////////////////////////////// + Benchmark: TokenERC721 + //////////////////////////////////////////////////////////////*/ + + function test_benchmark_tokenERC721_mintWithSignature_pay_with_ERC20() public { + vm.pauseGasMetering(); + vm.warp(1000); + + // update mintrequest data + _mintrequest.price = 1; + _mintrequest.currency = address(erc20); + _signature = signMintRequest(_mintrequest, privateKey); + + // approve erc20 tokens to tokenContract + vm.prank(recipient); + erc20.approve(address(tokenContract), 1); + + // mint with signature + vm.prank(recipient); + vm.resumeGasMetering(); + tokenContract.mintWithSignature(_mintrequest, _signature); + } + + function test_benchmark_tokenERC721_mintWithSignature_pay_with_native_token() public { + vm.pauseGasMetering(); + vm.warp(1000); + + // update mintrequest data + _mintrequest.price = 1; + _mintrequest.currency = address(NATIVE_TOKEN); + _signature = signMintRequest(_mintrequest, privateKey); + + // mint with signature + vm.prank(recipient); + vm.resumeGasMetering(); + tokenContract.mintWithSignature{ value: 1 }(_mintrequest, _signature); + } + + function test_benchmark_tokenERC721_mintTo() public { + vm.pauseGasMetering(); + string memory _tokenURI = "tokenURI"; + + vm.prank(deployerSigner); + vm.resumeGasMetering(); + tokenContract.mintTo(recipient, _tokenURI); + } + + function test_benchmark_tokenERC721_burn() public { + vm.pauseGasMetering(); + string memory _tokenURI = "tokenURI"; + + uint256 nextTokenId = tokenContract.nextTokenIdToMint(); + + vm.prank(deployerSigner); + tokenContract.mintTo(recipient, _tokenURI); + + vm.prank(recipient); + vm.resumeGasMetering(); + tokenContract.burn(nextTokenId); + } +} diff --git a/src/test/benchmark/TokenStakeBenchmark.t.sol b/src/test/benchmark/TokenStakeBenchmark.t.sol new file mode 100644 index 000000000..5f639614e --- /dev/null +++ b/src/test/benchmark/TokenStakeBenchmark.t.sol @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { TokenStake } from "contracts/prebuilts/staking/TokenStake.sol"; + +// Test imports + +import "../utils/BaseTest.sol"; + +contract TokenStakeBenchmarkTest is BaseTest { + TokenStake internal stakeContract; + + address internal stakerOne; + address internal stakerTwo; + + uint256 internal timeUnit; + uint256 internal rewardRatioNumerator; + uint256 internal rewardRatioDenominator; + + function setUp() public override { + super.setUp(); + + timeUnit = 60; + rewardRatioNumerator = 3; + rewardRatioDenominator = 50; + + stakerOne = address(0x345); + stakerTwo = address(0x567); + + erc20Aux.mint(stakerOne, 1000); // mint 1000 tokens to stakerOne + erc20Aux.mint(stakerTwo, 1000); // mint 1000 tokens to stakerTwo + + erc20.mint(deployer, 1000 ether); // mint reward tokens to contract admin + + stakeContract = TokenStake(payable(getContract("TokenStake"))); + + // set approvals + vm.prank(stakerOne); + erc20Aux.approve(address(stakeContract), type(uint256).max); + + vm.prank(stakerTwo); + erc20Aux.approve(address(stakeContract), type(uint256).max); + + vm.startPrank(deployer); + erc20.approve(address(stakeContract), type(uint256).max); + stakeContract.depositRewardTokens(100 ether); + // erc20.transfer(address(stakeContract), 100 ether); + vm.stopPrank(); + assertEq(stakeContract.getRewardTokenBalance(), 100 ether); + } + + /*/////////////////////////////////////////////////////////////// + Benchmark: TokenStake + //////////////////////////////////////////////////////////////*/ + + function test_benchmark_tokenStake_stake() public { + vm.pauseGasMetering(); + + vm.warp(1); + // stake 400 tokens + vm.prank(stakerOne); + vm.resumeGasMetering(); + stakeContract.stake(400); + } + + function test_benchmark_tokenStake_claimRewards() public { + vm.pauseGasMetering(); + vm.warp(1); + + // stake 50 tokens with token-id 0 + vm.prank(stakerOne); + stakeContract.stake(400); + + //=================== warp timestamp to claim rewards + vm.roll(100); + vm.warp(1000); + vm.prank(stakerOne); + vm.resumeGasMetering(); + stakeContract.claimRewards(); + } + + function test_benchmark_tokenStake_withdraw() public { + vm.pauseGasMetering(); + vm.warp(1); + + vm.prank(stakerOne); + stakeContract.stake(400); + + vm.prank(stakerTwo); + stakeContract.stake(200); + + //========== warp timestamp before withdraw + vm.roll(100); + vm.warp(1000); + + // withdraw partially for stakerOne + vm.prank(stakerOne); + vm.resumeGasMetering(); + stakeContract.withdraw(100); + } +} diff --git a/src/test/burn-to-claim-drop-BTT/BurnToClaimDropERC721.t.sol b/src/test/burn-to-claim-drop-BTT/BurnToClaimDropERC721.t.sol new file mode 100644 index 000000000..bb294e413 --- /dev/null +++ b/src/test/burn-to-claim-drop-BTT/BurnToClaimDropERC721.t.sol @@ -0,0 +1,1885 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "../utils/BaseTest.sol"; +import { BurnToClaimDropERC721 } from "contracts/prebuilts/unaudited/burn-to-claim-drop/BurnToClaimDropERC721.sol"; +import { BurnToClaimDrop721Logic, ERC721AUpgradeable, DelayedReveal, LazyMint, Drop, BurnToClaim, PrimarySale, PlatformFee } from "contracts/prebuilts/unaudited/burn-to-claim-drop/extension/BurnToClaimDrop721Logic.sol"; +import { PermissionsEnumerableImpl } from "contracts/extension/upgradeable/impl/PermissionsEnumerableImpl.sol"; +import { Royalty } from "contracts/extension/upgradeable/Royalty.sol"; +import { BatchMintMetadata } from "contracts/extension/upgradeable/BatchMintMetadata.sol"; +import { IBurnToClaim } from "contracts/extension/interface/IBurnToClaim.sol"; + +import "@thirdweb-dev/dynamic-contracts/src/interface/IExtension.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +// Test imports +import { Permissions } from "contracts/extension/Permissions.sol"; +import { PermissionsEnumerable } from "contracts/extension/PermissionsEnumerable.sol"; +import "erc721a-upgradeable/contracts/IERC721AUpgradeable.sol"; + +contract BurnToClaimDropERC721Test is BaseTest, IExtension { + using Strings for uint256; + using Strings for address; + + event TokensLazyMinted(uint256 indexed startTokenId, uint256 endTokenId, string baseURI, bytes encryptedBaseURI); + event TokenURIRevealed(uint256 indexed index, string revealedURI); + + BurnToClaimDrop721Logic public drop; + + bytes private emptyEncodedBytes = abi.encode("", ""); + + using stdStorage for StdStorage; + + function setUp() public override { + super.setUp(); + + // Deploy implementation. + Extension[] memory extensions = _setupExtensions(); + address dropImpl = address(new BurnToClaimDropERC721(extensions)); + + // Deploy proxy pointing to implementaion. + vm.prank(deployer); + drop = BurnToClaimDrop721Logic( + payable( + address( + new TWProxy( + dropImpl, + abi.encodeCall( + BurnToClaimDropERC721.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ) + ) + ) + ); + + erc20.mint(deployer, 1_000 ether); + vm.deal(deployer, 1_000 ether); + } + + function _setupExtensions() internal returns (Extension[] memory extensions) { + extensions = new Extension[](2); + + // Extension: Permissions + address permissions = address(new PermissionsEnumerableImpl()); + + Extension memory extension_permissions; + extension_permissions.metadata = ExtensionMetadata({ + name: "Permissions", + metadataURI: "ipfs://Permissions", + implementation: permissions + }); + + extension_permissions.functions = new ExtensionFunction[](7); + extension_permissions.functions[0] = ExtensionFunction( + Permissions.hasRole.selector, + "hasRole(bytes32,address)" + ); + extension_permissions.functions[1] = ExtensionFunction( + Permissions.hasRoleWithSwitch.selector, + "hasRoleWithSwitch(bytes32,address)" + ); + extension_permissions.functions[2] = ExtensionFunction( + Permissions.grantRole.selector, + "grantRole(bytes32,address)" + ); + extension_permissions.functions[3] = ExtensionFunction( + Permissions.renounceRole.selector, + "renounceRole(bytes32,address)" + ); + extension_permissions.functions[4] = ExtensionFunction( + Permissions.revokeRole.selector, + "revokeRole(bytes32,address)" + ); + extension_permissions.functions[5] = ExtensionFunction( + PermissionsEnumerable.getRoleMemberCount.selector, + "getRoleMemberCount(bytes32)" + ); + extension_permissions.functions[6] = ExtensionFunction( + PermissionsEnumerable.getRoleMember.selector, + "getRoleMember(bytes32,uint256)" + ); + + extensions[0] = extension_permissions; + + address dropLogic = address(new BurnToClaimDrop721Logic()); + + Extension memory extension_drop; + extension_drop.metadata = ExtensionMetadata({ + name: "BurnToClaimDrop721Logic", + metadataURI: "ipfs://BurnToClaimDrop721Logic", + implementation: dropLogic + }); + + extension_drop.functions = new ExtensionFunction[](32); + extension_drop.functions[0] = ExtensionFunction(BurnToClaimDrop721Logic.tokenURI.selector, "tokenURI(uint256)"); + extension_drop.functions[1] = ExtensionFunction( + BurnToClaimDrop721Logic.lazyMint.selector, + "lazyMint(uint256,string,bytes)" + ); + extension_drop.functions[2] = ExtensionFunction( + BurnToClaimDrop721Logic.reveal.selector, + "reveal(uint256,bytes)" + ); + extension_drop.functions[3] = ExtensionFunction(Drop.claimCondition.selector, "claimCondition()"); + extension_drop.functions[4] = ExtensionFunction( + BatchMintMetadata.getBaseURICount.selector, + "getBaseURICount()" + ); + extension_drop.functions[5] = ExtensionFunction( + Drop.claim.selector, + "claim(address,uint256,address,uint256,(bytes32[],uint256,uint256,address),bytes)" + ); + extension_drop.functions[6] = ExtensionFunction( + Drop.setClaimConditions.selector, + "setClaimConditions((uint256,uint256,uint256,uint256,bytes32,uint256,address,string)[],bool)" + ); + extension_drop.functions[7] = ExtensionFunction( + Drop.getActiveClaimConditionId.selector, + "getActiveClaimConditionId()" + ); + extension_drop.functions[8] = ExtensionFunction( + Drop.getClaimConditionById.selector, + "getClaimConditionById(uint256)" + ); + extension_drop.functions[9] = ExtensionFunction( + Drop.getSupplyClaimedByWallet.selector, + "getSupplyClaimedByWallet(uint256,address)" + ); + extension_drop.functions[10] = ExtensionFunction(BurnToClaimDrop721Logic.totalMinted.selector, "totalMinted()"); + extension_drop.functions[11] = ExtensionFunction( + BurnToClaimDrop721Logic.nextTokenIdToMint.selector, + "nextTokenIdToMint()" + ); + extension_drop.functions[12] = ExtensionFunction( + IERC721Upgradeable.setApprovalForAll.selector, + "setApprovalForAll(address,bool)" + ); + extension_drop.functions[13] = ExtensionFunction( + IERC721Upgradeable.approve.selector, + "approve(address,uint256)" + ); + extension_drop.functions[14] = ExtensionFunction( + IERC721Upgradeable.transferFrom.selector, + "transferFrom(address,address,uint256)" + ); + extension_drop.functions[15] = ExtensionFunction(ERC721AUpgradeable.balanceOf.selector, "balanceOf(address)"); + extension_drop.functions[16] = ExtensionFunction( + DelayedReveal.encryptDecrypt.selector, + "encryptDecrypt(bytes,bytes)" + ); + extension_drop.functions[17] = ExtensionFunction( + BurnToClaimDrop721Logic.supportsInterface.selector, + "supportsInterface(bytes4)" + ); + extension_drop.functions[18] = ExtensionFunction(Royalty.royaltyInfo.selector, "royaltyInfo(uint256,uint256)"); + extension_drop.functions[19] = ExtensionFunction( + Royalty.getRoyaltyInfoForToken.selector, + "getRoyaltyInfoForToken(uint256)" + ); + extension_drop.functions[20] = ExtensionFunction( + Royalty.getDefaultRoyaltyInfo.selector, + "getDefaultRoyaltyInfo()" + ); + extension_drop.functions[21] = ExtensionFunction( + Royalty.setDefaultRoyaltyInfo.selector, + "setDefaultRoyaltyInfo(address,uint256)" + ); + extension_drop.functions[22] = ExtensionFunction( + Royalty.setRoyaltyInfoForToken.selector, + "setRoyaltyInfoForToken(uint256,address,uint256)" + ); + extension_drop.functions[23] = ExtensionFunction(IERC721.ownerOf.selector, "ownerOf(uint256)"); + extension_drop.functions[24] = ExtensionFunction(IERC1155.balanceOf.selector, "balanceOf(address,uint256)"); + extension_drop.functions[25] = ExtensionFunction( + BurnToClaim.setBurnToClaimInfo.selector, + "setBurnToClaimInfo((address,uint8,uint256,uint256,address))" + ); + extension_drop.functions[26] = ExtensionFunction( + BurnToClaim.getBurnToClaimInfo.selector, + "getBurnToClaimInfo()" + ); + extension_drop.functions[27] = ExtensionFunction( + BurnToClaim.verifyBurnToClaim.selector, + "verifyBurnToClaim(address,uint256,uint256)" + ); + extension_drop.functions[28] = ExtensionFunction( + BurnToClaimDrop721Logic.burnAndClaim.selector, + "burnAndClaim(uint256,uint256)" + ); + extension_drop.functions[29] = ExtensionFunction( + BurnToClaimDrop721Logic.nextTokenIdToClaim.selector, + "nextTokenIdToClaim()" + ); + extension_drop.functions[30] = ExtensionFunction( + PrimarySale.setPrimarySaleRecipient.selector, + "setPrimarySaleRecipient(address)" + ); + extension_drop.functions[31] = ExtensionFunction( + PlatformFee.setPlatformFeeInfo.selector, + "setPlatformFeeInfo(address,uint256)" + ); + + extensions[1] = extension_drop; + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: misc. + //////////////////////////////////////////////////////////////*/ + + /** + * note: Tests whether contract reverts when a non-holder renounces a role. + */ + function test_revert_nonHolder_renounceRole() public { + address caller = address(0x123); + bytes32 role = keccak256("MINTER_ROLE"); + + vm.prank(caller); + vm.expectRevert( + abi.encodePacked( + "Permissions: account ", + Strings.toHexString(uint160(caller), 20), + " is missing role ", + Strings.toHexString(uint256(role), 32) + ) + ); + + Permissions(address(drop)).renounceRole(role, caller); + } + + /** + * note: Tests whether contract reverts when a role admin revokes a role for a non-holder. + */ + function test_revert_revokeRoleForNonHolder() public { + address target = address(0x123); + bytes32 role = keccak256("MINTER_ROLE"); + + vm.prank(deployer); + vm.expectRevert( + abi.encodePacked( + "Permissions: account ", + Strings.toHexString(uint160(target), 20), + " is missing role ", + Strings.toHexString(uint256(role), 32) + ) + ); + + Permissions(address(drop)).revokeRole(role, target); + } + + /** + * @dev Tests whether contract reverts when a role is granted to an existent role holder. + */ + function test_revert_grant_role_to_account_with_role() public { + bytes32 role = keccak256("ABC_ROLE"); + address receiver = getActor(0); + + vm.startPrank(deployer); + + Permissions(address(drop)).grantRole(role, receiver); + + vm.expectRevert("Can only grant to non holders"); + Permissions(address(drop)).grantRole(role, receiver); + + vm.stopPrank(); + } + + /** + * @dev Tests contract state for Transfer role. + */ + function test_state_grant_transferRole() public { + bytes32 role = keccak256("TRANSFER_ROLE"); + + // check if admin and address(0) have transfer role in the beginning + bool checkAddressZero = Permissions(address(drop)).hasRole(role, address(0)); + bool checkAdmin = Permissions(address(drop)).hasRole(role, deployer); + assertTrue(checkAddressZero); + assertTrue(checkAdmin); + + // check if transfer role can be granted to a non-holder + address receiver = getActor(0); + vm.startPrank(deployer); + Permissions(address(drop)).grantRole(role, receiver); + + // expect revert when granting to a holder + vm.expectRevert("Can only grant to non holders"); + Permissions(address(drop)).grantRole(role, receiver); + + // check if receiver has transfer role + bool checkReceiver = Permissions(address(drop)).hasRole(role, receiver); + assertTrue(checkReceiver); + + // check if role is correctly revoked + Permissions(address(drop)).revokeRole(role, receiver); + checkReceiver = Permissions(address(drop)).hasRole(role, receiver); + assertFalse(checkReceiver); + Permissions(address(drop)).revokeRole(role, address(0)); + checkAddressZero = Permissions(address(drop)).hasRole(role, address(0)); + assertFalse(checkAddressZero); + + vm.stopPrank(); + } + + /** + * @dev Tests contract state for Transfer role. + */ + function test_state_getRoleMember_transferRole() public { + bytes32 role = keccak256("TRANSFER_ROLE"); + + uint256 roleMemberCount = PermissionsEnumerable(address(drop)).getRoleMemberCount(role); + assertEq(roleMemberCount, 2); + + address roleMember = PermissionsEnumerable(address(drop)).getRoleMember(role, 1); + assertEq(roleMember, address(0)); + + vm.startPrank(deployer); + Permissions(address(drop)).grantRole(role, address(2)); + Permissions(address(drop)).grantRole(role, address(3)); + Permissions(address(drop)).grantRole(role, address(4)); + + roleMemberCount = PermissionsEnumerable(address(drop)).getRoleMemberCount(role); + console.log(roleMemberCount); + for (uint256 i = 0; i < roleMemberCount; i++) { + console.log(PermissionsEnumerable(address(drop)).getRoleMember(role, i)); + } + console.log(""); + + Permissions(address(drop)).revokeRole(role, address(2)); + roleMemberCount = PermissionsEnumerable(address(drop)).getRoleMemberCount(role); + console.log(roleMemberCount); + for (uint256 i = 0; i < roleMemberCount; i++) { + console.log(PermissionsEnumerable(address(drop)).getRoleMember(role, i)); + } + console.log(""); + + Permissions(address(drop)).revokeRole(role, address(0)); + roleMemberCount = PermissionsEnumerable(address(drop)).getRoleMemberCount(role); + console.log(roleMemberCount); + for (uint256 i = 0; i < roleMemberCount; i++) { + console.log(PermissionsEnumerable(address(drop)).getRoleMember(role, i)); + } + console.log(""); + + Permissions(address(drop)).grantRole(role, address(5)); + roleMemberCount = PermissionsEnumerable(address(drop)).getRoleMemberCount(role); + console.log(roleMemberCount); + for (uint256 i = 0; i < roleMemberCount; i++) { + console.log(PermissionsEnumerable(address(drop)).getRoleMember(role, i)); + } + console.log(""); + + Permissions(address(drop)).grantRole(role, address(0)); + roleMemberCount = PermissionsEnumerable(address(drop)).getRoleMemberCount(role); + console.log(roleMemberCount); + for (uint256 i = 0; i < roleMemberCount; i++) { + console.log(PermissionsEnumerable(address(drop)).getRoleMember(role, i)); + } + console.log(""); + + Permissions(address(drop)).grantRole(role, address(6)); + roleMemberCount = PermissionsEnumerable(address(drop)).getRoleMemberCount(role); + console.log(roleMemberCount); + for (uint256 i = 0; i < roleMemberCount; i++) { + console.log(PermissionsEnumerable(address(drop)).getRoleMember(role, i)); + } + console.log(""); + } + + /** + * note: Testing transfer of tokens when transfer-role is restricted + */ + function test_claim_transferRole() public { + vm.warp(1); + + address receiver = getActor(0); + bytes32[] memory proofs = new bytes32[](0); + + BurnToClaimDrop721Logic.AllowlistProof memory alp; + alp.proof = proofs; + + BurnToClaimDrop721Logic.ClaimCondition[] memory conditions = new BurnToClaimDrop721Logic.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 100; + conditions[0].quantityLimitPerWallet = 100; + + vm.prank(deployer); + drop.lazyMint(100, "ipfs://", emptyEncodedBytes); + vm.prank(deployer); + drop.setClaimConditions(conditions, false); + + vm.prank(getActor(5), getActor(5)); + drop.claim(receiver, 1, address(0), 0, alp, ""); + + // revoke transfer role from address(0) + vm.prank(deployer); + Permissions(address(drop)).revokeRole(keccak256("TRANSFER_ROLE"), address(0)); + vm.startPrank(receiver); + vm.expectRevert("!Transfer-Role"); + drop.transferFrom(receiver, address(123), 0); + } + + /** + * @dev Tests whether role member count is incremented correctly. + */ + function test_member_count_incremented_properly_when_role_granted() public { + bytes32 role = keccak256("ABC_ROLE"); + address receiver = getActor(0); + + vm.startPrank(deployer); + uint256 roleMemberCount = PermissionsEnumerable(address(drop)).getRoleMemberCount(role); + + assertEq(roleMemberCount, 0); + + Permissions(address(drop)).grantRole(role, receiver); + + assertEq(PermissionsEnumerable(address(drop)).getRoleMemberCount(role), 1); + + vm.stopPrank(); + } + + function test_claimCondition_with_startTimestamp() public { + vm.warp(1); + + address receiver = getActor(0); + bytes32[] memory proofs = new bytes32[](0); + + BurnToClaimDrop721Logic.AllowlistProof memory alp; + alp.proof = proofs; + + BurnToClaimDrop721Logic.ClaimCondition[] memory conditions = new BurnToClaimDrop721Logic.ClaimCondition[](1); + conditions[0].startTimestamp = 100; + conditions[0].maxClaimableSupply = 100; + conditions[0].quantityLimitPerWallet = 100; + + vm.prank(deployer); + drop.lazyMint(100, "ipfs://", emptyEncodedBytes); + + vm.prank(deployer); + drop.setClaimConditions(conditions, false); + + vm.warp(99); + vm.prank(getActor(5), getActor(5)); + vm.expectRevert("!CONDITION."); + drop.claim(receiver, 1, address(0), 0, alp, ""); + + vm.warp(100); + vm.prank(getActor(4), getActor(4)); + drop.claim(receiver, 1, address(0), 0, alp, ""); + } + + /*/////////////////////////////////////////////////////////////// + Primary sale and Platform fee tests + //////////////////////////////////////////////////////////////*/ + + /// note: Test whether transaction reverts when adding address(0) as primary sale recipient at deploy time + function test_revert_deploy_emptyPrimarySaleRecipient() public { + // Deploy implementation. + Extension[] memory extensions = _setupExtensions(); + address dropImpl = address(new BurnToClaimDropERC721(extensions)); + + // Deploy proxy pointing to implementaion. + vm.prank(deployer); + vm.expectRevert("Invalid recipient"); + drop = BurnToClaimDrop721Logic( + payable( + address( + new TWProxy( + dropImpl, + abi.encodeCall( + BurnToClaimDropERC721.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + address(0), + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ) + ) + ) + ); + } + + /// note: Test whether transaction reverts when adding address(0) as primary sale recipient + function test_revert_emptyPrimarySaleRecipient() public { + vm.prank(deployer); + vm.expectRevert("Invalid recipient"); + drop.setPrimarySaleRecipient(address(0)); + } + + /// note: Test whether transaction reverts when adding address(0) as platform fee recipient at deploy time + function test_revert_deploy_emptyPlatformFeeRecipient() public { + // Deploy implementation. + Extension[] memory extensions = _setupExtensions(); + address dropImpl = address(new BurnToClaimDropERC721(extensions)); + + // Deploy proxy pointing to implementaion. + vm.prank(deployer); + vm.expectRevert("Invalid recipient"); + drop = BurnToClaimDrop721Logic( + payable( + address( + new TWProxy( + dropImpl, + abi.encodeCall( + BurnToClaimDropERC721.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + address(0) + ) + ) + ) + ) + ) + ); + } + + /// note: Test whether transaction reverts when adding address(0) as platform fee recipient + function test_revert_emptyPlatformFeeRecipient() public { + vm.prank(deployer); + vm.expectRevert("Invalid recipient"); + drop.setPlatformFeeInfo(address(0), 100); + } + + /*/////////////////////////////////////////////////////////////// + Lazy Mint Tests + //////////////////////////////////////////////////////////////*/ + + /* + * note: Testing state changes; lazy mint a batch of tokens with no encrypted base URI. + */ + function test_state_lazyMint_noEncryptedURI() public { + uint256 amountToLazyMint = 100; + string memory baseURI = "ipfs://"; + + uint256 nextTokenIdToMintBefore = drop.nextTokenIdToMint(); + + vm.startPrank(deployer); + uint256 batchId = drop.lazyMint(amountToLazyMint, baseURI, emptyEncodedBytes); + + assertEq(nextTokenIdToMintBefore + amountToLazyMint, drop.nextTokenIdToMint()); + assertEq(nextTokenIdToMintBefore + amountToLazyMint, batchId); + + for (uint256 i = 0; i < amountToLazyMint; i += 1) { + string memory uri = drop.tokenURI(i); + console.log(uri); + assertEq(uri, string(abi.encodePacked(baseURI, i.toString()))); + } + + vm.stopPrank(); + } + + /* + * note: Testing state changes; lazy mint a batch of tokens with encrypted base URI. + */ + function test_state_lazyMint_withEncryptedURI() public { + uint256 amountToLazyMint = 100; + string memory baseURI = "ipfs://"; + bytes memory encryptedBaseURI = "encryptedBaseURI://"; + bytes32 provenanceHash = bytes32("whatever"); + + uint256 nextTokenIdToMintBefore = drop.nextTokenIdToMint(); + + vm.startPrank(deployer); + uint256 batchId = drop.lazyMint(amountToLazyMint, baseURI, abi.encode(encryptedBaseURI, provenanceHash)); + + assertEq(nextTokenIdToMintBefore + amountToLazyMint, drop.nextTokenIdToMint()); + assertEq(nextTokenIdToMintBefore + amountToLazyMint, batchId); + + for (uint256 i = 0; i < amountToLazyMint; i += 1) { + string memory uri = drop.tokenURI(i); + console.log(uri); + assertEq(uri, string(abi.encodePacked(baseURI, "0"))); + } + + vm.stopPrank(); + } + + /** + * note: Testing revert condition; an address without MINTER_ROLE calls lazyMint function. + */ + function test_revert_lazyMint_MINTER_ROLE() public { + vm.expectRevert("Not authorized"); + drop.lazyMint(100, "ipfs://", emptyEncodedBytes); + } + + /* + * note: Testing revert condition; calling tokenURI for invalid batch id. + */ + function test_revert_lazyMint_URIForNonLazyMintedToken() public { + vm.startPrank(deployer); + + drop.lazyMint(100, "ipfs://", emptyEncodedBytes); + + vm.expectRevert("Invalid tokenId"); + drop.tokenURI(100); + + vm.stopPrank(); + } + + /** + * note: Testing event emission; tokens lazy minted. + */ + function test_event_lazyMint_TokensLazyMinted() public { + vm.startPrank(deployer); + + vm.expectEmit(true, false, false, true); + emit TokensLazyMinted(0, 99, "ipfs://", emptyEncodedBytes); + drop.lazyMint(100, "ipfs://", emptyEncodedBytes); + + vm.stopPrank(); + } + + /* + * note: Fuzz testing state changes; lazy mint a batch of tokens with no encrypted base URI. + */ + function test_fuzz_lazyMint_noEncryptedURI(uint256 x) public { + vm.assume(x > 0); + + uint256 amountToLazyMint = x; + string memory baseURI = "ipfs://"; + + uint256 nextTokenIdToMintBefore = drop.nextTokenIdToMint(); + + vm.startPrank(deployer); + uint256 batchId = drop.lazyMint(amountToLazyMint, baseURI, emptyEncodedBytes); + + assertEq(nextTokenIdToMintBefore + amountToLazyMint, drop.nextTokenIdToMint()); + assertEq(nextTokenIdToMintBefore + amountToLazyMint, batchId); + + string memory uri = drop.tokenURI(0); + assertEq(uri, string(abi.encodePacked(baseURI, uint256(0).toString()))); + + uri = drop.tokenURI(x - 1); + assertEq(uri, string(abi.encodePacked(baseURI, uint256(x - 1).toString()))); + + /** + * note: this loop takes too long to run with fuzz tests. + */ + // for(uint256 i = 0; i < amountToLazyMint; i += 1) { + // string memory uri = drop.tokenURI(i); + // console.log(uri); + // assertEq(uri, string(abi.encodePacked(baseURI, i.toString()))); + // } + + vm.stopPrank(); + } + + /* + * note: Fuzz testing state changes; lazy mint a batch of tokens with encrypted base URI. + */ + function test_fuzz_lazyMint_withEncryptedURI(uint256 x) public { + vm.assume(x > 0); + + uint256 amountToLazyMint = x; + string memory baseURI = "ipfs://"; + bytes memory encryptedBaseURI = "encryptedBaseURI://"; + bytes32 provenanceHash = bytes32("whatever"); + + uint256 nextTokenIdToMintBefore = drop.nextTokenIdToMint(); + + vm.startPrank(deployer); + uint256 batchId = drop.lazyMint(amountToLazyMint, baseURI, abi.encode(encryptedBaseURI, provenanceHash)); + + assertEq(nextTokenIdToMintBefore + amountToLazyMint, drop.nextTokenIdToMint()); + assertEq(nextTokenIdToMintBefore + amountToLazyMint, batchId); + + string memory uri = drop.tokenURI(0); + assertEq(uri, string(abi.encodePacked(baseURI, "0"))); + + uri = drop.tokenURI(x - 1); + assertEq(uri, string(abi.encodePacked(baseURI, "0"))); + + /** + * note: this loop takes too long to run with fuzz tests. + */ + // for(uint256 i = 0; i < amountToLazyMint; i += 1) { + // string memory uri = drop.tokenURI(1); + // assertEq(uri, string(abi.encodePacked(baseURI, "0"))); + // } + + vm.stopPrank(); + } + + /* + * note: Fuzz testing; a batch of tokens, and nextTokenIdToMint + */ + function test_fuzz_lazyMint_batchMintAndNextTokenIdToMint(uint256 x) public { + vm.assume(x > 0); + vm.startPrank(deployer); + + if (x == 0) { + vm.expectRevert("Zero amount"); + } + drop.lazyMint(x, "ipfs://", emptyEncodedBytes); + + uint256 slot = stdstore.target(address(drop)).sig("nextTokenIdToMint()").find(); + bytes32 loc = bytes32(slot); + uint256 nextTokenIdToMint = uint256(vm.load(address(drop), loc)); + + assertEq(nextTokenIdToMint, x); + vm.stopPrank(); + } + + /*/////////////////////////////////////////////////////////////// + Delayed Reveal Tests + //////////////////////////////////////////////////////////////*/ + + /* + * note: Testing state changes; URI revealed for a batch of tokens. + */ + function test_state_reveal() public { + vm.startPrank(deployer); + + bytes memory key = "key"; + uint256 amountToLazyMint = 100; + bytes memory secretURI = "ipfs://"; + string memory placeholderURI = "abcd://"; + bytes memory encryptedURI = drop.encryptDecrypt(secretURI, key); + bytes32 provenanceHash = keccak256(abi.encodePacked(secretURI, key, block.chainid)); + + drop.lazyMint(amountToLazyMint, placeholderURI, abi.encode(encryptedURI, provenanceHash)); + + for (uint256 i = 0; i < amountToLazyMint; i += 1) { + string memory uri = drop.tokenURI(i); + assertEq(uri, string(abi.encodePacked(placeholderURI, "0"))); + } + + string memory revealedURI = drop.reveal(0, key); + assertEq(revealedURI, string(secretURI)); + + for (uint256 i = 0; i < amountToLazyMint; i += 1) { + string memory uri = drop.tokenURI(i); + assertEq(uri, string(abi.encodePacked(secretURI, i.toString()))); + } + + vm.stopPrank(); + } + + /** + * note: Testing revert condition; an address without MINTER_ROLE calls reveal function. + */ + function test_revert_reveal_MINTER_ROLE() public { + bytes memory key = "key"; + bytes memory encryptedURI = drop.encryptDecrypt("ipfs://", key); + bytes32 provenanceHash = keccak256(abi.encodePacked("ipfs://", key, block.chainid)); + vm.prank(deployer); + drop.lazyMint(100, "", abi.encode(encryptedURI, provenanceHash)); + + vm.prank(deployer); + drop.reveal(0, "key"); + + vm.expectRevert("not minter."); + drop.reveal(0, "key"); + } + + /* + * note: Testing revert condition; trying to reveal URI for non-existent batch. + */ + function test_revert_reveal_revealingNonExistentBatch() public { + vm.startPrank(deployer); + + bytes memory key = "key"; + bytes memory encryptedURI = drop.encryptDecrypt("ipfs://", key); + bytes32 provenanceHash = keccak256(abi.encodePacked("ipfs://", key, block.chainid)); + drop.lazyMint(100, "", abi.encode(encryptedURI, provenanceHash)); + drop.reveal(0, "key"); + + console.log(drop.getBaseURICount()); + + drop.lazyMint(100, "", abi.encode(encryptedURI, provenanceHash)); + vm.expectRevert("Invalid index"); + drop.reveal(2, "key"); + + vm.stopPrank(); + } + + /* + * note: Testing revert condition; already revealed URI. + */ + function test_revert_delayedReveal_alreadyRevealed() public { + vm.startPrank(deployer); + + bytes memory key = "key"; + bytes memory encryptedURI = drop.encryptDecrypt("ipfs://", key); + bytes32 provenanceHash = keccak256(abi.encodePacked("ipfs://", key, block.chainid)); + drop.lazyMint(100, "", abi.encode(encryptedURI, provenanceHash)); + drop.reveal(0, "key"); + + vm.expectRevert("Nothing to reveal"); + drop.reveal(0, "key"); + + vm.stopPrank(); + } + + /* + * note: Testing state changes; revealing URI with an incorrect key. + */ + function testFail_reveal_incorrectKey() public { + vm.startPrank(deployer); + + bytes memory key = "key"; + bytes memory encryptedURI = drop.encryptDecrypt("ipfs://", key); + bytes32 provenanceHash = keccak256(abi.encodePacked("ipfs://", key, block.chainid)); + drop.lazyMint(100, "", abi.encode(encryptedURI, provenanceHash)); + + string memory revealedURI = drop.reveal(0, "keyy"); + assertEq(revealedURI, "ipfs://"); + + vm.stopPrank(); + } + + /** + * note: Testing event emission; TokenURIRevealed. + */ + function test_event_reveal_TokenURIRevealed() public { + vm.startPrank(deployer); + + bytes memory key = "key"; + bytes memory encryptedURI = drop.encryptDecrypt("ipfs://", key); + bytes32 provenanceHash = keccak256(abi.encodePacked("ipfs://", key, block.chainid)); + drop.lazyMint(100, "", abi.encode(encryptedURI, provenanceHash)); + + vm.expectEmit(true, false, false, true); + emit TokenURIRevealed(0, "ipfs://"); + drop.reveal(0, "key"); + + vm.stopPrank(); + } + + /*/////////////////////////////////////////////////////////////// + Claim Tests + //////////////////////////////////////////////////////////////*/ + + /** + * note: Testing revert condition; not enough minted tokens. + */ + function test_revert_claimCondition_notEnoughMintedTokens() public { + vm.warp(1); + + address receiver = getActor(0); + bytes32[] memory proofs = new bytes32[](0); + + BurnToClaimDrop721Logic.AllowlistProof memory alp; + alp.proof = proofs; + + BurnToClaimDrop721Logic.ClaimCondition[] memory conditions = new BurnToClaimDrop721Logic.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 100; + conditions[0].quantityLimitPerWallet = 200; + + vm.prank(deployer); + drop.lazyMint(100, "ipfs://", emptyEncodedBytes); + vm.prank(deployer); + drop.setClaimConditions(conditions, false); + + vm.expectRevert("!Tokens"); + vm.prank(getActor(6), getActor(6)); + drop.claim(receiver, 101, address(0), 0, alp, ""); + } + + /** + * note: Testing revert condition; exceed max claimable supply. + */ + function test_revert_claimCondition_exceedMaxClaimableSupply() public { + vm.warp(1); + + address receiver = getActor(0); + bytes32[] memory proofs = new bytes32[](0); + + BurnToClaimDrop721Logic.AllowlistProof memory alp; + alp.proof = proofs; + + BurnToClaimDrop721Logic.ClaimCondition[] memory conditions = new BurnToClaimDrop721Logic.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 100; + conditions[0].quantityLimitPerWallet = 200; + + vm.prank(deployer); + drop.lazyMint(200, "ipfs://", emptyEncodedBytes); + vm.prank(deployer); + drop.setClaimConditions(conditions, false); + + vm.prank(getActor(5), getActor(5)); + drop.claim(receiver, 100, address(0), 0, alp, ""); + + vm.expectRevert("!MaxSupply"); + vm.prank(getActor(6), getActor(6)); + drop.claim(receiver, 1, address(0), 0, alp, ""); + } + + /** + * note: Testing quantity limit restriction when no allowlist present. + */ + function test_fuzz_claim_noAllowlist(uint256 x) public { + vm.assume(x != 0); + vm.warp(1); + + address receiver = getActor(0); + bytes32[] memory proofs = new bytes32[](0); + + BurnToClaimDrop721Logic.AllowlistProof memory alp; + alp.proof = proofs; + alp.quantityLimitPerWallet = x; + + BurnToClaimDrop721Logic.ClaimCondition[] memory conditions = new BurnToClaimDrop721Logic.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 500; + conditions[0].quantityLimitPerWallet = 100; + + vm.prank(deployer); + drop.lazyMint(500, "ipfs://", emptyEncodedBytes); + + vm.prank(deployer); + drop.setClaimConditions(conditions, false); + + bytes memory errorQty = "!Qty"; + + vm.prank(getActor(5), getActor(5)); + vm.expectRevert(errorQty); + drop.claim(receiver, 0, address(0), 0, alp, ""); + + vm.prank(getActor(5), getActor(5)); + vm.expectRevert(errorQty); + drop.claim(receiver, 101, address(0), 0, alp, ""); + + vm.prank(deployer); + drop.setClaimConditions(conditions, true); + + vm.prank(getActor(5), getActor(5)); + vm.expectRevert(errorQty); + drop.claim(receiver, 101, address(0), 0, alp, ""); + } + + /** + * note: Testing quantity limit restriction + * - allowlist quantity set to some value different than general limit + * - allowlist price set to 0 + */ + function test_state_claim_allowlisted_SetQuantityZeroPrice() public { + string[] memory inputs = new string[](5); + + inputs[0] = "node"; + inputs[1] = "src/test/scripts/generateRoot.ts"; + inputs[2] = "300"; + inputs[3] = "0"; + inputs[4] = Strings.toHexString(uint160(address(erc20))); // address of erc20 + + bytes memory result = vm.ffi(inputs); + // revert(); + bytes32 root = abi.decode(result, (bytes32)); + + inputs[1] = "src/test/scripts/getProof.ts"; + result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + BurnToClaimDrop721Logic.AllowlistProof memory alp; + alp.proof = proofs; + alp.quantityLimitPerWallet = 300; + alp.pricePerToken = 0; + alp.currency = address(erc20); + + vm.warp(1); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); // in allowlist + + BurnToClaimDrop721Logic.ClaimCondition[] memory conditions = new BurnToClaimDrop721Logic.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 500; + conditions[0].quantityLimitPerWallet = 10; + conditions[0].merkleRoot = root; + conditions[0].pricePerToken = 10; + conditions[0].currency = address(erc20); + + vm.prank(deployer); + drop.lazyMint(500, "ipfs://", emptyEncodedBytes); + vm.prank(deployer); + drop.setClaimConditions(conditions, false); + + // vm.prank(getActor(5), getActor(5)); + vm.prank(receiver, receiver); + drop.claim(receiver, 100, address(erc20), 0, alp, ""); // claims for free, because allowlist price is 0 + assertEq(drop.getSupplyClaimedByWallet(drop.getActiveClaimConditionId(), receiver), 100); + } + + /** + * note: Testing quantity limit restriction + * - allowlist quantity set to some value different than general limit + * - allowlist price set to non-zero value + */ + function test_state_claim_allowlisted_SetQuantityPrice() public { + string[] memory inputs = new string[](5); + + inputs[0] = "node"; + inputs[1] = "src/test/scripts/generateRoot.ts"; + inputs[2] = "300"; + inputs[3] = "5"; + inputs[4] = Strings.toHexString(uint160(address(erc20))); // address of erc20 + + bytes memory result = vm.ffi(inputs); + // revert(); + bytes32 root = abi.decode(result, (bytes32)); + + inputs[1] = "src/test/scripts/getProof.ts"; + result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + BurnToClaimDrop721Logic.AllowlistProof memory alp; + alp.proof = proofs; + alp.quantityLimitPerWallet = 300; + alp.pricePerToken = 5; + alp.currency = address(erc20); + + vm.warp(1); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); // in allowlist + + BurnToClaimDrop721Logic.ClaimCondition[] memory conditions = new BurnToClaimDrop721Logic.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 500; + conditions[0].quantityLimitPerWallet = 10; + conditions[0].merkleRoot = root; + conditions[0].pricePerToken = 10; + conditions[0].currency = address(erc20); + + vm.prank(deployer); + drop.lazyMint(500, "ipfs://", emptyEncodedBytes); + vm.prank(deployer); + drop.setClaimConditions(conditions, false); + + vm.prank(receiver, receiver); + vm.expectRevert("!PriceOrCurrency"); + drop.claim(receiver, 100, address(erc20), 0, alp, ""); + + erc20.mint(receiver, 10000); + vm.prank(receiver); + erc20.approve(address(drop), 10000); + + // vm.prank(getActor(5), getActor(5)); + vm.prank(receiver, receiver); + drop.claim(receiver, 100, address(erc20), 5, alp, ""); + assertEq(drop.getSupplyClaimedByWallet(drop.getActiveClaimConditionId(), receiver), 100); + assertEq(erc20.balanceOf(receiver), 10000 - 500); + } + + /** + * note: Testing quantity limit restriction + * - allowlist quantity set to some value different than general limit + * - allowlist price not set; should default to general price and currency + */ + function test_state_claim_allowlisted_SetQuantityDefaultPrice() public { + string[] memory inputs = new string[](5); + + inputs[0] = "node"; + inputs[1] = "src/test/scripts/generateRoot.ts"; + inputs[2] = "300"; + inputs[3] = Strings.toString(type(uint256).max); // this implies that general price is applicable + inputs[4] = "0x0000000000000000000000000000000000000000"; + + bytes memory result = vm.ffi(inputs); + // revert(); + bytes32 root = abi.decode(result, (bytes32)); + + inputs[1] = "src/test/scripts/getProof.ts"; + result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + BurnToClaimDrop721Logic.AllowlistProof memory alp; + alp.proof = proofs; + alp.quantityLimitPerWallet = 300; + alp.pricePerToken = type(uint256).max; + alp.currency = address(0); + + vm.warp(1); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); // in allowlist + + BurnToClaimDrop721Logic.ClaimCondition[] memory conditions = new BurnToClaimDrop721Logic.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 500; + conditions[0].quantityLimitPerWallet = 10; + conditions[0].merkleRoot = root; + conditions[0].pricePerToken = 10; + conditions[0].currency = address(erc20); + + vm.prank(deployer); + drop.lazyMint(500, "ipfs://", emptyEncodedBytes); + vm.prank(deployer); + drop.setClaimConditions(conditions, false); + + erc20.mint(receiver, 10000); + vm.prank(receiver); + erc20.approve(address(drop), 10000); + + // vm.prank(getActor(5), getActor(5)); + vm.prank(receiver, receiver); + drop.claim(receiver, 100, address(erc20), 10, alp, ""); + assertEq(drop.getSupplyClaimedByWallet(drop.getActiveClaimConditionId(), receiver), 100); + assertEq(erc20.balanceOf(receiver), 10000 - 1000); + } + + /** + * note: Testing quantity limit restriction + * - allowlist quantity set to 0 => should default to general limit + * - allowlist price set to some value different than general price + */ + function test_state_claim_allowlisted_DefaultQuantitySomePrice() public { + string[] memory inputs = new string[](5); + + inputs[0] = "node"; + inputs[1] = "src/test/scripts/generateRoot.ts"; + inputs[2] = "0"; // this implies that general limit is applicable + inputs[3] = "5"; + inputs[4] = "0x0000000000000000000000000000000000000000"; // general currency will be applicable + + bytes memory result = vm.ffi(inputs); + // revert(); + bytes32 root = abi.decode(result, (bytes32)); + + inputs[1] = "src/test/scripts/getProof.ts"; + result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + BurnToClaimDrop721Logic.AllowlistProof memory alp; + alp.proof = proofs; + alp.quantityLimitPerWallet = 0; + alp.pricePerToken = 5; + alp.currency = address(0); + + vm.warp(1); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); // in allowlist + + BurnToClaimDrop721Logic.ClaimCondition[] memory conditions = new BurnToClaimDrop721Logic.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 500; + conditions[0].quantityLimitPerWallet = 10; + conditions[0].merkleRoot = root; + conditions[0].pricePerToken = 10; + conditions[0].currency = address(erc20); + + vm.prank(deployer); + drop.lazyMint(500, "ipfs://", emptyEncodedBytes); + vm.prank(deployer); + drop.setClaimConditions(conditions, false); + + erc20.mint(receiver, 10000); + vm.prank(receiver); + erc20.approve(address(drop), 10000); + + bytes memory errorQty = "!Qty"; + vm.prank(receiver, receiver); + vm.expectRevert(errorQty); + drop.claim(receiver, 100, address(erc20), 5, alp, ""); // trying to claim more than general limit + + // vm.prank(getActor(5), getActor(5)); + vm.prank(receiver, receiver); + drop.claim(receiver, 10, address(erc20), 5, alp, ""); + assertEq(drop.getSupplyClaimedByWallet(drop.getActiveClaimConditionId(), receiver), 10); + assertEq(erc20.balanceOf(receiver), 10000 - 50); + } + + function test_fuzz_claim_merkleProof(uint256 x) public { + vm.assume(x > 10 && x < 500); + string[] memory inputs = new string[](5); + + inputs[0] = "node"; + inputs[1] = "src/test/scripts/generateRoot.ts"; + inputs[2] = Strings.toString(x); + inputs[3] = "0"; + inputs[4] = "0x0000000000000000000000000000000000000000"; + + bytes memory result = vm.ffi(inputs); + // revert(); + bytes32 root = abi.decode(result, (bytes32)); + + inputs[1] = "src/test/scripts/getProof.ts"; + result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + BurnToClaimDrop721Logic.AllowlistProof memory alp; + alp.proof = proofs; + alp.quantityLimitPerWallet = x; + alp.pricePerToken = 0; + alp.currency = address(0); + + vm.warp(1); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); + + // bytes32[] memory proofs = new bytes32[](0); + + BurnToClaimDrop721Logic.ClaimCondition[] memory conditions = new BurnToClaimDrop721Logic.ClaimCondition[](1); + conditions[0].maxClaimableSupply = x; + conditions[0].quantityLimitPerWallet = 1; + conditions[0].merkleRoot = root; + + vm.prank(deployer); + drop.lazyMint(2 * x, "ipfs://", emptyEncodedBytes); + vm.prank(deployer); + drop.setClaimConditions(conditions, false); + + // vm.prank(getActor(5), getActor(5)); + vm.prank(receiver, receiver); + drop.claim(receiver, x - 5, address(0), 0, alp, ""); + assertEq(drop.getSupplyClaimedByWallet(drop.getActiveClaimConditionId(), receiver), x - 5); + + bytes memory errorQty = "!Qty"; + + vm.prank(receiver, receiver); + vm.expectRevert(errorQty); + drop.claim(receiver, 6, address(0), 0, alp, ""); + + vm.prank(receiver, receiver); + drop.claim(receiver, 5, address(0), 0, alp, ""); + assertEq(drop.getSupplyClaimedByWallet(drop.getActiveClaimConditionId(), receiver), x); + + vm.prank(receiver, receiver); + vm.expectRevert(errorQty); + drop.claim(receiver, 5, address(0), 0, alp, ""); // quantity limit already claimed + } + + /** + * note: Testing state changes; reset eligibility of claim conditions and claiming again for same condition id. + */ + function test_state_claimCondition_resetEligibility() public { + vm.warp(1); + + address receiver = getActor(0); + bytes32[] memory proofs = new bytes32[](0); + + BurnToClaimDrop721Logic.AllowlistProof memory alp; + alp.proof = proofs; + + BurnToClaimDrop721Logic.ClaimCondition[] memory conditions = new BurnToClaimDrop721Logic.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 500; + conditions[0].quantityLimitPerWallet = 100; + + vm.prank(deployer); + drop.lazyMint(500, "ipfs://", emptyEncodedBytes); + + vm.prank(deployer); + drop.setClaimConditions(conditions, false); + + vm.prank(getActor(5), getActor(5)); + drop.claim(receiver, 100, address(0), 0, alp, ""); + + bytes memory errorQty = "!Qty"; + + vm.prank(getActor(5), getActor(5)); + vm.expectRevert(errorQty); + drop.claim(receiver, 100, address(0), 0, alp, ""); + + vm.prank(deployer); + drop.setClaimConditions(conditions, true); + + vm.prank(getActor(5), getActor(5)); + drop.claim(receiver, 100, address(0), 0, alp, ""); + } + + /*/////////////////////////////////////////////////////////////// + setClaimConditions + //////////////////////////////////////////////////////////////*/ + + function test_claimCondition_startIdAndCount() public { + vm.startPrank(deployer); + + uint256 currentStartId = 0; + uint256 count = 0; + + BurnToClaimDrop721Logic.ClaimCondition[] memory conditions = new BurnToClaimDrop721Logic.ClaimCondition[](2); + conditions[0].startTimestamp = 0; + conditions[0].maxClaimableSupply = 10; + conditions[1].startTimestamp = 1; + conditions[1].maxClaimableSupply = 10; + + drop.setClaimConditions(conditions, false); + (currentStartId, count) = drop.claimCondition(); + assertEq(currentStartId, 0); + assertEq(count, 2); + + drop.setClaimConditions(conditions, false); + (currentStartId, count) = drop.claimCondition(); + assertEq(currentStartId, 0); + assertEq(count, 2); + + drop.setClaimConditions(conditions, true); + (currentStartId, count) = drop.claimCondition(); + assertEq(currentStartId, 2); + assertEq(count, 2); + + drop.setClaimConditions(conditions, true); + (currentStartId, count) = drop.claimCondition(); + assertEq(currentStartId, 4); + assertEq(count, 2); + } + + function test_claimCondition_startPhase() public { + vm.startPrank(deployer); + + uint256 activeConditionId = 0; + + BurnToClaimDrop721Logic.ClaimCondition[] memory conditions = new BurnToClaimDrop721Logic.ClaimCondition[](3); + conditions[0].startTimestamp = 10; + conditions[0].maxClaimableSupply = 11; + conditions[0].quantityLimitPerWallet = 12; + conditions[1].startTimestamp = 20; + conditions[1].maxClaimableSupply = 21; + conditions[1].quantityLimitPerWallet = 22; + conditions[2].startTimestamp = 30; + conditions[2].maxClaimableSupply = 31; + conditions[2].quantityLimitPerWallet = 32; + drop.setClaimConditions(conditions, false); + + vm.expectRevert("!CONDITION."); + drop.getActiveClaimConditionId(); + + vm.warp(10); + activeConditionId = drop.getActiveClaimConditionId(); + assertEq(activeConditionId, 0); + assertEq(drop.getClaimConditionById(activeConditionId).startTimestamp, 10); + assertEq(drop.getClaimConditionById(activeConditionId).maxClaimableSupply, 11); + assertEq(drop.getClaimConditionById(activeConditionId).quantityLimitPerWallet, 12); + + vm.warp(20); + activeConditionId = drop.getActiveClaimConditionId(); + assertEq(activeConditionId, 1); + assertEq(drop.getClaimConditionById(activeConditionId).startTimestamp, 20); + assertEq(drop.getClaimConditionById(activeConditionId).maxClaimableSupply, 21); + assertEq(drop.getClaimConditionById(activeConditionId).quantityLimitPerWallet, 22); + + vm.warp(30); + activeConditionId = drop.getActiveClaimConditionId(); + assertEq(activeConditionId, 2); + assertEq(drop.getClaimConditionById(activeConditionId).startTimestamp, 30); + assertEq(drop.getClaimConditionById(activeConditionId).maxClaimableSupply, 31); + assertEq(drop.getClaimConditionById(activeConditionId).quantityLimitPerWallet, 32); + + vm.warp(40); + assertEq(drop.getActiveClaimConditionId(), 2); + } + + /*/////////////////////////////////////////////////////////////// + Miscellaneous + //////////////////////////////////////////////////////////////*/ + + function test_delayedReveal_withNewLazyMintedEmptyBatch() public { + vm.startPrank(deployer); + + bytes memory encryptedURI = drop.encryptDecrypt("ipfs://", "key"); + bytes32 provenanceHash = keccak256(abi.encodePacked("ipfs://", "key", block.chainid)); + drop.lazyMint(100, "", abi.encode(encryptedURI, provenanceHash)); + drop.reveal(0, "key"); + + string memory uri = drop.tokenURI(1); + assertEq(uri, string(abi.encodePacked("ipfs://", "1"))); + + bytes memory newEncryptedURI = drop.encryptDecrypt("ipfs://secret", "key"); + vm.expectRevert("0 amt"); + drop.lazyMint(0, "", abi.encode(newEncryptedURI, provenanceHash)); + + vm.stopPrank(); + } + + /*/////////////////////////////////////////////////////////////// + Burn To Claim + //////////////////////////////////////////////////////////////*/ + + function test_state_burnAndClaim_1155Origin_zeroMintPrice() public { + IBurnToClaim.BurnToClaimInfo memory burnToClaimInfo; + + burnToClaimInfo.originContractAddress = address(erc1155); + burnToClaimInfo.tokenType = IBurnToClaim.TokenType.ERC1155; + burnToClaimInfo.tokenId = 0; + burnToClaimInfo.mintPriceForNewToken = 0; + burnToClaimInfo.currency = address(erc20); + + // set origin contract details for burn and claim + vm.prank(deployer); + drop.setBurnToClaimInfo(burnToClaimInfo); + + // check details correctly saved + BurnToClaimDrop721Logic.BurnToClaimInfo memory savedInfo = drop.getBurnToClaimInfo(); + assertEq(savedInfo.originContractAddress, burnToClaimInfo.originContractAddress); + assertTrue(savedInfo.tokenType == burnToClaimInfo.tokenType); + assertEq(savedInfo.tokenId, burnToClaimInfo.tokenId); + assertEq(savedInfo.mintPriceForNewToken, burnToClaimInfo.mintPriceForNewToken); + assertEq(savedInfo.currency, burnToClaimInfo.currency); + + // mint some erc1155 to a claimer + address claimer = getActor(0); + erc1155.mint(claimer, 0, 10); + assertEq(erc1155.balanceOf(claimer, 0), 10); + vm.prank(claimer); + erc1155.setApprovalForAll(address(drop), true); + + // lazy mint tokens + vm.prank(deployer); + drop.lazyMint(100, "ipfs://", emptyEncodedBytes); + + // burn and claim + vm.prank(claimer); + drop.burnAndClaim(0, 10); + + // check state + assertEq(erc1155.balanceOf(claimer, 0), 0); + assertEq(drop.balanceOf(claimer), 10); + assertEq(drop.nextTokenIdToClaim(), 10); + } + + function test_state_burnAndClaim_1155Origin_nonZeroMintPrice() public { + IBurnToClaim.BurnToClaimInfo memory burnToClaimInfo; + + burnToClaimInfo.originContractAddress = address(erc1155); + burnToClaimInfo.tokenType = IBurnToClaim.TokenType.ERC1155; + burnToClaimInfo.tokenId = 0; + burnToClaimInfo.mintPriceForNewToken = 1; + burnToClaimInfo.currency = address(erc20); + + // set origin contract details for burn and claim + vm.prank(deployer); + drop.setBurnToClaimInfo(burnToClaimInfo); + + // check details correctly saved + BurnToClaimDrop721Logic.BurnToClaimInfo memory savedInfo = drop.getBurnToClaimInfo(); + assertEq(savedInfo.originContractAddress, burnToClaimInfo.originContractAddress); + assertTrue(savedInfo.tokenType == burnToClaimInfo.tokenType); + assertEq(savedInfo.tokenId, burnToClaimInfo.tokenId); + assertEq(savedInfo.mintPriceForNewToken, burnToClaimInfo.mintPriceForNewToken); + assertEq(savedInfo.currency, burnToClaimInfo.currency); + + // mint some erc1155 to a claimer + address claimer = getActor(0); + erc1155.mint(claimer, 0, 10); + assertEq(erc1155.balanceOf(claimer, 0), 10); + vm.prank(claimer); + erc1155.setApprovalForAll(address(drop), true); + + // mint erc20 to claimer, to pay claim price + erc20.mint(claimer, 100); + vm.prank(claimer); + erc20.approve(address(drop), type(uint256).max); + + // lazy mint tokens + vm.prank(deployer); + drop.lazyMint(100, "ipfs://", emptyEncodedBytes); + + // burn and claim + vm.prank(claimer); + drop.burnAndClaim(0, 10); + + // check state + assertEq(erc1155.balanceOf(claimer, 0), 0); + assertEq(erc20.balanceOf(claimer), 90); + assertEq(erc20.balanceOf(saleRecipient), 10); + assertEq(drop.balanceOf(claimer), 10); + assertEq(drop.nextTokenIdToClaim(), 10); + } + + function test_state_burnAndClaim_1155Origin_nonZeroMintPrice_nativeToken() public { + IBurnToClaim.BurnToClaimInfo memory burnToClaimInfo; + + burnToClaimInfo.originContractAddress = address(erc1155); + burnToClaimInfo.tokenType = IBurnToClaim.TokenType.ERC1155; + burnToClaimInfo.tokenId = 0; + burnToClaimInfo.mintPriceForNewToken = 1; + burnToClaimInfo.currency = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + + // set origin contract details for burn and claim + vm.prank(deployer); + drop.setBurnToClaimInfo(burnToClaimInfo); + + // check details correctly saved + BurnToClaimDrop721Logic.BurnToClaimInfo memory savedInfo = drop.getBurnToClaimInfo(); + assertEq(savedInfo.originContractAddress, burnToClaimInfo.originContractAddress); + assertTrue(savedInfo.tokenType == burnToClaimInfo.tokenType); + assertEq(savedInfo.tokenId, burnToClaimInfo.tokenId); + assertEq(savedInfo.mintPriceForNewToken, burnToClaimInfo.mintPriceForNewToken); + assertEq(savedInfo.currency, burnToClaimInfo.currency); + + // mint some erc1155 to a claimer + address claimer = getActor(0); + erc1155.mint(claimer, 0, 10); + assertEq(erc1155.balanceOf(claimer, 0), 10); + vm.prank(claimer); + erc1155.setApprovalForAll(address(drop), true); + + // deal ether to claimer, to pay claim price + vm.deal(claimer, 100); + + // lazy mint tokens + vm.prank(deployer); + drop.lazyMint(100, "ipfs://", emptyEncodedBytes); + + // burn and claim + vm.prank(claimer); + drop.burnAndClaim{ value: 10 }(0, 10); + + // check state + assertEq(erc1155.balanceOf(claimer, 0), 0); + assertEq(claimer.balance, 90); + assertEq(saleRecipient.balance, 10); + assertEq(drop.balanceOf(claimer), 10); + assertEq(drop.nextTokenIdToClaim(), 10); + } + + function test_state_burnAndClaim_721Origin_zeroMintPrice() public { + IBurnToClaim.BurnToClaimInfo memory burnToClaimInfo; + + burnToClaimInfo.originContractAddress = address(erc721); + burnToClaimInfo.tokenType = IBurnToClaim.TokenType.ERC721; + burnToClaimInfo.tokenId = 0; + burnToClaimInfo.mintPriceForNewToken = 0; + burnToClaimInfo.currency = address(erc20); + + // set origin contract details for burn and claim + vm.prank(deployer); + drop.setBurnToClaimInfo(burnToClaimInfo); + + // check details correctly saved + BurnToClaimDrop721Logic.BurnToClaimInfo memory savedInfo = drop.getBurnToClaimInfo(); + assertEq(savedInfo.originContractAddress, burnToClaimInfo.originContractAddress); + assertTrue(savedInfo.tokenType == burnToClaimInfo.tokenType); + assertEq(savedInfo.tokenId, burnToClaimInfo.tokenId); + assertEq(savedInfo.mintPriceForNewToken, burnToClaimInfo.mintPriceForNewToken); + assertEq(savedInfo.currency, burnToClaimInfo.currency); + + // mint some erc721 to a claimer + address claimer = getActor(0); + erc721.mint(claimer, 10); + assertEq(erc721.balanceOf(claimer), 10); + vm.prank(claimer); + erc721.setApprovalForAll(address(drop), true); + + // lazy mint tokens + vm.prank(deployer); + drop.lazyMint(100, "ipfs://", emptyEncodedBytes); + + // burn and claim + vm.prank(claimer); + drop.burnAndClaim(0, 1); + + // check state + assertEq(erc721.balanceOf(claimer), 9); + assertEq(drop.balanceOf(claimer), 1); + assertEq(drop.nextTokenIdToClaim(), 1); + + vm.expectRevert("ERC721: invalid token ID"); // because the token doesn't exist anymore + erc721.ownerOf(0); + } + + function test_state_burnAndClaim_721Origin_nonZeroMintPrice() public { + IBurnToClaim.BurnToClaimInfo memory burnToClaimInfo; + + burnToClaimInfo.originContractAddress = address(erc721); + burnToClaimInfo.tokenType = IBurnToClaim.TokenType.ERC721; + burnToClaimInfo.tokenId = 0; + burnToClaimInfo.mintPriceForNewToken = 1; + burnToClaimInfo.currency = address(erc20); + + // set origin contract details for burn and claim + vm.prank(deployer); + drop.setBurnToClaimInfo(burnToClaimInfo); + + // check details correctly saved + BurnToClaimDrop721Logic.BurnToClaimInfo memory savedInfo = drop.getBurnToClaimInfo(); + assertEq(savedInfo.originContractAddress, burnToClaimInfo.originContractAddress); + assertTrue(savedInfo.tokenType == burnToClaimInfo.tokenType); + assertEq(savedInfo.tokenId, burnToClaimInfo.tokenId); + assertEq(savedInfo.mintPriceForNewToken, burnToClaimInfo.mintPriceForNewToken); + assertEq(savedInfo.currency, burnToClaimInfo.currency); + + // mint some erc721 to a claimer + address claimer = getActor(0); + erc721.mint(claimer, 10); + assertEq(erc721.balanceOf(claimer), 10); + vm.prank(claimer); + erc721.setApprovalForAll(address(drop), true); + + // mint erc20 to claimer, to pay claim price + erc20.mint(claimer, 100); + vm.prank(claimer); + erc20.approve(address(drop), type(uint256).max); + + // lazy mint tokens + vm.prank(deployer); + drop.lazyMint(100, "ipfs://", emptyEncodedBytes); + + // burn and claim + vm.prank(claimer); + drop.burnAndClaim(0, 1); + + // check state + assertEq(erc721.balanceOf(claimer), 9); + assertEq(drop.balanceOf(claimer), 1); + assertEq(drop.nextTokenIdToClaim(), 1); + assertEq(erc20.balanceOf(claimer), 99); + assertEq(erc20.balanceOf(saleRecipient), 1); + + vm.expectRevert("ERC721: invalid token ID"); // because the token doesn't exist anymore + erc721.ownerOf(0); + } + + function test_state_burnAndClaim_721Origin_nonZeroMintPrice_nativeToken() public { + IBurnToClaim.BurnToClaimInfo memory burnToClaimInfo; + + burnToClaimInfo.originContractAddress = address(erc721); + burnToClaimInfo.tokenType = IBurnToClaim.TokenType.ERC721; + burnToClaimInfo.tokenId = 0; + burnToClaimInfo.mintPriceForNewToken = 1; + burnToClaimInfo.currency = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + + // set origin contract details for burn and claim + vm.prank(deployer); + drop.setBurnToClaimInfo(burnToClaimInfo); + + // check details correctly saved + BurnToClaimDrop721Logic.BurnToClaimInfo memory savedInfo = drop.getBurnToClaimInfo(); + assertEq(savedInfo.originContractAddress, burnToClaimInfo.originContractAddress); + assertTrue(savedInfo.tokenType == burnToClaimInfo.tokenType); + assertEq(savedInfo.tokenId, burnToClaimInfo.tokenId); + assertEq(savedInfo.mintPriceForNewToken, burnToClaimInfo.mintPriceForNewToken); + assertEq(savedInfo.currency, burnToClaimInfo.currency); + + // mint some erc721 to a claimer + address claimer = getActor(0); + erc721.mint(claimer, 10); + assertEq(erc721.balanceOf(claimer), 10); + vm.prank(claimer); + erc721.setApprovalForAll(address(drop), true); + + // deal ether to claimer, to pay claim price + vm.deal(claimer, 100); + + // lazy mint tokens + vm.prank(deployer); + drop.lazyMint(100, "ipfs://", emptyEncodedBytes); + + // burn and claim + vm.prank(claimer); + drop.burnAndClaim{ value: 1 }(0, 1); + + // check state + assertEq(erc721.balanceOf(claimer), 9); + assertEq(drop.balanceOf(claimer), 1); + assertEq(drop.nextTokenIdToClaim(), 1); + assertEq(claimer.balance, 99); + assertEq(saleRecipient.balance, 1); + + vm.expectRevert("ERC721: invalid token ID"); // because the token doesn't exist anymore + erc721.ownerOf(0); + } + + function test_revert_burnAndClaim_originNotSet() public { + // lazy mint tokens + vm.prank(deployer); + drop.lazyMint(100, "ipfs://", emptyEncodedBytes); + + // burn and claim + vm.expectRevert(); + drop.burnAndClaim(0, 1); + } + + function test_revert_burnAndClaim_noLazyMintedTokens() public { + // burn and claim + vm.expectRevert("!Tokens"); + drop.burnAndClaim(0, 1); + } + + function test_revert_burnAndClaim_invalidTokenId() public { + IBurnToClaim.BurnToClaimInfo memory burnToClaimInfo; + + burnToClaimInfo.originContractAddress = address(erc1155); + burnToClaimInfo.tokenType = IBurnToClaim.TokenType.ERC1155; + burnToClaimInfo.tokenId = 0; + burnToClaimInfo.mintPriceForNewToken = 0; + burnToClaimInfo.currency = address(erc20); + + // set origin contract details for burn and claim + vm.prank(deployer); + drop.setBurnToClaimInfo(burnToClaimInfo); + + // lazy mint tokens + vm.prank(deployer); + drop.lazyMint(100, "ipfs://", emptyEncodedBytes); + + // mint some erc1155 to a claimer + address claimer = getActor(0); + erc1155.mint(claimer, 0, 10); + assertEq(erc1155.balanceOf(claimer, 0), 10); + vm.prank(claimer); + erc1155.setApprovalForAll(address(drop), true); + + // burn and claim + vm.prank(claimer); + vm.expectRevert("Invalid token Id"); + drop.burnAndClaim(1, 1); + } + + function test_revert_burnAndClaim_notEnoughBalance() public { + IBurnToClaim.BurnToClaimInfo memory burnToClaimInfo; + + burnToClaimInfo.originContractAddress = address(erc1155); + burnToClaimInfo.tokenType = IBurnToClaim.TokenType.ERC1155; + burnToClaimInfo.tokenId = 0; + burnToClaimInfo.mintPriceForNewToken = 0; + burnToClaimInfo.currency = address(erc20); + + // set origin contract details for burn and claim + vm.prank(deployer); + drop.setBurnToClaimInfo(burnToClaimInfo); + + // lazy mint tokens + vm.prank(deployer); + drop.lazyMint(100, "ipfs://", emptyEncodedBytes); + + // mint some erc1155 to a claimer + address claimer = getActor(0); + erc1155.mint(claimer, 0, 10); + assertEq(erc1155.balanceOf(claimer, 0), 10); + vm.prank(claimer); + erc1155.setApprovalForAll(address(drop), true); + + // burn and claim + vm.prank(claimer); + vm.expectRevert("!Balance"); + drop.burnAndClaim(0, 11); + } + + function test_revert_burnAndClaim_notOwnerOfToken() public { + IBurnToClaim.BurnToClaimInfo memory burnToClaimInfo; + + burnToClaimInfo.originContractAddress = address(erc721); + burnToClaimInfo.tokenType = IBurnToClaim.TokenType.ERC721; + burnToClaimInfo.tokenId = 0; + burnToClaimInfo.mintPriceForNewToken = 1; + burnToClaimInfo.currency = address(erc20); + + // set origin contract details for burn and claim + vm.prank(deployer); + drop.setBurnToClaimInfo(burnToClaimInfo); + + // mint some erc721 to a claimer + address claimer = getActor(0); + erc721.mint(claimer, 10); + assertEq(erc721.balanceOf(claimer), 10); + vm.prank(claimer); + erc721.setApprovalForAll(address(drop), true); + + // mint erc721 to another address + erc721.mint(address(0x567), 5); + + // lazy mint tokens + vm.prank(deployer); + drop.lazyMint(100, "ipfs://", emptyEncodedBytes); + + // burn and claim + vm.prank(claimer); + vm.expectRevert("!Owner"); + drop.burnAndClaim(11, 1); + } + + /*/////////////////////////////////////////////////////////////// + Extension Role and Upgradeability + //////////////////////////////////////////////////////////////*/ + + // function test_addExtension() public { + // address permissionsNew = address(new PermissionsEnumerableImpl()); + + // Extension memory extension_permissions_new; + // extension_permissions_new.metadata = ExtensionMetadata({ + // name: "PermissionsNew", + // metadataURI: "ipfs://PermissionsNew", + // implementation: permissionsNew + // }); + + // extension_permissions_new.functions = new ExtensionFunction[](4); + // extension_permissions_new.functions[0] = ExtensionFunction( + // Permissions.hasRole.selector, + // "hasRole(bytes32,address)" + // ); + // extension_permissions_new.functions[1] = ExtensionFunction( + // Permissions.hasRoleWithSwitch.selector, + // "hasRoleWithSwitch(bytes32,address)" + // ); + // extension_permissions_new.functions[2] = ExtensionFunction( + // Permissions.grantRole.selector, + // "grantRole(bytes32,address)" + // ); + // extension_permissions_new.functions[3] = ExtensionFunction( + // PermissionsEnumerable.getRoleMemberCount.selector, + // "getRoleMemberCount(bytes32)" + // ); + + // // cast drop to router type + // BurnToClaimDropERC721 dropRouter = BurnToClaimDropERC721(payable(address(drop))); + + // vm.prank(deployer); + // dropRouter.addExtension(extension_permissions_new); + + // // assertEq( + // // dropRouter.getExtensionForFunction(PermissionsEnumerable.getRoleMemberCount.selector).name, + // // "PermissionsNew" + // // ); + + // // assertEq( + // // dropRouter.getExtensionForFunction(PermissionsEnumerable.getRoleMemberCount.selector).implementation, + // // permissionsNew + // // ); + // } + + function test_revert_addExtension_NotAuthorized() public { + Extension memory extension_permissions_new; + + // cast drop to router type + BurnToClaimDropERC721 dropRouter = BurnToClaimDropERC721(payable(address(drop))); + + vm.prank(address(0x123)); + vm.expectRevert("ExtensionManager: unauthorized."); + dropRouter.addExtension(extension_permissions_new); + } + + function test_revert_addExtension_deployerRenounceExtensionRole() public { + Extension memory extension_permissions_new; + + // cast drop to router type + BurnToClaimDropERC721 dropRouter = BurnToClaimDropERC721(payable(address(drop))); + + vm.prank(deployer); + Permissions(address(drop)).renounceRole(keccak256("EXTENSION_ROLE"), deployer); + + vm.prank(deployer); + vm.expectRevert("ExtensionManager: unauthorized."); + dropRouter.addExtension(extension_permissions_new); + + vm.startPrank(deployer); + vm.expectRevert( + abi.encodePacked( + "Permissions: account ", + Strings.toHexString(uint160(deployer), 20), + " is missing role ", + Strings.toHexString(uint256(keccak256("EXTENSION_ROLE")), 32) + ) + ); + Permissions(address(drop)).grantRole(keccak256("EXTENSION_ROLE"), address(0x12345)); + vm.stopPrank(); + } +} diff --git a/src/test/burn-to-claim-drop-BTT/logic/burn-and-claim/burnAndClaim.t.sol b/src/test/burn-to-claim-drop-BTT/logic/burn-and-claim/burnAndClaim.t.sol new file mode 100644 index 000000000..eb188e56a --- /dev/null +++ b/src/test/burn-to-claim-drop-BTT/logic/burn-and-claim/burnAndClaim.t.sol @@ -0,0 +1,802 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "../../../utils/BaseTest.sol"; +import { BurnToClaimDropERC721 } from "contracts/prebuilts/unaudited/burn-to-claim-drop/BurnToClaimDropERC721.sol"; +import { BurnToClaimDrop721Logic, ERC721AUpgradeable, DelayedReveal, LazyMint, Drop, BurnToClaim, PrimarySale, PlatformFee } from "contracts/prebuilts/unaudited/burn-to-claim-drop/extension/BurnToClaimDrop721Logic.sol"; +import { PermissionsEnumerableImpl } from "contracts/extension/upgradeable/impl/PermissionsEnumerableImpl.sol"; +import { Royalty } from "contracts/extension/upgradeable/Royalty.sol"; +import { BatchMintMetadata } from "contracts/extension/upgradeable/BatchMintMetadata.sol"; +import { IBurnToClaim } from "contracts/extension/interface/IBurnToClaim.sol"; + +import "@thirdweb-dev/dynamic-contracts/src/interface/IExtension.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +// Test imports +import { Permissions } from "contracts/extension/Permissions.sol"; +import "erc721a-upgradeable/contracts/IERC721AUpgradeable.sol"; + +contract BurnToClaimDropERC721Logic_BurnAndClaim is BaseTest, IExtension { + using Strings for uint256; + using Strings for address; + + event TokensBurnedAndClaimed( + address indexed originContract, + address indexed tokenOwner, + uint256 indexed burnTokenId, + uint256 quantity + ); + + BurnToClaimDrop721Logic public drop; + uint256 internal _tokenId; + uint256 internal _quantity; + uint256 internal _msgValue; + uint256[] internal batchIds; + address internal caller; + bytes internal data; + IBurnToClaim.BurnToClaimInfo internal info; + + bytes private emptyEncodedBytes = abi.encode("", ""); + + using stdStorage for StdStorage; + + function setUp() public override { + super.setUp(); + + // Deploy implementation. + Extension[] memory extensions = _setupExtensions(); + address dropImpl = address(new BurnToClaimDropERC721(extensions)); + + // Deploy proxy pointing to implementaion. + vm.prank(deployer); + drop = BurnToClaimDrop721Logic( + payable( + address( + new TWProxy( + dropImpl, + abi.encodeCall( + BurnToClaimDropERC721.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ) + ) + ) + ); + + caller = getActor(5); + + erc20.mint(deployer, 1_000 ether); + vm.deal(deployer, 1_000 ether); + erc20.mint(caller, 1_000 ether); + vm.deal(caller, 1_000 ether); + + erc721.mint(deployer, 100); + erc721NonBurnable.mint(deployer, 100); + + erc1155NonBurnable.mint(deployer, 0, 100); + erc1155.mint(deployer, 0, 100); + erc1155.mint(deployer, 1, 100); + + vm.startPrank(deployer); + erc721.setApprovalForAll(address(drop), true); + erc1155.setApprovalForAll(address(drop), true); + erc20.approve(address(drop), type(uint256).max); + vm.stopPrank(); + + vm.startPrank(caller); + erc721.setApprovalForAll(address(drop), true); + erc1155.setApprovalForAll(address(drop), true); + erc20.approve(address(drop), type(uint256).max); + vm.stopPrank(); + + // startId = 0; + // mint 5 batches + // vm.startPrank(deployer); + // for (uint256 i = 0; i < 5; i++) { + // uint256 _amount = (i + 1) * 10; + // uint256 batchId = startId + _amount; + // batchIds.push(batchId); + + // string memory baseURI = Strings.toString(batchId); + // startId = drop.lazyMint(_amount, baseURI, ""); + // } + // vm.stopPrank(); + } + + function _setupExtensions() internal returns (Extension[] memory extensions) { + extensions = new Extension[](2); + + // Extension: Permissions + address permissions = address(new PermissionsEnumerableImpl()); + + Extension memory extension_permissions; + extension_permissions.metadata = ExtensionMetadata({ + name: "Permissions", + metadataURI: "ipfs://Permissions", + implementation: permissions + }); + + extension_permissions.functions = new ExtensionFunction[](1); + extension_permissions.functions[0] = ExtensionFunction( + Permissions.hasRole.selector, + "hasRole(bytes32,address)" + ); + + extensions[0] = extension_permissions; + + address dropLogic = address(new BurnToClaimDrop721Logic()); + + Extension memory extension_drop; + extension_drop.metadata = ExtensionMetadata({ + name: "BurnToClaimDrop721Logic", + metadataURI: "ipfs://BurnToClaimDrop721Logic", + implementation: dropLogic + }); + + extension_drop.functions = new ExtensionFunction[](10); + extension_drop.functions[0] = ExtensionFunction(BurnToClaimDrop721Logic.tokenURI.selector, "tokenURI(uint256)"); + extension_drop.functions[1] = ExtensionFunction( + BurnToClaimDrop721Logic.lazyMint.selector, + "lazyMint(uint256,string,bytes)" + ); + extension_drop.functions[2] = ExtensionFunction( + BurnToClaimDrop721Logic.setMaxTotalMinted.selector, + "setMaxTotalMinted(uint256)" + ); + extension_drop.functions[3] = ExtensionFunction( + BurnToClaimDrop721Logic.nextTokenIdToMint.selector, + "nextTokenIdToMint()" + ); + extension_drop.functions[4] = ExtensionFunction( + BurnToClaimDrop721Logic.burnAndClaim.selector, + "burnAndClaim(uint256,uint256)" + ); + extension_drop.functions[5] = ExtensionFunction( + BurnToClaim.getBurnToClaimInfo.selector, + "getBurnToClaimInfo()" + ); + extension_drop.functions[6] = ExtensionFunction( + BurnToClaim.setBurnToClaimInfo.selector, + "setBurnToClaimInfo((address,uint8,uint256,uint256,address))" + ); + extension_drop.functions[7] = ExtensionFunction( + BurnToClaimDrop721Logic.nextTokenIdToClaim.selector, + "nextTokenIdToClaim()" + ); + extension_drop.functions[8] = ExtensionFunction(ERC721AUpgradeable.balanceOf.selector, "balanceOf(address)"); + extension_drop.functions[9] = ExtensionFunction(ERC721AUpgradeable.ownerOf.selector, "ownerOf(uint256)"); + + extensions[1] = extension_drop; + } + + function test_burnAndClaim_notEnoughLazyMintedTokens() public { + vm.expectRevert("!Tokens"); + drop.burnAndClaim(0, 1); + } + + modifier whenEnoughLazyMintedTokens() { + vm.prank(deployer); + drop.lazyMint(1000, "ipfs://", ""); + _; + } + + function test_burnAndClaim_exceedMaxTotalMint() public whenEnoughLazyMintedTokens { + vm.prank(deployer); + drop.setMaxTotalMinted(1); //set max total mint cap as 1 + + vm.expectRevert("exceed max total mint cap."); + drop.burnAndClaim(0, 2); + } + + modifier whenNotExceedMaxTotalMinted() { + vm.prank(deployer); + drop.setMaxTotalMinted(1000); + _; + } + + function test_burnAndClaim_burnToClaimInfoNotSet() public whenEnoughLazyMintedTokens whenNotExceedMaxTotalMinted { + // it will fail when verifyClaim tries to check owner/balance on nft contract which is still address(0) + vm.expectRevert(); + drop.burnAndClaim(0, 1); + } + + // ================== + // ======= Test branch: burn-to-claim origin contract is ERC721 + // ================== + + modifier whenBurnToClaimInfoSetERC721() { + info = IBurnToClaim.BurnToClaimInfo({ + originContractAddress: address(erc721NonBurnable), + tokenType: IBurnToClaim.TokenType.ERC721, + tokenId: 0, + mintPriceForNewToken: 0, + currency: address(erc20) + }); + + vm.prank(deployer); + drop.setBurnToClaimInfo(info); + + _; + } + + function test_burnAndClaim_ERC721_invalidQuantity() + public + whenEnoughLazyMintedTokens + whenNotExceedMaxTotalMinted + whenBurnToClaimInfoSetERC721 + { + vm.expectRevert("Invalid amount"); + drop.burnAndClaim(0, 0); + } + + modifier whenValidQuantityERC721() { + _quantity = 1; + _; + } + + function test_burnAndClaim_ERC721_notOwner() + public + whenEnoughLazyMintedTokens + whenNotExceedMaxTotalMinted + whenBurnToClaimInfoSetERC721 + whenValidQuantityERC721 + { + vm.expectRevert("!Owner"); + drop.burnAndClaim(_tokenId, _quantity); + } + + modifier whenCorrectOwnerERC721() { + vm.startPrank(deployer); + erc721NonBurnable.transferFrom(deployer, caller, _tokenId); + erc721.transferFrom(deployer, caller, _tokenId); + vm.stopPrank(); + _; + } + + function test_burnAndClaim_ERC721_notBurnable() + public + whenEnoughLazyMintedTokens + whenNotExceedMaxTotalMinted + whenBurnToClaimInfoSetERC721 + whenValidQuantityERC721 + whenCorrectOwnerERC721 + { + vm.expectRevert(); // `EvmError: Revert` when trying to burn on a non-burnable contract + vm.prank(caller); + drop.burnAndClaim(_tokenId, _quantity); + } + + modifier whenBurnToClaimInfoSet_ERC721Burnable() { + info = IBurnToClaim.BurnToClaimInfo({ + originContractAddress: address(erc721), + tokenType: IBurnToClaim.TokenType.ERC721, + tokenId: 0, + mintPriceForNewToken: 0, + currency: address(erc20) + }); + + vm.prank(deployer); + drop.setBurnToClaimInfo(info); + + _; + } + + function test_burnAndClaim_ERC721_mintPriceZero_msgValueNonZero() + public + whenEnoughLazyMintedTokens + whenNotExceedMaxTotalMinted + whenBurnToClaimInfoSet_ERC721Burnable + whenValidQuantityERC721 + whenCorrectOwnerERC721 + { + vm.expectRevert("!Value"); + vm.prank(caller); + drop.burnAndClaim{ value: 1 }(_tokenId, _quantity); + } + + modifier whenMsgValueZero() { + _msgValue = 0; + _; + } + + function test_burnAndClaim_ERC721_mintPriceZero() + public + whenEnoughLazyMintedTokens + whenNotExceedMaxTotalMinted + whenBurnToClaimInfoSet_ERC721Burnable + whenValidQuantityERC721 + whenCorrectOwnerERC721 + whenMsgValueZero + { + // state before + uint256 _nextTokenIdToClaim = drop.nextTokenIdToClaim(); + + // burn and claim + vm.prank(caller); + drop.burnAndClaim{ value: _msgValue }(_tokenId, _quantity); + + // check state after + vm.expectRevert(); // because token non-existent after burning + erc721.ownerOf(_tokenId); + + assertEq(drop.balanceOf(caller), _quantity); + assertEq(drop.ownerOf(_nextTokenIdToClaim), caller); + assertEq(drop.nextTokenIdToClaim(), _nextTokenIdToClaim + _quantity); + } + + function test_burnAndClaim_ERC721_mintPriceZero_event() + public + whenEnoughLazyMintedTokens + whenNotExceedMaxTotalMinted + whenBurnToClaimInfoSet_ERC721Burnable + whenValidQuantityERC721 + whenCorrectOwnerERC721 + whenMsgValueZero + { + vm.prank(caller); + vm.expectEmit(true, true, true, true); + emit TokensBurnedAndClaimed(address(erc721), caller, _tokenId, _quantity); + drop.burnAndClaim{ value: _msgValue }(_tokenId, _quantity); + } + + modifier whenBurnToClaimInfoSet_ERC721Burnable_nonZeroPriceNativeToken() { + info = IBurnToClaim.BurnToClaimInfo({ + originContractAddress: address(erc721), + tokenType: IBurnToClaim.TokenType.ERC721, + tokenId: 0, + mintPriceForNewToken: 100, + currency: NATIVE_TOKEN + }); + + vm.prank(deployer); + drop.setBurnToClaimInfo(info); + + _; + } + + function test_burnAndClaim_ERC721_mintPriceNonZero_nativeToken_incorrectMsgValue() + public + whenEnoughLazyMintedTokens + whenNotExceedMaxTotalMinted + whenBurnToClaimInfoSet_ERC721Burnable_nonZeroPriceNativeToken + whenValidQuantityERC721 + whenCorrectOwnerERC721 + { + uint256 incorrectTotalPrice = (info.mintPriceForNewToken * _quantity) + 1; + + vm.prank(caller); + vm.expectRevert("Invalid msg value"); + drop.burnAndClaim{ value: incorrectTotalPrice }(_tokenId, _quantity); + } + + modifier whenCorrectMsgValue() { + _msgValue = info.mintPriceForNewToken * _quantity; + _; + } + + function test_burnAndClaim_ERC721_mintPriceNonZero_nativeToken() + public + whenEnoughLazyMintedTokens + whenNotExceedMaxTotalMinted + whenBurnToClaimInfoSet_ERC721Burnable_nonZeroPriceNativeToken + whenValidQuantityERC721 + whenCorrectOwnerERC721 + whenCorrectMsgValue + { + // state before + uint256 _nextTokenIdToClaim = drop.nextTokenIdToClaim(); + assertEq(platformFeeRecipient.balance, 0); + assertEq(saleRecipient.balance, 0); + assertEq(caller.balance, 1000 ether); + + // burn and claim + vm.prank(caller); + drop.burnAndClaim{ value: _msgValue }(_tokenId, _quantity); + + // check state after + uint256 totalPrice = (info.mintPriceForNewToken * _quantity); + uint256 _platformFee = (totalPrice * platformFeeBps) / 10_000; + uint256 _saleProceeds = totalPrice - _platformFee; + vm.expectRevert(); // because token non-existent after burning + erc721.ownerOf(_tokenId); + + assertEq(drop.balanceOf(caller), _quantity); + assertEq(drop.ownerOf(_nextTokenIdToClaim), caller); + assertEq(drop.nextTokenIdToClaim(), _nextTokenIdToClaim + _quantity); + assertEq(platformFeeRecipient.balance, _platformFee); + assertEq(saleRecipient.balance, _saleProceeds); + assertEq(caller.balance, 1000 ether - totalPrice); + } + + function test_burnAndClaim_ERC721_mintPriceNonZero_nativeToken_event() + public + whenEnoughLazyMintedTokens + whenNotExceedMaxTotalMinted + whenBurnToClaimInfoSet_ERC721Burnable_nonZeroPriceNativeToken + whenValidQuantityERC721 + whenCorrectOwnerERC721 + whenCorrectMsgValue + { + vm.prank(caller); + vm.expectEmit(true, true, true, true); + emit TokensBurnedAndClaimed(address(erc721), caller, _tokenId, _quantity); + drop.burnAndClaim{ value: _msgValue }(_tokenId, _quantity); + } + + modifier whenBurnToClaimInfoSet_ERC721Burnable_nonZeroPriceERC20() { + info = IBurnToClaim.BurnToClaimInfo({ + originContractAddress: address(erc721), + tokenType: IBurnToClaim.TokenType.ERC721, + tokenId: 0, + mintPriceForNewToken: 100, + currency: address(erc20) + }); + + vm.prank(deployer); + drop.setBurnToClaimInfo(info); + + _; + } + + function test_burnAndClaim_ERC721_mintPriceNonZero_ERC20_nonZeroMsgValue() + public + whenEnoughLazyMintedTokens + whenNotExceedMaxTotalMinted + whenBurnToClaimInfoSet_ERC721Burnable_nonZeroPriceERC20 + whenValidQuantityERC721 + whenCorrectOwnerERC721 + { + vm.prank(caller); + vm.expectRevert("Invalid msg value"); + drop.burnAndClaim{ value: 1 }(_tokenId, _quantity); + } + + function test_burnAndClaim_ERC721_mintPriceNonZero_ERC20() + public + whenEnoughLazyMintedTokens + whenNotExceedMaxTotalMinted + whenBurnToClaimInfoSet_ERC721Burnable_nonZeroPriceERC20 + whenValidQuantityERC721 + whenCorrectOwnerERC721 + whenMsgValueZero + { + // state before + uint256 _nextTokenIdToClaim = drop.nextTokenIdToClaim(); + assertEq(erc20.balanceOf(platformFeeRecipient), 0); + assertEq(erc20.balanceOf(saleRecipient), 0); + assertEq(erc20.balanceOf(caller), 1000 ether); + + // burn and claim + vm.prank(caller); + drop.burnAndClaim{ value: _msgValue }(_tokenId, _quantity); + + // check state after + uint256 totalPrice = (info.mintPriceForNewToken * _quantity); + uint256 _platformFee = (totalPrice * platformFeeBps) / 10_000; + uint256 _saleProceeds = totalPrice - _platformFee; + vm.expectRevert(); // because token non-existent after burning + erc721.ownerOf(_tokenId); + + assertEq(drop.balanceOf(caller), _quantity); + assertEq(drop.ownerOf(_nextTokenIdToClaim), caller); + assertEq(drop.nextTokenIdToClaim(), _nextTokenIdToClaim + _quantity); + assertEq(erc20.balanceOf(platformFeeRecipient), _platformFee); + assertEq(erc20.balanceOf(saleRecipient), _saleProceeds); + assertEq(erc20.balanceOf(caller), 1000 ether - totalPrice); + } + + function test_burnAndClaim_ERC721_mintPriceNonZero_ERC20_event() + public + whenEnoughLazyMintedTokens + whenNotExceedMaxTotalMinted + whenBurnToClaimInfoSet_ERC721Burnable_nonZeroPriceERC20 + whenValidQuantityERC721 + whenCorrectOwnerERC721 + whenMsgValueZero + { + vm.prank(caller); + vm.expectEmit(true, true, true, true); + emit TokensBurnedAndClaimed(address(erc721), caller, _tokenId, _quantity); + drop.burnAndClaim{ value: _msgValue }(_tokenId, _quantity); + } + + // ================== + // ======= Test branch: burn-to-claim origin contract is ERC1155 + // ================== + + modifier whenBurnToClaimInfoSetERC1155() { + info = IBurnToClaim.BurnToClaimInfo({ + originContractAddress: address(erc1155NonBurnable), + tokenType: IBurnToClaim.TokenType.ERC1155, + tokenId: 0, + mintPriceForNewToken: 0, + currency: address(erc20) + }); + + vm.prank(deployer); + drop.setBurnToClaimInfo(info); + + _; + } + + function test_burnAndClaim_ERC1155_invalidTokenId() + public + whenEnoughLazyMintedTokens + whenNotExceedMaxTotalMinted + whenBurnToClaimInfoSetERC1155 + { + vm.expectRevert("Invalid token Id"); + drop.burnAndClaim(1, 1); + } + + modifier whenValidTokenIdERC1155() { + _quantity = 1; + _tokenId = 0; + _; + } + + function test_burnAndClaim_ERC1155_notEnoughBalance() + public + whenEnoughLazyMintedTokens + whenNotExceedMaxTotalMinted + whenBurnToClaimInfoSetERC1155 + whenValidTokenIdERC1155 + { + vm.expectRevert("!Balance"); + vm.prank(caller); + drop.burnAndClaim(_tokenId, _quantity); + } + + modifier whenEnoughBalanceERC1155() { + vm.startPrank(deployer); + erc1155NonBurnable.safeTransferFrom(deployer, caller, _tokenId, 100, ""); + erc1155.safeTransferFrom(deployer, caller, _tokenId, 100, ""); + vm.stopPrank(); + _; + } + + function test_burnAndClaim_ERC1155_notBurnable() + public + whenEnoughLazyMintedTokens + whenNotExceedMaxTotalMinted + whenBurnToClaimInfoSetERC1155 + whenValidTokenIdERC1155 + whenEnoughBalanceERC1155 + { + vm.expectRevert(); // `EvmError: Revert` when trying to burn on a non-burnable contract + vm.prank(caller); + drop.burnAndClaim(_tokenId, _quantity); + } + + modifier whenBurnToClaimInfoSet_ERC1155Burnable() { + info = IBurnToClaim.BurnToClaimInfo({ + originContractAddress: address(erc1155), + tokenType: IBurnToClaim.TokenType.ERC1155, + tokenId: 0, + mintPriceForNewToken: 0, + currency: address(erc20) + }); + + vm.prank(deployer); + drop.setBurnToClaimInfo(info); + + _; + } + + function test_burnAndClaim_ERC1155_mintPriceZero_msgValueNonZero() + public + whenEnoughLazyMintedTokens + whenNotExceedMaxTotalMinted + whenBurnToClaimInfoSet_ERC1155Burnable + whenValidTokenIdERC1155 + whenEnoughBalanceERC1155 + { + vm.expectRevert("!Value"); + vm.prank(caller); + drop.burnAndClaim{ value: 1 }(_tokenId, _quantity); + } + + function test_burnAndClaim_ERC1155_mintPriceZero() + public + whenEnoughLazyMintedTokens + whenNotExceedMaxTotalMinted + whenBurnToClaimInfoSet_ERC1155Burnable + whenValidTokenIdERC1155 + whenEnoughBalanceERC1155 + whenMsgValueZero + { + // state before + uint256 _nextTokenIdToClaim = drop.nextTokenIdToClaim(); + + // burn and claim + vm.prank(caller); + drop.burnAndClaim{ value: _msgValue }(_tokenId, _quantity); + + // check state after + assertEq(erc1155.balanceOf(caller, _tokenId), 100 - _quantity); + assertEq(drop.balanceOf(caller), _quantity); + assertEq(drop.ownerOf(_nextTokenIdToClaim), caller); + assertEq(drop.nextTokenIdToClaim(), _nextTokenIdToClaim + _quantity); + } + + function test_burnAndClaim_ERC1155_mintPriceZero_event() + public + whenEnoughLazyMintedTokens + whenNotExceedMaxTotalMinted + whenBurnToClaimInfoSet_ERC1155Burnable + whenValidTokenIdERC1155 + whenEnoughBalanceERC1155 + whenMsgValueZero + { + vm.prank(caller); + vm.expectEmit(true, true, true, true); + emit TokensBurnedAndClaimed(address(erc1155), caller, _tokenId, _quantity); + drop.burnAndClaim{ value: _msgValue }(_tokenId, _quantity); + } + + modifier whenBurnToClaimInfoSet_ERC1155Burnable_nonZeroPriceNativeToken() { + info = IBurnToClaim.BurnToClaimInfo({ + originContractAddress: address(erc1155), + tokenType: IBurnToClaim.TokenType.ERC1155, + tokenId: 0, + mintPriceForNewToken: 100, + currency: NATIVE_TOKEN + }); + + vm.prank(deployer); + drop.setBurnToClaimInfo(info); + + _; + } + + function test_burnAndClaim_ERC1155_mintPriceNonZero_nativeToken_incorrectMsgValue() + public + whenEnoughLazyMintedTokens + whenNotExceedMaxTotalMinted + whenBurnToClaimInfoSet_ERC1155Burnable_nonZeroPriceNativeToken + whenValidTokenIdERC1155 + whenEnoughBalanceERC1155 + { + uint256 incorrectTotalPrice = (info.mintPriceForNewToken * _quantity) + 1; + + vm.prank(caller); + vm.expectRevert("Invalid msg value"); + drop.burnAndClaim{ value: incorrectTotalPrice }(_tokenId, _quantity); + } + + function test_burnAndClaim_ERC1155_mintPriceNonZero_nativeToken() + public + whenEnoughLazyMintedTokens + whenNotExceedMaxTotalMinted + whenBurnToClaimInfoSet_ERC1155Burnable_nonZeroPriceNativeToken + whenValidTokenIdERC1155 + whenEnoughBalanceERC1155 + whenCorrectMsgValue + { + // state before + uint256 _nextTokenIdToClaim = drop.nextTokenIdToClaim(); + assertEq(platformFeeRecipient.balance, 0); + assertEq(saleRecipient.balance, 0); + assertEq(caller.balance, 1000 ether); + + // burn and claim + vm.prank(caller); + drop.burnAndClaim{ value: _msgValue }(_tokenId, _quantity); + + // check state after + uint256 totalPrice = (info.mintPriceForNewToken * _quantity); + uint256 _platformFee = (totalPrice * platformFeeBps) / 10_000; + uint256 _saleProceeds = totalPrice - _platformFee; + + assertEq(erc1155.balanceOf(caller, _tokenId), 100 - _quantity); + assertEq(drop.balanceOf(caller), _quantity); + assertEq(drop.ownerOf(_nextTokenIdToClaim), caller); + assertEq(drop.nextTokenIdToClaim(), _nextTokenIdToClaim + _quantity); + assertEq(platformFeeRecipient.balance, _platformFee); + assertEq(saleRecipient.balance, _saleProceeds); + assertEq(caller.balance, 1000 ether - totalPrice); + } + + function test_burnAndClaim_ERC1155_mintPriceNonZero_nativeToken_event() + public + whenEnoughLazyMintedTokens + whenNotExceedMaxTotalMinted + whenBurnToClaimInfoSet_ERC1155Burnable_nonZeroPriceNativeToken + whenValidTokenIdERC1155 + whenEnoughBalanceERC1155 + whenCorrectMsgValue + { + vm.prank(caller); + vm.expectEmit(true, true, true, true); + emit TokensBurnedAndClaimed(address(erc1155), caller, _tokenId, _quantity); + drop.burnAndClaim{ value: _msgValue }(_tokenId, _quantity); + } + + modifier whenBurnToClaimInfoSet_ERC1155Burnable_nonZeroPriceERC20() { + info = IBurnToClaim.BurnToClaimInfo({ + originContractAddress: address(erc1155), + tokenType: IBurnToClaim.TokenType.ERC1155, + tokenId: 0, + mintPriceForNewToken: 100, + currency: address(erc20) + }); + + vm.prank(deployer); + drop.setBurnToClaimInfo(info); + + _; + } + + function test_burnAndClaim_ERC1155_mintPriceNonZero_ERC20_nonZeroMsgValue() + public + whenEnoughLazyMintedTokens + whenNotExceedMaxTotalMinted + whenBurnToClaimInfoSet_ERC1155Burnable_nonZeroPriceERC20 + whenValidTokenIdERC1155 + whenEnoughBalanceERC1155 + { + vm.prank(caller); + vm.expectRevert("Invalid msg value"); + drop.burnAndClaim{ value: 1 }(_tokenId, _quantity); + } + + function test_burnAndClaim_ERC1155_mintPriceNonZero_ERC20() + public + whenEnoughLazyMintedTokens + whenNotExceedMaxTotalMinted + whenBurnToClaimInfoSet_ERC1155Burnable_nonZeroPriceERC20 + whenValidTokenIdERC1155 + whenEnoughBalanceERC1155 + whenMsgValueZero + { + // state before + uint256 _nextTokenIdToClaim = drop.nextTokenIdToClaim(); + assertEq(erc20.balanceOf(platformFeeRecipient), 0); + assertEq(erc20.balanceOf(saleRecipient), 0); + assertEq(erc20.balanceOf(caller), 1000 ether); + + // burn and claim + vm.prank(caller); + drop.burnAndClaim{ value: _msgValue }(_tokenId, _quantity); + + // check state after + uint256 totalPrice = (info.mintPriceForNewToken * _quantity); + uint256 _platformFee = (totalPrice * platformFeeBps) / 10_000; + uint256 _saleProceeds = totalPrice - _platformFee; + + assertEq(erc1155.balanceOf(caller, _tokenId), 100 - _quantity); + assertEq(drop.balanceOf(caller), _quantity); + assertEq(drop.ownerOf(_nextTokenIdToClaim), caller); + assertEq(drop.nextTokenIdToClaim(), _nextTokenIdToClaim + _quantity); + assertEq(erc20.balanceOf(platformFeeRecipient), _platformFee); + assertEq(erc20.balanceOf(saleRecipient), _saleProceeds); + assertEq(erc20.balanceOf(caller), 1000 ether - totalPrice); + } + + function test_burnAndClaim_ERC1155_mintPriceNonZero_ERC20_event() + public + whenEnoughLazyMintedTokens + whenNotExceedMaxTotalMinted + whenBurnToClaimInfoSet_ERC1155Burnable_nonZeroPriceERC20 + whenValidTokenIdERC1155 + whenEnoughBalanceERC1155 + whenMsgValueZero + { + vm.prank(caller); + vm.expectEmit(true, true, true, true); + emit TokensBurnedAndClaimed(address(erc1155), caller, _tokenId, _quantity); + drop.burnAndClaim{ value: _msgValue }(_tokenId, _quantity); + } +} diff --git a/src/test/burn-to-claim-drop-BTT/logic/burn-and-claim/burnAndClaim.tree b/src/test/burn-to-claim-drop-BTT/logic/burn-and-claim/burnAndClaim.tree new file mode 100644 index 000000000..00e6af1b9 --- /dev/null +++ b/src/test/burn-to-claim-drop-BTT/logic/burn-and-claim/burnAndClaim.tree @@ -0,0 +1,83 @@ +burnAndClaim(uint256 _burnTokenId, uint256 _quantity) +├── when the sum of `_quantity` and total minted is greater than nextTokenIdToLazyMint +│ └── it should revert ✅ +└── when the sum of `_quantity` and total minted less than or equal to nextTokenIdToLazyMint + └── when maxTotalMinted is not zero ✅ // TODO when zero + └── when the sum of `_quantity` and total minted greater than maxTotalMinted + │ └── it should revert ✅ + └── when the sum of `_quantity` and total minted less than or equal to maxTotalMinted + ├── when burn-to-claim info is not set + │ └── it should revert ✅ + └── when burn-to-claim info is set, with token type ERC721 + │ ├── when `_quantity` is not 1 + │ │ └── it should revert ✅ + │ └── when `_quantity` param is 1 + │ ├── when caller (i.e. _dropMsgSender) is not the actual token owner + │ │ └── it should revert ✅ + │ └── when caller is the actual token owner + │ ├── when the origin ERC721 contract is not burnable + │ │ └── it should revert ✅ + │ └── when the origin ERC721 contract is burnable + │ └── when mint price (i.e. pricePerToken) is zero + │ │ └── when msg.value is not zero + │ │ │ └── it should revert ✅ + │ │ └── when msg.value is zero + │ │ └── it should successfully burn the token with given tokenId for the token owner ✅ + │ │ └── it should mint new tokens to caller ✅ + │ │ └── it should emit TokensBurnedAndClaimed event ✅ + │ └── when mint price is not zero + │ └── when currency is native token + │ │ └── when msg.value is not equal to total price + │ │ │ └── it should revert ✅ + │ │ └── when msg.value is equal to total price + │ │ └── it should successfully burn the token with given tokenId for the token owner ✅ + │ │ └── it should mint new tokens to caller ✅ + │ │ └── (transfer to sale recipient) ✅ + │ │ └── (transfer to fee recipient) ✅ + │ │ └── it should emit TokensBurnedAndClaimed event ✅ + │ └── when currency is some ERC20 token + │ └── when msg.value is not zero + │ │ └── it should revert ✅ + │ └── when msg.value is zero + │ └── it should successfully burn the token with given tokenId for the token owner ✅ + │ └── it should mint new tokens to caller ✅ + │ └── (transfer to sale recipient) ✅ + │ └── (transfer to fee recipient) ✅ + │ └── it should emit TokensBurnedAndClaimed event ✅ + └── when burn-to-claim info is set, with token type ERC1155 + ├── when `_burnTokenId` param doesn't match eligible tokenId + │ └── it should revert ✅ + └── when `_burnTokenId` param matches eligible tokenId + ├── when caller (i.e. _dropMsgSender) has balance less than quantity param + │ └── it should revert ✅ + └── when caller has balance greater than or equal to quantity param + ├── when the origin ERC1155 contract is not burnable + │ └── it should revert ✅ + └── when the origin ERC1155 contract is burnable + └── when mint price (i.e. pricePerToken) is zero + │ └── when msg.value is not zero + │ │ └── it should revert ✅ + │ └── when msg.value is zero + │ └── it should successfully burn the token with given tokenId for the token owner ✅ + │ └── it should mint new tokens to caller ✅ + │ └── it should emit TokensBurnedAndClaimed event ✅ + └── when mint price is not zero + └── when currency is native token + │ └── when msg.value is not equal to total price + │ │ └── it should revert ✅ + │ └── when msg.value is equal to total price + │ └── it should successfully burn the token with given tokenId for the token owner ✅ + │ └── it should mint new tokens to caller ✅ + │ └── (transfer to sale recipient) ✅ + │ └── (transfer to fee recipient) ✅ + │ └── it should emit TokensBurnedAndClaimed event ✅ + └── when currency is some ERC20 token + └── when msg.value is not zero + │ └── it should revert ✅ + └── when msg.value is zero + └── it should successfully burn the token with given tokenId for the token owner ✅ + └── it should mint new tokens to caller ✅ + └── (transfer to sale recipient) ✅ + └── (transfer to fee recipient) ✅ + └── it should emit TokensBurnedAndClaimed event ✅ + diff --git a/src/test/burn-to-claim-drop-BTT/logic/lazy-mint/lazyMint.t.sol b/src/test/burn-to-claim-drop-BTT/logic/lazy-mint/lazyMint.t.sol new file mode 100644 index 000000000..e9b19f812 --- /dev/null +++ b/src/test/burn-to-claim-drop-BTT/logic/lazy-mint/lazyMint.t.sol @@ -0,0 +1,265 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "../../../utils/BaseTest.sol"; +import { BurnToClaimDropERC721 } from "contracts/prebuilts/unaudited/burn-to-claim-drop/BurnToClaimDropERC721.sol"; +import { BurnToClaimDrop721Logic, ERC721AUpgradeable, DelayedReveal, LazyMint, Drop, BurnToClaim, PrimarySale, PlatformFee } from "contracts/prebuilts/unaudited/burn-to-claim-drop/extension/BurnToClaimDrop721Logic.sol"; +import { PermissionsEnumerableImpl } from "contracts/extension/upgradeable/impl/PermissionsEnumerableImpl.sol"; +import { Royalty } from "contracts/extension/upgradeable/Royalty.sol"; +import { BatchMintMetadata } from "contracts/extension/upgradeable/BatchMintMetadata.sol"; +import { IBurnToClaim } from "contracts/extension/interface/IBurnToClaim.sol"; + +import "@thirdweb-dev/dynamic-contracts/src/interface/IExtension.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +// Test imports +import "erc721a-upgradeable/contracts/IERC721AUpgradeable.sol"; +import { Permissions } from "contracts/extension/Permissions.sol"; + +contract BurnToClaimDropERC721Logic_LazyMint is BaseTest, IExtension { + using Strings for uint256; + using Strings for address; + + event TokensLazyMinted(uint256 indexed startTokenId, uint256 endTokenId, string baseURI, bytes encryptedBaseURI); + + BurnToClaimDrop721Logic public drop; + uint256 internal startId; + uint256 internal amount; + uint256[] internal batchIds; + address internal caller; + bytes internal data; + bytes internal encryptedUri; + bytes32 internal provenanceHash; + + bytes private emptyEncodedBytes = abi.encode("", ""); + + using stdStorage for StdStorage; + + function setUp() public override { + super.setUp(); + + // Deploy implementation. + Extension[] memory extensions = _setupExtensions(); + address dropImpl = address(new BurnToClaimDropERC721(extensions)); + + // Deploy proxy pointing to implementaion. + vm.prank(deployer); + drop = BurnToClaimDrop721Logic( + payable( + address( + new TWProxy( + dropImpl, + abi.encodeCall( + BurnToClaimDropERC721.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ) + ) + ) + ); + + erc20.mint(deployer, 1_000 ether); + vm.deal(deployer, 1_000 ether); + + startId = 0; + // mint 5 batches + vm.startPrank(deployer); + for (uint256 i = 0; i < 5; i++) { + uint256 _amount = (i + 1) * 10; + uint256 batchId = startId + _amount; + batchIds.push(batchId); + + string memory baseURI = Strings.toString(batchId); + startId = drop.lazyMint(_amount, baseURI, ""); + } + vm.stopPrank(); + + encryptedUri = bytes("ipfs://encryptedURI"); + provenanceHash = keccak256("provenanceHash"); + } + + function _setupExtensions() internal returns (Extension[] memory extensions) { + extensions = new Extension[](2); + + // Extension: Permissions + address permissions = address(new PermissionsEnumerableImpl()); + + Extension memory extension_permissions; + extension_permissions.metadata = ExtensionMetadata({ + name: "Permissions", + metadataURI: "ipfs://Permissions", + implementation: permissions + }); + + extension_permissions.functions = new ExtensionFunction[](1); + extension_permissions.functions[0] = ExtensionFunction( + Permissions.hasRole.selector, + "hasRole(bytes32,address)" + ); + + extensions[0] = extension_permissions; + + address dropLogic = address(new BurnToClaimDrop721Logic()); + + Extension memory extension_drop; + extension_drop.metadata = ExtensionMetadata({ + name: "BurnToClaimDrop721Logic", + metadataURI: "ipfs://BurnToClaimDrop721Logic", + implementation: dropLogic + }); + + extension_drop.functions = new ExtensionFunction[](6); + extension_drop.functions[0] = ExtensionFunction(BurnToClaimDrop721Logic.tokenURI.selector, "tokenURI(uint256)"); + extension_drop.functions[1] = ExtensionFunction( + BurnToClaimDrop721Logic.lazyMint.selector, + "lazyMint(uint256,string,bytes)" + ); + extension_drop.functions[2] = ExtensionFunction( + BatchMintMetadata.getBaseURICount.selector, + "getBaseURICount()" + ); + extension_drop.functions[3] = ExtensionFunction( + BurnToClaimDrop721Logic.nextTokenIdToMint.selector, + "nextTokenIdToMint()" + ); + extension_drop.functions[4] = ExtensionFunction( + DelayedReveal.encryptDecrypt.selector, + "encryptDecrypt(bytes,bytes)" + ); + extension_drop.functions[5] = ExtensionFunction( + DelayedReveal.isEncryptedBatch.selector, + "isEncryptedBatch(uint256)" + ); + + extensions[1] = extension_drop; + } + + // ================== + // ======= Test branch: when `data` empty + // ================== + + function test_lazyMint_callerNotAuthorized() public { + vm.prank(address(caller)); + vm.expectRevert("Not authorized"); + drop.lazyMint(amount, "", ""); + } + + modifier whenCallerAuthorized() { + caller = deployer; + _; + } + + function test_lazyMint_zeroAmount() public whenCallerAuthorized { + vm.prank(address(caller)); + vm.expectRevert("0 amt"); + drop.lazyMint(amount, "", ""); + } + + modifier whenAmountNotZero() { + amount = 50; + _; + } + + function test_lazyMint() public whenCallerAuthorized whenAmountNotZero { + // check previous state + uint256 _nextTokenIdToLazyMintOld = drop.nextTokenIdToMint(); + assertEq(_nextTokenIdToLazyMintOld, batchIds[4]); + + string memory baseURI = "ipfs://baseURI"; + + // lazy mint next batch + vm.prank(address(caller)); + uint256 _batchId = drop.lazyMint(amount, baseURI, ""); + + // check new state + assertEq(_batchId, _nextTokenIdToLazyMintOld + amount); + for (uint256 i = _nextTokenIdToLazyMintOld; i < _batchId; i++) { + assertEq(drop.tokenURI(i), string(abi.encodePacked(baseURI, i.toString()))); + } + assertEq(drop.nextTokenIdToMint(), _nextTokenIdToLazyMintOld + amount); + assertEq(drop.getBaseURICount(), batchIds.length + 1); + } + + function test_lazyMint_event() public whenCallerAuthorized whenAmountNotZero { + string memory baseURI = "ipfs://baseURI"; + uint256 _nextTokenIdToLazyMintOld = drop.nextTokenIdToMint(); + + // lazy mint next batch + vm.prank(address(caller)); + vm.expectEmit(true, false, false, true); + emit TokensLazyMinted(_nextTokenIdToLazyMintOld, _nextTokenIdToLazyMintOld + amount - 1, baseURI, ""); + drop.lazyMint(amount, baseURI, ""); + } + + // ================== + // ======= Test branch: when `data` not empty + // ================== + + function test_lazyMint_withData_callerNotAuthorized() public { + vm.prank(address(caller)); + vm.expectRevert("Not authorized"); + drop.lazyMint(amount, "", data); + } + + function test_lazyMint_withData_zeroAmount() public whenCallerAuthorized { + vm.prank(address(caller)); + vm.expectRevert("0 amt"); + drop.lazyMint(amount, "", data); + } + + function test_lazyMint_withData_incorrectData() public whenCallerAuthorized whenAmountNotZero { + data = bytes("random data"); // not bytes+bytes32 encoded as expected + vm.prank(address(caller)); + vm.expectRevert(); + drop.lazyMint(amount, "", data); + } + + modifier whenCorrectEncodingOfData() { + data = abi.encode(encryptedUri, provenanceHash); + _; + } + + function test_lazyMint_withData() public whenCallerAuthorized whenAmountNotZero whenCorrectEncodingOfData { + // check previous state + uint256 _nextTokenIdToLazyMintOld = drop.nextTokenIdToMint(); + assertEq(_nextTokenIdToLazyMintOld, batchIds[4]); + + string memory placeholderURI = "ipfs://placeholderURI"; + + // lazy mint next batch + vm.prank(address(caller)); + uint256 _batchId = drop.lazyMint(amount, placeholderURI, data); + + // check new state + assertTrue(drop.isEncryptedBatch(_batchId)); // encrypted batch + assertEq(_batchId, _nextTokenIdToLazyMintOld + amount); + for (uint256 i = _nextTokenIdToLazyMintOld; i < _batchId; i++) { + assertEq(drop.tokenURI(i), string(abi.encodePacked(placeholderURI, "0"))); // encrypted batch, hence token-id 0 + } + assertEq(drop.nextTokenIdToMint(), _nextTokenIdToLazyMintOld + amount); + assertEq(drop.getBaseURICount(), batchIds.length + 1); + } + + function test_lazyMint_withData_event() public whenCallerAuthorized whenAmountNotZero whenCorrectEncodingOfData { + string memory placeholderURI = "ipfs://placeholderURI"; + uint256 _nextTokenIdToLazyMintOld = drop.nextTokenIdToMint(); + + // lazy mint next batch + vm.prank(address(caller)); + vm.expectEmit(true, false, false, true); + emit TokensLazyMinted(_nextTokenIdToLazyMintOld, _nextTokenIdToLazyMintOld + amount - 1, placeholderURI, data); + drop.lazyMint(amount, placeholderURI, data); + } +} diff --git a/src/test/burn-to-claim-drop-BTT/logic/lazy-mint/lazyMint.tree b/src/test/burn-to-claim-drop-BTT/logic/lazy-mint/lazyMint.tree new file mode 100644 index 000000000..39b512286 --- /dev/null +++ b/src/test/burn-to-claim-drop-BTT/logic/lazy-mint/lazyMint.tree @@ -0,0 +1,38 @@ +lazyMint( + uint256 _amount, + string calldata _baseURIForTokens, + bytes calldata _data +) +// Assume `_data` empty +├── when caller not authorized + │ └── it should revert ✅ + └── when caller is authorized + └── when amount to lazy mint is 0 + │ └── it should revert ✅ + └── when amount to lazy mint is not 0 + └── it should save the batch of tokens starting at `nextTokenIdToLazyMint` ✅ + └── it should store batch id equal to the sum of `nextTokenIdToLazyMint` and `_amount` ✅ + └── it should map the new batch id to `_baseURIForTokens` ✅ + └── it should increase `nextTokenIdToLazyMint` by `_amount` ✅ + └── it should return the new `batchId` ✅ + └── it should emit TokensLazyMinted event ✅ + +// Assume `_data` not empty +├── when caller not authorized + │ └── it should revert ✅ + └── when caller is authorized + └── when amount to lazy mint is 0 + │ └── it should revert ✅ + └── when amount to lazy mint is not 0 + └── when data can't be decoded + │ └── it should revert ✅ + └── when data can be decoded successfully + └── when decoded encryptedURI and provenanceHash are non-empty + └── it should set encrypted data for the new batch equal to _data ✅ + └── it should save the batch of tokens starting at `nextTokenIdToLazyMint` ✅ + └── it should store batch id equal to the sum of `nextTokenIdToLazyMint` and `_amount` ✅ + └── it should map the new batch id to `_baseURIForTokens` ✅ + └── it should increase `nextTokenIdToLazyMint` by `_amount` ✅ + └── it should return the new `batchId` ✅ + └── it should emit TokensLazyMinted event ✅ + diff --git a/src/test/burn-to-claim-drop-BTT/logic/other-functions/other.t.sol b/src/test/burn-to-claim-drop-BTT/logic/other-functions/other.t.sol new file mode 100644 index 000000000..0f99059b6 --- /dev/null +++ b/src/test/burn-to-claim-drop-BTT/logic/other-functions/other.t.sol @@ -0,0 +1,395 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "../../../utils/BaseTest.sol"; +import { BurnToClaimDropERC721 } from "contracts/prebuilts/unaudited/burn-to-claim-drop/BurnToClaimDropERC721.sol"; +import { BurnToClaimDrop721Logic, IERC2981 } from "contracts/prebuilts/unaudited/burn-to-claim-drop/extension/BurnToClaimDrop721Logic.sol"; +import { IDrop } from "contracts/extension/interface/IDrop.sol"; +import { IStaking721 } from "contracts/extension/interface/IStaking721.sol"; +import { PermissionsEnumerableImpl } from "contracts/extension/upgradeable/impl/PermissionsEnumerableImpl.sol"; + +import { ERC721AStorage } from "contracts/extension/upgradeable/init/ERC721AInit.sol"; + +import "@thirdweb-dev/dynamic-contracts/src/interface/IExtension.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; +import { Permissions } from "contracts/extension/Permissions.sol"; + +contract MyBurnToClaimDrop721Logic is BurnToClaimDrop721Logic { + function canSetPlatformFeeInfo() external view returns (bool) { + return _canSetPlatformFeeInfo(); + } + + function canSetPrimarySaleRecipient() external view returns (bool) { + return _canSetPrimarySaleRecipient(); + } + + function canSetOwner() external view returns (bool) { + return _canSetOwner(); + } + + function canSetRoyaltyInfo() external view returns (bool) { + return _canSetRoyaltyInfo(); + } + + function canSetContractURI() external view returns (bool) { + return _canSetContractURI(); + } + + function canSetClaimConditions() external view returns (bool) { + return _canSetClaimConditions(); + } + + function canLazyMint() external view returns (bool) { + return _canLazyMint(); + } + + function canSetBurnToClaim() external view returns (bool) { + return _canSetBurnToClaim(); + } + + function beforeTokenTransfers(address from, address to, uint256 startTokenId, uint256 quantity) external { + _beforeTokenTransfers(from, to, startTokenId, quantity); + } + + function transferTokensOnClaim(address _to, uint256 _quantityBeingClaimed) external returns (uint256 startTokenId) { + ERC721AStorage.Data storage data = ERC721AStorage.erc721AStorage(); + startTokenId = data._currentIndex; + _safeMint(_to, _quantityBeingClaimed); + } + + function beforeClaim(uint256 _quantity, AllowlistProof calldata proof) external { + _beforeClaim(address(0), _quantity, address(0), 0, proof, ""); + } + + function mintTo(address _recipient) external { + _safeMint(_recipient, 1); + } +} + +contract BurnToClaimDrop721Logic_OtherFunctions is BaseTest, IExtension { + MyBurnToClaimDrop721Logic public drop; + address internal caller; + address internal recipient; + + function setUp() public override { + super.setUp(); + + // Deploy implementation. + Extension[] memory extensions = _setupExtensions(); + address dropImpl = address(new BurnToClaimDropERC721(extensions)); + + // Deploy proxy pointing to implementaion. + vm.prank(deployer); + drop = MyBurnToClaimDrop721Logic( + payable( + address( + new TWProxy( + dropImpl, + abi.encodeCall( + BurnToClaimDropERC721.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ) + ) + ) + ); + + caller = getActor(5); + recipient = getActor(6); + } + + function _setupExtensions() internal returns (Extension[] memory extensions) { + extensions = new Extension[](2); + + // Extension: Permissions + address permissions = address(new PermissionsEnumerableImpl()); + + Extension memory extension_permissions; + extension_permissions.metadata = ExtensionMetadata({ + name: "Permissions", + metadataURI: "ipfs://Permissions", + implementation: permissions + }); + + extension_permissions.functions = new ExtensionFunction[](3); + extension_permissions.functions[0] = ExtensionFunction( + Permissions.hasRole.selector, + "hasRole(bytes32,address)" + ); + extension_permissions.functions[1] = ExtensionFunction( + Permissions.grantRole.selector, + "grantRole(bytes32,address)" + ); + extension_permissions.functions[2] = ExtensionFunction( + Permissions.revokeRole.selector, + "revokeRole(bytes32,address)" + ); + + extensions[0] = extension_permissions; + + address dropLogic = address(new MyBurnToClaimDrop721Logic()); + + Extension memory extension_drop; + extension_drop.metadata = ExtensionMetadata({ + name: "MyBurnToClaimDrop721Logic", + metadataURI: "ipfs://MyBurnToClaimDrop721Logic", + implementation: dropLogic + }); + + extension_drop.functions = new ExtensionFunction[](18); + extension_drop.functions[0] = ExtensionFunction( + MyBurnToClaimDrop721Logic.canSetPlatformFeeInfo.selector, + "canSetPlatformFeeInfo()" + ); + extension_drop.functions[1] = ExtensionFunction( + MyBurnToClaimDrop721Logic.canSetPrimarySaleRecipient.selector, + "canSetPrimarySaleRecipient()" + ); + extension_drop.functions[2] = ExtensionFunction( + MyBurnToClaimDrop721Logic.canSetOwner.selector, + "canSetOwner()" + ); + extension_drop.functions[3] = ExtensionFunction( + MyBurnToClaimDrop721Logic.canSetRoyaltyInfo.selector, + "canSetRoyaltyInfo()" + ); + extension_drop.functions[4] = ExtensionFunction( + MyBurnToClaimDrop721Logic.canSetClaimConditions.selector, + "canSetClaimConditions()" + ); + extension_drop.functions[5] = ExtensionFunction( + MyBurnToClaimDrop721Logic.canSetContractURI.selector, + "canSetContractURI()" + ); + extension_drop.functions[6] = ExtensionFunction( + MyBurnToClaimDrop721Logic.canLazyMint.selector, + "canLazyMint()" + ); + extension_drop.functions[7] = ExtensionFunction( + MyBurnToClaimDrop721Logic.canSetBurnToClaim.selector, + "canSetBurnToClaim()" + ); + extension_drop.functions[8] = ExtensionFunction( + MyBurnToClaimDrop721Logic.beforeTokenTransfers.selector, + "beforeTokenTransfers(address,address,uint256,uint256)" + ); + extension_drop.functions[9] = ExtensionFunction(BurnToClaimDrop721Logic.totalMinted.selector, "totalMinted()"); + extension_drop.functions[10] = ExtensionFunction( + MyBurnToClaimDrop721Logic.transferTokensOnClaim.selector, + "transferTokensOnClaim(address,uint256)" + ); + extension_drop.functions[11] = ExtensionFunction( + BurnToClaimDrop721Logic.supportsInterface.selector, + "supportsInterface(bytes4)" + ); + extension_drop.functions[12] = ExtensionFunction( + MyBurnToClaimDrop721Logic.beforeClaim.selector, + "beforeClaim(uint256,(bytes32[],uint256,uint256,address))" + ); + extension_drop.functions[13] = ExtensionFunction( + BurnToClaimDrop721Logic.lazyMint.selector, + "lazyMint(uint256,string,bytes)" + ); + extension_drop.functions[14] = ExtensionFunction( + BurnToClaimDrop721Logic.setMaxTotalMinted.selector, + "setMaxTotalMinted(uint256)" + ); + extension_drop.functions[15] = ExtensionFunction(BurnToClaimDrop721Logic.burn.selector, "burn(uint256)"); + extension_drop.functions[16] = ExtensionFunction(MyBurnToClaimDrop721Logic.mintTo.selector, "mintTo(address)"); + extension_drop.functions[17] = ExtensionFunction( + IERC721.setApprovalForAll.selector, + "setApprovalForAll(address,bool)" + ); + + extensions[1] = extension_drop; + } + + modifier whenCallerAuthorized() { + caller = deployer; + _; + } + + function test_canSetPlatformFeeInfo_notAuthorized() public { + vm.prank(caller); + drop.canSetPlatformFeeInfo(); + } + + function test_canSetPlatformFeeInfo() public whenCallerAuthorized { + vm.prank(caller); + assertTrue(drop.canSetPlatformFeeInfo()); + } + + function test_canSetPrimarySaleRecipient_notAuthorized() public { + vm.prank(caller); + drop.canSetPrimarySaleRecipient(); + } + + function test_canSetPrimarySaleRecipient() public whenCallerAuthorized { + vm.prank(caller); + assertTrue(drop.canSetPrimarySaleRecipient()); + } + + function test_canSetOwner_notAuthorized() public { + vm.prank(caller); + drop.canSetOwner(); + } + + function test_canSetOwner() public whenCallerAuthorized { + vm.prank(caller); + assertTrue(drop.canSetOwner()); + } + + function test_canSetRoyaltyInfo_notAuthorized() public { + vm.prank(caller); + drop.canSetRoyaltyInfo(); + } + + function test_canSetRoyaltyInfo() public whenCallerAuthorized { + vm.prank(caller); + assertTrue(drop.canSetRoyaltyInfo()); + } + + function test_canSetContractURI_notAuthorized() public { + vm.prank(caller); + drop.canSetContractURI(); + } + + function test_canSetContractURI() public whenCallerAuthorized { + vm.prank(caller); + assertTrue(drop.canSetContractURI()); + } + + function test_canSetClaimConditions_notAuthorized() public { + vm.prank(caller); + drop.canSetClaimConditions(); + } + + function test_canSetClaimConditions() public whenCallerAuthorized { + vm.prank(caller); + assertTrue(drop.canSetClaimConditions()); + } + + function test_canLazyMint_notAuthorized() public { + vm.prank(caller); + drop.canLazyMint(); + } + + function test_canLazyMint() public whenCallerAuthorized { + vm.prank(caller); + assertTrue(drop.canLazyMint()); + } + + function test_canSetBurnToClaim_notAuthorized() public { + vm.prank(caller); + drop.canSetBurnToClaim(); + } + + function test_canSetBurnToClaim() public whenCallerAuthorized { + vm.prank(caller); + assertTrue(drop.canSetBurnToClaim()); + } + + function test_beforeTokenTransfers_restricted_notTransferRole() public { + vm.prank(deployer); + Permissions(address(drop)).revokeRole(keccak256("TRANSFER_ROLE"), address(0)); + vm.expectRevert("!Transfer-Role"); + drop.beforeTokenTransfers(caller, address(0x123), 0, 1); + } + + modifier whenTransferRole() { + vm.prank(deployer); + Permissions(address(drop)).grantRole(keccak256("TRANSFER_ROLE"), caller); + _; + } + + function test_beforeTokenTransfers_restricted() public whenTransferRole { + drop.beforeTokenTransfers(caller, address(0x123), 0, 1); + } + + function test_totalMinted() public { + uint256 totalMinted = drop.totalMinted(); + assertEq(totalMinted, 0); + + // mint tokens + drop.transferTokensOnClaim(caller, 10); + totalMinted = drop.totalMinted(); + assertEq(totalMinted, 10); + } + + function test_supportsInterface() public { + assertTrue(drop.supportsInterface(type(IERC2981).interfaceId)); + assertFalse(drop.supportsInterface(type(IStaking721).interfaceId)); + } + + function test_beforeClaim() public { + bytes32[] memory emptyBytes32Array = new bytes32[](0); + IDrop.AllowlistProof memory proof = IDrop.AllowlistProof(emptyBytes32Array, 0, 0, address(0)); + drop.beforeClaim(0, proof); + + vm.expectRevert("!Tokens"); + drop.beforeClaim(1, proof); + + vm.prank(deployer); + drop.lazyMint(100, "ipfs://", ""); + + vm.prank(deployer); + drop.setMaxTotalMinted(1); + + vm.expectRevert("exceed max total mint cap."); + drop.beforeClaim(10, proof); + + vm.prank(deployer); + drop.setMaxTotalMinted(0); + + drop.beforeClaim(10, proof); // no revert if max total mint cap is set to 0 + } + + //=========== burn tests ========= + + function test_burn_whenNotOwnerNorApproved() public { + // mint + drop.mintTo(recipient); + + // burn + vm.expectRevert(); + drop.burn(0); + } + + function test_burn_whenOwner() public { + // mint + drop.mintTo(recipient); + + // burn + vm.prank(recipient); + drop.burn(0); + + vm.expectRevert(); // checking non-existent token, because burned + drop.ownerOf(0); + } + + function test_burn_whenApproved() public { + drop.mintTo(recipient); + + vm.prank(recipient); + drop.setApprovalForAll(caller, true); + + // burn + vm.prank(caller); + drop.burn(0); + + vm.expectRevert(); // checking non-existent token, because burned + drop.ownerOf(0); + } +} diff --git a/src/test/burn-to-claim-drop-BTT/logic/other-functions/other.tree b/src/test/burn-to-claim-drop-BTT/logic/other-functions/other.tree new file mode 100644 index 000000000..a7ed4a957 --- /dev/null +++ b/src/test/burn-to-claim-drop-BTT/logic/other-functions/other.tree @@ -0,0 +1,86 @@ +_canSetPlatformFeeInfo() +├── when the caller doesn't have DEFAULT_ADMIN_ROLE +│ └── it should revert ✅ +└── when the caller has DEFAULT_ADMIN_ROLE + └── it should return true ✅ + +_canSetPrimarySaleRecipient() +├── when the caller doesn't have DEFAULT_ADMIN_ROLE +│ └── it should revert ✅ +└── when the caller has DEFAULT_ADMIN_ROLE + └── it should return true ✅ + +_canSetOwner() +├── when the caller doesn't have DEFAULT_ADMIN_ROLE +│ └── it should revert ✅ +└── when the caller has DEFAULT_ADMIN_ROLE + └── it should return true ✅ + +_canSetRoyaltyInfo() +├── when the caller doesn't have DEFAULT_ADMIN_ROLE +│ └── it should revert ✅ +└── when the caller has DEFAULT_ADMIN_ROLE + └── it should return true ✅ + +_canSetContractURI() +├── when the caller doesn't have DEFAULT_ADMIN_ROLE +│ └── it should revert ✅ +└── when the caller has DEFAULT_ADMIN_ROLE + └── it should return true ✅ + +_canSetClaimConditions() +├── when the caller doesn't have DEFAULT_ADMIN_ROLE +│ └── it should revert ✅ +└── when the caller has DEFAULT_ADMIN_ROLE + └── it should return true ✅ + +_canLazyMint() +├── when the caller doesn't have MINTER_ROLE +│ └── it should revert ✅ +└── when the caller has MINTER_ROLE + └── it should return true ✅ + +_canSetBurnToClaim() +├── when the caller doesn't have DEFAULT_ADMIN_ROLE +│ └── it should revert ✅ +└── when the caller has DEFAULT_ADMIN_ROLE + └── it should return true ✅ + +burn(uint256 tokenId) +├── when the caller isn't the owner of `tokenId` or token not approved to caller +│ └── it should revert ✅ +└── when the caller owns `tokenId` +│ └── it should burn the token ✅ +└── when the `tokenId` is approved to caller + └── it should burn the token ✅ + +_beforeTokenTransfers( + address from, + address to, + uint256 startTokenId, + uint256 quantity +) +├── when transfers are restricted (i.e. address(0) doesn't have transfer role, or from-to addresses are not address(0) +│ └── when from and to don't have transfer role +│ └── it should revert ✅ + +totalMinted() +├── should return the quantity of tokens minted (i.e. claimed) so far ✅ + +supportsInterface(bytes4 interfaceId) +├── it should return true for supported interface ✅ +├── it should return false for not supported interface ✅ + +_beforeClaim( + address, + uint256 _quantity, + address, + uint256, + AllowlistProof calldata, + bytes memory +) +├── when `_quantity` exceeds lazy minted quantity +│ └── it should revert ✅ +├── when `_quantity` exceeds max total mint cap (if not zero) +│ └── it should revert ✅ + diff --git a/src/test/burn-to-claim-drop-BTT/logic/reveal/reveal.t.sol b/src/test/burn-to-claim-drop-BTT/logic/reveal/reveal.t.sol new file mode 100644 index 000000000..f60d11351 --- /dev/null +++ b/src/test/burn-to-claim-drop-BTT/logic/reveal/reveal.t.sol @@ -0,0 +1,241 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "../../../utils/BaseTest.sol"; +import { BurnToClaimDropERC721 } from "contracts/prebuilts/unaudited/burn-to-claim-drop/BurnToClaimDropERC721.sol"; +import { BurnToClaimDrop721Logic, ERC721AUpgradeable, DelayedReveal, LazyMint, Drop, BurnToClaim, PrimarySale, PlatformFee } from "contracts/prebuilts/unaudited/burn-to-claim-drop/extension/BurnToClaimDrop721Logic.sol"; +import { PermissionsEnumerableImpl } from "contracts/extension/upgradeable/impl/PermissionsEnumerableImpl.sol"; +import { Royalty } from "contracts/extension/upgradeable/Royalty.sol"; +import { BatchMintMetadata } from "contracts/extension/upgradeable/BatchMintMetadata.sol"; +import { IBurnToClaim } from "contracts/extension/interface/IBurnToClaim.sol"; + +import "@thirdweb-dev/dynamic-contracts/src/interface/IExtension.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +// Test imports +import "erc721a-upgradeable/contracts/IERC721AUpgradeable.sol"; + +contract BurnToClaimDropERC721Logic_Reveal is BaseTest, IExtension { + using Strings for uint256; + using Strings for address; + + event TokenURIRevealed(uint256 indexed index, string revealedURI); + + BurnToClaimDrop721Logic public drop; + uint256 internal startId; + uint256 internal amount; + uint256[] internal batchIds; + address internal caller; + bytes internal data; + string internal placeholderURI; + bytes internal originalURI; + uint256 internal _index; + bytes internal _key; + + bytes private emptyEncodedBytes = abi.encode("", ""); + + using stdStorage for StdStorage; + + function setUp() public override { + super.setUp(); + + // Deploy implementation. + Extension[] memory extensions = _setupExtensions(); + address dropImpl = address(new BurnToClaimDropERC721(extensions)); + + // Deploy proxy pointing to implementaion. + vm.prank(deployer); + drop = BurnToClaimDrop721Logic( + payable( + address( + new TWProxy( + dropImpl, + abi.encodeCall( + BurnToClaimDropERC721.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ) + ) + ) + ); + + erc20.mint(deployer, 1_000 ether); + vm.deal(deployer, 1_000 ether); + + startId = 0; + originalURI = bytes("ipfs://originalURI"); + placeholderURI = "ipfs://placeholderURI"; + _key = "key123"; + // mint 3 batches + vm.startPrank(deployer); + for (uint256 i = 0; i < 3; i++) { + uint256 _amount = (i + 1) * 10; + uint256 batchId = startId + _amount; + batchIds.push(batchId); + + // set encrypted uri for one of the batches + if (i == 1) { + bytes memory _encryptedURI = drop.encryptDecrypt(originalURI, _key); + bytes32 _provenanceHash = keccak256(abi.encodePacked(originalURI, _key, block.chainid)); + + startId = drop.lazyMint(_amount, placeholderURI, abi.encode(_encryptedURI, _provenanceHash)); + } else { + startId = drop.lazyMint(_amount, string(originalURI), ""); + } + } + vm.stopPrank(); + } + + function _setupExtensions() internal returns (Extension[] memory extensions) { + extensions = new Extension[](2); + + // Extension: Permissions + address permissions = address(new PermissionsEnumerableImpl()); + + Extension memory extension_permissions; + extension_permissions.metadata = ExtensionMetadata({ + name: "Permissions", + metadataURI: "ipfs://Permissions", + implementation: permissions + }); + + extension_permissions.functions = new ExtensionFunction[](1); + extension_permissions.functions[0] = ExtensionFunction( + Permissions.hasRole.selector, + "hasRole(bytes32,address)" + ); + + extensions[0] = extension_permissions; + + address dropLogic = address(new BurnToClaimDrop721Logic()); + + Extension memory extension_drop; + extension_drop.metadata = ExtensionMetadata({ + name: "BurnToClaimDrop721Logic", + metadataURI: "ipfs://BurnToClaimDrop721Logic", + implementation: dropLogic + }); + + extension_drop.functions = new ExtensionFunction[](6); + extension_drop.functions[0] = ExtensionFunction(BurnToClaimDrop721Logic.tokenURI.selector, "tokenURI(uint256)"); + extension_drop.functions[1] = ExtensionFunction( + BurnToClaimDrop721Logic.lazyMint.selector, + "lazyMint(uint256,string,bytes)" + ); + extension_drop.functions[2] = ExtensionFunction( + BurnToClaimDrop721Logic.reveal.selector, + "reveal(uint256,bytes)" + ); + extension_drop.functions[3] = ExtensionFunction( + DelayedReveal.encryptDecrypt.selector, + "encryptDecrypt(bytes,bytes)" + ); + extension_drop.functions[4] = ExtensionFunction( + DelayedReveal.isEncryptedBatch.selector, + "isEncryptedBatch(uint256)" + ); + extension_drop.functions[5] = ExtensionFunction( + DelayedReveal.getRevealURI.selector, + "getRevealURI(uint256,bytes)" + ); + + extensions[1] = extension_drop; + } + + function test_reveal_callerNotAuthorized() public { + vm.prank(address(caller)); + vm.expectRevert("Not authorized"); + drop.lazyMint(amount, "", ""); + } + + modifier whenCallerAuthorized() { + caller = deployer; + _; + } + + function test_reveal_invalidIndex() public whenCallerAuthorized { + vm.prank(address(caller)); + vm.expectRevert("Invalid index"); + drop.reveal(4, "key"); + } + + modifier whenValidIndex() { + _; + } + + function test_reveal_noEncryptedURI() public whenCallerAuthorized whenValidIndex { + _index = 2; + vm.prank(address(caller)); + vm.expectRevert("Nothing to reveal"); + drop.reveal(_index, "key"); + } + + modifier whenEncryptedURI() { + _index = 1; + _; + } + + function test_reveal_incorrectKey() public whenCallerAuthorized whenValidIndex whenEncryptedURI { + vm.prank(address(caller)); + vm.expectRevert("Incorrect key"); + drop.reveal(_index, "incorrect key"); + } + + modifier whenCorrectKey() { + _; + } + + function test_reveal() public whenCallerAuthorized whenValidIndex whenEncryptedURI { + //state before + for (uint256 i = 0; i < 3; i++) { + uint256 _startId = i > 0 ? batchIds[i - 1] : 0; + + for (uint256 j = _startId; j < batchIds[i]; j += 1) { + string memory uri = drop.tokenURI(j); + if (i == 1) { + assertEq(uri, string(abi.encodePacked(placeholderURI, "0"))); // <-- placeholder URI for encrypted batch + } else { + assertEq(uri, string(abi.encodePacked(string(originalURI), j.toString()))); + } + } + } + + // reveal + vm.prank(address(caller)); + string memory revealedURI = drop.reveal(_index, _key); + + // check state after + vm.expectRevert("Nothing to reveal"); + drop.getRevealURI(_index, _key); + + assertEq(revealedURI, string(originalURI)); + + for (uint256 i = 0; i < 3; i++) { + uint256 _startId = i > 0 ? batchIds[i - 1] : 0; + + for (uint256 j = _startId; j < batchIds[i]; j += 1) { + string memory uri = drop.tokenURI(j); + assertEq(uri, string(abi.encodePacked(string(originalURI), j.toString()))); + } + } + } + + function test_reveal_event() public whenCallerAuthorized whenValidIndex whenEncryptedURI { + vm.prank(address(caller)); + vm.expectEmit(true, false, false, true); + emit TokenURIRevealed(1, string(originalURI)); + drop.reveal(_index, _key); + } +} diff --git a/src/test/burn-to-claim-drop-BTT/logic/reveal/reveal.tree b/src/test/burn-to-claim-drop-BTT/logic/reveal/reveal.tree new file mode 100644 index 000000000..febcdb258 --- /dev/null +++ b/src/test/burn-to-claim-drop-BTT/logic/reveal/reveal.tree @@ -0,0 +1,16 @@ +reveal(uint256 index, bytes calldata key) +├── when caller doesn't have minter_role +│ └── it should revert ✅ +└── when caller has minter role + ├── when index is more than number of batches + │ └── it should revert ✅ + └── when index is within total number of batches + ├── when there is no encrypted uri associated with the batch index + │ └── it should revert ✅ + └── when there is an encrypted uri present + ├── when the provenance hash generated is incorrect for the given key + │ └── it should revert ✅ + └── when provenance hash is correct + └── it should set the encrypted data for this batch to "" ✅ + └── it should set base URI for this batch to correct revealed URI ✅ + └── it should emit TokenURIRevealed event ✅ \ No newline at end of file diff --git a/src/test/burn-to-claim-drop-BTT/router/initialize/initialize.t.sol b/src/test/burn-to-claim-drop-BTT/router/initialize/initialize.t.sol new file mode 100644 index 000000000..cb1da0318 --- /dev/null +++ b/src/test/burn-to-claim-drop-BTT/router/initialize/initialize.t.sol @@ -0,0 +1,455 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "../../../utils/BaseTest.sol"; +import { BurnToClaimDropERC721 } from "contracts/prebuilts/unaudited/burn-to-claim-drop/BurnToClaimDropERC721.sol"; +import { BurnToClaimDrop721Logic } from "contracts/prebuilts/unaudited/burn-to-claim-drop/extension/BurnToClaimDrop721Logic.sol"; +import { PermissionsEnumerableImpl } from "contracts/extension/upgradeable/impl/PermissionsEnumerableImpl.sol"; + +import { ERC721AStorage } from "contracts/extension/upgradeable/init/ERC721AInit.sol"; +import { ERC2771ContextStorage } from "contracts/extension/upgradeable/init/ERC2771ContextInit.sol"; +import { ContractMetadataStorage } from "contracts/extension/upgradeable/init/ContractMetadataInit.sol"; +import { OwnableStorage } from "contracts/extension/upgradeable/init/OwnableInit.sol"; +import { PlatformFeeStorage } from "contracts/extension/upgradeable/init/PlatformFeeInit.sol"; +import { RoyaltyStorage } from "contracts/extension/upgradeable/init/RoyaltyInit.sol"; +import { PrimarySaleStorage } from "contracts/extension/upgradeable/init/PrimarySaleInit.sol"; +import { PermissionsStorage } from "contracts/extension/upgradeable/init/PermissionsInit.sol"; + +import "@thirdweb-dev/dynamic-contracts/src/interface/IExtension.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +contract BurnToClaimDropERC721Router is BurnToClaimDropERC721 { + constructor(Extension[] memory _extensions) BurnToClaimDropERC721(_extensions) {} + + function hasRole(bytes32 role, address addr) public view returns (bool) { + return _hasRole(role, addr); + } + + function roleAdmin(bytes32 role) public view returns (bytes32) { + PermissionsStorage.Data storage data = PermissionsStorage.data(); + return data._getRoleAdmin[role]; + } + + function name() public view returns (string memory) { + ERC721AStorage.Data storage data = ERC721AStorage.erc721AStorage(); + return data._name; + } + + function symbol() public view returns (string memory) { + ERC721AStorage.Data storage data = ERC721AStorage.erc721AStorage(); + return data._symbol; + } + + function trustedForwarders(address[] memory _trustedForwarders) public view returns (bool) { + ERC2771ContextStorage.Data storage data = ERC2771ContextStorage.data(); + + for (uint256 i = 0; i < _trustedForwarders.length; i++) { + if (!data.trustedForwarder[_trustedForwarders[i]]) { + return false; + } + } + return true; + } + + function contractURI() public view returns (string memory) { + ContractMetadataStorage.Data storage data = ContractMetadataStorage.data(); + return data.contractURI; + } + + function owner() public view returns (address) { + OwnableStorage.Data storage data = OwnableStorage.data(); + return data._owner; + } + + function platformFeeRecipient() public view returns (address) { + PlatformFeeStorage.Data storage data = PlatformFeeStorage.data(); + return data.platformFeeRecipient; + } + + function platformFeeBps() public view returns (uint16) { + PlatformFeeStorage.Data storage data = PlatformFeeStorage.data(); + return data.platformFeeBps; + } + + function royaltyRecipient() public view returns (address) { + RoyaltyStorage.Data storage data = RoyaltyStorage.data(); + return data.royaltyRecipient; + } + + function royaltyBps() public view returns (uint16) { + RoyaltyStorage.Data storage data = RoyaltyStorage.data(); + return data.royaltyBps; + } + + function primarySaleRecipient() public view returns (address) { + PrimarySaleStorage.Data storage data = PrimarySaleStorage.data(); + return data.recipient; + } +} + +contract BurnToClaimDropERC721_Initialize is BaseTest, IExtension { + address public implementation; + address public proxy; + + event ContractURIUpdated(string prevURI, string newURI); + event OwnerUpdated(address indexed prevOwner, address indexed newOwner); + event RoleGranted(bytes32 indexed role, address indexed account, address indexed sender); + event RoleAdminChanged(bytes32 indexed role, bytes32 indexed previousAdminRole, bytes32 indexed newAdminRole); + event PlatformFeeInfoUpdated(address indexed platformFeeRecipient, uint256 platformFeeBps); + event DefaultRoyalty(address indexed newRoyaltyRecipient, uint256 newRoyaltyBps); + event PrimarySaleRecipientUpdated(address indexed recipient); + + function setUp() public override { + super.setUp(); + + // Deploy implementation. + Extension[] memory extensions = _setupExtensions(); // setup just a couple of extension/functions for testing here + implementation = address(new BurnToClaimDropERC721Router(extensions)); + + // Deploy proxy pointing to implementaion. + vm.prank(deployer); + proxy = address( + new TWProxy( + implementation, + abi.encodeCall( + BurnToClaimDropERC721.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ) + ); + } + + function _setupExtensions() internal returns (Extension[] memory extensions) { + extensions = new Extension[](2); + + // Extension: Permissions + address permissions = address(new PermissionsEnumerableImpl()); + + Extension memory extension_permissions; + extension_permissions.metadata = ExtensionMetadata({ + name: "Permissions", + metadataURI: "ipfs://Permissions", + implementation: permissions + }); + + extension_permissions.functions = new ExtensionFunction[](1); + extension_permissions.functions[0] = ExtensionFunction( + Permissions.hasRole.selector, + "hasRole(bytes32,address)" + ); + + extensions[0] = extension_permissions; + + address dropLogic = address(new BurnToClaimDrop721Logic()); + + Extension memory extension_drop; + extension_drop.metadata = ExtensionMetadata({ + name: "BurnToClaimDrop721Logic", + metadataURI: "ipfs://BurnToClaimDrop721Logic", + implementation: dropLogic + }); + + extension_drop.functions = new ExtensionFunction[](1); + extension_drop.functions[0] = ExtensionFunction(BurnToClaimDrop721Logic.tokenURI.selector, "tokenURI(uint256)"); + extensions[1] = extension_drop; + } + + function test_initialize_initializingImplementation() public { + vm.expectRevert("Initializable: contract is already initialized"); + BurnToClaimDropERC721Router(payable(implementation)).initialize( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ); + } + + modifier whenNotImplementation() { + _; + } + + function test_initialize_proxyAlreadyInitialized() public whenNotImplementation { + vm.expectRevert("Initializable: contract is already initialized"); + BurnToClaimDropERC721Router(payable(proxy)).initialize( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ); + } + + modifier whenProxyNotInitialized() { + proxy = address(new TWProxy(implementation, "")); + _; + } + + function test_initialize() public whenNotImplementation whenProxyNotInitialized { + BurnToClaimDropERC721(payable(proxy)).initialize( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ); + + // check state + BurnToClaimDropERC721Router router = BurnToClaimDropERC721Router(payable(proxy)); + assertEq(router.name(), NAME); + assertEq(router.symbol(), SYMBOL); + assertTrue(router.trustedForwarders(forwarders())); + assertEq(router.platformFeeRecipient(), platformFeeRecipient); + assertEq(router.platformFeeBps(), platformFeeBps); + assertEq(router.royaltyRecipient(), royaltyRecipient); + assertEq(router.royaltyBps(), royaltyBps); + assertEq(router.primarySaleRecipient(), saleRecipient); + assertTrue(router.hasRole(bytes32(0x00), deployer)); + assertTrue(router.hasRole(keccak256("TRANSFER_ROLE"), deployer)); + assertTrue(router.hasRole(keccak256("TRANSFER_ROLE"), address(0))); + assertTrue(router.hasRole(keccak256("MINTER_ROLE"), deployer)); + assertTrue(router.hasRole(keccak256("EXTENSION_ROLE"), deployer)); + assertEq(router.roleAdmin(keccak256("EXTENSION_ROLE")), keccak256("EXTENSION_ROLE")); + + // check default extensions + Extension[] memory _extensions = router.getAllExtensions(); + assertEq(_extensions.length, 2); + } + + function test_initialize_event_ContractURIUpdated() public whenNotImplementation whenProxyNotInitialized { + vm.expectEmit(false, false, false, true); + emit ContractURIUpdated("", CONTRACT_URI); + BurnToClaimDropERC721(payable(proxy)).initialize( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ); + } + + function test_initialize_event_OwnerUpdated() public whenNotImplementation whenProxyNotInitialized { + vm.expectEmit(true, true, false, false); + emit OwnerUpdated(address(0), deployer); + BurnToClaimDropERC721(payable(proxy)).initialize( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ); + } + + function test_initialize_event_RoleGranted_DefaultAdmin() public whenNotImplementation whenProxyNotInitialized { + bytes32 _defaultAdminRole = bytes32(0x00); + vm.prank(deployer); + vm.expectEmit(true, true, true, false); + emit RoleGranted(_defaultAdminRole, deployer, deployer); + BurnToClaimDropERC721(payable(proxy)).initialize( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ); + } + + function test_initialize_event_RoleGranted_MinterRole() public whenNotImplementation whenProxyNotInitialized { + bytes32 _minterRole = keccak256("MINTER_ROLE"); + vm.prank(deployer); + vm.expectEmit(true, true, true, false); + emit RoleGranted(_minterRole, deployer, deployer); + BurnToClaimDropERC721(payable(proxy)).initialize( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ); + } + + function test_initialize_event_RoleGranted_TransferRole() public whenNotImplementation whenProxyNotInitialized { + bytes32 _transferRole = keccak256("TRANSFER_ROLE"); + vm.prank(deployer); + vm.expectEmit(true, true, true, false); + emit RoleGranted(_transferRole, deployer, deployer); + BurnToClaimDropERC721(payable(proxy)).initialize( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ); + } + + function test_initialize_event_RoleGranted_TransferRole_AddressZero() + public + whenNotImplementation + whenProxyNotInitialized + { + bytes32 _transferRole = keccak256("TRANSFER_ROLE"); + vm.prank(deployer); + vm.expectEmit(true, true, true, false); + emit RoleGranted(_transferRole, address(0), deployer); + BurnToClaimDropERC721(payable(proxy)).initialize( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ); + } + + function test_initialize_event_RoleGranted_ExtensionRole() public whenNotImplementation whenProxyNotInitialized { + bytes32 _extensionRole = keccak256("EXTENSION_ROLE"); + vm.prank(deployer); + vm.expectEmit(true, true, true, false); + emit RoleGranted(_extensionRole, deployer, deployer); + BurnToClaimDropERC721(payable(proxy)).initialize( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ); + } + + function test_initialize_event_RoleAdminChanged_ExtensionRole() + public + whenNotImplementation + whenProxyNotInitialized + { + bytes32 _extensionRole = keccak256("EXTENSION_ROLE"); + vm.prank(deployer); + vm.expectEmit(true, true, true, false); + emit RoleAdminChanged(_extensionRole, bytes32(0x00), _extensionRole); + BurnToClaimDropERC721(payable(proxy)).initialize( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ); + } + + function test_initialize_event_PlatformFeeInfoUpdated() public whenNotImplementation whenProxyNotInitialized { + vm.prank(deployer); + vm.expectEmit(true, false, false, true); + emit PlatformFeeInfoUpdated(platformFeeRecipient, platformFeeBps); + BurnToClaimDropERC721(payable(proxy)).initialize( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ); + } + + function test_initialize_event_DefaultRoyalty() public whenNotImplementation whenProxyNotInitialized { + vm.prank(deployer); + vm.expectEmit(true, false, false, true); + emit DefaultRoyalty(royaltyRecipient, royaltyBps); + BurnToClaimDropERC721(payable(proxy)).initialize( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ); + } + + function test_initialize_event_PrimarySaleRecipientUpdated() public whenNotImplementation whenProxyNotInitialized { + vm.prank(deployer); + vm.expectEmit(true, false, false, false); + emit PrimarySaleRecipientUpdated(saleRecipient); + BurnToClaimDropERC721(payable(proxy)).initialize( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ); + } +} diff --git a/src/test/burn-to-claim-drop-BTT/router/initialize/initialize.tree b/src/test/burn-to-claim-drop-BTT/router/initialize/initialize.tree new file mode 100644 index 000000000..295d65120 --- /dev/null +++ b/src/test/burn-to-claim-drop-BTT/router/initialize/initialize.tree @@ -0,0 +1,44 @@ +initialize( + address _defaultAdmin, + string memory _name, + string memory _symbol, + string memory _contractURI, + address[] memory _trustedForwarders, + address _saleRecipient, + address _royaltyRecipient, + uint128 _royaltyBps, + uint128 _platformFeeBps, + address _platformFeeRecipient +) +├── when it is the implementation contract (not proxy) +│ └── it should revert ✅ +└── when it is a proxy to the implementation + └── when it is already initialized + │ └── it should revert ✅ + └── when it is not initialized + └── it should initialize base-router with default extensions if any ✅ + └── it should set trustedForwarder mapping to true for all addresses in `_trustedForwarders` ✅ + └── it should set _name and _symbol to `_name` and `_symbol` param values respectively ✅ + └── it should set contractURI to `_contractURI` param value ✅ + └── it should emit ContractURIUpdated event ✅ + └── it should set _owner to `_defaultAdmin` param value ✅ + └── it should emit OwnerUpdated event ✅ + └── it should grant 0x00 (DEFAULT_ADMIN_ROLE) to `_defaultAdmin` address ✅ + └── it should emit RoleGranted event ✅ + └── it should grant MINTER_ROLE to `_defaultAdmin` address ✅ + └── it should emit RoleGranted event ✅ + └── it should grant TRANSFER_ROLE to `_defaultAdmin` address ✅ + └── it should emit RoleGranted event ✅ + └── it should grant TRANSFER_ROLE to address(0) ✅ + └── it should emit RoleGranted event ✅ + └── it should grant EXTENSION_ROLE to `_defaultAdmin` address ✅ + └── it should emit RoleGranted event ✅ + └── it should set EXTENSION_ROLE as role admin for EXTENSION_ROLE ✅ + └── it should emit RoleAdminChanged event ✅ + └── it should set platformFeeRecipient and platformFeeBps as `_platformFeeRecipient` and `_platformFeeBps` respectively ✅ + └── it should emit PlatformFeeInfoUpdated event ✅ + └── it should set royaltyRecipient and royaltyBps as `_royaltyRecipient` and `_royaltyBps` respectively ✅ + └── it should emit DefaultRoyalty event ✅ + └── it should set primary sale recipient as `_saleRecipient` param value ✅ + └── it should emit PrimarySaleRecipientUpdated event ✅ + diff --git a/src/test/burn-to-claim-drop-BTT/router/other-functions/other.t.sol b/src/test/burn-to-claim-drop-BTT/router/other-functions/other.t.sol new file mode 100644 index 000000000..d9cdb0656 --- /dev/null +++ b/src/test/burn-to-claim-drop-BTT/router/other-functions/other.t.sol @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "../../../utils/BaseTest.sol"; +import { BurnToClaimDropERC721 } from "contracts/prebuilts/unaudited/burn-to-claim-drop/BurnToClaimDropERC721.sol"; + +import "@thirdweb-dev/dynamic-contracts/src/interface/IExtension.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +contract BurnToClaimDropERC721Router is BurnToClaimDropERC721 { + constructor(Extension[] memory _extensions) BurnToClaimDropERC721(_extensions) {} + + function isAuthorizedCallToUpgrade() public view returns (bool) { + return _isAuthorizedCallToUpgrade(); + } +} + +contract BurnToClaimDropERC721_OtherFunctions is BaseTest, IExtension { + address public implementation; + address public proxy; + + function setUp() public override { + super.setUp(); + + // Deploy implementation. + Extension[] memory extensions; + implementation = address(new BurnToClaimDropERC721Router(extensions)); + + // Deploy proxy pointing to implementaion. + vm.prank(deployer); + proxy = address( + new TWProxy( + implementation, + abi.encodeCall( + BurnToClaimDropERC721.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ) + ); + } + + function test_contractType() public { + BurnToClaimDropERC721Router router = BurnToClaimDropERC721Router(payable(proxy)); + assertEq(router.contractType(), bytes32("BurnToClaimDropERC721")); + } + + function test_contractVersion() public { + BurnToClaimDropERC721Router router = BurnToClaimDropERC721Router(payable(proxy)); + assertEq(router.contractVersion(), uint8(5)); + } + + function test_isAuthorizedCallToUpgrade_notExtensionRole() public { + BurnToClaimDropERC721Router router = BurnToClaimDropERC721Router(payable(proxy)); + assertFalse(router.isAuthorizedCallToUpgrade()); + } + + modifier whenExtensionRole() { + _; + } + + function test_isAuthorizedCallToUpgrade() public whenExtensionRole { + BurnToClaimDropERC721Router router = BurnToClaimDropERC721Router(payable(proxy)); + + vm.prank(deployer); + assertTrue(router.isAuthorizedCallToUpgrade()); + } +} diff --git a/src/test/burn-to-claim-drop-BTT/router/other-functions/other.tree b/src/test/burn-to-claim-drop-BTT/router/other-functions/other.tree new file mode 100644 index 000000000..98e8df4fe --- /dev/null +++ b/src/test/burn-to-claim-drop-BTT/router/other-functions/other.tree @@ -0,0 +1,12 @@ +contractType() +├── it should return bytes32("BurnToClaimDropERC721") ✅ + +contractVersion() +├── it should return uint8(5) ✅ + +_isAuthorizedCallToUpgrade() +├── when the caller doesn't have EXTENSION_ROLE +│ └── it should revert ✅ +└── when the caller has EXTENSION_ROLE + └── it should return true ✅ + diff --git a/src/test/burn-to-claim-drop/BurnToClaimDropERC721.t.sol b/src/test/burn-to-claim-drop/BurnToClaimDropERC721.t.sol new file mode 100644 index 000000000..036cd243f --- /dev/null +++ b/src/test/burn-to-claim-drop/BurnToClaimDropERC721.t.sol @@ -0,0 +1,1883 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "../utils/BaseTest.sol"; +import { BurnToClaimDropERC721 } from "contracts/prebuilts/unaudited/burn-to-claim-drop/BurnToClaimDropERC721.sol"; +import { BurnToClaimDrop721Logic, ERC721AUpgradeable, DelayedReveal, LazyMint, Drop, BurnToClaim, PrimarySale, PlatformFee } from "contracts/prebuilts/unaudited/burn-to-claim-drop/extension/BurnToClaimDrop721Logic.sol"; +import { PermissionsEnumerableImpl } from "contracts/extension/upgradeable/impl/PermissionsEnumerableImpl.sol"; +import { Royalty } from "contracts/extension/upgradeable/Royalty.sol"; +import { BatchMintMetadata } from "contracts/extension/upgradeable/BatchMintMetadata.sol"; +import { IBurnToClaim } from "contracts/extension/interface/IBurnToClaim.sol"; + +import "@thirdweb-dev/dynamic-contracts/src/interface/IExtension.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +// Test imports +import "erc721a-upgradeable/contracts/IERC721AUpgradeable.sol"; + +contract BurnToClaimDropERC721Test is BaseTest, IExtension { + using Strings for uint256; + using Strings for address; + + event TokensLazyMinted(uint256 indexed startTokenId, uint256 endTokenId, string baseURI, bytes encryptedBaseURI); + event TokenURIRevealed(uint256 indexed index, string revealedURI); + + BurnToClaimDrop721Logic public drop; + + bytes private emptyEncodedBytes = abi.encode("", ""); + + using stdStorage for StdStorage; + + function setUp() public override { + super.setUp(); + + // Deploy implementation. + Extension[] memory extensions = _setupExtensions(); + address dropImpl = address(new BurnToClaimDropERC721(extensions)); + + // Deploy proxy pointing to implementaion. + vm.prank(deployer); + drop = BurnToClaimDrop721Logic( + payable( + address( + new TWProxy( + dropImpl, + abi.encodeCall( + BurnToClaimDropERC721.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ) + ) + ) + ); + + erc20.mint(deployer, 1_000 ether); + vm.deal(deployer, 1_000 ether); + } + + function _setupExtensions() internal returns (Extension[] memory extensions) { + extensions = new Extension[](2); + + // Extension: Permissions + address permissions = address(new PermissionsEnumerableImpl()); + + Extension memory extension_permissions; + extension_permissions.metadata = ExtensionMetadata({ + name: "Permissions", + metadataURI: "ipfs://Permissions", + implementation: permissions + }); + + extension_permissions.functions = new ExtensionFunction[](7); + extension_permissions.functions[0] = ExtensionFunction( + Permissions.hasRole.selector, + "hasRole(bytes32,address)" + ); + extension_permissions.functions[1] = ExtensionFunction( + Permissions.hasRoleWithSwitch.selector, + "hasRoleWithSwitch(bytes32,address)" + ); + extension_permissions.functions[2] = ExtensionFunction( + Permissions.grantRole.selector, + "grantRole(bytes32,address)" + ); + extension_permissions.functions[3] = ExtensionFunction( + Permissions.renounceRole.selector, + "renounceRole(bytes32,address)" + ); + extension_permissions.functions[4] = ExtensionFunction( + Permissions.revokeRole.selector, + "revokeRole(bytes32,address)" + ); + extension_permissions.functions[5] = ExtensionFunction( + PermissionsEnumerable.getRoleMemberCount.selector, + "getRoleMemberCount(bytes32)" + ); + extension_permissions.functions[6] = ExtensionFunction( + PermissionsEnumerable.getRoleMember.selector, + "getRoleMember(bytes32,uint256)" + ); + + extensions[0] = extension_permissions; + + address dropLogic = address(new BurnToClaimDrop721Logic()); + + Extension memory extension_drop; + extension_drop.metadata = ExtensionMetadata({ + name: "BurnToClaimDrop721Logic", + metadataURI: "ipfs://BurnToClaimDrop721Logic", + implementation: dropLogic + }); + + extension_drop.functions = new ExtensionFunction[](32); + extension_drop.functions[0] = ExtensionFunction(BurnToClaimDrop721Logic.tokenURI.selector, "tokenURI(uint256)"); + extension_drop.functions[1] = ExtensionFunction( + BurnToClaimDrop721Logic.lazyMint.selector, + "lazyMint(uint256,string,bytes)" + ); + extension_drop.functions[2] = ExtensionFunction( + BurnToClaimDrop721Logic.reveal.selector, + "reveal(uint256,bytes)" + ); + extension_drop.functions[3] = ExtensionFunction(Drop.claimCondition.selector, "claimCondition()"); + extension_drop.functions[4] = ExtensionFunction( + BatchMintMetadata.getBaseURICount.selector, + "getBaseURICount()" + ); + extension_drop.functions[5] = ExtensionFunction( + Drop.claim.selector, + "claim(address,uint256,address,uint256,(bytes32[],uint256,uint256,address),bytes)" + ); + extension_drop.functions[6] = ExtensionFunction( + Drop.setClaimConditions.selector, + "setClaimConditions((uint256,uint256,uint256,uint256,bytes32,uint256,address,string)[],bool)" + ); + extension_drop.functions[7] = ExtensionFunction( + Drop.getActiveClaimConditionId.selector, + "getActiveClaimConditionId()" + ); + extension_drop.functions[8] = ExtensionFunction( + Drop.getClaimConditionById.selector, + "getClaimConditionById(uint256)" + ); + extension_drop.functions[9] = ExtensionFunction( + Drop.getSupplyClaimedByWallet.selector, + "getSupplyClaimedByWallet(uint256,address)" + ); + extension_drop.functions[10] = ExtensionFunction(BurnToClaimDrop721Logic.totalMinted.selector, "totalMinted()"); + extension_drop.functions[11] = ExtensionFunction( + BurnToClaimDrop721Logic.nextTokenIdToMint.selector, + "nextTokenIdToMint()" + ); + extension_drop.functions[12] = ExtensionFunction( + IERC721Upgradeable.setApprovalForAll.selector, + "setApprovalForAll(address,bool)" + ); + extension_drop.functions[13] = ExtensionFunction( + IERC721Upgradeable.approve.selector, + "approve(address,uint256)" + ); + extension_drop.functions[14] = ExtensionFunction( + IERC721Upgradeable.transferFrom.selector, + "transferFrom(address,address,uint256)" + ); + extension_drop.functions[15] = ExtensionFunction(ERC721AUpgradeable.balanceOf.selector, "balanceOf(address)"); + extension_drop.functions[16] = ExtensionFunction( + DelayedReveal.encryptDecrypt.selector, + "encryptDecrypt(bytes,bytes)" + ); + extension_drop.functions[17] = ExtensionFunction( + BurnToClaimDrop721Logic.supportsInterface.selector, + "supportsInterface(bytes4)" + ); + extension_drop.functions[18] = ExtensionFunction(Royalty.royaltyInfo.selector, "royaltyInfo(uint256,uint256)"); + extension_drop.functions[19] = ExtensionFunction( + Royalty.getRoyaltyInfoForToken.selector, + "getRoyaltyInfoForToken(uint256)" + ); + extension_drop.functions[20] = ExtensionFunction( + Royalty.getDefaultRoyaltyInfo.selector, + "getDefaultRoyaltyInfo()" + ); + extension_drop.functions[21] = ExtensionFunction( + Royalty.setDefaultRoyaltyInfo.selector, + "setDefaultRoyaltyInfo(address,uint256)" + ); + extension_drop.functions[22] = ExtensionFunction( + Royalty.setRoyaltyInfoForToken.selector, + "setRoyaltyInfoForToken(uint256,address,uint256)" + ); + extension_drop.functions[23] = ExtensionFunction(IERC721.ownerOf.selector, "ownerOf(uint256)"); + extension_drop.functions[24] = ExtensionFunction(IERC1155.balanceOf.selector, "balanceOf(address,uint256)"); + extension_drop.functions[25] = ExtensionFunction( + BurnToClaim.setBurnToClaimInfo.selector, + "setBurnToClaimInfo((address,uint8,uint256,uint256,address))" + ); + extension_drop.functions[26] = ExtensionFunction( + BurnToClaim.getBurnToClaimInfo.selector, + "getBurnToClaimInfo()" + ); + extension_drop.functions[27] = ExtensionFunction( + BurnToClaim.verifyBurnToClaim.selector, + "verifyBurnToClaim(address,uint256,uint256)" + ); + extension_drop.functions[28] = ExtensionFunction( + BurnToClaimDrop721Logic.burnAndClaim.selector, + "burnAndClaim(uint256,uint256)" + ); + extension_drop.functions[29] = ExtensionFunction( + BurnToClaimDrop721Logic.nextTokenIdToClaim.selector, + "nextTokenIdToClaim()" + ); + extension_drop.functions[30] = ExtensionFunction( + PrimarySale.setPrimarySaleRecipient.selector, + "setPrimarySaleRecipient(address)" + ); + extension_drop.functions[31] = ExtensionFunction( + PlatformFee.setPlatformFeeInfo.selector, + "setPlatformFeeInfo(address,uint256)" + ); + + extensions[1] = extension_drop; + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: misc. + //////////////////////////////////////////////////////////////*/ + + /** + * note: Tests whether contract reverts when a non-holder renounces a role. + */ + function test_revert_nonHolder_renounceRole() public { + address caller = address(0x123); + bytes32 role = keccak256("MINTER_ROLE"); + + vm.prank(caller); + vm.expectRevert( + abi.encodePacked( + "Permissions: account ", + Strings.toHexString(uint160(caller), 20), + " is missing role ", + Strings.toHexString(uint256(role), 32) + ) + ); + + Permissions(address(drop)).renounceRole(role, caller); + } + + /** + * note: Tests whether contract reverts when a role admin revokes a role for a non-holder. + */ + function test_revert_revokeRoleForNonHolder() public { + address target = address(0x123); + bytes32 role = keccak256("MINTER_ROLE"); + + vm.prank(deployer); + vm.expectRevert( + abi.encodePacked( + "Permissions: account ", + Strings.toHexString(uint160(target), 20), + " is missing role ", + Strings.toHexString(uint256(role), 32) + ) + ); + + Permissions(address(drop)).revokeRole(role, target); + } + + /** + * @dev Tests whether contract reverts when a role is granted to an existent role holder. + */ + function test_revert_grant_role_to_account_with_role() public { + bytes32 role = keccak256("ABC_ROLE"); + address receiver = getActor(0); + + vm.startPrank(deployer); + + Permissions(address(drop)).grantRole(role, receiver); + + vm.expectRevert("Can only grant to non holders"); + Permissions(address(drop)).grantRole(role, receiver); + + vm.stopPrank(); + } + + /** + * @dev Tests contract state for Transfer role. + */ + function test_state_grant_transferRole() public { + bytes32 role = keccak256("TRANSFER_ROLE"); + + // check if admin and address(0) have transfer role in the beginning + bool checkAddressZero = Permissions(address(drop)).hasRole(role, address(0)); + bool checkAdmin = Permissions(address(drop)).hasRole(role, deployer); + assertTrue(checkAddressZero); + assertTrue(checkAdmin); + + // check if transfer role can be granted to a non-holder + address receiver = getActor(0); + vm.startPrank(deployer); + Permissions(address(drop)).grantRole(role, receiver); + + // expect revert when granting to a holder + vm.expectRevert("Can only grant to non holders"); + Permissions(address(drop)).grantRole(role, receiver); + + // check if receiver has transfer role + bool checkReceiver = Permissions(address(drop)).hasRole(role, receiver); + assertTrue(checkReceiver); + + // check if role is correctly revoked + Permissions(address(drop)).revokeRole(role, receiver); + checkReceiver = Permissions(address(drop)).hasRole(role, receiver); + assertFalse(checkReceiver); + Permissions(address(drop)).revokeRole(role, address(0)); + checkAddressZero = Permissions(address(drop)).hasRole(role, address(0)); + assertFalse(checkAddressZero); + + vm.stopPrank(); + } + + /** + * @dev Tests contract state for Transfer role. + */ + function test_state_getRoleMember_transferRole() public { + bytes32 role = keccak256("TRANSFER_ROLE"); + + uint256 roleMemberCount = PermissionsEnumerable(address(drop)).getRoleMemberCount(role); + assertEq(roleMemberCount, 2); + + address roleMember = PermissionsEnumerable(address(drop)).getRoleMember(role, 1); + assertEq(roleMember, address(0)); + + vm.startPrank(deployer); + Permissions(address(drop)).grantRole(role, address(2)); + Permissions(address(drop)).grantRole(role, address(3)); + Permissions(address(drop)).grantRole(role, address(4)); + + roleMemberCount = PermissionsEnumerable(address(drop)).getRoleMemberCount(role); + console.log(roleMemberCount); + for (uint256 i = 0; i < roleMemberCount; i++) { + console.log(PermissionsEnumerable(address(drop)).getRoleMember(role, i)); + } + console.log(""); + + Permissions(address(drop)).revokeRole(role, address(2)); + roleMemberCount = PermissionsEnumerable(address(drop)).getRoleMemberCount(role); + console.log(roleMemberCount); + for (uint256 i = 0; i < roleMemberCount; i++) { + console.log(PermissionsEnumerable(address(drop)).getRoleMember(role, i)); + } + console.log(""); + + Permissions(address(drop)).revokeRole(role, address(0)); + roleMemberCount = PermissionsEnumerable(address(drop)).getRoleMemberCount(role); + console.log(roleMemberCount); + for (uint256 i = 0; i < roleMemberCount; i++) { + console.log(PermissionsEnumerable(address(drop)).getRoleMember(role, i)); + } + console.log(""); + + Permissions(address(drop)).grantRole(role, address(5)); + roleMemberCount = PermissionsEnumerable(address(drop)).getRoleMemberCount(role); + console.log(roleMemberCount); + for (uint256 i = 0; i < roleMemberCount; i++) { + console.log(PermissionsEnumerable(address(drop)).getRoleMember(role, i)); + } + console.log(""); + + Permissions(address(drop)).grantRole(role, address(0)); + roleMemberCount = PermissionsEnumerable(address(drop)).getRoleMemberCount(role); + console.log(roleMemberCount); + for (uint256 i = 0; i < roleMemberCount; i++) { + console.log(PermissionsEnumerable(address(drop)).getRoleMember(role, i)); + } + console.log(""); + + Permissions(address(drop)).grantRole(role, address(6)); + roleMemberCount = PermissionsEnumerable(address(drop)).getRoleMemberCount(role); + console.log(roleMemberCount); + for (uint256 i = 0; i < roleMemberCount; i++) { + console.log(PermissionsEnumerable(address(drop)).getRoleMember(role, i)); + } + console.log(""); + } + + /** + * note: Testing transfer of tokens when transfer-role is restricted + */ + function test_claim_transferRole() public { + vm.warp(1); + + address receiver = getActor(0); + bytes32[] memory proofs = new bytes32[](0); + + BurnToClaimDrop721Logic.AllowlistProof memory alp; + alp.proof = proofs; + + BurnToClaimDrop721Logic.ClaimCondition[] memory conditions = new BurnToClaimDrop721Logic.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 100; + conditions[0].quantityLimitPerWallet = 100; + + vm.prank(deployer); + drop.lazyMint(100, "ipfs://", emptyEncodedBytes); + vm.prank(deployer); + drop.setClaimConditions(conditions, false); + + vm.prank(getActor(5), getActor(5)); + drop.claim(receiver, 1, address(0), 0, alp, ""); + + // revoke transfer role from address(0) + vm.prank(deployer); + Permissions(address(drop)).revokeRole(keccak256("TRANSFER_ROLE"), address(0)); + vm.startPrank(receiver); + vm.expectRevert("!Transfer-Role"); + drop.transferFrom(receiver, address(123), 0); + } + + /** + * @dev Tests whether role member count is incremented correctly. + */ + function test_member_count_incremented_properly_when_role_granted() public { + bytes32 role = keccak256("ABC_ROLE"); + address receiver = getActor(0); + + vm.startPrank(deployer); + uint256 roleMemberCount = PermissionsEnumerable(address(drop)).getRoleMemberCount(role); + + assertEq(roleMemberCount, 0); + + Permissions(address(drop)).grantRole(role, receiver); + + assertEq(PermissionsEnumerable(address(drop)).getRoleMemberCount(role), 1); + + vm.stopPrank(); + } + + function test_claimCondition_with_startTimestamp() public { + vm.warp(1); + + address receiver = getActor(0); + bytes32[] memory proofs = new bytes32[](0); + + BurnToClaimDrop721Logic.AllowlistProof memory alp; + alp.proof = proofs; + + BurnToClaimDrop721Logic.ClaimCondition[] memory conditions = new BurnToClaimDrop721Logic.ClaimCondition[](1); + conditions[0].startTimestamp = 100; + conditions[0].maxClaimableSupply = 100; + conditions[0].quantityLimitPerWallet = 100; + + vm.prank(deployer); + drop.lazyMint(100, "ipfs://", emptyEncodedBytes); + + vm.prank(deployer); + drop.setClaimConditions(conditions, false); + + vm.warp(99); + vm.prank(getActor(5), getActor(5)); + vm.expectRevert("!CONDITION."); + drop.claim(receiver, 1, address(0), 0, alp, ""); + + vm.warp(100); + vm.prank(getActor(4), getActor(4)); + drop.claim(receiver, 1, address(0), 0, alp, ""); + } + + /*/////////////////////////////////////////////////////////////// + Primary sale and Platform fee tests + //////////////////////////////////////////////////////////////*/ + + /// note: Test whether transaction reverts when adding address(0) as primary sale recipient at deploy time + function test_revert_deploy_emptyPrimarySaleRecipient() public { + // Deploy implementation. + Extension[] memory extensions = _setupExtensions(); + address dropImpl = address(new BurnToClaimDropERC721(extensions)); + + // Deploy proxy pointing to implementaion. + vm.prank(deployer); + vm.expectRevert("Invalid recipient"); + drop = BurnToClaimDrop721Logic( + payable( + address( + new TWProxy( + dropImpl, + abi.encodeCall( + BurnToClaimDropERC721.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + address(0), + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ) + ) + ) + ); + } + + /// note: Test whether transaction reverts when adding address(0) as primary sale recipient + function test_revert_emptyPrimarySaleRecipient() public { + vm.prank(deployer); + vm.expectRevert("Invalid recipient"); + drop.setPrimarySaleRecipient(address(0)); + } + + /// note: Test whether transaction reverts when adding address(0) as platform fee recipient at deploy time + function test_revert_deploy_emptyPlatformFeeRecipient() public { + // Deploy implementation. + Extension[] memory extensions = _setupExtensions(); + address dropImpl = address(new BurnToClaimDropERC721(extensions)); + + // Deploy proxy pointing to implementaion. + vm.prank(deployer); + vm.expectRevert("Invalid recipient"); + drop = BurnToClaimDrop721Logic( + payable( + address( + new TWProxy( + dropImpl, + abi.encodeCall( + BurnToClaimDropERC721.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + address(0) + ) + ) + ) + ) + ) + ); + } + + /// note: Test whether transaction reverts when adding address(0) as platform fee recipient + function test_revert_emptyPlatformFeeRecipient() public { + vm.prank(deployer); + vm.expectRevert("Invalid recipient"); + drop.setPlatformFeeInfo(address(0), 100); + } + + /*/////////////////////////////////////////////////////////////// + Lazy Mint Tests + //////////////////////////////////////////////////////////////*/ + + /* + * note: Testing state changes; lazy mint a batch of tokens with no encrypted base URI. + */ + function test_state_lazyMint_noEncryptedURI() public { + uint256 amountToLazyMint = 100; + string memory baseURI = "ipfs://"; + + uint256 nextTokenIdToMintBefore = drop.nextTokenIdToMint(); + + vm.startPrank(deployer); + uint256 batchId = drop.lazyMint(amountToLazyMint, baseURI, emptyEncodedBytes); + + assertEq(nextTokenIdToMintBefore + amountToLazyMint, drop.nextTokenIdToMint()); + assertEq(nextTokenIdToMintBefore + amountToLazyMint, batchId); + + for (uint256 i = 0; i < amountToLazyMint; i += 1) { + string memory uri = drop.tokenURI(i); + console.log(uri); + assertEq(uri, string(abi.encodePacked(baseURI, i.toString()))); + } + + vm.stopPrank(); + } + + /* + * note: Testing state changes; lazy mint a batch of tokens with encrypted base URI. + */ + function test_state_lazyMint_withEncryptedURI() public { + uint256 amountToLazyMint = 100; + string memory baseURI = "ipfs://"; + bytes memory encryptedBaseURI = "encryptedBaseURI://"; + bytes32 provenanceHash = bytes32("whatever"); + + uint256 nextTokenIdToMintBefore = drop.nextTokenIdToMint(); + + vm.startPrank(deployer); + uint256 batchId = drop.lazyMint(amountToLazyMint, baseURI, abi.encode(encryptedBaseURI, provenanceHash)); + + assertEq(nextTokenIdToMintBefore + amountToLazyMint, drop.nextTokenIdToMint()); + assertEq(nextTokenIdToMintBefore + amountToLazyMint, batchId); + + for (uint256 i = 0; i < amountToLazyMint; i += 1) { + string memory uri = drop.tokenURI(i); + console.log(uri); + assertEq(uri, string(abi.encodePacked(baseURI, "0"))); + } + + vm.stopPrank(); + } + + /** + * note: Testing revert condition; an address without MINTER_ROLE calls lazyMint function. + */ + function test_revert_lazyMint_MINTER_ROLE() public { + vm.expectRevert("Not authorized"); + drop.lazyMint(100, "ipfs://", emptyEncodedBytes); + } + + /* + * note: Testing revert condition; calling tokenURI for invalid batch id. + */ + function test_revert_lazyMint_URIForNonLazyMintedToken() public { + vm.startPrank(deployer); + + drop.lazyMint(100, "ipfs://", emptyEncodedBytes); + + vm.expectRevert("Invalid tokenId"); + drop.tokenURI(100); + + vm.stopPrank(); + } + + /** + * note: Testing event emission; tokens lazy minted. + */ + function test_event_lazyMint_TokensLazyMinted() public { + vm.startPrank(deployer); + + vm.expectEmit(true, false, false, true); + emit TokensLazyMinted(0, 99, "ipfs://", emptyEncodedBytes); + drop.lazyMint(100, "ipfs://", emptyEncodedBytes); + + vm.stopPrank(); + } + + /* + * note: Fuzz testing state changes; lazy mint a batch of tokens with no encrypted base URI. + */ + function test_fuzz_lazyMint_noEncryptedURI(uint256 x) public { + vm.assume(x > 0); + + uint256 amountToLazyMint = x; + string memory baseURI = "ipfs://"; + + uint256 nextTokenIdToMintBefore = drop.nextTokenIdToMint(); + + vm.startPrank(deployer); + uint256 batchId = drop.lazyMint(amountToLazyMint, baseURI, emptyEncodedBytes); + + assertEq(nextTokenIdToMintBefore + amountToLazyMint, drop.nextTokenIdToMint()); + assertEq(nextTokenIdToMintBefore + amountToLazyMint, batchId); + + string memory uri = drop.tokenURI(0); + assertEq(uri, string(abi.encodePacked(baseURI, uint256(0).toString()))); + + uri = drop.tokenURI(x - 1); + assertEq(uri, string(abi.encodePacked(baseURI, uint256(x - 1).toString()))); + + /** + * note: this loop takes too long to run with fuzz tests. + */ + // for(uint256 i = 0; i < amountToLazyMint; i += 1) { + // string memory uri = drop.tokenURI(i); + // console.log(uri); + // assertEq(uri, string(abi.encodePacked(baseURI, i.toString()))); + // } + + vm.stopPrank(); + } + + /* + * note: Fuzz testing state changes; lazy mint a batch of tokens with encrypted base URI. + */ + function test_fuzz_lazyMint_withEncryptedURI(uint256 x) public { + vm.assume(x > 0); + + uint256 amountToLazyMint = x; + string memory baseURI = "ipfs://"; + bytes memory encryptedBaseURI = "encryptedBaseURI://"; + bytes32 provenanceHash = bytes32("whatever"); + + uint256 nextTokenIdToMintBefore = drop.nextTokenIdToMint(); + + vm.startPrank(deployer); + uint256 batchId = drop.lazyMint(amountToLazyMint, baseURI, abi.encode(encryptedBaseURI, provenanceHash)); + + assertEq(nextTokenIdToMintBefore + amountToLazyMint, drop.nextTokenIdToMint()); + assertEq(nextTokenIdToMintBefore + amountToLazyMint, batchId); + + string memory uri = drop.tokenURI(0); + assertEq(uri, string(abi.encodePacked(baseURI, "0"))); + + uri = drop.tokenURI(x - 1); + assertEq(uri, string(abi.encodePacked(baseURI, "0"))); + + /** + * note: this loop takes too long to run with fuzz tests. + */ + // for(uint256 i = 0; i < amountToLazyMint; i += 1) { + // string memory uri = drop.tokenURI(1); + // assertEq(uri, string(abi.encodePacked(baseURI, "0"))); + // } + + vm.stopPrank(); + } + + /* + * note: Fuzz testing; a batch of tokens, and nextTokenIdToMint + */ + function test_fuzz_lazyMint_batchMintAndNextTokenIdToMint(uint256 x) public { + vm.assume(x > 0); + vm.startPrank(deployer); + + if (x == 0) { + vm.expectRevert("Zero amount"); + } + drop.lazyMint(x, "ipfs://", emptyEncodedBytes); + + uint256 slot = stdstore.target(address(drop)).sig("nextTokenIdToMint()").find(); + bytes32 loc = bytes32(slot); + uint256 nextTokenIdToMint = uint256(vm.load(address(drop), loc)); + + assertEq(nextTokenIdToMint, x); + vm.stopPrank(); + } + + /*/////////////////////////////////////////////////////////////// + Delayed Reveal Tests + //////////////////////////////////////////////////////////////*/ + + /* + * note: Testing state changes; URI revealed for a batch of tokens. + */ + function test_state_reveal() public { + vm.startPrank(deployer); + + bytes memory key = "key"; + uint256 amountToLazyMint = 100; + bytes memory secretURI = "ipfs://"; + string memory placeholderURI = "abcd://"; + bytes memory encryptedURI = drop.encryptDecrypt(secretURI, key); + bytes32 provenanceHash = keccak256(abi.encodePacked(secretURI, key, block.chainid)); + + drop.lazyMint(amountToLazyMint, placeholderURI, abi.encode(encryptedURI, provenanceHash)); + + for (uint256 i = 0; i < amountToLazyMint; i += 1) { + string memory uri = drop.tokenURI(i); + assertEq(uri, string(abi.encodePacked(placeholderURI, "0"))); + } + + string memory revealedURI = drop.reveal(0, key); + assertEq(revealedURI, string(secretURI)); + + for (uint256 i = 0; i < amountToLazyMint; i += 1) { + string memory uri = drop.tokenURI(i); + assertEq(uri, string(abi.encodePacked(secretURI, i.toString()))); + } + + vm.stopPrank(); + } + + /** + * note: Testing revert condition; an address without MINTER_ROLE calls reveal function. + */ + function test_revert_reveal_MINTER_ROLE() public { + bytes memory key = "key"; + bytes memory encryptedURI = drop.encryptDecrypt("ipfs://", key); + bytes32 provenanceHash = keccak256(abi.encodePacked("ipfs://", key, block.chainid)); + vm.prank(deployer); + drop.lazyMint(100, "", abi.encode(encryptedURI, provenanceHash)); + + vm.prank(deployer); + drop.reveal(0, "key"); + + vm.expectRevert("not minter."); + drop.reveal(0, "key"); + } + + /* + * note: Testing revert condition; trying to reveal URI for non-existent batch. + */ + function test_revert_reveal_revealingNonExistentBatch() public { + vm.startPrank(deployer); + + bytes memory key = "key"; + bytes memory encryptedURI = drop.encryptDecrypt("ipfs://", key); + bytes32 provenanceHash = keccak256(abi.encodePacked("ipfs://", key, block.chainid)); + drop.lazyMint(100, "", abi.encode(encryptedURI, provenanceHash)); + drop.reveal(0, "key"); + + console.log(drop.getBaseURICount()); + + drop.lazyMint(100, "", abi.encode(encryptedURI, provenanceHash)); + vm.expectRevert("Invalid index"); + drop.reveal(2, "key"); + + vm.stopPrank(); + } + + /* + * note: Testing revert condition; already revealed URI. + */ + function test_revert_delayedReveal_alreadyRevealed() public { + vm.startPrank(deployer); + + bytes memory key = "key"; + bytes memory encryptedURI = drop.encryptDecrypt("ipfs://", key); + bytes32 provenanceHash = keccak256(abi.encodePacked("ipfs://", key, block.chainid)); + drop.lazyMint(100, "", abi.encode(encryptedURI, provenanceHash)); + drop.reveal(0, "key"); + + vm.expectRevert("Nothing to reveal"); + drop.reveal(0, "key"); + + vm.stopPrank(); + } + + /* + * note: Testing state changes; revealing URI with an incorrect key. + */ + function testFail_reveal_incorrectKey() public { + vm.startPrank(deployer); + + bytes memory key = "key"; + bytes memory encryptedURI = drop.encryptDecrypt("ipfs://", key); + bytes32 provenanceHash = keccak256(abi.encodePacked("ipfs://", key, block.chainid)); + drop.lazyMint(100, "", abi.encode(encryptedURI, provenanceHash)); + + string memory revealedURI = drop.reveal(0, "keyy"); + assertEq(revealedURI, "ipfs://"); + + vm.stopPrank(); + } + + /** + * note: Testing event emission; TokenURIRevealed. + */ + function test_event_reveal_TokenURIRevealed() public { + vm.startPrank(deployer); + + bytes memory key = "key"; + bytes memory encryptedURI = drop.encryptDecrypt("ipfs://", key); + bytes32 provenanceHash = keccak256(abi.encodePacked("ipfs://", key, block.chainid)); + drop.lazyMint(100, "", abi.encode(encryptedURI, provenanceHash)); + + vm.expectEmit(true, false, false, true); + emit TokenURIRevealed(0, "ipfs://"); + drop.reveal(0, "key"); + + vm.stopPrank(); + } + + /*/////////////////////////////////////////////////////////////// + Claim Tests + //////////////////////////////////////////////////////////////*/ + + /** + * note: Testing revert condition; not enough minted tokens. + */ + function test_revert_claimCondition_notEnoughMintedTokens() public { + vm.warp(1); + + address receiver = getActor(0); + bytes32[] memory proofs = new bytes32[](0); + + BurnToClaimDrop721Logic.AllowlistProof memory alp; + alp.proof = proofs; + + BurnToClaimDrop721Logic.ClaimCondition[] memory conditions = new BurnToClaimDrop721Logic.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 100; + conditions[0].quantityLimitPerWallet = 200; + + vm.prank(deployer); + drop.lazyMint(100, "ipfs://", emptyEncodedBytes); + vm.prank(deployer); + drop.setClaimConditions(conditions, false); + + vm.expectRevert("!Tokens"); + vm.prank(getActor(6), getActor(6)); + drop.claim(receiver, 101, address(0), 0, alp, ""); + } + + /** + * note: Testing revert condition; exceed max claimable supply. + */ + function test_revert_claimCondition_exceedMaxClaimableSupply() public { + vm.warp(1); + + address receiver = getActor(0); + bytes32[] memory proofs = new bytes32[](0); + + BurnToClaimDrop721Logic.AllowlistProof memory alp; + alp.proof = proofs; + + BurnToClaimDrop721Logic.ClaimCondition[] memory conditions = new BurnToClaimDrop721Logic.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 100; + conditions[0].quantityLimitPerWallet = 200; + + vm.prank(deployer); + drop.lazyMint(200, "ipfs://", emptyEncodedBytes); + vm.prank(deployer); + drop.setClaimConditions(conditions, false); + + vm.prank(getActor(5), getActor(5)); + drop.claim(receiver, 100, address(0), 0, alp, ""); + + vm.expectRevert("!MaxSupply"); + vm.prank(getActor(6), getActor(6)); + drop.claim(receiver, 1, address(0), 0, alp, ""); + } + + /** + * note: Testing quantity limit restriction when no allowlist present. + */ + function test_fuzz_claim_noAllowlist(uint256 x) public { + vm.assume(x != 0); + vm.warp(1); + + address receiver = getActor(0); + bytes32[] memory proofs = new bytes32[](0); + + BurnToClaimDrop721Logic.AllowlistProof memory alp; + alp.proof = proofs; + alp.quantityLimitPerWallet = x; + + BurnToClaimDrop721Logic.ClaimCondition[] memory conditions = new BurnToClaimDrop721Logic.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 500; + conditions[0].quantityLimitPerWallet = 100; + + vm.prank(deployer); + drop.lazyMint(500, "ipfs://", emptyEncodedBytes); + + vm.prank(deployer); + drop.setClaimConditions(conditions, false); + + bytes memory errorQty = "!Qty"; + + vm.prank(getActor(5), getActor(5)); + vm.expectRevert(errorQty); + drop.claim(receiver, 0, address(0), 0, alp, ""); + + vm.prank(getActor(5), getActor(5)); + vm.expectRevert(errorQty); + drop.claim(receiver, 101, address(0), 0, alp, ""); + + vm.prank(deployer); + drop.setClaimConditions(conditions, true); + + vm.prank(getActor(5), getActor(5)); + vm.expectRevert(errorQty); + drop.claim(receiver, 101, address(0), 0, alp, ""); + } + + /** + * note: Testing quantity limit restriction + * - allowlist quantity set to some value different than general limit + * - allowlist price set to 0 + */ + function test_state_claim_allowlisted_SetQuantityZeroPrice() public { + string[] memory inputs = new string[](5); + + inputs[0] = "node"; + inputs[1] = "src/test/scripts/generateRoot.ts"; + inputs[2] = "300"; + inputs[3] = "0"; + inputs[4] = Strings.toHexString(uint160(address(erc20))); // address of erc20 + + bytes memory result = vm.ffi(inputs); + // revert(); + bytes32 root = abi.decode(result, (bytes32)); + + inputs[1] = "src/test/scripts/getProof.ts"; + result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + BurnToClaimDrop721Logic.AllowlistProof memory alp; + alp.proof = proofs; + alp.quantityLimitPerWallet = 300; + alp.pricePerToken = 0; + alp.currency = address(erc20); + + vm.warp(1); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); // in allowlist + + BurnToClaimDrop721Logic.ClaimCondition[] memory conditions = new BurnToClaimDrop721Logic.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 500; + conditions[0].quantityLimitPerWallet = 10; + conditions[0].merkleRoot = root; + conditions[0].pricePerToken = 10; + conditions[0].currency = address(erc20); + + vm.prank(deployer); + drop.lazyMint(500, "ipfs://", emptyEncodedBytes); + vm.prank(deployer); + drop.setClaimConditions(conditions, false); + + // vm.prank(getActor(5), getActor(5)); + vm.prank(receiver, receiver); + drop.claim(receiver, 100, address(erc20), 0, alp, ""); // claims for free, because allowlist price is 0 + assertEq(drop.getSupplyClaimedByWallet(drop.getActiveClaimConditionId(), receiver), 100); + } + + /** + * note: Testing quantity limit restriction + * - allowlist quantity set to some value different than general limit + * - allowlist price set to non-zero value + */ + function test_state_claim_allowlisted_SetQuantityPrice() public { + string[] memory inputs = new string[](5); + + inputs[0] = "node"; + inputs[1] = "src/test/scripts/generateRoot.ts"; + inputs[2] = "300"; + inputs[3] = "5"; + inputs[4] = Strings.toHexString(uint160(address(erc20))); // address of erc20 + + bytes memory result = vm.ffi(inputs); + // revert(); + bytes32 root = abi.decode(result, (bytes32)); + + inputs[1] = "src/test/scripts/getProof.ts"; + result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + BurnToClaimDrop721Logic.AllowlistProof memory alp; + alp.proof = proofs; + alp.quantityLimitPerWallet = 300; + alp.pricePerToken = 5; + alp.currency = address(erc20); + + vm.warp(1); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); // in allowlist + + BurnToClaimDrop721Logic.ClaimCondition[] memory conditions = new BurnToClaimDrop721Logic.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 500; + conditions[0].quantityLimitPerWallet = 10; + conditions[0].merkleRoot = root; + conditions[0].pricePerToken = 10; + conditions[0].currency = address(erc20); + + vm.prank(deployer); + drop.lazyMint(500, "ipfs://", emptyEncodedBytes); + vm.prank(deployer); + drop.setClaimConditions(conditions, false); + + vm.prank(receiver, receiver); + vm.expectRevert("!PriceOrCurrency"); + drop.claim(receiver, 100, address(erc20), 0, alp, ""); + + erc20.mint(receiver, 10000); + vm.prank(receiver); + erc20.approve(address(drop), 10000); + + // vm.prank(getActor(5), getActor(5)); + vm.prank(receiver, receiver); + drop.claim(receiver, 100, address(erc20), 5, alp, ""); + assertEq(drop.getSupplyClaimedByWallet(drop.getActiveClaimConditionId(), receiver), 100); + assertEq(erc20.balanceOf(receiver), 10000 - 500); + } + + /** + * note: Testing quantity limit restriction + * - allowlist quantity set to some value different than general limit + * - allowlist price not set; should default to general price and currency + */ + function test_state_claim_allowlisted_SetQuantityDefaultPrice() public { + string[] memory inputs = new string[](5); + + inputs[0] = "node"; + inputs[1] = "src/test/scripts/generateRoot.ts"; + inputs[2] = "300"; + inputs[3] = Strings.toString(type(uint256).max); // this implies that general price is applicable + inputs[4] = "0x0000000000000000000000000000000000000000"; + + bytes memory result = vm.ffi(inputs); + // revert(); + bytes32 root = abi.decode(result, (bytes32)); + + inputs[1] = "src/test/scripts/getProof.ts"; + result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + BurnToClaimDrop721Logic.AllowlistProof memory alp; + alp.proof = proofs; + alp.quantityLimitPerWallet = 300; + alp.pricePerToken = type(uint256).max; + alp.currency = address(0); + + vm.warp(1); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); // in allowlist + + BurnToClaimDrop721Logic.ClaimCondition[] memory conditions = new BurnToClaimDrop721Logic.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 500; + conditions[0].quantityLimitPerWallet = 10; + conditions[0].merkleRoot = root; + conditions[0].pricePerToken = 10; + conditions[0].currency = address(erc20); + + vm.prank(deployer); + drop.lazyMint(500, "ipfs://", emptyEncodedBytes); + vm.prank(deployer); + drop.setClaimConditions(conditions, false); + + erc20.mint(receiver, 10000); + vm.prank(receiver); + erc20.approve(address(drop), 10000); + + // vm.prank(getActor(5), getActor(5)); + vm.prank(receiver, receiver); + drop.claim(receiver, 100, address(erc20), 10, alp, ""); + assertEq(drop.getSupplyClaimedByWallet(drop.getActiveClaimConditionId(), receiver), 100); + assertEq(erc20.balanceOf(receiver), 10000 - 1000); + } + + /** + * note: Testing quantity limit restriction + * - allowlist quantity set to 0 => should default to general limit + * - allowlist price set to some value different than general price + */ + function test_state_claim_allowlisted_DefaultQuantitySomePrice() public { + string[] memory inputs = new string[](5); + + inputs[0] = "node"; + inputs[1] = "src/test/scripts/generateRoot.ts"; + inputs[2] = "0"; // this implies that general limit is applicable + inputs[3] = "5"; + inputs[4] = "0x0000000000000000000000000000000000000000"; // general currency will be applicable + + bytes memory result = vm.ffi(inputs); + // revert(); + bytes32 root = abi.decode(result, (bytes32)); + + inputs[1] = "src/test/scripts/getProof.ts"; + result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + BurnToClaimDrop721Logic.AllowlistProof memory alp; + alp.proof = proofs; + alp.quantityLimitPerWallet = 0; + alp.pricePerToken = 5; + alp.currency = address(0); + + vm.warp(1); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); // in allowlist + + BurnToClaimDrop721Logic.ClaimCondition[] memory conditions = new BurnToClaimDrop721Logic.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 500; + conditions[0].quantityLimitPerWallet = 10; + conditions[0].merkleRoot = root; + conditions[0].pricePerToken = 10; + conditions[0].currency = address(erc20); + + vm.prank(deployer); + drop.lazyMint(500, "ipfs://", emptyEncodedBytes); + vm.prank(deployer); + drop.setClaimConditions(conditions, false); + + erc20.mint(receiver, 10000); + vm.prank(receiver); + erc20.approve(address(drop), 10000); + + bytes memory errorQty = "!Qty"; + vm.prank(receiver, receiver); + vm.expectRevert(errorQty); + drop.claim(receiver, 100, address(erc20), 5, alp, ""); // trying to claim more than general limit + + // vm.prank(getActor(5), getActor(5)); + vm.prank(receiver, receiver); + drop.claim(receiver, 10, address(erc20), 5, alp, ""); + assertEq(drop.getSupplyClaimedByWallet(drop.getActiveClaimConditionId(), receiver), 10); + assertEq(erc20.balanceOf(receiver), 10000 - 50); + } + + function test_fuzz_claim_merkleProof(uint256 x) public { + vm.assume(x > 10 && x < 500); + string[] memory inputs = new string[](5); + + inputs[0] = "node"; + inputs[1] = "src/test/scripts/generateRoot.ts"; + inputs[2] = Strings.toString(x); + inputs[3] = "0"; + inputs[4] = "0x0000000000000000000000000000000000000000"; + + bytes memory result = vm.ffi(inputs); + // revert(); + bytes32 root = abi.decode(result, (bytes32)); + + inputs[1] = "src/test/scripts/getProof.ts"; + result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + BurnToClaimDrop721Logic.AllowlistProof memory alp; + alp.proof = proofs; + alp.quantityLimitPerWallet = x; + alp.pricePerToken = 0; + alp.currency = address(0); + + vm.warp(1); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); + + // bytes32[] memory proofs = new bytes32[](0); + + BurnToClaimDrop721Logic.ClaimCondition[] memory conditions = new BurnToClaimDrop721Logic.ClaimCondition[](1); + conditions[0].maxClaimableSupply = x; + conditions[0].quantityLimitPerWallet = 1; + conditions[0].merkleRoot = root; + + vm.prank(deployer); + drop.lazyMint(2 * x, "ipfs://", emptyEncodedBytes); + vm.prank(deployer); + drop.setClaimConditions(conditions, false); + + // vm.prank(getActor(5), getActor(5)); + vm.prank(receiver, receiver); + drop.claim(receiver, x - 5, address(0), 0, alp, ""); + assertEq(drop.getSupplyClaimedByWallet(drop.getActiveClaimConditionId(), receiver), x - 5); + + bytes memory errorQty = "!Qty"; + + vm.prank(receiver, receiver); + vm.expectRevert(errorQty); + drop.claim(receiver, 6, address(0), 0, alp, ""); + + vm.prank(receiver, receiver); + drop.claim(receiver, 5, address(0), 0, alp, ""); + assertEq(drop.getSupplyClaimedByWallet(drop.getActiveClaimConditionId(), receiver), x); + + vm.prank(receiver, receiver); + vm.expectRevert(errorQty); + drop.claim(receiver, 5, address(0), 0, alp, ""); // quantity limit already claimed + } + + /** + * note: Testing state changes; reset eligibility of claim conditions and claiming again for same condition id. + */ + function test_state_claimCondition_resetEligibility() public { + vm.warp(1); + + address receiver = getActor(0); + bytes32[] memory proofs = new bytes32[](0); + + BurnToClaimDrop721Logic.AllowlistProof memory alp; + alp.proof = proofs; + + BurnToClaimDrop721Logic.ClaimCondition[] memory conditions = new BurnToClaimDrop721Logic.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 500; + conditions[0].quantityLimitPerWallet = 100; + + vm.prank(deployer); + drop.lazyMint(500, "ipfs://", emptyEncodedBytes); + + vm.prank(deployer); + drop.setClaimConditions(conditions, false); + + vm.prank(getActor(5), getActor(5)); + drop.claim(receiver, 100, address(0), 0, alp, ""); + + bytes memory errorQty = "!Qty"; + + vm.prank(getActor(5), getActor(5)); + vm.expectRevert(errorQty); + drop.claim(receiver, 100, address(0), 0, alp, ""); + + vm.prank(deployer); + drop.setClaimConditions(conditions, true); + + vm.prank(getActor(5), getActor(5)); + drop.claim(receiver, 100, address(0), 0, alp, ""); + } + + /*/////////////////////////////////////////////////////////////// + setClaimConditions + //////////////////////////////////////////////////////////////*/ + + function test_claimCondition_startIdAndCount() public { + vm.startPrank(deployer); + + uint256 currentStartId = 0; + uint256 count = 0; + + BurnToClaimDrop721Logic.ClaimCondition[] memory conditions = new BurnToClaimDrop721Logic.ClaimCondition[](2); + conditions[0].startTimestamp = 0; + conditions[0].maxClaimableSupply = 10; + conditions[1].startTimestamp = 1; + conditions[1].maxClaimableSupply = 10; + + drop.setClaimConditions(conditions, false); + (currentStartId, count) = drop.claimCondition(); + assertEq(currentStartId, 0); + assertEq(count, 2); + + drop.setClaimConditions(conditions, false); + (currentStartId, count) = drop.claimCondition(); + assertEq(currentStartId, 0); + assertEq(count, 2); + + drop.setClaimConditions(conditions, true); + (currentStartId, count) = drop.claimCondition(); + assertEq(currentStartId, 2); + assertEq(count, 2); + + drop.setClaimConditions(conditions, true); + (currentStartId, count) = drop.claimCondition(); + assertEq(currentStartId, 4); + assertEq(count, 2); + } + + function test_claimCondition_startPhase() public { + vm.startPrank(deployer); + + uint256 activeConditionId = 0; + + BurnToClaimDrop721Logic.ClaimCondition[] memory conditions = new BurnToClaimDrop721Logic.ClaimCondition[](3); + conditions[0].startTimestamp = 10; + conditions[0].maxClaimableSupply = 11; + conditions[0].quantityLimitPerWallet = 12; + conditions[1].startTimestamp = 20; + conditions[1].maxClaimableSupply = 21; + conditions[1].quantityLimitPerWallet = 22; + conditions[2].startTimestamp = 30; + conditions[2].maxClaimableSupply = 31; + conditions[2].quantityLimitPerWallet = 32; + drop.setClaimConditions(conditions, false); + + vm.expectRevert("!CONDITION."); + drop.getActiveClaimConditionId(); + + vm.warp(10); + activeConditionId = drop.getActiveClaimConditionId(); + assertEq(activeConditionId, 0); + assertEq(drop.getClaimConditionById(activeConditionId).startTimestamp, 10); + assertEq(drop.getClaimConditionById(activeConditionId).maxClaimableSupply, 11); + assertEq(drop.getClaimConditionById(activeConditionId).quantityLimitPerWallet, 12); + + vm.warp(20); + activeConditionId = drop.getActiveClaimConditionId(); + assertEq(activeConditionId, 1); + assertEq(drop.getClaimConditionById(activeConditionId).startTimestamp, 20); + assertEq(drop.getClaimConditionById(activeConditionId).maxClaimableSupply, 21); + assertEq(drop.getClaimConditionById(activeConditionId).quantityLimitPerWallet, 22); + + vm.warp(30); + activeConditionId = drop.getActiveClaimConditionId(); + assertEq(activeConditionId, 2); + assertEq(drop.getClaimConditionById(activeConditionId).startTimestamp, 30); + assertEq(drop.getClaimConditionById(activeConditionId).maxClaimableSupply, 31); + assertEq(drop.getClaimConditionById(activeConditionId).quantityLimitPerWallet, 32); + + vm.warp(40); + assertEq(drop.getActiveClaimConditionId(), 2); + } + + /*/////////////////////////////////////////////////////////////// + Miscellaneous + //////////////////////////////////////////////////////////////*/ + + function test_delayedReveal_withNewLazyMintedEmptyBatch() public { + vm.startPrank(deployer); + + bytes memory encryptedURI = drop.encryptDecrypt("ipfs://", "key"); + bytes32 provenanceHash = keccak256(abi.encodePacked("ipfs://", "key", block.chainid)); + drop.lazyMint(100, "", abi.encode(encryptedURI, provenanceHash)); + drop.reveal(0, "key"); + + string memory uri = drop.tokenURI(1); + assertEq(uri, string(abi.encodePacked("ipfs://", "1"))); + + bytes memory newEncryptedURI = drop.encryptDecrypt("ipfs://secret", "key"); + vm.expectRevert("0 amt"); + drop.lazyMint(0, "", abi.encode(newEncryptedURI, provenanceHash)); + + vm.stopPrank(); + } + + /*/////////////////////////////////////////////////////////////// + Burn To Claim + //////////////////////////////////////////////////////////////*/ + + function test_state_burnAndClaim_1155Origin_zeroMintPrice() public { + IBurnToClaim.BurnToClaimInfo memory burnToClaimInfo; + + burnToClaimInfo.originContractAddress = address(erc1155); + burnToClaimInfo.tokenType = IBurnToClaim.TokenType.ERC1155; + burnToClaimInfo.tokenId = 0; + burnToClaimInfo.mintPriceForNewToken = 0; + burnToClaimInfo.currency = address(erc20); + + // set origin contract details for burn and claim + vm.prank(deployer); + drop.setBurnToClaimInfo(burnToClaimInfo); + + // check details correctly saved + BurnToClaimDrop721Logic.BurnToClaimInfo memory savedInfo = drop.getBurnToClaimInfo(); + assertEq(savedInfo.originContractAddress, burnToClaimInfo.originContractAddress); + assertTrue(savedInfo.tokenType == burnToClaimInfo.tokenType); + assertEq(savedInfo.tokenId, burnToClaimInfo.tokenId); + assertEq(savedInfo.mintPriceForNewToken, burnToClaimInfo.mintPriceForNewToken); + assertEq(savedInfo.currency, burnToClaimInfo.currency); + + // mint some erc1155 to a claimer + address claimer = getActor(0); + erc1155.mint(claimer, 0, 10); + assertEq(erc1155.balanceOf(claimer, 0), 10); + vm.prank(claimer); + erc1155.setApprovalForAll(address(drop), true); + + // lazy mint tokens + vm.prank(deployer); + drop.lazyMint(100, "ipfs://", emptyEncodedBytes); + + // burn and claim + vm.prank(claimer); + drop.burnAndClaim(0, 10); + + // check state + assertEq(erc1155.balanceOf(claimer, 0), 0); + assertEq(drop.balanceOf(claimer), 10); + assertEq(drop.nextTokenIdToClaim(), 10); + } + + function test_state_burnAndClaim_1155Origin_nonZeroMintPrice() public { + IBurnToClaim.BurnToClaimInfo memory burnToClaimInfo; + + burnToClaimInfo.originContractAddress = address(erc1155); + burnToClaimInfo.tokenType = IBurnToClaim.TokenType.ERC1155; + burnToClaimInfo.tokenId = 0; + burnToClaimInfo.mintPriceForNewToken = 1; + burnToClaimInfo.currency = address(erc20); + + // set origin contract details for burn and claim + vm.prank(deployer); + drop.setBurnToClaimInfo(burnToClaimInfo); + + // check details correctly saved + BurnToClaimDrop721Logic.BurnToClaimInfo memory savedInfo = drop.getBurnToClaimInfo(); + assertEq(savedInfo.originContractAddress, burnToClaimInfo.originContractAddress); + assertTrue(savedInfo.tokenType == burnToClaimInfo.tokenType); + assertEq(savedInfo.tokenId, burnToClaimInfo.tokenId); + assertEq(savedInfo.mintPriceForNewToken, burnToClaimInfo.mintPriceForNewToken); + assertEq(savedInfo.currency, burnToClaimInfo.currency); + + // mint some erc1155 to a claimer + address claimer = getActor(0); + erc1155.mint(claimer, 0, 10); + assertEq(erc1155.balanceOf(claimer, 0), 10); + vm.prank(claimer); + erc1155.setApprovalForAll(address(drop), true); + + // mint erc20 to claimer, to pay claim price + erc20.mint(claimer, 100); + vm.prank(claimer); + erc20.approve(address(drop), type(uint256).max); + + // lazy mint tokens + vm.prank(deployer); + drop.lazyMint(100, "ipfs://", emptyEncodedBytes); + + // burn and claim + vm.prank(claimer); + drop.burnAndClaim(0, 10); + + // check state + assertEq(erc1155.balanceOf(claimer, 0), 0); + assertEq(erc20.balanceOf(claimer), 90); + assertEq(erc20.balanceOf(saleRecipient), 10); + assertEq(drop.balanceOf(claimer), 10); + assertEq(drop.nextTokenIdToClaim(), 10); + } + + function test_state_burnAndClaim_1155Origin_nonZeroMintPrice_nativeToken() public { + IBurnToClaim.BurnToClaimInfo memory burnToClaimInfo; + + burnToClaimInfo.originContractAddress = address(erc1155); + burnToClaimInfo.tokenType = IBurnToClaim.TokenType.ERC1155; + burnToClaimInfo.tokenId = 0; + burnToClaimInfo.mintPriceForNewToken = 1; + burnToClaimInfo.currency = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + + // set origin contract details for burn and claim + vm.prank(deployer); + drop.setBurnToClaimInfo(burnToClaimInfo); + + // check details correctly saved + BurnToClaimDrop721Logic.BurnToClaimInfo memory savedInfo = drop.getBurnToClaimInfo(); + assertEq(savedInfo.originContractAddress, burnToClaimInfo.originContractAddress); + assertTrue(savedInfo.tokenType == burnToClaimInfo.tokenType); + assertEq(savedInfo.tokenId, burnToClaimInfo.tokenId); + assertEq(savedInfo.mintPriceForNewToken, burnToClaimInfo.mintPriceForNewToken); + assertEq(savedInfo.currency, burnToClaimInfo.currency); + + // mint some erc1155 to a claimer + address claimer = getActor(0); + erc1155.mint(claimer, 0, 10); + assertEq(erc1155.balanceOf(claimer, 0), 10); + vm.prank(claimer); + erc1155.setApprovalForAll(address(drop), true); + + // deal ether to claimer, to pay claim price + vm.deal(claimer, 100); + + // lazy mint tokens + vm.prank(deployer); + drop.lazyMint(100, "ipfs://", emptyEncodedBytes); + + // burn and claim + vm.prank(claimer); + drop.burnAndClaim{ value: 10 }(0, 10); + + // check state + assertEq(erc1155.balanceOf(claimer, 0), 0); + assertEq(claimer.balance, 90); + assertEq(saleRecipient.balance, 10); + assertEq(drop.balanceOf(claimer), 10); + assertEq(drop.nextTokenIdToClaim(), 10); + } + + function test_state_burnAndClaim_721Origin_zeroMintPrice() public { + IBurnToClaim.BurnToClaimInfo memory burnToClaimInfo; + + burnToClaimInfo.originContractAddress = address(erc721); + burnToClaimInfo.tokenType = IBurnToClaim.TokenType.ERC721; + burnToClaimInfo.tokenId = 0; + burnToClaimInfo.mintPriceForNewToken = 0; + burnToClaimInfo.currency = address(erc20); + + // set origin contract details for burn and claim + vm.prank(deployer); + drop.setBurnToClaimInfo(burnToClaimInfo); + + // check details correctly saved + BurnToClaimDrop721Logic.BurnToClaimInfo memory savedInfo = drop.getBurnToClaimInfo(); + assertEq(savedInfo.originContractAddress, burnToClaimInfo.originContractAddress); + assertTrue(savedInfo.tokenType == burnToClaimInfo.tokenType); + assertEq(savedInfo.tokenId, burnToClaimInfo.tokenId); + assertEq(savedInfo.mintPriceForNewToken, burnToClaimInfo.mintPriceForNewToken); + assertEq(savedInfo.currency, burnToClaimInfo.currency); + + // mint some erc721 to a claimer + address claimer = getActor(0); + erc721.mint(claimer, 10); + assertEq(erc721.balanceOf(claimer), 10); + vm.prank(claimer); + erc721.setApprovalForAll(address(drop), true); + + // lazy mint tokens + vm.prank(deployer); + drop.lazyMint(100, "ipfs://", emptyEncodedBytes); + + // burn and claim + vm.prank(claimer); + drop.burnAndClaim(0, 1); + + // check state + assertEq(erc721.balanceOf(claimer), 9); + assertEq(drop.balanceOf(claimer), 1); + assertEq(drop.nextTokenIdToClaim(), 1); + + vm.expectRevert("ERC721: invalid token ID"); // because the token doesn't exist anymore + erc721.ownerOf(0); + } + + function test_state_burnAndClaim_721Origin_nonZeroMintPrice() public { + IBurnToClaim.BurnToClaimInfo memory burnToClaimInfo; + + burnToClaimInfo.originContractAddress = address(erc721); + burnToClaimInfo.tokenType = IBurnToClaim.TokenType.ERC721; + burnToClaimInfo.tokenId = 0; + burnToClaimInfo.mintPriceForNewToken = 1; + burnToClaimInfo.currency = address(erc20); + + // set origin contract details for burn and claim + vm.prank(deployer); + drop.setBurnToClaimInfo(burnToClaimInfo); + + // check details correctly saved + BurnToClaimDrop721Logic.BurnToClaimInfo memory savedInfo = drop.getBurnToClaimInfo(); + assertEq(savedInfo.originContractAddress, burnToClaimInfo.originContractAddress); + assertTrue(savedInfo.tokenType == burnToClaimInfo.tokenType); + assertEq(savedInfo.tokenId, burnToClaimInfo.tokenId); + assertEq(savedInfo.mintPriceForNewToken, burnToClaimInfo.mintPriceForNewToken); + assertEq(savedInfo.currency, burnToClaimInfo.currency); + + // mint some erc721 to a claimer + address claimer = getActor(0); + erc721.mint(claimer, 10); + assertEq(erc721.balanceOf(claimer), 10); + vm.prank(claimer); + erc721.setApprovalForAll(address(drop), true); + + // mint erc20 to claimer, to pay claim price + erc20.mint(claimer, 100); + vm.prank(claimer); + erc20.approve(address(drop), type(uint256).max); + + // lazy mint tokens + vm.prank(deployer); + drop.lazyMint(100, "ipfs://", emptyEncodedBytes); + + // burn and claim + vm.prank(claimer); + drop.burnAndClaim(0, 1); + + // check state + assertEq(erc721.balanceOf(claimer), 9); + assertEq(drop.balanceOf(claimer), 1); + assertEq(drop.nextTokenIdToClaim(), 1); + assertEq(erc20.balanceOf(claimer), 99); + assertEq(erc20.balanceOf(saleRecipient), 1); + + vm.expectRevert("ERC721: invalid token ID"); // because the token doesn't exist anymore + erc721.ownerOf(0); + } + + function test_state_burnAndClaim_721Origin_nonZeroMintPrice_nativeToken() public { + IBurnToClaim.BurnToClaimInfo memory burnToClaimInfo; + + burnToClaimInfo.originContractAddress = address(erc721); + burnToClaimInfo.tokenType = IBurnToClaim.TokenType.ERC721; + burnToClaimInfo.tokenId = 0; + burnToClaimInfo.mintPriceForNewToken = 1; + burnToClaimInfo.currency = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + + // set origin contract details for burn and claim + vm.prank(deployer); + drop.setBurnToClaimInfo(burnToClaimInfo); + + // check details correctly saved + BurnToClaimDrop721Logic.BurnToClaimInfo memory savedInfo = drop.getBurnToClaimInfo(); + assertEq(savedInfo.originContractAddress, burnToClaimInfo.originContractAddress); + assertTrue(savedInfo.tokenType == burnToClaimInfo.tokenType); + assertEq(savedInfo.tokenId, burnToClaimInfo.tokenId); + assertEq(savedInfo.mintPriceForNewToken, burnToClaimInfo.mintPriceForNewToken); + assertEq(savedInfo.currency, burnToClaimInfo.currency); + + // mint some erc721 to a claimer + address claimer = getActor(0); + erc721.mint(claimer, 10); + assertEq(erc721.balanceOf(claimer), 10); + vm.prank(claimer); + erc721.setApprovalForAll(address(drop), true); + + // deal ether to claimer, to pay claim price + vm.deal(claimer, 100); + + // lazy mint tokens + vm.prank(deployer); + drop.lazyMint(100, "ipfs://", emptyEncodedBytes); + + // burn and claim + vm.prank(claimer); + drop.burnAndClaim{ value: 1 }(0, 1); + + // check state + assertEq(erc721.balanceOf(claimer), 9); + assertEq(drop.balanceOf(claimer), 1); + assertEq(drop.nextTokenIdToClaim(), 1); + assertEq(claimer.balance, 99); + assertEq(saleRecipient.balance, 1); + + vm.expectRevert("ERC721: invalid token ID"); // because the token doesn't exist anymore + erc721.ownerOf(0); + } + + function test_revert_burnAndClaim_originNotSet() public { + // lazy mint tokens + vm.prank(deployer); + drop.lazyMint(100, "ipfs://", emptyEncodedBytes); + + // burn and claim + vm.expectRevert(); + drop.burnAndClaim(0, 1); + } + + function test_revert_burnAndClaim_noLazyMintedTokens() public { + // burn and claim + vm.expectRevert("!Tokens"); + drop.burnAndClaim(0, 1); + } + + function test_revert_burnAndClaim_invalidTokenId() public { + IBurnToClaim.BurnToClaimInfo memory burnToClaimInfo; + + burnToClaimInfo.originContractAddress = address(erc1155); + burnToClaimInfo.tokenType = IBurnToClaim.TokenType.ERC1155; + burnToClaimInfo.tokenId = 0; + burnToClaimInfo.mintPriceForNewToken = 0; + burnToClaimInfo.currency = address(erc20); + + // set origin contract details for burn and claim + vm.prank(deployer); + drop.setBurnToClaimInfo(burnToClaimInfo); + + // lazy mint tokens + vm.prank(deployer); + drop.lazyMint(100, "ipfs://", emptyEncodedBytes); + + // mint some erc1155 to a claimer + address claimer = getActor(0); + erc1155.mint(claimer, 0, 10); + assertEq(erc1155.balanceOf(claimer, 0), 10); + vm.prank(claimer); + erc1155.setApprovalForAll(address(drop), true); + + // burn and claim + vm.prank(claimer); + vm.expectRevert("Invalid token Id"); + drop.burnAndClaim(1, 1); + } + + function test_revert_burnAndClaim_notEnoughBalance() public { + IBurnToClaim.BurnToClaimInfo memory burnToClaimInfo; + + burnToClaimInfo.originContractAddress = address(erc1155); + burnToClaimInfo.tokenType = IBurnToClaim.TokenType.ERC1155; + burnToClaimInfo.tokenId = 0; + burnToClaimInfo.mintPriceForNewToken = 0; + burnToClaimInfo.currency = address(erc20); + + // set origin contract details for burn and claim + vm.prank(deployer); + drop.setBurnToClaimInfo(burnToClaimInfo); + + // lazy mint tokens + vm.prank(deployer); + drop.lazyMint(100, "ipfs://", emptyEncodedBytes); + + // mint some erc1155 to a claimer + address claimer = getActor(0); + erc1155.mint(claimer, 0, 10); + assertEq(erc1155.balanceOf(claimer, 0), 10); + vm.prank(claimer); + erc1155.setApprovalForAll(address(drop), true); + + // burn and claim + vm.prank(claimer); + vm.expectRevert("!Balance"); + drop.burnAndClaim(0, 11); + } + + function test_revert_burnAndClaim_notOwnerOfToken() public { + IBurnToClaim.BurnToClaimInfo memory burnToClaimInfo; + + burnToClaimInfo.originContractAddress = address(erc721); + burnToClaimInfo.tokenType = IBurnToClaim.TokenType.ERC721; + burnToClaimInfo.tokenId = 0; + burnToClaimInfo.mintPriceForNewToken = 1; + burnToClaimInfo.currency = address(erc20); + + // set origin contract details for burn and claim + vm.prank(deployer); + drop.setBurnToClaimInfo(burnToClaimInfo); + + // mint some erc721 to a claimer + address claimer = getActor(0); + erc721.mint(claimer, 10); + assertEq(erc721.balanceOf(claimer), 10); + vm.prank(claimer); + erc721.setApprovalForAll(address(drop), true); + + // mint erc721 to another address + erc721.mint(address(0x567), 5); + + // lazy mint tokens + vm.prank(deployer); + drop.lazyMint(100, "ipfs://", emptyEncodedBytes); + + // burn and claim + vm.prank(claimer); + vm.expectRevert("!Owner"); + drop.burnAndClaim(11, 1); + } + + /*/////////////////////////////////////////////////////////////// + Extension Role and Upgradeability + //////////////////////////////////////////////////////////////*/ + + // function test_addExtension() public { + // address permissionsNew = address(new PermissionsEnumerableImpl()); + + // Extension memory extension_permissions_new; + // extension_permissions_new.metadata = ExtensionMetadata({ + // name: "PermissionsNew", + // metadataURI: "ipfs://PermissionsNew", + // implementation: permissionsNew + // }); + + // extension_permissions_new.functions = new ExtensionFunction[](4); + // extension_permissions_new.functions[0] = ExtensionFunction( + // Permissions.hasRole.selector, + // "hasRole(bytes32,address)" + // ); + // extension_permissions_new.functions[1] = ExtensionFunction( + // Permissions.hasRoleWithSwitch.selector, + // "hasRoleWithSwitch(bytes32,address)" + // ); + // extension_permissions_new.functions[2] = ExtensionFunction( + // Permissions.grantRole.selector, + // "grantRole(bytes32,address)" + // ); + // extension_permissions_new.functions[3] = ExtensionFunction( + // PermissionsEnumerable.getRoleMemberCount.selector, + // "getRoleMemberCount(bytes32)" + // ); + + // // cast drop to router type + // BurnToClaimDropERC721 dropRouter = BurnToClaimDropERC721(payable(address(drop))); + + // vm.prank(deployer); + // dropRouter.addExtension(extension_permissions_new); + + // // assertEq( + // // dropRouter.getExtensionForFunction(PermissionsEnumerable.getRoleMemberCount.selector).name, + // // "PermissionsNew" + // // ); + + // // assertEq( + // // dropRouter.getExtensionForFunction(PermissionsEnumerable.getRoleMemberCount.selector).implementation, + // // permissionsNew + // // ); + // } + + function test_revert_addExtension_NotAuthorized() public { + Extension memory extension_permissions_new; + + // cast drop to router type + BurnToClaimDropERC721 dropRouter = BurnToClaimDropERC721(payable(address(drop))); + + vm.prank(address(0x123)); + vm.expectRevert("ExtensionManager: unauthorized."); + dropRouter.addExtension(extension_permissions_new); + } + + function test_revert_addExtension_deployerRenounceExtensionRole() public { + Extension memory extension_permissions_new; + + // cast drop to router type + BurnToClaimDropERC721 dropRouter = BurnToClaimDropERC721(payable(address(drop))); + + vm.prank(deployer); + Permissions(address(drop)).renounceRole(keccak256("EXTENSION_ROLE"), deployer); + + vm.prank(deployer); + vm.expectRevert("ExtensionManager: unauthorized."); + dropRouter.addExtension(extension_permissions_new); + + vm.startPrank(deployer); + vm.expectRevert( + abi.encodePacked( + "Permissions: account ", + Strings.toHexString(uint160(deployer), 20), + " is missing role ", + Strings.toHexString(uint256(keccak256("EXTENSION_ROLE")), 32) + ) + ); + Permissions(address(drop)).grantRole(keccak256("EXTENSION_ROLE"), address(0x12345)); + vm.stopPrank(); + } +} diff --git a/src/test/drop/DropERC1155.t.sol b/src/test/drop/DropERC1155.t.sol new file mode 100644 index 000000000..840f78cf6 --- /dev/null +++ b/src/test/drop/DropERC1155.t.sol @@ -0,0 +1,956 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { DropERC1155, BatchMintMetadata, Drop1155, LazyMint, IPermissions, ILazyMint } from "contracts/prebuilts/drop/DropERC1155.sol"; + +// Test imports + +import "../utils/BaseTest.sol"; +import "@openzeppelin/contracts-upgradeable/utils/StringsUpgradeable.sol"; + +contract DropERC1155Test is BaseTest { + using Strings for uint256; + using Strings for address; + + event TokensLazyMinted(uint256 indexed startTokenId, uint256 endTokenId, string baseURI, bytes encryptedBaseURI); + event TokenURIRevealed(uint256 indexed index, string revealedURI); + event MaxTotalSupplyUpdated(uint256 tokenId, uint256 maxTotalSupply); + + DropERC1155 public drop; + + bytes private emptyEncodedBytes = abi.encode("", ""); + + using stdStorage for StdStorage; + + function setUp() public override { + super.setUp(); + drop = DropERC1155(getContract("DropERC1155")); + + erc20.mint(deployer, 1_000 ether); + vm.deal(deployer, 1_000 ether); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: misc. + //////////////////////////////////////////////////////////////*/ + + /** + * note: Tests whether contract reverts when a non-holder renounces a role. + */ + function test_revert_nonHolder_renounceRole() public { + address caller = address(0x123); + bytes32 role = keccak256("MINTER_ROLE"); + + vm.prank(caller); + vm.expectRevert(abi.encodeWithSelector(Permissions.PermissionsUnauthorizedAccount.selector, caller, role)); + + drop.renounceRole(role, caller); + } + + /** + * note: Tests whether contract reverts when a role admin revokes a role for a non-holder. + */ + function test_revert_revokeRoleForNonHolder() public { + address target = address(0x123); + bytes32 role = keccak256("MINTER_ROLE"); + + vm.prank(deployer); + vm.expectRevert(abi.encodeWithSelector(Permissions.PermissionsUnauthorizedAccount.selector, target, role)); + + drop.revokeRole(role, target); + } + + /** + * @dev Tests whether contract reverts when a role is granted to an existent role holder. + */ + function test_revert_grant_role_to_account_with_role() public { + bytes32 role = keccak256("ABC_ROLE"); + address receiver = getActor(0); + + vm.startPrank(deployer); + + drop.grantRole(role, receiver); + + vm.expectRevert(abi.encodeWithSelector(Permissions.PermissionsAlreadyGranted.selector, receiver, role)); + drop.grantRole(role, receiver); + + vm.stopPrank(); + } + + /** + * @dev Tests contract state for Transfer role. + */ + function test_state_grant_transferRole() public { + bytes32 role = keccak256("TRANSFER_ROLE"); + + // check if admin and address(0) have transfer role in the beginning + bool checkAddressZero = drop.hasRole(role, address(0)); + bool checkAdmin = drop.hasRole(role, deployer); + assertTrue(checkAddressZero); + assertTrue(checkAdmin); + + // check if transfer role can be granted to a non-holder + address receiver = getActor(0); + vm.startPrank(deployer); + drop.grantRole(role, receiver); + + // expect revert when granting to a holder + vm.expectRevert(abi.encodeWithSelector(Permissions.PermissionsAlreadyGranted.selector, receiver, role)); + drop.grantRole(role, receiver); + + // check if receiver has transfer role + bool checkReceiver = drop.hasRole(role, receiver); + assertTrue(checkReceiver); + + // check if role is correctly revoked + drop.revokeRole(role, receiver); + checkReceiver = drop.hasRole(role, receiver); + assertFalse(checkReceiver); + drop.revokeRole(role, address(0)); + checkAddressZero = drop.hasRole(role, address(0)); + assertFalse(checkAddressZero); + + vm.stopPrank(); + } + + /** + * @dev Tests contract state for Transfer role. + */ + function test_state_getRoleMember_transferRole() public { + bytes32 role = keccak256("TRANSFER_ROLE"); + + uint256 roleMemberCount = drop.getRoleMemberCount(role); + assertEq(roleMemberCount, 2); + + address roleMember = drop.getRoleMember(role, 1); + assertEq(roleMember, address(0)); + + vm.startPrank(deployer); + drop.grantRole(role, address(2)); + drop.grantRole(role, address(3)); + drop.grantRole(role, address(4)); + + roleMemberCount = drop.getRoleMemberCount(role); + console.log(roleMemberCount); + for (uint256 i = 0; i < roleMemberCount; i++) { + console.log(drop.getRoleMember(role, i)); + } + console.log(""); + + drop.revokeRole(role, address(2)); + roleMemberCount = drop.getRoleMemberCount(role); + console.log(roleMemberCount); + for (uint256 i = 0; i < roleMemberCount; i++) { + console.log(drop.getRoleMember(role, i)); + } + console.log(""); + + drop.revokeRole(role, address(0)); + roleMemberCount = drop.getRoleMemberCount(role); + console.log(roleMemberCount); + for (uint256 i = 0; i < roleMemberCount; i++) { + console.log(drop.getRoleMember(role, i)); + } + console.log(""); + + drop.grantRole(role, address(5)); + roleMemberCount = drop.getRoleMemberCount(role); + console.log(roleMemberCount); + for (uint256 i = 0; i < roleMemberCount; i++) { + console.log(drop.getRoleMember(role, i)); + } + console.log(""); + + drop.grantRole(role, address(0)); + roleMemberCount = drop.getRoleMemberCount(role); + console.log(roleMemberCount); + for (uint256 i = 0; i < roleMemberCount; i++) { + console.log(drop.getRoleMember(role, i)); + } + console.log(""); + + drop.grantRole(role, address(6)); + roleMemberCount = drop.getRoleMemberCount(role); + console.log(roleMemberCount); + for (uint256 i = 0; i < roleMemberCount; i++) { + console.log(drop.getRoleMember(role, i)); + } + console.log(""); + } + + /** + * note: Testing transfer of tokens when transfer-role is restricted + */ + function test_claim_transferRole() public { + vm.warp(1); + + uint256 _tokenId = 0; + address receiver = getActor(0); + bytes32[] memory proofs = new bytes32[](0); + + DropERC1155.AllowlistProof memory alp; + alp.proof = proofs; + + DropERC1155.ClaimCondition[] memory conditions = new DropERC1155.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 100; + conditions[0].quantityLimitPerWallet = 100; + + vm.prank(deployer); + drop.lazyMint(100, "ipfs://", emptyEncodedBytes); + vm.prank(deployer); + drop.setClaimConditions(_tokenId, conditions, false); + + vm.prank(getActor(5), getActor(5)); + drop.claim(receiver, _tokenId, 1, address(0), 0, alp, ""); + + // revoke transfer role from address(0) + vm.prank(deployer); + drop.revokeRole(keccak256("TRANSFER_ROLE"), address(0)); + vm.startPrank(receiver); + vm.expectRevert("restricted to TRANSFER_ROLE holders."); + drop.safeTransferFrom(receiver, address(123), 0, 0, ""); + } + + /** + * @dev Tests whether role member count is incremented correctly. + */ + function test_member_count_incremented_properly_when_role_granted() public { + bytes32 role = keccak256("ABC_ROLE"); + address receiver = getActor(0); + + vm.startPrank(deployer); + uint256 roleMemberCount = drop.getRoleMemberCount(role); + + assertEq(roleMemberCount, 0); + + drop.grantRole(role, receiver); + + assertEq(drop.getRoleMemberCount(role), 1); + + vm.stopPrank(); + } + + function test_claimCondition_with_startTimestamp() public { + vm.warp(1); + + uint256 _tokenId = 0; + address receiver = getActor(0); + bytes32[] memory proofs = new bytes32[](0); + + DropERC1155.AllowlistProof memory alp; + alp.proof = proofs; + + DropERC1155.ClaimCondition[] memory conditions = new DropERC1155.ClaimCondition[](1); + conditions[0].startTimestamp = 100; + conditions[0].maxClaimableSupply = 100; + conditions[0].quantityLimitPerWallet = 100; + + vm.prank(deployer); + drop.lazyMint(100, "ipfs://", emptyEncodedBytes); + + vm.prank(deployer); + drop.setClaimConditions(_tokenId, conditions, false); + + vm.warp(99); + vm.prank(getActor(5), getActor(5)); + vm.expectRevert(abi.encodeWithSelector(Drop1155.DropNoActiveCondition.selector)); + drop.claim(receiver, _tokenId, 1, address(0), 0, alp, ""); + + vm.warp(100); + vm.prank(getActor(4), getActor(4)); + drop.claim(receiver, _tokenId, 1, address(0), 0, alp, ""); + } + + /*/////////////////////////////////////////////////////////////// + Lazy Mint Tests + //////////////////////////////////////////////////////////////*/ + + /* + * note: Testing state changes; lazy mint a batch of tokens with no encrypted base URI. + */ + function test_state_lazyMint_noEncryptedURI() public { + uint256 amountToLazyMint = 100; + string memory baseURI = "ipfs://"; + + uint256 nextTokenIdToMintBefore = drop.nextTokenIdToMint(); + + vm.startPrank(deployer); + uint256 batchId = drop.lazyMint(amountToLazyMint, baseURI, emptyEncodedBytes); + + assertEq(nextTokenIdToMintBefore + amountToLazyMint, drop.nextTokenIdToMint()); + assertEq(nextTokenIdToMintBefore + amountToLazyMint, batchId); + + for (uint256 i = 0; i < amountToLazyMint; i += 1) { + string memory uri = drop.uri(i); + console.log(uri); + assertEq(uri, string(abi.encodePacked(baseURI, i.toString()))); + } + + vm.stopPrank(); + } + + /** + * note: Testing revert condition; an address without MINTER_ROLE calls lazyMint function. + */ + function test_revert_lazyMint_MINTER_ROLE() public { + vm.expectRevert(abi.encodeWithSelector(LazyMint.LazyMintUnauthorized.selector)); + drop.lazyMint(100, "ipfs://", emptyEncodedBytes); + } + + /* + * note: Testing revert condition; calling tokenURI for invalid batch id. + */ + function test_revert_lazyMint_URIForNonLazyMintedToken() public { + vm.startPrank(deployer); + + drop.lazyMint(100, "ipfs://", emptyEncodedBytes); + + vm.expectRevert(abi.encodeWithSelector(BatchMintMetadata.BatchMintInvalidTokenId.selector, 100)); + drop.uri(100); + + vm.stopPrank(); + } + + /** + * note: Testing event emission; tokens lazy minted. + */ + function test_event_lazyMint_TokensLazyMinted() public { + vm.startPrank(deployer); + + vm.expectEmit(true, false, false, true); + emit TokensLazyMinted(0, 99, "ipfs://", emptyEncodedBytes); + drop.lazyMint(100, "ipfs://", emptyEncodedBytes); + + vm.stopPrank(); + } + + /* + * note: Fuzz testing state changes; lazy mint a batch of tokens with no encrypted base URI. + */ + function test_fuzz_lazyMint_noEncryptedURI(uint256 x) public { + vm.assume(x > 0); + + uint256 amountToLazyMint = x; + string memory baseURI = "ipfs://"; + + uint256 nextTokenIdToMintBefore = drop.nextTokenIdToMint(); + + vm.startPrank(deployer); + uint256 batchId = drop.lazyMint(amountToLazyMint, baseURI, emptyEncodedBytes); + + assertEq(nextTokenIdToMintBefore + amountToLazyMint, drop.nextTokenIdToMint()); + assertEq(nextTokenIdToMintBefore + amountToLazyMint, batchId); + + string memory uri = drop.uri(0); + assertEq(uri, string(abi.encodePacked(baseURI, uint256(0).toString()))); + + uri = drop.uri(x - 1); + assertEq(uri, string(abi.encodePacked(baseURI, uint256(x - 1).toString()))); + + /** + * note: this loop takes too long to run with fuzz tests. + */ + // for(uint256 i = 0; i < amountToLazyMint; i += 1) { + // string memory uri = drop.uri(i); + // console.log(uri); + // assertEq(uri, string(abi.encodePacked(baseURI, i.toString()))); + // } + + vm.stopPrank(); + } + + /* + * note: Fuzz testing; a batch of tokens, and nextTokenIdToMint + */ + function test_fuzz_lazyMint_batchMintAndNextTokenIdToMint(uint256 x) public { + vm.assume(x > 0); + vm.startPrank(deployer); + + if (x == 0) { + vm.expectRevert("Zero amount"); + } + drop.lazyMint(x, "ipfs://", emptyEncodedBytes); + + uint256 slot = stdstore.target(address(drop)).sig("nextTokenIdToMint()").find(); + bytes32 loc = bytes32(slot); + uint256 nextTokenIdToMint = uint256(vm.load(address(drop), loc)); + + assertEq(nextTokenIdToMint, x); + vm.stopPrank(); + } + + /*/////////////////////////////////////////////////////////////// + Claim Tests + //////////////////////////////////////////////////////////////*/ + + /** + * note: Testing revert condition; not enough minted tokens. + */ + function test_revert_claimCondition_notEnoughMintedTokens() public { + vm.warp(1); + + uint256 _tokenId = 0; + address receiver = getActor(0); + bytes32[] memory proofs = new bytes32[](0); + + DropERC1155.AllowlistProof memory alp; + alp.proof = proofs; + + DropERC1155.ClaimCondition[] memory conditions = new DropERC1155.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 100; + conditions[0].quantityLimitPerWallet = 200; + + vm.prank(deployer); + drop.lazyMint(100, "ipfs://", emptyEncodedBytes); + vm.prank(deployer); + drop.setClaimConditions(_tokenId, conditions, false); + + vm.expectRevert(abi.encodeWithSelector(Drop1155.DropNoActiveCondition.selector)); + vm.prank(getActor(6), getActor(6)); + drop.claim(receiver, 100, 101, address(0), 0, alp, ""); + } + + /** + * note: Testing revert condition; exceed max claimable supply. + */ + function test_revert_claimCondition_exceedMaxClaimableSupply() public { + vm.warp(1); + + uint256 _tokenId = 0; + address receiver = getActor(0); + bytes32[] memory proofs = new bytes32[](0); + + DropERC1155.AllowlistProof memory alp; + alp.proof = proofs; + + DropERC1155.ClaimCondition[] memory conditions = new DropERC1155.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 100; + conditions[0].quantityLimitPerWallet = 200; + + vm.prank(deployer); + drop.lazyMint(200, "ipfs://", emptyEncodedBytes); + vm.prank(deployer); + drop.setClaimConditions(_tokenId, conditions, false); + + vm.prank(getActor(5), getActor(5)); + drop.claim(receiver, _tokenId, 100, address(0), 0, alp, ""); + + vm.expectRevert(abi.encodeWithSelector(Drop1155.DropClaimExceedMaxSupply.selector, 100, 101)); + vm.prank(getActor(6), getActor(6)); + drop.claim(receiver, _tokenId, 1, address(0), 0, alp, ""); + } + + /** + * note: Testing quantity limit restriction when no allowlist present. + */ + function test_fuzz_claim_noAllowlist(uint256 x) public { + vm.assume(x != 0); + vm.warp(1); + + uint256 _tokenId = 0; + address receiver = getActor(0); + bytes32[] memory proofs = new bytes32[](0); + + DropERC1155.AllowlistProof memory alp; + alp.proof = proofs; + alp.quantityLimitPerWallet = x; + + DropERC1155.ClaimCondition[] memory conditions = new DropERC1155.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 500; + conditions[0].quantityLimitPerWallet = 100; + + vm.prank(deployer); + drop.lazyMint(500, "ipfs://", emptyEncodedBytes); + + vm.prank(deployer); + drop.setClaimConditions(_tokenId, conditions, false); + + vm.prank(getActor(5), getActor(5)); + vm.expectRevert(abi.encodeWithSelector(Drop1155.DropClaimExceedLimit.selector, 100, 0)); + drop.claim(receiver, _tokenId, 0, address(0), 0, alp, ""); + + vm.prank(getActor(5), getActor(5)); + vm.expectRevert(abi.encodeWithSelector(Drop1155.DropClaimExceedLimit.selector, 100, 101)); + drop.claim(receiver, _tokenId, 101, address(0), 0, alp, ""); + + vm.prank(deployer); + drop.setClaimConditions(_tokenId, conditions, true); + + vm.prank(getActor(5), getActor(5)); + vm.expectRevert(abi.encodeWithSelector(Drop1155.DropClaimExceedLimit.selector, 100, 101)); + drop.claim(receiver, _tokenId, 101, address(0), 0, alp, ""); + } + + /** + * note: Testing quantity limit restriction + * - allowlist quantity set to some value different than general limit + * - allowlist price set to 0 + */ + function test_state_claim_allowlisted_SetQuantityZeroPrice() public { + uint256 _tokenId = 0; + string[] memory inputs = new string[](5); + + inputs[0] = "node"; + inputs[1] = "src/test/scripts/generateRoot.ts"; + inputs[2] = "300"; + inputs[3] = "0"; + inputs[4] = Strings.toHexString(uint160(address(erc20))); // address of erc20 + + bytes memory result = vm.ffi(inputs); + // revert(); + bytes32 root = abi.decode(result, (bytes32)); + + inputs[1] = "src/test/scripts/getProof.ts"; + result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + DropERC1155.AllowlistProof memory alp; + alp.proof = proofs; + alp.quantityLimitPerWallet = 300; + alp.pricePerToken = 0; + alp.currency = address(erc20); + + vm.warp(1); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); // in allowlist + + DropERC1155.ClaimCondition[] memory conditions = new DropERC1155.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 500; + conditions[0].quantityLimitPerWallet = 10; + conditions[0].merkleRoot = root; + conditions[0].pricePerToken = 10; + conditions[0].currency = address(erc20); + + vm.prank(deployer); + drop.lazyMint(500, "ipfs://", emptyEncodedBytes); + vm.prank(deployer); + drop.setClaimConditions(_tokenId, conditions, false); + + // vm.prank(getActor(5), getActor(5)); + vm.prank(receiver, receiver); + drop.claim(receiver, _tokenId, 100, address(erc20), 0, alp, ""); // claims for free, because allowlist price is 0 + assertEq(drop.getSupplyClaimedByWallet(_tokenId, drop.getActiveClaimConditionId(_tokenId), receiver), 100); + } + + /** + * note: Testing quantity limit restriction + * - allowlist quantity set to some value different than general limit + * - allowlist price set to non-zero value + */ + function test_state_claim_allowlisted_SetQuantityPrice() public { + uint256 _tokenId = 0; + string[] memory inputs = new string[](5); + + inputs[0] = "node"; + inputs[1] = "src/test/scripts/generateRoot.ts"; + inputs[2] = "300"; + inputs[3] = "5"; + inputs[4] = Strings.toHexString(uint160(address(erc20))); // address of erc20 + + bytes memory result = vm.ffi(inputs); + // revert(); + bytes32 root = abi.decode(result, (bytes32)); + + inputs[1] = "src/test/scripts/getProof.ts"; + result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + DropERC1155.AllowlistProof memory alp; + alp.proof = proofs; + alp.quantityLimitPerWallet = 300; + alp.pricePerToken = 5; + alp.currency = address(erc20); + + vm.warp(1); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); // in allowlist + + DropERC1155.ClaimCondition[] memory conditions = new DropERC1155.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 500; + conditions[0].quantityLimitPerWallet = 10; + conditions[0].merkleRoot = root; + conditions[0].pricePerToken = 10; + conditions[0].currency = address(erc20); + + vm.prank(deployer); + drop.lazyMint(500, "ipfs://", emptyEncodedBytes); + vm.prank(deployer); + drop.setClaimConditions(_tokenId, conditions, false); + + vm.prank(receiver, receiver); + vm.expectRevert( + abi.encodeWithSelector(Drop1155.DropClaimInvalidTokenPrice.selector, address(erc20), 0, address(erc20), 5) + ); + drop.claim(receiver, _tokenId, 100, address(erc20), 0, alp, ""); + + erc20.mint(receiver, 10000); + vm.prank(receiver); + erc20.approve(address(drop), 10000); + + // vm.prank(getActor(5), getActor(5)); + vm.prank(receiver, receiver); + drop.claim(receiver, _tokenId, 100, address(erc20), 5, alp, ""); + assertEq(drop.getSupplyClaimedByWallet(_tokenId, drop.getActiveClaimConditionId(_tokenId), receiver), 100); + assertEq(erc20.balanceOf(receiver), 10000 - 500); + } + + /** + * note: Testing quantity limit restriction + * - allowlist quantity set to some value different than general limit + * - allowlist price not set; should default to general price and currency + */ + function test_state_claim_allowlisted_SetQuantityDefaultPrice() public { + uint256 _tokenId = 0; + string[] memory inputs = new string[](5); + + inputs[0] = "node"; + inputs[1] = "src/test/scripts/generateRoot.ts"; + inputs[2] = "300"; + inputs[3] = Strings.toString(type(uint256).max); // this implies that general price is applicable + inputs[4] = "0x0000000000000000000000000000000000000000"; + + bytes memory result = vm.ffi(inputs); + // revert(); + bytes32 root = abi.decode(result, (bytes32)); + + inputs[1] = "src/test/scripts/getProof.ts"; + result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + DropERC1155.AllowlistProof memory alp; + alp.proof = proofs; + alp.quantityLimitPerWallet = 300; + alp.pricePerToken = type(uint256).max; + alp.currency = address(0); + + vm.warp(1); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); // in allowlist + + DropERC1155.ClaimCondition[] memory conditions = new DropERC1155.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 500; + conditions[0].quantityLimitPerWallet = 10; + conditions[0].merkleRoot = root; + conditions[0].pricePerToken = 10; + conditions[0].currency = address(erc20); + + vm.prank(deployer); + drop.lazyMint(500, "ipfs://", emptyEncodedBytes); + vm.prank(deployer); + drop.setClaimConditions(_tokenId, conditions, false); + + erc20.mint(receiver, 10000); + vm.prank(receiver); + erc20.approve(address(drop), 10000); + + // vm.prank(getActor(5), getActor(5)); + vm.prank(receiver, receiver); + drop.claim(receiver, _tokenId, 100, address(erc20), 10, alp, ""); + assertEq(drop.getSupplyClaimedByWallet(_tokenId, drop.getActiveClaimConditionId(_tokenId), receiver), 100); + assertEq(erc20.balanceOf(receiver), 10000 - 1000); + } + + /** + * note: Testing quantity limit restriction + * - allowlist quantity set to 0 => should default to general limit + * - allowlist price set to some value different than general price + */ + function test_state_claim_allowlisted_DefaultQuantitySomePrice() public { + uint256 _tokenId = 0; + string[] memory inputs = new string[](5); + + inputs[0] = "node"; + inputs[1] = "src/test/scripts/generateRoot.ts"; + inputs[2] = "0"; // this implies that general limit is applicable + inputs[3] = "5"; + inputs[4] = "0x0000000000000000000000000000000000000000"; // general currency will be applicable + + bytes memory result = vm.ffi(inputs); + // revert(); + bytes32 root = abi.decode(result, (bytes32)); + + inputs[1] = "src/test/scripts/getProof.ts"; + result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + DropERC1155.AllowlistProof memory alp; + alp.proof = proofs; + alp.quantityLimitPerWallet = 0; + alp.pricePerToken = 5; + alp.currency = address(0); + + vm.warp(1); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); // in allowlist + + DropERC1155.ClaimCondition[] memory conditions = new DropERC1155.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 500; + conditions[0].quantityLimitPerWallet = 10; + conditions[0].merkleRoot = root; + conditions[0].pricePerToken = 10; + conditions[0].currency = address(erc20); + + vm.prank(deployer); + drop.lazyMint(500, "ipfs://", emptyEncodedBytes); + vm.prank(deployer); + drop.setClaimConditions(_tokenId, conditions, false); + + erc20.mint(receiver, 10000); + vm.prank(receiver); + erc20.approve(address(drop), 10000); + + vm.prank(receiver, receiver); + vm.expectRevert(abi.encodeWithSelector(Drop1155.DropClaimExceedLimit.selector, 10, 100)); + drop.claim(receiver, _tokenId, 100, address(erc20), 5, alp, ""); // trying to claim more than general limit + + // vm.prank(getActor(5), getActor(5)); + vm.prank(receiver, receiver); + drop.claim(receiver, _tokenId, 10, address(erc20), 5, alp, ""); + assertEq(drop.getSupplyClaimedByWallet(_tokenId, drop.getActiveClaimConditionId(_tokenId), receiver), 10); + assertEq(erc20.balanceOf(receiver), 10000 - 50); + } + + function test_fuzz_claim_merkleProof(uint256 x) public { + vm.assume(x > 10 && x < 500); + uint256 _tokenId = 0; + string[] memory inputs = new string[](5); + + inputs[0] = "node"; + inputs[1] = "src/test/scripts/generateRoot.ts"; + inputs[2] = Strings.toString(x); + inputs[3] = "0"; + inputs[4] = "0x0000000000000000000000000000000000000000"; + + bytes memory result = vm.ffi(inputs); + // revert(); + bytes32 root = abi.decode(result, (bytes32)); + + inputs[1] = "src/test/scripts/getProof.ts"; + result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + DropERC1155.AllowlistProof memory alp; + alp.proof = proofs; + alp.quantityLimitPerWallet = x; + alp.pricePerToken = 0; + alp.currency = address(0); + + vm.warp(1); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); + + // bytes32[] memory proofs = new bytes32[](0); + + DropERC1155.ClaimCondition[] memory conditions = new DropERC1155.ClaimCondition[](1); + conditions[0].maxClaimableSupply = x; + conditions[0].quantityLimitPerWallet = 1; + conditions[0].merkleRoot = root; + + vm.prank(deployer); + drop.lazyMint(2 * x, "ipfs://", emptyEncodedBytes); + vm.prank(deployer); + drop.setClaimConditions(_tokenId, conditions, false); + + // vm.prank(getActor(5), getActor(5)); + vm.prank(receiver, receiver); + drop.claim(receiver, _tokenId, x - 5, address(0), 0, alp, ""); + assertEq(drop.getSupplyClaimedByWallet(_tokenId, drop.getActiveClaimConditionId(_tokenId), receiver), x - 5); + + vm.prank(receiver, receiver); + vm.expectRevert(abi.encodeWithSelector(Drop1155.DropClaimExceedLimit.selector, x, x + 1)); + drop.claim(receiver, _tokenId, 6, address(0), 0, alp, ""); + + vm.prank(receiver, receiver); + drop.claim(receiver, _tokenId, 5, address(0), 0, alp, ""); + assertEq(drop.getSupplyClaimedByWallet(_tokenId, drop.getActiveClaimConditionId(_tokenId), receiver), x); + + vm.prank(receiver, receiver); + vm.expectRevert(abi.encodeWithSelector(Drop1155.DropClaimExceedLimit.selector, x, x + 5)); + drop.claim(receiver, _tokenId, 5, address(0), 0, alp, ""); // quantity limit already claimed + } + + /** + * note: Testing state changes; reset eligibility of claim conditions and claiming again for same condition id. + */ + function test_state_claimCondition_resetEligibility() public { + vm.warp(1); + + uint256 _tokenId = 0; + address receiver = getActor(0); + bytes32[] memory proofs = new bytes32[](0); + + DropERC1155.AllowlistProof memory alp; + alp.proof = proofs; + + DropERC1155.ClaimCondition[] memory conditions = new DropERC1155.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 500; + conditions[0].quantityLimitPerWallet = 100; + + vm.prank(deployer); + drop.lazyMint(500, "ipfs://", emptyEncodedBytes); + + vm.prank(deployer); + drop.setClaimConditions(_tokenId, conditions, false); + + vm.prank(getActor(5), getActor(5)); + drop.claim(receiver, _tokenId, 100, address(0), 0, alp, ""); + + vm.prank(getActor(5), getActor(5)); + vm.expectRevert(abi.encodeWithSelector(Drop1155.DropClaimExceedLimit.selector, 100, 200)); + drop.claim(receiver, _tokenId, 100, address(0), 0, alp, ""); + + vm.prank(deployer); + drop.setClaimConditions(_tokenId, conditions, true); + + vm.prank(getActor(5), getActor(5)); + drop.claim(receiver, _tokenId, 100, address(0), 0, alp, ""); + } + + /*/////////////////////////////////////////////////////////////// + setClaimConditions + //////////////////////////////////////////////////////////////*/ + + function test_claimCondition_startIdAndCount() public { + vm.startPrank(deployer); + + uint256 _tokenId = 0; + uint256 currentStartId = 0; + uint256 count = 0; + + DropERC1155.ClaimCondition[] memory conditions = new DropERC1155.ClaimCondition[](2); + conditions[0].startTimestamp = 0; + conditions[0].maxClaimableSupply = 10; + conditions[1].startTimestamp = 1; + conditions[1].maxClaimableSupply = 10; + + drop.setClaimConditions(_tokenId, conditions, false); + (currentStartId, count) = drop.claimCondition(_tokenId); + assertEq(currentStartId, 0); + assertEq(count, 2); + + drop.setClaimConditions(_tokenId, conditions, false); + (currentStartId, count) = drop.claimCondition(_tokenId); + assertEq(currentStartId, 0); + assertEq(count, 2); + + drop.setClaimConditions(_tokenId, conditions, true); + (currentStartId, count) = drop.claimCondition(_tokenId); + assertEq(currentStartId, 2); + assertEq(count, 2); + + drop.setClaimConditions(_tokenId, conditions, true); + (currentStartId, count) = drop.claimCondition(_tokenId); + assertEq(currentStartId, 4); + assertEq(count, 2); + } + + function test_claimCondition_startPhase() public { + vm.startPrank(deployer); + + uint256 _tokenId = 0; + uint256 activeConditionId = 0; + + DropERC1155.ClaimCondition[] memory conditions = new DropERC1155.ClaimCondition[](3); + conditions[0].startTimestamp = 10; + conditions[0].maxClaimableSupply = 11; + conditions[0].quantityLimitPerWallet = 12; + conditions[1].startTimestamp = 20; + conditions[1].maxClaimableSupply = 21; + conditions[1].quantityLimitPerWallet = 22; + conditions[2].startTimestamp = 30; + conditions[2].maxClaimableSupply = 31; + conditions[2].quantityLimitPerWallet = 32; + drop.setClaimConditions(_tokenId, conditions, false); + + vm.expectRevert(abi.encodeWithSelector(Drop1155.DropNoActiveCondition.selector)); + drop.getActiveClaimConditionId(_tokenId); + + vm.warp(10); + activeConditionId = drop.getActiveClaimConditionId(_tokenId); + assertEq(activeConditionId, 0); + assertEq(drop.getClaimConditionById(_tokenId, activeConditionId).startTimestamp, 10); + assertEq(drop.getClaimConditionById(_tokenId, activeConditionId).maxClaimableSupply, 11); + assertEq(drop.getClaimConditionById(_tokenId, activeConditionId).quantityLimitPerWallet, 12); + + vm.warp(20); + activeConditionId = drop.getActiveClaimConditionId(_tokenId); + assertEq(activeConditionId, 1); + assertEq(drop.getClaimConditionById(_tokenId, activeConditionId).startTimestamp, 20); + assertEq(drop.getClaimConditionById(_tokenId, activeConditionId).maxClaimableSupply, 21); + assertEq(drop.getClaimConditionById(_tokenId, activeConditionId).quantityLimitPerWallet, 22); + + vm.warp(30); + activeConditionId = drop.getActiveClaimConditionId(_tokenId); + assertEq(activeConditionId, 2); + assertEq(drop.getClaimConditionById(_tokenId, activeConditionId).startTimestamp, 30); + assertEq(drop.getClaimConditionById(_tokenId, activeConditionId).maxClaimableSupply, 31); + assertEq(drop.getClaimConditionById(_tokenId, activeConditionId).quantityLimitPerWallet, 32); + + vm.warp(40); + assertEq(drop.getActiveClaimConditionId(_tokenId), 2); + } + + /*/////////////////////////////////////////////////////////////// + Unit Test: updateBatchBaseURI + //////////////////////////////////////////////////////////////*/ + + function test_state_updateBatchBaseURI() public { + string memory initURI = "ipfs://init"; + string memory newURI = "ipfs://new"; + + vm.startPrank(deployer); + drop.lazyMint(100, initURI, ""); + + string memory initTokenURI = drop.uri(0); + + assertEq(initTokenURI, string(abi.encodePacked(initURI, "0"))); + + drop.updateBatchBaseURI(0, newURI); + + string memory newTokenURI = drop.uri(0); + + assertEq(newTokenURI, string(abi.encodePacked(newURI, "0"))); + } + + /*/////////////////////////////////////////////////////////////// + Unit Test: freezeBatchBaseURI + //////////////////////////////////////////////////////////////*/ + + function test_state_freezeBatchBaseURI() public { + string memory initURI = "ipfs://init"; + + vm.startPrank(deployer); + drop.lazyMint(100, initURI, ""); + + string memory initTokenURI = drop.uri(0); + + assertEq(initTokenURI, string(abi.encodePacked(initURI, "0"))); + + drop.freezeBatchBaseURI(0); + + assertEq(drop.batchFrozen(100), true); + } + + /*/////////////////////////////////////////////////////////////// + Unit Test: setMaxTotalSupply + //////////////////////////////////////////////////////////////*/ + + function test_state_setMaxTotalSupply() public { + vm.startPrank(deployer); + drop.setMaxTotalSupply(1, 100); + + assertEq(drop.maxTotalSupply(1), 100); + } + + function test_event_setMaxTotalSupply_MaxTotalSupplyUpdated() public { + vm.startPrank(deployer); + + vm.expectEmit(true, false, false, true); + emit MaxTotalSupplyUpdated(1, 100); + drop.setMaxTotalSupply(1, 100); + } + + /*/////////////////////////////////////////////////////////////// + Miscellaneous + //////////////////////////////////////////////////////////////*/ +} diff --git a/src/test/drop/DropERC20.t.sol b/src/test/drop/DropERC20.t.sol new file mode 100644 index 000000000..f0958a5fb --- /dev/null +++ b/src/test/drop/DropERC20.t.sol @@ -0,0 +1,716 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { DropERC20, Permissions, Drop } from "contracts/prebuilts/drop/DropERC20.sol"; + +// Test imports + +import "../utils/BaseTest.sol"; +import "@openzeppelin/contracts-upgradeable/utils/StringsUpgradeable.sol"; + +contract DropERC20Test is BaseTest { + using Strings for uint256; + using Strings for address; + + DropERC20 public drop; + + using stdStorage for StdStorage; + + function setUp() public override { + super.setUp(); + drop = DropERC20(getContract("DropERC20")); + + erc20.mint(deployer, 1_000 ether); + vm.deal(deployer, 1_000 ether); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: misc. + //////////////////////////////////////////////////////////////*/ + + /** + * note: Tests whether contract reverts when a non-holder renounces a role. + */ + function test_revert_nonHolder_renounceRole() public { + address caller = address(0x123); + bytes32 role = keccak256("TRANSFER_ROLE"); + + vm.prank(caller); + vm.expectRevert(abi.encodeWithSelector(Permissions.PermissionsUnauthorizedAccount.selector, caller, role)); + drop.renounceRole(role, caller); + } + + /** + * note: Tests whether contract reverts when a role admin revokes a role for a non-holder. + */ + function test_revert_revokeRoleForNonHolder() public { + address target = address(0x123); + bytes32 role = keccak256("TRANSFER_ROLE"); + + vm.prank(deployer); + vm.expectRevert(abi.encodeWithSelector(Permissions.PermissionsUnauthorizedAccount.selector, target, role)); + drop.revokeRole(role, target); + } + + /** + * @dev Tests whether contract reverts when a role is granted to an existent role holder. + */ + function test_revert_grant_role_to_account_with_role() public { + bytes32 role = keccak256("ABC_ROLE"); + address receiver = getActor(0); + + vm.startPrank(deployer); + + drop.grantRole(role, receiver); + + vm.expectRevert(abi.encodeWithSelector(Permissions.PermissionsAlreadyGranted.selector, receiver, role)); + drop.grantRole(role, receiver); + + vm.stopPrank(); + } + + /** + * @dev Tests contract state for Transfer role. + */ + function test_state_grant_transferRole() public { + bytes32 role = keccak256("TRANSFER_ROLE"); + + // check if admin and address(0) have transfer role in the beginning + bool checkAddressZero = drop.hasRole(role, address(0)); + bool checkAdmin = drop.hasRole(role, deployer); + assertTrue(checkAddressZero); + assertTrue(checkAdmin); + + // check if transfer role can be granted to a non-holder + address receiver = getActor(0); + vm.startPrank(deployer); + drop.grantRole(role, receiver); + + // expect revert when granting to a holder + vm.expectRevert(abi.encodeWithSelector(Permissions.PermissionsAlreadyGranted.selector, receiver, role)); + drop.grantRole(role, receiver); + + // check if receiver has transfer role + bool checkReceiver = drop.hasRole(role, receiver); + assertTrue(checkReceiver); + + // check if role is correctly revoked + drop.revokeRole(role, receiver); + checkReceiver = drop.hasRole(role, receiver); + assertFalse(checkReceiver); + drop.revokeRole(role, address(0)); + checkAddressZero = drop.hasRole(role, address(0)); + assertFalse(checkAddressZero); + + vm.stopPrank(); + } + + /** + * @dev Tests contract state for Transfer role. + */ + function test_state_getRoleMember_transferRole() public { + bytes32 role = keccak256("TRANSFER_ROLE"); + + uint256 roleMemberCount = drop.getRoleMemberCount(role); + assertEq(roleMemberCount, 2); + + address roleMember = drop.getRoleMember(role, 1); + assertEq(roleMember, address(0)); + + vm.startPrank(deployer); + drop.grantRole(role, address(2)); + drop.grantRole(role, address(3)); + drop.grantRole(role, address(4)); + + roleMemberCount = drop.getRoleMemberCount(role); + console.log(roleMemberCount); + for (uint256 i = 0; i < roleMemberCount; i++) { + console.log(drop.getRoleMember(role, i)); + } + console.log(""); + + drop.revokeRole(role, address(2)); + roleMemberCount = drop.getRoleMemberCount(role); + console.log(roleMemberCount); + for (uint256 i = 0; i < roleMemberCount; i++) { + console.log(drop.getRoleMember(role, i)); + } + console.log(""); + + drop.revokeRole(role, address(0)); + roleMemberCount = drop.getRoleMemberCount(role); + console.log(roleMemberCount); + for (uint256 i = 0; i < roleMemberCount; i++) { + console.log(drop.getRoleMember(role, i)); + } + console.log(""); + + drop.grantRole(role, address(5)); + roleMemberCount = drop.getRoleMemberCount(role); + console.log(roleMemberCount); + for (uint256 i = 0; i < roleMemberCount; i++) { + console.log(drop.getRoleMember(role, i)); + } + console.log(""); + + drop.grantRole(role, address(0)); + roleMemberCount = drop.getRoleMemberCount(role); + console.log(roleMemberCount); + for (uint256 i = 0; i < roleMemberCount; i++) { + console.log(drop.getRoleMember(role, i)); + } + console.log(""); + + drop.grantRole(role, address(6)); + roleMemberCount = drop.getRoleMemberCount(role); + console.log(roleMemberCount); + for (uint256 i = 0; i < roleMemberCount; i++) { + console.log(drop.getRoleMember(role, i)); + } + console.log(""); + } + + /** + * note: Testing transfer of tokens when transfer-role is restricted + */ + function test_claim_transferRole() public { + vm.warp(1); + + address receiver = getActor(0); + bytes32[] memory proofs = new bytes32[](0); + + DropERC20.AllowlistProof memory alp; + alp.proof = proofs; + + DropERC20.ClaimCondition[] memory conditions = new DropERC20.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 100; + conditions[0].quantityLimitPerWallet = 100; + + vm.prank(deployer); + drop.setClaimConditions(conditions, false); + + vm.prank(getActor(5), getActor(5)); + drop.claim(receiver, 1, address(0), 0, alp, ""); + + // revoke transfer role from address(0) + vm.prank(deployer); + drop.revokeRole(keccak256("TRANSFER_ROLE"), address(0)); + vm.startPrank(receiver); + vm.expectRevert("transfers restricted."); + drop.transferFrom(receiver, address(123), 0); + } + + /** + * @dev Tests whether role member count is incremented correctly. + */ + function test_member_count_incremented_properly_when_role_granted() public { + bytes32 role = keccak256("ABC_ROLE"); + address receiver = getActor(0); + + vm.startPrank(deployer); + uint256 roleMemberCount = drop.getRoleMemberCount(role); + + assertEq(roleMemberCount, 0); + + drop.grantRole(role, receiver); + + assertEq(drop.getRoleMemberCount(role), 1); + + vm.stopPrank(); + } + + function test_claimCondition_with_startTimestamp() public { + vm.warp(1); + + address receiver = getActor(0); + bytes32[] memory proofs = new bytes32[](0); + + DropERC20.AllowlistProof memory alp; + alp.proof = proofs; + + DropERC20.ClaimCondition[] memory conditions = new DropERC20.ClaimCondition[](1); + conditions[0].startTimestamp = 100; + conditions[0].maxClaimableSupply = 100; + conditions[0].quantityLimitPerWallet = 100; + + vm.prank(deployer); + drop.setClaimConditions(conditions, false); + + vm.warp(99); + vm.prank(getActor(5), getActor(5)); + vm.expectRevert(abi.encodeWithSelector(Drop.DropNoActiveCondition.selector)); + drop.claim(receiver, 1, address(0), 0, alp, ""); + + vm.warp(100); + vm.prank(getActor(4), getActor(4)); + drop.claim(receiver, 1, address(0), 0, alp, ""); + } + + /*/////////////////////////////////////////////////////////////// + Claim Tests + //////////////////////////////////////////////////////////////*/ + + /** + * note: Testing revert condition; exceed max claimable supply. + */ + function test_revert_claimCondition_exceedMaxClaimableSupply() public { + vm.warp(1); + + address receiver = getActor(0); + bytes32[] memory proofs = new bytes32[](0); + + DropERC20.AllowlistProof memory alp; + alp.proof = proofs; + + DropERC20.ClaimCondition[] memory conditions = new DropERC20.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 100; + conditions[0].quantityLimitPerWallet = 200; + + vm.prank(deployer); + drop.setClaimConditions(conditions, false); + + vm.prank(getActor(5), getActor(5)); + drop.claim(receiver, 100, address(0), 0, alp, ""); + + vm.expectRevert( + abi.encodeWithSelector(Drop.DropClaimExceedMaxSupply.selector, conditions[0].maxClaimableSupply, 101) + ); + vm.prank(getActor(6), getActor(6)); + drop.claim(receiver, 1, address(0), 0, alp, ""); + } + + /** + * note: Testing quantity limit restriction when no allowlist present. + */ + function test_fuzz_claim_noAllowlist(uint256 x) public { + vm.assume(x != 0); + vm.warp(1); + + address receiver = getActor(0); + bytes32[] memory proofs = new bytes32[](0); + + DropERC20.AllowlistProof memory alp; + alp.proof = proofs; + alp.quantityLimitPerWallet = x; + + DropERC20.ClaimCondition[] memory conditions = new DropERC20.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 500; + conditions[0].quantityLimitPerWallet = 100; + + vm.prank(deployer); + drop.setClaimConditions(conditions, false); + + vm.prank(getActor(5), getActor(5)); + vm.expectRevert( + abi.encodeWithSelector(Drop.DropClaimExceedLimit.selector, conditions[0].quantityLimitPerWallet, 0) + ); + drop.claim(receiver, 0, address(0), 0, alp, ""); + + vm.prank(getActor(5), getActor(5)); + vm.expectRevert( + abi.encodeWithSelector(Drop.DropClaimExceedLimit.selector, conditions[0].quantityLimitPerWallet, 101) + ); + drop.claim(receiver, 101, address(0), 0, alp, ""); + + vm.prank(deployer); + drop.setClaimConditions(conditions, true); + + vm.prank(getActor(5), getActor(5)); + vm.expectRevert( + abi.encodeWithSelector(Drop.DropClaimExceedLimit.selector, conditions[0].quantityLimitPerWallet, 101) + ); + drop.claim(receiver, 101, address(0), 0, alp, ""); + } + + /** + * note: Testing quantity limit restriction + * - allowlist quantity set to some value different than general limit + * - allowlist price set to 0 + */ + function test_state_claim_allowlisted_SetQuantityZeroPrice() public { + string[] memory inputs = new string[](5); + + inputs[0] = "node"; + inputs[1] = "src/test/scripts/generateRoot.ts"; + inputs[2] = "300"; + inputs[3] = "0"; + inputs[4] = Strings.toHexString(uint160(address(erc20))); // address of erc20 + + bytes memory result = vm.ffi(inputs); + // revert(); + bytes32 root = abi.decode(result, (bytes32)); + + inputs[1] = "src/test/scripts/getProof.ts"; + result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + DropERC20.AllowlistProof memory alp; + alp.proof = proofs; + alp.quantityLimitPerWallet = 300; + alp.pricePerToken = 0; + alp.currency = address(erc20); + + vm.warp(1); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); // in allowlist + + DropERC20.ClaimCondition[] memory conditions = new DropERC20.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 500; + conditions[0].quantityLimitPerWallet = 10; + conditions[0].merkleRoot = root; + conditions[0].pricePerToken = 10; + conditions[0].currency = address(erc20); + + vm.prank(deployer); + drop.setClaimConditions(conditions, false); + + // vm.prank(getActor(5), getActor(5)); + vm.prank(receiver, receiver); + drop.claim(receiver, 100, address(erc20), 0, alp, ""); // claims for free, because allowlist price is 0 + assertEq(drop.getSupplyClaimedByWallet(drop.getActiveClaimConditionId(), receiver), 100); + } + + /** + * note: Testing quantity limit restriction + * - allowlist quantity set to some value different than general limit + * - allowlist price set to non-zero value + */ + function test_state_claim_allowlisted_SetQuantityPrice() public { + string[] memory inputs = new string[](5); + + inputs[0] = "node"; + inputs[1] = "src/test/scripts/generateRoot.ts"; + inputs[2] = Strings.toString(uint256(300 ether)); + inputs[3] = Strings.toString(uint256(1 ether)); + inputs[4] = Strings.toHexString(uint160(address(erc20))); // address of erc20 + + bytes memory result = vm.ffi(inputs); + // revert(); + bytes32 root = abi.decode(result, (bytes32)); + + inputs[1] = "src/test/scripts/getProof.ts"; + result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + DropERC20.AllowlistProof memory alp; + alp.proof = proofs; + alp.quantityLimitPerWallet = 300 ether; + alp.pricePerToken = 1 ether; + alp.currency = address(erc20); + + vm.warp(1); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); // in allowlist + + DropERC20.ClaimCondition[] memory conditions = new DropERC20.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 500 ether; + conditions[0].quantityLimitPerWallet = 10 ether; + conditions[0].merkleRoot = root; + conditions[0].pricePerToken = 5 ether; + conditions[0].currency = address(erc20); + + vm.prank(deployer); + drop.setClaimConditions(conditions, false); + + vm.prank(receiver, receiver); + vm.expectRevert( + abi.encodeWithSelector(Drop.DropClaimInvalidTokenPrice.selector, address(erc20), 0, address(erc20), 1 ether) + ); + drop.claim(receiver, 100 ether, address(erc20), 0, alp, ""); + + erc20.mint(receiver, 1000 ether); + vm.prank(receiver); + erc20.approve(address(drop), 1000 ether); + + // vm.prank(getActor(5), getActor(5)); + vm.prank(receiver, receiver); + drop.claim(receiver, 100 ether, address(erc20), 1 ether, alp, ""); + assertEq(drop.getSupplyClaimedByWallet(drop.getActiveClaimConditionId(), receiver), 100 ether); + assertEq(erc20.balanceOf(receiver), 900 ether); + } + + /** + * note: Testing quantity limit restriction + * - allowlist quantity set to some value different than general limit + * - allowlist price not set; should default to general price and currency + */ + function test_state_claim_allowlisted_SetQuantityDefaultPrice() public { + string[] memory inputs = new string[](5); + + inputs[0] = "node"; + inputs[1] = "src/test/scripts/generateRoot.ts"; + inputs[2] = Strings.toString(uint256(300 ether)); + inputs[3] = Strings.toString(type(uint256).max); // this implies that general price is applicable + inputs[4] = "0x0000000000000000000000000000000000000000"; + + bytes memory result = vm.ffi(inputs); + // revert(); + bytes32 root = abi.decode(result, (bytes32)); + + inputs[1] = "src/test/scripts/getProof.ts"; + result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + DropERC20.AllowlistProof memory alp; + alp.proof = proofs; + alp.quantityLimitPerWallet = 300 ether; + alp.pricePerToken = type(uint256).max; + alp.currency = address(0); + + vm.warp(1); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); // in allowlist + + DropERC20.ClaimCondition[] memory conditions = new DropERC20.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 500 ether; + conditions[0].quantityLimitPerWallet = 10; + conditions[0].merkleRoot = root; + conditions[0].pricePerToken = 10 ether; + conditions[0].currency = address(erc20); + + vm.prank(deployer); + drop.setClaimConditions(conditions, false); + + erc20.mint(receiver, 10000 ether); + vm.prank(receiver); + erc20.approve(address(drop), 10000 ether); + + // vm.prank(getActor(5), getActor(5)); + vm.prank(receiver, receiver); + drop.claim(receiver, 100 ether, address(erc20), 10 ether, alp, ""); + assertEq(drop.getSupplyClaimedByWallet(drop.getActiveClaimConditionId(), receiver), 100 ether); + assertEq(erc20.balanceOf(receiver), 10000 ether - 1000 ether); + } + + /** + * note: Testing quantity limit restriction + * - allowlist quantity set to 0 => should default to general limit + * - allowlist price set to some value different than general price + */ + function test_state_claim_allowlisted_DefaultQuantitySomePrice() public { + string[] memory inputs = new string[](5); + + inputs[0] = "node"; + inputs[1] = "src/test/scripts/generateRoot.ts"; + inputs[2] = "0"; // this implies that general limit is applicable + inputs[3] = Strings.toString(uint256(5 ether)); + inputs[4] = "0x0000000000000000000000000000000000000000"; // general currency will be applicable + + bytes memory result = vm.ffi(inputs); + // revert(); + bytes32 root = abi.decode(result, (bytes32)); + + inputs[1] = "src/test/scripts/getProof.ts"; + result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + DropERC20.AllowlistProof memory alp; + alp.proof = proofs; + alp.quantityLimitPerWallet = 0; + alp.pricePerToken = 5 ether; + alp.currency = address(0); + + vm.warp(1); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); // in allowlist + + DropERC20.ClaimCondition[] memory conditions = new DropERC20.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 500 ether; + conditions[0].quantityLimitPerWallet = 10 ether; + conditions[0].merkleRoot = root; + conditions[0].pricePerToken = 10 ether; + conditions[0].currency = address(erc20); + + vm.prank(deployer); + drop.setClaimConditions(conditions, false); + + erc20.mint(receiver, 10000 ether); + vm.prank(receiver); + erc20.approve(address(drop), 10000 ether); + + vm.prank(receiver, receiver); + vm.expectRevert( + abi.encodeWithSelector(Drop.DropClaimExceedLimit.selector, conditions[0].quantityLimitPerWallet, 100 ether) + ); + drop.claim(receiver, 100 ether, address(erc20), 5 ether, alp, ""); // trying to claim more than general limit + + // vm.prank(getActor(5), getActor(5)); + vm.prank(receiver, receiver); + drop.claim(receiver, 10 ether, address(erc20), 5 ether, alp, ""); + assertEq(drop.getSupplyClaimedByWallet(drop.getActiveClaimConditionId(), receiver), 10 ether); + assertEq(erc20.balanceOf(receiver), 10000 ether - 50 ether); + } + + function test_fuzz_claim_merkleProof(uint256 x) public { + vm.assume(x > 10 && x < 500); + string[] memory inputs = new string[](5); + + inputs[0] = "node"; + inputs[1] = "src/test/scripts/generateRoot.ts"; + inputs[2] = Strings.toString(x); + inputs[3] = "0"; + inputs[4] = "0x0000000000000000000000000000000000000000"; + + bytes memory result = vm.ffi(inputs); + // revert(); + bytes32 root = abi.decode(result, (bytes32)); + + inputs[1] = "src/test/scripts/getProof.ts"; + result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + DropERC20.AllowlistProof memory alp; + alp.proof = proofs; + alp.quantityLimitPerWallet = x; + alp.pricePerToken = 0; + alp.currency = address(0); + + vm.warp(1); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); + + // bytes32[] memory proofs = new bytes32[](0); + + DropERC20.ClaimCondition[] memory conditions = new DropERC20.ClaimCondition[](1); + conditions[0].maxClaimableSupply = x; + conditions[0].quantityLimitPerWallet = 1; + conditions[0].merkleRoot = root; + + vm.prank(deployer); + drop.setClaimConditions(conditions, false); + + // vm.prank(getActor(5), getActor(5)); + vm.prank(receiver, receiver); + drop.claim(receiver, x - 5, address(0), 0, alp, ""); + assertEq(drop.getSupplyClaimedByWallet(drop.getActiveClaimConditionId(), receiver), x - 5); + + vm.prank(receiver, receiver); + vm.expectRevert(abi.encodeWithSelector(Drop.DropClaimExceedLimit.selector, x, x + 1)); + drop.claim(receiver, 6, address(0), 0, alp, ""); + + vm.prank(receiver, receiver); + drop.claim(receiver, 5, address(0), 0, alp, ""); + assertEq(drop.getSupplyClaimedByWallet(drop.getActiveClaimConditionId(), receiver), x); + + vm.prank(receiver, receiver); + vm.expectRevert(abi.encodeWithSelector(Drop.DropClaimExceedLimit.selector, x, x + 5)); + drop.claim(receiver, 5, address(0), 0, alp, ""); // quantity limit already claimed + } + + /** + * note: Testing state changes; reset eligibility of claim conditions and claiming again for same condition id. + */ + function test_state_claimCondition_resetEligibility() public { + vm.warp(1); + + address receiver = getActor(0); + bytes32[] memory proofs = new bytes32[](0); + + DropERC20.AllowlistProof memory alp; + alp.proof = proofs; + + DropERC20.ClaimCondition[] memory conditions = new DropERC20.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 500; + conditions[0].quantityLimitPerWallet = 100; + + vm.prank(deployer); + drop.setClaimConditions(conditions, false); + + vm.prank(getActor(5), getActor(5)); + drop.claim(receiver, 100, address(0), 0, alp, ""); + + vm.prank(getActor(5), getActor(5)); + vm.expectRevert( + abi.encodeWithSelector(Drop.DropClaimExceedLimit.selector, conditions[0].quantityLimitPerWallet, 200) + ); + drop.claim(receiver, 100, address(0), 0, alp, ""); + + vm.prank(deployer); + drop.setClaimConditions(conditions, true); + + vm.prank(getActor(5), getActor(5)); + drop.claim(receiver, 100, address(0), 0, alp, ""); + } + + /*/////////////////////////////////////////////////////////////// + setClaimConditions + //////////////////////////////////////////////////////////////*/ + + function test_claimCondition_startIdAndCount() public { + vm.startPrank(deployer); + + uint256 currentStartId = 0; + uint256 count = 0; + + DropERC20.ClaimCondition[] memory conditions = new DropERC20.ClaimCondition[](2); + conditions[0].startTimestamp = 0; + conditions[0].maxClaimableSupply = 10; + conditions[1].startTimestamp = 1; + conditions[1].maxClaimableSupply = 10; + + drop.setClaimConditions(conditions, false); + (currentStartId, count) = drop.claimCondition(); + assertEq(currentStartId, 0); + assertEq(count, 2); + + drop.setClaimConditions(conditions, false); + (currentStartId, count) = drop.claimCondition(); + assertEq(currentStartId, 0); + assertEq(count, 2); + + drop.setClaimConditions(conditions, true); + (currentStartId, count) = drop.claimCondition(); + assertEq(currentStartId, 2); + assertEq(count, 2); + + drop.setClaimConditions(conditions, true); + (currentStartId, count) = drop.claimCondition(); + assertEq(currentStartId, 4); + assertEq(count, 2); + } + + function test_claimCondition_startPhase() public { + vm.startPrank(deployer); + + uint256 activeConditionId = 0; + + DropERC20.ClaimCondition[] memory conditions = new DropERC20.ClaimCondition[](3); + conditions[0].startTimestamp = 10; + conditions[0].maxClaimableSupply = 11; + conditions[0].quantityLimitPerWallet = 12; + conditions[1].startTimestamp = 20; + conditions[1].maxClaimableSupply = 21; + conditions[1].quantityLimitPerWallet = 22; + conditions[2].startTimestamp = 30; + conditions[2].maxClaimableSupply = 31; + conditions[2].quantityLimitPerWallet = 32; + drop.setClaimConditions(conditions, false); + + vm.expectRevert(abi.encodeWithSelector(Drop.DropNoActiveCondition.selector)); + drop.getActiveClaimConditionId(); + + vm.warp(10); + activeConditionId = drop.getActiveClaimConditionId(); + assertEq(activeConditionId, 0); + assertEq(drop.getClaimConditionById(activeConditionId).startTimestamp, 10); + assertEq(drop.getClaimConditionById(activeConditionId).maxClaimableSupply, 11); + assertEq(drop.getClaimConditionById(activeConditionId).quantityLimitPerWallet, 12); + + vm.warp(20); + activeConditionId = drop.getActiveClaimConditionId(); + assertEq(activeConditionId, 1); + assertEq(drop.getClaimConditionById(activeConditionId).startTimestamp, 20); + assertEq(drop.getClaimConditionById(activeConditionId).maxClaimableSupply, 21); + assertEq(drop.getClaimConditionById(activeConditionId).quantityLimitPerWallet, 22); + + vm.warp(30); + activeConditionId = drop.getActiveClaimConditionId(); + assertEq(activeConditionId, 2); + assertEq(drop.getClaimConditionById(activeConditionId).startTimestamp, 30); + assertEq(drop.getClaimConditionById(activeConditionId).maxClaimableSupply, 31); + assertEq(drop.getClaimConditionById(activeConditionId).quantityLimitPerWallet, 32); + + vm.warp(40); + assertEq(drop.getActiveClaimConditionId(), 2); + } +} diff --git a/src/test/drop/DropERC721.t.sol b/src/test/drop/DropERC721.t.sol new file mode 100644 index 000000000..dbf660c09 --- /dev/null +++ b/src/test/drop/DropERC721.t.sol @@ -0,0 +1,1203 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { DropERC721, Permissions, LazyMint, BatchMintMetadata, Drop, DelayedReveal, IDelayedReveal, ERC721AUpgradeable, IPermissions, ILazyMint } from "contracts/prebuilts/drop/DropERC721.sol"; + +// Test imports +import "erc721a-upgradeable/contracts/IERC721AUpgradeable.sol"; + +import "../utils/BaseTest.sol"; +import "@openzeppelin/contracts-upgradeable/utils/StringsUpgradeable.sol"; + +contract DropERC721Test is BaseTest { + using Strings for uint256; + using Strings for address; + + event TokensLazyMinted(uint256 indexed startTokenId, uint256 endTokenId, string baseURI, bytes encryptedBaseURI); + event TokenURIRevealed(uint256 indexed index, string revealedURI); + event MaxTotalSupplyUpdated(uint256 maxTotalSupply); + + DropERC721 public drop; + + bytes private emptyEncodedBytes = abi.encode("", ""); + + using stdStorage for StdStorage; + + function setUp() public override { + super.setUp(); + drop = DropERC721(getContract("DropERC721")); + + erc20.mint(deployer, 1_000 ether); + vm.deal(deployer, 1_000 ether); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: misc. + //////////////////////////////////////////////////////////////*/ + + /** + * note: Tests whether contract reverts when a non-holder renounces a role. + */ + function test_revert_nonHolder_renounceRole() public { + address caller = address(0x123); + bytes32 role = keccak256("MINTER_ROLE"); + + vm.prank(caller); + vm.expectRevert(abi.encodeWithSelector(Permissions.PermissionsUnauthorizedAccount.selector, caller, role)); + drop.renounceRole(role, caller); + } + + /** + * note: Tests whether contract reverts when a role admin revokes a role for a non-holder. + */ + function test_revert_revokeRoleForNonHolder() public { + address target = address(0x123); + bytes32 role = keccak256("MINTER_ROLE"); + + vm.prank(deployer); + vm.expectRevert(abi.encodeWithSelector(Permissions.PermissionsUnauthorizedAccount.selector, target, role)); + drop.revokeRole(role, target); + } + + /** + * @dev Tests whether contract reverts when a role is granted to an existent role holder. + */ + function test_revert_grant_role_to_account_with_role() public { + bytes32 role = keccak256("ABC_ROLE"); + address receiver = getActor(0); + + vm.startPrank(deployer); + + drop.grantRole(role, receiver); + + vm.expectRevert(abi.encodeWithSelector(Permissions.PermissionsAlreadyGranted.selector, receiver, role)); + drop.grantRole(role, receiver); + + vm.stopPrank(); + } + + /** + * @dev Tests contract state for Transfer role. + */ + function test_state_grant_transferRole() public { + bytes32 role = keccak256("TRANSFER_ROLE"); + + // check if admin and address(0) have transfer role in the beginning + bool checkAddressZero = drop.hasRole(role, address(0)); + bool checkAdmin = drop.hasRole(role, deployer); + assertTrue(checkAddressZero); + assertTrue(checkAdmin); + + // check if transfer role can be granted to a non-holder + address receiver = getActor(0); + vm.startPrank(deployer); + drop.grantRole(role, receiver); + + // expect revert when granting to a holder + vm.expectRevert(abi.encodeWithSelector(Permissions.PermissionsAlreadyGranted.selector, receiver, role)); + drop.grantRole(role, receiver); + + // check if receiver has transfer role + bool checkReceiver = drop.hasRole(role, receiver); + assertTrue(checkReceiver); + + // check if role is correctly revoked + drop.revokeRole(role, receiver); + checkReceiver = drop.hasRole(role, receiver); + assertFalse(checkReceiver); + drop.revokeRole(role, address(0)); + checkAddressZero = drop.hasRole(role, address(0)); + assertFalse(checkAddressZero); + + vm.stopPrank(); + } + + /** + * @dev Tests contract state for Transfer role. + */ + function test_state_getRoleMember_transferRole() public { + bytes32 role = keccak256("TRANSFER_ROLE"); + + uint256 roleMemberCount = drop.getRoleMemberCount(role); + assertEq(roleMemberCount, 2); + + address roleMember = drop.getRoleMember(role, 1); + assertEq(roleMember, address(0)); + + vm.startPrank(deployer); + drop.grantRole(role, address(2)); + drop.grantRole(role, address(3)); + drop.grantRole(role, address(4)); + + roleMemberCount = drop.getRoleMemberCount(role); + assertEq(roleMemberCount, 5); + console.log(roleMemberCount); + for (uint256 i = 0; i < roleMemberCount; i++) { + console.log(drop.getRoleMember(role, i)); + } + console.log(""); + + drop.revokeRole(role, address(2)); + roleMemberCount = drop.getRoleMemberCount(role); + assertEq(roleMemberCount, 4); + console.log(roleMemberCount); + for (uint256 i = 0; i < roleMemberCount; i++) { + console.log(drop.getRoleMember(role, i)); + } + console.log(""); + + drop.revokeRole(role, address(0)); + roleMemberCount = drop.getRoleMemberCount(role); + assertEq(roleMemberCount, 3); + console.log(roleMemberCount); + for (uint256 i = 0; i < roleMemberCount; i++) { + console.log(drop.getRoleMember(role, i)); + } + console.log(""); + + drop.grantRole(role, address(5)); + roleMemberCount = drop.getRoleMemberCount(role); + assertEq(roleMemberCount, 4); + console.log(roleMemberCount); + for (uint256 i = 0; i < roleMemberCount; i++) { + console.log(drop.getRoleMember(role, i)); + } + console.log(""); + + drop.grantRole(role, address(0)); + roleMemberCount = drop.getRoleMemberCount(role); + assertEq(roleMemberCount, 5); + console.log(roleMemberCount); + for (uint256 i = 0; i < roleMemberCount; i++) { + console.log(drop.getRoleMember(role, i)); + } + console.log(""); + + drop.grantRole(role, address(6)); + roleMemberCount = drop.getRoleMemberCount(role); + assertEq(roleMemberCount, 6); + console.log(roleMemberCount); + for (uint256 i = 0; i < roleMemberCount; i++) { + console.log(drop.getRoleMember(role, i)); + } + console.log(""); + + drop.revokeRole(role, address(0)); + roleMemberCount = drop.getRoleMemberCount(role); + assertEq(roleMemberCount, 5); + console.log(roleMemberCount); + for (uint256 i = 0; i < roleMemberCount; i++) { + console.log(drop.getRoleMember(role, i)); + } + console.log(""); + + drop.revokeRole(role, address(4)); + roleMemberCount = drop.getRoleMemberCount(role); + assertEq(roleMemberCount, 4); + console.log(roleMemberCount); + for (uint256 i = 0; i < roleMemberCount; i++) { + console.log(drop.getRoleMember(role, i)); + } + console.log(""); + } + + /** + * note: Testing transfer of tokens when transfer-role is restricted + */ + function test_claim_transferRole() public { + vm.warp(1); + + address receiver = getActor(0); + bytes32[] memory proofs = new bytes32[](0); + + DropERC721.AllowlistProof memory alp; + alp.proof = proofs; + + DropERC721.ClaimCondition[] memory conditions = new DropERC721.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 100; + conditions[0].quantityLimitPerWallet = 100; + + vm.prank(deployer); + drop.lazyMint(100, "ipfs://", emptyEncodedBytes); + vm.prank(deployer); + drop.setClaimConditions(conditions, false); + + vm.prank(getActor(5), getActor(5)); + drop.claim(receiver, 1, address(0), 0, alp, ""); + + // revoke transfer role from address(0) + vm.prank(deployer); + drop.revokeRole(keccak256("TRANSFER_ROLE"), address(0)); + vm.startPrank(receiver); + vm.expectRevert("!Transfer-Role"); + drop.transferFrom(receiver, address(123), 0); + } + + /** + * @dev Tests whether role member count is incremented correctly. + */ + function test_member_count_incremented_properly_when_role_granted() public { + bytes32 role = keccak256("ABC_ROLE"); + address receiver = getActor(0); + + vm.startPrank(deployer); + uint256 roleMemberCount = drop.getRoleMemberCount(role); + + assertEq(roleMemberCount, 0); + + drop.grantRole(role, receiver); + + assertEq(drop.getRoleMemberCount(role), 1); + + vm.stopPrank(); + } + + function test_claimCondition_with_startTimestamp() public { + vm.warp(1); + + address receiver = getActor(0); + bytes32[] memory proofs = new bytes32[](0); + + DropERC721.AllowlistProof memory alp; + alp.proof = proofs; + + DropERC721.ClaimCondition[] memory conditions = new DropERC721.ClaimCondition[](1); + conditions[0].startTimestamp = 100; + conditions[0].maxClaimableSupply = 100; + conditions[0].quantityLimitPerWallet = 100; + + vm.prank(deployer); + drop.lazyMint(100, "ipfs://", emptyEncodedBytes); + + vm.prank(deployer); + drop.setClaimConditions(conditions, false); + + vm.warp(99); + vm.prank(getActor(5), getActor(5)); + vm.expectRevert(abi.encodeWithSelector(Drop.DropNoActiveCondition.selector)); + drop.claim(receiver, 1, address(0), 0, alp, ""); + + vm.warp(100); + vm.prank(getActor(4), getActor(4)); + drop.claim(receiver, 1, address(0), 0, alp, ""); + } + + /*/////////////////////////////////////////////////////////////// + Lazy Mint Tests + //////////////////////////////////////////////////////////////*/ + + /* + * note: Testing state changes; lazy mint a batch of tokens with no encrypted base URI. + */ + function test_state_lazyMint_noEncryptedURI() public { + uint256 amountToLazyMint = 100; + string memory baseURI = "ipfs://"; + + uint256 nextTokenIdToMintBefore = drop.nextTokenIdToMint(); + + vm.startPrank(deployer); + uint256 batchId = drop.lazyMint(amountToLazyMint, baseURI, emptyEncodedBytes); + + assertEq(nextTokenIdToMintBefore + amountToLazyMint, drop.nextTokenIdToMint()); + assertEq(nextTokenIdToMintBefore + amountToLazyMint, batchId); + + for (uint256 i = 0; i < amountToLazyMint; i += 1) { + string memory uri = drop.tokenURI(i); + console.log(uri); + assertEq(uri, string(abi.encodePacked(baseURI, i.toString()))); + } + + vm.stopPrank(); + } + + /* + * note: Testing state changes; lazy mint a batch of tokens with encrypted base URI. + */ + function test_state_lazyMint_withEncryptedURI() public { + uint256 amountToLazyMint = 100; + string memory baseURI = "ipfs://"; + bytes memory encryptedBaseURI = "encryptedBaseURI://"; + bytes32 provenanceHash = bytes32("whatever"); + + uint256 nextTokenIdToMintBefore = drop.nextTokenIdToMint(); + + vm.startPrank(deployer); + uint256 batchId = drop.lazyMint(amountToLazyMint, baseURI, abi.encode(encryptedBaseURI, provenanceHash)); + + assertEq(nextTokenIdToMintBefore + amountToLazyMint, drop.nextTokenIdToMint()); + assertEq(nextTokenIdToMintBefore + amountToLazyMint, batchId); + + for (uint256 i = 0; i < amountToLazyMint; i += 1) { + string memory uri = drop.tokenURI(i); + console.log(uri); + assertEq(uri, string(abi.encodePacked(baseURI, "0"))); + } + + vm.stopPrank(); + } + + /** + * note: Testing revert condition; an address without MINTER_ROLE calls lazyMint function. + */ + function test_revert_lazyMint_MINTER_ROLE() public { + vm.expectRevert(abi.encodeWithSelector(LazyMint.LazyMintUnauthorized.selector)); + drop.lazyMint(100, "ipfs://", emptyEncodedBytes); + } + + /* + * note: Testing revert condition; calling tokenURI for invalid batch id. + */ + function test_revert_lazyMint_URIForNonLazyMintedToken() public { + vm.startPrank(deployer); + + drop.lazyMint(100, "ipfs://", emptyEncodedBytes); + + vm.expectRevert(abi.encodeWithSelector(BatchMintMetadata.BatchMintInvalidTokenId.selector, 100)); + drop.tokenURI(100); + + vm.stopPrank(); + } + + /** + * note: Testing event emission; tokens lazy minted. + */ + function test_event_lazyMint_TokensLazyMinted() public { + vm.startPrank(deployer); + + vm.expectEmit(true, false, false, true); + emit TokensLazyMinted(0, 99, "ipfs://", emptyEncodedBytes); + drop.lazyMint(100, "ipfs://", emptyEncodedBytes); + + vm.stopPrank(); + } + + /* + * note: Fuzz testing state changes; lazy mint a batch of tokens with no encrypted base URI. + */ + function test_fuzz_lazyMint_noEncryptedURI(uint256 x) public { + vm.assume(x > 0); + + uint256 amountToLazyMint = x; + string memory baseURI = "ipfs://"; + + uint256 nextTokenIdToMintBefore = drop.nextTokenIdToMint(); + + vm.startPrank(deployer); + uint256 batchId = drop.lazyMint(amountToLazyMint, baseURI, emptyEncodedBytes); + + assertEq(nextTokenIdToMintBefore + amountToLazyMint, drop.nextTokenIdToMint()); + assertEq(nextTokenIdToMintBefore + amountToLazyMint, batchId); + + string memory uri = drop.tokenURI(0); + assertEq(uri, string(abi.encodePacked(baseURI, uint256(0).toString()))); + + uri = drop.tokenURI(x - 1); + assertEq(uri, string(abi.encodePacked(baseURI, uint256(x - 1).toString()))); + + /** + * note: this loop takes too long to run with fuzz tests. + */ + // for(uint256 i = 0; i < amountToLazyMint; i += 1) { + // string memory uri = drop.tokenURI(i); + // console.log(uri); + // assertEq(uri, string(abi.encodePacked(baseURI, i.toString()))); + // } + + vm.stopPrank(); + } + + /* + * note: Fuzz testing state changes; lazy mint a batch of tokens with encrypted base URI. + */ + function test_fuzz_lazyMint_withEncryptedURI(uint256 x) public { + vm.assume(x > 0); + + uint256 amountToLazyMint = x; + string memory baseURI = "ipfs://"; + bytes memory encryptedBaseURI = "encryptedBaseURI://"; + bytes32 provenanceHash = bytes32("whatever"); + + uint256 nextTokenIdToMintBefore = drop.nextTokenIdToMint(); + + vm.startPrank(deployer); + uint256 batchId = drop.lazyMint(amountToLazyMint, baseURI, abi.encode(encryptedBaseURI, provenanceHash)); + + assertEq(nextTokenIdToMintBefore + amountToLazyMint, drop.nextTokenIdToMint()); + assertEq(nextTokenIdToMintBefore + amountToLazyMint, batchId); + + string memory uri = drop.tokenURI(0); + assertEq(uri, string(abi.encodePacked(baseURI, "0"))); + + uri = drop.tokenURI(x - 1); + assertEq(uri, string(abi.encodePacked(baseURI, "0"))); + + /** + * note: this loop takes too long to run with fuzz tests. + */ + // for(uint256 i = 0; i < amountToLazyMint; i += 1) { + // string memory uri = drop.tokenURI(1); + // assertEq(uri, string(abi.encodePacked(baseURI, "0"))); + // } + + vm.stopPrank(); + } + + /* + * note: Fuzz testing; a batch of tokens, and nextTokenIdToMint + */ + function test_fuzz_lazyMint_batchMintAndNextTokenIdToMint(uint256 x) public { + vm.assume(x > 0); + vm.startPrank(deployer); + + if (x == 0) { + vm.expectRevert("Zero amount"); + } + drop.lazyMint(x, "ipfs://", emptyEncodedBytes); + + uint256 slot = stdstore.target(address(drop)).sig("nextTokenIdToMint()").find(); + bytes32 loc = bytes32(slot); + uint256 nextTokenIdToMint = uint256(vm.load(address(drop), loc)); + + assertEq(nextTokenIdToMint, x); + vm.stopPrank(); + } + + /*/////////////////////////////////////////////////////////////// + Delayed Reveal Tests + //////////////////////////////////////////////////////////////*/ + + /* + * note: Testing state changes; URI revealed for a batch of tokens. + */ + function test_state_reveal() public { + vm.startPrank(deployer); + + bytes memory key = "key"; + uint256 amountToLazyMint = 100; + bytes memory secretURI = "ipfs://"; + string memory placeholderURI = "abcd://"; + bytes memory encryptedURI = drop.encryptDecrypt(secretURI, key); + bytes32 provenanceHash = keccak256(abi.encodePacked(secretURI, key, block.chainid)); + + drop.lazyMint(amountToLazyMint, placeholderURI, abi.encode(encryptedURI, provenanceHash)); + + for (uint256 i = 0; i < amountToLazyMint; i += 1) { + string memory uri = drop.tokenURI(i); + assertEq(uri, string(abi.encodePacked(placeholderURI, "0"))); + } + + string memory revealedURI = drop.reveal(0, key); + assertEq(revealedURI, string(secretURI)); + + for (uint256 i = 0; i < amountToLazyMint; i += 1) { + string memory uri = drop.tokenURI(i); + assertEq(uri, string(abi.encodePacked(secretURI, i.toString()))); + } + + vm.stopPrank(); + } + + /** + * note: Testing revert condition; an address without METADATA_ROLE calls reveal function. + */ + function test_revert_reveal_METADATA_ROLE() public { + bytes memory key = "key"; + bytes memory encryptedURI = drop.encryptDecrypt("ipfs://", key); + bytes32 provenanceHash = keccak256(abi.encodePacked("ipfs://", key, block.chainid)); + vm.prank(deployer); + drop.lazyMint(100, "", abi.encode(encryptedURI, provenanceHash)); + + vm.prank(deployer); + drop.reveal(0, "key"); + + vm.expectRevert( + abi.encodeWithSelector( + Permissions.PermissionsUnauthorizedAccount.selector, + address(this), + keccak256("METADATA_ROLE") + ) + ); + drop.reveal(0, "key"); + } + + /* + * note: Testing revert condition; trying to reveal URI for non-existent batch. + */ + function test_revert_reveal_revealingNonExistentBatch() public { + vm.startPrank(deployer); + + bytes memory key = "key"; + bytes memory encryptedURI = drop.encryptDecrypt("ipfs://", key); + bytes32 provenanceHash = keccak256(abi.encodePacked("ipfs://", key, block.chainid)); + drop.lazyMint(100, "", abi.encode(encryptedURI, provenanceHash)); + drop.reveal(0, "key"); + + console.log(drop.getBaseURICount()); + + drop.lazyMint(100, "", abi.encode(encryptedURI, provenanceHash)); + vm.expectRevert(abi.encodeWithSelector(BatchMintMetadata.BatchMintInvalidBatchId.selector, 2)); + drop.reveal(2, "key"); + + vm.stopPrank(); + } + + /* + * note: Testing revert condition; already revealed URI. + */ + function test_revert_delayedReveal_alreadyRevealed() public { + vm.startPrank(deployer); + + bytes memory key = "key"; + bytes memory encryptedURI = drop.encryptDecrypt("ipfs://", key); + bytes32 provenanceHash = keccak256(abi.encodePacked("ipfs://", key, block.chainid)); + drop.lazyMint(100, "", abi.encode(encryptedURI, provenanceHash)); + drop.reveal(0, "key"); + + vm.expectRevert(abi.encodeWithSelector(DelayedReveal.DelayedRevealNothingToReveal.selector)); + drop.reveal(0, "key"); + + vm.stopPrank(); + } + + /* + * note: Testing state changes; revealing URI with an incorrect key. + */ + function testFail_reveal_incorrectKey() public { + vm.startPrank(deployer); + + bytes memory key = "key"; + bytes memory encryptedURI = drop.encryptDecrypt("ipfs://", key); + bytes32 provenanceHash = keccak256(abi.encodePacked("ipfs://", key, block.chainid)); + drop.lazyMint(100, "", abi.encode(encryptedURI, provenanceHash)); + + string memory revealedURI = drop.reveal(0, "keyy"); + assertEq(revealedURI, "ipfs://"); + + vm.stopPrank(); + } + + /** + * note: Testing event emission; TokenURIRevealed. + */ + function test_event_reveal_TokenURIRevealed() public { + vm.startPrank(deployer); + + bytes memory key = "key"; + bytes memory encryptedURI = drop.encryptDecrypt("ipfs://", key); + bytes32 provenanceHash = keccak256(abi.encodePacked("ipfs://", key, block.chainid)); + drop.lazyMint(100, "", abi.encode(encryptedURI, provenanceHash)); + + vm.expectEmit(true, false, false, true); + emit TokenURIRevealed(0, "ipfs://"); + drop.reveal(0, "key"); + + vm.stopPrank(); + } + + /*/////////////////////////////////////////////////////////////// + Unit Test: updateBatchBaseURI + //////////////////////////////////////////////////////////////*/ + + function test_state_updateBatchBaseURI() public { + string memory initURI = "ipfs://init"; + string memory newURI = "ipfs://new"; + + vm.startPrank(deployer); + drop.lazyMint(100, initURI, ""); + + string memory initTokenURI = drop.tokenURI(0); + + assertEq(initTokenURI, string(abi.encodePacked(initURI, "0"))); + + drop.updateBatchBaseURI(0, newURI); + + string memory newTokenURI = drop.tokenURI(0); + + assertEq(newTokenURI, string(abi.encodePacked(newURI, "0"))); + } + + function test_updateBatchBaseURI_revert_encrypted() public { + bytes memory uri = "ipfs://init"; + bytes memory key = "key"; + + vm.startPrank(deployer); + bytes memory encryptedURI = drop.encryptDecrypt(uri, key); + bytes32 provenanceHash = keccak256(abi.encodePacked(uri, key, block.chainid)); + drop.lazyMint(100, "", abi.encode(encryptedURI, provenanceHash)); + + vm.expectRevert("Encrypted batch"); + drop.updateBatchBaseURI(0, "uri"); + } + + /*/////////////////////////////////////////////////////////////// + Unit Test: freezeBatchBaseURI + //////////////////////////////////////////////////////////////*/ + + function test_state_freezeBatchBaseURI() public { + string memory initURI = "ipfs://init"; + + vm.startPrank(deployer); + drop.lazyMint(100, initURI, ""); + + string memory initTokenURI = drop.tokenURI(0); + + assertEq(initTokenURI, string(abi.encodePacked(initURI, "0"))); + + drop.freezeBatchBaseURI(0); + + assertEq(drop.batchFrozen(100), true); + } + + /*/////////////////////////////////////////////////////////////// + Unit Test: setMaxTotalSupply + //////////////////////////////////////////////////////////////*/ + + function test_state_setMaxTotalSupply() public { + vm.startPrank(deployer); + drop.setMaxTotalSupply(100); + + assertEq(drop.maxTotalSupply(), 100); + } + + function test_event_setMaxTotalSupply_MaxTotalSupplyUpdated() public { + vm.startPrank(deployer); + + vm.expectEmit(true, false, false, true); + emit MaxTotalSupplyUpdated(100); + drop.setMaxTotalSupply(100); + } + + /*/////////////////////////////////////////////////////////////// + Claim Tests + //////////////////////////////////////////////////////////////*/ + + /** + * note: Testing revert condition; not enough minted tokens. + */ + function test_revert_claimCondition_notEnoughMintedTokens() public { + vm.warp(1); + + address receiver = getActor(0); + bytes32[] memory proofs = new bytes32[](0); + + DropERC721.AllowlistProof memory alp; + alp.proof = proofs; + + DropERC721.ClaimCondition[] memory conditions = new DropERC721.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 100; + conditions[0].quantityLimitPerWallet = 200; + + vm.prank(deployer); + drop.lazyMint(100, "ipfs://", emptyEncodedBytes); + vm.prank(deployer); + drop.setClaimConditions(conditions, false); + + vm.expectRevert("!Tokens"); + vm.prank(getActor(6), getActor(6)); + drop.claim(receiver, 101, address(0), 0, alp, ""); + } + + /** + * note: Testing revert condition; exceed max claimable supply. + */ + function test_revert_claimCondition_exceedMaxClaimableSupply() public { + vm.warp(1); + + address receiver = getActor(0); + bytes32[] memory proofs = new bytes32[](0); + + DropERC721.AllowlistProof memory alp; + alp.proof = proofs; + + DropERC721.ClaimCondition[] memory conditions = new DropERC721.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 100; + conditions[0].quantityLimitPerWallet = 200; + + vm.prank(deployer); + drop.lazyMint(200, "ipfs://", emptyEncodedBytes); + vm.prank(deployer); + drop.setClaimConditions(conditions, false); + + vm.prank(getActor(5), getActor(5)); + drop.claim(receiver, 100, address(0), 0, alp, ""); + + vm.expectRevert( + abi.encodeWithSelector(Drop.DropClaimExceedMaxSupply.selector, conditions[0].maxClaimableSupply, 101) + ); + vm.prank(getActor(6), getActor(6)); + drop.claim(receiver, 1, address(0), 0, alp, ""); + } + + /** + * note: Testing quantity limit restriction when no allowlist present. + */ + function test_fuzz_claim_noAllowlist(uint256 x) public { + vm.assume(x != 0); + vm.warp(1); + + address receiver = getActor(0); + bytes32[] memory proofs = new bytes32[](0); + + DropERC721.AllowlistProof memory alp; + alp.proof = proofs; + alp.quantityLimitPerWallet = x; + + DropERC721.ClaimCondition[] memory conditions = new DropERC721.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 500; + conditions[0].quantityLimitPerWallet = 100; + + vm.prank(deployer); + drop.lazyMint(500, "ipfs://", emptyEncodedBytes); + + vm.prank(deployer); + drop.setClaimConditions(conditions, false); + + vm.prank(getActor(5), getActor(5)); + vm.expectRevert( + abi.encodeWithSelector(Drop.DropClaimExceedLimit.selector, conditions[0].quantityLimitPerWallet, 0) + ); + drop.claim(receiver, 0, address(0), 0, alp, ""); + + vm.prank(getActor(5), getActor(5)); + vm.expectRevert( + abi.encodeWithSelector(Drop.DropClaimExceedLimit.selector, conditions[0].quantityLimitPerWallet, 101) + ); + drop.claim(receiver, 101, address(0), 0, alp, ""); + + vm.prank(deployer); + drop.setClaimConditions(conditions, true); + + vm.prank(getActor(5), getActor(5)); + vm.expectRevert( + abi.encodeWithSelector(Drop.DropClaimExceedLimit.selector, conditions[0].quantityLimitPerWallet, 101) + ); + drop.claim(receiver, 101, address(0), 0, alp, ""); + } + + /** + * note: Testing quantity limit restriction + * - allowlist quantity set to some value different than general limit + * - allowlist price set to 0 + */ + function test_state_claim_allowlisted_SetQuantityZeroPrice() public { + string[] memory inputs = new string[](5); + + inputs[0] = "node"; + inputs[1] = "src/test/scripts/generateRoot.ts"; + inputs[2] = "300"; + inputs[3] = "0"; + inputs[4] = Strings.toHexString(uint160(address(erc20))); // address of erc20 + + bytes memory result = vm.ffi(inputs); + // revert(); + bytes32 root = abi.decode(result, (bytes32)); + + inputs[1] = "src/test/scripts/getProof.ts"; + result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + DropERC721.AllowlistProof memory alp; + alp.proof = proofs; + alp.quantityLimitPerWallet = 300; + alp.pricePerToken = 0; + alp.currency = address(erc20); + + vm.warp(1); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); // in allowlist + + DropERC721.ClaimCondition[] memory conditions = new DropERC721.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 500; + conditions[0].quantityLimitPerWallet = 10; + conditions[0].merkleRoot = root; + conditions[0].pricePerToken = 10; + conditions[0].currency = address(erc20); + + vm.prank(deployer); + drop.lazyMint(500, "ipfs://", emptyEncodedBytes); + vm.prank(deployer); + drop.setClaimConditions(conditions, false); + + // vm.prank(getActor(5), getActor(5)); + vm.prank(receiver, receiver); + drop.claim(receiver, 100, address(erc20), 0, alp, ""); // claims for free, because allowlist price is 0 + assertEq(drop.getSupplyClaimedByWallet(drop.getActiveClaimConditionId(), receiver), 100); + } + + /** + * note: Testing quantity limit restriction + * - allowlist quantity set to some value different than general limit + * - allowlist price set to non-zero value + */ + function test_state_claim_allowlisted_SetQuantityPrice() public { + string[] memory inputs = new string[](5); + + inputs[0] = "node"; + inputs[1] = "src/test/scripts/generateRoot.ts"; + inputs[2] = "300"; + inputs[3] = "5"; + inputs[4] = Strings.toHexString(uint160(address(erc20))); // address of erc20 + + bytes memory result = vm.ffi(inputs); + // revert(); + bytes32 root = abi.decode(result, (bytes32)); + + inputs[1] = "src/test/scripts/getProof.ts"; + result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + DropERC721.AllowlistProof memory alp; + alp.proof = proofs; + alp.quantityLimitPerWallet = 300; + alp.pricePerToken = 5; + alp.currency = address(erc20); + + vm.warp(1); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); // in allowlist + + DropERC721.ClaimCondition[] memory conditions = new DropERC721.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 500; + conditions[0].quantityLimitPerWallet = 10; + conditions[0].merkleRoot = root; + conditions[0].pricePerToken = 10; + conditions[0].currency = address(erc20); + + vm.prank(deployer); + drop.lazyMint(500, "ipfs://", emptyEncodedBytes); + vm.prank(deployer); + drop.setClaimConditions(conditions, false); + + vm.prank(receiver, receiver); + vm.expectRevert( + abi.encodeWithSelector(Drop.DropClaimInvalidTokenPrice.selector, address(erc20), 0, address(erc20), 5) + ); + drop.claim(receiver, 100, address(erc20), 0, alp, ""); + + erc20.mint(receiver, 10000); + vm.prank(receiver); + erc20.approve(address(drop), 10000); + + // vm.prank(getActor(5), getActor(5)); + vm.prank(receiver, receiver); + drop.claim(receiver, 100, address(erc20), 5, alp, ""); + assertEq(drop.getSupplyClaimedByWallet(drop.getActiveClaimConditionId(), receiver), 100); + assertEq(erc20.balanceOf(receiver), 10000 - 500); + } + + /** + * note: Testing quantity limit restriction + * - allowlist quantity set to some value different than general limit + * - allowlist price not set; should default to general price and currency + */ + function test_state_claim_allowlisted_SetQuantityDefaultPrice() public { + string[] memory inputs = new string[](5); + + inputs[0] = "node"; + inputs[1] = "src/test/scripts/generateRoot.ts"; + inputs[2] = "300"; + inputs[3] = Strings.toString(type(uint256).max); // this implies that general price is applicable + inputs[4] = "0x0000000000000000000000000000000000000000"; + + bytes memory result = vm.ffi(inputs); + // revert(); + bytes32 root = abi.decode(result, (bytes32)); + + inputs[1] = "src/test/scripts/getProof.ts"; + result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + DropERC721.AllowlistProof memory alp; + alp.proof = proofs; + alp.quantityLimitPerWallet = 300; + alp.pricePerToken = type(uint256).max; + alp.currency = address(0); + + vm.warp(1); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); // in allowlist + + DropERC721.ClaimCondition[] memory conditions = new DropERC721.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 500; + conditions[0].quantityLimitPerWallet = 10; + conditions[0].merkleRoot = root; + conditions[0].pricePerToken = 10; + conditions[0].currency = address(erc20); + + vm.prank(deployer); + drop.lazyMint(500, "ipfs://", emptyEncodedBytes); + vm.prank(deployer); + drop.setClaimConditions(conditions, false); + + erc20.mint(receiver, 10000); + vm.prank(receiver); + erc20.approve(address(drop), 10000); + + // vm.prank(getActor(5), getActor(5)); + vm.prank(receiver, receiver); + drop.claim(receiver, 100, address(erc20), 10, alp, ""); + assertEq(drop.getSupplyClaimedByWallet(drop.getActiveClaimConditionId(), receiver), 100); + assertEq(erc20.balanceOf(receiver), 10000 - 1000); + } + + /** + * note: Testing quantity limit restriction + * - allowlist quantity set to 0 => should default to general limit + * - allowlist price set to some value different than general price + */ + function test_state_claim_allowlisted_DefaultQuantitySomePrice() public { + string[] memory inputs = new string[](5); + + inputs[0] = "node"; + inputs[1] = "src/test/scripts/generateRoot.ts"; + inputs[2] = "0"; // this implies that general limit is applicable + inputs[3] = "5"; + inputs[4] = "0x0000000000000000000000000000000000000000"; // general currency will be applicable + + bytes memory result = vm.ffi(inputs); + // revert(); + bytes32 root = abi.decode(result, (bytes32)); + + inputs[1] = "src/test/scripts/getProof.ts"; + result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + DropERC721.AllowlistProof memory alp; + alp.proof = proofs; + alp.quantityLimitPerWallet = 0; + alp.pricePerToken = 5; + alp.currency = address(0); + + vm.warp(1); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); // in allowlist + + DropERC721.ClaimCondition[] memory conditions = new DropERC721.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 500; + conditions[0].quantityLimitPerWallet = 10; + conditions[0].merkleRoot = root; + conditions[0].pricePerToken = 10; + conditions[0].currency = address(erc20); + + vm.prank(deployer); + drop.lazyMint(500, "ipfs://", emptyEncodedBytes); + vm.prank(deployer); + drop.setClaimConditions(conditions, false); + + erc20.mint(receiver, 10000); + vm.prank(receiver); + erc20.approve(address(drop), 10000); + + vm.prank(receiver, receiver); + vm.expectRevert( + abi.encodeWithSelector(Drop.DropClaimExceedLimit.selector, conditions[0].quantityLimitPerWallet, 100) + ); + drop.claim(receiver, 100, address(erc20), 5, alp, ""); // trying to claim more than general limit + + // vm.prank(getActor(5), getActor(5)); + vm.prank(receiver, receiver); + drop.claim(receiver, 10, address(erc20), 5, alp, ""); + assertEq(drop.getSupplyClaimedByWallet(drop.getActiveClaimConditionId(), receiver), 10); + assertEq(erc20.balanceOf(receiver), 10000 - 50); + } + + function test_fuzz_claim_merkleProof(uint256 x) public { + vm.assume(x > 10 && x < 500); + string[] memory inputs = new string[](5); + + inputs[0] = "node"; + inputs[1] = "src/test/scripts/generateRoot.ts"; + inputs[2] = Strings.toString(x); + inputs[3] = "0"; + inputs[4] = "0x0000000000000000000000000000000000000000"; + + bytes memory result = vm.ffi(inputs); + // revert(); + bytes32 root = abi.decode(result, (bytes32)); + + inputs[1] = "src/test/scripts/getProof.ts"; + result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + DropERC721.AllowlistProof memory alp; + alp.proof = proofs; + alp.quantityLimitPerWallet = x; + alp.pricePerToken = 0; + alp.currency = address(0); + + vm.warp(1); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); + + // bytes32[] memory proofs = new bytes32[](0); + + DropERC721.ClaimCondition[] memory conditions = new DropERC721.ClaimCondition[](1); + conditions[0].maxClaimableSupply = x; + conditions[0].quantityLimitPerWallet = 1; + conditions[0].merkleRoot = root; + + vm.prank(deployer); + drop.lazyMint(2 * x, "ipfs://", emptyEncodedBytes); + vm.prank(deployer); + drop.setClaimConditions(conditions, false); + + // vm.prank(getActor(5), getActor(5)); + vm.prank(receiver, receiver); + drop.claim(receiver, x - 5, address(0), 0, alp, ""); + assertEq(drop.getSupplyClaimedByWallet(drop.getActiveClaimConditionId(), receiver), x - 5); + + vm.prank(receiver, receiver); + vm.expectRevert(abi.encodeWithSelector(Drop.DropClaimExceedLimit.selector, x, x + 1)); + drop.claim(receiver, 6, address(0), 0, alp, ""); + + vm.prank(receiver, receiver); + drop.claim(receiver, 5, address(0), 0, alp, ""); + assertEq(drop.getSupplyClaimedByWallet(drop.getActiveClaimConditionId(), receiver), x); + + vm.prank(receiver, receiver); + vm.expectRevert(abi.encodeWithSelector(Drop.DropClaimExceedLimit.selector, x, x + 5)); + drop.claim(receiver, 5, address(0), 0, alp, ""); // quantity limit already claimed + } + + /** + * note: Testing state changes; reset eligibility of claim conditions and claiming again for same condition id. + */ + function test_state_claimCondition_resetEligibility() public { + vm.warp(1); + + address receiver = getActor(0); + bytes32[] memory proofs = new bytes32[](0); + + DropERC721.AllowlistProof memory alp; + alp.proof = proofs; + + DropERC721.ClaimCondition[] memory conditions = new DropERC721.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 500; + conditions[0].quantityLimitPerWallet = 100; + + vm.prank(deployer); + drop.lazyMint(500, "ipfs://", emptyEncodedBytes); + + vm.prank(deployer); + drop.setClaimConditions(conditions, false); + + vm.prank(getActor(5), getActor(5)); + drop.claim(receiver, 100, address(0), 0, alp, ""); + + vm.prank(getActor(5), getActor(5)); + vm.expectRevert( + abi.encodeWithSelector(Drop.DropClaimExceedLimit.selector, conditions[0].quantityLimitPerWallet, 200) + ); + drop.claim(receiver, 100, address(0), 0, alp, ""); + + vm.prank(deployer); + drop.setClaimConditions(conditions, true); + + vm.prank(getActor(5), getActor(5)); + drop.claim(receiver, 100, address(0), 0, alp, ""); + } + + /*/////////////////////////////////////////////////////////////// + setClaimConditions + //////////////////////////////////////////////////////////////*/ + + function test_claimCondition_startIdAndCount() public { + vm.startPrank(deployer); + + uint256 currentStartId = 0; + uint256 count = 0; + + DropERC721.ClaimCondition[] memory conditions = new DropERC721.ClaimCondition[](2); + conditions[0].startTimestamp = 0; + conditions[0].maxClaimableSupply = 10; + conditions[1].startTimestamp = 1; + conditions[1].maxClaimableSupply = 10; + + drop.setClaimConditions(conditions, false); + (currentStartId, count) = drop.claimCondition(); + assertEq(currentStartId, 0); + assertEq(count, 2); + + drop.setClaimConditions(conditions, false); + (currentStartId, count) = drop.claimCondition(); + assertEq(currentStartId, 0); + assertEq(count, 2); + + drop.setClaimConditions(conditions, true); + (currentStartId, count) = drop.claimCondition(); + assertEq(currentStartId, 2); + assertEq(count, 2); + + drop.setClaimConditions(conditions, true); + (currentStartId, count) = drop.claimCondition(); + assertEq(currentStartId, 4); + assertEq(count, 2); + } + + function test_claimCondition_startPhase() public { + vm.startPrank(deployer); + + uint256 activeConditionId = 0; + + DropERC721.ClaimCondition[] memory conditions = new DropERC721.ClaimCondition[](3); + conditions[0].startTimestamp = 10; + conditions[0].maxClaimableSupply = 11; + conditions[0].quantityLimitPerWallet = 12; + conditions[1].startTimestamp = 20; + conditions[1].maxClaimableSupply = 21; + conditions[1].quantityLimitPerWallet = 22; + conditions[2].startTimestamp = 30; + conditions[2].maxClaimableSupply = 31; + conditions[2].quantityLimitPerWallet = 32; + drop.setClaimConditions(conditions, false); + + vm.expectRevert(abi.encodeWithSelector(Drop.DropNoActiveCondition.selector)); + drop.getActiveClaimConditionId(); + + vm.warp(10); + activeConditionId = drop.getActiveClaimConditionId(); + assertEq(activeConditionId, 0); + assertEq(drop.getClaimConditionById(activeConditionId).startTimestamp, 10); + assertEq(drop.getClaimConditionById(activeConditionId).maxClaimableSupply, 11); + assertEq(drop.getClaimConditionById(activeConditionId).quantityLimitPerWallet, 12); + + vm.warp(20); + activeConditionId = drop.getActiveClaimConditionId(); + assertEq(activeConditionId, 1); + assertEq(drop.getClaimConditionById(activeConditionId).startTimestamp, 20); + assertEq(drop.getClaimConditionById(activeConditionId).maxClaimableSupply, 21); + assertEq(drop.getClaimConditionById(activeConditionId).quantityLimitPerWallet, 22); + + vm.warp(30); + activeConditionId = drop.getActiveClaimConditionId(); + assertEq(activeConditionId, 2); + assertEq(drop.getClaimConditionById(activeConditionId).startTimestamp, 30); + assertEq(drop.getClaimConditionById(activeConditionId).maxClaimableSupply, 31); + assertEq(drop.getClaimConditionById(activeConditionId).quantityLimitPerWallet, 32); + + vm.warp(40); + assertEq(drop.getActiveClaimConditionId(), 2); + } + + /*/////////////////////////////////////////////////////////////// + Miscellaneous + //////////////////////////////////////////////////////////////*/ + + function test_delayedReveal_withNewLazyMintedEmptyBatch() public { + vm.startPrank(deployer); + + bytes memory encryptedURI = drop.encryptDecrypt("ipfs://", "key"); + bytes32 provenanceHash = keccak256(abi.encodePacked("ipfs://", "key", block.chainid)); + drop.lazyMint(100, "", abi.encode(encryptedURI, provenanceHash)); + drop.reveal(0, "key"); + + string memory uri = drop.tokenURI(1); + assertEq(uri, string(abi.encodePacked("ipfs://", "1"))); + + bytes memory newEncryptedURI = drop.encryptDecrypt("ipfs://secret", "key"); + vm.expectRevert(abi.encodeWithSelector(LazyMint.LazyMintInvalidAmount.selector)); + drop.lazyMint(0, "", abi.encode(newEncryptedURI, provenanceHash)); + + vm.stopPrank(); + } +} diff --git a/src/test/drop/drop-erc1155/_beforeClaim/_beforeClaim.t.sol b/src/test/drop/drop-erc1155/_beforeClaim/_beforeClaim.t.sol new file mode 100644 index 000000000..8397c3195 --- /dev/null +++ b/src/test/drop/drop-erc1155/_beforeClaim/_beforeClaim.t.sol @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { DropERC1155 } from "contracts/prebuilts/drop/DropERC1155.sol"; +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +// Test imports +import "../../../utils/BaseTest.sol"; + +contract HarnessDropERC1155 is DropERC1155 { + function beforeClaim( + uint256 _tokenId, + address, + uint256 _quantity, + address, + uint256, + AllowlistProof calldata alp, + bytes memory + ) external view { + _beforeClaim(_tokenId, address(0), _quantity, address(0), 0, alp, bytes("")); + } +} + +contract DropERC1155Test_beforeClaim is BaseTest { + address public dropImp; + HarnessDropERC1155 public proxy; + + function setUp() public override { + super.setUp(); + + bytes memory initializeData = abi.encodeCall( + DropERC1155.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ); + + dropImp = address(new HarnessDropERC1155()); + proxy = HarnessDropERC1155(address(new TWProxy(dropImp, initializeData))); + + vm.prank(deployer); + proxy.setMaxTotalSupply(0, 1); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: misc. + //////////////////////////////////////////////////////////////*/ + + /** + * note: Tests whether contract reverts when a non-holder renounces a role. + */ + function test_revert_ExceedMaxSupply() public { + DropERC1155.AllowlistProof memory alp; + vm.expectRevert("exceed max total supply"); + proxy.beforeClaim(0, address(0), 2, address(0), 0, alp, bytes("")); + } + + function test_NoRevert() public view { + DropERC1155.AllowlistProof memory alp; + proxy.beforeClaim(0, address(0), 1, address(0), 0, alp, bytes("")); + } +} diff --git a/src/test/drop/drop-erc1155/_beforeClaim/_beforeClaim.tree b/src/test/drop/drop-erc1155/_beforeClaim/_beforeClaim.tree new file mode 100644 index 000000000..a5a114d07 --- /dev/null +++ b/src/test/drop/drop-erc1155/_beforeClaim/_beforeClaim.tree @@ -0,0 +1,12 @@ +function _beforeClaim( + uint256 _tokenId, + address, + uint256 _quantity, + address, + uint256, + AllowlistProof calldata, + bytes memory +) +└── when maxTotalSupply for _tokenId is not zero + └── when totalSupply of _tokenId + _quantity is greater than or equal to maxTotalSupply for _tokenId + └── it should revert ✅ \ No newline at end of file diff --git a/src/test/drop/drop-erc1155/_beforeTokenTransfer/_beforeTokenTransfer.sol b/src/test/drop/drop-erc1155/_beforeTokenTransfer/_beforeTokenTransfer.sol new file mode 100644 index 000000000..b4c32607c --- /dev/null +++ b/src/test/drop/drop-erc1155/_beforeTokenTransfer/_beforeTokenTransfer.sol @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { DropERC1155 } from "contracts/prebuilts/drop/DropERC1155.sol"; +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +// Test imports +import "../../../utils/BaseTest.sol"; + +contract HarnessDropERC1155 is DropERC1155 { + function beforeTokenTransfer( + address operator, + address from, + address to, + uint256[] memory ids, + uint256[] memory amounts, + bytes memory data + ) external { + _beforeTokenTransfer(operator, from, to, ids, amounts, data); + } +} + +contract DropERC1155Test_beforeTokenTransfer is BaseTest { + address private beforeTransfer_from = address(0x01); + address private beforeTransfer_to = address(0x01); + uint256[] private beforeTransfer_ids; + uint256[] private beforeTransfer_amounts; + bytes private beforeTransfer_data; + + address public dropImp; + HarnessDropERC1155 public proxy; + + function setUp() public override { + super.setUp(); + + bytes memory initializeData = abi.encodeCall( + DropERC1155.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ); + + dropImp = address(new HarnessDropERC1155()); + proxy = HarnessDropERC1155(address(new TWProxy(dropImp, initializeData))); + + beforeTransfer_ids = new uint256[](1); + beforeTransfer_ids[0] = 0; + beforeTransfer_amounts = new uint256[](1); + beforeTransfer_amounts[0] = 1; + beforeTransfer_data = abi.encode("", ""); + } + + modifier fromAddressZero() { + beforeTransfer_from = address(0); + _; + } + + modifier toAddressZero() { + beforeTransfer_to = address(0); + _; + } + + /** + * note: Tests whether contract reverts when a non-holder renounces a role. + */ + function test_state_transferFromZero() public fromAddressZero { + uint256 beforeTokenTotalSupply = proxy.totalSupply(0); + proxy.beforeTokenTransfer( + deployer, + beforeTransfer_from, + beforeTransfer_to, + beforeTransfer_ids, + beforeTransfer_amounts, + beforeTransfer_data + ); + uint256 afterTokenTotalSupply = proxy.totalSupply(0); + assertEq(beforeTokenTotalSupply + beforeTransfer_amounts[0], afterTokenTotalSupply); + } + + function test_state_tranferToZero() public toAddressZero { + proxy.beforeTokenTransfer( + deployer, + beforeTransfer_to, + beforeTransfer_from, + beforeTransfer_ids, + beforeTransfer_amounts, + beforeTransfer_data + ); + uint256 beforeTokenTotalSupply = proxy.totalSupply(0); + proxy.beforeTokenTransfer( + deployer, + beforeTransfer_from, + beforeTransfer_to, + beforeTransfer_ids, + beforeTransfer_amounts, + beforeTransfer_data + ); + uint256 afterTokenTotalSupply = proxy.totalSupply(0); + assertEq(beforeTokenTotalSupply - beforeTransfer_amounts[0], afterTokenTotalSupply); + } +} diff --git a/src/test/drop/drop-erc1155/_beforeTokenTransfer/_beforeTokenTransfer.tree b/src/test/drop/drop-erc1155/_beforeTokenTransfer/_beforeTokenTransfer.tree new file mode 100644 index 000000000..b5e147b76 --- /dev/null +++ b/src/test/drop/drop-erc1155/_beforeTokenTransfer/_beforeTokenTransfer.tree @@ -0,0 +1,12 @@ +function _beforeTokenTransfer( + address operator, + address from, + address to, + uint256[] memory ids, + uint256[] memory amounts, + bytes memory data +) +├── when from equals to address(0) +│ └── totalSupply for each id is incremented by the corresponding amounts ✅ +└── when to equals address(0) + └── totalSupply for each id is decremented by the corresponding amounts ✅ \ No newline at end of file diff --git a/src/test/drop/drop-erc1155/_canSetFunctions/_canSetFunctions.sol b/src/test/drop/drop-erc1155/_canSetFunctions/_canSetFunctions.sol new file mode 100644 index 000000000..2be22888c --- /dev/null +++ b/src/test/drop/drop-erc1155/_canSetFunctions/_canSetFunctions.sol @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { DropERC1155 } from "contracts/prebuilts/drop/DropERC1155.sol"; +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +// Test imports +import "../../../utils/BaseTest.sol"; + +contract HarnessDropERC1155 is DropERC1155 { + function canSetPlatformFeeInfo() external view returns (bool) { + return _canSetPlatformFeeInfo(); + } + + /// @dev Checks whether primary sale recipient can be set in the given execution context. + function canSetPrimarySaleRecipient() external view returns (bool) { + return _canSetPrimarySaleRecipient(); + } + + /// @dev Checks whether owner can be set in the given execution context. + function canSetOwner() external view returns (bool) { + return _canSetOwner(); + } + + /// @dev Checks whether royalty info can be set in the given execution context. + function canSetRoyaltyInfo() external view returns (bool) { + return _canSetRoyaltyInfo(); + } + + /// @dev Checks whether contract metadata can be set in the given execution context. + function canSetContractURI() external view returns (bool) { + return _canSetContractURI(); + } + + /// @dev Checks whether platform fee info can be set in the given execution context. + function canSetClaimConditions() external view returns (bool) { + return _canSetClaimConditions(); + } + + /// @dev Returns whether lazy minting can be done in the given execution context. + function canLazyMint() external view virtual returns (bool) { + return _canLazyMint(); + } +} + +contract DropERC1155Test_canSetFunctions is BaseTest { + address public dropImp; + HarnessDropERC1155 public proxy; + + function setUp() public override { + super.setUp(); + + bytes memory initializeData = abi.encodeCall( + DropERC1155.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ); + + dropImp = address(new HarnessDropERC1155()); + proxy = HarnessDropERC1155(address(new TWProxy(dropImp, initializeData))); + } + + modifier HasDefaultAdminRole() { + vm.startPrank(deployer); + _; + } + + modifier DoesNotHaveDefaultAdminRole() { + vm.startPrank(address(0x123)); + _; + } + + modifier HasMinterRole() { + vm.startPrank(deployer); + _; + } + + modifier DoesNotHaveMinterRole() { + vm.startPrank(address(0x123)); + _; + } + + /** + * note: Tests whether contract reverts when a non-holder renounces a role. + */ + function test_canSetPlatformFeeInfo_true() public HasDefaultAdminRole { + assertTrue(proxy.canSetPlatformFeeInfo()); + } + + function test_canSetPlatformFeeInfo_false() public DoesNotHaveDefaultAdminRole { + assertFalse(proxy.canSetPlatformFeeInfo()); + } + + function test_canSetPrimarySaleRecipient_true() public HasDefaultAdminRole { + assertTrue(proxy.canSetPrimarySaleRecipient()); + } + + function test_canSetPrimarySaleRecipient_false() public DoesNotHaveDefaultAdminRole { + assertFalse(proxy.canSetPrimarySaleRecipient()); + } + + function test_canSetOwner_true() public HasDefaultAdminRole { + assertTrue(proxy.canSetOwner()); + } + + function test_canSetOwner_false() public DoesNotHaveDefaultAdminRole { + assertFalse(proxy.canSetOwner()); + } + + function test_canSetRoyaltyInfo_true() public HasDefaultAdminRole { + assertTrue(proxy.canSetRoyaltyInfo()); + } + + function test_canSetRoyaltyInfo_false() public DoesNotHaveDefaultAdminRole { + assertFalse(proxy.canSetRoyaltyInfo()); + } + + function test_canSetContractURI_true() public HasDefaultAdminRole { + assertTrue(proxy.canSetContractURI()); + } + + function test_canSetContractURI_false() public DoesNotHaveDefaultAdminRole { + assertFalse(proxy.canSetContractURI()); + } + + function test_canSetClaimConditions_true() public HasDefaultAdminRole { + assertTrue(proxy.canSetClaimConditions()); + } + + function test_canSetClaimConditions_false() public DoesNotHaveDefaultAdminRole { + assertFalse(proxy.canSetClaimConditions()); + } + + function test_canLazyMint_true() public HasMinterRole { + assertTrue(proxy.canLazyMint()); + } + + function test_canLazyMint_false() public DoesNotHaveMinterRole { + assertFalse(proxy.canLazyMint()); + } +} diff --git a/src/test/drop/drop-erc1155/_canSetFunctions/_canSetFunctions.tree b/src/test/drop/drop-erc1155/_canSetFunctions/_canSetFunctions.tree new file mode 100644 index 000000000..4b214c4e9 --- /dev/null +++ b/src/test/drop/drop-erc1155/_canSetFunctions/_canSetFunctions.tree @@ -0,0 +1,41 @@ +function _canSetPlatformFeeInfo() +├── when caller has DEFAULT_ADMIN_ROLE +│ └── it should return true ✅ +└── when caller does not have DEFAULT_ADMIN_ROLE + └── it should return false ✅ + +function _canSetPrimarySaleRecipient() +├── when caller has DEFAULT_ADMIN_ROLE +│ └── it should return true ✅ +└── when caller does not have DEFAULT_ADMIN_ROLE + └── it should return false ✅ + +function _canSetOwner() +├── when caller has DEFAULT_ADMIN_ROLE +│ └── it should return true ✅ +└── when caller does not have DEFAULT_ADMIN_ROLE + └── it should return false ✅ + +function _canSetRoyaltyInfo() +├── when caller has DEFAULT_ADMIN_ROLE +│ └── it should return true ✅ +└── when caller does not have DEFAULT_ADMIN_ROLE + └── it should return false ✅ + +function _canSetContractURI() +├── when caller has DEFAULT_ADMIN_ROLE +│ └── it should return true ✅ +└── when caller does not have DEFAULT_ADMIN_ROLE + └── it should return false ✅ + +function _canSetClaimConditions() +├── when caller has DEFAULT_ADMIN_ROLE +│ └── it should return true ✅ +└── when caller does not have DEFAULT_ADMIN_ROLE + └── it should return false ✅ + +function _canLazyMint() +├── when caller has minterRole +│ └── it should return true ✅ +└── when caller does not have minterRole + └── it should return false ✅ \ No newline at end of file diff --git a/src/test/drop/drop-erc1155/burnBatch/burnBatch.t.sol b/src/test/drop/drop-erc1155/burnBatch/burnBatch.t.sol new file mode 100644 index 000000000..86e365f33 --- /dev/null +++ b/src/test/drop/drop-erc1155/burnBatch/burnBatch.t.sol @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { DropERC1155 } from "contracts/prebuilts/drop/DropERC1155.sol"; + +// Test imports +import "../../../utils/BaseTest.sol"; +import "../../../../../lib/openzeppelin-contracts-upgradeable/contracts/interfaces/IERC2981Upgradeable.sol"; + +contract DropERC1155Test_burnBatch is BaseTest { + DropERC1155 public drop; + + address private unauthorized = address(0x999); + address private account; + uint256[] private ids; + uint256[] private values; + + address private receiver; + bytes private emptyEncodedBytes = abi.encode("", ""); + + event TransferBatch( + address indexed operator, + address indexed from, + address indexed to, + uint256[] ids, + uint256[] values + ); + + function setUp() public override { + super.setUp(); + drop = DropERC1155(getContract("DropERC1155")); + + ids = new uint256[](1); + values = new uint256[](1); + ids[0] = 0; + values[0] = 1; + } + + /*/////////////////////////////////////////////////////////////// + Branch Testing + //////////////////////////////////////////////////////////////*/ + + modifier callerNotApproved() { + vm.startPrank(unauthorized); + _; + } + + modifier callerOwner() { + receiver = getActor(0); + vm.startPrank(receiver); + _; + } + + modifier callerApproved() { + receiver = getActor(0); + vm.prank(receiver); + drop.setApprovalForAll(deployer, true); + vm.startPrank(deployer); + _; + } + + modifier IdValueMismatch() { + values = new uint256[](2); + values[0] = 1; + values[1] = 1; + _; + } + + modifier tokenClaimed() { + vm.warp(1); + + uint256 _tokenId = 0; + receiver = getActor(0); + bytes32[] memory proofs = new bytes32[](0); + + DropERC1155.AllowlistProof memory alp; + alp.proof = proofs; + + DropERC1155.ClaimCondition[] memory conditions = new DropERC1155.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 100; + conditions[0].quantityLimitPerWallet = 100; + + vm.prank(deployer); + drop.lazyMint(100, "ipfs://", emptyEncodedBytes); + vm.prank(deployer); + drop.setClaimConditions(_tokenId, conditions, false); + + vm.prank(getActor(5), getActor(5)); + drop.claim(receiver, _tokenId, 1, address(0), 0, alp, ""); + + _; + } + + function test_revert_callerNotApproved() public tokenClaimed callerNotApproved { + vm.expectRevert("ERC1155: caller is not owner nor approved."); + drop.burnBatch(receiver, ids, values); + } + + function test_state_callerApproved() public tokenClaimed callerApproved { + uint256 beforeBalance = drop.balanceOf(receiver, ids[0]); + drop.burnBatch(receiver, ids, values); + uint256 afterBalance = drop.balanceOf(receiver, ids[0]); + assertEq(beforeBalance - values[0], afterBalance); + } + + function test_state_callerOwner() public tokenClaimed callerOwner { + uint256 beforeBalance = drop.balanceOf(receiver, ids[0]); + drop.burnBatch(receiver, ids, values); + uint256 afterBalance = drop.balanceOf(receiver, ids[0]); + assertEq(beforeBalance - values[0], afterBalance); + } + + function test_revert_IdValueMismatch() public tokenClaimed IdValueMismatch callerOwner { + vm.expectRevert("ERC1155: ids and amounts length mismatch"); + drop.burnBatch(receiver, ids, values); + } + + function test_revert_balanceUnderflow() public tokenClaimed callerOwner { + values[0] = 2; + vm.expectRevert(); + drop.burnBatch(receiver, ids, values); + } + + function test_event() public tokenClaimed callerOwner { + vm.expectEmit(true, true, true, true); + emit TransferBatch(receiver, receiver, address(0), ids, values); + drop.burnBatch(receiver, ids, values); + } +} diff --git a/src/test/drop/drop-erc1155/burnBatch/burnBatch.tree b/src/test/drop/drop-erc1155/burnBatch/burnBatch.tree new file mode 100644 index 000000000..e8685085e --- /dev/null +++ b/src/test/drop/drop-erc1155/burnBatch/burnBatch.tree @@ -0,0 +1,17 @@ +function burnBatch( + address account, + uint256[] memory ids, + uint256[] memory values +) +├── when account does not equal _msgSender() and _msgSender() is not an approved operator for account +│ └── it should revert ✅ +└── when account is equal to _msgSender() or _msgSender() is an approved operator for account + ├── when ids and values are not the same length + │ └── it should revert ✅ + └── when ids and values are the same length + ├── when the balance of account for each id is not greater than or equal to the corresponding value + │ └── it should revert ✅ + └── when the balance of account for each id is greater than or equal to the corresponding value + ├── it should reduce the balance of each id for account by the corresponding value ✅ + ├── it should reduce the total supply of each id by the corresponding value ✅ + └── it should emit TransferBatch with the following parameters: _msgSender(), account, address(0), ids, amounts ✅ \ No newline at end of file diff --git a/src/test/drop/drop-erc1155/collectPriceOnClaim/collectPriceOnClaim.t.sol b/src/test/drop/drop-erc1155/collectPriceOnClaim/collectPriceOnClaim.t.sol new file mode 100644 index 000000000..bd2e68b9a --- /dev/null +++ b/src/test/drop/drop-erc1155/collectPriceOnClaim/collectPriceOnClaim.t.sol @@ -0,0 +1,272 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { DropERC1155 } from "contracts/prebuilts/drop/DropERC1155.sol"; +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +// Test imports +import "../../../utils/BaseTest.sol"; + +contract HarnessDropERC1155 is DropERC1155 { + function collectPriceOnClaimHarness( + uint256 _tokenId, + address _primarySaleRecipient, + uint256 _quantityToClaim, + address _currency, + uint256 _pricePerToken + ) public payable { + collectPriceOnClaim(_tokenId, _primarySaleRecipient, _quantityToClaim, _currency, _pricePerToken); + } +} + +contract DropERC1155Test_collectPrice is BaseTest { + address private collectPrice_saleRecipient = address(0x010); + address private collectPrice_royaltyRecipient = address(0x011); + uint128 private collectPrice_royaltyBps = 1000; + uint128 private collectPrice_platformFeeBps = 1000; + address private collectPrice_platformFeeRecipient = address(0x012); + uint256 private collectPrice_quantityToClaim = 1; + uint256 private collectPrice_pricePerToken; + address private collectPrice_currency; + uint256 private collectPrice_msgValue; + address private collectPrice_tokenSaleRecipient = address(0x111); + + address public dropImp; + HarnessDropERC1155 public proxy; + + function setUp() public override { + super.setUp(); + + bytes memory initializeData = abi.encodeCall( + DropERC1155.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ); + + dropImp = address(new HarnessDropERC1155()); + proxy = HarnessDropERC1155(address(new TWProxy(dropImp, initializeData))); + } + + modifier pricePerTokenZero() { + collectPrice_pricePerToken = 0; + _; + } + + modifier pricePerTokenNotZero() { + collectPrice_pricePerToken = 1 ether; + _; + } + + modifier msgValueNotZero() { + collectPrice_msgValue = 1 ether; + _; + } + + modifier nativeCurrency() { + collectPrice_currency = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + _; + } + + modifier erc20Currency() { + collectPrice_currency = address(erc20); + erc20.mint(address(this), 1_000 ether); + _; + } + + modifier primarySaleRecipientZeroAddress() { + saleRecipient = address(0); + _; + } + + modifier primarySaleRecipientNotZeroAddress() { + saleRecipient = address(0x112); + _; + } + + modifier saleRecipientSet() { + vm.prank(deployer); + proxy.setSaleRecipientForToken(0, address(0x111)); + _; + } + + /*/////////////////////////////////////////////////////////////// + Branch Testing + //////////////////////////////////////////////////////////////*/ + + function test_revert_msgValueNotZero() public nativeCurrency msgValueNotZero pricePerTokenZero { + vm.expectRevert(); + proxy.collectPriceOnClaimHarness{ value: collectPrice_msgValue }( + 0, + saleRecipient, + collectPrice_quantityToClaim, + collectPrice_currency, + collectPrice_pricePerToken + ); + } + + function test_msgValueZero_return() public nativeCurrency pricePerTokenZero { + proxy.collectPriceOnClaimHarness{ value: collectPrice_msgValue }( + 0, + saleRecipient, + collectPrice_quantityToClaim, + collectPrice_currency, + collectPrice_pricePerToken + ); + } + + function test_revert_priceValueMismatchNativeCurrency() public nativeCurrency pricePerTokenNotZero { + vm.expectRevert(); + proxy.collectPriceOnClaimHarness{ value: collectPrice_msgValue }( + 0, + saleRecipient, + collectPrice_quantityToClaim, + collectPrice_currency, + collectPrice_pricePerToken + ); + } + + function test_transferNativeCurrencyToSaleRecipient() public nativeCurrency pricePerTokenNotZero msgValueNotZero { + uint256 balanceSaleRecipientBefore = address(saleRecipient).balance; + uint256 platformFeeRecipientBefore = address(platformFeeRecipient).balance; + proxy.collectPriceOnClaimHarness{ value: collectPrice_msgValue }( + 0, + saleRecipient, + collectPrice_quantityToClaim, + collectPrice_currency, + collectPrice_pricePerToken + ); + + uint256 balanceSaleRecipientAfter = address(saleRecipient).balance; + uint256 platformFeeRecipientAfter = address(platformFeeRecipient).balance; + uint256 expectedPlatformFee = (collectPrice_pricePerToken * platformFeeBps) / MAX_BPS; + uint256 expectedSaleRecipientProceed = collectPrice_msgValue - expectedPlatformFee; + + assertEq(balanceSaleRecipientAfter - balanceSaleRecipientBefore, expectedSaleRecipientProceed); + assertEq(platformFeeRecipientAfter - platformFeeRecipientBefore, expectedPlatformFee); + } + + function test_transferERC20ToSaleRecipient() public erc20Currency pricePerTokenNotZero { + uint256 balanceSaleRecipientBefore = erc20.balanceOf(saleRecipient); + uint256 platformFeeRecipientBefore = erc20.balanceOf(platformFeeRecipient); + erc20.approve(address(proxy), collectPrice_pricePerToken); + proxy.collectPriceOnClaimHarness( + 0, + saleRecipient, + collectPrice_quantityToClaim, + collectPrice_currency, + collectPrice_pricePerToken + ); + + uint256 balanceSaleRecipientAfter = erc20.balanceOf(saleRecipient); + uint256 platformFeeRecipientAfter = erc20.balanceOf(platformFeeRecipient); + uint256 expectedPlatformFee = (collectPrice_pricePerToken * platformFeeBps) / MAX_BPS; + uint256 expectedSaleRecipientProceed = collectPrice_pricePerToken - expectedPlatformFee; + + assertEq(balanceSaleRecipientAfter - balanceSaleRecipientBefore, expectedSaleRecipientProceed); + assertEq(platformFeeRecipientAfter - platformFeeRecipientBefore, expectedPlatformFee); + } + + function test_transferNativeCurrencyToTokenIdSaleRecipient() + public + nativeCurrency + pricePerTokenNotZero + msgValueNotZero + saleRecipientSet + primarySaleRecipientZeroAddress + { + uint256 balanceSaleRecipientBefore = address(collectPrice_tokenSaleRecipient).balance; + uint256 platformFeeRecipientBefore = address(platformFeeRecipient).balance; + proxy.collectPriceOnClaimHarness{ value: collectPrice_msgValue }( + 0, + address(0), + collectPrice_quantityToClaim, + collectPrice_currency, + collectPrice_pricePerToken + ); + + uint256 balanceSaleRecipientAfter = address(collectPrice_tokenSaleRecipient).balance; + uint256 platformFeeRecipientAfter = address(platformFeeRecipient).balance; + uint256 expectedPlatformFee = (collectPrice_pricePerToken * platformFeeBps) / MAX_BPS; + uint256 expectedSaleRecipientProceed = collectPrice_msgValue - expectedPlatformFee; + + assertEq(balanceSaleRecipientAfter - balanceSaleRecipientBefore, expectedSaleRecipientProceed); + assertEq(platformFeeRecipientAfter - platformFeeRecipientBefore, expectedPlatformFee); + } + + function test_transferERC20ToTokenIdSaleRecipient() public erc20Currency pricePerTokenNotZero saleRecipientSet { + uint256 balanceSaleRecipientBefore = erc20.balanceOf(collectPrice_tokenSaleRecipient); + uint256 platformFeeRecipientBefore = erc20.balanceOf(platformFeeRecipient); + erc20.approve(address(proxy), collectPrice_pricePerToken); + proxy.collectPriceOnClaimHarness( + 0, + address(0), + collectPrice_quantityToClaim, + collectPrice_currency, + collectPrice_pricePerToken + ); + + uint256 balanceSaleRecipientAfter = erc20.balanceOf(collectPrice_tokenSaleRecipient); + uint256 platformFeeRecipientAfter = erc20.balanceOf(platformFeeRecipient); + uint256 expectedPlatformFee = (collectPrice_pricePerToken * platformFeeBps) / MAX_BPS; + uint256 expectedSaleRecipientProceed = collectPrice_pricePerToken - expectedPlatformFee; + + assertEq(balanceSaleRecipientAfter - balanceSaleRecipientBefore, expectedSaleRecipientProceed); + assertEq(platformFeeRecipientAfter - platformFeeRecipientBefore, expectedPlatformFee); + } + + function test_transferNativeCurrencyToPrimarySaleRecipient() + public + nativeCurrency + pricePerTokenNotZero + msgValueNotZero + { + uint256 balanceSaleRecipientBefore = address(saleRecipient).balance; + uint256 platformFeeRecipientBefore = address(platformFeeRecipient).balance; + proxy.collectPriceOnClaimHarness{ value: collectPrice_msgValue }( + 0, + address(0), + collectPrice_quantityToClaim, + collectPrice_currency, + collectPrice_pricePerToken + ); + + uint256 balanceSaleRecipientAfter = address(saleRecipient).balance; + uint256 platformFeeRecipientAfter = address(platformFeeRecipient).balance; + uint256 expectedPlatformFee = (collectPrice_pricePerToken * platformFeeBps) / MAX_BPS; + uint256 expectedSaleRecipientProceed = collectPrice_msgValue - expectedPlatformFee; + + assertEq(balanceSaleRecipientAfter - balanceSaleRecipientBefore, expectedSaleRecipientProceed); + assertEq(platformFeeRecipientAfter - platformFeeRecipientBefore, expectedPlatformFee); + } + + function test_transferERC20ToPrimarySaleRecipient() public erc20Currency pricePerTokenNotZero { + uint256 balanceSaleRecipientBefore = erc20.balanceOf(saleRecipient); + uint256 platformFeeRecipientBefore = erc20.balanceOf(platformFeeRecipient); + erc20.approve(address(proxy), collectPrice_pricePerToken); + proxy.collectPriceOnClaimHarness( + 0, + address(0), + collectPrice_quantityToClaim, + collectPrice_currency, + collectPrice_pricePerToken + ); + + uint256 balanceSaleRecipientAfter = erc20.balanceOf(saleRecipient); + uint256 platformFeeRecipientAfter = erc20.balanceOf(platformFeeRecipient); + uint256 expectedPlatformFee = (collectPrice_pricePerToken * platformFeeBps) / MAX_BPS; + uint256 expectedSaleRecipientProceed = collectPrice_pricePerToken - expectedPlatformFee; + + assertEq(balanceSaleRecipientAfter - balanceSaleRecipientBefore, expectedSaleRecipientProceed); + assertEq(platformFeeRecipientAfter - platformFeeRecipientBefore, expectedPlatformFee); + } +} diff --git a/src/test/drop/drop-erc1155/collectPriceOnClaim/collectPriceOnClaim.tree b/src/test/drop/drop-erc1155/collectPriceOnClaim/collectPriceOnClaim.tree new file mode 100644 index 000000000..6cb5cd180 --- /dev/null +++ b/src/test/drop/drop-erc1155/collectPriceOnClaim/collectPriceOnClaim.tree @@ -0,0 +1,44 @@ +function collectPriceOnClaim( + uint256 _tokenId, + address _primarySaleRecipient, + uint256 _quantityToClaim, + address _currency, + uint256 _pricePerToken + ) +├── when _pricePerToken is equal to zero +│ ├── when msg.value does not equal to zero +│ │ └── it should revert ✅ +│ └── when msg.value is equal to zero +│ └── it should return ✅ +└── when _pricePerToken is not equal to zero + ├── when _primarySaleRecipient is equal to address(0) + │ ├── when saleRecipient for _tokenId is equal to address(0) + │ │ ├── when currency is native token + │ │ │ ├── when msg.value does not equal totalPrice + │ │ │ │ └── it should revert ✅ + │ │ │ └── when msg.value does equal totalPrice + │ │ │ ├── it should transfer platformFees to platformFeeRecipient in native token ✅ + │ │ │ └── it should transfer totalPrice - platformFees to primarySaleRecipient in native token ✅ + │ │ └── when currency is not native token + │ │ ├── it should transfer platformFees to platformFeeRecipient in _currency token ✅ + │ │ └── it should transfer totalPrice - platformFees to primarySaleRecipient in _currency token ✅ + │ └── when salerecipient for _tokenId is not equal to address(0) + │ ├── when currency is native token + │ │ ├── when msg.value does not equal totalPrice + │ │ │ └── it should revert ✅ + │ │ └── when msg.value does equal totalPrice + │ │ ├── it should transfer platformFees to platformFeeRecipient in native token ✅ + │ │ └── it should transfer totalPrice - platformFees to saleRecipient for _tokenId in native token ✅ + │ └── when currency is not native token + │ ├── it should transfer platformFees to platformFeeRecipient in _currency token ✅ + │ └── it should transfer totalPrice - platformFees to saleRecipient for _tokenId in _currency token ✅ + └── when _primarySaleRecipient is not equal to address(0) + ├── when currency is native token + │ ├── when msg.value does not equal totalPrice + │ │ └── it should revert ✅ + │ └── when msg.value does equal totalPrice + │ ├── it should transfer platformFees to platformFeeRecipient in native token ✅ + │ └── it should transfer totalPrice - platformFees to _primarySaleRecipient in native token ✅ + └── when currency is not native token + ├── it should transfer platformFees to platformFeeRecipient in _currency token ✅ + └── it should transfer totalPrice - platformFees to _primarySaleRecipient in _currency token ✅ \ No newline at end of file diff --git a/src/test/drop/drop-erc1155/freezeBatchBaseURI/freezeBatchBaseURI.t.sol b/src/test/drop/drop-erc1155/freezeBatchBaseURI/freezeBatchBaseURI.t.sol new file mode 100644 index 000000000..774669b51 --- /dev/null +++ b/src/test/drop/drop-erc1155/freezeBatchBaseURI/freezeBatchBaseURI.t.sol @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { DropERC1155, BatchMintMetadata } from "contracts/prebuilts/drop/DropERC1155.sol"; + +// Test imports + +import "../../../utils/BaseTest.sol"; +import "../../../../../lib/openzeppelin-contracts-upgradeable/contracts/interfaces/IERC2981Upgradeable.sol"; + +contract DropERC1155Test_freezeBatchBaseURI is BaseTest { + event MetadataFrozen(); + + DropERC1155 public drop; + + address private unauthorized = address(0x123); + + bytes private emptyEncodedBytes = abi.encode("", ""); + + function setUp() public override { + super.setUp(); + drop = DropERC1155(getContract("DropERC1155")); + } + + /*/////////////////////////////////////////////////////////////// + Branch Testing + //////////////////////////////////////////////////////////////*/ + + modifier callerWithoutMetadataRole() { + vm.startPrank(unauthorized); + _; + } + + modifier callerWithMetadataRole() { + vm.startPrank(deployer); + _; + } + + modifier lazyMint() { + vm.prank(deployer); + drop.lazyMint(100, "ipfs://", emptyEncodedBytes); + _; + } + + modifier lazyMintEmptyUri() { + vm.prank(deployer); + drop.lazyMint(100, "", emptyEncodedBytes); + _; + } + + function test_revert_NoMetadataRole() public lazyMint callerWithoutMetadataRole { + bytes32 role = keccak256("METADATA_ROLE"); + vm.expectRevert( + abi.encodeWithSelector(Permissions.PermissionsUnauthorizedAccount.selector, unauthorized, role) + ); + drop.freezeBatchBaseURI(0); + } + + function test_revert_IndexTooHigh() public lazyMint callerWithMetadataRole { + vm.expectRevert(abi.encodeWithSelector(BatchMintMetadata.BatchMintInvalidBatchId.selector, 1)); + drop.freezeBatchBaseURI(1); + } + + function test_revert_EmptyBaseURI() public lazyMintEmptyUri callerWithMetadataRole { + vm.expectRevert( + abi.encodeWithSelector(BatchMintMetadata.BatchMintInvalidBatchId.selector, drop.getBatchIdAtIndex(0)) + ); + drop.freezeBatchBaseURI(0); + } + + function test_state() public lazyMint callerWithMetadataRole { + uint256 batchId = drop.getBatchIdAtIndex(0); + drop.freezeBatchBaseURI(0); + assertEq(drop.batchFrozen(batchId), true); + } + + function test_event() public lazyMint callerWithMetadataRole { + vm.expectEmit(false, false, false, false); + emit MetadataFrozen(); + drop.freezeBatchBaseURI(0); + } +} diff --git a/src/test/drop/drop-erc1155/freezeBatchBaseURI/freezeBatchBaseURI.tree b/src/test/drop/drop-erc1155/freezeBatchBaseURI/freezeBatchBaseURI.tree new file mode 100644 index 000000000..7a9a00b7f --- /dev/null +++ b/src/test/drop/drop-erc1155/freezeBatchBaseURI/freezeBatchBaseURI.tree @@ -0,0 +1,12 @@ +function freezeBatchBaseURI(uint256 _index) +├── when the caller does not have metadataRole +│ └── it should revert ✅ +└── when the caller has metadataRole + ├── when _index is greater than the number of current batches + │ └── it should revert ✅ + └── when _index is equal to or less than the number of current batches + ├── when the baseURI for the batch at _index is not set + │ └── it should revert ✅ + └── when the baseURI for the batch at _index is set + ├── it should set batchFrozen[(batchId for _index)] to true ✅ + └── it should emit MetadataFrozen ✅ \ No newline at end of file diff --git a/src/test/drop/drop-erc1155/initialize/initialize.t.sol b/src/test/drop/drop-erc1155/initialize/initialize.t.sol new file mode 100644 index 000000000..4f242b9e9 --- /dev/null +++ b/src/test/drop/drop-erc1155/initialize/initialize.t.sol @@ -0,0 +1,407 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { DropERC1155, Royalty, PlatformFee } from "contracts/prebuilts/drop/DropERC1155.sol"; + +// Test imports +import "../../../utils/BaseTest.sol"; + +contract DropERC1155Test_initializer is BaseTest { + DropERC1155 public newDropContract; + + event ContractURIUpdated(string prevURI, string newURI); + event OwnerUpdated(address indexed prevOwner, address indexed newOwner); + event RoleGranted(bytes32 indexed role, address indexed account, address indexed sender); + event RoleAdminChanged(bytes32 indexed role, bytes32 indexed previousAdminRole, bytes32 indexed newAdminRole); + event PlatformFeeInfoUpdated(address indexed platformFeeRecipient, uint256 platformFeeBps); + event DefaultRoyalty(address indexed newRoyaltyRecipient, uint256 newRoyaltyBps); + event PrimarySaleRecipientUpdated(address indexed recipient); + + function setUp() public override { + super.setUp(); + } + + modifier royaltyBPSTooHigh() { + uint128 royaltyBps = 10001; + _; + } + + modifier platformFeeBPSTooHigh() { + uint128 platformFeeBps = 10001; + _; + } + + function test_state() public { + deployContractProxy( + "DropERC1155", + abi.encodeCall( + DropERC1155.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ); + + newDropContract = DropERC1155(getContract("DropERC1155")); + (address _platformFeeRecipient, uint128 _platformFeeBps) = newDropContract.getPlatformFeeInfo(); + (address _royaltyRecipient, uint128 _royaltyBps) = newDropContract.getDefaultRoyaltyInfo(); + address _saleRecipient = newDropContract.primarySaleRecipient(); + + for (uint256 i = 0; i < forwarders().length; i++) { + assertEq(newDropContract.isTrustedForwarder(forwarders()[i]), true); + } + + assertEq(newDropContract.name(), NAME); + assertEq(newDropContract.symbol(), SYMBOL); + assertEq(newDropContract.contractURI(), CONTRACT_URI); + assertEq(newDropContract.owner(), deployer); + assertEq(_platformFeeRecipient, platformFeeRecipient); + assertEq(_platformFeeBps, platformFeeBps); + assertEq(_royaltyRecipient, royaltyRecipient); + assertEq(_royaltyBps, royaltyBps); + assertEq(_saleRecipient, saleRecipient); + } + + function test_revert_RoyaltyBPSTooHigh() public royaltyBPSTooHigh { + vm.expectRevert(abi.encodeWithSelector(Royalty.RoyaltyExceededMaxFeeBps.selector, 10_000, 10_001)); + deployContractProxy( + "DropERC1155", + abi.encodeCall( + DropERC1155.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + 10001, + platformFeeBps, + platformFeeRecipient + ) + ) + ); + } + + function test_revert_PlatformFeeBPSTooHigh() public platformFeeBPSTooHigh { + vm.expectRevert(abi.encodeWithSelector(PlatformFee.PlatformFeeExceededMaxFeeBps.selector, 10_000, 10_001)); + deployContractProxy( + "DropERC1155", + abi.encodeCall( + DropERC1155.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + 10001, + platformFeeRecipient + ) + ) + ); + } + + function test_event_ContractURIUpdated() public { + vm.expectEmit(false, false, false, true); + emit ContractURIUpdated("", CONTRACT_URI); + deployContractProxy( + "DropERC1155", + abi.encodeCall( + DropERC1155.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ); + } + + function test_event_OwnerUpdated() public { + vm.expectEmit(true, true, false, false); + emit OwnerUpdated(address(0), deployer); + deployContractProxy( + "DropERC1155", + abi.encodeCall( + DropERC1155.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ); + } + + function test_event_RoleGrantedDefaultAdminRole() public { + bytes32 role = bytes32(0x00); + vm.expectEmit(true, true, true, false); + emit RoleGranted(role, deployer, factory); + deployContractProxy( + "DropERC1155", + abi.encodeCall( + DropERC1155.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ); + } + + function test_event_RoleGrantedMinterRole() public { + bytes32 role = keccak256("MINTER_ROLE"); + vm.expectEmit(true, true, true, false); + emit RoleGranted(role, deployer, factory); + deployContractProxy( + "DropERC1155", + abi.encodeCall( + DropERC1155.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ); + } + + function test_event_RoleGrantedTransferRole() public { + bytes32 role = keccak256("TRANSFER_ROLE"); + vm.expectEmit(true, true, true, false); + emit RoleGranted(role, deployer, factory); + deployContractProxy( + "DropERC1155", + abi.encodeCall( + DropERC1155.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ); + } + + function test_event_RoleGrantedTransferRoleZeroAddress() public { + bytes32 role = keccak256("TRANSFER_ROLE"); + vm.expectEmit(true, true, true, false); + emit RoleGranted(role, address(0), factory); + deployContractProxy( + "DropERC1155", + abi.encodeCall( + DropERC1155.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ); + } + + function test_event_RoleGrantedMetadataRole() public { + bytes32 role = keccak256("METADATA_ROLE"); + vm.expectEmit(true, true, true, false); + emit RoleGranted(role, deployer, factory); + deployContractProxy( + "DropERC1155", + abi.encodeCall( + DropERC1155.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ); + } + + function test_event_RoleAdminChangedMetadataRole() public { + bytes32 role = keccak256("METADATA_ROLE"); + vm.expectEmit(true, true, true, false); + emit RoleAdminChanged(role, bytes32(0x00), role); + deployContractProxy( + "DropERC1155", + abi.encodeCall( + DropERC1155.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ); + } + + function test_event_PlatformFeeInfoUpdated() public { + vm.expectEmit(true, false, false, true); + emit PlatformFeeInfoUpdated(platformFeeRecipient, platformFeeBps); + deployContractProxy( + "DropERC1155", + abi.encodeCall( + DropERC1155.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ); + } + + function test_event_DefaultRoyalty() public { + vm.expectEmit(true, false, false, true); + emit DefaultRoyalty(royaltyRecipient, royaltyBps); + deployContractProxy( + "DropERC1155", + abi.encodeCall( + DropERC1155.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ); + } + + function test_event_PrimarySaleRecipientUpdated() public { + vm.expectEmit(true, false, false, false); + emit PrimarySaleRecipientUpdated(saleRecipient); + deployContractProxy( + "DropERC1155", + abi.encodeCall( + DropERC1155.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ); + } + + function test_roleCheck() public { + deployContractProxy( + "DropERC1155", + abi.encodeCall( + DropERC1155.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ); + + newDropContract = DropERC1155(getContract("DropERC1155")); + + assertEq(newDropContract.hasRole(bytes32(0x00), deployer), true); + assertEq(newDropContract.hasRole(keccak256("MINTER_ROLE"), deployer), true); + assertEq(newDropContract.hasRole(keccak256("TRANSFER_ROLE"), deployer), true); + assertEq(newDropContract.hasRole(keccak256("TRANSFER_ROLE"), address(0)), true); + assertEq(newDropContract.hasRole(keccak256("METADATA_ROLE"), deployer), true); + + assertEq(newDropContract.getRoleAdmin(keccak256("METADATA_ROLE")), keccak256("METADATA_ROLE")); + } +} diff --git a/src/test/drop/drop-erc1155/initialize/initialize.tree b/src/test/drop/drop-erc1155/initialize/initialize.tree new file mode 100644 index 000000000..76c8d15d0 --- /dev/null +++ b/src/test/drop/drop-erc1155/initialize/initialize.tree @@ -0,0 +1,50 @@ +function initialize( + address _defaultAdmin, + string memory _name, + string memory _symbol, + string memory _contractURI, + address[] memory _trustedForwarders, + address _saleRecipient, + address _royaltyRecipient, + uint128 _royaltyBps, + uint128 _platformFeeBps, + address _platformFeeRecipient +) +├── when _trustedForwarders.length > 0 +│ └── it should set _trustedForwarder[_trustedForwarders[i]] as true for each address in _trustedForwarders ✅ +├── it should set _uri to an empty string ✅ +├── it should set contractURI as _contractURI ✅ +├── it should emit ContractURIUpdated with the parameters: prevURI, _uri ✅ +├── it should set _defaultAdmin as the owner of the contract ✅ +├── it should emit OwnerUpdated with the parameters: _prevOwner, _defaultAdmin ✅ +├── it should assign the role DEFAULT_ADMIN_ROLE to _defaultAdmin ✅ +├── it should emit RoleGranted with the parameters: DEFAULT_ADMIN_ROLE, _defaultAdmin, msg.sender ✅ +├── it should assign the role _minterRole to _defaultAdmin ✅ +├── it should emit RoleGranted with the parameters: _minterRole, _defaultAdmin, msg.sender ✅ +├── it should assign the role _transferRole to _defaultAdmin ✅ +├── it should emit RoleGranted with the parameters: _transferRole, _defaultAdmin, msg.sender ✅ +├── it should assign the role _transferRole to address(0) ✅ +├── it should emit RoleGranted with the parameters: _transferRole, address(0), msg.sender ✅ +├── it should assign the role _metadataRole to _defaultAdmin ✅ +├── it should emit RoleGranted with the parameters: _metadataRole, _defaultAdmin, msg.sender ✅ +├── it should set _getAdminRole[_metadataRole] to equal _metadataRole ✅ +├── it should emit RoleAdminChanged with the parameters _metadataRole, previousAdminRole, _metadataRole ✅ +├── when _platformFeeBps is greater than 10_000 +│ └── it should revert ✅ +├── when _platformFeeBps is less than or equal to 10_000 +│ ├── it should set platformFeeBps to uint16(_platformFeeBps); ✅ +│ ├── it should set platformFeeRecipient to _platformFeeRecipient ✅ +│ └── it should emit PlatformFeeInfoUpdated with the following parameters: _platformFeeRecipient, _platformFeeBps ✅ +├── when _royaltyBps is greater than 10_000 +│ └── it should revert ✅ +├── when _royaltyBps is less than or equal to 10_000 +│ ├── it should set royaltyRecipient as _royaltyRecipient ✅ +│ ├── it should set royaltyBps as uint16(_royaltyBps) ✅ +│ └── it should emit DefaultRoyalty with the parameters _royaltyRecipient, _royaltyBps ✅ +├── it should set recipient as _primarySaleRecipient ✅ +├── it should emit PrimarySaleRecipientUpdated with the parameters _primarySaleRecipient ✅ +├── it should set transferRole as keccak256("TRANSFER_ROLE") ✅ +├── it should set minterRole as keccak256("MINTER_ROLE") ✅ +├── it should set metadataRole as keccak256("METADATA_ROLE") ✅ +├── it should set name as _name ✅ +└── it should set symbol as _symbol ✅ diff --git a/src/test/drop/drop-erc1155/miscellaneous/miscellaneous.t.sol b/src/test/drop/drop-erc1155/miscellaneous/miscellaneous.t.sol new file mode 100644 index 000000000..661c0e3db --- /dev/null +++ b/src/test/drop/drop-erc1155/miscellaneous/miscellaneous.t.sol @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { DropERC1155 } from "contracts/prebuilts/drop/DropERC1155.sol"; + +// Test imports + +import "../../../utils/BaseTest.sol"; +import "../../../../../lib/openzeppelin-contracts-upgradeable/contracts/interfaces/IERC2981Upgradeable.sol"; +import "../../../../../lib/openzeppelin-contracts-upgradeable/contracts/interfaces/IERC1155Upgradeable.sol"; +import "../../../../../lib/openzeppelin-contracts-upgradeable/contracts/interfaces/IERC1155MetadataURIUpgradeable.sol"; + +contract DropERC1155Test_misc is BaseTest { + DropERC1155 public drop; + + bytes private emptyEncodedBytes = abi.encode("", ""); + + function setUp() public override { + super.setUp(); + drop = DropERC1155(getContract("DropERC1155")); + } + + /*/////////////////////////////////////////////////////////////// + Branch Testing + //////////////////////////////////////////////////////////////*/ + + modifier lazyMint() { + vm.prank(deployer); + drop.lazyMint(10, "ipfs://", emptyEncodedBytes); + _; + } + + function test_nextTokenIdToMint_ZeroLazyMinted() public { + uint256 nextTokenIdToMint = drop.nextTokenIdToMint(); + assertEq(nextTokenIdToMint, 0); + } + + function test_nextTokenIdToMint_TenLazyMinted() public lazyMint { + uint256 nextTokenIdToMint = drop.nextTokenIdToMint(); + assertEq(nextTokenIdToMint, 10); + } + + function test_contractType() public { + assertEq(drop.contractType(), bytes32("DropERC1155")); + } + + function test_contractVersion() public { + assertEq(drop.contractVersion(), uint8(4)); + } + + function test_supportsInterface() public { + assertEq(drop.supportsInterface(type(IERC2981Upgradeable).interfaceId), true); + assertEq(drop.supportsInterface(type(IERC1155Upgradeable).interfaceId), true); + assertEq(drop.supportsInterface(type(IERC1155MetadataURIUpgradeable).interfaceId), true); + } + + function test__msgData() public { + HarnessDropERC1155MsgData msgDataDrop = new HarnessDropERC1155MsgData(); + bytes memory msgData = msgDataDrop.msgData(); + bytes4 expectedData = msgDataDrop.msgData.selector; + assertEq(bytes4(msgData), expectedData); + } +} + +contract HarnessDropERC1155MsgData is DropERC1155 { + function msgData() public view returns (bytes memory) { + return _msgData(); + } +} diff --git a/src/test/drop/drop-erc1155/miscellaneous/miscellaneous.tree b/src/test/drop/drop-erc1155/miscellaneous/miscellaneous.tree new file mode 100644 index 000000000..e74189c71 --- /dev/null +++ b/src/test/drop/drop-erc1155/miscellaneous/miscellaneous.tree @@ -0,0 +1,8 @@ +function nextTokenIdToMint() +└── it should return the next tokenId that is to be lazy minted ✅ + +function contractType() +└── it should return "DropERC1155" in bytes32 format ✅ + +function contractVersion() +└── it should return 4 in uint8 format ✅ \ No newline at end of file diff --git a/src/test/drop/drop-erc1155/setMaxTotalSupply/setMaxTotalSupply.t.sol b/src/test/drop/drop-erc1155/setMaxTotalSupply/setMaxTotalSupply.t.sol new file mode 100644 index 000000000..965c4ce5e --- /dev/null +++ b/src/test/drop/drop-erc1155/setMaxTotalSupply/setMaxTotalSupply.t.sol @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { DropERC1155 } from "contracts/prebuilts/drop/DropERC1155.sol"; + +// Test imports + +import "../../../utils/BaseTest.sol"; +import "../../../../../lib/openzeppelin-contracts-upgradeable/contracts/interfaces/IERC2981Upgradeable.sol"; + +contract DropERC1155Test_setMaxTotalSupply is BaseTest { + DropERC1155 public drop; + + address private unauthorized = address(0x123); + + uint256 private newMaxSupply = 100; + string private updatedBaseURI = "ipfs://"; + + event MaxTotalSupplyUpdated(uint256 tokenId, uint256 maxTotalSupply); + + function setUp() public override { + super.setUp(); + drop = DropERC1155(getContract("DropERC1155")); + } + + /*/////////////////////////////////////////////////////////////// + Branch Testing + //////////////////////////////////////////////////////////////*/ + + modifier callerWithoutAdminRole() { + vm.startPrank(unauthorized); + _; + } + + modifier callerWithAdminRole() { + vm.startPrank(deployer); + _; + } + + function test_revert_NoAdminRole() public callerWithoutAdminRole { + bytes32 role = bytes32(0x00); + vm.expectRevert( + abi.encodeWithSelector(Permissions.PermissionsUnauthorizedAccount.selector, unauthorized, role) + ); + drop.setMaxTotalSupply(0, newMaxSupply); + } + + function test_state() public callerWithAdminRole { + drop.setMaxTotalSupply(0, newMaxSupply); + uint256 newMaxTotalSupply = drop.maxTotalSupply(0); + assertEq(newMaxSupply, newMaxTotalSupply); + } + + function test_event() public callerWithAdminRole { + vm.expectEmit(false, false, false, true); + emit MaxTotalSupplyUpdated(0, newMaxSupply); + drop.setMaxTotalSupply(0, newMaxSupply); + } +} diff --git a/src/test/drop/drop-erc1155/setMaxTotalSupply/setMaxTotalSupply.tree b/src/test/drop/drop-erc1155/setMaxTotalSupply/setMaxTotalSupply.tree new file mode 100644 index 000000000..8aa6ad0ef --- /dev/null +++ b/src/test/drop/drop-erc1155/setMaxTotalSupply/setMaxTotalSupply.tree @@ -0,0 +1,6 @@ +function setMaxTotalSupply(uint256 _tokenId, uint256 _maxTotalSupply) +├── when the caller does not have DEFAULT_ADMIN_ROLE +│ └── it should revert ✅ +└── when the caller does have DEFAULT_ADMIN_ROLE + ├── it should set maxTotalSupply for _tokenId as _maxTotalSupply ✅ + └── it should emit MaxTotalSupplyUpdated with the parameters _tokenId, _maxTotalSupply ✅ \ No newline at end of file diff --git a/src/test/drop/drop-erc1155/setSaleRecipientForToken/setSaleRecipientForToken.t.sol b/src/test/drop/drop-erc1155/setSaleRecipientForToken/setSaleRecipientForToken.t.sol new file mode 100644 index 000000000..a3d194a53 --- /dev/null +++ b/src/test/drop/drop-erc1155/setSaleRecipientForToken/setSaleRecipientForToken.t.sol @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { DropERC1155 } from "contracts/prebuilts/drop/DropERC1155.sol"; + +// Test imports + +import "../../../utils/BaseTest.sol"; +import "../../../../../lib/openzeppelin-contracts-upgradeable/contracts/interfaces/IERC2981Upgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/utils/StringsUpgradeable.sol"; + +contract DropERC1155Test_setSaleRecipientForToken is BaseTest { + using Strings for uint256; + + DropERC1155 public drop; + + address private unauthorized = address(0x123); + address private recipient = address(0x456); + + event SaleRecipientForTokenUpdated(uint256 indexed tokenId, address saleRecipient); + + function setUp() public override { + super.setUp(); + drop = DropERC1155(getContract("DropERC1155")); + } + + /*/////////////////////////////////////////////////////////////// + Branch Testing + //////////////////////////////////////////////////////////////*/ + + modifier callerWithoutAdminRole() { + vm.startPrank(unauthorized); + _; + } + + modifier callerWithAdminRole() { + vm.startPrank(deployer); + _; + } + + function test_revert_NoAdminRole() public callerWithoutAdminRole { + bytes32 role = bytes32(0x00); + vm.expectRevert( + abi.encodeWithSelector(Permissions.PermissionsUnauthorizedAccount.selector, unauthorized, role) + ); + drop.setSaleRecipientForToken(0, recipient); + } + + function test_state() public callerWithAdminRole { + drop.setSaleRecipientForToken(0, recipient); + address newSaleRecipient = drop.saleRecipient(0); + assertEq(newSaleRecipient, recipient); + } + + function test_event() public callerWithAdminRole { + vm.expectEmit(true, true, false, false); + emit SaleRecipientForTokenUpdated(0, recipient); + drop.setSaleRecipientForToken(0, recipient); + } +} diff --git a/src/test/drop/drop-erc1155/setSaleRecipientForToken/setSaleRecipientForToken.tree b/src/test/drop/drop-erc1155/setSaleRecipientForToken/setSaleRecipientForToken.tree new file mode 100644 index 000000000..72d7c2cad --- /dev/null +++ b/src/test/drop/drop-erc1155/setSaleRecipientForToken/setSaleRecipientForToken.tree @@ -0,0 +1,6 @@ +function setSaleRecipientForToken(uint256 _tokenId, address _saleRecipient) +├── when called by a user without DEFAULT_ADMIN_ROLE +│ └── it should revert ✅ +└── when called by a user with DEFAULT_ADMIN_ROLE + ├── it should set saleRecipient for _tokenId as _saleRecipient ✅ + └── it should emit SaleRecipientForTokenUpdated with the parameters _tokenId, _saleRecipient ✅ \ No newline at end of file diff --git a/src/test/drop/drop-erc1155/transferTokensOnClaim/transferTokensOnClaim.t.sol b/src/test/drop/drop-erc1155/transferTokensOnClaim/transferTokensOnClaim.t.sol new file mode 100644 index 000000000..411206cc5 --- /dev/null +++ b/src/test/drop/drop-erc1155/transferTokensOnClaim/transferTokensOnClaim.t.sol @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { DropERC1155 } from "contracts/prebuilts/drop/DropERC1155.sol"; +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +// Test imports + +import "../../../utils/BaseTest.sol"; +import "@openzeppelin/contracts-upgradeable/utils/StringsUpgradeable.sol"; + +contract HarnessDropERC1155 is DropERC1155 { + function transferTokensOnClaimHarness(address to, uint256 _tokenId, uint256 _quantityBeingClaimed) external { + transferTokensOnClaim(to, _tokenId, _quantityBeingClaimed); + } +} + +contract MockERC1155Receiver { + function onERC1155Received(address, address, uint256, uint256, bytes memory) external pure returns (bytes4) { + return this.onERC1155Received.selector; + } + + function onERC1155BatchReceived( + address, + address, + uint256[] memory, + uint256[] memory, + bytes memory + ) external pure returns (bytes4) { + return this.onERC1155BatchReceived.selector; + } +} + +contract MockERC11555NotReceiver {} + +contract DropERC1155Test_transferTokensOnClaim is BaseTest { + using Strings for uint256; + using Strings for address; + + address private to; + MockERC1155Receiver private receiver; + MockERC11555NotReceiver private notReceiver; + + address public dropImp; + HarnessDropERC1155 public proxy; + + function setUp() public override { + super.setUp(); + + bytes memory initializeData = abi.encodeCall( + DropERC1155.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ); + + dropImp = address(new HarnessDropERC1155()); + proxy = HarnessDropERC1155(address(new TWProxy(dropImp, initializeData))); + + receiver = new MockERC1155Receiver(); + notReceiver = new MockERC11555NotReceiver(); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: misc. + //////////////////////////////////////////////////////////////*/ + + modifier toEOA() { + to = address(0x01); + _; + } + + modifier toReceiever() { + to = address(receiver); + _; + } + + modifier toNotReceiever() { + to = address(notReceiver); + _; + } + + /** + * note: Tests whether contract reverts when a non-holder renounces a role. + */ + function test_revert_ContractNotERC155Receiver() public toNotReceiever { + vm.expectRevert("ERC1155: transfer to non-ERC1155Receiver implementer"); + proxy.transferTokensOnClaimHarness(to, 0, 1); + } + + function test_state_ContractERC1155Receiver() public toReceiever { + uint256 beforeBalance = proxy.balanceOf(to, 0); + proxy.transferTokensOnClaimHarness(to, 0, 1); + uint256 afterBalance = proxy.balanceOf(to, 0); + assertEq(beforeBalance + 1, afterBalance); + } + + function test_state_EOAReceiver() public toEOA { + uint256 beforeBalance = proxy.balanceOf(to, 0); + proxy.transferTokensOnClaimHarness(to, 0, 1); + uint256 afterBalance = proxy.balanceOf(to, 0); + assertEq(beforeBalance + 1, afterBalance); + } +} diff --git a/src/test/drop/drop-erc1155/transferTokensOnClaim/transferTokensOnClaim.tree b/src/test/drop/drop-erc1155/transferTokensOnClaim/transferTokensOnClaim.tree new file mode 100644 index 000000000..eb600dbed --- /dev/null +++ b/src/test/drop/drop-erc1155/transferTokensOnClaim/transferTokensOnClaim.tree @@ -0,0 +1,12 @@ +function transferTokensOnClaim( + address _to, + uint256 _tokenId, + uint256 _quantityBeingClaimed +) +├── when {to} is a smart contract +│ ├── when {to} does not implement onERC1155Received +│ │ └── it should revert ✅ +│ └── when {to} does implement onERC1155Received +│ └── it should mint {amount} number of {id} tokens to {to} ✅ +└── when {to} is an EOA + └── it should mint {amount} number of {id} tokens to {to} ✅ \ No newline at end of file diff --git a/src/test/drop/drop-erc1155/updateBatchBaseURI/updateBatchBaseURI.t.sol b/src/test/drop/drop-erc1155/updateBatchBaseURI/updateBatchBaseURI.t.sol new file mode 100644 index 000000000..fa980975b --- /dev/null +++ b/src/test/drop/drop-erc1155/updateBatchBaseURI/updateBatchBaseURI.t.sol @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { DropERC1155, BatchMintMetadata } from "contracts/prebuilts/drop/DropERC1155.sol"; + +// Test imports + +import "../../../utils/BaseTest.sol"; +import "../../../../../lib/openzeppelin-contracts-upgradeable/contracts/interfaces/IERC2981Upgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/utils/StringsUpgradeable.sol"; + +contract DropERC1155Test_updateBatchBaseURI is BaseTest { + using Strings for uint256; + + event MetadataFrozen(); + + DropERC1155 public drop; + + address private unauthorized = address(0x123); + + bytes private emptyEncodedBytes = abi.encode("", ""); + string private updatedBaseURI = "ipfs://"; + + event BatchMetadataUpdate(uint256 _fromTokenId, uint256 _toTokenId); + + function setUp() public override { + super.setUp(); + drop = DropERC1155(getContract("DropERC1155")); + } + + /*/////////////////////////////////////////////////////////////// + Branch Testing + //////////////////////////////////////////////////////////////*/ + + modifier callerWithoutMetadataRole() { + vm.startPrank(unauthorized); + _; + } + + modifier callerWithMetadataRole() { + vm.startPrank(deployer); + _; + } + + modifier lazyMint() { + vm.prank(deployer); + drop.lazyMint(100, "ipfs://", emptyEncodedBytes); + _; + } + + modifier lazyMintEmptyUri() { + vm.prank(deployer); + drop.lazyMint(100, "", emptyEncodedBytes); + _; + } + + modifier batchFrozen() { + vm.prank(deployer); + drop.freezeBatchBaseURI(0); + _; + } + + function test_revert_NoMetadataRole() public lazyMint callerWithoutMetadataRole { + bytes32 role = keccak256("METADATA_ROLE"); + vm.expectRevert( + abi.encodeWithSelector(Permissions.PermissionsUnauthorizedAccount.selector, unauthorized, role) + ); + drop.updateBatchBaseURI(0, updatedBaseURI); + } + + function test_revert_IndexTooHigh() public lazyMint callerWithMetadataRole { + vm.expectRevert(abi.encodeWithSelector(BatchMintMetadata.BatchMintInvalidBatchId.selector, 1)); + drop.updateBatchBaseURI(1, updatedBaseURI); + } + + function test_revert_BatchFrozen() public lazyMint batchFrozen callerWithMetadataRole { + vm.expectRevert( + abi.encodeWithSelector(BatchMintMetadata.BatchMintMetadataFrozen.selector, drop.getBatchIdAtIndex(0)) + ); + drop.updateBatchBaseURI(0, updatedBaseURI); + } + + function test_state() public lazyMint callerWithMetadataRole { + drop.updateBatchBaseURI(0, updatedBaseURI); + string memory newBaseURI = drop.uri(0); + console.log("newBaseURI: %s", newBaseURI); + assertEq(newBaseURI, string(abi.encodePacked(updatedBaseURI, "0"))); + } + + function test_event() public lazyMint callerWithMetadataRole { + vm.expectEmit(false, false, false, false); + emit BatchMetadataUpdate(0, 100); + drop.updateBatchBaseURI(0, updatedBaseURI); + } +} diff --git a/src/test/drop/drop-erc1155/updateBatchBaseURI/updateBatchBaseURI.tree b/src/test/drop/drop-erc1155/updateBatchBaseURI/updateBatchBaseURI.tree new file mode 100644 index 000000000..75ffaadcf --- /dev/null +++ b/src/test/drop/drop-erc1155/updateBatchBaseURI/updateBatchBaseURI.tree @@ -0,0 +1,9 @@ +function updateBatchBaseURI(uint256 _index, string calldata _uri) +├── when the caller does not have metadataRole +│ └── it should revert ✅ +└── when the caller has metadataRole + ├── when batchFrozen[_batchId for _index] is equal to true + │ └── it should revert ✅ + └── when batchFrozen[_batchId for _index] is equal to false + ├── it should set baseURI[_batchId for _index] to _uri ✅ + └── it should emit BatchMetadataUpdate with the parameters startingTokenId, _batchId ✅ \ No newline at end of file diff --git a/src/test/drop/drop-erc20/_beforeClaim/_beforeClaim.t.sol b/src/test/drop/drop-erc20/_beforeClaim/_beforeClaim.t.sol new file mode 100644 index 000000000..c6c3e681b --- /dev/null +++ b/src/test/drop/drop-erc20/_beforeClaim/_beforeClaim.t.sol @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { DropERC20 } from "contracts/prebuilts/drop/DropERC20.sol"; +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +// Test imports +import "../../../utils/BaseTest.sol"; + +contract HarnessDropERC20BeforeClaim is DropERC20 { + bytes private emptyBytes = bytes(""); + + function harness_beforeClaim(uint256 quantity, AllowlistProof calldata _proof) public view { + _beforeClaim(address(0), quantity, address(0), 0, _proof, emptyBytes); + } +} + +contract DropERC20Test_beforeClaim is BaseTest { + address public dropImp; + HarnessDropERC20BeforeClaim public proxy; + + uint256 private mintQty; + + function setUp() public override { + super.setUp(); + + bytes memory initializeData = abi.encodeCall( + DropERC20.initialize, + (deployer, NAME, SYMBOL, CONTRACT_URI, forwarders(), saleRecipient, platformFeeRecipient, platformFeeBps) + ); + + dropImp = address(new HarnessDropERC20BeforeClaim()); + proxy = HarnessDropERC20BeforeClaim(address(new TWProxy(dropImp, initializeData))); + } + + modifier setMaxTotalSupply() { + vm.prank(deployer); + proxy.setMaxTotalSupply(100); + _; + } + + modifier qtyExceedMaxTotalSupply() { + mintQty = 101; + _; + } + + function test_revert_MaxSupplyExceeded() public setMaxTotalSupply qtyExceedMaxTotalSupply { + DropERC20.AllowlistProof memory proof; + vm.expectRevert("exceed max total supply."); + proxy.harness_beforeClaim(mintQty, proof); + } +} diff --git a/src/test/drop/drop-erc20/_beforeClaim/_beforeClaim.tree b/src/test/drop/drop-erc20/_beforeClaim/_beforeClaim.tree new file mode 100644 index 000000000..f1cf867a4 --- /dev/null +++ b/src/test/drop/drop-erc20/_beforeClaim/_beforeClaim.tree @@ -0,0 +1,10 @@ +function _beforeClaim( + address, + uint256 _quantity, + address, + uint256, + AllowlistProof calldata, + bytes memory +) +└── when maxTotalSupply does not equal to 0 and totalSupply() + _quantity is greater than _maxTotalSupply + └── it should revert ✅ \ No newline at end of file diff --git a/src/test/drop/drop-erc20/_canSetFunctions/_canSetFunctions.t.sol b/src/test/drop/drop-erc20/_canSetFunctions/_canSetFunctions.t.sol new file mode 100644 index 000000000..2b17cdd08 --- /dev/null +++ b/src/test/drop/drop-erc20/_canSetFunctions/_canSetFunctions.t.sol @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { DropERC20 } from "contracts/prebuilts/drop/DropERC20.sol"; +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +// Test imports +import "../../../utils/BaseTest.sol"; + +contract HarnessDropERC20CanSet is DropERC20 { + function canSetPlatformFeeInfo() external view returns (bool) { + return _canSetPlatformFeeInfo(); + } + + function canSetPrimarySaleRecipient() external view returns (bool) { + return _canSetPrimarySaleRecipient(); + } + + function canSetContractURI() external view returns (bool) { + return _canSetContractURI(); + } + + function canSetClaimConditions() external view returns (bool) { + return _canSetClaimConditions(); + } +} + +contract DropERC20Test_canSet is BaseTest { + address public dropImp; + + HarnessDropERC20CanSet public proxy; + + function setUp() public override { + super.setUp(); + + bytes memory initializeData = abi.encodeCall( + DropERC20.initialize, + (deployer, NAME, SYMBOL, CONTRACT_URI, forwarders(), saleRecipient, platformFeeRecipient, platformFeeBps) + ); + + dropImp = address(new HarnessDropERC20CanSet()); + proxy = HarnessDropERC20CanSet(address(new TWProxy(dropImp, initializeData))); + } + + modifier callerHasDefaultAdminRole() { + vm.startPrank(deployer); + _; + } + + modifier callerDoesNotHaveDefaultAdminRole() { + _; + } + + function test_canSetPlatformFee_returnTrue() public callerHasDefaultAdminRole { + bool status = proxy.canSetPlatformFeeInfo(); + assertEq(status, true); + } + + function test_canSetPlatformFee_returnFalse() public callerDoesNotHaveDefaultAdminRole { + bool status = proxy.canSetPlatformFeeInfo(); + assertEq(status, false); + } + + function test_canSetPrimarySaleRecipient_returnTrue() public callerHasDefaultAdminRole { + bool status = proxy.canSetPrimarySaleRecipient(); + assertEq(status, true); + } + + function test_canSetPrimarySaleRecipient_returnFalse() public callerDoesNotHaveDefaultAdminRole { + bool status = proxy.canSetPrimarySaleRecipient(); + assertEq(status, false); + } + + function test_canSetContractURI_returnTrue() public callerHasDefaultAdminRole { + bool status = proxy.canSetContractURI(); + assertEq(status, true); + } + + function test_canSetContractURI_returnFalse() public callerDoesNotHaveDefaultAdminRole { + bool status = proxy.canSetContractURI(); + assertEq(status, false); + } + + function test_canSetClaimConditions_returnTrue() public callerHasDefaultAdminRole { + bool status = proxy.canSetClaimConditions(); + assertEq(status, true); + } + + function test_canSetClaimConditions_returnFalse() public callerDoesNotHaveDefaultAdminRole { + bool status = proxy.canSetClaimConditions(); + assertEq(status, false); + } +} diff --git a/src/test/drop/drop-erc20/_canSetFunctions/_canSetFunctions.tree b/src/test/drop/drop-erc20/_canSetFunctions/_canSetFunctions.tree new file mode 100644 index 000000000..2f2da72e4 --- /dev/null +++ b/src/test/drop/drop-erc20/_canSetFunctions/_canSetFunctions.tree @@ -0,0 +1,23 @@ +function _canSetPlatformFeeInfo() +├── when caller has DEFAULT_ADMIN_ROLE +│ └── it should return true ✅ +└── when caller does not have DEFAULT_ADMIN_ROLE + └── it should return false ✅ + +function _canSetPrimarySaleRecipient() +├── when caller has DEFAULT_ADMIN_ROLE +│ └── it should return true ✅ +└── when caller does not have DEFAULT_ADMIN_ROLE + └── it should return false ✅ + +function _canSetContractURI() +├── when caller has DEFAULT_ADMIN_ROLE +│ └── it should return true ✅ +└── when caller does not have DEFAULT_ADMIN_ROLE + └── it should return false ✅ + +function _canSetClaimConditions() +├── when caller has DEFAULT_ADMIN_ROLE +│ └── it should return true ✅ +└── when caller does not have DEFAULT_ADMIN_ROLE + └── it should return false ✅ diff --git a/src/test/drop/drop-erc20/_collectPriceOnClaim/_collectPriceOnClaim.t.sol b/src/test/drop/drop-erc20/_collectPriceOnClaim/_collectPriceOnClaim.t.sol new file mode 100644 index 000000000..b78b3eed9 --- /dev/null +++ b/src/test/drop/drop-erc20/_collectPriceOnClaim/_collectPriceOnClaim.t.sol @@ -0,0 +1,209 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { DropERC20 } from "contracts/prebuilts/drop/DropERC20.sol"; +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +// Test imports +import "../../../utils/BaseTest.sol"; + +contract HarnessDropERC20CollectPriceOnClaim is DropERC20 { + function harness_collectPrice( + address _primarySaleRecipient, + uint256 _quantityToClaim, + address _currency, + uint256 _pricePerToken + ) public payable { + _collectPriceOnClaim(_primarySaleRecipient, _quantityToClaim, _currency, _pricePerToken); + } +} + +contract DropERC20Test_collectPrice is BaseTest { + address public dropImp; + HarnessDropERC20CollectPriceOnClaim public proxy; + + address private currency; + address private primarySaleRecipient; + uint256 private msgValue; + uint256 private pricePerToken; + + function setUp() public override { + super.setUp(); + + bytes memory initializeData = abi.encodeCall( + DropERC20.initialize, + (deployer, NAME, SYMBOL, CONTRACT_URI, forwarders(), saleRecipient, platformFeeRecipient, platformFeeBps) + ); + + dropImp = address(new HarnessDropERC20CollectPriceOnClaim()); + proxy = HarnessDropERC20CollectPriceOnClaim(address(new TWProxy(dropImp, initializeData))); + } + + modifier pricePerTokenZero() { + _; + } + + modifier pricePerTokenNotZero() { + pricePerToken = 1 ether; + _; + } + + modifier msgValueZero() { + _; + } + + modifier msgValueNotZero() { + msgValue = 1 ether; + _; + } + + modifier valuePriceMismatch() { + msgValue = 1 ether; + pricePerToken = 2 ether; + _; + } + + modifier primarySaleRecipientZeroAddress() { + primarySaleRecipient = address(0); + _; + } + + modifier primarySaleRecipientNotZeroAddress() { + primarySaleRecipient = address(0x0999); + _; + } + + modifier currencyNativeToken() { + currency = NATIVE_TOKEN; + _; + } + + modifier currencyNotNativeToken() { + currency = address(erc20); + _; + } + + function test_revert_pricePerTokenZeroMsgValueNotZero() public pricePerTokenZero msgValueNotZero { + vm.expectRevert("!Value"); + proxy.harness_collectPrice{ value: msgValue }(primarySaleRecipient, 1 ether, currency, pricePerToken); + } + + function test_revert_nativeCurrencyTotalPriceZero() public pricePerTokenNotZero msgValueZero currencyNativeToken { + vm.expectRevert("quantity too low"); + proxy.harness_collectPrice{ value: msgValue }(primarySaleRecipient, 0, currency, pricePerToken); + } + + function test_revert_nativeCurrencyValuePriceMismatch() public currencyNativeToken valuePriceMismatch { + vm.expectRevert("Invalid msg value"); + proxy.harness_collectPrice{ value: msgValue }(primarySaleRecipient, 1 ether, currency, pricePerToken); + } + + function test_revert_erc20ValuePriceMismatch() public currencyNotNativeToken valuePriceMismatch { + vm.expectRevert("Invalid msg value"); + proxy.harness_collectPrice{ value: msgValue }(primarySaleRecipient, 1 ether, currency, pricePerToken); + } + + function test_state_nativeCurrency() + public + currencyNativeToken + pricePerTokenNotZero + msgValueNotZero + primarySaleRecipientNotZeroAddress + { + (address platformFeeRecipient, uint16 platformFeeBps) = proxy.getPlatformFeeInfo(); + uint256 beforeBalancePrimarySaleRecipient = address(primarySaleRecipient).balance; + uint256 beforeBalancePlatformFeeRecipient = address(platformFeeRecipient).balance; + + proxy.harness_collectPrice{ value: msgValue }(primarySaleRecipient, 1 ether, currency, pricePerToken); + + uint256 afterBalancePrimarySaleRecipient = address(primarySaleRecipient).balance; + uint256 afterBalancePlatformFeeRecipient = address(platformFeeRecipient).balance; + + uint256 platformFeeVal = (msgValue * platformFeeBps) / MAX_BPS; + uint256 primarySaleRecipientVal = msgValue - platformFeeVal; + + assertEq(beforeBalancePrimarySaleRecipient + primarySaleRecipientVal, afterBalancePrimarySaleRecipient); + assertEq(beforeBalancePlatformFeeRecipient + platformFeeVal, afterBalancePlatformFeeRecipient); + } + + function test_revert_erc20_msgValueNotZero() + public + currencyNotNativeToken + msgValueNotZero + primarySaleRecipientNotZeroAddress + { + vm.expectRevert("!Value"); + proxy.harness_collectPrice{ value: msgValue }(primarySaleRecipient, msgValue, currency, pricePerToken); + } + + function test_state_erc20() public currencyNotNativeToken pricePerTokenNotZero primarySaleRecipientNotZeroAddress { + (address platformFeeRecipient, uint16 platformFeeBps) = proxy.getPlatformFeeInfo(); + + erc20.mint(address(this), pricePerToken); + ERC20(erc20).approve(address(proxy), pricePerToken); + uint256 beforeBalancePrimarySaleRecipient = erc20.balanceOf(primarySaleRecipient); + uint256 beforeBalancePlatformFeeRecipient = erc20.balanceOf(platformFeeRecipient); + + proxy.harness_collectPrice(primarySaleRecipient, pricePerToken, currency, pricePerToken); + + uint256 afterBalancePrimarySaleRecipient = erc20.balanceOf(primarySaleRecipient); + uint256 afterBalancePlatformFeeRecipient = erc20.balanceOf(platformFeeRecipient); + + uint256 platformFeeVal = (pricePerToken * platformFeeBps) / MAX_BPS; + uint256 primarySaleRecipientVal = 1 ether - platformFeeVal; + + assertEq(beforeBalancePrimarySaleRecipient + primarySaleRecipientVal, afterBalancePrimarySaleRecipient); + assertEq(beforeBalancePlatformFeeRecipient + platformFeeVal, afterBalancePlatformFeeRecipient); + } + + function test_state_erc20StoredPrimarySaleRecipient() + public + currencyNotNativeToken + pricePerTokenNotZero + primarySaleRecipientZeroAddress + { + (address platformFeeRecipient, uint16 platformFeeBps) = proxy.getPlatformFeeInfo(); + address storedPrimarySaleRecipient = proxy.primarySaleRecipient(); + + erc20.mint(address(this), pricePerToken); + ERC20(erc20).approve(address(proxy), pricePerToken); + uint256 beforeBalancePrimarySaleRecipient = erc20.balanceOf(storedPrimarySaleRecipient); + uint256 beforeBalancePlatformFeeRecipient = erc20.balanceOf(platformFeeRecipient); + + proxy.harness_collectPrice(primarySaleRecipient, pricePerToken, currency, pricePerToken); + + uint256 afterBalancePrimarySaleRecipient = erc20.balanceOf(storedPrimarySaleRecipient); + uint256 afterBalancePlatformFeeRecipient = erc20.balanceOf(platformFeeRecipient); + + uint256 platformFeeVal = (pricePerToken * platformFeeBps) / MAX_BPS; + uint256 primarySaleRecipientVal = 1 ether - platformFeeVal; + + assertEq(beforeBalancePrimarySaleRecipient + primarySaleRecipientVal, afterBalancePrimarySaleRecipient); + assertEq(beforeBalancePlatformFeeRecipient + platformFeeVal, afterBalancePlatformFeeRecipient); + } + + function test_state_nativeCurrencyStoredPrimarySaleRecipient() + public + currencyNativeToken + pricePerTokenNotZero + primarySaleRecipientZeroAddress + msgValueNotZero + { + (address platformFeeRecipient, uint16 platformFeeBps) = proxy.getPlatformFeeInfo(); + address storedPrimarySaleRecipient = proxy.primarySaleRecipient(); + + uint256 beforeBalancePrimarySaleRecipient = address(storedPrimarySaleRecipient).balance; + uint256 beforeBalancePlatformFeeRecipient = address(platformFeeRecipient).balance; + + proxy.harness_collectPrice{ value: msgValue }(primarySaleRecipient, 1 ether, currency, pricePerToken); + + uint256 afterBalancePrimarySaleRecipient = address(storedPrimarySaleRecipient).balance; + uint256 afterBalancePlatformFeeRecipient = address(platformFeeRecipient).balance; + + uint256 platformFeeVal = (msgValue * platformFeeBps) / MAX_BPS; + uint256 primarySaleRecipientVal = msgValue - platformFeeVal; + + assertEq(beforeBalancePrimarySaleRecipient + primarySaleRecipientVal, afterBalancePrimarySaleRecipient); + assertEq(beforeBalancePlatformFeeRecipient + platformFeeVal, afterBalancePlatformFeeRecipient); + } +} diff --git a/src/test/drop/drop-erc20/_collectPriceOnClaim/_collectPriceOnClaim.tree b/src/test/drop/drop-erc20/_collectPriceOnClaim/_collectPriceOnClaim.tree new file mode 100644 index 000000000..933ae6877 --- /dev/null +++ b/src/test/drop/drop-erc20/_collectPriceOnClaim/_collectPriceOnClaim.tree @@ -0,0 +1,44 @@ +function _collectPriceOnClaim( + address _primarySaleRecipient, + uint256 _quantityToClaim, + address _currency, + uint256 _pricePerToken +) +├── when _pricePerToken is equal to zero +│ ├── when msg.value does not equal to zero +│ │ └── it should revert ✅ +│ └── when msg.value is equal to zero +│ └── it should return ✅ +└── when _pricePerToken is not equal to zero + ├── when _primarySaleRecipient is equal to address(0) + │ ├── when totalPrice is equal to zero + │ │ └── it should revert ✅ + │ └── when total price is not equal to zero + │ ├── when currency is native token + │ │ ├── when msg.value does not equal totalPrice + │ │ │ └── it should revert ✅ + │ │ └── when msg.value does equal totalPrice + │ │ ├── platformFees (totalPrice * platformFeeBps / MAX_BPS) should be transfered to platformFeeRecipient ✅ + │ │ └── totalPrice - platformFees should be transfered to primarySaleRecipient() ✅ + │ └── when currency is not native token + │ ├── when msg.value is not equal to zero + │ │ └── it should revert ✅ + │ └── when msg.value is equal to zero + │ ├── platformFees (totalPrice * platformFeeBps / MAX_BPS) should be transfered to platformFeeRecipient ✅ + │ └── totalPrice - platformFees should be transfered to primarySaleRecipient() ✅ + └── when _primarySaleRecipient is not equal to address(0) + ├── when totalPrice is equal to zero + │ └── it should revert ✅ + └── when total price is not equal to zero + ├── when currency is not native token + │ ├── when msg.value does not equal totalPrice + │ │ └── it should revert ✅ + │ └── when msg.value does equal totalPrice + │ ├── platformFees (totalPrice * platformFeeBps / MAX_BPS) should be transfered to platformFeeRecipient ✅ + │ └── totalPrice - platformFees should be transfered to _primarySaleRecipient ✅ + └── when currency is not native token + ├── when msg.value is not equal to zero + │ └── it should revert ✅ + └── when msg.value is equal to zero + ├── platformFees (totalPrice * platformFeeBps / MAX_BPS) should be transfered to platformFeeRecipient ✅ + └── totalPrice - platformFees should be transfered to _primarySaleRecipient ✅ \ No newline at end of file diff --git a/src/test/drop/drop-erc20/initialize/initialize.t.sol b/src/test/drop/drop-erc20/initialize/initialize.t.sol new file mode 100644 index 000000000..61debd129 --- /dev/null +++ b/src/test/drop/drop-erc20/initialize/initialize.t.sol @@ -0,0 +1,235 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { DropERC20, PlatformFee } from "contracts/prebuilts/drop/DropERC20.sol"; + +// Test imports +import "../../../utils/BaseTest.sol"; + +contract DropERC20Test_initializer is BaseTest { + DropERC20 public newDropContract; + + event ContractURIUpdated(string prevURI, string newURI); + event RoleGranted(bytes32 indexed role, address indexed account, address indexed sender); + event PlatformFeeInfoUpdated(address indexed platformFeeRecipient, uint256 platformFeeBps); + event PrimarySaleRecipientUpdated(address indexed recipient); + + function setUp() public override { + super.setUp(); + } + + modifier platformFeeBPSTooHigh() { + platformFeeBps = 10001; + _; + } + + function test_state() public { + deployContractProxy( + "DropERC20", + abi.encodeCall( + DropERC20.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + platformFeeRecipient, + platformFeeBps + ) + ) + ); + + newDropContract = DropERC20(getContract("DropERC20")); + (address _platformFeeRecipient, uint128 _platformFeeBps) = newDropContract.getPlatformFeeInfo(); + address _saleRecipient = newDropContract.primarySaleRecipient(); + + for (uint256 i = 0; i < forwarders().length; i++) { + assertEq(newDropContract.isTrustedForwarder(forwarders()[i]), true); + } + + assertEq(newDropContract.name(), NAME); + assertEq(newDropContract.symbol(), SYMBOL); + assertEq(newDropContract.contractURI(), CONTRACT_URI); + assertEq(_platformFeeRecipient, platformFeeRecipient); + assertEq(_platformFeeBps, platformFeeBps); + assertEq(_saleRecipient, saleRecipient); + } + + function test_revert_PlatformFeeBPSTooHigh() public platformFeeBPSTooHigh { + vm.expectRevert( + abi.encodeWithSelector(PlatformFee.PlatformFeeExceededMaxFeeBps.selector, 10_000, platformFeeBps) + ); + deployContractProxy( + "DropERC20", + abi.encodeCall( + DropERC20.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + platformFeeRecipient, + platformFeeBps + ) + ) + ); + } + + function test_event_ContractURIUpdated() public { + vm.expectEmit(false, false, false, true); + emit ContractURIUpdated("", CONTRACT_URI); + deployContractProxy( + "DropERC20", + abi.encodeCall( + DropERC20.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + platformFeeRecipient, + platformFeeBps + ) + ) + ); + } + + function test_event_RoleGrantedDefaultAdminRole() public { + bytes32 role = bytes32(0x00); + vm.expectEmit(true, true, true, false); + emit RoleGranted(role, deployer, factory); + deployContractProxy( + "DropERC20", + abi.encodeCall( + DropERC20.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + platformFeeRecipient, + platformFeeBps + ) + ) + ); + } + + function test_event_RoleGrantedTransferRole() public { + bytes32 role = keccak256("TRANSFER_ROLE"); + vm.expectEmit(true, true, true, false); + emit RoleGranted(role, deployer, factory); + deployContractProxy( + "DropERC20", + abi.encodeCall( + DropERC20.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + platformFeeRecipient, + platformFeeBps + ) + ) + ); + } + + function test_event_RoleGrantedTransferRoleZeroAddress() public { + bytes32 role = keccak256("TRANSFER_ROLE"); + vm.expectEmit(true, true, true, false); + emit RoleGranted(role, address(0), factory); + deployContractProxy( + "DropERC20", + abi.encodeCall( + DropERC20.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + platformFeeRecipient, + platformFeeBps + ) + ) + ); + } + + function test_event_PlatformFeeInfoUpdated() public { + vm.expectEmit(true, false, false, true); + emit PlatformFeeInfoUpdated(platformFeeRecipient, platformFeeBps); + deployContractProxy( + "DropERC20", + abi.encodeCall( + DropERC20.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + platformFeeRecipient, + platformFeeBps + ) + ) + ); + } + + function test_event_PrimarySaleRecipientUpdated() public { + vm.expectEmit(true, false, false, false); + emit PrimarySaleRecipientUpdated(saleRecipient); + deployContractProxy( + "DropERC20", + abi.encodeCall( + DropERC20.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + platformFeeRecipient, + platformFeeBps + ) + ) + ); + } + + function test_roleCheck() public { + deployContractProxy( + "DropERC20", + abi.encodeCall( + DropERC20.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + platformFeeRecipient, + platformFeeBps + ) + ) + ); + + newDropContract = DropERC20(getContract("DropERC20")); + + assertEq(newDropContract.hasRole(bytes32(0x00), deployer), true); + assertEq(newDropContract.hasRole(keccak256("TRANSFER_ROLE"), deployer), true); + assertEq(newDropContract.hasRole(keccak256("TRANSFER_ROLE"), address(0)), true); + } +} diff --git a/src/test/drop/drop-erc20/initialize/intialize.tree b/src/test/drop/drop-erc20/initialize/intialize.tree new file mode 100644 index 000000000..6731bc81e --- /dev/null +++ b/src/test/drop/drop-erc20/initialize/intialize.tree @@ -0,0 +1,33 @@ +function initialize( + address _defaultAdmin, + string memory _name, + string memory _symbol, + string memory _contractURI, + address[] memory _trustedForwarders, + address _saleRecipient, + address _platformFeeRecipient, + uint128 _platformFeeBps +) +├── when _trustedForwarders.length > 0 +│ └── it should set _trustedForwarder[_trustedForwarders[i]] as true for each address in _trustedForwarders ✅ +├── it should set contractURI as _contractURI ✅ +├── it should emit ContractURIUpdated with the parameters: prevURI, _uri ✅ +├── it should set _defaultAdmin as the owner of the contract ✅ +├── it should emit OwnerUpdated with the parameters: _prevOwner, _defaultAdmin ✅ +├── it should assign the role DEFAULT_ADMIN_ROLE to _defaultAdmin ✅ +├── it should emit RoleGranted with the parameters: DEFAULT_ADMIN_ROLE, _defaultAdmin, msg.sender ✅ +├── it should assign the role _transferRole to _defaultAdmin ✅ +├── it should emit RoleGranted with the parameters: _transferRole, _defaultAdmin, msg.sender ✅ +├── it should assign the role _transferRole to address(0) ✅ +├── it should emit RoleGranted with the parameters: _transferRole, address(0), msg.sender ✅ +├── when _platformFeeBps is greater than 10_000 +│ └── it should revert ✅ +├── when _platformFeeBps is less than or equal to 10_000 +│ ├── it should set platformFeeBps to uint16(_platformFeeBps); ✅ +│ ├── it should set platformFeeRecipient to _platformFeeRecipient ✅ +│ └── it should emit PlatformFeeInfoUpdated with the following parameters: _platformFeeRecipient, _platformFeeBps ✅ +├── it should set recipient as _primarySaleRecipient ✅ +├── it should emit PrimarySaleRecipientUpdated with the parameters _primarySaleRecipient ✅ +├── it should set transferRole as keccak256("TRANSFER_ROLE")✅ +├── it should set _name as _name ✅ +└── it should set _symbol as _symbol ✅ \ No newline at end of file diff --git a/src/test/drop/drop-erc20/miscellaneous/miscellaneous.t.sol b/src/test/drop/drop-erc20/miscellaneous/miscellaneous.t.sol new file mode 100644 index 000000000..0845465ff --- /dev/null +++ b/src/test/drop/drop-erc20/miscellaneous/miscellaneous.t.sol @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { DropERC20 } from "contracts/prebuilts/drop/DropERC20.sol"; +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +// Test imports +import "../../../utils/BaseTest.sol"; + +contract HarnessDropERC20Misc is DropERC20 { + bytes32 private transferRole = keccak256("TRANSFER_ROLE"); + + function msgData() public view returns (bytes memory) { + return _msgData(); + } + + function transferTokensOnClaim(address _to, uint256 _quantityBeingClaimed) public returns (uint256) { + return _transferTokensOnClaim(_to, _quantityBeingClaimed); + } + + function beforeTokenTransfer(address from, address to, uint256 amount) public { + _beforeTokenTransfer(from, to, amount); + } + + function mint(address to, uint256 amount) public { + _mint(to, amount); + } + + function burn(address from, uint256 amount) public { + _burn(from, amount); + } + + function hasTransferRole(address _account) public view returns (bool) { + return hasRole(transferRole, _account); + } +} + +contract DropERC20Test_misc is BaseTest { + address public dropImp; + HarnessDropERC20Misc public proxy; + + function setUp() public override { + super.setUp(); + + bytes memory initializeData = abi.encodeCall( + DropERC20.initialize, + (deployer, NAME, SYMBOL, CONTRACT_URI, forwarders(), saleRecipient, platformFeeRecipient, platformFeeBps) + ); + + dropImp = address(new HarnessDropERC20Misc()); + proxy = HarnessDropERC20Misc(address(new TWProxy(dropImp, initializeData))); + } + + function test_contractType_returnValue() public { + assertEq(proxy.contractType(), "DropERC20"); + } + + function test_contractVersion_returnValue() public { + assertEq(proxy.contractVersion(), uint8(4)); + } + + function test_msgData_returnValue() public { + bytes memory msgData = proxy.msgData(); + bytes4 expectedData = proxy.msgData.selector; + assertEq(bytes4(msgData), expectedData); + } + + function test_state_transferTokensOnClaim() public { + uint256 initialBalance = proxy.balanceOf(deployer); + uint256 quantityBeingClaimed = 1; + proxy.transferTokensOnClaim(deployer, quantityBeingClaimed); + assertEq(proxy.balanceOf(deployer), initialBalance + quantityBeingClaimed); + } + + function test_returnValue_transferTokensOnClaim() public { + uint256 quantityBeingClaimed = 1; + uint256 returnValue = proxy.transferTokensOnClaim(deployer, quantityBeingClaimed); + assertEq(returnValue, 0); + } + + function test_beforeTokenTransfer_revert_addressZeroNoTransferRole() public { + vm.prank(deployer); + proxy.revokeRole(keccak256("TRANSFER_ROLE"), address(0)); + vm.expectRevert("transfers restricted."); + proxy.beforeTokenTransfer(address(0x01), address(0x02), 1); + } + + function test_beforeTokenTransfer_doesNotRevert_addressZeroNoTransferRole_burnMint() public { + vm.prank(deployer); + proxy.revokeRole(keccak256("TRANSFER_ROLE"), address(0)); + proxy.beforeTokenTransfer(address(0), address(0x02), 1); + proxy.beforeTokenTransfer(address(0x01), address(0), 1); + } + + function test_state_mint() public { + uint256 initialBalance = proxy.balanceOf(deployer); + uint256 amount = 1; + proxy.mint(deployer, amount); + assertEq(proxy.balanceOf(deployer), initialBalance + amount); + } + + function test_state_burn() public { + proxy.mint(deployer, 1); + uint256 initialBalance = proxy.balanceOf(deployer); + uint256 amount = 1; + proxy.burn(deployer, amount); + assertEq(proxy.balanceOf(deployer), initialBalance - amount); + } + + function test_transfer_drop() public { + //deal erc20 drop to address(0x1) + deal(address(proxy), address(0x1), 1); + vm.prank(address(0x1)); + proxy.transfer(address(0x2), 1); + assertEq(proxy.balanceOf(address(0x2)), 1); + } +} diff --git a/src/test/drop/drop-erc20/miscellaneous/miscellaneous.tree b/src/test/drop/drop-erc20/miscellaneous/miscellaneous.tree new file mode 100644 index 000000000..61c35271e --- /dev/null +++ b/src/test/drop/drop-erc20/miscellaneous/miscellaneous.tree @@ -0,0 +1,30 @@ +function contractType() +└── it should return bytes32("DropERC20") ✅ + +function contractVersion() +└── it should return uint8(4) ✅ + +function _mint(address account, uint256 amount) +└── it should mint amount tokens to account ✅ + +function _burn(address account, uint256 amount) +└── it should burn amount tokens from account ✅ + +function _afterTokenTransfer( + address from, + address to, + uint256 amount +) +└── it should call _afterTokenTransfer logic from ERC20VotesUpgradeable + +function _msgData() +└── it should return msg.data ✅ + +function _beforeTokenTransfer(address from, address to, uint256 amount) +└── when address(0) does not have transferRole and from does not equal address(0) and from does not equal address(0) + └── when from does not have transfer role and to does not have transferRole + └── it should revert ✅ + +function _transferTokensOnClaim(address _to, uint256 _quantityBeingClaimed) +├── it should mint _quantityBeingClaimed tokens to _to ✅ +└── it should return 0 ✅ \ No newline at end of file diff --git a/src/test/drop/drop-erc20/setMaxTotalSupply/setMaxTotalSupply.t.sol b/src/test/drop/drop-erc20/setMaxTotalSupply/setMaxTotalSupply.t.sol new file mode 100644 index 000000000..8349f808c --- /dev/null +++ b/src/test/drop/drop-erc20/setMaxTotalSupply/setMaxTotalSupply.t.sol @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { DropERC20 } from "contracts/prebuilts/drop/DropERC20.sol"; + +// Test imports +import "../../../utils/BaseTest.sol"; + +contract DropERC20Test_setMaxTotalSupply is BaseTest { + event MaxTotalSupplyUpdated(uint256 maxTotalSupply); + + DropERC20 public drop; + + function setUp() public override { + super.setUp(); + + drop = DropERC20(getContract("DropERC20")); + } + + modifier callerHasDefaultAdminRole() { + vm.startPrank(deployer); + _; + } + + modifier callerDoesNotHaveDefaultAdminRole() { + _; + } + + function test_revert_doesNotHaveAdminRole() public callerDoesNotHaveDefaultAdminRole { + bytes32 role = bytes32(0x00); + + vm.expectRevert( + abi.encodeWithSelector(Permissions.PermissionsUnauthorizedAccount.selector, address(this), role) + ); + drop.setMaxTotalSupply(0); + } + + function test_state_callerHasDefaultAdminRole() public callerHasDefaultAdminRole { + drop.setMaxTotalSupply(100); + assertEq(drop.maxTotalSupply(), 100); + } + + function test_event_callerHasDefaultAdminRole() public callerHasDefaultAdminRole { + vm.expectEmit(false, false, false, true); + emit MaxTotalSupplyUpdated(100); + drop.setMaxTotalSupply(100); + } +} diff --git a/src/test/drop/drop-erc20/setMaxTotalSupply/setMaxTotalSupply.tree b/src/test/drop/drop-erc20/setMaxTotalSupply/setMaxTotalSupply.tree new file mode 100644 index 000000000..a8b23e9ca --- /dev/null +++ b/src/test/drop/drop-erc20/setMaxTotalSupply/setMaxTotalSupply.tree @@ -0,0 +1,6 @@ +function setMaxTotalSupply(uint256 _maxTotalSupply) +├── when the caller does not have DEFAULT_ADMIN_ROLE +│ └── it should revert ✅ +└── when the caller does have DEFAULT_ADMIN_ROLE + ├── it should set maxTotalSupply as _maxTotalSupply ✅ + └── it should emit MaxTotalSupplyUpdated with the parameters _maxTotalSupply ✅ \ No newline at end of file diff --git a/src/test/drop/drop-erc721/_beforeClaim/_beforeClaim.t.sol b/src/test/drop/drop-erc721/_beforeClaim/_beforeClaim.t.sol new file mode 100644 index 000000000..f30885a51 --- /dev/null +++ b/src/test/drop/drop-erc721/_beforeClaim/_beforeClaim.t.sol @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { DropERC721 } from "contracts/prebuilts/drop/DropERC721.sol"; + +// Test imports +import "../../../utils/BaseTest.sol"; + +contract DropERC721Test_beforeClaim is BaseTest { + event TokenURIRevealed(uint256 indexed index, string revealedURI); + + DropERC721 public drop; + + bytes private beforeClaim_data; + string private beforeClaim_baseURI; + uint256 private beforeClaim_amount; + address private receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); + + DropERC721.AllowlistProof private alp; + + function setUp() public override { + super.setUp(); + drop = DropERC721(getContract("DropERC721")); + + string[] memory inputs = new string[](5); + + inputs[0] = "node"; + inputs[1] = "src/test/scripts/generateRoot.ts"; + inputs[2] = "300"; + inputs[3] = "0"; + inputs[4] = Strings.toHexString(uint160(address(erc20))); + + bytes memory result = vm.ffi(inputs); + // revert(); + bytes32 root = abi.decode(result, (bytes32)); + + inputs[1] = "src/test/scripts/getProof.ts"; + result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + alp.proof = proofs; + alp.quantityLimitPerWallet = 300; + alp.pricePerToken = 0; + alp.currency = address(erc20); + + vm.warp(1); + + DropERC721.ClaimCondition[] memory conditions = new DropERC721.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 500; + conditions[0].quantityLimitPerWallet = 10; + conditions[0].merkleRoot = root; + conditions[0].pricePerToken = 10; + conditions[0].currency = address(erc20); + + vm.prank(deployer); + drop.setClaimConditions(conditions, false); + } + + /*/////////////////////////////////////////////////////////////// + Branch Testing + //////////////////////////////////////////////////////////////*/ + + modifier lazyMintUnEncrypted() { + beforeClaim_amount = 10; + beforeClaim_baseURI = "ipfs://"; + vm.prank(deployer); + drop.lazyMint(beforeClaim_amount, beforeClaim_baseURI, beforeClaim_data); + _; + } + + modifier setMaxSupply() { + vm.prank(deployer); + drop.setMaxTotalSupply(5); + _; + } + + function test_revert_greaterThanNextTokenIdToLazyMint() public lazyMintUnEncrypted { + vm.prank(receiver, receiver); + vm.expectRevert("!Tokens"); + drop.claim(receiver, 11, address(erc20), 0, alp, ""); + } + + function test_revert_greaterThanMaxTotalSupply() public lazyMintUnEncrypted setMaxSupply { + vm.prank(receiver, receiver); + vm.expectRevert("!Supply"); + drop.claim(receiver, 6, address(erc20), 0, alp, ""); + } +} diff --git a/src/test/drop/drop-erc721/_beforeClaim/_beforeClaim.tree b/src/test/drop/drop-erc721/_beforeClaim/_beforeClaim.tree new file mode 100644 index 000000000..a225a8be4 --- /dev/null +++ b/src/test/drop/drop-erc721/_beforeClaim/_beforeClaim.tree @@ -0,0 +1,12 @@ +function _beforeClaim( + address, + uint256 _quantity, + address, + uint256, + AllowlistProof calldata, + bytes memory +) +├── when _current index + _quantity are greater than nextTokenIdToLazyMint +│ └── it should revert ✅ +└── when maxTotalSupply does not equal zero and _currentIndex + _quantity is greater than maxTotalSupply + └── it should revert ✅ diff --git a/src/test/drop/drop-erc721/_canSetFunctions/_canSetFunctions.t.sol b/src/test/drop/drop-erc721/_canSetFunctions/_canSetFunctions.t.sol new file mode 100644 index 000000000..61d67d687 --- /dev/null +++ b/src/test/drop/drop-erc721/_canSetFunctions/_canSetFunctions.t.sol @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { DropERC721, PlatformFee, PrimarySale, ContractMetadata, Royalty, LazyMint, Drop, Ownable } from "contracts/prebuilts/drop/DropERC721.sol"; + +// Test imports +import "../../../utils/BaseTest.sol"; + +contract DropERC721Test_canSetFunctions is BaseTest { + DropERC721 public drop; + + bytes private canset_data; + string private canset_baseURI; + uint256 private canset_amount; + bytes private canset_encryptedURI; + bytes32 private canset_provenanceHash; + address private unauthorized = address(0x123); + + function setUp() public override { + super.setUp(); + drop = DropERC721(getContract("DropERC721")); + } + + /*/////////////////////////////////////////////////////////////// + Branch Testing + //////////////////////////////////////////////////////////////*/ + + modifier callerNotAdmin() { + vm.startPrank(unauthorized); + _; + } + + modifier callerAdmin() { + vm.startPrank(deployer); + _; + } + + modifier callerNotMinter() { + vm.startPrank(unauthorized); + _; + } + + modifier callerMinter() { + vm.startPrank(deployer); + _; + } + + function test__canSetPlatformFeeInfo_revert_callerNotAdmin() public callerNotAdmin { + vm.expectRevert(abi.encodeWithSelector(PlatformFee.PlatformFeeUnauthorized.selector)); + drop.setPlatformFeeInfo(address(0x1), 1); + } + + function test__canSetPlatformFeeInfo_callerAdmin() public callerAdmin { + drop.setPlatformFeeInfo(address(0x1), 1); + (address recipient, uint16 bps) = drop.getPlatformFeeInfo(); + assertEq(recipient, address(0x1)); + assertEq(bps, 1); + } + + function test__canSetPrimarySaleRecipient_revert_callerNotAdmin() public callerNotAdmin { + vm.expectRevert(abi.encodeWithSelector(PrimarySale.PrimarySaleUnauthorized.selector)); + drop.setPrimarySaleRecipient(address(0x1)); + } + + function test__canSetPrimarySaleRecipient_callerAdmin() public callerAdmin { + drop.setPrimarySaleRecipient(address(0x1)); + assertEq(drop.primarySaleRecipient(), address(0x1)); + } + + function test__canSetOwner_revert_callerNotAdmin() public callerNotAdmin { + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorized.selector)); + drop.setOwner(address(0x1)); + } + + function test__canSetOwner_callerAdmin() public callerAdmin { + drop.setOwner(address(0x1)); + assertEq(drop.owner(), address(0x1)); + } + + function test__canSetRoyaltyInfo_revert_callerNotAdmin() public callerNotAdmin { + vm.expectRevert(abi.encodeWithSelector(Royalty.RoyaltyUnauthorized.selector)); + drop.setDefaultRoyaltyInfo(address(0x1), 1); + } + + function test__canSetRoyaltyInfo_callerAdmin() public callerAdmin { + drop.setDefaultRoyaltyInfo(address(0x1), 1); + (address recipient, uint16 bps) = drop.getDefaultRoyaltyInfo(); + assertEq(recipient, address(0x1)); + assertEq(bps, 1); + } + + function test__canSetContractURI_revert_callerNotAdmin() public callerNotAdmin { + vm.expectRevert(abi.encodeWithSelector(ContractMetadata.ContractMetadataUnauthorized.selector)); + drop.setContractURI("ipfs://"); + } + + function test__canSetContractURI_callerAdmin() public callerAdmin { + drop.setContractURI("ipfs://"); + assertEq(drop.contractURI(), "ipfs://"); + } + + function test__canSetClaimConditions_revert_callerNotAdmin() public callerNotAdmin { + DropERC721.ClaimCondition[] memory conditions = new DropERC721.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 500; + conditions[0].quantityLimitPerWallet = 10; + conditions[0].merkleRoot = bytes32(0); + conditions[0].pricePerToken = 10; + conditions[0].currency = address(0x111); + vm.expectRevert(abi.encodeWithSelector(Drop.DropUnauthorized.selector)); + drop.setClaimConditions(conditions, true); + } + + function test__canSetClaimConditions_callerAdmin() public callerAdmin { + DropERC721.ClaimCondition[] memory conditions = new DropERC721.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 500; + conditions[0].quantityLimitPerWallet = 10; + conditions[0].merkleRoot = bytes32(0); + conditions[0].pricePerToken = 10; + conditions[0].currency = address(0x111); + drop.setClaimConditions(conditions, true); + } + + function test__canLazyMint_revert_callerNotMinter() public callerNotMinter { + canset_amount = 10; + canset_baseURI = "ipfs://"; + canset_data = abi.encode(canset_encryptedURI, canset_provenanceHash); + vm.expectRevert(abi.encodeWithSelector(LazyMint.LazyMintUnauthorized.selector)); + drop.lazyMint(canset_amount, canset_baseURI, canset_data); + } + + function test__canLazyMint_callerMinter() public callerMinter { + canset_amount = 10; + canset_baseURI = "ipfs://"; + canset_data = abi.encode(canset_encryptedURI, canset_provenanceHash); + drop.lazyMint(canset_amount, canset_baseURI, canset_data); + } +} diff --git a/src/test/drop/drop-erc721/_canSetFunctions/_canSetFunctions.tree b/src/test/drop/drop-erc721/_canSetFunctions/_canSetFunctions.tree new file mode 100644 index 000000000..72bfbe39a --- /dev/null +++ b/src/test/drop/drop-erc721/_canSetFunctions/_canSetFunctions.tree @@ -0,0 +1,41 @@ +function _canSetPlatformFeeInfo() +├── when caller has DEFAULT_ADMIN_ROLE +│ └── it should return true ✅ +└── when caller does not have DEFAULT_ADMIN_ROLE + └── it should return false ✅ + +function _canSetPrimarySaleRecipient() +├── when caller has DEFAULT_ADMIN_ROLE +│ └── it should return true ✅ +└── when caller does not have DEFAULT_ADMIN_ROLE + └── it should return false ✅ + +function _canSetOwner() +├── when caller has DEFAULT_ADMIN_ROLE +│ └── it should return true ✅ +└── when caller does not have DEFAULT_ADMIN_ROLE + └── it should return false ✅ + +function _canSetRoyaltyInfo() +├── when caller has DEFAULT_ADMIN_ROLE +│ └── it should return true ✅ +└── when caller does not have DEFAULT_ADMIN_ROLE + └── it should return false ✅ + +function _canSetContractURI() +├── when caller has DEFAULT_ADMIN_ROLE +│ └── it should return true ✅ +└── when caller does not have DEFAULT_ADMIN_ROLE + └── it should return false ✅ + +function _canSetClaimConditions() +├── when caller has DEFAULT_ADMIN_ROLE +│ └── it should return true ✅ +└── when caller does not have DEFAULT_ADMIN_ROLE + └── it should return false ✅ + +function _canLazyMint() +├── when caller has minterRole +│ └── it should return true ✅ +└── when caller does not have minterRole + └── it should return false ✅ \ No newline at end of file diff --git a/src/test/drop/drop-erc721/_collectPriceOnClaim/_collectPriceOnClaim.t.sol b/src/test/drop/drop-erc721/_collectPriceOnClaim/_collectPriceOnClaim.t.sol new file mode 100644 index 000000000..cee3d2798 --- /dev/null +++ b/src/test/drop/drop-erc721/_collectPriceOnClaim/_collectPriceOnClaim.t.sol @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { DropERC721 } from "contracts/prebuilts/drop/DropERC721.sol"; +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +// Test imports +import "../../../utils/BaseTest.sol"; + +contract HarnessDropERC721 is DropERC721 { + function collectionPriceOnClaim( + address _primarySaleRecipient, + uint256 _quantityToClaim, + address _currency, + uint256 _pricePerToken + ) public payable { + _collectPriceOnClaim(_primarySaleRecipient, _quantityToClaim, _currency, _pricePerToken); + } +} + +contract DropERC721Test_collectPrice is BaseTest { + address public dropImp; + HarnessDropERC721 public proxy; + + address private collectPrice_saleRecipient = address(0x010); + uint256 private collectPrice_quantityToClaim = 1; + uint256 private collectPrice_pricePerToken; + address private collectPrice_currency; + uint256 private collectPrice_msgValue; + + function setUp() public override { + super.setUp(); + + bytes memory initializeData = abi.encodeCall( + DropERC721.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ); + + dropImp = address(new HarnessDropERC721()); + proxy = HarnessDropERC721(address(new TWProxy(dropImp, initializeData))); + } + + modifier pricePerTokenZero() { + collectPrice_pricePerToken = 0; + _; + } + + modifier pricePerTokenNotZero() { + collectPrice_pricePerToken = 1 ether; + _; + } + + modifier msgValueNotZero() { + collectPrice_msgValue = 1 ether; + _; + } + + modifier nativeCurrency() { + collectPrice_currency = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + _; + } + + modifier erc20Currency() { + collectPrice_currency = address(erc20); + erc20.mint(address(this), 1_000 ether); + _; + } + + /*/////////////////////////////////////////////////////////////// + Branch Testing + //////////////////////////////////////////////////////////////*/ + + function test_revert_msgValueNotZero() public nativeCurrency msgValueNotZero pricePerTokenZero { + vm.expectRevert(); + proxy.collectionPriceOnClaim{ value: collectPrice_msgValue }( + collectPrice_saleRecipient, + collectPrice_quantityToClaim, + collectPrice_currency, + collectPrice_pricePerToken + ); + } + + function test_revert_priceValueMismatchNativeCurrency() public nativeCurrency pricePerTokenNotZero { + vm.expectRevert(); + proxy.collectionPriceOnClaim{ value: collectPrice_msgValue }( + collectPrice_saleRecipient, + collectPrice_quantityToClaim, + collectPrice_currency, + collectPrice_pricePerToken + ); + } + + function test_transferNativeCurrency() public nativeCurrency pricePerTokenNotZero msgValueNotZero { + uint256 balanceSaleRecipientBefore = address(saleRecipient).balance; + uint256 platformFeeRecipientBefore = address(platformFeeRecipient).balance; + proxy.collectionPriceOnClaim{ value: collectPrice_msgValue }( + saleRecipient, + collectPrice_quantityToClaim, + collectPrice_currency, + collectPrice_pricePerToken + ); + + uint256 balanceSaleRecipientAfter = address(saleRecipient).balance; + uint256 platformFeeRecipientAfter = address(platformFeeRecipient).balance; + uint256 expectedPlatformFee = (collectPrice_pricePerToken * platformFeeBps) / MAX_BPS; + uint256 expectedSaleRecipientProceed = collectPrice_msgValue - expectedPlatformFee; + + assertEq(balanceSaleRecipientAfter - balanceSaleRecipientBefore, expectedSaleRecipientProceed); + assertEq(platformFeeRecipientAfter - platformFeeRecipientBefore, expectedPlatformFee); + } + + function test_transferERC20() public erc20Currency pricePerTokenNotZero { + uint256 balanceSaleRecipientBefore = erc20.balanceOf(saleRecipient); + uint256 platformFeeRecipientBefore = erc20.balanceOf(platformFeeRecipient); + erc20.approve(address(proxy), collectPrice_pricePerToken); + proxy.collectionPriceOnClaim( + saleRecipient, + collectPrice_quantityToClaim, + collectPrice_currency, + collectPrice_pricePerToken + ); + + uint256 balanceSaleRecipientAfter = erc20.balanceOf(saleRecipient); + uint256 platformFeeRecipientAfter = erc20.balanceOf(platformFeeRecipient); + uint256 expectedPlatformFee = (collectPrice_pricePerToken * platformFeeBps) / MAX_BPS; + uint256 expectedSaleRecipientProceed = collectPrice_pricePerToken - expectedPlatformFee; + + assertEq(balanceSaleRecipientAfter - balanceSaleRecipientBefore, expectedSaleRecipientProceed); + assertEq(platformFeeRecipientAfter - platformFeeRecipientBefore, expectedPlatformFee); + } +} diff --git a/src/test/drop/drop-erc721/_collectPriceOnClaim/_collectPriceOnClaim.tree b/src/test/drop/drop-erc721/_collectPriceOnClaim/_collectPriceOnClaim.tree new file mode 100644 index 000000000..b962e7d4e --- /dev/null +++ b/src/test/drop/drop-erc721/_collectPriceOnClaim/_collectPriceOnClaim.tree @@ -0,0 +1,20 @@ +function _collectPriceOnClaim( + address _primarySaleRecipient, + uint256 _quantityToClaim, + address _currency, + uint256 _pricePerToken +) +├── when _pricePerToken is equal to zero +│ └── when msg.value does not equal to zero +│ └── it should revert ✅ +└── when _pricePerToken is not equal to zero + └── when _primarySaleRecipient is equal to address(0) + ├── when _currency is native token + │ ├── when msg.value does not equal to totalPrice + │ │ └── it should revert ✅ + │ └── when msg.value does equal to totalPrice + │ ├── it should transfer platformFees to platformFeeRecipient in native token ✅ + │ └── it should transfer totalPrice - platformFees to saleRecipient in native token ✅ + └── when _currency is not native token + ├── it should transfer platformFees to platformFeeRecipient in _currency token ✅ + └── it should transfer totalPrice - platformFees to saleRecipient in _currency token ✅ \ No newline at end of file diff --git a/src/test/drop/drop-erc721/_transferTokensOnClaim/_tranferTokensOnClaim.tree b/src/test/drop/drop-erc721/_transferTokensOnClaim/_tranferTokensOnClaim.tree new file mode 100644 index 000000000..6bf501586 --- /dev/null +++ b/src/test/drop/drop-erc721/_transferTokensOnClaim/_tranferTokensOnClaim.tree @@ -0,0 +1,2 @@ +function _transferTokensOnClaim(address _to, uint256 _quantityBeingClaimed) +└── it should mint `_quantityBeingClaimed` number of tokens to `to` ✅ \ No newline at end of file diff --git a/src/test/drop/drop-erc721/_transferTokensOnClaim/_transferTokensOnClaim.t.sol b/src/test/drop/drop-erc721/_transferTokensOnClaim/_transferTokensOnClaim.t.sol new file mode 100644 index 000000000..87e98d606 --- /dev/null +++ b/src/test/drop/drop-erc721/_transferTokensOnClaim/_transferTokensOnClaim.t.sol @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { DropERC721 } from "contracts/prebuilts/drop/DropERC721.sol"; +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +// Test imports +import "erc721a-upgradeable/contracts/IERC721AUpgradeable.sol"; +import "../../../utils/BaseTest.sol"; + +contract HarnessDropERC721 is DropERC721 { + function transferTokensOnClaim(address _to, uint256 _quantityToClaim) public payable { + _transferTokensOnClaim(_to, _quantityToClaim); + } +} + +contract DropERC721Test_transferTokensOnClaim is BaseTest { + address public dropImp; + HarnessDropERC721 public proxy; + + address private transferTokens_receiver; + + ERC20 private nonReceiver; + + function setUp() public override { + super.setUp(); + + bytes memory initializeData = abi.encodeCall( + DropERC721.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ); + + dropImp = address(new HarnessDropERC721()); + proxy = HarnessDropERC721(address(new TWProxy(dropImp, initializeData))); + + nonReceiver = new ERC20("", ""); + } + + modifier transferToEOA() { + transferTokens_receiver = address(0x111); + _; + } + + modifier transferToNonReceiver() { + transferTokens_receiver = address(nonReceiver); + _; + } + + /*/////////////////////////////////////////////////////////////// + Branch Testing + //////////////////////////////////////////////////////////////*/ + + function test_revert_transferToNonReceiver() public transferToNonReceiver { + vm.expectRevert(IERC721AUpgradeable.TransferToNonERC721ReceiverImplementer.selector); + proxy.transferTokensOnClaim(transferTokens_receiver, 1); + } + + function test_transferToEOA() public transferToEOA { + uint256 eoaBalanceBefore = proxy.balanceOf(transferTokens_receiver); + uint256 supplyBefore = proxy.totalSupply(); + proxy.transferTokensOnClaim(transferTokens_receiver, 1); + assertEq(proxy.totalSupply(), supplyBefore + 1); + assertEq(proxy.balanceOf(transferTokens_receiver), eoaBalanceBefore + 1); + } +} diff --git a/src/test/drop/drop-erc721/freezeBatchBaseURI/freezeBatchBaseURI.t.sol b/src/test/drop/drop-erc721/freezeBatchBaseURI/freezeBatchBaseURI.t.sol new file mode 100644 index 000000000..9cb5c23c2 --- /dev/null +++ b/src/test/drop/drop-erc721/freezeBatchBaseURI/freezeBatchBaseURI.t.sol @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { DropERC721, BatchMintMetadata } from "contracts/prebuilts/drop/DropERC721.sol"; + +// Test imports +import "../../../utils/BaseTest.sol"; + +contract DropERC721Test_freezeBatchBaseURI is BaseTest { + event MetadataFrozen(); + + DropERC721 public drop; + + bytes private freeze_data; + string private freeze_baseURI; + uint256 private freeze_amount; + bytes private freeze_encryptedURI; + bytes32 private freeze_provenanceHash; + string private freeze_revealedURI; + bytes private freeze_key; + address private unauthorized = address(0x123); + + function setUp() public override { + super.setUp(); + drop = DropERC721(getContract("DropERC721")); + } + + /*/////////////////////////////////////////////////////////////// + Branch Testing + //////////////////////////////////////////////////////////////*/ + + modifier callerWithoutMetadataRole() { + vm.startPrank(unauthorized); + _; + } + + modifier callerWithMetadataRole() { + vm.startPrank(deployer); + _; + } + + modifier lazyMintEncrypted() { + freeze_amount = 10; + freeze_baseURI = "ipfs://"; + freeze_revealedURI = "ipfs://revealed"; + freeze_key = "key"; + freeze_encryptedURI = drop.encryptDecrypt(bytes(freeze_revealedURI), freeze_key); + freeze_provenanceHash = keccak256(abi.encodePacked(freeze_revealedURI, freeze_key, block.chainid)); + freeze_data = abi.encode(freeze_encryptedURI, freeze_provenanceHash); + vm.prank(deployer); + drop.lazyMint(freeze_amount, freeze_baseURI, freeze_data); + _; + } + + modifier lazyMintUnEncryptedEmptyBaseURI() { + freeze_amount = 10; + freeze_baseURI = ""; + vm.prank(deployer); + drop.lazyMint(freeze_amount, freeze_baseURI, freeze_data); + _; + } + + modifier lazyMintUnEncryptedRegularBaseURI() { + freeze_amount = 10; + freeze_baseURI = "ipfs://"; + vm.prank(deployer); + drop.lazyMint(freeze_amount, freeze_baseURI, freeze_data); + _; + } + + function test_revert_NoMetadataRole() public callerWithoutMetadataRole { + bytes32 role = keccak256("METADATA_ROLE"); + vm.expectRevert( + abi.encodeWithSelector(Permissions.PermissionsUnauthorizedAccount.selector, unauthorized, role) + ); + drop.freezeBatchBaseURI(0); + } + + function test_revert_EncryptedBatch() public lazyMintEncrypted callerWithMetadataRole { + vm.expectRevert("Encrypted batch"); + drop.freezeBatchBaseURI(0); + } + + function test_revert_EmptyBaseURI() public lazyMintUnEncryptedEmptyBaseURI callerWithMetadataRole { + vm.expectRevert( + abi.encodeWithSelector(BatchMintMetadata.BatchMintInvalidBatchId.selector, drop.getBatchIdAtIndex(0)) + ); + drop.freezeBatchBaseURI(0); + } + + function test_state() public lazyMintUnEncryptedRegularBaseURI callerWithMetadataRole { + uint256 batchId = drop.getBatchIdAtIndex(0); + drop.freezeBatchBaseURI(0); + assertEq(drop.batchFrozen(batchId), true); + } + + function test_event() public lazyMintUnEncryptedRegularBaseURI callerWithMetadataRole { + vm.expectEmit(false, false, false, false); + emit MetadataFrozen(); + drop.freezeBatchBaseURI(0); + } +} diff --git a/src/test/drop/drop-erc721/freezeBatchBaseURI/freezeBatchBaseURI.tree b/src/test/drop/drop-erc721/freezeBatchBaseURI/freezeBatchBaseURI.tree new file mode 100644 index 000000000..9c29a9a83 --- /dev/null +++ b/src/test/drop/drop-erc721/freezeBatchBaseURI/freezeBatchBaseURI.tree @@ -0,0 +1,12 @@ +function freezeBatchBaseURI(uint256 _index) +├── when called by a user without the METADATA_ROLE +│ └── it should revert ✅ +└── when called by a user with the METADATA_ROLE + ├── when the batchId for the provided _index is an encrypted batch + │ └── it should revert ✅ + └── when the batchId for the provided _index is not an encrypted batch + ├── when the baseURI for the batchId is empty + │ └── it should revert ✅ + └── when the baseURI for the batchId is not empty + ├── it should set batchFrozen[batchId] as true ✅ + └── it should emit MetadataFrozen ✅ \ No newline at end of file diff --git a/src/test/drop/drop-erc721/initalizer/initializer.t.sol b/src/test/drop/drop-erc721/initalizer/initializer.t.sol new file mode 100644 index 000000000..5ef6ba8f1 --- /dev/null +++ b/src/test/drop/drop-erc721/initalizer/initializer.t.sol @@ -0,0 +1,407 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { DropERC721, PlatformFee, Royalty } from "contracts/prebuilts/drop/DropERC721.sol"; + +// Test imports +import "../../../utils/BaseTest.sol"; + +contract DropERC721Test_initializer is BaseTest { + DropERC721 public newDropContract; + + event ContractURIUpdated(string prevURI, string newURI); + event OwnerUpdated(address indexed prevOwner, address indexed newOwner); + event RoleGranted(bytes32 indexed role, address indexed account, address indexed sender); + event RoleAdminChanged(bytes32 indexed role, bytes32 indexed previousAdminRole, bytes32 indexed newAdminRole); + event PlatformFeeInfoUpdated(address indexed platformFeeRecipient, uint256 platformFeeBps); + event DefaultRoyalty(address indexed newRoyaltyRecipient, uint256 newRoyaltyBps); + event PrimarySaleRecipientUpdated(address indexed recipient); + + function setUp() public override { + super.setUp(); + } + + modifier royaltyBPSTooHigh() { + uint128 royaltyBps = 10001; + _; + } + + modifier platformFeeBPSTooHigh() { + uint128 platformFeeBps = 10001; + _; + } + + function test_state() public { + deployContractProxy( + "DropERC721", + abi.encodeCall( + DropERC721.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ); + + newDropContract = DropERC721(getContract("DropERC721")); + (address _platformFeeRecipient, uint128 _platformFeeBps) = newDropContract.getPlatformFeeInfo(); + (address _royaltyRecipient, uint128 _royaltyBps) = newDropContract.getDefaultRoyaltyInfo(); + address _saleRecipient = newDropContract.primarySaleRecipient(); + + for (uint256 i = 0; i < forwarders().length; i++) { + assertEq(newDropContract.isTrustedForwarder(forwarders()[i]), true); + } + + assertEq(newDropContract.name(), NAME); + assertEq(newDropContract.symbol(), SYMBOL); + assertEq(newDropContract.contractURI(), CONTRACT_URI); + assertEq(newDropContract.owner(), deployer); + assertEq(_platformFeeRecipient, platformFeeRecipient); + assertEq(_platformFeeBps, platformFeeBps); + assertEq(_royaltyRecipient, royaltyRecipient); + assertEq(_royaltyBps, royaltyBps); + assertEq(_saleRecipient, saleRecipient); + } + + function test_revert_RoyaltyBPSTooHigh() public royaltyBPSTooHigh { + vm.expectRevert(abi.encodeWithSelector(Royalty.RoyaltyExceededMaxFeeBps.selector, 10_000, 10_001)); + deployContractProxy( + "DropERC721", + abi.encodeCall( + DropERC721.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + 10001, + platformFeeBps, + platformFeeRecipient + ) + ) + ); + } + + function test_revert_PlatformFeeBPSTooHigh() public platformFeeBPSTooHigh { + vm.expectRevert(abi.encodeWithSelector(PlatformFee.PlatformFeeExceededMaxFeeBps.selector, 10_000, 10_001)); + deployContractProxy( + "DropERC721", + abi.encodeCall( + DropERC721.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + 10001, + platformFeeRecipient + ) + ) + ); + } + + function test_event_ContractURIUpdated() public { + vm.expectEmit(false, false, false, true); + emit ContractURIUpdated("", CONTRACT_URI); + deployContractProxy( + "DropERC721", + abi.encodeCall( + DropERC721.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ); + } + + function test_event_OwnerUpdated() public { + vm.expectEmit(true, true, false, false); + emit OwnerUpdated(address(0), deployer); + deployContractProxy( + "DropERC721", + abi.encodeCall( + DropERC721.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ); + } + + function test_event_RoleGrantedDefaultAdminRole() public { + bytes32 role = bytes32(0x00); + vm.expectEmit(true, true, true, false); + emit RoleGranted(role, deployer, factory); + deployContractProxy( + "DropERC721", + abi.encodeCall( + DropERC721.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ); + } + + function test_event_RoleGrantedMinterRole() public { + bytes32 role = keccak256("MINTER_ROLE"); + vm.expectEmit(true, true, true, false); + emit RoleGranted(role, deployer, factory); + deployContractProxy( + "DropERC721", + abi.encodeCall( + DropERC721.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ); + } + + function test_event_RoleGrantedTransferRole() public { + bytes32 role = keccak256("TRANSFER_ROLE"); + vm.expectEmit(true, true, true, false); + emit RoleGranted(role, deployer, factory); + deployContractProxy( + "DropERC721", + abi.encodeCall( + DropERC721.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ); + } + + function test_event_RoleGrantedTransferRoleZeroAddress() public { + bytes32 role = keccak256("TRANSFER_ROLE"); + vm.expectEmit(true, true, true, false); + emit RoleGranted(role, address(0), factory); + deployContractProxy( + "DropERC721", + abi.encodeCall( + DropERC721.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ); + } + + function test_event_RoleGrantedMetadataRole() public { + bytes32 role = keccak256("METADATA_ROLE"); + vm.expectEmit(true, true, true, false); + emit RoleGranted(role, deployer, factory); + deployContractProxy( + "DropERC721", + abi.encodeCall( + DropERC721.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ); + } + + function test_event_RoleAdminChangedMetadataRole() public { + bytes32 role = keccak256("METADATA_ROLE"); + vm.expectEmit(true, true, true, false); + emit RoleAdminChanged(role, bytes32(0x00), role); + deployContractProxy( + "DropERC721", + abi.encodeCall( + DropERC721.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ); + } + + function test_event_PlatformFeeInfoUpdated() public { + vm.expectEmit(true, false, false, true); + emit PlatformFeeInfoUpdated(platformFeeRecipient, platformFeeBps); + deployContractProxy( + "DropERC721", + abi.encodeCall( + DropERC721.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ); + } + + function test_event_DefaultRoyalty() public { + vm.expectEmit(true, false, false, true); + emit DefaultRoyalty(royaltyRecipient, royaltyBps); + deployContractProxy( + "DropERC721", + abi.encodeCall( + DropERC721.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ); + } + + function test_event_PrimarySaleRecipientUpdated() public { + vm.expectEmit(true, false, false, false); + emit PrimarySaleRecipientUpdated(saleRecipient); + deployContractProxy( + "DropERC721", + abi.encodeCall( + DropERC721.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ); + } + + function test_roleCheck() public { + deployContractProxy( + "DropERC721", + abi.encodeCall( + DropERC721.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ); + + newDropContract = DropERC721(getContract("DropERC721")); + + assertEq(newDropContract.hasRole(bytes32(0x00), deployer), true); + assertEq(newDropContract.hasRole(keccak256("MINTER_ROLE"), deployer), true); + assertEq(newDropContract.hasRole(keccak256("TRANSFER_ROLE"), deployer), true); + assertEq(newDropContract.hasRole(keccak256("TRANSFER_ROLE"), address(0)), true); + assertEq(newDropContract.hasRole(keccak256("METADATA_ROLE"), deployer), true); + + assertEq(newDropContract.getRoleAdmin(keccak256("METADATA_ROLE")), keccak256("METADATA_ROLE")); + } +} diff --git a/src/test/drop/drop-erc721/initalizer/initializer.tree b/src/test/drop/drop-erc721/initalizer/initializer.tree new file mode 100644 index 000000000..cec2fc97d --- /dev/null +++ b/src/test/drop/drop-erc721/initalizer/initializer.tree @@ -0,0 +1,53 @@ +function initialize( + address _defaultAdmin, + string memory _name, + string memory _symbol, + string memory _contractURI, + address[] memory _trustedForwarders, + address _saleRecipient, + address _royaltyRecipient, + uint128 _royaltyBps, + uint128 _platformFeeBps, + address _platformFeeRecipient +) +├── when _trustedForwarders.length > 0 +│ └── it should set _trustedForwarder[_trustedForwarders[i]] as true for each address in _trustedForwarders ✅ +├── it should set _name as the value provided in _name ✅ +├── it should set _symbol as the value provided in _symbol ✅ +├── it should set _currentIndex as 0 +├── it should set contractURI as _contractURI ✅ +├── it should emit ContractURIUpdated with the parameters: prevURI, _uri ✅ +├── it should set _defaultAdmin as the owner of the contract ✅ +├── it should emit OwnerUpdated with the parameters: _prevOwner, _defaultAdmin ✅ +├── it should assign the role DEFAULT_ADMIN_ROLE to _defaultAdmin ✅ +├── it should emit RoleGranted with the parameters: DEFAULT_ADMIN_ROLE, _defaultAdmin, msg.sender ✅ +├── it should assign the role _minterRole to _defaultAdmin ✅ +├── it should emit RoleGranted with the parameters: _minterRole, _defaultAdmin, msg.sender ✅ +├── it should assign the role _transferRole to _defaultAdmin ✅ +├── it should emit RoleGranted with the parameters: _transferRole, _defaultAdmin, msg.sender ✅ +├── it should assign the role _transferRole to address(0) ✅ +├── it should emit RoleGranted with the parameters: _transferRole, address(0), msg.sender ✅ +├── it should assign the role _metadataRole to _defaultAdmin ✅ +├── it should emit RoleGranted with the parameters: _metadataRole, _defaultAdmin, msg.sender ✅ +├── it should set _getAdminRole[_metadataRole] to equal _metadataRole ✅ +├── it should emit RoleAdminChanged with the parameters _metadataRole, previousAdminRole, _metadataRole ✅ +├── when _platformFeeBps is greater than 10_000 +│ └── it should revert ✅ +├── when _platformFeeBps is less than or equal to 10_000 +│ ├── it should set platformFeeBps to uint16(_platformFeeBps) ✅ +│ ├── it should set platformFeeRecipient to _platformFeeRecipient ✅ +│ └── it should emit PlatformFeeInfoUpdated with the following parameters: _platformFeeRecipient, _platformFeeBps ✅ +├── when _royaltyBps is greater than 10_000 +│ └── it should revert ✅ +├── when _royaltyBps is less than or equal to 10_000 +│ ├── it should set royaltyRecipient as _royaltyRecipient ✅ +│ ├── it should set royaltyBps as uint16(_royaltyBps) ✅ +│ └── it should emit DefaultRoyalty with the parameters _royaltyRecipient, _royaltyBps ✅ +├── it should set recipient as _primarySaleRecipient ✅ +├── it should emit PrimarySaleRecipientUpdated with the parameters _primarySaleRecipient ✅ +├── it should set transferRole as keccak256("TRANSFER_ROLE") +├── it should set minterRole as keccak256("MINTER_ROLE") +└── it should set metadataRole as keccak256("METADATA_ROLE") + + + diff --git a/src/test/drop/drop-erc721/lazyMint/lazyMint.t.sol b/src/test/drop/drop-erc721/lazyMint/lazyMint.t.sol new file mode 100644 index 000000000..921976be7 --- /dev/null +++ b/src/test/drop/drop-erc721/lazyMint/lazyMint.t.sol @@ -0,0 +1,219 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { DropERC721, LazyMint } from "contracts/prebuilts/drop/DropERC721.sol"; + +// Test imports +import "../../../utils/BaseTest.sol"; + +contract DropERC721Test_lazyMint is BaseTest { + event TokensLazyMinted(uint256 indexed startTokenId, uint256 endTokenId, string baseURI, bytes encryptedBaseURI); + + DropERC721 public drop; + + bytes private lazymint_data; + uint256 private lazyMint_amount; + bytes private lazyMint_encryptedURI; + bytes32 private lazyMint_provenanceHash; + string private lazyMint_revealedURI = "test"; + + function setUp() public override { + super.setUp(); + drop = DropERC721(getContract("DropERC721")); + } + + /*/////////////////////////////////////////////////////////////// + Branch Testing + //////////////////////////////////////////////////////////////*/ + + modifier callerWithoutMinterRole() { + vm.startPrank(address(0x123)); + _; + } + + modifier callerWithMinterRole() { + vm.startPrank(deployer); + _; + } + + modifier amountEqualZero() { + lazyMint_amount = 0; + _; + } + + modifier amountNotEqualZero() { + lazyMint_amount = 1; + _; + } + + modifier dataLengthZero() { + lazymint_data = abi.encode(""); + _; + } + + modifier dataInvalidFormat() { + lazyMint_provenanceHash = bytes32("provenanceHash"); + lazymint_data = abi.encode(lazyMint_provenanceHash); + console.log(lazymint_data.length); + _; + } + + modifier dataValidFormat() { + lazyMint_provenanceHash = bytes32("provenanceHash"); + lazyMint_encryptedURI = "encryptedURI"; + lazymint_data = abi.encode(lazyMint_encryptedURI, lazyMint_provenanceHash); + console.log(lazymint_data.length); + _; + } + + modifier dataValidFormatNoURI() { + lazyMint_provenanceHash = bytes32("provenanceHash"); + lazyMint_encryptedURI = ""; + lazymint_data = abi.encode(lazyMint_encryptedURI, lazyMint_provenanceHash); + console.log(lazymint_data.length); + _; + } + + modifier dataValidFormatNoHash() { + lazyMint_provenanceHash = bytes32(""); + lazyMint_encryptedURI = "encryptedURI"; + lazymint_data = abi.encode(lazyMint_encryptedURI, lazyMint_provenanceHash); + console.log(lazymint_data.length); + _; + } + + function test_revert_NoMinterRole() public callerWithoutMinterRole dataLengthZero { + vm.expectRevert(abi.encodeWithSelector(LazyMint.LazyMintUnauthorized.selector)); + drop.lazyMint(lazyMint_amount, lazyMint_revealedURI, lazymint_data); + } + + function test_revert_AmountEqualZero() public callerWithMinterRole dataLengthZero amountEqualZero { + vm.expectRevert(abi.encodeWithSelector(LazyMint.LazyMintInvalidAmount.selector)); + drop.lazyMint(lazyMint_amount, lazyMint_revealedURI, lazymint_data); + } + + function test_revert_DataInvalidFormat() public callerWithMinterRole amountNotEqualZero dataInvalidFormat { + vm.expectRevert(); + drop.lazyMint(lazyMint_amount, lazyMint_revealedURI, lazymint_data); + } + + function test_state_dataLengthZero() public callerWithMinterRole amountNotEqualZero dataLengthZero { + uint256 nextTokenIdToLazyMintBefore = drop.nextTokenIdToMint(); + uint256 expectedBatchId = nextTokenIdToLazyMintBefore + lazyMint_amount; + + uint256 batchIdReturn = drop.lazyMint(lazyMint_amount, lazyMint_revealedURI, lazymint_data); + + uint256 batchIdState = drop.getBatchIdAtIndex(0); + string memory baseURIState = drop.tokenURI(0); + + assertEq(nextTokenIdToLazyMintBefore + lazyMint_amount, drop.nextTokenIdToMint()); + assertEq(expectedBatchId, batchIdReturn); + assertEq(expectedBatchId, batchIdState); + assertEq(string(abi.encodePacked(lazyMint_revealedURI, "0")), baseURIState); + } + + function test_event_dataLengthZero() public callerWithMinterRole amountNotEqualZero dataLengthZero { + uint256 nextTokenIdToLazyMintBefore = drop.nextTokenIdToMint(); + + vm.expectEmit(true, false, false, true); + emit TokensLazyMinted( + nextTokenIdToLazyMintBefore, + nextTokenIdToLazyMintBefore + lazyMint_amount - 1, + lazyMint_revealedURI, + lazymint_data + ); + drop.lazyMint(lazyMint_amount, lazyMint_revealedURI, lazymint_data); + } + + function test_state_noEncryptedURI() public callerWithMinterRole amountNotEqualZero dataValidFormatNoURI { + uint256 nextTokenIdToLazyMintBefore = drop.nextTokenIdToMint(); + uint256 expectedBatchId = nextTokenIdToLazyMintBefore + lazyMint_amount; + bytes memory expectedEncryptedData; + + uint256 batchIdReturn = drop.lazyMint(lazyMint_amount, lazyMint_revealedURI, lazymint_data); + + uint256 batchIdState = drop.getBatchIdAtIndex(0); + string memory baseURIState = drop.tokenURI(0); + bytes memory encryptedDataState = drop.encryptedData(0); + + assertEq(nextTokenIdToLazyMintBefore + lazyMint_amount, drop.nextTokenIdToMint()); + assertEq(expectedBatchId, batchIdReturn); + assertEq(expectedBatchId, batchIdState); + assertEq(string(abi.encodePacked(lazyMint_revealedURI, "0")), baseURIState); + assertEq(expectedEncryptedData, encryptedDataState); + } + + function test_event_noEncryptedURI() public callerWithMinterRole amountNotEqualZero dataValidFormatNoURI { + uint256 nextTokenIdToLazyMintBefore = drop.nextTokenIdToMint(); + + vm.expectEmit(true, false, false, true); + emit TokensLazyMinted( + nextTokenIdToLazyMintBefore, + nextTokenIdToLazyMintBefore + lazyMint_amount - 1, + lazyMint_revealedURI, + lazymint_data + ); + drop.lazyMint(lazyMint_amount, lazyMint_revealedURI, lazymint_data); + } + + function test_state_noProvenanceHash() public callerWithMinterRole amountNotEqualZero dataValidFormatNoHash { + uint256 nextTokenIdToLazyMintBefore = drop.nextTokenIdToMint(); + uint256 expectedBatchId = nextTokenIdToLazyMintBefore + lazyMint_amount; + bytes memory expectedEncryptedData; + + uint256 batchIdReturn = drop.lazyMint(lazyMint_amount, lazyMint_revealedURI, lazymint_data); + + uint256 batchIdState = drop.getBatchIdAtIndex(0); + string memory baseURIState = drop.tokenURI(0); + bytes memory encryptedDataState = drop.encryptedData(0); + + assertEq(nextTokenIdToLazyMintBefore + lazyMint_amount, drop.nextTokenIdToMint()); + assertEq(expectedBatchId, batchIdReturn); + assertEq(expectedBatchId, batchIdState); + assertEq(string(abi.encodePacked(lazyMint_revealedURI, "0")), baseURIState); + assertEq(expectedEncryptedData, encryptedDataState); + } + + function test_event_noProvenanceHash() public callerWithMinterRole amountNotEqualZero dataValidFormatNoHash { + uint256 nextTokenIdToLazyMintBefore = drop.nextTokenIdToMint(); + + vm.expectEmit(true, false, false, true); + emit TokensLazyMinted( + nextTokenIdToLazyMintBefore, + nextTokenIdToLazyMintBefore + lazyMint_amount - 1, + lazyMint_revealedURI, + lazymint_data + ); + drop.lazyMint(lazyMint_amount, lazyMint_revealedURI, lazymint_data); + } + + function test_state_encryptedURIAndHash() public callerWithMinterRole amountNotEqualZero dataValidFormat { + uint256 nextTokenIdToLazyMintBefore = drop.nextTokenIdToMint(); + uint256 expectedBatchId = nextTokenIdToLazyMintBefore + lazyMint_amount; + + uint256 batchIdReturn = drop.lazyMint(lazyMint_amount, lazyMint_revealedURI, lazymint_data); + + uint256 batchIdState = drop.getBatchIdAtIndex(0); + string memory baseURIState = drop.tokenURI(0); + bytes memory encryptedDataState = drop.encryptedData(batchIdReturn); + + assertEq(nextTokenIdToLazyMintBefore + lazyMint_amount, drop.nextTokenIdToMint()); + assertEq(expectedBatchId, batchIdReturn); + assertEq(expectedBatchId, batchIdState); + assertEq(string(abi.encodePacked(lazyMint_revealedURI, "0")), baseURIState); + assertEq(lazymint_data, encryptedDataState); + } + + function test_event_encryptedURIAndHash() public callerWithMinterRole amountNotEqualZero dataValidFormat { + uint256 nextTokenIdToLazyMintBefore = drop.nextTokenIdToMint(); + + vm.expectEmit(true, false, false, true); + emit TokensLazyMinted( + nextTokenIdToLazyMintBefore, + nextTokenIdToLazyMintBefore + lazyMint_amount - 1, + lazyMint_revealedURI, + lazymint_data + ); + drop.lazyMint(lazyMint_amount, lazyMint_revealedURI, lazymint_data); + } +} diff --git a/src/test/drop/drop-erc721/lazyMint/lazyMint.tree b/src/test/drop/drop-erc721/lazyMint/lazyMint.tree new file mode 100644 index 000000000..84738d926 --- /dev/null +++ b/src/test/drop/drop-erc721/lazyMint/lazyMint.tree @@ -0,0 +1,33 @@ +function lazyMint( + uint256 _amount, + string calldata _baseURIForTokens, + bytes calldata _data +) +├── when called by a user without MINTER_ROLE +│ └── it should revert ✅ +└── when called by a user with MINTER_ROLE + ├── when _data.length == 0 + │ ├── when _amount is equal to 0 + │ │ └── it should revert ✅ + │ └── when _amount is greater than 0 + │ ├── it should push batchId (_startId + _amountToMint) to the batchIds array ✅ + │ ├── it should set baseURI[batchId] as _baseURIForTokens ✅ + │ └── it should emit TokensLazyMinted with the parameters: startId, startId + amount - 1, _baseURIForTokens, _data ✅ + └── when _data.length > 0 + ├── when _data invalid format + │ └── it should revert ✅ + └── when _data valid format + ├── it should decode _data into bytes memory encryptedURI and bytes32 provenanceHash ✅ + ├── when encryptedURI.length = 0 + │ ├── it should push batchId (_startId + _amountToMint) to the batchIds array ✅ + │ ├── it should set baseURI[batchId] as _baseURIForTokens ✅ + │ └── it should emit TokensLazyMinted with the parameters: startId, startId + amount - 1, _baseURIForTokens, _data ✅ + ├── when provenanceHash = "" + │ ├── it should push batchId (_startId + _amountToMint) to the batchIds array ✅ + │ ├── it should set baseURI[batchId] as _baseURIForTokens ✅ + │ └── it should emit TokensLazyMinted with the parameters: startId, startId + amount - 1, _baseURIForTokens, _data ✅ + └── when encryptedURI.length > 0 and provenanceHash does not equal "" + ├── it should set the encryptedData[batchId] equal to _data ✅ + ├── it should push batchId (_startId + _amountToMint) to the batchIds array ✅ + ├── it should set baseURI[batchId] as _baseURIForTokens ✅ + └── it should emit TokensLazyMinted with the parameters: startId, startId + amount - 1, _baseURIForTokens, _data ✅ \ No newline at end of file diff --git a/src/test/drop/drop-erc721/miscellaneous/miscellaneous.t.sol b/src/test/drop/drop-erc721/miscellaneous/miscellaneous.t.sol new file mode 100644 index 000000000..9ec52d8f8 --- /dev/null +++ b/src/test/drop/drop-erc721/miscellaneous/miscellaneous.t.sol @@ -0,0 +1,195 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { DropERC721 } from "contracts/prebuilts/drop/DropERC721.sol"; + +// Test imports +import "erc721a-upgradeable/contracts/IERC721AUpgradeable.sol"; +import "../../../utils/BaseTest.sol"; +import "../../../../../lib/openzeppelin-contracts-upgradeable/contracts/interfaces/IERC2981Upgradeable.sol"; + +contract DropERC721Test_misc is BaseTest { + DropERC721 public drop; + + bytes private misc_data; + string private misc_baseURI; + uint256 private misc_amount; + bytes private misc_encryptedURI; + bytes32 private misc_provenanceHash; + string private misc_revealedURI; + uint256 private misc_index; + bytes private misc_key; + address private unauthorized = address(0x123); + + function setUp() public override { + super.setUp(); + drop = DropERC721(getContract("DropERC721")); + } + + /*/////////////////////////////////////////////////////////////// + Branch Testing + //////////////////////////////////////////////////////////////*/ + + modifier callerNotApproved() { + vm.startPrank(unauthorized); + _; + } + + modifier callerOwner() { + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); + vm.startPrank(receiver); + _; + } + + modifier callerApproved() { + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); + vm.prank(receiver); + drop.setApprovalForAll(deployer, true); + vm.startPrank(deployer); + _; + } + + modifier validIndex() { + misc_index = 0; + _; + } + + modifier invalidKey() { + misc_key = "invalidKey"; + _; + } + + modifier lazyMintEncrypted() { + misc_amount = 10; + misc_baseURI = "ipfs://"; + misc_revealedURI = "ipfs://revealed"; + misc_key = "key"; + misc_encryptedURI = drop.encryptDecrypt(bytes(misc_revealedURI), misc_key); + misc_provenanceHash = keccak256(abi.encodePacked(misc_revealedURI, misc_key, block.chainid)); + misc_data = abi.encode(misc_encryptedURI, misc_provenanceHash); + vm.prank(deployer); + drop.lazyMint(misc_amount, misc_baseURI, misc_data); + _; + } + + modifier tokenClaimed() { + string[] memory inputs = new string[](5); + + inputs[0] = "node"; + inputs[1] = "src/test/scripts/generateRoot.ts"; + inputs[2] = "300"; + inputs[3] = "0"; + inputs[4] = Strings.toHexString(uint160(address(erc20))); // address of erc20 + + bytes memory result = vm.ffi(inputs); + // revert(); + bytes32 root = abi.decode(result, (bytes32)); + + inputs[1] = "src/test/scripts/getProof.ts"; + result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + DropERC721.AllowlistProof memory alp; + alp.proof = proofs; + alp.quantityLimitPerWallet = 300; + alp.pricePerToken = 0; + alp.currency = address(erc20); + + vm.warp(1); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); // in allowlist + + DropERC721.ClaimCondition[] memory conditions = new DropERC721.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 500; + conditions[0].quantityLimitPerWallet = 10; + conditions[0].merkleRoot = root; + conditions[0].pricePerToken = 10; + conditions[0].currency = address(erc20); + + vm.prank(deployer); + drop.setClaimConditions(conditions, false); + + // vm.prank(getActor(5), getActor(5)); + vm.prank(receiver, receiver); + drop.claim(receiver, 10, address(erc20), 0, alp, ""); // claims for free, because allowlist price is 0 + _; + } + + function test_totalMinted_TenLazyMintedZeroClaim() public lazyMintEncrypted { + uint256 totalMinted = drop.totalMinted(); + assertEq(totalMinted, 0); + } + + function test_totalMinted_TenLazyMintedTenClaim() public lazyMintEncrypted tokenClaimed { + uint256 totalMinted = drop.totalMinted(); + assertEq(totalMinted, 10); + } + + function test_nextTokenIdToMint_ZeroLazyMinted() public { + uint256 nextTokenIdToMint = drop.nextTokenIdToMint(); + assertEq(nextTokenIdToMint, 0); + } + + function test_nextTokenIdToMint_TenLazyMinted() public lazyMintEncrypted { + uint256 nextTokenIdToMint = drop.nextTokenIdToMint(); + assertEq(nextTokenIdToMint, 10); + } + + function test_nextTokenIdToClaim_ZeroClaimed() public { + uint256 nextTokenIdToClaim = drop.nextTokenIdToClaim(); + assertEq(nextTokenIdToClaim, 0); + } + + function test_nextTokenIdToClaim_TenClaimed() public lazyMintEncrypted tokenClaimed { + uint256 nextTokenIdToClaim = drop.nextTokenIdToClaim(); + assertEq(nextTokenIdToClaim, 10); + } + + function test_burn_revert_callerNotApproved() public lazyMintEncrypted tokenClaimed callerNotApproved { + vm.expectRevert(IERC721AUpgradeable.TransferCallerNotOwnerNorApproved.selector); + drop.burn(0); + } + + function test_burn_CallerApproved() public lazyMintEncrypted tokenClaimed callerApproved { + drop.burn(0); + uint256 totalSupply = drop.totalSupply(); + assertEq(totalSupply, 9); + vm.expectRevert(IERC721AUpgradeable.OwnerQueryForNonexistentToken.selector); + drop.ownerOf(0); + } + + function test_burn_revert_callerOwnerOfToken() public lazyMintEncrypted tokenClaimed callerOwner { + drop.burn(0); + uint256 totalSupply = drop.totalSupply(); + assertEq(totalSupply, 9); + vm.expectRevert(IERC721AUpgradeable.OwnerQueryForNonexistentToken.selector); + drop.ownerOf(0); + } + + function test_contractType() public { + assertEq(drop.contractType(), bytes32("DropERC721")); + } + + function test_contractVersion() public { + assertEq(drop.contractVersion(), uint8(4)); + } + + function test_supportsInterface() public { + assertEq(drop.supportsInterface(type(IERC2981Upgradeable).interfaceId), true); + assertEq(drop.supportsInterface(type(IERC721Upgradeable).interfaceId), true); + assertEq(drop.supportsInterface(type(IERC721MetadataUpgradeable).interfaceId), true); + } + + function test__msgData() public { + HarnessDropERC721MsgData msgDataDrop = new HarnessDropERC721MsgData(); + bytes memory msgData = msgDataDrop.msgData(); + bytes4 expectedData = msgDataDrop.msgData.selector; + assertEq(bytes4(msgData), expectedData); + } +} + +contract HarnessDropERC721MsgData is DropERC721 { + function msgData() public view returns (bytes memory) { + return _msgData(); + } +} diff --git a/src/test/drop/drop-erc721/miscellaneous/miscellaneous.tree b/src/test/drop/drop-erc721/miscellaneous/miscellaneous.tree new file mode 100644 index 000000000..0e15c80ad --- /dev/null +++ b/src/test/drop/drop-erc721/miscellaneous/miscellaneous.tree @@ -0,0 +1,23 @@ +function totalMinted() +└── it should return total number of minted tokens ✅ + +function nextTokenIdToMint() +└── it should return the next tokenId that is to be lazy minted ✅ + +function nextTokenIdToClaim() +└── it should return the next tokenId to be minted ✅ + +function burn(uint256 tokenId) +├── when caller is not the owner of tokenId +│ ├── when caller is not an approved operator of the owner of tokenId +│ │ └── it should revert ✅ +│ └── when caller is an approved operator of the owner of tokenId +│ └── it should burn the token ✅ +└── when caller is the owner of tokenId + └── it should burn the token ✅ + +function contractType() +└── it should return "DropERC721" in bytes32 format ✅ + +function contractVersion() +└── it should return 4 in uint8 format ✅ \ No newline at end of file diff --git a/src/test/drop/drop-erc721/reveal/reveal.t.sol b/src/test/drop/drop-erc721/reveal/reveal.t.sol new file mode 100644 index 000000000..998c7ee27 --- /dev/null +++ b/src/test/drop/drop-erc721/reveal/reveal.t.sol @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { DropERC721, BatchMintMetadata, DelayedReveal } from "contracts/prebuilts/drop/DropERC721.sol"; + +// Test imports + +import "../../../utils/BaseTest.sol"; +import "@openzeppelin/contracts-upgradeable/utils/StringsUpgradeable.sol"; + +contract DropERC721Test_reveal is BaseTest { + using Strings for uint256; + + event TokenURIRevealed(uint256 indexed index, string revealedURI); + + DropERC721 public drop; + + bytes private reveal_data; + string private reveal_baseURI; + uint256 private reveal_amount; + bytes private reveal_encryptedURI; + bytes32 private reveal_provenanceHash; + string private reveal_revealedURI; + uint256 private reveal_index; + bytes private reveal_key; + address private unauthorized = address(0x123); + + function setUp() public override { + super.setUp(); + drop = DropERC721(getContract("DropERC721")); + } + + /*/////////////////////////////////////////////////////////////// + Branch Testing + //////////////////////////////////////////////////////////////*/ + + modifier callerWithoutMetadataRole() { + vm.startPrank(unauthorized); + _; + } + + modifier callerWithMetadataRole() { + vm.startPrank(deployer); + _; + } + + modifier validIndex() { + reveal_index = 0; + _; + } + + modifier invalidKey() { + reveal_key = "invalidKey"; + _; + } + + modifier invalidIndex() { + reveal_index = 1; + _; + } + + modifier lazyMintEncrypted() { + reveal_amount = 10; + reveal_baseURI = "ipfs://"; + reveal_revealedURI = "ipfs://revealed"; + reveal_key = "key"; + reveal_encryptedURI = drop.encryptDecrypt(bytes(reveal_revealedURI), reveal_key); + reveal_provenanceHash = keccak256(abi.encodePacked(reveal_revealedURI, reveal_key, block.chainid)); + reveal_data = abi.encode(reveal_encryptedURI, reveal_provenanceHash); + vm.prank(deployer); + drop.lazyMint(reveal_amount, reveal_baseURI, reveal_data); + _; + } + + modifier lazyMintUnEncrypted() { + reveal_amount = 10; + reveal_baseURI = "ipfs://"; + vm.prank(deployer); + drop.lazyMint(reveal_amount, reveal_baseURI, reveal_data); + _; + } + + function test_revert_NoMetadataRole() public callerWithoutMetadataRole { + bytes32 role = keccak256("METADATA_ROLE"); + + vm.expectRevert( + abi.encodeWithSelector(Permissions.PermissionsUnauthorizedAccount.selector, unauthorized, role) + ); + drop.reveal(reveal_index, reveal_key); + } + + function test_state() public validIndex lazyMintEncrypted callerWithMetadataRole { + for (uint256 i = 0; i < reveal_amount; i += 1) { + string memory uri = drop.tokenURI(i); + assertEq(uri, string(abi.encodePacked(reveal_baseURI, "0"))); + } + + string memory revealedURI = drop.reveal(reveal_index, reveal_key); + assertEq(revealedURI, string(reveal_revealedURI)); + + for (uint256 i = 0; i < reveal_amount; i += 1) { + string memory uri = drop.tokenURI(i); + assertEq(uri, string(abi.encodePacked(reveal_revealedURI, i.toString()))); + } + + assertEq(drop.encryptedData(reveal_index), ""); + } + + function test_event() public validIndex lazyMintEncrypted callerWithMetadataRole { + vm.expectEmit(); + emit TokenURIRevealed(reveal_index, reveal_revealedURI); + drop.reveal(reveal_index, reveal_key); + } + + function test_revert_InvalidIndex() public invalidIndex lazyMintEncrypted callerWithMetadataRole { + vm.expectRevert(abi.encodeWithSelector(BatchMintMetadata.BatchMintInvalidBatchId.selector, reveal_index)); + drop.reveal(reveal_index, reveal_key); + } + + function test_revert_InvalidKey() public validIndex lazyMintEncrypted invalidKey callerWithMetadataRole { + string memory incorrectURI = string(drop.encryptDecrypt(reveal_encryptedURI, reveal_key)); + + vm.expectRevert( + abi.encodeWithSelector( + DelayedReveal.DelayedRevealIncorrectResultHash.selector, + reveal_provenanceHash, + keccak256(abi.encodePacked(incorrectURI, reveal_key, block.chainid)) + ) + ); + drop.reveal(reveal_index, reveal_key); + } + + function test_revert_NoEncryptedData() public validIndex lazyMintUnEncrypted callerWithMetadataRole { + vm.expectRevert(abi.encodeWithSelector(DelayedReveal.DelayedRevealNothingToReveal.selector)); + drop.reveal(reveal_index, reveal_key); + } +} diff --git a/src/test/drop/drop-erc721/reveal/reveal.tree b/src/test/drop/drop-erc721/reveal/reveal.tree new file mode 100644 index 000000000..5e1e6717d --- /dev/null +++ b/src/test/drop/drop-erc721/reveal/reveal.tree @@ -0,0 +1,16 @@ +function reveal(uint256 _index, bytes calldata _key) +├── when called by a user without the METADATA_ROLE +│ └── it should revert ✅ +└── when called by a user with the METADATA_ROLE + ├── when called with an invalid index + │ └── it should revert ✅ + └── when called with a valid index + ├── when called on a batch with no encryptedData + │ └── it should revert ✅ + └── when called a batch with encryptedData + ├── when called with an invalid key + │ └── it should revert ✅ + └── when called with a valid key + ├── it should set encryptedData[(batchId of _index)] as a blank string ("") ✅ + ├── it should set the baseURI[(batchId of _index)] as the revealed uri ✅ + └── it should emit TokenURIRevealed with the following parameters: _index, revealed uri ✅ \ No newline at end of file diff --git a/src/test/drop/drop-erc721/setMaxTotalSupply/setMaxTotalSupply.t.sol b/src/test/drop/drop-erc721/setMaxTotalSupply/setMaxTotalSupply.t.sol new file mode 100644 index 000000000..592fa0fac --- /dev/null +++ b/src/test/drop/drop-erc721/setMaxTotalSupply/setMaxTotalSupply.t.sol @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { DropERC721 } from "contracts/prebuilts/drop/DropERC721.sol"; + +// Test imports + +import "../../../utils/BaseTest.sol"; + +contract DropERC721Test_setMaxTotalSupply is BaseTest { + event MaxTotalSupplyUpdated(uint256 maxTotalSupply); + + DropERC721 public drop; + + address private unauthorized = address(0x123); + + function setUp() public override { + super.setUp(); + drop = DropERC721(getContract("DropERC721")); + } + + /*/////////////////////////////////////////////////////////////// + Branch Testing + //////////////////////////////////////////////////////////////*/ + + modifier callerNotAdmin() { + vm.startPrank(unauthorized); + _; + } + + modifier callerAdmin() { + vm.startPrank(deployer); + _; + } + + function test_revert_CallerNotAdmin() public callerNotAdmin { + bytes32 role = bytes32(0x00); + vm.expectRevert( + abi.encodeWithSelector(Permissions.PermissionsUnauthorizedAccount.selector, unauthorized, role) + ); + drop.setMaxTotalSupply(0); + } + + function test_state() public callerAdmin { + drop.setMaxTotalSupply(0); + assertEq(drop.maxTotalSupply(), 0); + } + + function test_event() public callerAdmin { + vm.expectEmit(false, false, false, false); + emit MaxTotalSupplyUpdated(0); + drop.setMaxTotalSupply(0); + } +} diff --git a/src/test/drop/drop-erc721/setMaxTotalSupply/setMaxTotalSupply.tree b/src/test/drop/drop-erc721/setMaxTotalSupply/setMaxTotalSupply.tree new file mode 100644 index 000000000..19631c92f --- /dev/null +++ b/src/test/drop/drop-erc721/setMaxTotalSupply/setMaxTotalSupply.tree @@ -0,0 +1,6 @@ +function setMaxTotalSupply(uint256 _maxTotalSupply) +├── when called by a user without the DEFAULT_ADMIN_ROLE +│ └── it should revert ✅ +└── when called by a user with the DEFAULT_ADMIN_ROLE + ├── it should set maxTotalSupply to _maxTotalSupply ✅ + └── it should emit MaxTotalSupplyUpdated with the following parameters: _maxTotalSupply ✅ \ No newline at end of file diff --git a/src/test/drop/drop-erc721/updateBatchBaseURI/updateBatchBaseURI.t.sol b/src/test/drop/drop-erc721/updateBatchBaseURI/updateBatchBaseURI.t.sol new file mode 100644 index 000000000..901d7a7b1 --- /dev/null +++ b/src/test/drop/drop-erc721/updateBatchBaseURI/updateBatchBaseURI.t.sol @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { DropERC721, BatchMintMetadata, Permissions } from "contracts/prebuilts/drop/DropERC721.sol"; + +// Test imports + +import "../../../utils/BaseTest.sol"; +import "@openzeppelin/contracts-upgradeable/utils/StringsUpgradeable.sol"; + +contract DropERC721Test_updateBatchBaseURI is BaseTest { + using Strings for uint256; + + event BatchMetadataUpdate(uint256 _fromTokenId, uint256 _toTokenId); + + DropERC721 public drop; + + bytes private updateBatch_data; + string private updateBatch_baseURI; + string private updateBatch_newBaseURI; + uint256 private updateBatch_amount; + bytes private updateBatch_encryptedURI; + bytes32 private updateBatch_provenanceHash; + string private updateBatch_revealedURI; + bytes private updateBatch_key; + address private unauthorized = address(0x123); + + function setUp() public override { + super.setUp(); + drop = DropERC721(getContract("DropERC721")); + } + + /*/////////////////////////////////////////////////////////////// + Branch Testing + //////////////////////////////////////////////////////////////*/ + + modifier callerWithoutMetadataRole() { + vm.startPrank(unauthorized); + _; + } + + modifier callerWithMetadataRole() { + vm.startPrank(deployer); + _; + } + + modifier lazyMintEncrypted() { + updateBatch_amount = 10; + updateBatch_baseURI = "ipfs://"; + updateBatch_revealedURI = "ipfs://revealed"; + updateBatch_key = "key"; + updateBatch_encryptedURI = drop.encryptDecrypt(bytes(updateBatch_revealedURI), updateBatch_key); + updateBatch_provenanceHash = keccak256( + abi.encodePacked(updateBatch_revealedURI, updateBatch_key, block.chainid) + ); + updateBatch_data = abi.encode(updateBatch_encryptedURI, updateBatch_provenanceHash); + vm.prank(deployer); + drop.lazyMint(updateBatch_amount, updateBatch_baseURI, updateBatch_data); + _; + } + + modifier lazyMintUnEncryptedEmptyBaseURI() { + updateBatch_amount = 10; + updateBatch_baseURI = ""; + vm.prank(deployer); + drop.lazyMint(updateBatch_amount, updateBatch_baseURI, updateBatch_data); + _; + } + + modifier lazyMintUnEncryptedRegularBaseURI() { + updateBatch_amount = 10; + updateBatch_baseURI = "ipfs://"; + vm.prank(deployer); + drop.lazyMint(updateBatch_amount, updateBatch_baseURI, updateBatch_data); + _; + } + + modifier batchFrozen() { + drop.freezeBatchBaseURI(0); + _; + } + + function test_revert_NoMetadataRole() public callerWithoutMetadataRole { + bytes32 role = keccak256("METADATA_ROLE"); + vm.expectRevert( + abi.encodeWithSelector(Permissions.PermissionsUnauthorizedAccount.selector, unauthorized, role) + ); + drop.updateBatchBaseURI(0, updateBatch_newBaseURI); + } + + function test_revert_EncryptedBatch() public lazyMintEncrypted callerWithMetadataRole { + vm.expectRevert("Encrypted batch"); + drop.updateBatchBaseURI(0, updateBatch_newBaseURI); + } + + function test_revert_FrozenBatch() public lazyMintUnEncryptedRegularBaseURI callerWithMetadataRole batchFrozen { + vm.expectRevert( + abi.encodeWithSelector(BatchMintMetadata.BatchMintMetadataFrozen.selector, drop.getBatchIdAtIndex(0)) + ); + drop.updateBatchBaseURI(0, updateBatch_newBaseURI); + } + + function test_state() public lazyMintUnEncryptedRegularBaseURI callerWithMetadataRole { + drop.updateBatchBaseURI(0, updateBatch_newBaseURI); + for (uint256 i = 0; i < updateBatch_amount; i += 1) { + string memory uri = drop.tokenURI(i); + assertEq(uri, string(abi.encodePacked(updateBatch_newBaseURI, i.toString()))); + } + } + + function test_event() public lazyMintUnEncryptedRegularBaseURI callerWithMetadataRole { + vm.expectEmit(false, false, false, false); + emit BatchMetadataUpdate(0, 10); + drop.updateBatchBaseURI(0, updateBatch_newBaseURI); + } +} diff --git a/src/test/drop/drop-erc721/updateBatchBaseURI/updateBatchBaseURI.tree b/src/test/drop/drop-erc721/updateBatchBaseURI/updateBatchBaseURI.tree new file mode 100644 index 000000000..b03e1198f --- /dev/null +++ b/src/test/drop/drop-erc721/updateBatchBaseURI/updateBatchBaseURI.tree @@ -0,0 +1,12 @@ +function updateBatchBaseURI(uint256 _index, string calldata _uri) +├── when called by a user without the METADATA_ROLE +│ └── it should revert ✅ +└── when called by a user with the METADATA_ROLE + ├── when the batchId for the provided _index is an encrypted batch + │ └── it should revert ✅ + └── when the batchId for the provided _index is not an encrypted batch + ├── when the batchId for the provided _index is frozen + │ └── it should revert ✅ + └── when the batchId for the provided _index is not frozen + ├── it should set the baseURI for the batchId as _uri ✅ + └── it should emit BatchMetadataUpdate with the following parameters: starting tokenId of batch, ending tokenId of batch ✅ \ No newline at end of file diff --git a/src/test/marketplace/DirectListings.t.sol b/src/test/marketplace/DirectListings.t.sol new file mode 100644 index 000000000..46ff07402 --- /dev/null +++ b/src/test/marketplace/DirectListings.t.sol @@ -0,0 +1,2145 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +// Test helper imports +import "../utils/BaseTest.sol"; + +// Test contracts and interfaces +import { RoyaltyPaymentsLogic } from "contracts/extension/plugin/RoyaltyPayments.sol"; +import { MarketplaceV3, IPlatformFee } from "contracts/prebuilts/marketplace/entrypoint/MarketplaceV3.sol"; +import { DirectListingsLogic } from "contracts/prebuilts/marketplace/direct-listings/DirectListingsLogic.sol"; +import { TWProxy } from "contracts/infra/TWProxy.sol"; +import { ERC721Base } from "contracts/base/ERC721Base.sol"; +import { MockRoyaltyEngineV1 } from "../mocks/MockRoyaltyEngineV1.sol"; + +import { IDirectListings } from "contracts/prebuilts/marketplace/IMarketplace.sol"; + +import "@thirdweb-dev/dynamic-contracts/src/interface/IExtension.sol"; + +contract MarketplaceDirectListingsTest is BaseTest, IExtension { + // Target contract + address public marketplace; + + // Participants + address public marketplaceDeployer; + address public seller; + address public buyer; + + function setUp() public override { + super.setUp(); + + marketplaceDeployer = getActor(1); + seller = getActor(2); + buyer = getActor(3); + + // Deploy implementation. + Extension[] memory extensions = _setupExtensions(); + address impl = address( + new MarketplaceV3(MarketplaceV3.MarketplaceConstructorParams(extensions, address(0), address(weth))) + ); + + vm.prank(marketplaceDeployer); + marketplace = address( + new TWProxy( + impl, + abi.encodeCall( + MarketplaceV3.initialize, + (marketplaceDeployer, "", new address[](0), marketplaceDeployer, 0) + ) + ) + ); + + // Setup roles for seller and assets + vm.startPrank(marketplaceDeployer); + Permissions(marketplace).revokeRole(keccak256("ASSET_ROLE"), address(0)); + Permissions(marketplace).revokeRole(keccak256("LISTER_ROLE"), address(0)); + Permissions(marketplace).grantRole(keccak256("LISTER_ROLE"), seller); + Permissions(marketplace).grantRole(keccak256("ASSET_ROLE"), address(erc721)); + Permissions(marketplace).grantRole(keccak256("ASSET_ROLE"), address(erc1155)); + + vm.stopPrank(); + + vm.label(impl, "MarketplaceV3_Impl"); + vm.label(marketplace, "Marketplace"); + vm.label(seller, "Seller"); + vm.label(buyer, "Buyer"); + vm.label(address(erc721), "ERC721_Token"); + vm.label(address(erc1155), "ERC1155_Token"); + } + + function _setupExtensions() internal returns (Extension[] memory extensions) { + extensions = new Extension[](1); + + // Deploy `DirectListings` + address directListings = address(new DirectListingsLogic(address(weth))); + vm.label(directListings, "DirectListings_Extension"); + + // Extension: DirectListingsLogic + Extension memory extension_directListings; + extension_directListings.metadata = ExtensionMetadata({ + name: "DirectListingsLogic", + metadataURI: "ipfs://DirectListings", + implementation: directListings + }); + + extension_directListings.functions = new ExtensionFunction[](13); + extension_directListings.functions[0] = ExtensionFunction( + DirectListingsLogic.totalListings.selector, + "totalListings()" + ); + extension_directListings.functions[1] = ExtensionFunction( + DirectListingsLogic.isBuyerApprovedForListing.selector, + "isBuyerApprovedForListing(uint256,address)" + ); + extension_directListings.functions[2] = ExtensionFunction( + DirectListingsLogic.isCurrencyApprovedForListing.selector, + "isCurrencyApprovedForListing(uint256,address)" + ); + extension_directListings.functions[3] = ExtensionFunction( + DirectListingsLogic.currencyPriceForListing.selector, + "currencyPriceForListing(uint256,address)" + ); + extension_directListings.functions[4] = ExtensionFunction( + DirectListingsLogic.createListing.selector, + "createListing((address,uint256,uint256,address,uint256,uint128,uint128,bool))" + ); + extension_directListings.functions[5] = ExtensionFunction( + DirectListingsLogic.updateListing.selector, + "updateListing(uint256,(address,uint256,uint256,address,uint256,uint128,uint128,bool))" + ); + extension_directListings.functions[6] = ExtensionFunction( + DirectListingsLogic.cancelListing.selector, + "cancelListing(uint256)" + ); + extension_directListings.functions[7] = ExtensionFunction( + DirectListingsLogic.approveBuyerForListing.selector, + "approveBuyerForListing(uint256,address,bool)" + ); + extension_directListings.functions[8] = ExtensionFunction( + DirectListingsLogic.approveCurrencyForListing.selector, + "approveCurrencyForListing(uint256,address,uint256)" + ); + extension_directListings.functions[9] = ExtensionFunction( + DirectListingsLogic.buyFromListing.selector, + "buyFromListing(uint256,address,uint256,address,uint256)" + ); + extension_directListings.functions[10] = ExtensionFunction( + DirectListingsLogic.getAllListings.selector, + "getAllListings(uint256,uint256)" + ); + extension_directListings.functions[11] = ExtensionFunction( + DirectListingsLogic.getAllValidListings.selector, + "getAllValidListings(uint256,uint256)" + ); + extension_directListings.functions[12] = ExtensionFunction( + DirectListingsLogic.getListing.selector, + "getListing(uint256)" + ); + + extensions[0] = extension_directListings; + } + + function _setupERC721BalanceForSeller(address _seller, uint256 _numOfTokens) private { + erc721.mint(_seller, _numOfTokens); + } + + function test_state_initial() public { + uint256 totalListings = DirectListingsLogic(marketplace).totalListings(); + assertEq(totalListings, 0); + } + + /*/////////////////////////////////////////////////////////////// + Miscellaneous + //////////////////////////////////////////////////////////////*/ + + function test_getValidListings_burnListedTokens() public { + // Sample listing parameters. + address assetContract = address(erc721); + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = address(erc20); + uint256 pricePerToken = 1 ether; + uint128 startTimestamp = 100; + uint128 endTimestamp = 200; + bool reserved = true; + + // Mint the ERC721 tokens to seller. These tokens will be listed. + _setupERC721BalanceForSeller(seller, 1); + + // Approve Marketplace to transfer token. + vm.prank(seller); + erc721.setApprovalForAll(marketplace, true); + + // List tokens. + IDirectListings.ListingParameters memory listingParams = IDirectListings.ListingParameters( + assetContract, + tokenId, + quantity, + currency, + pricePerToken, + startTimestamp, + endTimestamp, + reserved + ); + + vm.prank(seller); + DirectListingsLogic(marketplace).createListing(listingParams); + + // Total listings incremented + assertEq(DirectListingsLogic(marketplace).totalListings(), 1); + + // burn listed token + vm.prank(seller); + erc721.burn(0); + + vm.warp(150); + // Fetch listing and verify state. + uint256 totalListings = DirectListingsLogic(marketplace).totalListings(); + assertEq(DirectListingsLogic(marketplace).getAllValidListings(0, totalListings - 1).length, 0); + } + + /** + * @dev Tests contract state for Lister role. + */ + function test_state_getRoleMember_listerRole() public { + bytes32 role = keccak256("LISTER_ROLE"); + + uint256 roleMemberCount = PermissionsEnumerable(marketplace).getRoleMemberCount(role); + assertEq(roleMemberCount, 1); + + address roleMember = PermissionsEnumerable(marketplace).getRoleMember(role, 1); + assertEq(roleMember, address(0)); + + vm.startPrank(marketplaceDeployer); + Permissions(marketplace).grantRole(role, address(2)); + Permissions(marketplace).grantRole(role, address(3)); + Permissions(marketplace).grantRole(role, address(4)); + + roleMemberCount = PermissionsEnumerable(marketplace).getRoleMemberCount(role); + assertEq(roleMemberCount, 4); + console.log(roleMemberCount); + for (uint256 i = 0; i < roleMemberCount; i++) { + console.log(PermissionsEnumerable(marketplace).getRoleMember(role, i)); + } + console.log(""); + + Permissions(marketplace).revokeRole(role, address(2)); + roleMemberCount = PermissionsEnumerable(marketplace).getRoleMemberCount(role); + assertEq(roleMemberCount, 3); + console.log(roleMemberCount); + for (uint256 i = 0; i < roleMemberCount; i++) { + console.log(PermissionsEnumerable(marketplace).getRoleMember(role, i)); + } + console.log(""); + + Permissions(marketplace).grantRole(role, address(5)); + roleMemberCount = PermissionsEnumerable(marketplace).getRoleMemberCount(role); + assertEq(roleMemberCount, 4); + console.log(roleMemberCount); + for (uint256 i = 0; i < roleMemberCount; i++) { + console.log(PermissionsEnumerable(marketplace).getRoleMember(role, i)); + } + console.log(""); + + Permissions(marketplace).grantRole(role, address(0)); + roleMemberCount = PermissionsEnumerable(marketplace).getRoleMemberCount(role); + assertEq(roleMemberCount, 5); + console.log(roleMemberCount); + for (uint256 i = 0; i < roleMemberCount; i++) { + console.log(PermissionsEnumerable(marketplace).getRoleMember(role, i)); + } + console.log(""); + + Permissions(marketplace).grantRole(role, address(6)); + roleMemberCount = PermissionsEnumerable(marketplace).getRoleMemberCount(role); + assertEq(roleMemberCount, 6); + console.log(roleMemberCount); + for (uint256 i = 0; i < roleMemberCount; i++) { + console.log(PermissionsEnumerable(marketplace).getRoleMember(role, i)); + } + console.log(""); + + Permissions(marketplace).revokeRole(role, address(3)); + roleMemberCount = PermissionsEnumerable(marketplace).getRoleMemberCount(role); + assertEq(roleMemberCount, 5); + console.log(roleMemberCount); + for (uint256 i = 0; i < roleMemberCount; i++) { + console.log(PermissionsEnumerable(marketplace).getRoleMember(role, i)); + } + console.log(""); + + Permissions(marketplace).revokeRole(role, address(4)); + roleMemberCount = PermissionsEnumerable(marketplace).getRoleMemberCount(role); + assertEq(roleMemberCount, 4); + console.log(roleMemberCount); + for (uint256 i = 0; i < roleMemberCount; i++) { + console.log(PermissionsEnumerable(marketplace).getRoleMember(role, i)); + } + console.log(""); + + Permissions(marketplace).revokeRole(role, address(0)); + roleMemberCount = PermissionsEnumerable(marketplace).getRoleMemberCount(role); + assertEq(roleMemberCount, 3); + console.log(roleMemberCount); + for (uint256 i = 0; i < roleMemberCount; i++) { + console.log(PermissionsEnumerable(marketplace).getRoleMember(role, i)); + } + console.log(""); + + vm.stopPrank(); + } + + function test_state_approvedCurrencies() public { + (uint256 listingId, IDirectListings.ListingParameters memory listingParams) = _setup_updateListing(); + address currencyToApprove = address(erc20); // same currency as main listing + uint256 pricePerTokenForCurrency = 2 ether; + + // Seller approves currency for listing. + vm.prank(seller); + vm.expectRevert("Marketplace: approving listing currency with different price."); + DirectListingsLogic(marketplace).approveCurrencyForListing( + listingId, + currencyToApprove, + pricePerTokenForCurrency + ); + + // change currency + currencyToApprove = NATIVE_TOKEN; + + vm.prank(seller); + DirectListingsLogic(marketplace).approveCurrencyForListing( + listingId, + currencyToApprove, + pricePerTokenForCurrency + ); + + assertEq(DirectListingsLogic(marketplace).isCurrencyApprovedForListing(listingId, NATIVE_TOKEN), true); + assertEq( + DirectListingsLogic(marketplace).currencyPriceForListing(listingId, NATIVE_TOKEN), + pricePerTokenForCurrency + ); + + // should revert when updating listing with an approved currency but different price + listingParams.currency = NATIVE_TOKEN; + vm.prank(seller); + vm.expectRevert("Marketplace: price different from approved price"); + DirectListingsLogic(marketplace).updateListing(listingId, listingParams); + + // change listingParams.pricePerToken to approved price + listingParams.pricePerToken = pricePerTokenForCurrency; + vm.prank(seller); + DirectListingsLogic(marketplace).updateListing(listingId, listingParams); + } + + /*/////////////////////////////////////////////////////////////// + Royalty Tests (incl Royalty Engine / Registry) + //////////////////////////////////////////////////////////////*/ + + function _setupRoyaltyEngine() + private + returns ( + MockRoyaltyEngineV1 royaltyEngine, + address payable[] memory mockRecipients, + uint256[] memory mockAmounts + ) + { + mockRecipients = new address payable[](2); + mockAmounts = new uint256[](2); + + mockRecipients[0] = payable(address(0x12345)); + mockRecipients[1] = payable(address(0x56789)); + + mockAmounts[0] = 10; + mockAmounts[1] = 15; + + royaltyEngine = new MockRoyaltyEngineV1(mockRecipients, mockAmounts); + } + + function _setupListingForRoyaltyTests(address erc721TokenAddress) private returns (uint256 listingId) { + // Sample listing parameters. + address assetContract = erc721TokenAddress; + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = address(erc20); + uint256 pricePerToken = 100 ether; + uint128 startTimestamp = 100; + uint128 endTimestamp = 200; + bool reserved = false; + + // Approve Marketplace to transfer token. + vm.prank(seller); + IERC721(erc721TokenAddress).setApprovalForAll(marketplace, true); + + // List tokens. + IDirectListings.ListingParameters memory listingParams = IDirectListings.ListingParameters( + assetContract, + tokenId, + quantity, + currency, + pricePerToken, + startTimestamp, + endTimestamp, + reserved + ); + + vm.prank(seller); + listingId = DirectListingsLogic(marketplace).createListing(listingParams); + } + + function _buyFromListingForRoyaltyTests(uint256 listingId) private returns (uint256 totalPrice) { + IDirectListings.Listing memory listing = DirectListingsLogic(marketplace).getListing(listingId); + + address buyFor = buyer; + uint256 quantityToBuy = listing.quantity; + address currency = listing.currency; + uint256 pricePerToken = listing.pricePerToken; + totalPrice = pricePerToken * quantityToBuy; + + // Mint requisite total price to buyer. + erc20.mint(buyer, totalPrice); + + // Approve marketplace to transfer currency + vm.prank(buyer); + erc20.increaseAllowance(marketplace, totalPrice); + + // Buy tokens from listing. + vm.warp(listing.startTimestamp); + vm.prank(buyer); + DirectListingsLogic(marketplace).buyFromListing(listingId, buyFor, quantityToBuy, currency, totalPrice); + } + + function test_royaltyEngine_tokenWithCustomRoyalties() public { + ( + MockRoyaltyEngineV1 royaltyEngine, + address payable[] memory customRoyaltyRecipients, + uint256[] memory customRoyaltyAmounts + ) = _setupRoyaltyEngine(); + + // Add RoyaltyEngine to marketplace + vm.prank(marketplaceDeployer); + RoyaltyPaymentsLogic(marketplace).setRoyaltyEngine(address(royaltyEngine)); + + assertEq(RoyaltyPaymentsLogic(marketplace).getRoyaltyEngineAddress(), address(royaltyEngine)); + + // 1. ========= Create listing ========= + + // Mint the ERC721 tokens to seller. These tokens will be listed. + _setupERC721BalanceForSeller(seller, 1); + uint256 listingId = _setupListingForRoyaltyTests(address(erc721)); + + // 2. ========= Buy from listing ========= + + uint256 totalPrice = _buyFromListingForRoyaltyTests(listingId); + + // 3. ======== Check balances after royalty payments ======== + + { + // Royalty recipients receive correct amounts + assertBalERC20Eq(address(erc20), customRoyaltyRecipients[0], customRoyaltyAmounts[0]); + assertBalERC20Eq(address(erc20), customRoyaltyRecipients[1], customRoyaltyAmounts[1]); + + // Seller gets total price minus royalty amounts + assertBalERC20Eq(address(erc20), seller, totalPrice - customRoyaltyAmounts[0] - customRoyaltyAmounts[1]); + } + } + + function test_royaltyEngine_tokenWithERC2981() public { + (MockRoyaltyEngineV1 royaltyEngine, , ) = _setupRoyaltyEngine(); + + // Add RoyaltyEngine to marketplace + vm.prank(marketplaceDeployer); + RoyaltyPaymentsLogic(marketplace).setRoyaltyEngine(address(royaltyEngine)); + + assertEq(RoyaltyPaymentsLogic(marketplace).getRoyaltyEngineAddress(), address(royaltyEngine)); + + // create token with ERC2981 + address royaltyRecipient = address(0x12345); + uint128 royaltyBps = 10; + ERC721Base nft2981 = new ERC721Base(address(0x12345), "NFT 2981", "NFT2981", royaltyRecipient, royaltyBps); + // Mint the ERC721 tokens to seller. These tokens will be listed. + vm.prank(address(0x12345)); + nft2981.mintTo(seller, ""); + + vm.prank(marketplaceDeployer); + Permissions(marketplace).grantRole(keccak256("ASSET_ROLE"), address(nft2981)); + + // 1. ========= Create listing ========= + + uint256 listingId = _setupListingForRoyaltyTests(address(nft2981)); + + // 2. ========= Buy from listing ========= + + uint256 totalPrice = _buyFromListingForRoyaltyTests(listingId); + + // 3. ======== Check balances after royalty payments ======== + + { + uint256 royaltyAmount = (royaltyBps * totalPrice) / 10_000; + // Royalty recipient receives correct amounts + assertBalERC20Eq(address(erc20), royaltyRecipient, royaltyAmount); + + // Seller gets total price minus royalty amount + assertBalERC20Eq(address(erc20), seller, totalPrice - royaltyAmount); + } + } + + function test_noRoyaltyEngine_defaultERC2981Token() public { + // create token with ERC2981 + address royaltyRecipient = address(0x12345); + uint128 royaltyBps = 10; + ERC721Base nft2981 = new ERC721Base(address(0x12345), "NFT 2981", "NFT2981", royaltyRecipient, royaltyBps); + vm.prank(address(0x12345)); + nft2981.mintTo(seller, ""); + + vm.prank(marketplaceDeployer); + Permissions(marketplace).grantRole(keccak256("ASSET_ROLE"), address(nft2981)); + + // 1. ========= Create listing ========= + + uint256 listingId = _setupListingForRoyaltyTests(address(nft2981)); + + // 2. ========= Buy from listing ========= + + uint256 totalPrice = _buyFromListingForRoyaltyTests(listingId); + + // 3. ======== Check balances after royalty payments ======== + + { + uint256 royaltyAmount = (royaltyBps * totalPrice) / 10_000; + // Royalty recipient receives correct amounts + assertBalERC20Eq(address(erc20), royaltyRecipient, royaltyAmount); + + // Seller gets total price minus royalty amount + assertBalERC20Eq(address(erc20), seller, totalPrice - royaltyAmount); + } + } + + function test_royaltyEngine_correctlyDistributeAllFees() public { + ( + MockRoyaltyEngineV1 royaltyEngine, + address payable[] memory customRoyaltyRecipients, + uint256[] memory customRoyaltyAmounts + ) = _setupRoyaltyEngine(); + + // Add RoyaltyEngine to marketplace + vm.prank(marketplaceDeployer); + RoyaltyPaymentsLogic(marketplace).setRoyaltyEngine(address(royaltyEngine)); + + assertEq(RoyaltyPaymentsLogic(marketplace).getRoyaltyEngineAddress(), address(royaltyEngine)); + + // Set platform fee on marketplace + address platformFeeRecipient = marketplaceDeployer; + uint128 platformFeeBps = 5; + vm.prank(marketplaceDeployer); + IPlatformFee(marketplace).setPlatformFeeInfo(platformFeeRecipient, platformFeeBps); + + // 1. ========= Create listing ========= + + _setupERC721BalanceForSeller(seller, 1); + uint256 listingId = _setupListingForRoyaltyTests(address(erc721)); + + // 2. ========= Buy from listing ========= + + uint256 totalPrice = _buyFromListingForRoyaltyTests(listingId); + + // 3. ======== Check balances after fee payments (platform fee + royalty) ======== + + { + // Royalty recipients receive correct amounts + assertBalERC20Eq(address(erc20), customRoyaltyRecipients[0], customRoyaltyAmounts[0]); + assertBalERC20Eq(address(erc20), customRoyaltyRecipients[1], customRoyaltyAmounts[1]); + + // Platform fee recipient + uint256 platformFeeAmount = (platformFeeBps * totalPrice) / 10_000; + assertBalERC20Eq(address(erc20), platformFeeRecipient, platformFeeAmount); + + // Seller gets total price minus royalty amounts + assertBalERC20Eq( + address(erc20), + seller, + totalPrice - customRoyaltyAmounts[0] - customRoyaltyAmounts[1] - platformFeeAmount + ); + } + } + + function test_revert_feesExceedTotalPrice() public { + (MockRoyaltyEngineV1 royaltyEngine, , ) = _setupRoyaltyEngine(); + + // Add RoyaltyEngine to marketplace + vm.prank(marketplaceDeployer); + RoyaltyPaymentsLogic(marketplace).setRoyaltyEngine(address(royaltyEngine)); + + assertEq(RoyaltyPaymentsLogic(marketplace).getRoyaltyEngineAddress(), address(royaltyEngine)); + + // Set platform fee on marketplace + address platformFeeRecipient = marketplaceDeployer; + uint128 platformFeeBps = 10_000; // equal to max bps 10_000 or 100% + vm.prank(marketplaceDeployer); + IPlatformFee(marketplace).setPlatformFeeInfo(platformFeeRecipient, platformFeeBps); + + // 1. ========= Create listing ========= + + _setupERC721BalanceForSeller(seller, 1); + uint256 listingId = _setupListingForRoyaltyTests(address(erc721)); + + // 2. ========= Buy from listing ========= + + IDirectListings.Listing memory listing = DirectListingsLogic(marketplace).getListing(listingId); + + address buyFor = buyer; + uint256 quantityToBuy = listing.quantity; + address currency = listing.currency; + uint256 pricePerToken = listing.pricePerToken; + uint256 totalPrice = pricePerToken * quantityToBuy; + + // Mint requisite total price to buyer. + erc20.mint(buyer, totalPrice); + + // Approve marketplace to transfer currency + vm.prank(buyer); + erc20.increaseAllowance(marketplace, totalPrice); + + // Buy tokens from listing. + vm.warp(listing.startTimestamp); + + vm.expectRevert("fees exceed the price"); + vm.prank(buyer); + DirectListingsLogic(marketplace).buyFromListing(listingId, buyFor, quantityToBuy, currency, totalPrice); + } + + /*/////////////////////////////////////////////////////////////// + Create listing + //////////////////////////////////////////////////////////////*/ + + function test_state_createListing() public { + // Sample listing parameters. + address assetContract = address(erc721); + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = address(erc20); + uint256 pricePerToken = 1 ether; + uint128 startTimestamp = 100; + uint128 endTimestamp = 200; + bool reserved = true; + + // Mint the ERC721 tokens to seller. These tokens will be listed. + _setupERC721BalanceForSeller(seller, 1); + + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = tokenId; + assertIsOwnerERC721(address(erc721), seller, tokenIds); + + // Approve Marketplace to transfer token. + vm.prank(seller); + erc721.setApprovalForAll(marketplace, true); + + // List tokens. + IDirectListings.ListingParameters memory listingParams = IDirectListings.ListingParameters( + assetContract, + tokenId, + quantity, + currency, + pricePerToken, + startTimestamp, + endTimestamp, + reserved + ); + + vm.prank(seller); + uint256 listingId = DirectListingsLogic(marketplace).createListing(listingParams); + + // Test consequent state of the contract. + + // Seller is still owner of token. + assertIsOwnerERC721(address(erc721), seller, tokenIds); + + // Total listings incremented + assertEq(DirectListingsLogic(marketplace).totalListings(), 1); + + // Fetch listing and verify state. + IDirectListings.Listing memory listing = DirectListingsLogic(marketplace).getListing(listingId); + + assertEq(listing.listingId, listingId); + assertEq(listing.listingCreator, seller); + assertEq(listing.assetContract, assetContract); + assertEq(listing.tokenId, tokenId); + assertEq(listing.quantity, quantity); + assertEq(listing.currency, currency); + assertEq(listing.pricePerToken, pricePerToken); + assertEq(listing.startTimestamp, startTimestamp); + assertEq(listing.endTimestamp, endTimestamp); + assertEq(listing.reserved, reserved); + assertEq(uint256(listing.tokenType), uint256(IDirectListings.TokenType.ERC721)); + } + + function test_revert_createListing_notOwnerOfListedToken() public { + // Sample listing parameters. + address assetContract = address(erc721); + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = address(erc20); + uint256 pricePerToken = 1 ether; + uint128 startTimestamp = 100; + uint128 endTimestamp = 200; + bool reserved = true; + + // Don't mint to 'token to be listed' to the seller. + address someWallet = getActor(1000); + _setupERC721BalanceForSeller(someWallet, 1); + + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = tokenId; + assertIsOwnerERC721(address(erc721), someWallet, tokenIds); + assertIsNotOwnerERC721(address(erc721), seller, tokenIds); + + // Approve Marketplace to transfer token. + vm.prank(someWallet); + erc721.setApprovalForAll(marketplace, true); + + // List tokens. + IDirectListings.ListingParameters memory listingParams = IDirectListings.ListingParameters( + assetContract, + tokenId, + quantity, + currency, + pricePerToken, + startTimestamp, + endTimestamp, + reserved + ); + + vm.prank(seller); + vm.expectRevert("Marketplace: not owner or approved tokens."); + DirectListingsLogic(marketplace).createListing(listingParams); + } + + function test_revert_createListing_notApprovedMarketplaceToTransferToken() public { + // Sample listing parameters. + address assetContract = address(erc721); + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = address(erc20); + uint256 pricePerToken = 1 ether; + uint128 startTimestamp = 100; + uint128 endTimestamp = 200; + bool reserved = true; + + // Mint the ERC721 tokens to seller. These tokens will be listed. + _setupERC721BalanceForSeller(seller, 1); + + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = tokenId; + assertIsOwnerERC721(address(erc721), seller, tokenIds); + + // Don't approve Marketplace to transfer token. + vm.prank(seller); + erc721.setApprovalForAll(marketplace, false); + + // List tokens. + IDirectListings.ListingParameters memory listingParams = IDirectListings.ListingParameters( + assetContract, + tokenId, + quantity, + currency, + pricePerToken, + startTimestamp, + endTimestamp, + reserved + ); + + vm.prank(seller); + vm.expectRevert("Marketplace: not owner or approved tokens."); + DirectListingsLogic(marketplace).createListing(listingParams); + } + + function test_revert_createListing_listingZeroQuantity() public { + // Sample listing parameters. + address assetContract = address(erc721); + uint256 tokenId = 0; + uint256 quantity = 0; // Listing ZERO quantity + address currency = address(erc20); + uint256 pricePerToken = 1 ether; + uint128 startTimestamp = 100; + uint128 endTimestamp = 200; + bool reserved = true; + + // Mint the ERC721 tokens to seller. These tokens will be listed. + _setupERC721BalanceForSeller(seller, 1); + + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = tokenId; + assertIsOwnerERC721(address(erc721), seller, tokenIds); + + // Approve Marketplace to transfer token. + vm.prank(seller); + erc721.setApprovalForAll(marketplace, true); + + // List tokens. + IDirectListings.ListingParameters memory listingParams = IDirectListings.ListingParameters( + assetContract, + tokenId, + quantity, + currency, + pricePerToken, + startTimestamp, + endTimestamp, + reserved + ); + + vm.prank(seller); + vm.expectRevert("Marketplace: listing zero quantity."); + DirectListingsLogic(marketplace).createListing(listingParams); + } + + function test_revert_createListing_listingInvalidQuantity() public { + // Sample listing parameters. + address assetContract = address(erc721); + uint256 tokenId = 0; + uint256 quantity = 2; // Listing more than `1` quantity + address currency = address(erc20); + uint256 pricePerToken = 1 ether; + uint128 startTimestamp = 100; + uint128 endTimestamp = 200; + bool reserved = true; + + // Mint the ERC721 tokens to seller. These tokens will be listed. + _setupERC721BalanceForSeller(seller, 1); + + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = tokenId; + assertIsOwnerERC721(address(erc721), seller, tokenIds); + + // Approve Marketplace to transfer token. + vm.prank(seller); + erc721.setApprovalForAll(marketplace, true); + + // List tokens. + IDirectListings.ListingParameters memory listingParams = IDirectListings.ListingParameters( + assetContract, + tokenId, + quantity, + currency, + pricePerToken, + startTimestamp, + endTimestamp, + reserved + ); + + vm.prank(seller); + vm.expectRevert("Marketplace: listing invalid quantity."); + DirectListingsLogic(marketplace).createListing(listingParams); + } + + function test_revert_createListing_invalidStartTimestamp() public { + uint256 blockTimestamp = 100 minutes; + // Set block.timestamp + vm.warp(blockTimestamp); + + // Sample listing parameters. + address assetContract = address(erc721); + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = address(erc20); + uint256 pricePerToken = 1 ether; + uint128 startTimestamp = uint128(blockTimestamp - 61 minutes); // start time is less than block timestamp. + uint128 endTimestamp = uint128(startTimestamp + 1); + bool reserved = true; + + // Mint the ERC721 tokens to seller. These tokens will be listed. + _setupERC721BalanceForSeller(seller, 1); + + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = tokenId; + assertIsOwnerERC721(address(erc721), seller, tokenIds); + + // Approve Marketplace to transfer token. + vm.prank(seller); + erc721.setApprovalForAll(marketplace, true); + + // List tokens. + IDirectListings.ListingParameters memory listingParams = IDirectListings.ListingParameters( + assetContract, + tokenId, + quantity, + currency, + pricePerToken, + startTimestamp, + endTimestamp, + reserved + ); + + vm.prank(seller); + vm.expectRevert("Marketplace: invalid startTimestamp."); + DirectListingsLogic(marketplace).createListing(listingParams); + } + + function test_revert_createListing_invalidEndTimestamp() public { + // Sample listing parameters. + address assetContract = address(erc721); + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = address(erc20); + uint256 pricePerToken = 1 ether; + uint128 startTimestamp = 100; + uint128 endTimestamp = uint128(startTimestamp - 1); // End timestamp is less than start timestamp. + bool reserved = true; + + // Mint the ERC721 tokens to seller. These tokens will be listed. + _setupERC721BalanceForSeller(seller, 1); + + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = tokenId; + assertIsOwnerERC721(address(erc721), seller, tokenIds); + + // Approve Marketplace to transfer token. + vm.prank(seller); + erc721.setApprovalForAll(marketplace, true); + + // List tokens. + IDirectListings.ListingParameters memory listingParams = IDirectListings.ListingParameters( + assetContract, + tokenId, + quantity, + currency, + pricePerToken, + startTimestamp, + endTimestamp, + reserved + ); + + vm.prank(seller); + vm.expectRevert("Marketplace: endTimestamp not greater than startTimestamp."); + DirectListingsLogic(marketplace).createListing(listingParams); + } + + function test_revert_createListing_listingNonERC721OrERC1155Token() public { + // Sample listing parameters. + address assetContract = address(erc20); + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = address(erc20); + uint256 pricePerToken = 1 ether; + uint128 startTimestamp = 100; + uint128 endTimestamp = 200; + bool reserved = true; + + // List tokens. + IDirectListings.ListingParameters memory listingParams = IDirectListings.ListingParameters( + assetContract, + tokenId, + quantity, + currency, + pricePerToken, + startTimestamp, + endTimestamp, + reserved + ); + + // Grant ERC20 token asset role. + vm.prank(marketplaceDeployer); + Permissions(marketplace).grantRole(keccak256("ASSET_ROLE"), address(erc20)); + + vm.prank(seller); + vm.expectRevert("Marketplace: listed token must be ERC1155 or ERC721."); + DirectListingsLogic(marketplace).createListing(listingParams); + } + + function test_revert_createListing_noListerRoleWhenRestrictionsActive() public { + // Sample listing parameters. + address assetContract = address(erc721); + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = address(erc20); + uint256 pricePerToken = 1 ether; + uint128 startTimestamp = 100; + uint128 endTimestamp = 200; + bool reserved = true; + + // Mint the ERC721 tokens to seller. These tokens will be listed. + _setupERC721BalanceForSeller(seller, 1); + + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = tokenId; + assertIsOwnerERC721(address(erc721), seller, tokenIds); + + // Approve Marketplace to transfer token. + vm.prank(seller); + erc721.setApprovalForAll(marketplace, true); + + // List tokens. + IDirectListings.ListingParameters memory listingParams = IDirectListings.ListingParameters( + assetContract, + tokenId, + quantity, + currency, + pricePerToken, + startTimestamp, + endTimestamp, + reserved + ); + + // Revoke LISTER_ROLE from seller. + vm.startPrank(marketplaceDeployer); + assertEq(Permissions(marketplace).hasRole(keccak256("LISTER_ROLE"), address(0)), false); + Permissions(marketplace).revokeRole(keccak256("LISTER_ROLE"), seller); + assertEq(Permissions(marketplace).hasRole(keccak256("LISTER_ROLE"), seller), false); + + vm.stopPrank(); + + vm.prank(seller); + vm.expectRevert("!LISTER_ROLE"); + DirectListingsLogic(marketplace).createListing(listingParams); + } + + function test_revert_createListing_noAssetRoleWhenRestrictionsActive() public { + // Sample listing parameters. + address assetContract = address(erc721); + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = address(erc20); + uint256 pricePerToken = 1 ether; + uint128 startTimestamp = 100; + uint128 endTimestamp = 200; + bool reserved = true; + + // Mint the ERC721 tokens to seller. These tokens will be listed. + _setupERC721BalanceForSeller(seller, 1); + + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = tokenId; + assertIsOwnerERC721(address(erc721), seller, tokenIds); + + // Approve Marketplace to transfer token. + vm.prank(seller); + erc721.setApprovalForAll(marketplace, true); + + // List tokens. + IDirectListings.ListingParameters memory listingParams = IDirectListings.ListingParameters( + assetContract, + tokenId, + quantity, + currency, + pricePerToken, + startTimestamp, + endTimestamp, + reserved + ); + + // Revoke ASSET_ROLE from token to list. + vm.startPrank(marketplaceDeployer); + assertEq(Permissions(marketplace).hasRole(keccak256("ASSET_ROLE"), address(0)), false); + Permissions(marketplace).revokeRole(keccak256("ASSET_ROLE"), address(erc721)); + assertEq(Permissions(marketplace).hasRole(keccak256("ASSET_ROLE"), address(erc721)), false); + + vm.stopPrank(); + + vm.prank(seller); + vm.expectRevert("!ASSET_ROLE"); + DirectListingsLogic(marketplace).createListing(listingParams); + } + + /*/////////////////////////////////////////////////////////////// + Update listing + //////////////////////////////////////////////////////////////*/ + + function _setup_updateListing() + private + returns (uint256 listingId, IDirectListings.ListingParameters memory listingParams) + { + // Sample listing parameters. + address assetContract = address(erc721); + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = address(erc20); + uint256 pricePerToken = 1 ether; + uint128 startTimestamp = 100; + uint128 endTimestamp = 200; + bool reserved = true; + + // Mint the ERC721 tokens to seller. These tokens will be listed. + _setupERC721BalanceForSeller(seller, 1); + + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = tokenId; + assertIsOwnerERC721(address(erc721), seller, tokenIds); + + // Approve Marketplace to transfer token. + vm.prank(seller); + erc721.setApprovalForAll(marketplace, true); + + // List tokens. + listingParams = IDirectListings.ListingParameters( + assetContract, + tokenId, + quantity, + currency, + pricePerToken, + startTimestamp, + endTimestamp, + reserved + ); + + vm.prank(seller); + listingId = DirectListingsLogic(marketplace).createListing(listingParams); + } + + function test_state_updateListing() public { + (uint256 listingId, IDirectListings.ListingParameters memory listingParamsToUpdate) = _setup_updateListing(); + + // Mint MORE ERC721 tokens to seller. A new tokenId will be listed. + _setupERC721BalanceForSeller(seller, 1); + + uint256[] memory tokenIds = new uint256[](2); + tokenIds[0] = 0; + tokenIds[1] = 1; + assertIsOwnerERC721(address(erc721), seller, tokenIds); + + listingParamsToUpdate.pricePerToken = 2 ether; + + vm.prank(seller); + DirectListingsLogic(marketplace).updateListing(listingId, listingParamsToUpdate); + + // Test consequent state of the contract. + + // Seller is still owner of token. + assertIsOwnerERC721(address(erc721), seller, tokenIds); + + // Total listings not incremented on update. + assertEq(DirectListingsLogic(marketplace).totalListings(), 1); + + // Fetch listing and verify state. + IDirectListings.Listing memory listing = DirectListingsLogic(marketplace).getListing(listingId); + + assertEq(listing.listingId, listingId); + assertEq(listing.listingCreator, seller); + assertEq(listing.assetContract, listingParamsToUpdate.assetContract); + assertEq(listing.tokenId, 0); + assertEq(listing.quantity, listingParamsToUpdate.quantity); + assertEq(listing.currency, listingParamsToUpdate.currency); + assertEq(listing.pricePerToken, listingParamsToUpdate.pricePerToken); + assertEq(listing.startTimestamp, listingParamsToUpdate.startTimestamp); + assertEq(listing.endTimestamp, listingParamsToUpdate.endTimestamp); + assertEq(listing.reserved, listingParamsToUpdate.reserved); + assertEq(uint256(listing.tokenType), uint256(IDirectListings.TokenType.ERC721)); + } + + function test_revert_updateListing_notListingCreator() public { + (uint256 listingId, IDirectListings.ListingParameters memory listingParamsToUpdate) = _setup_updateListing(); + + // Mint MORE ERC721 tokens to seller. A new tokenId will be listed. + _setupERC721BalanceForSeller(seller, 1); + + uint256[] memory tokenIds = new uint256[](2); + tokenIds[0] = 0; + tokenIds[1] = 1; + assertIsOwnerERC721(address(erc721), seller, tokenIds); + + address notSeller = getActor(1000); // Someone other than the seller calls update. + vm.prank(notSeller); + vm.expectRevert("Marketplace: not listing creator."); + DirectListingsLogic(marketplace).updateListing(listingId, listingParamsToUpdate); + } + + function test_revert_updateListing_notOwnerOfListedToken() public { + (uint256 listingId, IDirectListings.ListingParameters memory listingParamsToUpdate) = _setup_updateListing(); + + // Mint MORE ERC721 tokens but NOT to seller. A new tokenId will be listed. + address notSeller = getActor(1000); + _setupERC721BalanceForSeller(notSeller, 1); + + // Approve Marketplace to transfer token. + vm.prank(notSeller); + erc721.setApprovalForAll(marketplace, true); + + // Transfer away owned token. + vm.prank(seller); + erc721.transferFrom(seller, address(0x1234), 0); + + vm.prank(seller); + vm.expectRevert("Marketplace: not owner or approved tokens."); + DirectListingsLogic(marketplace).updateListing(listingId, listingParamsToUpdate); + } + + function test_revert_updateListing_notApprovedMarketplaceToTransferToken() public { + (uint256 listingId, IDirectListings.ListingParameters memory listingParamsToUpdate) = _setup_updateListing(); + + // Mint MORE ERC721 tokens to seller. A new tokenId will be listed. + _setupERC721BalanceForSeller(seller, 1); + + uint256[] memory tokenIds = new uint256[](2); + tokenIds[0] = 0; + tokenIds[1] = 1; + assertIsOwnerERC721(address(erc721), seller, tokenIds); + + // Don't approve Marketplace to transfer token. + vm.prank(seller); + erc721.setApprovalForAll(marketplace, false); + + vm.prank(seller); + vm.expectRevert("Marketplace: not owner or approved tokens."); + DirectListingsLogic(marketplace).updateListing(listingId, listingParamsToUpdate); + } + + function test_revert_updateListing_listingZeroQuantity() public { + (uint256 listingId, IDirectListings.ListingParameters memory listingParamsToUpdate) = _setup_updateListing(); + + // Mint MORE ERC721 tokens to seller. A new tokenId will be listed. + _setupERC721BalanceForSeller(seller, 1); + + uint256[] memory tokenIds = new uint256[](2); + tokenIds[0] = 0; + tokenIds[1] = 1; + assertIsOwnerERC721(address(erc721), seller, tokenIds); + + listingParamsToUpdate.quantity = 0; // Listing zero quantity + + vm.prank(seller); + vm.expectRevert("Marketplace: listing zero quantity."); + DirectListingsLogic(marketplace).updateListing(listingId, listingParamsToUpdate); + } + + function test_revert_updateListing_listingInvalidQuantity() public { + (uint256 listingId, IDirectListings.ListingParameters memory listingParamsToUpdate) = _setup_updateListing(); + + // Mint MORE ERC721 tokens to seller. A new tokenId will be listed. + _setupERC721BalanceForSeller(seller, 1); + + uint256[] memory tokenIds = new uint256[](2); + tokenIds[0] = 0; + tokenIds[1] = 1; + assertIsOwnerERC721(address(erc721), seller, tokenIds); + + listingParamsToUpdate.quantity = 2; // Listing more than `1` of the ERC721 token + + vm.prank(seller); + vm.expectRevert("Marketplace: listing invalid quantity."); + DirectListingsLogic(marketplace).updateListing(listingId, listingParamsToUpdate); + } + + function test_revert_updateListing_listingNonERC721OrERC1155Token() public { + (uint256 listingId, IDirectListings.ListingParameters memory listingParamsToUpdate) = _setup_updateListing(); + + // Mint MORE ERC721 tokens to seller. A new tokenId will be listed. + _setupERC721BalanceForSeller(seller, 1); + + uint256[] memory tokenIds = new uint256[](2); + tokenIds[0] = 0; + tokenIds[1] = 1; + assertIsOwnerERC721(address(erc721), seller, tokenIds); + + listingParamsToUpdate.assetContract = address(erc20); // Listing non ERC721 / ERC1155 token. + + // Grant ERC20 token asset role. + vm.prank(marketplaceDeployer); + Permissions(marketplace).grantRole(keccak256("ASSET_ROLE"), address(erc20)); + + vm.prank(seller); + vm.expectRevert("Marketplace: listed token must be ERC1155 or ERC721."); + DirectListingsLogic(marketplace).updateListing(listingId, listingParamsToUpdate); + } + + function test_revert_updateListing_invalidStartTimestamp() public { + (uint256 listingId, IDirectListings.ListingParameters memory listingParamsToUpdate) = _setup_updateListing(); + + // Mint MORE ERC721 tokens to seller. A new tokenId will be listed. + _setupERC721BalanceForSeller(seller, 1); + + uint256[] memory tokenIds = new uint256[](2); + tokenIds[0] = 0; + tokenIds[1] = 1; + assertIsOwnerERC721(address(erc721), seller, tokenIds); + + uint128 currentStartTimestamp = listingParamsToUpdate.startTimestamp; + listingParamsToUpdate.startTimestamp = currentStartTimestamp - 1; // Retroactively decreasing startTimestamp. + + vm.warp(currentStartTimestamp + 50); + vm.prank(seller); + vm.expectRevert("Marketplace: listing already active."); + DirectListingsLogic(marketplace).updateListing(listingId, listingParamsToUpdate); + } + + function test_revert_updateListing_invalidEndTimestamp() public { + (uint256 listingId, IDirectListings.ListingParameters memory listingParamsToUpdate) = _setup_updateListing(); + + // Mint MORE ERC721 tokens to seller. A new tokenId will be listed. + _setupERC721BalanceForSeller(seller, 1); + + uint256[] memory tokenIds = new uint256[](2); + tokenIds[0] = 0; + tokenIds[1] = 1; + assertIsOwnerERC721(address(erc721), seller, tokenIds); + + uint128 currentStartTimestamp = listingParamsToUpdate.startTimestamp; + listingParamsToUpdate.endTimestamp = currentStartTimestamp - 1; // End timestamp less than startTimestamp + + vm.prank(seller); + vm.expectRevert("Marketplace: endTimestamp not greater than startTimestamp."); + DirectListingsLogic(marketplace).updateListing(listingId, listingParamsToUpdate); + } + + function test_revert_updateListing_noAssetRoleWhenRestrictionsActive() public { + (uint256 listingId, IDirectListings.ListingParameters memory listingParamsToUpdate) = _setup_updateListing(); + + // Mint MORE ERC721 tokens to seller. A new tokenId will be listed. + _setupERC721BalanceForSeller(seller, 1); + + uint256[] memory tokenIds = new uint256[](2); + tokenIds[0] = 0; + tokenIds[1] = 1; + assertIsOwnerERC721(address(erc721), seller, tokenIds); + + // Revoke ASSET_ROLE from token to list. + vm.startPrank(marketplaceDeployer); + assertEq(Permissions(marketplace).hasRole(keccak256("ASSET_ROLE"), address(0)), false); + Permissions(marketplace).revokeRole(keccak256("ASSET_ROLE"), address(erc721)); + assertEq(Permissions(marketplace).hasRole(keccak256("ASSET_ROLE"), address(erc721)), false); + + vm.stopPrank(); + + vm.prank(seller); + vm.expectRevert("!ASSET_ROLE"); + DirectListingsLogic(marketplace).updateListing(listingId, listingParamsToUpdate); + } + + /*/////////////////////////////////////////////////////////////// + Cancel listing + //////////////////////////////////////////////////////////////*/ + + function _setup_cancelListing() private returns (uint256 listingId, IDirectListings.Listing memory listing) { + (listingId, ) = _setup_updateListing(); + listing = DirectListingsLogic(marketplace).getListing(listingId); + } + + function test_state_cancelListing() public { + (uint256 listingId, IDirectListings.Listing memory existingListingAtId) = _setup_cancelListing(); + + // Verify existing listing at `listingId` + assertEq(existingListingAtId.assetContract, address(erc721)); + + vm.prank(seller); + DirectListingsLogic(marketplace).cancelListing(listingId); + + // status should be `CANCELLED` + IDirectListings.Listing memory cancelledListing = DirectListingsLogic(marketplace).getListing(listingId); + assertTrue(cancelledListing.status == IDirectListings.Status.CANCELLED); + } + + function test_revert_cancelListing_notListingCreator() public { + (uint256 listingId, IDirectListings.Listing memory existingListingAtId) = _setup_cancelListing(); + + // Verify existing listing at `listingId` + assertEq(existingListingAtId.assetContract, address(erc721)); + + address notSeller = getActor(1000); + vm.prank(notSeller); + vm.expectRevert("Marketplace: not listing creator."); + DirectListingsLogic(marketplace).cancelListing(listingId); + } + + function test_revert_cancelListing_nonExistentListing() public { + _setup_cancelListing(); + + // Verify no listing exists at `nexListingId` + uint256 nextListingId = DirectListingsLogic(marketplace).totalListings(); + + vm.prank(seller); + vm.expectRevert("Marketplace: invalid listing."); + DirectListingsLogic(marketplace).cancelListing(nextListingId); + } + + /*/////////////////////////////////////////////////////////////// + Approve buyer for listing + //////////////////////////////////////////////////////////////*/ + + function _setup_approveBuyerForListing() private returns (uint256 listingId) { + (listingId, ) = _setup_updateListing(); + } + + function test_state_approveBuyerForListing() public { + uint256 listingId = _setup_approveBuyerForListing(); + bool toApprove = true; + + assertEq(DirectListingsLogic(marketplace).getListing(listingId).reserved, true); + + // Seller approves buyer for reserved listing. + vm.prank(seller); + DirectListingsLogic(marketplace).approveBuyerForListing(listingId, buyer, toApprove); + + assertEq(DirectListingsLogic(marketplace).isBuyerApprovedForListing(listingId, buyer), true); + } + + function test_revert_approveBuyerForListing_notListingCreator() public { + uint256 listingId = _setup_approveBuyerForListing(); + bool toApprove = true; + + assertEq(DirectListingsLogic(marketplace).getListing(listingId).reserved, true); + + // Someone other than the seller approves buyer for reserved listing. + address notSeller = getActor(1000); + vm.prank(notSeller); + vm.expectRevert("Marketplace: not listing creator."); + DirectListingsLogic(marketplace).approveBuyerForListing(listingId, buyer, toApprove); + } + + function test_revert_approveBuyerForListing_listingNotReserved() public { + (uint256 listingId, IDirectListings.ListingParameters memory listingParamsToUpdate) = _setup_updateListing(); + bool toApprove = true; + + assertEq(DirectListingsLogic(marketplace).getListing(listingId).reserved, true); + + listingParamsToUpdate.reserved = false; + + vm.prank(seller); + DirectListingsLogic(marketplace).updateListing(listingId, listingParamsToUpdate); + + assertEq(DirectListingsLogic(marketplace).getListing(listingId).reserved, false); + + // Seller approves buyer for reserved listing. + vm.prank(seller); + vm.expectRevert("Marketplace: listing not reserved."); + DirectListingsLogic(marketplace).approveBuyerForListing(listingId, buyer, toApprove); + } + + /*/////////////////////////////////////////////////////////////// + Approve currency for listing + //////////////////////////////////////////////////////////////*/ + + function _setup_approveCurrencyForListing() private returns (uint256 listingId) { + (listingId, ) = _setup_updateListing(); + } + + function test_state_approveCurrencyForListing() public { + uint256 listingId = _setup_approveCurrencyForListing(); + address currencyToApprove = NATIVE_TOKEN; + uint256 pricePerTokenForCurrency = 2 ether; + + // Seller approves buyer for reserved listing. + vm.prank(seller); + DirectListingsLogic(marketplace).approveCurrencyForListing( + listingId, + currencyToApprove, + pricePerTokenForCurrency + ); + + assertEq(DirectListingsLogic(marketplace).isCurrencyApprovedForListing(listingId, NATIVE_TOKEN), true); + assertEq( + DirectListingsLogic(marketplace).currencyPriceForListing(listingId, NATIVE_TOKEN), + pricePerTokenForCurrency + ); + } + + function test_revert_approveCurrencyForListing_notListingCreator() public { + uint256 listingId = _setup_approveCurrencyForListing(); + address currencyToApprove = NATIVE_TOKEN; + uint256 pricePerTokenForCurrency = 2 ether; + + // Someone other than seller approves buyer for reserved listing. + address notSeller = getActor(1000); + vm.prank(notSeller); + vm.expectRevert("Marketplace: not listing creator."); + DirectListingsLogic(marketplace).approveCurrencyForListing( + listingId, + currencyToApprove, + pricePerTokenForCurrency + ); + } + + function test_revert_approveCurrencyForListing_reApprovingMainCurrency() public { + uint256 listingId = _setup_approveCurrencyForListing(); + address currencyToApprove = DirectListingsLogic(marketplace).getListing(listingId).currency; + uint256 pricePerTokenForCurrency = 2 ether; + + // Seller approves buyer for reserved listing. + vm.prank(seller); + vm.expectRevert("Marketplace: approving listing currency with different price."); + DirectListingsLogic(marketplace).approveCurrencyForListing( + listingId, + currencyToApprove, + pricePerTokenForCurrency + ); + } + + /*/////////////////////////////////////////////////////////////// + Buy from listing + //////////////////////////////////////////////////////////////*/ + + function _setup_buyFromListing() private returns (uint256 listingId, IDirectListings.Listing memory listing) { + (listingId, ) = _setup_updateListing(); + listing = DirectListingsLogic(marketplace).getListing(listingId); + } + + function test_state_buyFromListing() public { + (uint256 listingId, IDirectListings.Listing memory listing) = _setup_buyFromListing(); + + address buyFor = buyer; + uint256 quantityToBuy = listing.quantity; + address currency = listing.currency; + uint256 pricePerToken = listing.pricePerToken; + uint256 totalPrice = pricePerToken * quantityToBuy; + + // Seller approves buyer for listing + vm.prank(seller); + DirectListingsLogic(marketplace).approveBuyerForListing(listingId, buyer, true); + + // Verify that seller is owner of listed tokens, pre-sale. + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = 0; + assertIsOwnerERC721(address(erc721), seller, tokenIds); + assertIsNotOwnerERC721(address(erc721), buyer, tokenIds); + + // Mint requisite total price to buyer. + erc20.mint(buyer, totalPrice); + assertBalERC20Eq(address(erc20), buyer, totalPrice); + assertBalERC20Eq(address(erc20), seller, 0); + + // Approve marketplace to transfer currency + vm.prank(buyer); + erc20.increaseAllowance(marketplace, totalPrice); + + // Buy tokens from listing. + vm.warp(listing.startTimestamp); + vm.prank(buyer); + DirectListingsLogic(marketplace).buyFromListing(listingId, buyFor, quantityToBuy, currency, totalPrice); + + // Verify that buyer is owner of listed tokens, post-sale. + assertIsOwnerERC721(address(erc721), buyer, tokenIds); + assertIsNotOwnerERC721(address(erc721), seller, tokenIds); + + // Verify seller is paid total price. + assertBalERC20Eq(address(erc20), buyer, 0); + assertBalERC20Eq(address(erc20), seller, totalPrice); + + if (quantityToBuy == listing.quantity) { + // Verify listing status is `COMPLETED` if listing tokens are all bought. + IDirectListings.Listing memory completedListing = DirectListingsLogic(marketplace).getListing(listingId); + assertTrue(completedListing.status == IDirectListings.Status.COMPLETED); + } + } + + function test_state_buyFromListing_nativeToken() public { + (uint256 listingId, IDirectListings.Listing memory listing) = _setup_buyFromListing(); + + address buyFor = buyer; + uint256 quantityToBuy = listing.quantity; + address currency = NATIVE_TOKEN; + uint256 pricePerToken = listing.pricePerToken; + uint256 totalPrice = pricePerToken * quantityToBuy; + + // Approve NATIVE_TOKEN for listing + vm.prank(seller); + DirectListingsLogic(marketplace).approveCurrencyForListing(listingId, currency, pricePerToken); + + // Seller approves buyer for listing + vm.prank(seller); + DirectListingsLogic(marketplace).approveBuyerForListing(listingId, buyer, true); + + // Verify that seller is owner of listed tokens, pre-sale. + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = 0; + assertIsOwnerERC721(address(erc721), seller, tokenIds); + assertIsNotOwnerERC721(address(erc721), buyer, tokenIds); + + // Deal requisite total price to buyer. + vm.deal(buyer, totalPrice); + uint256 buyerBalBefore = buyer.balance; + uint256 sellerBalBefore = seller.balance; + + // Buy tokens from listing. + vm.warp(listing.startTimestamp); + vm.prank(buyer); + DirectListingsLogic(marketplace).buyFromListing{ value: totalPrice }( + listingId, + buyFor, + quantityToBuy, + currency, + totalPrice + ); + + // Verify that buyer is owner of listed tokens, post-sale. + assertIsOwnerERC721(address(erc721), buyer, tokenIds); + assertIsNotOwnerERC721(address(erc721), seller, tokenIds); + + // Verify seller is paid total price. + assertEq(buyer.balance, buyerBalBefore - totalPrice); + assertEq(seller.balance, sellerBalBefore + totalPrice); + + if (quantityToBuy == listing.quantity) { + // Verify listing status is `COMPLETED` if listing tokens are all bought. + IDirectListings.Listing memory completedListing = DirectListingsLogic(marketplace).getListing(listingId); + assertTrue(completedListing.status == IDirectListings.Status.COMPLETED); + } + } + + function test_revert_buyFromListing_nativeToken_incorrectValueSent() public { + (uint256 listingId, IDirectListings.Listing memory listing) = _setup_buyFromListing(); + + address buyFor = buyer; + uint256 quantityToBuy = listing.quantity; + address currency = NATIVE_TOKEN; + uint256 pricePerToken = listing.pricePerToken; + uint256 totalPrice = pricePerToken * quantityToBuy; + + // Approve NATIVE_TOKEN for listing + vm.prank(seller); + DirectListingsLogic(marketplace).approveCurrencyForListing(listingId, currency, pricePerToken); + + // Seller approves buyer for listing + vm.prank(seller); + DirectListingsLogic(marketplace).approveBuyerForListing(listingId, buyer, true); + + // Verify that seller is owner of listed tokens, pre-sale. + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = 0; + assertIsOwnerERC721(address(erc721), seller, tokenIds); + assertIsNotOwnerERC721(address(erc721), buyer, tokenIds); + + // Deal requisite total price to buyer. + vm.deal(buyer, totalPrice); + + // Buy tokens from listing. + vm.warp(listing.startTimestamp); + vm.prank(buyer); + vm.expectRevert("Marketplace: msg.value must exactly be the total price."); + DirectListingsLogic(marketplace).buyFromListing{ value: totalPrice - 1 }( // sending insufficient value + listingId, + buyFor, + quantityToBuy, + currency, + totalPrice + ); + } + + function test_revert_buyFromListing_unexpectedTotalPrice() public { + (uint256 listingId, IDirectListings.Listing memory listing) = _setup_buyFromListing(); + + address buyFor = buyer; + uint256 quantityToBuy = listing.quantity; + address currency = NATIVE_TOKEN; + uint256 pricePerToken = listing.pricePerToken; + uint256 totalPrice = pricePerToken * quantityToBuy; + + // Approve NATIVE_TOKEN for listing + vm.prank(seller); + DirectListingsLogic(marketplace).approveCurrencyForListing(listingId, currency, pricePerToken); + + // Seller approves buyer for listing + vm.prank(seller); + DirectListingsLogic(marketplace).approveBuyerForListing(listingId, buyer, true); + + // Verify that seller is owner of listed tokens, pre-sale. + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = 0; + assertIsOwnerERC721(address(erc721), seller, tokenIds); + assertIsNotOwnerERC721(address(erc721), buyer, tokenIds); + + // Deal requisite total price to buyer. + vm.deal(buyer, totalPrice); + + // Buy tokens from listing. + vm.warp(listing.startTimestamp); + vm.prank(buyer); + vm.expectRevert("Unexpected total price"); + DirectListingsLogic(marketplace).buyFromListing{ value: totalPrice }( + listingId, + buyFor, + quantityToBuy, + currency, + totalPrice + 1 // Pass unexpected total price + ); + } + + function test_revert_buyFromListing_invalidCurrency() public { + (uint256 listingId, IDirectListings.Listing memory listing) = _setup_buyFromListing(); + + address buyFor = buyer; + uint256 quantityToBuy = listing.quantity; + uint256 pricePerToken = listing.pricePerToken; + uint256 totalPrice = pricePerToken * quantityToBuy; + + // Seller approves buyer for listing + vm.prank(seller); + DirectListingsLogic(marketplace).approveBuyerForListing(listingId, buyer, true); + + // Verify that seller is owner of listed tokens, pre-sale. + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = 0; + assertIsOwnerERC721(address(erc721), seller, tokenIds); + assertIsNotOwnerERC721(address(erc721), buyer, tokenIds); + + // Mint requisite total price to buyer. + erc20.mint(buyer, totalPrice); + assertBalERC20Eq(address(erc20), buyer, totalPrice); + assertBalERC20Eq(address(erc20), seller, 0); + + // Approve marketplace to transfer currency + vm.prank(buyer); + erc20.increaseAllowance(marketplace, totalPrice); + + // Buy tokens from listing. + + assertEq(listing.currency, address(erc20)); + assertEq(DirectListingsLogic(marketplace).isCurrencyApprovedForListing(listingId, NATIVE_TOKEN), false); + + vm.warp(listing.startTimestamp); + vm.prank(buyer); + vm.expectRevert("Paying in invalid currency."); + DirectListingsLogic(marketplace).buyFromListing(listingId, buyFor, quantityToBuy, NATIVE_TOKEN, totalPrice); + } + + function test_revert_buyFromListing_buyerBalanceLessThanPrice() public { + (uint256 listingId, IDirectListings.Listing memory listing) = _setup_buyFromListing(); + + address buyFor = buyer; + uint256 quantityToBuy = listing.quantity; + address currency = listing.currency; + uint256 pricePerToken = listing.pricePerToken; + uint256 totalPrice = pricePerToken * quantityToBuy; + + // Seller approves buyer for listing + vm.prank(seller); + DirectListingsLogic(marketplace).approveBuyerForListing(listingId, buyer, true); + + // Verify that seller is owner of listed tokens, pre-sale. + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = 0; + assertIsOwnerERC721(address(erc721), seller, tokenIds); + assertIsNotOwnerERC721(address(erc721), buyer, tokenIds); + + // Mint requisite total price to buyer. + erc20.mint(buyer, totalPrice - 1); // Buyer balance less than total price + assertBalERC20Eq(address(erc20), buyer, totalPrice - 1); + assertBalERC20Eq(address(erc20), seller, 0); + + // Approve marketplace to transfer currency + vm.prank(buyer); + erc20.increaseAllowance(marketplace, totalPrice); + + // Buy tokens from listing. + vm.warp(listing.startTimestamp); + vm.prank(buyer); + vm.expectRevert("!BAL20"); + DirectListingsLogic(marketplace).buyFromListing(listingId, buyFor, quantityToBuy, currency, totalPrice); + } + + function test_revert_buyFromListing_notApprovedMarketplaceToTransferPrice() public { + (uint256 listingId, IDirectListings.Listing memory listing) = _setup_buyFromListing(); + + address buyFor = buyer; + uint256 quantityToBuy = listing.quantity; + address currency = listing.currency; + uint256 pricePerToken = listing.pricePerToken; + uint256 totalPrice = pricePerToken * quantityToBuy; + + // Seller approves buyer for listing + vm.prank(seller); + DirectListingsLogic(marketplace).approveBuyerForListing(listingId, buyer, true); + + // Verify that seller is owner of listed tokens, pre-sale. + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = 0; + assertIsOwnerERC721(address(erc721), seller, tokenIds); + assertIsNotOwnerERC721(address(erc721), buyer, tokenIds); + + // Mint requisite total price to buyer. + erc20.mint(buyer, totalPrice); + assertBalERC20Eq(address(erc20), buyer, totalPrice); + assertBalERC20Eq(address(erc20), seller, 0); + + // Don't approve marketplace to transfer currency + vm.prank(buyer); + erc20.approve(marketplace, 0); + + // Buy tokens from listing. + vm.warp(listing.startTimestamp); + vm.prank(buyer); + vm.expectRevert("!BAL20"); + DirectListingsLogic(marketplace).buyFromListing(listingId, buyFor, quantityToBuy, currency, totalPrice); + } + + function test_revert_buyFromListing_buyingZeroQuantity() public { + (uint256 listingId, IDirectListings.Listing memory listing) = _setup_buyFromListing(); + + address buyFor = buyer; + uint256 quantityToBuy = 0; // Buying zero quantity + address currency = listing.currency; + uint256 pricePerToken = listing.pricePerToken; + uint256 totalPrice = pricePerToken * quantityToBuy; + + // Seller approves buyer for listing + vm.prank(seller); + DirectListingsLogic(marketplace).approveBuyerForListing(listingId, buyer, true); + + // Verify that seller is owner of listed tokens, pre-sale. + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = 0; + assertIsOwnerERC721(address(erc721), seller, tokenIds); + assertIsNotOwnerERC721(address(erc721), buyer, tokenIds); + + // Mint requisite total price to buyer. + erc20.mint(buyer, totalPrice); + assertBalERC20Eq(address(erc20), buyer, totalPrice); + assertBalERC20Eq(address(erc20), seller, 0); + + // Don't approve marketplace to transfer currency + vm.prank(buyer); + erc20.increaseAllowance(marketplace, totalPrice); + + // Buy tokens from listing. + vm.warp(listing.startTimestamp); + vm.prank(buyer); + vm.expectRevert("Buying invalid quantity"); + DirectListingsLogic(marketplace).buyFromListing(listingId, buyFor, quantityToBuy, currency, totalPrice); + } + + function test_revert_buyFromListing_buyingMoreQuantityThanListed() public { + (uint256 listingId, IDirectListings.Listing memory listing) = _setup_buyFromListing(); + + address buyFor = buyer; + uint256 quantityToBuy = listing.quantity + 1; // Buying more than listed. + address currency = listing.currency; + uint256 pricePerToken = listing.pricePerToken; + uint256 totalPrice = pricePerToken * quantityToBuy; + + // Seller approves buyer for listing + vm.prank(seller); + DirectListingsLogic(marketplace).approveBuyerForListing(listingId, buyer, true); + + // Verify that seller is owner of listed tokens, pre-sale. + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = 0; + assertIsOwnerERC721(address(erc721), seller, tokenIds); + assertIsNotOwnerERC721(address(erc721), buyer, tokenIds); + + // Mint requisite total price to buyer. + erc20.mint(buyer, totalPrice); + assertBalERC20Eq(address(erc20), buyer, totalPrice); + assertBalERC20Eq(address(erc20), seller, 0); + + // Don't approve marketplace to transfer currency + vm.prank(buyer); + erc20.increaseAllowance(marketplace, totalPrice); + + // Buy tokens from listing. + vm.warp(listing.startTimestamp); + vm.prank(buyer); + vm.expectRevert("Buying invalid quantity"); + DirectListingsLogic(marketplace).buyFromListing(listingId, buyFor, quantityToBuy, currency, totalPrice); + } + + /*/////////////////////////////////////////////////////////////// + View functions + //////////////////////////////////////////////////////////////*/ + + function _createListing(address _seller) private returns (uint256 listingId) { + // Sample listing parameters. + address assetContract = address(erc721); + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = address(erc20); + uint256 pricePerToken = 1 ether; + uint128 startTimestamp = 100; + uint128 endTimestamp = 200; + bool reserved = true; + + // Mint the ERC721 tokens to seller. These tokens will be listed. + _setupERC721BalanceForSeller(_seller, 1); + + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = tokenId; + assertIsOwnerERC721(address(erc721), _seller, tokenIds); + + // Approve Marketplace to transfer token. + vm.prank(_seller); + erc721.setApprovalForAll(marketplace, true); + + // List tokens. + IDirectListings.ListingParameters memory listingParams = IDirectListings.ListingParameters( + assetContract, + tokenId, + quantity, + currency, + pricePerToken, + startTimestamp, + endTimestamp, + reserved + ); + + vm.prank(_seller); + listingId = DirectListingsLogic(marketplace).createListing(listingParams); + } + + function test_audit_native_tokens_locked() public { + (uint256 listingId, IDirectListings.Listing memory existingListing) = _setup_buyFromListing(); + + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = existingListing.tokenId; + + // Verify existing auction at `auctionId` + assertEq(existingListing.assetContract, address(erc721)); + + vm.warp(existingListing.startTimestamp); + + // No ether is locked in contract + assertEq(marketplace.balance, 0); + + // buy from listing + erc20.mint(buyer, 10 ether); + vm.deal(buyer, 1 ether); + + vm.prank(seller); + DirectListingsLogic(marketplace).approveBuyerForListing(listingId, buyer, true); + + vm.startPrank(buyer); + erc20.approve(marketplace, 10 ether); + + vm.expectRevert("Marketplace: invalid native tokens sent."); + DirectListingsLogic(marketplace).buyFromListing{ value: 1 ether }(listingId, buyer, 1, address(erc20), 1 ether); + vm.stopPrank(); + + // 1 ether is temporary locked in contract + assertEq(marketplace.balance, 0 ether); + } +} + +contract IssueC2_MarketplaceDirectListingsTest is BaseTest, IExtension { + // Target contract + address public marketplace; + + // Participants + address public marketplaceDeployer; + address public seller; + address public buyer; + + function setUp() public override { + super.setUp(); + + marketplaceDeployer = getActor(1); + seller = getActor(2); + buyer = getActor(3); + + // Deploy implementation. + Extension[] memory extensions = _setupExtensions(); + address impl = address( + new MarketplaceV3(MarketplaceV3.MarketplaceConstructorParams(extensions, address(0), address(weth))) + ); + + vm.prank(marketplaceDeployer); + marketplace = address( + new TWProxy( + impl, + abi.encodeCall( + MarketplaceV3.initialize, + (marketplaceDeployer, "", new address[](0), marketplaceDeployer, 0) + ) + ) + ); + + // Setup roles for seller and assets + vm.startPrank(marketplaceDeployer); + Permissions(marketplace).revokeRole(keccak256("ASSET_ROLE"), address(0)); + Permissions(marketplace).revokeRole(keccak256("LISTER_ROLE"), address(0)); + Permissions(marketplace).grantRole(keccak256("LISTER_ROLE"), seller); + Permissions(marketplace).grantRole(keccak256("ASSET_ROLE"), address(erc721)); + Permissions(marketplace).grantRole(keccak256("ASSET_ROLE"), address(erc1155)); + + vm.stopPrank(); + + vm.label(impl, "MarketplaceV3_Impl"); + vm.label(marketplace, "Marketplace"); + vm.label(seller, "Seller"); + vm.label(buyer, "Buyer"); + vm.label(address(erc721), "ERC721_Token"); + vm.label(address(erc1155), "ERC1155_Token"); + } + + function _setupExtensions() internal returns (Extension[] memory extensions) { + extensions = new Extension[](1); + + // Deploy `DirectListings` + address directListings = address(new DirectListingsLogic(address(weth))); + vm.label(directListings, "DirectListings_Extension"); + + // Extension: DirectListingsLogic + Extension memory extension_directListings; + extension_directListings.metadata = ExtensionMetadata({ + name: "DirectListingsLogic", + metadataURI: "ipfs://DirectListings", + implementation: directListings + }); + + extension_directListings.functions = new ExtensionFunction[](13); + extension_directListings.functions[0] = ExtensionFunction( + DirectListingsLogic.totalListings.selector, + "totalListings()" + ); + extension_directListings.functions[1] = ExtensionFunction( + DirectListingsLogic.isBuyerApprovedForListing.selector, + "isBuyerApprovedForListing(uint256,address)" + ); + extension_directListings.functions[2] = ExtensionFunction( + DirectListingsLogic.isCurrencyApprovedForListing.selector, + "isCurrencyApprovedForListing(uint256,address)" + ); + extension_directListings.functions[3] = ExtensionFunction( + DirectListingsLogic.currencyPriceForListing.selector, + "currencyPriceForListing(uint256,address)" + ); + extension_directListings.functions[4] = ExtensionFunction( + DirectListingsLogic.createListing.selector, + "createListing((address,uint256,uint256,address,uint256,uint128,uint128,bool))" + ); + extension_directListings.functions[5] = ExtensionFunction( + DirectListingsLogic.updateListing.selector, + "updateListing(uint256,(address,uint256,uint256,address,uint256,uint128,uint128,bool))" + ); + extension_directListings.functions[6] = ExtensionFunction( + DirectListingsLogic.cancelListing.selector, + "cancelListing(uint256)" + ); + extension_directListings.functions[7] = ExtensionFunction( + DirectListingsLogic.approveBuyerForListing.selector, + "approveBuyerForListing(uint256,address,bool)" + ); + extension_directListings.functions[8] = ExtensionFunction( + DirectListingsLogic.approveCurrencyForListing.selector, + "approveCurrencyForListing(uint256,address,uint256)" + ); + extension_directListings.functions[9] = ExtensionFunction( + DirectListingsLogic.buyFromListing.selector, + "buyFromListing(uint256,address,uint256,address,uint256)" + ); + extension_directListings.functions[10] = ExtensionFunction( + DirectListingsLogic.getAllListings.selector, + "getAllListings(uint256,uint256)" + ); + extension_directListings.functions[11] = ExtensionFunction( + DirectListingsLogic.getAllValidListings.selector, + "getAllValidListings(uint256,uint256)" + ); + extension_directListings.functions[12] = ExtensionFunction( + DirectListingsLogic.getListing.selector, + "getListing(uint256)" + ); + + extensions[0] = extension_directListings; + } + + function _setupERC721BalanceForSeller(address _seller, uint256 _numOfTokens) private { + erc721.mint(_seller, _numOfTokens); + } + + function _setup_updateListing() + private + returns (uint256 listingId, IDirectListings.ListingParameters memory listingParams) + { + // Sample listing parameters. + address assetContract = address(erc721); + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = address(erc20); + uint256 pricePerToken = 1 ether; + uint128 startTimestamp = 100; + uint128 endTimestamp = 200; + bool reserved = true; + + // Mint the ERC721 tokens to seller. These tokens will be listed. + _setupERC721BalanceForSeller(seller, 1); + + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = tokenId; + assertIsOwnerERC721(address(erc721), seller, tokenIds); + + // Approve Marketplace to transfer token. + vm.prank(seller); + erc721.setApprovalForAll(marketplace, true); + + // List tokens. + listingParams = IDirectListings.ListingParameters( + assetContract, + tokenId, + quantity, + currency, + pricePerToken, + startTimestamp, + endTimestamp, + reserved + ); + + vm.prank(seller); + listingId = DirectListingsLogic(marketplace).createListing(listingParams); + } + + function _setup_buyFromListing() private returns (uint256 listingId, IDirectListings.Listing memory listing) { + (listingId, ) = _setup_updateListing(); + listing = DirectListingsLogic(marketplace).getListing(listingId); + } + + function test_state_buyFromListing_after_update() public { + (uint256 listingId, IDirectListings.Listing memory listing) = _setup_buyFromListing(); + + uint256 quantityToBuy = listing.quantity; + uint256 pricePerToken = listing.pricePerToken; + uint256 totalPrice = pricePerToken * quantityToBuy; + + // Seller approves buyer for listing + vm.prank(seller); + DirectListingsLogic(marketplace).approveBuyerForListing(listingId, buyer, true); + + // Verify that seller is owner of listed tokens, pre-sale. + // This token (Id = 0) was created in the above _setup_buyFromListing + uint256[] memory expectedTokenIds = new uint256[](1); + expectedTokenIds[0] = 0; + assertIsOwnerERC721(address(erc721), seller, expectedTokenIds); + assertIsNotOwnerERC721(address(erc721), buyer, expectedTokenIds); + + // Mint a new token. This is token we will "swap out" via updateListing + // It should be tokenId of 1 + _setupERC721BalanceForSeller(seller, 1); + + // Verify that seller is owner of new token, pre-sale. + uint256[] memory swappedTokenIds = new uint256[](1); + swappedTokenIds[0] = 1; + assertIsOwnerERC721(address(erc721), seller, swappedTokenIds); + assertIsNotOwnerERC721(address(erc721), buyer, swappedTokenIds); + + // Mint requisite total price to buyer. + erc20.mint(buyer, totalPrice); + assertBalERC20Eq(address(erc20), buyer, totalPrice); + assertBalERC20Eq(address(erc20), seller, 0); + + // Approve marketplace to transfer currency + vm.prank(buyer); + erc20.increaseAllowance(marketplace, totalPrice); + + vm.prank(seller); + erc721.setApprovalForAll(marketplace, true); + + // Create ListingParameters with new tokenId (1) and update + IDirectListings.ListingParameters memory listingParams = IDirectListings.ListingParameters( + address(erc721), + 1, + 1, + address(erc20), + 1 ether, + 100, + 200, + true + ); + vm.prank(seller); + vm.expectRevert("Marketplace: cannot update what token is listed."); + DirectListingsLogic(marketplace).updateListing(listingId, listingParams); + + // Buy listing + // vm.warp(listing.startTimestamp); + // vm.prank(buyer); + // DirectListingsLogic(marketplace).buyFromListing(listingId, buyFor, quantityToBuy, currency, totalPrice); + + // // Buyer is owner of the swapped out token (tokenId = 1) and not the expected (tokenId = 0) + // assertIsOwnerERC721(address(erc721), buyer, swappedTokenIds); + // assertIsNotOwnerERC721(address(erc721), buyer, expectedTokenIds); + + // // Verify seller is paid total price. + // assertBalERC20Eq(address(erc20), buyer, 0); + // assertBalERC20Eq(address(erc20), seller, totalPrice); + } +} diff --git a/src/test/marketplace/EnglishAuctions.t.sol b/src/test/marketplace/EnglishAuctions.t.sol new file mode 100644 index 000000000..ac3a07581 --- /dev/null +++ b/src/test/marketplace/EnglishAuctions.t.sol @@ -0,0 +1,2716 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +// Test helper imports +import "../utils/BaseTest.sol"; + +// Test contracts and interfaces +import { PluginMap, IPluginMap } from "contracts/extension/plugin/PluginMap.sol"; +import { RoyaltyPaymentsLogic } from "contracts/extension/plugin/RoyaltyPayments.sol"; +import { MarketplaceV3, IPlatformFee } from "contracts/prebuilts/marketplace/entrypoint/MarketplaceV3.sol"; +import { EnglishAuctionsLogic } from "contracts/prebuilts/marketplace/english-auctions/EnglishAuctionsLogic.sol"; +import { TWProxy } from "contracts/infra/TWProxy.sol"; +import { ERC721Base } from "contracts/base/ERC721Base.sol"; +import { MockRoyaltyEngineV1 } from "../mocks/MockRoyaltyEngineV1.sol"; + +import { IEnglishAuctions } from "contracts/prebuilts/marketplace/IMarketplace.sol"; + +import "@thirdweb-dev/dynamic-contracts/src/interface/IExtension.sol"; + +contract MarketplaceEnglishAuctionsTest is BaseTest, IExtension { + // Target contract + address public marketplace; + + // Participants + address public marketplaceDeployer; + address public seller; + address public buyer; + + function setUp() public override { + super.setUp(); + + marketplaceDeployer = getActor(1); + seller = getActor(2); + buyer = getActor(3); + + // Deploy implementation. + Extension[] memory extensions = _setupExtensions(); + address impl = address( + new MarketplaceV3(MarketplaceV3.MarketplaceConstructorParams(extensions, address(0), address(weth))) + ); + + vm.prank(marketplaceDeployer); + marketplace = address( + new TWProxy( + impl, + abi.encodeCall( + MarketplaceV3.initialize, + (marketplaceDeployer, "", new address[](0), marketplaceDeployer, 0) + ) + ) + ); + + // Setup roles for seller and assets + vm.startPrank(marketplaceDeployer); + Permissions(marketplace).revokeRole(keccak256("ASSET_ROLE"), address(0)); + Permissions(marketplace).revokeRole(keccak256("LISTER_ROLE"), address(0)); + Permissions(marketplace).grantRole(keccak256("LISTER_ROLE"), seller); + Permissions(marketplace).grantRole(keccak256("ASSET_ROLE"), address(erc721)); + Permissions(marketplace).grantRole(keccak256("ASSET_ROLE"), address(erc1155)); + + vm.stopPrank(); + + vm.label(impl, "MarketplaceV3_Impl"); + vm.label(marketplace, "Marketplace"); + vm.label(seller, "Seller"); + vm.label(buyer, "Buyer"); + vm.label(address(erc721), "ERC721_Token"); + vm.label(address(erc1155), "ERC1155_Token"); + } + + function _setupExtensions() internal returns (Extension[] memory extensions) { + extensions = new Extension[](1); + + // Deploy `EnglishAuctions` + address englishAuctions = address(new EnglishAuctionsLogic(address(weth))); + vm.label(englishAuctions, "EnglishAuctions_Extension"); + + // Extension: EnglishAuctionsLogic + Extension memory extension_englishAuctions; + extension_englishAuctions.metadata = ExtensionMetadata({ + name: "EnglishAuctionsLogic", + metadataURI: "ipfs://EnglishAuctions", + implementation: englishAuctions + }); + + extension_englishAuctions.functions = new ExtensionFunction[](12); + extension_englishAuctions.functions[0] = ExtensionFunction( + EnglishAuctionsLogic.totalAuctions.selector, + "totalAuctions()" + ); + extension_englishAuctions.functions[1] = ExtensionFunction( + EnglishAuctionsLogic.createAuction.selector, + "createAuction((address,uint256,uint256,address,uint256,uint256,uint64,uint64,uint64,uint64))" + ); + extension_englishAuctions.functions[2] = ExtensionFunction( + EnglishAuctionsLogic.cancelAuction.selector, + "cancelAuction(uint256)" + ); + extension_englishAuctions.functions[3] = ExtensionFunction( + EnglishAuctionsLogic.collectAuctionPayout.selector, + "collectAuctionPayout(uint256)" + ); + extension_englishAuctions.functions[4] = ExtensionFunction( + EnglishAuctionsLogic.collectAuctionTokens.selector, + "collectAuctionTokens(uint256)" + ); + extension_englishAuctions.functions[5] = ExtensionFunction( + EnglishAuctionsLogic.bidInAuction.selector, + "bidInAuction(uint256,uint256)" + ); + extension_englishAuctions.functions[6] = ExtensionFunction( + EnglishAuctionsLogic.isNewWinningBid.selector, + "isNewWinningBid(uint256,uint256)" + ); + extension_englishAuctions.functions[7] = ExtensionFunction( + EnglishAuctionsLogic.getAuction.selector, + "getAuction(uint256)" + ); + extension_englishAuctions.functions[8] = ExtensionFunction( + EnglishAuctionsLogic.getAllAuctions.selector, + "getAllAuctions(uint256,uint256)" + ); + extension_englishAuctions.functions[9] = ExtensionFunction( + EnglishAuctionsLogic.getAllValidAuctions.selector, + "getAllValidAuctions(uint256,uint256)" + ); + extension_englishAuctions.functions[10] = ExtensionFunction( + EnglishAuctionsLogic.getWinningBid.selector, + "getWinningBid(uint256)" + ); + extension_englishAuctions.functions[11] = ExtensionFunction( + EnglishAuctionsLogic.isAuctionExpired.selector, + "isAuctionExpired(uint256)" + ); + + extensions[0] = extension_englishAuctions; + } + + function _setupERC721BalanceForSeller(address _seller, uint256 _numOfTokens) private { + erc721.mint(_seller, _numOfTokens); + } + + function test_state_initial() public { + uint256 totoalAuctions = EnglishAuctionsLogic(marketplace).totalAuctions(); + assertEq(totoalAuctions, 0); + } + + /*/////////////////////////////////////////////////////////////// + Royalty Tests (incl Royalty Engine / Registry) + //////////////////////////////////////////////////////////////*/ + + function _setupRoyaltyEngine() + private + returns ( + MockRoyaltyEngineV1 royaltyEngine, + address payable[] memory mockRecipients, + uint256[] memory mockAmounts + ) + { + mockRecipients = new address payable[](2); + mockAmounts = new uint256[](2); + + mockRecipients[0] = payable(address(0x12345)); + mockRecipients[1] = payable(address(0x56789)); + + mockAmounts[0] = 10; + mockAmounts[1] = 15; + + royaltyEngine = new MockRoyaltyEngineV1(mockRecipients, mockAmounts); + } + + function _setupAuctionForRoyaltyTests(address erc721TokenAddress) private returns (uint256 auctionId) { + // Sample auction parameters. + address assetContract = erc721TokenAddress; + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = address(erc20); + uint256 minimumBidAmount = 1 ether; + uint256 buyoutBidAmount = 10 ether; + uint64 timeBufferInSeconds = 10 seconds; + uint64 bidBufferBps = 1000; + uint64 startTimestamp = 100; + uint64 endTimestamp = 200; + + // Approve Marketplace to transfer token. + vm.prank(seller); + IERC721(erc721TokenAddress).setApprovalForAll(marketplace, true); + + // Auction tokens. + IEnglishAuctions.AuctionParameters memory auctionParams = IEnglishAuctions.AuctionParameters( + assetContract, + tokenId, + quantity, + currency, + minimumBidAmount, + buyoutBidAmount, + timeBufferInSeconds, + bidBufferBps, + startTimestamp, + endTimestamp + ); + + vm.prank(seller); + auctionId = EnglishAuctionsLogic(marketplace).createAuction(auctionParams); + } + + function _buyoutAuctionForRoyaltyTests(uint256 auctionId) private returns (uint256 buyoutAmount) { + IEnglishAuctions.Auction memory existingAuction = EnglishAuctionsLogic(marketplace).getAuction(auctionId); + + buyoutAmount = existingAuction.buyoutBidAmount; + + // Mint requisite total price to buyer. + erc20.mint(buyer, buyoutAmount); + + // Approve marketplace to transfer currency + vm.prank(buyer); + erc20.approve(marketplace, buyoutAmount); + + // Place buyout bid in auction. + vm.warp(existingAuction.startTimestamp); + vm.prank(buyer); + EnglishAuctionsLogic(marketplace).bidInAuction(auctionId, buyoutAmount); + } + + function test_royaltyEngine_tokenWithCustomRoyalties() public { + ( + MockRoyaltyEngineV1 royaltyEngine, + address payable[] memory customRoyaltyRecipients, + uint256[] memory customRoyaltyAmounts + ) = _setupRoyaltyEngine(); + + // Add RoyaltyEngine to marketplace + vm.prank(marketplaceDeployer); + RoyaltyPaymentsLogic(marketplace).setRoyaltyEngine(address(royaltyEngine)); + + assertEq(RoyaltyPaymentsLogic(marketplace).getRoyaltyEngineAddress(), address(royaltyEngine)); + + // 1. ========= Create auction ========= + + // Mint the ERC721 tokens to seller. These tokens will be auctioned. + _setupERC721BalanceForSeller(seller, 1); + uint256 auctionId = _setupAuctionForRoyaltyTests(address(erc721)); + + // 2. ========= Bid in auction ========= + + uint256 buyoutAmount = _buyoutAuctionForRoyaltyTests(auctionId); + + // 3. ========= Seller collects auction payout + + vm.prank(seller); + EnglishAuctionsLogic(marketplace).collectAuctionPayout(auctionId); + + // 4. ======== Check balances after royalty payments ======== + + { + // Royalty recipients receive correct amounts + assertBalERC20Eq(address(erc20), customRoyaltyRecipients[0], customRoyaltyAmounts[0]); + assertBalERC20Eq(address(erc20), customRoyaltyRecipients[1], customRoyaltyAmounts[1]); + + // Seller gets total price minus royalty amounts + assertBalERC20Eq(address(erc20), seller, buyoutAmount - customRoyaltyAmounts[0] - customRoyaltyAmounts[1]); + } + } + + function test_royaltyEngine_tokenWithERC2981() public { + (MockRoyaltyEngineV1 royaltyEngine, , ) = _setupRoyaltyEngine(); + + // Add RoyaltyEngine to marketplace + vm.prank(marketplaceDeployer); + RoyaltyPaymentsLogic(marketplace).setRoyaltyEngine(address(royaltyEngine)); + + assertEq(RoyaltyPaymentsLogic(marketplace).getRoyaltyEngineAddress(), address(royaltyEngine)); + + // create token with ERC2981 + address royaltyRecipient = address(0x12345); + uint128 royaltyBps = 10; + ERC721Base nft2981 = new ERC721Base(address(0x12345), "NFT 2981", "NFT2981", royaltyRecipient, royaltyBps); + // Mint the ERC721 tokens to seller. These tokens will be listed. + vm.prank(address(0x12345)); + nft2981.mintTo(seller, ""); + + vm.prank(marketplaceDeployer); + Permissions(marketplace).grantRole(keccak256("ASSET_ROLE"), address(nft2981)); + + // 1. ========= Create auction ========= + + uint256 auctionId = _setupAuctionForRoyaltyTests(address(nft2981)); + + // 2. ========= Bid in auction ========= + + uint256 buyoutAmount = _buyoutAuctionForRoyaltyTests(auctionId); + + // 3. ========= Seller collects auction payout + + vm.prank(seller); + EnglishAuctionsLogic(marketplace).collectAuctionPayout(auctionId); + + // 4. ======== Check balances after royalty payments ======== + + { + uint256 royaltyAmount = (royaltyBps * buyoutAmount) / 10_000; + // Royalty recipient receives correct amounts + assertBalERC20Eq(address(erc20), royaltyRecipient, royaltyAmount); + + // Seller gets total price minus royalty amount + assertBalERC20Eq(address(erc20), seller, buyoutAmount - royaltyAmount); + } + } + + function test_noRoyaltyEngine_defaultERC2981Token() public { + // create token with ERC2981 + address royaltyRecipient = address(0x12345); + uint128 royaltyBps = 10; + ERC721Base nft2981 = new ERC721Base(address(0x12345), "NFT 2981", "NFT2981", royaltyRecipient, royaltyBps); + vm.prank(address(0x12345)); + nft2981.mintTo(seller, ""); + + vm.prank(marketplaceDeployer); + Permissions(marketplace).grantRole(keccak256("ASSET_ROLE"), address(nft2981)); + + // 1. ========= Create auction ========= + + uint256 auctionId = _setupAuctionForRoyaltyTests(address(nft2981)); + + // 2. ========= Bid in auction ========= + + uint256 buyoutAmount = _buyoutAuctionForRoyaltyTests(auctionId); + + // 3. ========= Seller collects auction payout + + vm.prank(seller); + EnglishAuctionsLogic(marketplace).collectAuctionPayout(auctionId); + + // 4. ======== Check balances after royalty payments ======== + + { + uint256 royaltyAmount = (royaltyBps * buyoutAmount) / 10_000; + // Royalty recipient receives correct amounts + assertBalERC20Eq(address(erc20), royaltyRecipient, royaltyAmount); + + // Seller gets total price minus royalty amount + assertBalERC20Eq(address(erc20), seller, buyoutAmount - royaltyAmount); + } + } + + function test_royaltyEngine_correctlyDistributeAllFees() public { + ( + MockRoyaltyEngineV1 royaltyEngine, + address payable[] memory customRoyaltyRecipients, + uint256[] memory customRoyaltyAmounts + ) = _setupRoyaltyEngine(); + + // Add RoyaltyEngine to marketplace + vm.prank(marketplaceDeployer); + RoyaltyPaymentsLogic(marketplace).setRoyaltyEngine(address(royaltyEngine)); + + assertEq(RoyaltyPaymentsLogic(marketplace).getRoyaltyEngineAddress(), address(royaltyEngine)); + + // Set platform fee on marketplace + address platformFeeRecipient = marketplaceDeployer; + uint128 platformFeeBps = 5; + vm.prank(marketplaceDeployer); + IPlatformFee(marketplace).setPlatformFeeInfo(platformFeeRecipient, platformFeeBps); + + // 1. ========= Create auction ========= + + // Mint the ERC721 tokens to seller. These tokens will be auctioned. + _setupERC721BalanceForSeller(seller, 1); + uint256 auctionId = _setupAuctionForRoyaltyTests(address(erc721)); + + // 2. ========= Bid in auction ========= + + uint256 buyoutAmount = _buyoutAuctionForRoyaltyTests(auctionId); + + // 3. ========= Seller collects auction payout + + vm.prank(seller); + EnglishAuctionsLogic(marketplace).collectAuctionPayout(auctionId); + + // 4. ======== Check balances after royalty payments ======== + + { + // Royalty recipients receive correct amounts + assertBalERC20Eq(address(erc20), customRoyaltyRecipients[0], customRoyaltyAmounts[0]); + assertBalERC20Eq(address(erc20), customRoyaltyRecipients[1], customRoyaltyAmounts[1]); + + // Platform fee recipient + uint256 platformFeeAmount = (platformFeeBps * buyoutAmount) / 10_000; + assertBalERC20Eq(address(erc20), platformFeeRecipient, platformFeeAmount); + + // Seller gets total price minus royalty amounts + assertBalERC20Eq( + address(erc20), + seller, + buyoutAmount - customRoyaltyAmounts[0] - customRoyaltyAmounts[1] - platformFeeAmount + ); + } + } + + function test_revert_feesExceedTotalPrice() public { + (MockRoyaltyEngineV1 royaltyEngine, , ) = _setupRoyaltyEngine(); + + // Add RoyaltyEngine to marketplace + vm.prank(marketplaceDeployer); + RoyaltyPaymentsLogic(marketplace).setRoyaltyEngine(address(royaltyEngine)); + + assertEq(RoyaltyPaymentsLogic(marketplace).getRoyaltyEngineAddress(), address(royaltyEngine)); + + // Set platform fee on marketplace + address platformFeeRecipient = marketplaceDeployer; + uint128 platformFeeBps = 10_000; // equal to max bps 10_000 or 100% + vm.prank(marketplaceDeployer); + IPlatformFee(marketplace).setPlatformFeeInfo(platformFeeRecipient, platformFeeBps); + + // 1. ========= Create auction ========= + + _setupERC721BalanceForSeller(seller, 1); + uint256 auctionId = _setupAuctionForRoyaltyTests(address(erc721)); + + // 2. ========= Bid in auction ========= + + IEnglishAuctions.Auction memory auction = EnglishAuctionsLogic(marketplace).getAuction(auctionId); + + uint256 buyoutAmount = auction.buyoutBidAmount; + + // Mint requisite total price to buyer. + erc20.mint(buyer, buyoutAmount); + + // Approve marketplace to transfer currency + vm.prank(buyer); + erc20.increaseAllowance(marketplace, buyoutAmount); + + // Buy tokens from auction. + vm.warp(auction.startTimestamp); + + vm.prank(buyer); + EnglishAuctionsLogic(marketplace).bidInAuction(auctionId, buyoutAmount); + + // 3. ========= Seller collects auction payout + + vm.expectRevert("fees exceed the price"); + vm.prank(seller); + EnglishAuctionsLogic(marketplace).collectAuctionPayout(auctionId); + } + + /*/////////////////////////////////////////////////////////////// + Create Auction + //////////////////////////////////////////////////////////////*/ + + function test_state_createAuction() public { + // Sample auction parameters. + address assetContract = address(erc721); + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = address(erc20); + uint256 minimumBidAmount = 1 ether; + uint256 buyoutBidAmount = 10 ether; + uint64 timeBufferInSeconds = 10 seconds; + uint64 bidBufferBps = 1000; + uint64 startTimestamp = 100; + uint64 endTimestamp = 200; + + // Mint the ERC721 tokens to seller. These tokens will be auctioned. + _setupERC721BalanceForSeller(seller, 1); + + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = tokenId; + assertIsOwnerERC721(address(erc721), seller, tokenIds); + + // Approve Marketplace to transfer token. + vm.prank(seller); + erc721.setApprovalForAll(marketplace, true); + + // Auction tokens. + IEnglishAuctions.AuctionParameters memory auctionParams = IEnglishAuctions.AuctionParameters( + assetContract, + tokenId, + quantity, + currency, + minimumBidAmount, + buyoutBidAmount, + timeBufferInSeconds, + bidBufferBps, + startTimestamp, + endTimestamp + ); + + vm.prank(seller); + uint256 auctionId = EnglishAuctionsLogic(marketplace).createAuction(auctionParams); + + // Test consequent state of the contract. + + // Marketplace is owner of token. + assertIsOwnerERC721(address(erc721), marketplace, tokenIds); + + // Total listings incremented + assertEq(EnglishAuctionsLogic(marketplace).totalAuctions(), 1); + + // Fetch listing and verify state. + IEnglishAuctions.Auction memory auction = EnglishAuctionsLogic(marketplace).getAuction(auctionId); + + assertEq(auction.auctionId, auctionId); + assertEq(auction.auctionCreator, seller); + assertEq(auction.assetContract, assetContract); + assertEq(auction.tokenId, tokenId); + assertEq(auction.quantity, quantity); + assertEq(auction.currency, currency); + assertEq(auction.minimumBidAmount, minimumBidAmount); + assertEq(auction.buyoutBidAmount, buyoutBidAmount); + assertEq(auction.timeBufferInSeconds, timeBufferInSeconds); + assertEq(auction.bidBufferBps, bidBufferBps); + assertEq(auction.startTimestamp, startTimestamp); + assertEq(auction.endTimestamp, endTimestamp); + assertEq(uint256(auction.tokenType), uint256(IEnglishAuctions.TokenType.ERC721)); + } + + function test_revert_createAuction_notOwnerOfAuctionedToken() public { + // Sample auction parameters. + address assetContract = address(erc721); + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = address(erc20); + uint256 minimumBidAmount = 1 ether; + uint256 buyoutBidAmount = 10 ether; + uint64 timeBufferInSeconds = 10 seconds; + uint64 bidBufferBps = 1000; + uint64 startTimestamp = 100; + uint64 endTimestamp = 200; + + // Don't mint to 'token to be auctioned' to the seller. + address someWallet = getActor(1000); + _setupERC721BalanceForSeller(someWallet, 1); + + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = tokenId; + assertIsOwnerERC721(address(erc721), someWallet, tokenIds); + assertIsNotOwnerERC721(address(erc721), seller, tokenIds); + + // Approve Marketplace to transfer token. + vm.prank(someWallet); + erc721.setApprovalForAll(marketplace, true); + + // Auction tokens. + IEnglishAuctions.AuctionParameters memory auctionParams = IEnglishAuctions.AuctionParameters( + assetContract, + tokenId, + quantity, + currency, + minimumBidAmount, + buyoutBidAmount, + timeBufferInSeconds, + bidBufferBps, + startTimestamp, + endTimestamp + ); + + vm.prank(seller); + vm.expectRevert("ERC721: transfer from incorrect owner"); + EnglishAuctionsLogic(marketplace).createAuction(auctionParams); + } + + function test_revert_createAuction_notApprovedMarketplaceToTransferToken() public { + // Sample auction parameters. + address assetContract = address(erc721); + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = address(erc20); + uint256 minimumBidAmount = 1 ether; + uint256 buyoutBidAmount = 10 ether; + uint64 timeBufferInSeconds = 10 seconds; + uint64 bidBufferBps = 1000; + uint64 startTimestamp = 100; + uint64 endTimestamp = 200; + + // Mint the ERC721 tokens to seller. These tokens will be auctioned. + _setupERC721BalanceForSeller(seller, 1); + + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = tokenId; + assertIsOwnerERC721(address(erc721), seller, tokenIds); + + // Don't approve Marketplace to transfer token. + vm.prank(seller); + erc721.setApprovalForAll(marketplace, false); + + // Auction tokens. + IEnglishAuctions.AuctionParameters memory auctionParams = IEnglishAuctions.AuctionParameters( + assetContract, + tokenId, + quantity, + currency, + minimumBidAmount, + buyoutBidAmount, + timeBufferInSeconds, + bidBufferBps, + startTimestamp, + endTimestamp + ); + + vm.prank(seller); + vm.expectRevert("ERC721: caller is not token owner or approved"); + EnglishAuctionsLogic(marketplace).createAuction(auctionParams); + } + + function test_revert_createAuction_auctioningZeroQuantity() public { + // Sample auction parameters. + address assetContract = address(erc721); + uint256 tokenId = 0; + uint256 quantity = 0; + address currency = address(erc20); + uint256 minimumBidAmount = 1 ether; + uint256 buyoutBidAmount = 10 ether; + uint64 timeBufferInSeconds = 10 seconds; + uint64 bidBufferBps = 1000; + uint64 startTimestamp = 100; + uint64 endTimestamp = 200; + + // Mint the ERC721 tokens to seller. These tokens will be auctioned. + _setupERC721BalanceForSeller(seller, 1); + + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = tokenId; + assertIsOwnerERC721(address(erc721), seller, tokenIds); + + // Approve Marketplace to transfer token. + vm.prank(seller); + erc721.setApprovalForAll(marketplace, true); + + // Auction tokens. + IEnglishAuctions.AuctionParameters memory auctionParams = IEnglishAuctions.AuctionParameters( + assetContract, + tokenId, + quantity, + currency, + minimumBidAmount, + buyoutBidAmount, + timeBufferInSeconds, + bidBufferBps, + startTimestamp, + endTimestamp + ); + + vm.prank(seller); + vm.expectRevert("Marketplace: auctioning zero quantity."); + EnglishAuctionsLogic(marketplace).createAuction(auctionParams); + } + + function test_revert_createAuction_invalidQuantity() public { + // Sample auction parameters. + address assetContract = address(erc721); + uint256 tokenId = 0; + uint256 quantity = 2; // Listing more than `1` quantity + address currency = address(erc20); + uint256 minimumBidAmount = 1 ether; + uint256 buyoutBidAmount = 10 ether; + uint64 timeBufferInSeconds = 10 seconds; + uint64 bidBufferBps = 1000; + uint64 startTimestamp = 100; + uint64 endTimestamp = 200; + + // Mint the ERC721 tokens to seller. These tokens will be auctioned. + _setupERC721BalanceForSeller(seller, 1); + + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = tokenId; + assertIsOwnerERC721(address(erc721), seller, tokenIds); + + // Approve Marketplace to transfer token. + vm.prank(seller); + erc721.setApprovalForAll(marketplace, true); + + // Auction tokens. + IEnglishAuctions.AuctionParameters memory auctionParams = IEnglishAuctions.AuctionParameters( + assetContract, + tokenId, + quantity, + currency, + minimumBidAmount, + buyoutBidAmount, + timeBufferInSeconds, + bidBufferBps, + startTimestamp, + endTimestamp + ); + + vm.prank(seller); + vm.expectRevert("Marketplace: auctioning invalid quantity."); + EnglishAuctionsLogic(marketplace).createAuction(auctionParams); + } + + function test_revert_createAuction_noBidOrTimeBuffer() public { + // Sample auction parameters. + address assetContract = address(erc721); + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = address(erc20); + uint256 minimumBidAmount = 1 ether; + uint256 buyoutBidAmount = 10 ether; + uint64 timeBufferInSeconds = 0; + uint64 bidBufferBps = 1000; + uint64 startTimestamp = 100; + uint64 endTimestamp = 200; + + // Mint the ERC721 tokens to seller. These tokens will be auctioned. + _setupERC721BalanceForSeller(seller, 1); + + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = tokenId; + assertIsOwnerERC721(address(erc721), seller, tokenIds); + + // Approve Marketplace to transfer token. + vm.prank(seller); + erc721.setApprovalForAll(marketplace, true); + + // Auction tokens. + IEnglishAuctions.AuctionParameters memory auctionParams = IEnglishAuctions.AuctionParameters( + assetContract, + tokenId, + quantity, + currency, + minimumBidAmount, + buyoutBidAmount, + timeBufferInSeconds, + bidBufferBps, + startTimestamp, + endTimestamp + ); + + vm.prank(seller); + vm.expectRevert("Marketplace: no time-buffer."); + EnglishAuctionsLogic(marketplace).createAuction(auctionParams); + + timeBufferInSeconds = 10 seconds; + bidBufferBps = 0; + + auctionParams = IEnglishAuctions.AuctionParameters( + assetContract, + tokenId, + quantity, + currency, + minimumBidAmount, + buyoutBidAmount, + timeBufferInSeconds, + bidBufferBps, + startTimestamp, + endTimestamp + ); + + vm.prank(seller); + vm.expectRevert("Marketplace: no bid-buffer."); + EnglishAuctionsLogic(marketplace).createAuction(auctionParams); + } + + function test_revert_createAuction_invalidBidAmounts() public { + // Sample auction parameters. + address assetContract = address(erc721); + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = address(erc20); + uint256 minimumBidAmount = 10 ether; // set minimumBidAmount greater than buyoutBidAmount + uint256 buyoutBidAmount = 1 ether; + uint64 timeBufferInSeconds = 10 seconds; + uint64 bidBufferBps = 1000; + uint64 startTimestamp = 100; + uint64 endTimestamp = 200; + + // Mint the ERC721 tokens to seller. These tokens will be auctioned. + _setupERC721BalanceForSeller(seller, 1); + + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = tokenId; + assertIsOwnerERC721(address(erc721), seller, tokenIds); + + // Approve Marketplace to transfer token. + vm.prank(seller); + erc721.setApprovalForAll(marketplace, true); + + // Auction tokens. + IEnglishAuctions.AuctionParameters memory auctionParams = IEnglishAuctions.AuctionParameters( + assetContract, + tokenId, + quantity, + currency, + minimumBidAmount, + buyoutBidAmount, + timeBufferInSeconds, + bidBufferBps, + startTimestamp, + endTimestamp + ); + + vm.prank(seller); + vm.expectRevert("Marketplace: invalid bid amounts."); + EnglishAuctionsLogic(marketplace).createAuction(auctionParams); + } + + function test_revert_createAuction_invalidStartTimestamp() public { + uint256 blockTimestamp = 100 minutes; + // Set block.timestamp + vm.warp(blockTimestamp); + + // Sample auction parameters. + address assetContract = address(erc721); + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = address(erc20); + uint256 minimumBidAmount = 1 ether; + uint256 buyoutBidAmount = 10 ether; + uint64 timeBufferInSeconds = 10 seconds; + uint64 bidBufferBps = 1000; + uint64 startTimestamp = uint64(blockTimestamp - 61 minutes); // start time is less than block timestamp. + uint64 endTimestamp = startTimestamp + 1; + + // Mint the ERC721 tokens to seller. These tokens will be auctioned. + _setupERC721BalanceForSeller(seller, 1); + + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = tokenId; + assertIsOwnerERC721(address(erc721), seller, tokenIds); + + // Approve Marketplace to transfer token. + vm.prank(seller); + erc721.setApprovalForAll(marketplace, true); + + // Auction tokens. + IEnglishAuctions.AuctionParameters memory auctionParams = IEnglishAuctions.AuctionParameters( + assetContract, + tokenId, + quantity, + currency, + minimumBidAmount, + buyoutBidAmount, + timeBufferInSeconds, + bidBufferBps, + startTimestamp, + endTimestamp + ); + + vm.prank(seller); + vm.expectRevert("Marketplace: invalid timestamps."); + EnglishAuctionsLogic(marketplace).createAuction(auctionParams); + } + + function test_revert_createAuction_invalidEndTimestamp() public { + // Sample auction parameters. + address assetContract = address(erc721); + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = address(erc20); + uint256 minimumBidAmount = 1 ether; + uint256 buyoutBidAmount = 10 ether; + uint64 timeBufferInSeconds = 10 seconds; + uint64 bidBufferBps = 1000; + uint64 startTimestamp = 100; + uint64 endTimestamp = startTimestamp - 1; + + // Mint the ERC721 tokens to seller. These tokens will be auctioned. + _setupERC721BalanceForSeller(seller, 1); + + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = tokenId; + assertIsOwnerERC721(address(erc721), seller, tokenIds); + + // Approve Marketplace to transfer token. + vm.prank(seller); + erc721.setApprovalForAll(marketplace, true); + + // Auction tokens. + IEnglishAuctions.AuctionParameters memory auctionParams = IEnglishAuctions.AuctionParameters( + assetContract, + tokenId, + quantity, + currency, + minimumBidAmount, + buyoutBidAmount, + timeBufferInSeconds, + bidBufferBps, + startTimestamp, + endTimestamp + ); + + vm.prank(seller); + vm.expectRevert("Marketplace: invalid timestamps."); + EnglishAuctionsLogic(marketplace).createAuction(auctionParams); + } + + function test_revert_createAuction_invalidAssetContract() public { + // Sample auction parameters. + address assetContract = address(erc20); + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = address(erc20); + uint256 minimumBidAmount = 1 ether; + uint256 buyoutBidAmount = 10 ether; + uint64 timeBufferInSeconds = 10 seconds; + uint64 bidBufferBps = 1000; + uint64 startTimestamp = 100; + uint64 endTimestamp = startTimestamp - 1; + + // Auction tokens. + IEnglishAuctions.AuctionParameters memory auctionParams = IEnglishAuctions.AuctionParameters( + assetContract, + tokenId, + quantity, + currency, + minimumBidAmount, + buyoutBidAmount, + timeBufferInSeconds, + bidBufferBps, + startTimestamp, + endTimestamp + ); + + // Grant ERC20 token asset role. + vm.prank(marketplaceDeployer); + Permissions(marketplace).grantRole(keccak256("ASSET_ROLE"), address(erc20)); + + vm.prank(seller); + vm.expectRevert("Marketplace: auctioned token must be ERC1155 or ERC721."); + EnglishAuctionsLogic(marketplace).createAuction(auctionParams); + } + + function test_revert_createAuction_noListerRoleWhenRestrictionsActive() public { + // Sample auction parameters. + address assetContract = address(erc721); + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = address(erc20); + uint256 minimumBidAmount = 1 ether; + uint256 buyoutBidAmount = 10 ether; + uint64 timeBufferInSeconds = 10 seconds; + uint64 bidBufferBps = 1000; + uint64 startTimestamp = 100; + uint64 endTimestamp = 200; + + // Mint the ERC721 tokens to seller. These tokens will be auctioned. + _setupERC721BalanceForSeller(seller, 1); + + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = tokenId; + assertIsOwnerERC721(address(erc721), seller, tokenIds); + + // Approve Marketplace to transfer token. + vm.prank(seller); + erc721.setApprovalForAll(marketplace, true); + + // Auction tokens. + IEnglishAuctions.AuctionParameters memory auctionParams = IEnglishAuctions.AuctionParameters( + assetContract, + tokenId, + quantity, + currency, + minimumBidAmount, + buyoutBidAmount, + timeBufferInSeconds, + bidBufferBps, + startTimestamp, + endTimestamp + ); + + // Revoke LISTER_ROLE from seller. + vm.startPrank(marketplaceDeployer); + assertEq(Permissions(marketplace).hasRole(keccak256("LISTER_ROLE"), address(0)), false); + Permissions(marketplace).revokeRole(keccak256("LISTER_ROLE"), seller); + assertEq(Permissions(marketplace).hasRole(keccak256("LISTER_ROLE"), seller), false); + + vm.stopPrank(); + + vm.prank(seller); + vm.expectRevert("!LISTER_ROLE"); + EnglishAuctionsLogic(marketplace).createAuction(auctionParams); + } + + function test_revert_createAuction_noAssetRoleWhenRestrictionsActive() public { + // Sample auction parameters. + address assetContract = address(erc721); + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = address(erc20); + uint256 minimumBidAmount = 1 ether; + uint256 buyoutBidAmount = 10 ether; + uint64 timeBufferInSeconds = 10 seconds; + uint64 bidBufferBps = 1000; + uint64 startTimestamp = 100; + uint64 endTimestamp = 200; + + // Mint the ERC721 tokens to seller. These tokens will be auctioned. + _setupERC721BalanceForSeller(seller, 1); + + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = tokenId; + assertIsOwnerERC721(address(erc721), seller, tokenIds); + + // Approve Marketplace to transfer token. + vm.prank(seller); + erc721.setApprovalForAll(marketplace, true); + + // Auction tokens. + IEnglishAuctions.AuctionParameters memory auctionParams = IEnglishAuctions.AuctionParameters( + assetContract, + tokenId, + quantity, + currency, + minimumBidAmount, + buyoutBidAmount, + timeBufferInSeconds, + bidBufferBps, + startTimestamp, + endTimestamp + ); + + // Revoke ASSET_ROLE from token to list. + vm.startPrank(marketplaceDeployer); + assertEq(Permissions(marketplace).hasRole(keccak256("ASSET_ROLE"), address(0)), false); + Permissions(marketplace).revokeRole(keccak256("ASSET_ROLE"), address(erc721)); + assertEq(Permissions(marketplace).hasRole(keccak256("ASSET_ROLE"), address(erc721)), false); + + vm.stopPrank(); + + vm.prank(seller); + vm.expectRevert("!ASSET_ROLE"); + EnglishAuctionsLogic(marketplace).createAuction(auctionParams); + } + + /*/////////////////////////////////////////////////////////////// + Cancel Auction + //////////////////////////////////////////////////////////////*/ + + function _setup_newAuction() private returns (uint256 auctionId) { + // Sample auction parameters. + address assetContract = address(erc721); + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = address(erc20); + uint256 minimumBidAmount = 1 ether; + uint256 buyoutBidAmount = 10 ether; + uint64 timeBufferInSeconds = 10 seconds; + uint64 bidBufferBps = 1000; + uint64 startTimestamp = 100; + uint64 endTimestamp = 200; + + // Mint the ERC721 tokens to seller. These tokens will be auctioned. + _setupERC721BalanceForSeller(seller, 1); + + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = tokenId; + assertIsOwnerERC721(address(erc721), seller, tokenIds); + + // Approve Marketplace to transfer token. + vm.prank(seller); + erc721.setApprovalForAll(marketplace, true); + + // Auction tokens. + IEnglishAuctions.AuctionParameters memory auctionParams = IEnglishAuctions.AuctionParameters( + assetContract, + tokenId, + quantity, + currency, + minimumBidAmount, + buyoutBidAmount, + timeBufferInSeconds, + bidBufferBps, + startTimestamp, + endTimestamp + ); + + vm.prank(seller); + auctionId = EnglishAuctionsLogic(marketplace).createAuction(auctionParams); + } + + function _setup_newAuction_nativeToken() private returns (uint256 auctionId) { + // Sample auction parameters. + address assetContract = address(erc721); + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = NATIVE_TOKEN; + uint256 minimumBidAmount = 1 ether; + uint256 buyoutBidAmount = 10 ether; + uint64 timeBufferInSeconds = 10 seconds; + uint64 bidBufferBps = 1000; + uint64 startTimestamp = 100; + uint64 endTimestamp = 200; + + // Mint the ERC721 tokens to seller. These tokens will be auctioned. + _setupERC721BalanceForSeller(seller, 1); + + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = tokenId; + assertIsOwnerERC721(address(erc721), seller, tokenIds); + + // Approve Marketplace to transfer token. + vm.prank(seller); + erc721.setApprovalForAll(marketplace, true); + + // Auction tokens. + IEnglishAuctions.AuctionParameters memory auctionParams = IEnglishAuctions.AuctionParameters( + assetContract, + tokenId, + quantity, + currency, + minimumBidAmount, + buyoutBidAmount, + timeBufferInSeconds, + bidBufferBps, + startTimestamp, + endTimestamp + ); + + vm.prank(seller); + auctionId = EnglishAuctionsLogic(marketplace).createAuction(auctionParams); + } + + function test_state_cancelAuction() public { + uint256 auctionId = _setup_newAuction(); + IEnglishAuctions.Auction memory existingAuction = EnglishAuctionsLogic(marketplace).getAuction(auctionId); + + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = existingAuction.tokenId; + + // Verify existing auction at `auctionId` + assertEq(existingAuction.assetContract, address(erc721)); + + vm.prank(seller); + EnglishAuctionsLogic(marketplace).cancelAuction(auctionId); + + // Test consequent states. + + // Seller is owner of token. + assertIsOwnerERC721(address(erc721), seller, tokenIds); + + // Total auction count should include deleted auctions too + assertEq(EnglishAuctionsLogic(marketplace).totalAuctions(), 1); + + // status should be `CANCELLED` + IEnglishAuctions.Auction memory cancelledAuction = EnglishAuctionsLogic(marketplace).getAuction(auctionId); + assertTrue(cancelledAuction.status == IEnglishAuctions.Status.CANCELLED); + } + + function test_revert_cancelAuction_bidsAlreadyMade() public { + uint256 auctionId = _setup_newAuction(); + IEnglishAuctions.Auction memory existingAuction = EnglishAuctionsLogic(marketplace).getAuction(auctionId); + + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = existingAuction.tokenId; + + // Verify existing auction at `auctionId` + assertEq(existingAuction.assetContract, address(erc721)); + + vm.warp(existingAuction.startTimestamp); + + // place bid + erc20.mint(buyer, 1 ether); + vm.startPrank(buyer); + erc20.approve(marketplace, 1 ether); + EnglishAuctionsLogic(marketplace).bidInAuction(auctionId, 1 ether); + vm.stopPrank(); + + vm.prank(seller); + vm.expectRevert("Marketplace: bids already made."); + EnglishAuctionsLogic(marketplace).cancelAuction(auctionId); + } + + /*/////////////////////////////////////////////////////////////// + Bid In Auction + //////////////////////////////////////////////////////////////*/ + + function test_state_bidInAuction_firstBid() public { + uint256 auctionId = _setup_newAuction(); + IEnglishAuctions.Auction memory existingAuction = EnglishAuctionsLogic(marketplace).getAuction(auctionId); + + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = existingAuction.tokenId; + + // Verify existing auction at `auctionId` + assertEq(existingAuction.assetContract, address(erc721)); + + vm.warp(existingAuction.startTimestamp); + + // place bid + erc20.mint(buyer, 1 ether); + vm.startPrank(buyer); + erc20.approve(marketplace, 1 ether); + EnglishAuctionsLogic(marketplace).bidInAuction(auctionId, 1 ether); + vm.stopPrank(); + + (address bidder, address currency, uint256 bidAmount) = EnglishAuctionsLogic(marketplace).getWinningBid( + auctionId + ); + + // Test consequent states. + // Seller is owner of token. + assertIsOwnerERC721(address(erc721), marketplace, tokenIds); + assertEq(erc20.balanceOf(marketplace), 1 ether); + assertEq(erc20.balanceOf(buyer), 0); + assertEq(buyer, bidder); + assertEq(currency, address(erc20)); + assertEq(bidAmount, 1 ether); + } + + function test_state_bidInAuction_secondBid() public { + uint256 auctionId = _setup_newAuction(); + IEnglishAuctions.Auction memory existingAuction = EnglishAuctionsLogic(marketplace).getAuction(auctionId); + + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = existingAuction.tokenId; + + // Verify existing auction at `auctionId` + assertEq(existingAuction.assetContract, address(erc721)); + + vm.warp(existingAuction.startTimestamp); + + // place first bid + erc20.mint(buyer, 1 ether); + vm.startPrank(buyer); + erc20.approve(marketplace, 1 ether); + EnglishAuctionsLogic(marketplace).bidInAuction(auctionId, 1 ether); + vm.stopPrank(); + + (address bidder, address currency, uint256 bidAmount) = EnglishAuctionsLogic(marketplace).getWinningBid( + auctionId + ); + + // Test consequent states. + // Seller is owner of token. + assertIsOwnerERC721(address(erc721), marketplace, tokenIds); + assertEq(erc20.balanceOf(marketplace), 1 ether); + assertEq(erc20.balanceOf(buyer), 0); + assertEq(buyer, bidder); + assertEq(currency, address(erc20)); + assertEq(bidAmount, 1 ether); + + // place second winning bid + erc20.mint(address(0x345), 2 ether); + vm.startPrank(address(0x345)); + erc20.approve(marketplace, 2 ether); + EnglishAuctionsLogic(marketplace).bidInAuction(auctionId, 2 ether); + vm.stopPrank(); + + (bidder, currency, bidAmount) = EnglishAuctionsLogic(marketplace).getWinningBid(auctionId); + + // Test consequent states. + // Seller is owner of token. + assertIsOwnerERC721(address(erc721), marketplace, tokenIds); + assertEq(erc20.balanceOf(marketplace), 2 ether); + assertEq(erc20.balanceOf(buyer), 1 ether); + assertEq(erc20.balanceOf(address(0x345)), 0); + assertEq(address(0x345), bidder); + assertEq(currency, address(erc20)); + assertEq(bidAmount, 2 ether); + } + + function test_state_bidInAuction_buyoutBid() public { + uint256 auctionId = _setup_newAuction(); + IEnglishAuctions.Auction memory existingAuction = EnglishAuctionsLogic(marketplace).getAuction(auctionId); + + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = existingAuction.tokenId; + + // Verify existing auction at `auctionId` + assertEq(existingAuction.assetContract, address(erc721)); + + vm.warp(existingAuction.startTimestamp); + + // place first bid + erc20.mint(buyer, 1 ether); + vm.startPrank(buyer); + erc20.approve(marketplace, 1 ether); + EnglishAuctionsLogic(marketplace).bidInAuction(auctionId, 1 ether); + vm.stopPrank(); + + (address bidder, address currency, uint256 bidAmount) = EnglishAuctionsLogic(marketplace).getWinningBid( + auctionId + ); + + // Test consequent states. + // Seller is owner of token. + assertIsOwnerERC721(address(erc721), marketplace, tokenIds); + assertEq(erc20.balanceOf(marketplace), 1 ether); + assertEq(erc20.balanceOf(buyer), 0); + assertEq(buyer, bidder); + assertEq(currency, address(erc20)); + assertEq(bidAmount, 1 ether); + + // place buyout bid + erc20.mint(address(0x345), 10 ether); + vm.startPrank(address(0x345)); + erc20.approve(marketplace, 10 ether); + EnglishAuctionsLogic(marketplace).bidInAuction(auctionId, 10 ether); + vm.stopPrank(); + + (bidder, currency, bidAmount) = EnglishAuctionsLogic(marketplace).getWinningBid(auctionId); + + // Test consequent states. + // Seller is owner of token. + assertIsOwnerERC721(address(erc721), address(0x345), tokenIds); + assertEq(erc20.balanceOf(marketplace), 10 ether); + assertEq(erc20.balanceOf(buyer), 1 ether); + assertEq(erc20.balanceOf(address(0x345)), 0); + assertEq(address(0x345), bidder); + assertEq(currency, address(erc20)); + assertEq(bidAmount, 10 ether); + } + + function test_revert_bidInAuction_inactiveAuction() public { + uint256 auctionId = _setup_newAuction(); + IEnglishAuctions.Auction memory existingAuction = EnglishAuctionsLogic(marketplace).getAuction(auctionId); + + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = existingAuction.tokenId; + + // Verify existing auction at `auctionId` + assertEq(existingAuction.assetContract, address(erc721)); + + // place bid before start-time + erc20.mint(buyer, 1 ether); + vm.startPrank(buyer); + erc20.approve(marketplace, 1 ether); + vm.expectRevert("Marketplace: inactive auction."); + EnglishAuctionsLogic(marketplace).bidInAuction(auctionId, 1 ether); + vm.stopPrank(); + + // place bid after end-time + vm.warp(existingAuction.endTimestamp); + + erc20.mint(buyer, 1 ether); + vm.startPrank(buyer); + erc20.approve(marketplace, 1 ether); + vm.expectRevert("Marketplace: inactive auction."); + EnglishAuctionsLogic(marketplace).bidInAuction(auctionId, 1 ether); + vm.stopPrank(); + } + + function test_revert_bidInAuction_notOwnerOfBidTokens() public { + uint256 auctionId = _setup_newAuction(); + IEnglishAuctions.Auction memory existingAuction = EnglishAuctionsLogic(marketplace).getAuction(auctionId); + + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = existingAuction.tokenId; + + // Verify existing auction at `auctionId` + assertEq(existingAuction.assetContract, address(erc721)); + + vm.warp(existingAuction.startTimestamp); + + // place bid + vm.startPrank(buyer); + erc20.approve(marketplace, 1 ether); + vm.expectRevert("ERC20: transfer amount exceeds balance"); + EnglishAuctionsLogic(marketplace).bidInAuction(auctionId, 1 ether); + vm.stopPrank(); + } + + function test_revert_bidInAuction_notApprovedMarketplaceToTransferToken() public { + uint256 auctionId = _setup_newAuction(); + IEnglishAuctions.Auction memory existingAuction = EnglishAuctionsLogic(marketplace).getAuction(auctionId); + + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = existingAuction.tokenId; + + // Verify existing auction at `auctionId` + assertEq(existingAuction.assetContract, address(erc721)); + + vm.warp(existingAuction.startTimestamp); + + // place bid + erc20.mint(buyer, 1 ether); + vm.startPrank(buyer); + vm.expectRevert("ERC20: insufficient allowance"); + EnglishAuctionsLogic(marketplace).bidInAuction(auctionId, 1 ether); + vm.stopPrank(); + } + + function test_revert_bidInAuction_notNewWinningBid_firstBid() public { + uint256 auctionId = _setup_newAuction(); + IEnglishAuctions.Auction memory existingAuction = EnglishAuctionsLogic(marketplace).getAuction(auctionId); + + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = existingAuction.tokenId; + + // Verify existing auction at `auctionId` + assertEq(existingAuction.assetContract, address(erc721)); + + vm.warp(existingAuction.startTimestamp); + + // place first bid less than minimum bid amount + erc20.mint(buyer, 0.5 ether); + vm.startPrank(buyer); + erc20.approve(marketplace, 0.5 ether); + vm.expectRevert("Marketplace: not winning bid."); + EnglishAuctionsLogic(marketplace).bidInAuction(auctionId, 0.5 ether); + vm.stopPrank(); + } + + function test_revert_bidInAuction_notNewWinningBid_secondBid() public { + uint256 auctionId = _setup_newAuction(); + IEnglishAuctions.Auction memory existingAuction = EnglishAuctionsLogic(marketplace).getAuction(auctionId); + + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = existingAuction.tokenId; + + // Verify existing auction at `auctionId` + assertEq(existingAuction.assetContract, address(erc721)); + + vm.warp(existingAuction.startTimestamp); + + // place first bid + erc20.mint(buyer, 1 ether); + vm.startPrank(buyer); + erc20.approve(marketplace, 1 ether); + EnglishAuctionsLogic(marketplace).bidInAuction(auctionId, 1 ether); + vm.stopPrank(); + + (address bidder, address currency, uint256 bidAmount) = EnglishAuctionsLogic(marketplace).getWinningBid( + auctionId + ); + + // Test consequent states. + // Seller is owner of token. + assertIsOwnerERC721(address(erc721), marketplace, tokenIds); + assertEq(erc20.balanceOf(marketplace), 1 ether); + assertEq(erc20.balanceOf(buyer), 0); + assertEq(buyer, bidder); + assertEq(currency, address(erc20)); + assertEq(bidAmount, 1 ether); + + // place second bid less-than/equal-to previous winning bid + erc20.mint(address(0x345), 1 ether); + vm.startPrank(address(0x345)); + erc20.approve(marketplace, 1 ether); + vm.expectRevert("Marketplace: not winning bid."); + EnglishAuctionsLogic(marketplace).bidInAuction(auctionId, 1 ether); + vm.stopPrank(); + } + + function test_state_bidInAuction_nativeToken() public { + uint256 auctionId = _setup_newAuction_nativeToken(); + IEnglishAuctions.Auction memory existingAuction = EnglishAuctionsLogic(marketplace).getAuction(auctionId); + + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = existingAuction.tokenId; + + // Verify existing auction at `auctionId` + assertEq(existingAuction.assetContract, address(erc721)); + + vm.warp(existingAuction.startTimestamp); + + // place bid + vm.deal(buyer, 10 ether); + vm.startPrank(buyer); + EnglishAuctionsLogic(marketplace).bidInAuction{ value: 1 ether }(auctionId, 1 ether); + vm.stopPrank(); + + (address bidder, address currency, uint256 bidAmount) = EnglishAuctionsLogic(marketplace).getWinningBid( + auctionId + ); + + // Test consequent states. + // Seller is owner of token. + assertIsOwnerERC721(address(erc721), marketplace, tokenIds); + assertEq(weth.balanceOf(marketplace), 1 ether); + assertEq(buyer.balance, 9 ether); + assertEq(buyer, bidder); + assertEq(currency, NATIVE_TOKEN); + assertEq(bidAmount, 1 ether); + } + + /*/////////////////////////////////////////////////////////////// + Collect Auction Payout + //////////////////////////////////////////////////////////////*/ + + function test_state_collectAuctionPayout_buyoutBid() public { + uint256 auctionId = _setup_newAuction(); + IEnglishAuctions.Auction memory existingAuction = EnglishAuctionsLogic(marketplace).getAuction(auctionId); + + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = existingAuction.tokenId; + + // Verify existing auction at `auctionId` + assertEq(existingAuction.assetContract, address(erc721)); + + vm.warp(existingAuction.startTimestamp); + + // place buyout bid + erc20.mint(buyer, 10 ether); + vm.startPrank(buyer); + erc20.approve(marketplace, 10 ether); + EnglishAuctionsLogic(marketplace).bidInAuction(auctionId, 10 ether); + vm.stopPrank(); + + (address bidder, address currency, uint256 bidAmount) = EnglishAuctionsLogic(marketplace).getWinningBid( + auctionId + ); + + // Test consequent states. + // Seller is owner of token. + assertIsOwnerERC721(address(erc721), buyer, tokenIds); + assertEq(erc20.balanceOf(marketplace), 10 ether); + assertEq(erc20.balanceOf(buyer), 0); + assertEq(buyer, bidder); + assertEq(currency, address(erc20)); + assertEq(bidAmount, 10 ether); + + // collect auction payout + vm.prank(seller); + EnglishAuctionsLogic(marketplace).collectAuctionPayout(auctionId); + + assertEq(erc20.balanceOf(marketplace), 0); + assertEq(erc20.balanceOf(seller), 10 ether); + } + + function test_state_collectAuctionPayout_afterAuctionEnds() public { + uint256 auctionId = _setup_newAuction(); + IEnglishAuctions.Auction memory existingAuction = EnglishAuctionsLogic(marketplace).getAuction(auctionId); + + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = existingAuction.tokenId; + + // Verify existing auction at `auctionId` + assertEq(existingAuction.assetContract, address(erc721)); + + vm.warp(existingAuction.startTimestamp); + + // place bid + erc20.mint(buyer, 5 ether); + vm.startPrank(buyer); + erc20.approve(marketplace, 5 ether); + EnglishAuctionsLogic(marketplace).bidInAuction(auctionId, 5 ether); + vm.stopPrank(); + + (address bidder, address currency, uint256 bidAmount) = EnglishAuctionsLogic(marketplace).getWinningBid( + auctionId + ); + + // Test consequent states. + // Seller is owner of token. + assertIsOwnerERC721(address(erc721), marketplace, tokenIds); + assertEq(erc20.balanceOf(marketplace), 5 ether); + assertEq(erc20.balanceOf(buyer), 0); + assertEq(buyer, bidder); + assertEq(currency, address(erc20)); + assertEq(bidAmount, 5 ether); + + vm.warp(existingAuction.endTimestamp); + + // collect auction payout + vm.prank(seller); + EnglishAuctionsLogic(marketplace).collectAuctionPayout(auctionId); + + assertIsOwnerERC721(address(erc721), marketplace, tokenIds); + assertEq(erc20.balanceOf(marketplace), 0); + assertEq(erc20.balanceOf(seller), 5 ether); + } + + function test_revert_collectAuctionPayout_auctionNotExpired() public { + uint256 auctionId = _setup_newAuction(); + IEnglishAuctions.Auction memory existingAuction = EnglishAuctionsLogic(marketplace).getAuction(auctionId); + + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = existingAuction.tokenId; + + // Verify existing auction at `auctionId` + assertEq(existingAuction.assetContract, address(erc721)); + + vm.warp(existingAuction.startTimestamp); + + // place bid + erc20.mint(buyer, 5 ether); + vm.startPrank(buyer); + erc20.approve(marketplace, 5 ether); + EnglishAuctionsLogic(marketplace).bidInAuction(auctionId, 5 ether); + vm.stopPrank(); + + // collect auction payout before auction has ended + vm.prank(seller); + vm.expectRevert("Marketplace: auction still active."); + EnglishAuctionsLogic(marketplace).collectAuctionPayout(auctionId); + } + + function test_revert_collectAuctionPayout_noBidsInAuction() public { + uint256 auctionId = _setup_newAuction(); + IEnglishAuctions.Auction memory existingAuction = EnglishAuctionsLogic(marketplace).getAuction(auctionId); + + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = existingAuction.tokenId; + + // Verify existing auction at `auctionId` + assertEq(existingAuction.assetContract, address(erc721)); + + vm.warp(existingAuction.endTimestamp); + + // collect auction payout without any bids made + vm.prank(seller); + vm.expectRevert("Marketplace: no bids were made."); + EnglishAuctionsLogic(marketplace).collectAuctionPayout(auctionId); + } + + /*/////////////////////////////////////////////////////////////// + Collect Auction Tokens + //////////////////////////////////////////////////////////////*/ + + function test_state_collectAuctionTokens() public { + uint256 auctionId = _setup_newAuction(); + IEnglishAuctions.Auction memory existingAuction = EnglishAuctionsLogic(marketplace).getAuction(auctionId); + + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = existingAuction.tokenId; + + // Verify existing auction at `auctionId` + assertEq(existingAuction.assetContract, address(erc721)); + + vm.warp(existingAuction.startTimestamp); + + // place bid + erc20.mint(buyer, 5 ether); + vm.startPrank(buyer); + erc20.approve(marketplace, 5 ether); + EnglishAuctionsLogic(marketplace).bidInAuction(auctionId, 5 ether); + vm.stopPrank(); + + (address bidder, address currency, uint256 bidAmount) = EnglishAuctionsLogic(marketplace).getWinningBid( + auctionId + ); + + // Test consequent states. + // Seller is owner of token. + assertIsOwnerERC721(address(erc721), marketplace, tokenIds); + assertEq(erc20.balanceOf(marketplace), 5 ether); + assertEq(erc20.balanceOf(buyer), 0); + assertEq(buyer, bidder); + assertEq(currency, address(erc20)); + assertEq(bidAmount, 5 ether); + + vm.warp(existingAuction.endTimestamp); + + // collect auction tokens + vm.prank(buyer); + EnglishAuctionsLogic(marketplace).collectAuctionTokens(auctionId); + + assertIsOwnerERC721(address(erc721), buyer, tokenIds); + assertEq(erc20.balanceOf(marketplace), 5 ether); + } + + function test_revert_collectAuctionTokens_auctionNotExpired() public { + uint256 auctionId = _setup_newAuction(); + IEnglishAuctions.Auction memory existingAuction = EnglishAuctionsLogic(marketplace).getAuction(auctionId); + + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = existingAuction.tokenId; + + // Verify existing auction at `auctionId` + assertEq(existingAuction.assetContract, address(erc721)); + + vm.warp(existingAuction.startTimestamp); + + // place bid + erc20.mint(buyer, 5 ether); + vm.startPrank(buyer); + erc20.approve(marketplace, 5 ether); + EnglishAuctionsLogic(marketplace).bidInAuction(auctionId, 5 ether); + vm.stopPrank(); + + (address bidder, address currency, uint256 bidAmount) = EnglishAuctionsLogic(marketplace).getWinningBid( + auctionId + ); + + // Test consequent states. + // Seller is owner of token. + assertIsOwnerERC721(address(erc721), marketplace, tokenIds); + assertEq(erc20.balanceOf(marketplace), 5 ether); + assertEq(erc20.balanceOf(buyer), 0); + assertEq(buyer, bidder); + assertEq(currency, address(erc20)); + assertEq(bidAmount, 5 ether); + + // collect auction tokens before auction has ended + vm.prank(buyer); + vm.expectRevert("Marketplace: auction still active."); + EnglishAuctionsLogic(marketplace).collectAuctionTokens(auctionId); + } + + /*/////////////////////////////////////////////////////////////// + View functions + //////////////////////////////////////////////////////////////*/ + + function test_state_isNewWinningBid() public { + uint256 auctionId = _setup_newAuction(); + IEnglishAuctions.Auction memory existingAuction = EnglishAuctionsLogic(marketplace).getAuction(auctionId); + + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = existingAuction.tokenId; + + // Verify existing auction at `auctionId` + assertEq(existingAuction.assetContract, address(erc721)); + + vm.warp(existingAuction.startTimestamp); + + // place bid + erc20.mint(buyer, 5 ether); + vm.startPrank(buyer); + erc20.approve(marketplace, 5 ether); + EnglishAuctionsLogic(marketplace).bidInAuction(auctionId, 5 ether); + vm.stopPrank(); + + // check if new winning bid + assertTrue(EnglishAuctionsLogic(marketplace).isNewWinningBid(auctionId, 6 ether)); + assertFalse(EnglishAuctionsLogic(marketplace).isNewWinningBid(auctionId, 5 ether)); + assertFalse(EnglishAuctionsLogic(marketplace).isNewWinningBid(auctionId, 4 ether)); + } + + function test_revert_isNewWinningBid() public { + uint256 auctionId = _setup_newAuction(); + IEnglishAuctions.Auction memory existingAuction = EnglishAuctionsLogic(marketplace).getAuction(auctionId); + + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = existingAuction.tokenId; + + // Verify existing auction at `auctionId` + assertEq(existingAuction.assetContract, address(erc721)); + + vm.warp(existingAuction.startTimestamp); + + // place bid + erc20.mint(buyer, 5 ether); + vm.startPrank(buyer); + erc20.approve(marketplace, 5 ether); + EnglishAuctionsLogic(marketplace).bidInAuction(auctionId, 5 ether); + vm.stopPrank(); + + // check winning bid for a non-existent auction + vm.expectRevert("Marketplace: invalid auction."); + EnglishAuctionsLogic(marketplace).isNewWinningBid(auctionId + 1, 6 ether); + } + + function test_state_getAllAuctions() public { + // Mint the ERC721 tokens to seller. These tokens will be auctioned. + _setupERC721BalanceForSeller(seller, 6); + + uint256[] memory auctionIds = new uint256[](5); + uint256[] memory tokenIds = new uint256[](5); + + // Approve Marketplace to transfer token. + vm.prank(seller); + erc721.setApprovalForAll(marketplace, true); + + // Sample auction parameters. + address assetContract = address(erc721); + uint256 quantity = 1; + address currency = address(erc20); + uint256 minimumBidAmount = 1 ether; + uint256 buyoutBidAmount = 10 ether; + uint64 timeBufferInSeconds = 10 seconds; + uint64 bidBufferBps = 1000; + uint64 startTimestamp = uint64(block.timestamp); + uint64 endTimestamp = startTimestamp + 200; + + IEnglishAuctions.AuctionParameters memory auctionParams; + + for (uint256 i = 0; i < 5; i += 1) { + tokenIds[i] = i; + + // Auction tokens. + auctionParams = IEnglishAuctions.AuctionParameters( + assetContract, + tokenIds[i], + quantity, + currency, + minimumBidAmount, + buyoutBidAmount, + timeBufferInSeconds, + bidBufferBps, + startTimestamp, + endTimestamp + ); + + vm.prank(seller); + auctionIds[i] = EnglishAuctionsLogic(marketplace).createAuction(auctionParams); + } + + IEnglishAuctions.Auction[] memory activeAuctions = EnglishAuctionsLogic(marketplace).getAllAuctions(0, 4); + assertEq(activeAuctions.length, 5); + + for (uint256 i = 0; i < 5; i += 1) { + assertEq(activeAuctions[i].auctionId, auctionIds[i]); + assertEq(activeAuctions[i].auctionCreator, seller); + assertEq(activeAuctions[i].assetContract, assetContract); + assertEq(activeAuctions[i].tokenId, tokenIds[i]); + assertEq(activeAuctions[i].quantity, quantity); + assertEq(activeAuctions[i].currency, currency); + assertEq(activeAuctions[i].minimumBidAmount, minimumBidAmount); + assertEq(activeAuctions[i].buyoutBidAmount, buyoutBidAmount); + assertEq(activeAuctions[i].timeBufferInSeconds, timeBufferInSeconds); + assertEq(activeAuctions[i].bidBufferBps, bidBufferBps); + assertEq(activeAuctions[i].startTimestamp, startTimestamp); + assertEq(activeAuctions[i].endTimestamp, endTimestamp); + assertEq(uint256(activeAuctions[i].tokenType), uint256(IEnglishAuctions.TokenType.ERC721)); + } + } + + function test_state_getAllValidAuctions() public { + // Mint the ERC721 tokens to seller. These tokens will be auctioned. + _setupERC721BalanceForSeller(seller, 6); + + uint256[] memory auctionIds = new uint256[](5); + uint256[] memory tokenIds = new uint256[](5); + + // Approve Marketplace to transfer token. + vm.prank(seller); + erc721.setApprovalForAll(marketplace, true); + + // Sample auction parameters. + address assetContract = address(erc721); + uint256 quantity = 1; + address currency = address(erc20); + uint256 minimumBidAmount = 1 ether; + uint256 buyoutBidAmount = 10 ether; + uint64 timeBufferInSeconds = 10 seconds; + uint64 bidBufferBps = 1000; + uint64 startTimestamp = uint64(block.timestamp); + uint64 endTimestamp = startTimestamp + 200; + + IEnglishAuctions.AuctionParameters memory auctionParams; + + for (uint256 i = 0; i < 5; i += 1) { + tokenIds[i] = i; + + // Auction tokens. + auctionParams = IEnglishAuctions.AuctionParameters( + assetContract, + tokenIds[i], + quantity, + currency, + minimumBidAmount, + buyoutBidAmount, + timeBufferInSeconds, + bidBufferBps, + startTimestamp, + endTimestamp + ); + + vm.prank(seller); + auctionIds[i] = EnglishAuctionsLogic(marketplace).createAuction(auctionParams); + } + + IEnglishAuctions.Auction[] memory activeAuctions = EnglishAuctionsLogic(marketplace).getAllValidAuctions(0, 4); + assertEq(activeAuctions.length, 5); + + for (uint256 i = 0; i < 5; i += 1) { + assertEq(activeAuctions[i].auctionId, auctionIds[i]); + assertEq(activeAuctions[i].auctionCreator, seller); + assertEq(activeAuctions[i].assetContract, assetContract); + assertEq(activeAuctions[i].tokenId, tokenIds[i]); + assertEq(activeAuctions[i].quantity, quantity); + assertEq(activeAuctions[i].currency, currency); + assertEq(activeAuctions[i].minimumBidAmount, minimumBidAmount); + assertEq(activeAuctions[i].buyoutBidAmount, buyoutBidAmount); + assertEq(activeAuctions[i].timeBufferInSeconds, timeBufferInSeconds); + assertEq(activeAuctions[i].bidBufferBps, bidBufferBps); + assertEq(activeAuctions[i].startTimestamp, startTimestamp); + assertEq(activeAuctions[i].endTimestamp, endTimestamp); + assertEq(uint256(activeAuctions[i].tokenType), uint256(IEnglishAuctions.TokenType.ERC721)); + } + + // create an inactive auction, and check the auctions returned + auctionParams = IEnglishAuctions.AuctionParameters( + assetContract, + 5, + quantity, + currency, + minimumBidAmount, + buyoutBidAmount, + timeBufferInSeconds, + bidBufferBps, + startTimestamp + 100, + endTimestamp + ); + + vm.prank(seller); + EnglishAuctionsLogic(marketplace).createAuction(auctionParams); + + activeAuctions = EnglishAuctionsLogic(marketplace).getAllValidAuctions(0, 5); + assertEq(activeAuctions.length, 5); + } + + function test_state_isAuctionExpired() public { + uint256 auctionId = _setup_newAuction(); + IEnglishAuctions.Auction memory existingAuction = EnglishAuctionsLogic(marketplace).getAuction(auctionId); + + vm.warp(existingAuction.endTimestamp); + assertTrue(EnglishAuctionsLogic(marketplace).isAuctionExpired(auctionId)); + } + + function test_revert_isAuctionExpired() public { + uint256 auctionId = _setup_newAuction(); + + vm.expectRevert("Marketplace: invalid auction."); + EnglishAuctionsLogic(marketplace).isAuctionExpired(auctionId + 1); + } + + /*/////////////////////////////////////////////////////////////// + Audit POCs + //////////////////////////////////////////////////////////////*/ + + function test_state_collectAuctionPayout_buyoutBid_nativeToken() public { + uint256 auctionId = _setup_newAuction_nativeToken(); + IEnglishAuctions.Auction memory existingAuction = EnglishAuctionsLogic(marketplace).getAuction(auctionId); + + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = existingAuction.tokenId; + + // Verify existing auction at `auctionId` + assertEq(existingAuction.assetContract, address(erc721)); + + vm.warp(existingAuction.startTimestamp); + + // place bid + vm.deal(buyer, 10 ether); + vm.startPrank(buyer); + EnglishAuctionsLogic(marketplace).bidInAuction{ value: 10 ether }(auctionId, 10 ether); + vm.stopPrank(); + + (address bidder, address currency, uint256 bidAmount) = EnglishAuctionsLogic(marketplace).getWinningBid( + auctionId + ); + + // Test consequent states. + // Seller is owner of token. + assertIsOwnerERC721(address(erc721), buyer, tokenIds); + assertEq(weth.balanceOf(marketplace), 10 ether); + assertEq(buyer.balance, 0 ether); + assertEq(buyer, bidder); + assertEq(currency, NATIVE_TOKEN); + assertEq(bidAmount, 10 ether); + + vm.prank(seller); + // calls WETH.withdraw (which calls receive function of Marketplace) and sends native tokens to seller + EnglishAuctionsLogic(marketplace).collectAuctionPayout(auctionId); + assertEq(weth.balanceOf(marketplace), 0 ether); + assertEq(seller.balance, 10 ether); + + // sending eth directly should fail + vm.deal(address(this), 1 ether); + (bool success, ) = marketplace.call{ value: 1 ether }(""); + assertEq(success, false); + } + + function test_audit_native_tokens_locked() public { + uint256 auctionId = _setup_newAuction(); + IEnglishAuctions.Auction memory existingAuction = EnglishAuctionsLogic(marketplace).getAuction(auctionId); + + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = existingAuction.tokenId; + + // Verify existing auction at `auctionId` + assertEq(existingAuction.assetContract, address(erc721)); + + vm.warp(existingAuction.startTimestamp); + + // place buyout bid + erc20.mint(buyer, 10 ether); + vm.deal(buyer, 1 ether); + + vm.startPrank(buyer); + erc20.approve(marketplace, 10 ether); + + vm.expectRevert("Marketplace: invalid native tokens sent."); + EnglishAuctionsLogic(marketplace).bidInAuction{ value: 1 ether }(auctionId, 10 ether); + vm.stopPrank(); + + // No ether is temporary locked in contract + assertEq(marketplace.balance, 0); + } + + function test_revert_collectAuctionPayout_buyoutBid_poc() public { + /*/////////////////////////////////////////////////////////////// + Initial State + //////////////////////////////////////////////////////////////*/ + + // consider that market place already has 200 ETH worth of tokens from all bids made + erc20.mint(marketplace, 200 ether); + + /*/////////////////////////////////////////////////////////////// + Create Auction + //////////////////////////////////////////////////////////////*/ + + // Buyout bid : 10 ETH + uint256 auctionId = _setup_newAuction(); + IEnglishAuctions.Auction memory existingAuction = EnglishAuctionsLogic(marketplace).getAuction(auctionId); + + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = existingAuction.tokenId; + + // Verify existing auction at `auctionId` + assertEq(existingAuction.assetContract, address(erc721)); + + vm.warp(existingAuction.startTimestamp); + + /*/////////////////////////////////////////////////////////////// + BID + //////////////////////////////////////////////////////////////*/ + + // place bid : 200 ETH + erc20.mint(buyer, 200 ether); + vm.startPrank(buyer); + erc20.approve(marketplace, 200 ether); + + vm.expectRevert("Marketplace: Bidding above buyout price."); + EnglishAuctionsLogic(marketplace).bidInAuction(auctionId, 200 ether); + vm.stopPrank(); + } + + function _setup_nativeTokenAuction() private returns (uint256 auctionId) { + // Sample auction parameters. + address assetContract = address(erc721); + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = NATIVE_TOKEN; + uint256 minimumBidAmount = 1 ether; + uint256 buyoutBidAmount = 10 ether; + uint64 timeBufferInSeconds = 10 seconds; + uint64 bidBufferBps = 1000; + uint64 startTimestamp = 100; + uint64 endTimestamp = 200; + + // Mint the ERC721 tokens to seller. These tokens will be auctioned. + _setupERC721BalanceForSeller(seller, 1); + + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = tokenId; + assertIsOwnerERC721(address(erc721), seller, tokenIds); + + // Approve Marketplace to transfer token. + vm.prank(seller); + erc721.setApprovalForAll(marketplace, true); + + // Auction tokens. + IEnglishAuctions.AuctionParameters memory auctionParams = IEnglishAuctions.AuctionParameters( + assetContract, + tokenId, + quantity, + currency, + minimumBidAmount, + buyoutBidAmount, + timeBufferInSeconds, + bidBufferBps, + startTimestamp, + endTimestamp + ); + + vm.prank(seller); + auctionId = EnglishAuctionsLogic(marketplace).createAuction(auctionParams); + } + + function test_revert_collectAuctionPayout_buyoutBid_nativeTokens_poc() public { + /*/////////////////////////////////////////////////////////////// + Initial State + //////////////////////////////////////////////////////////////*/ + + // consider that market place already has 200 ETH worth of tokens from all bids made + vm.deal(address(marketplace), 200 ether); + + /*/////////////////////////////////////////////////////////////// + Create Auction + //////////////////////////////////////////////////////////////*/ + + // Buyout bid : 10 ETH + uint256 auctionId = _setup_nativeTokenAuction(); + IEnglishAuctions.Auction memory existingAuction = EnglishAuctionsLogic(marketplace).getAuction(auctionId); + + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = existingAuction.tokenId; + + // Verify existing auction at `auctionId` + assertEq(existingAuction.assetContract, address(erc721)); + + vm.warp(existingAuction.startTimestamp); + + /*/////////////////////////////////////////////////////////////// + BID + //////////////////////////////////////////////////////////////*/ + + // place bid : 200 ETH + vm.deal(buyer, 200 ether); + vm.prank(buyer); + vm.expectRevert("Marketplace: Bidding above buyout price."); + EnglishAuctionsLogic(marketplace).bidInAuction(auctionId, 200 ether); + } +} + +contract BreitwieserTheCreator is BaseTest, IERC721Receiver, IExtension { + // Target contract + address public marketplace; + + // Participants + address public marketplaceDeployer; + address public seller; + address public buyer; + + function onERC721Received(address, address, uint256, bytes calldata) external pure returns (bytes4) { + return IERC721Receiver.onERC721Received.selector; + } + + function setUp() public override { + super.setUp(); + + marketplaceDeployer = getActor(1); + seller = getActor(2); + buyer = getActor(3); + + // Deploy implementation. + Extension[] memory extensions = _setupExtensions(); + address impl = address( + new MarketplaceV3(MarketplaceV3.MarketplaceConstructorParams(extensions, address(0), address(weth))) + ); + + vm.prank(marketplaceDeployer); + marketplace = address( + new TWProxy( + impl, + abi.encodeCall( + MarketplaceV3.initialize, + (marketplaceDeployer, "", new address[](0), marketplaceDeployer, 0) + ) + ) + ); + + // Setup roles for seller and assets + vm.startPrank(marketplaceDeployer); + Permissions(marketplace).revokeRole(keccak256("ASSET_ROLE"), address(0)); + Permissions(marketplace).revokeRole(keccak256("LISTER_ROLE"), address(0)); + Permissions(marketplace).grantRole(keccak256("LISTER_ROLE"), seller); + Permissions(marketplace).grantRole(keccak256("ASSET_ROLE"), address(erc721)); + Permissions(marketplace).grantRole(keccak256("ASSET_ROLE"), address(erc1155)); + + vm.stopPrank(); + + vm.label(impl, "MarketplaceV3_Impl"); + vm.label(marketplace, "Marketplace"); + vm.label(seller, "Seller"); + vm.label(buyer, "Buyer"); + vm.label(address(erc721), "ERC721_Token"); + vm.label(address(erc1155), "ERC1155_Token"); + } + + function _setupExtensions() internal returns (Extension[] memory extensions) { + extensions = new Extension[](1); + + // Deploy `EnglishAuctions` + address englishAuctions = address(new EnglishAuctionsLogic(address(weth))); + vm.label(englishAuctions, "EnglishAuctions_Extension"); + + // Extension: EnglishAuctionsLogic + Extension memory extension_englishAuctions; + extension_englishAuctions.metadata = ExtensionMetadata({ + name: "EnglishAuctionsLogic", + metadataURI: "ipfs://EnglishAuctions", + implementation: englishAuctions + }); + + extension_englishAuctions.functions = new ExtensionFunction[](12); + extension_englishAuctions.functions[0] = ExtensionFunction( + EnglishAuctionsLogic.totalAuctions.selector, + "totalAuctions()" + ); + extension_englishAuctions.functions[1] = ExtensionFunction( + EnglishAuctionsLogic.createAuction.selector, + "createAuction((address,uint256,uint256,address,uint256,uint256,uint64,uint64,uint64,uint64))" + ); + extension_englishAuctions.functions[2] = ExtensionFunction( + EnglishAuctionsLogic.cancelAuction.selector, + "cancelAuction(uint256)" + ); + extension_englishAuctions.functions[3] = ExtensionFunction( + EnglishAuctionsLogic.collectAuctionPayout.selector, + "collectAuctionPayout(uint256)" + ); + extension_englishAuctions.functions[4] = ExtensionFunction( + EnglishAuctionsLogic.collectAuctionTokens.selector, + "collectAuctionTokens(uint256)" + ); + extension_englishAuctions.functions[5] = ExtensionFunction( + EnglishAuctionsLogic.bidInAuction.selector, + "bidInAuction(uint256,uint256)" + ); + extension_englishAuctions.functions[6] = ExtensionFunction( + EnglishAuctionsLogic.isNewWinningBid.selector, + "isNewWinningBid(uint256,uint256)" + ); + extension_englishAuctions.functions[7] = ExtensionFunction( + EnglishAuctionsLogic.getAuction.selector, + "getAuction(uint256)" + ); + extension_englishAuctions.functions[8] = ExtensionFunction( + EnglishAuctionsLogic.getAllAuctions.selector, + "getAllAuctions(uint256,uint256)" + ); + extension_englishAuctions.functions[9] = ExtensionFunction( + EnglishAuctionsLogic.getAllValidAuctions.selector, + "getAllValidAuctions(uint256,uint256)" + ); + extension_englishAuctions.functions[10] = ExtensionFunction( + EnglishAuctionsLogic.getWinningBid.selector, + "getWinningBid(uint256)" + ); + extension_englishAuctions.functions[11] = ExtensionFunction( + EnglishAuctionsLogic.isAuctionExpired.selector, + "isAuctionExpired(uint256)" + ); + + extensions[0] = extension_englishAuctions; + } + + function _setupERC721BalanceForSeller(address _seller, uint256 _numOfTokens) private { + erc721.mint(_seller, _numOfTokens); + } + + function test_rob_as_creator() public { + ///////////////////////////// Setup: dummy NFT //////////////////////////// + + // Sample auction parameters. + address assetContract = address(erc721); + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = address(erc20); + uint256 minimumBidAmount = 1 ether; + uint256 buyoutBidAmount = 50 ether; + uint64 timeBufferInSeconds = 10 seconds; + uint64 bidBufferBps = 1000; + uint64 startTimestamp = 0; + uint64 endTimestamp = 200; + + // Mint the ERC721 tokens to seller. These tokens will be auctioned. + _setupERC721BalanceForSeller(seller, 1); + + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = tokenId; + assertIsOwnerERC721(address(erc721), seller, tokenIds); + + // Approve Marketplace to transfer token. + vm.prank(seller); + erc721.setApprovalForAll(marketplace, true); + + ////////////////////////////// Setup: auction tokens ////////////////////////////////// + IEnglishAuctions.AuctionParameters memory auctionParams = IEnglishAuctions.AuctionParameters( + assetContract, + tokenId, + quantity, + currency, + minimumBidAmount, + buyoutBidAmount, + timeBufferInSeconds, + bidBufferBps, + startTimestamp, + endTimestamp + ); + vm.prank(seller); + uint256 auctionId = EnglishAuctionsLogic(marketplace).createAuction(auctionParams); + + /////////////////////////// Setup: marketplace has currency ///////////// + uint256 mbalance = 100 ether; + erc20.mint(marketplace, mbalance); + + /////////////////////////// Attack: win to drain /////////////////////////////////////////// + + // 1. Buy out the token. + assertEq(erc20.balanceOf(seller), 0); + erc20.mint(seller, buyoutBidAmount); + assertEq(erc20.balanceOf(seller), buyoutBidAmount); + + vm.startPrank(seller); + + erc20.approve(marketplace, buyoutBidAmount); + EnglishAuctionsLogic(marketplace).bidInAuction(auctionId, buyoutBidAmount); + + // 2. Collect their own bid. + EnglishAuctionsLogic(marketplace).collectAuctionPayout(auctionId); + assertEq(erc20.balanceOf(seller), buyoutBidAmount); + + // 3. Profit. (FIXED) + + vm.expectRevert("Marketplace: payout already completed."); + EnglishAuctionsLogic(marketplace).collectAuctionPayout(auctionId); + // EnglishAuctionsLogic(marketplace).collectAuctionPayout(auctionId); + // assertEq(erc20.balanceOf(seller), buyoutBidAmount + mbalance); + } +} + +contract BreitwieserTheBidder is BaseTest, IExtension { + // Target contract + address public marketplace; + + // Participants + address public marketplaceDeployer; + address public seller; + address public buyer; + + function setUp() public override { + super.setUp(); + + marketplaceDeployer = getActor(1); + seller = getActor(2); + buyer = getActor(3); + + // Deploy implementation. + Extension[] memory extensions = _setupExtensions(); + address impl = address( + new MarketplaceV3(MarketplaceV3.MarketplaceConstructorParams(extensions, address(0), address(weth))) + ); + + vm.prank(marketplaceDeployer); + marketplace = address( + new TWProxy( + impl, + abi.encodeCall( + MarketplaceV3.initialize, + (marketplaceDeployer, "", new address[](0), marketplaceDeployer, 0) + ) + ) + ); + + // Setup roles for seller and assets + vm.startPrank(marketplaceDeployer); + Permissions(marketplace).revokeRole(keccak256("ASSET_ROLE"), address(0)); + Permissions(marketplace).revokeRole(keccak256("LISTER_ROLE"), address(0)); + Permissions(marketplace).grantRole(keccak256("LISTER_ROLE"), seller); + Permissions(marketplace).grantRole(keccak256("ASSET_ROLE"), address(erc721)); + Permissions(marketplace).grantRole(keccak256("ASSET_ROLE"), address(erc1155)); + + vm.stopPrank(); + + vm.label(impl, "MarketplaceV3_Impl"); + vm.label(marketplace, "Marketplace"); + vm.label(seller, "Seller"); + vm.label(buyer, "Buyer"); + vm.label(address(erc721), "ERC721_Token"); + vm.label(address(erc1155), "ERC1155_Token"); + } + + function _setupExtensions() internal returns (Extension[] memory extensions) { + extensions = new Extension[](1); + + // Deploy `EnglishAuctions` + address englishAuctions = address(new EnglishAuctionsLogic(address(weth))); + vm.label(englishAuctions, "EnglishAuctions_Extension"); + + // Extension: EnglishAuctionsLogic + Extension memory extension_englishAuctions; + extension_englishAuctions.metadata = ExtensionMetadata({ + name: "EnglishAuctionsLogic", + metadataURI: "ipfs://EnglishAuctions", + implementation: englishAuctions + }); + + extension_englishAuctions.functions = new ExtensionFunction[](12); + extension_englishAuctions.functions[0] = ExtensionFunction( + EnglishAuctionsLogic.totalAuctions.selector, + "totalAuctions()" + ); + extension_englishAuctions.functions[1] = ExtensionFunction( + EnglishAuctionsLogic.createAuction.selector, + "createAuction((address,uint256,uint256,address,uint256,uint256,uint64,uint64,uint64,uint64))" + ); + extension_englishAuctions.functions[2] = ExtensionFunction( + EnglishAuctionsLogic.cancelAuction.selector, + "cancelAuction(uint256)" + ); + extension_englishAuctions.functions[3] = ExtensionFunction( + EnglishAuctionsLogic.collectAuctionPayout.selector, + "collectAuctionPayout(uint256)" + ); + extension_englishAuctions.functions[4] = ExtensionFunction( + EnglishAuctionsLogic.collectAuctionTokens.selector, + "collectAuctionTokens(uint256)" + ); + extension_englishAuctions.functions[5] = ExtensionFunction( + EnglishAuctionsLogic.bidInAuction.selector, + "bidInAuction(uint256,uint256)" + ); + extension_englishAuctions.functions[6] = ExtensionFunction( + EnglishAuctionsLogic.isNewWinningBid.selector, + "isNewWinningBid(uint256,uint256)" + ); + extension_englishAuctions.functions[7] = ExtensionFunction( + EnglishAuctionsLogic.getAuction.selector, + "getAuction(uint256)" + ); + extension_englishAuctions.functions[8] = ExtensionFunction( + EnglishAuctionsLogic.getAllAuctions.selector, + "getAllAuctions(uint256,uint256)" + ); + extension_englishAuctions.functions[9] = ExtensionFunction( + EnglishAuctionsLogic.getAllValidAuctions.selector, + "getAllValidAuctions(uint256,uint256)" + ); + extension_englishAuctions.functions[10] = ExtensionFunction( + EnglishAuctionsLogic.getWinningBid.selector, + "getWinningBid(uint256)" + ); + extension_englishAuctions.functions[11] = ExtensionFunction( + EnglishAuctionsLogic.isAuctionExpired.selector, + "isAuctionExpired(uint256)" + ); + + extensions[0] = extension_englishAuctions; + } + + function _setupERC721BalanceForSeller(address _seller, uint256 _numOfTokens) private { + erc721.mint(_seller, _numOfTokens); + } + + function test_rob_as_bidder() public { + address attacker = address(0xbeef); + vm.prank(marketplaceDeployer); + Permissions(marketplace).grantRole(keccak256("LISTER_ROLE"), attacker); + + // Condition: multiple copies in circulation and attacker has at least 1. + uint256 tokenId = 999; + // Victim. + erc1155.mint(seller, tokenId, 1); + erc1155.mint(attacker, tokenId, 1); + + ////////////////// Setup: auction 1 ////////////////// + + IEnglishAuctions.AuctionParameters memory auctionParams1; + { + address assetContract = address(erc1155); + address currency = address(erc20); + uint256 minimumBidAmount = 1 ether; + uint256 buyoutBidAmount = 10 ether; + uint256 qty = 1; + uint64 timeBufferInSeconds = 10 seconds; + uint64 bidBufferBps = 1000; + uint64 startTimestamp = 0; + uint64 endTimestamp = 200; + auctionParams1 = IEnglishAuctions.AuctionParameters( + assetContract, + tokenId, + qty, + currency, + minimumBidAmount, + buyoutBidAmount, + timeBufferInSeconds, + bidBufferBps, + startTimestamp, + endTimestamp + ); + } + + vm.startPrank(seller); + + erc1155.setApprovalForAll(marketplace, true); + EnglishAuctionsLogic(marketplace).createAuction(auctionParams1); + + assertEq(erc1155.balanceOf(marketplace, tokenId), 1, "Marketplace should have the token."); + + vm.stopPrank(); + + ////////////////// Attack: auction the 2nd and steal the 1st token ////////////////// + + // 1. Set up auction. + erc20.mint(attacker, 1); + + vm.startPrank(attacker); + + erc1155.setApprovalForAll(marketplace, true); + + IEnglishAuctions.AuctionParameters memory auctionParams2; + { + address assetContract = address(erc1155); + address currency = address(erc20); + uint256 minimumBidAmount = 1; + uint256 buyoutBidAmount = 1; + uint64 timeBufferInSeconds = 10 seconds; + uint64 bidBufferBps = 1000; + uint64 startTimestamp = 0; + uint64 endTimestamp = 200; + auctionParams2 = IEnglishAuctions.AuctionParameters( + assetContract, + tokenId, + 1, + currency, + minimumBidAmount, + buyoutBidAmount, + timeBufferInSeconds, + bidBufferBps, + startTimestamp, + endTimestamp + ); + } + uint256 auctionId2 = EnglishAuctionsLogic(marketplace).createAuction(auctionParams2); + + assertEq(erc1155.balanceOf(marketplace, tokenId), 2, "Marketplace should have 2 tokens."); + + // 2. Bid and collect back token. + erc20.increaseAllowance(marketplace, 1); + // Bid a small amount: 1 wei. + EnglishAuctionsLogic(marketplace).bidInAuction(auctionId2, 1); + + assertEq(erc1155.balanceOf(attacker, tokenId), 1, "Attack should have collected back their token."); + + // Note: Attacker does not collect payout, it sets auction quantity to 0 and prevent further token collections. + + // 3. Fixed: Profit. + assertEq(erc1155.balanceOf(marketplace, tokenId), 1); + + vm.expectRevert("Marketplace: payout already completed."); + EnglishAuctionsLogic(marketplace).collectAuctionTokens(auctionId2); + + // assertEq(erc1155.balanceOf(attacker, tokenId), 2, "Attacker should have collected the 2nd token for free."); + + vm.stopPrank(); + } +} + +contract IssueC3_MarketplaceEnglishAuctionsTest is BaseTest, IExtension { + // Target contract + address public marketplace; + + // Participants + address public marketplaceDeployer; + address public seller; + address public buyer; + + function setUp() public override { + super.setUp(); + + marketplaceDeployer = getActor(1); + seller = getActor(2); + buyer = getActor(3); + + // Deploy implementation. + Extension[] memory extensions = _setupExtensions(); + address impl = address( + new MarketplaceV3(MarketplaceV3.MarketplaceConstructorParams(extensions, address(0), address(weth))) + ); + + vm.prank(marketplaceDeployer); + marketplace = address( + new TWProxy( + impl, + abi.encodeCall( + MarketplaceV3.initialize, + (marketplaceDeployer, "", new address[](0), marketplaceDeployer, 0) + ) + ) + ); + + // Setup roles for seller and assets + vm.startPrank(marketplaceDeployer); + Permissions(marketplace).revokeRole(keccak256("ASSET_ROLE"), address(0)); + Permissions(marketplace).revokeRole(keccak256("LISTER_ROLE"), address(0)); + Permissions(marketplace).grantRole(keccak256("LISTER_ROLE"), seller); + Permissions(marketplace).grantRole(keccak256("ASSET_ROLE"), address(erc721)); + Permissions(marketplace).grantRole(keccak256("ASSET_ROLE"), address(erc1155)); + + vm.stopPrank(); + + vm.label(impl, "MarketplaceV3_Impl"); + vm.label(marketplace, "Marketplace"); + vm.label(seller, "Seller"); + vm.label(buyer, "Buyer"); + vm.label(address(erc721), "ERC721_Token"); + vm.label(address(erc1155), "ERC1155_Token"); + } + + function _setupExtensions() internal returns (Extension[] memory extensions) { + extensions = new Extension[](1); + + // Deploy `EnglishAuctions` + address englishAuctions = address(new EnglishAuctionsLogic(address(weth))); + vm.label(englishAuctions, "EnglishAuctions_Extension"); + + // Extension: EnglishAuctionsLogic + Extension memory extension_englishAuctions; + extension_englishAuctions.metadata = ExtensionMetadata({ + name: "EnglishAuctionsLogic", + metadataURI: "ipfs://EnglishAuctions", + implementation: englishAuctions + }); + + extension_englishAuctions.functions = new ExtensionFunction[](12); + extension_englishAuctions.functions[0] = ExtensionFunction( + EnglishAuctionsLogic.totalAuctions.selector, + "totalAuctions()" + ); + extension_englishAuctions.functions[1] = ExtensionFunction( + EnglishAuctionsLogic.createAuction.selector, + "createAuction((address,uint256,uint256,address,uint256,uint256,uint64,uint64,uint64,uint64))" + ); + extension_englishAuctions.functions[2] = ExtensionFunction( + EnglishAuctionsLogic.cancelAuction.selector, + "cancelAuction(uint256)" + ); + extension_englishAuctions.functions[3] = ExtensionFunction( + EnglishAuctionsLogic.collectAuctionPayout.selector, + "collectAuctionPayout(uint256)" + ); + extension_englishAuctions.functions[4] = ExtensionFunction( + EnglishAuctionsLogic.collectAuctionTokens.selector, + "collectAuctionTokens(uint256)" + ); + extension_englishAuctions.functions[5] = ExtensionFunction( + EnglishAuctionsLogic.bidInAuction.selector, + "bidInAuction(uint256,uint256)" + ); + extension_englishAuctions.functions[6] = ExtensionFunction( + EnglishAuctionsLogic.isNewWinningBid.selector, + "isNewWinningBid(uint256,uint256)" + ); + extension_englishAuctions.functions[7] = ExtensionFunction( + EnglishAuctionsLogic.getAuction.selector, + "getAuction(uint256)" + ); + extension_englishAuctions.functions[8] = ExtensionFunction( + EnglishAuctionsLogic.getAllAuctions.selector, + "getAllAuctions(uint256,uint256)" + ); + extension_englishAuctions.functions[9] = ExtensionFunction( + EnglishAuctionsLogic.getAllValidAuctions.selector, + "getAllValidAuctions(uint256,uint256)" + ); + extension_englishAuctions.functions[10] = ExtensionFunction( + EnglishAuctionsLogic.getWinningBid.selector, + "getWinningBid(uint256)" + ); + extension_englishAuctions.functions[11] = ExtensionFunction( + EnglishAuctionsLogic.isAuctionExpired.selector, + "isAuctionExpired(uint256)" + ); + + extensions[0] = extension_englishAuctions; + } + + function _setupERC1155BalanceForSeller(address _seller, uint256 _numOfTokens) private { + erc1155.mint(_seller, 0, _numOfTokens); + } + + function _setup_newAuction_1155() private returns (uint256 auctionId) { + // Sample auction parameters. + address assetContract = address(erc1155); + uint256 tokenId = 0; + uint256 quantity = 2; + address currency = address(erc20); + uint256 minimumBidAmount = 1 ether; + uint256 buyoutBidAmount = 10 ether; + uint64 timeBufferInSeconds = 10 seconds; + uint64 bidBufferBps = 1000; + uint64 startTimestamp = 100; + uint64 endTimestamp = 200; + + // Mint the erc1155 tokens to seller. These tokens will be auctioned. + _setupERC1155BalanceForSeller(seller, 2); + + // Approve Marketplace to transfer token. + vm.prank(seller); + erc1155.setApprovalForAll(marketplace, true); + + // Auction tokens. + IEnglishAuctions.AuctionParameters memory auctionParams = IEnglishAuctions.AuctionParameters( + assetContract, + tokenId, + quantity, + currency, + minimumBidAmount, + buyoutBidAmount, + timeBufferInSeconds, + bidBufferBps, + startTimestamp, + endTimestamp + ); + + vm.prank(seller); + auctionId = EnglishAuctionsLogic(marketplace).createAuction(auctionParams); + } + + function test_state_collectAuctionTokens_afterAuctionPayout() public { + uint256 auctionId = _setup_newAuction_1155(); + IEnglishAuctions.Auction memory existingAuction = EnglishAuctionsLogic(marketplace).getAuction(auctionId); + + // Verify existing auction at `auctionId` + assertEq(existingAuction.assetContract, address(erc1155)); + + vm.warp(existingAuction.startTimestamp); + + // place bid + erc20.mint(buyer, 5 ether); + vm.startPrank(buyer); + erc20.approve(marketplace, 5 ether); + EnglishAuctionsLogic(marketplace).bidInAuction(auctionId, 5 ether); + vm.stopPrank(); + + (address bidder, address currency, uint256 bidAmount) = EnglishAuctionsLogic(marketplace).getWinningBid( + auctionId + ); + + // Seller is owner of token. + assertEq(erc20.balanceOf(marketplace), 5 ether); + assertEq(erc20.balanceOf(buyer), 0); + assertEq(buyer, bidder); + assertEq(currency, address(erc20)); + assertEq(bidAmount, 5 ether); + + vm.warp(existingAuction.endTimestamp); + + // collect auction payout + vm.prank(seller); + EnglishAuctionsLogic(marketplace).collectAuctionPayout(auctionId); + + // collect buyer token + vm.prank(buyer); + EnglishAuctionsLogic(marketplace).collectAuctionTokens(auctionId); + + // token is NOT stuck in the marketplace + assertEq(erc1155.balanceOf(marketplace, 0), 0); + assertEq(erc1155.balanceOf(buyer, 0), 2); + } +} diff --git a/src/test/marketplace/Offers.t.sol b/src/test/marketplace/Offers.t.sol new file mode 100644 index 000000000..86f1a918c --- /dev/null +++ b/src/test/marketplace/Offers.t.sol @@ -0,0 +1,1081 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +// Test helper imports +import "../utils/BaseTest.sol"; + +// Test contracts and interfaces + +import { PluginMap, IPluginMap } from "contracts/extension/plugin/PluginMap.sol"; +import { RoyaltyPaymentsLogic } from "contracts/extension/plugin/RoyaltyPayments.sol"; +import { MarketplaceV3, IPlatformFee } from "contracts/prebuilts/marketplace/entrypoint/MarketplaceV3.sol"; +import { OffersLogic } from "contracts/prebuilts/marketplace/offers/OffersLogic.sol"; +import { TWProxy } from "contracts/infra/TWProxy.sol"; +import { ERC721Base } from "contracts/base/ERC721Base.sol"; +import { MockRoyaltyEngineV1 } from "../mocks/MockRoyaltyEngineV1.sol"; + +import { IOffers } from "contracts/prebuilts/marketplace/IMarketplace.sol"; + +import "@thirdweb-dev/dynamic-contracts/src/interface/IExtension.sol"; + +contract MarketplaceOffersTest is BaseTest, IExtension { + // Target contract + address public marketplace; + + // Participants + address public marketplaceDeployer; + address public seller; + address public buyer; + + function setUp() public override { + super.setUp(); + + marketplaceDeployer = getActor(1); + seller = getActor(2); + buyer = getActor(3); + + // Deploy implementation. + Extension[] memory extensions = _setupExtensions(); + address impl = address( + new MarketplaceV3(MarketplaceV3.MarketplaceConstructorParams(extensions, address(0), address(weth))) + ); + + vm.prank(marketplaceDeployer); + marketplace = address( + new TWProxy( + impl, + abi.encodeCall( + MarketplaceV3.initialize, + (marketplaceDeployer, "", new address[](0), marketplaceDeployer, 0) + ) + ) + ); + + // Setup roles for seller and assets + vm.startPrank(marketplaceDeployer); + Permissions(marketplace).revokeRole(keccak256("ASSET_ROLE"), address(0)); + Permissions(marketplace).grantRole(keccak256("ASSET_ROLE"), address(erc721)); + Permissions(marketplace).grantRole(keccak256("ASSET_ROLE"), address(erc1155)); + + vm.stopPrank(); + + vm.label(impl, "MarketplaceV3_Impl"); + vm.label(marketplace, "Marketplace"); + vm.label(seller, "Seller"); + vm.label(buyer, "Buyer"); + vm.label(address(erc721), "ERC721_Token"); + vm.label(address(erc1155), "ERC1155_Token"); + } + + function _setupExtensions() internal returns (Extension[] memory extensions) { + extensions = new Extension[](1); + + // Deploy `Offers` + address offers = address(new OffersLogic()); + vm.label(offers, "Offers_Extension"); + + // Extension: OffersLogic + Extension memory extension_offers; + extension_offers.metadata = ExtensionMetadata({ + name: "OffersLogic", + metadataURI: "ipfs://Offers", + implementation: offers + }); + + extension_offers.functions = new ExtensionFunction[](7); + extension_offers.functions[0] = ExtensionFunction(OffersLogic.totalOffers.selector, "totalOffers()"); + extension_offers.functions[1] = ExtensionFunction( + OffersLogic.makeOffer.selector, + "makeOffer((address,uint256,uint256,address,uint256,uint256))" + ); + extension_offers.functions[2] = ExtensionFunction(OffersLogic.cancelOffer.selector, "cancelOffer(uint256)"); + extension_offers.functions[3] = ExtensionFunction(OffersLogic.acceptOffer.selector, "acceptOffer(uint256)"); + extension_offers.functions[4] = ExtensionFunction( + OffersLogic.getAllValidOffers.selector, + "getAllValidOffers(uint256,uint256)" + ); + extension_offers.functions[5] = ExtensionFunction( + OffersLogic.getAllOffers.selector, + "getAllOffers(uint256,uint256)" + ); + extension_offers.functions[6] = ExtensionFunction(OffersLogic.getOffer.selector, "getOffer(uint256)"); + + extensions[0] = extension_offers; + } + + function test_state_initial() public { + uint256 totalOffers = OffersLogic(marketplace).totalOffers(); + assertEq(totalOffers, 0); + } + + /*/////////////////////////////////////////////////////////////// + Royalty Tests (incl Royalty Engine / Registry) + //////////////////////////////////////////////////////////////*/ + + function _setupRoyaltyEngine() + private + returns ( + MockRoyaltyEngineV1 royaltyEngine, + address payable[] memory mockRecipients, + uint256[] memory mockAmounts + ) + { + mockRecipients = new address payable[](2); + mockAmounts = new uint256[](2); + + mockRecipients[0] = payable(address(0x12345)); + mockRecipients[1] = payable(address(0x56789)); + + mockAmounts[0] = 10; + mockAmounts[1] = 15; + + royaltyEngine = new MockRoyaltyEngineV1(mockRecipients, mockAmounts); + } + + function _setupOfferForRoyaltyTests(address erc721TokenAddress) private returns (uint256 offerId) { + // Sample offer parameters. + address assetContract = erc721TokenAddress; + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = address(erc20); + uint256 totalPrice = 1 ether; + uint256 expirationTimestamp = 200; + + // mint total-price to buyer + erc20.mint(buyer, totalPrice); + + // Approve Marketplace to transfer currency tokens. + vm.prank(buyer); + erc20.approve(marketplace, totalPrice); + + // Make offer. + IOffers.OfferParams memory offerParams = IOffers.OfferParams( + assetContract, + tokenId, + quantity, + currency, + totalPrice, + expirationTimestamp + ); + + vm.prank(buyer); + offerId = OffersLogic(marketplace).makeOffer(offerParams); + } + + function _acceptOfferForRoyaltyTests(uint256 offerId) private returns (uint256 totalPrice) { + IOffers.Offer memory offer = OffersLogic(marketplace).getOffer(offerId); + + totalPrice = offer.totalPrice; + + // Approve Marketplace to transfer token. + vm.prank(seller); + IERC721(offer.assetContract).setApprovalForAll(marketplace, true); + + // Accept offer + vm.prank(seller); + OffersLogic(marketplace).acceptOffer(offerId); + } + + function test_royaltyEngine_tokenWithCustomRoyalties() public { + ( + MockRoyaltyEngineV1 royaltyEngine, + address payable[] memory customRoyaltyRecipients, + uint256[] memory customRoyaltyAmounts + ) = _setupRoyaltyEngine(); + + // Add RoyaltyEngine to marketplace + vm.prank(marketplaceDeployer); + RoyaltyPaymentsLogic(marketplace).setRoyaltyEngine(address(royaltyEngine)); + + assertEq(RoyaltyPaymentsLogic(marketplace).getRoyaltyEngineAddress(), address(royaltyEngine)); + + // 1. ========= Make offer ========= + + uint256 offerId = _setupOfferForRoyaltyTests(address(erc721)); + + // 2. ========= Accept offer ========= + + // Mint the ERC721 tokens to seller. These tokens will be sold. + erc721.mint(seller, 1); + uint256 totalPrice = _acceptOfferForRoyaltyTests(offerId); + + // 3. ======== Check balances after royalty payments ======== + + { + // Royalty recipients receive correct amounts + assertBalERC20Eq(address(erc20), customRoyaltyRecipients[0], customRoyaltyAmounts[0]); + assertBalERC20Eq(address(erc20), customRoyaltyRecipients[1], customRoyaltyAmounts[1]); + + // Seller gets total price minus royalty amounts + assertBalERC20Eq(address(erc20), seller, totalPrice - customRoyaltyAmounts[0] - customRoyaltyAmounts[1]); + } + } + + function test_royaltyEngine_tokenWithERC2981() public { + (MockRoyaltyEngineV1 royaltyEngine, , ) = _setupRoyaltyEngine(); + + // Add RoyaltyEngine to marketplace + vm.prank(marketplaceDeployer); + RoyaltyPaymentsLogic(marketplace).setRoyaltyEngine(address(royaltyEngine)); + + assertEq(RoyaltyPaymentsLogic(marketplace).getRoyaltyEngineAddress(), address(royaltyEngine)); + + // create token with ERC2981 + address royaltyRecipient = address(0x12345); + uint128 royaltyBps = 10; + ERC721Base nft2981 = new ERC721Base(address(0x12345), "NFT 2981", "NFT2981", royaltyRecipient, royaltyBps); + // Mint the ERC721 tokens to seller. These tokens will be sold. + vm.prank(address(0x12345)); + nft2981.mintTo(seller, ""); + + vm.prank(marketplaceDeployer); + Permissions(marketplace).grantRole(keccak256("ASSET_ROLE"), address(nft2981)); + + // 1. ========= Make offer ========= + + uint256 offerId = _setupOfferForRoyaltyTests(address(nft2981)); + + // 2. ========= Accept offer ========= + + uint256 totalPrice = _acceptOfferForRoyaltyTests(offerId); + + // 3. ======== Check balances after royalty payments ======== + + { + uint256 royaltyAmount = (royaltyBps * totalPrice) / 10_000; + // Royalty recipient receives correct amounts + assertBalERC20Eq(address(erc20), royaltyRecipient, royaltyAmount); + + // Seller gets total price minus royalty amount + assertBalERC20Eq(address(erc20), seller, totalPrice - royaltyAmount); + } + } + + function test_noRoyaltyEngine_defaultERC2981Token() public { + // create token with ERC2981 + address royaltyRecipient = address(0x12345); + uint128 royaltyBps = 10; + ERC721Base nft2981 = new ERC721Base(address(0x12345), "NFT 2981", "NFT2981", royaltyRecipient, royaltyBps); + vm.prank(address(0x12345)); + nft2981.mintTo(seller, ""); + + vm.prank(marketplaceDeployer); + Permissions(marketplace).grantRole(keccak256("ASSET_ROLE"), address(nft2981)); + + // 1. ========= Make offer ========= + + uint256 offerId = _setupOfferForRoyaltyTests(address(nft2981)); + + // 2. ========= Accept offer ========= + + uint256 totalPrice = _acceptOfferForRoyaltyTests(offerId); + + // 3. ======== Check balances after royalty payments ======== + + { + uint256 royaltyAmount = (royaltyBps * totalPrice) / 10_000; + // Royalty recipient receives correct amounts + assertBalERC20Eq(address(erc20), royaltyRecipient, royaltyAmount); + + // Seller gets total price minus royalty amount + assertBalERC20Eq(address(erc20), seller, totalPrice - royaltyAmount); + } + } + + function test_royaltyEngine_correctlyDistributeAllFees() public { + ( + MockRoyaltyEngineV1 royaltyEngine, + address payable[] memory customRoyaltyRecipients, + uint256[] memory customRoyaltyAmounts + ) = _setupRoyaltyEngine(); + + // Add RoyaltyEngine to marketplace + vm.prank(marketplaceDeployer); + RoyaltyPaymentsLogic(marketplace).setRoyaltyEngine(address(royaltyEngine)); + + assertEq(RoyaltyPaymentsLogic(marketplace).getRoyaltyEngineAddress(), address(royaltyEngine)); + + // Set platform fee on marketplace + address platformFeeRecipient = marketplaceDeployer; + uint128 platformFeeBps = 5; + vm.prank(marketplaceDeployer); + IPlatformFee(marketplace).setPlatformFeeInfo(platformFeeRecipient, platformFeeBps); + + // 1. ========= Make offer ========= + + uint256 offerId = _setupOfferForRoyaltyTests(address(erc721)); + + // 2. ========= Accept offer ========= + + // Mint the ERC721 tokens to seller. These tokens will be sold. + erc721.mint(seller, 1); + uint256 totalPrice = _acceptOfferForRoyaltyTests(offerId); + + // 3. ======== Check balances after royalty payments ======== + + { + // Royalty recipients receive correct amounts + assertBalERC20Eq(address(erc20), customRoyaltyRecipients[0], customRoyaltyAmounts[0]); + assertBalERC20Eq(address(erc20), customRoyaltyRecipients[1], customRoyaltyAmounts[1]); + + // Platform fee recipient + uint256 platformFeeAmount = (platformFeeBps * totalPrice) / 10_000; + assertBalERC20Eq(address(erc20), platformFeeRecipient, platformFeeAmount); + + // Seller gets total price minus royalty amounts + assertBalERC20Eq( + address(erc20), + seller, + totalPrice - customRoyaltyAmounts[0] - customRoyaltyAmounts[1] - platformFeeAmount + ); + } + } + + function test_revert_feesExceedTotalPrice() public { + (MockRoyaltyEngineV1 royaltyEngine, , ) = _setupRoyaltyEngine(); + + // Add RoyaltyEngine to marketplace + vm.prank(marketplaceDeployer); + RoyaltyPaymentsLogic(marketplace).setRoyaltyEngine(address(royaltyEngine)); + + assertEq(RoyaltyPaymentsLogic(marketplace).getRoyaltyEngineAddress(), address(royaltyEngine)); + + // Set platform fee on marketplace + address platformFeeRecipient = marketplaceDeployer; + uint128 platformFeeBps = 10_000; // equal to max bps 10_000 or 100% + vm.prank(marketplaceDeployer); + IPlatformFee(marketplace).setPlatformFeeInfo(platformFeeRecipient, platformFeeBps); + + // 1. ========= Make offer ========= + + uint256 offerId = _setupOfferForRoyaltyTests(address(erc721)); + + // 2. ========= Accept offer ========= + + // Mint the ERC721 tokens to seller. These tokens will be sold. + erc721.mint(seller, 1); + + IOffers.Offer memory offer = OffersLogic(marketplace).getOffer(offerId); + + // Approve Marketplace to transfer token. + vm.prank(seller); + IERC721(offer.assetContract).setApprovalForAll(marketplace, true); + + // Accept offer + vm.expectRevert("fees exceed the price"); + vm.prank(seller); + OffersLogic(marketplace).acceptOffer(offerId); + } + + /*/////////////////////////////////////////////////////////////// + Make Offer + //////////////////////////////////////////////////////////////*/ + + function test_state_makeOffer() public { + // Sample offer parameters. + address assetContract = address(erc721); + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = address(erc20); + uint256 totalPrice = 1 ether; + uint256 expirationTimestamp = 200; + + // mint total-price to buyer + erc20.mint(buyer, totalPrice); + + // Approve Marketplace to transfer currency tokens. + vm.prank(buyer); + erc20.approve(marketplace, totalPrice); + + // Make offer. + IOffers.OfferParams memory offerParams = IOffers.OfferParams( + assetContract, + tokenId, + quantity, + currency, + totalPrice, + expirationTimestamp + ); + + vm.prank(buyer); + uint256 offerId = OffersLogic(marketplace).makeOffer(offerParams); + + // Test consequent state of the contract. + + // Total offers incremented + assertEq(OffersLogic(marketplace).totalOffers(), 1); + + // Fetch listing and verify state. + IOffers.Offer memory offer = OffersLogic(marketplace).getOffer(offerId); + + assertEq(offer.offerId, offerId); + assertEq(offer.offeror, buyer); + assertEq(offer.assetContract, assetContract); + assertEq(offer.tokenId, tokenId); + assertEq(offer.quantity, quantity); + assertEq(offer.currency, currency); + assertEq(offer.totalPrice, totalPrice); + assertEq(offer.expirationTimestamp, expirationTimestamp); + assertEq(uint256(offer.tokenType), uint256(IOffers.TokenType.ERC721)); + } + + function test_revert_makeOffer_notOwnerOfOfferedTokens() public { + // Sample offer parameters. + address assetContract = address(erc721); + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = address(erc20); + uint256 totalPrice = 1 ether; + uint256 expirationTimestamp = 200; + + // Approve Marketplace to transfer currency tokens. (without owning) + vm.prank(buyer); + erc20.approve(marketplace, totalPrice); + + // Make offer. + IOffers.OfferParams memory offerParams = IOffers.OfferParams( + assetContract, + tokenId, + quantity, + currency, + totalPrice, + expirationTimestamp + ); + + vm.prank(buyer); + vm.expectRevert("Marketplace: insufficient currency balance."); + OffersLogic(marketplace).makeOffer(offerParams); + } + + function test_revert_makeOffer_notApprovedMarketplaceToTransferTokens() public { + // Sample offer parameters. + address assetContract = address(erc721); + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = address(erc20); + uint256 totalPrice = 1 ether; + uint256 expirationTimestamp = 200; + + // mint total-price to buyer, but not approved to marketplace + erc20.mint(buyer, totalPrice); + + // Make offer. + IOffers.OfferParams memory offerParams = IOffers.OfferParams( + assetContract, + tokenId, + quantity, + currency, + totalPrice, + expirationTimestamp + ); + + vm.prank(buyer); + vm.expectRevert("Marketplace: insufficient currency balance."); + OffersLogic(marketplace).makeOffer(offerParams); + } + + function test_revert_makeOffer_wantedZeroTokens() public { + // Sample offer parameters. + address assetContract = address(erc721); + uint256 tokenId = 0; + uint256 quantity = 0; + address currency = address(erc20); + uint256 totalPrice = 1 ether; + uint256 expirationTimestamp = 200; + + // mint total-price to buyer + erc20.mint(buyer, totalPrice); + + // Approve Marketplace to transfer currency tokens. + vm.prank(buyer); + erc20.approve(marketplace, totalPrice); + + // Make offer. + IOffers.OfferParams memory offerParams = IOffers.OfferParams( + assetContract, + tokenId, + quantity, + currency, + totalPrice, + expirationTimestamp + ); + + vm.prank(buyer); + vm.expectRevert("Marketplace: wanted zero tokens."); + OffersLogic(marketplace).makeOffer(offerParams); + } + + function test_revert_makeOffer_invalidQuantity() public { + // Sample offer parameters. + address assetContract = address(erc721); + uint256 tokenId = 0; + uint256 quantity = 2; // Asking for more than `1` quantity of erc721 tokenId + address currency = address(erc20); + uint256 totalPrice = 1 ether; + uint256 expirationTimestamp = 200; + + // mint total-price to buyer + erc20.mint(buyer, totalPrice); + + // Approve Marketplace to transfer currency tokens. + vm.prank(buyer); + erc20.approve(marketplace, totalPrice); + + // Make offer. + IOffers.OfferParams memory offerParams = IOffers.OfferParams( + assetContract, + tokenId, + quantity, + currency, + totalPrice, + expirationTimestamp + ); + + vm.prank(buyer); + vm.expectRevert("Marketplace: wanted invalid quantity."); + OffersLogic(marketplace).makeOffer(offerParams); + } + + function test_revert_makeOffer_invalidExpirationTimestamp() public { + uint256 blockTimestamp = 100 minutes; + // Set block.timestamp + vm.warp(blockTimestamp); + + // Sample offer parameters. + address assetContract = address(erc721); + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = address(erc20); + uint256 totalPrice = 1 ether; + uint256 expirationTimestamp = blockTimestamp - 61 minutes; + + // mint total-price to buyer + erc20.mint(buyer, totalPrice); + + // Approve Marketplace to transfer currency tokens. + vm.prank(buyer); + erc20.approve(marketplace, totalPrice); + + // Make offer. + IOffers.OfferParams memory offerParams = IOffers.OfferParams( + assetContract, + tokenId, + quantity, + currency, + totalPrice, + expirationTimestamp + ); + + vm.prank(buyer); + vm.expectRevert("Marketplace: invalid expiration timestamp."); + OffersLogic(marketplace).makeOffer(offerParams); + } + + function test_revert_makeOffer_invalidAssetContract() public { + // Sample offer parameters. + address assetContract = address(erc20); + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = address(erc20); + uint256 totalPrice = 1 ether; + uint256 expirationTimestamp = block.timestamp; + + // mint total-price to buyer + erc20.mint(buyer, totalPrice); + + // Approve Marketplace to transfer currency tokens. + vm.prank(buyer); + erc20.approve(marketplace, totalPrice); + + // Make offer. + IOffers.OfferParams memory offerParams = IOffers.OfferParams( + assetContract, + tokenId, + quantity, + currency, + totalPrice, + expirationTimestamp + ); + + // Grant ERC20 token asset role. + vm.prank(marketplaceDeployer); + Permissions(marketplace).grantRole(keccak256("ASSET_ROLE"), address(erc20)); + + vm.prank(buyer); + vm.expectRevert("Marketplace: token must be ERC1155 or ERC721."); + OffersLogic(marketplace).makeOffer(offerParams); + } + + function test_revert_createListing_noAssetRoleWhenRestrictionsActive() public { + // Sample offer parameters. + address assetContract = address(erc721); + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = address(erc20); + uint256 totalPrice = 1 ether; + uint256 expirationTimestamp = block.timestamp; + + // mint total-price to buyer + erc20.mint(buyer, totalPrice); + + // Approve Marketplace to transfer currency tokens. + vm.prank(buyer); + erc20.approve(marketplace, totalPrice); + + // Make offer. + IOffers.OfferParams memory offerParams = IOffers.OfferParams( + assetContract, + tokenId, + quantity, + currency, + totalPrice, + expirationTimestamp + ); + + // Revoke ASSET_ROLE from token to list. + vm.startPrank(marketplaceDeployer); + assertEq(Permissions(marketplace).hasRole(keccak256("ASSET_ROLE"), address(0)), false); + Permissions(marketplace).revokeRole(keccak256("ASSET_ROLE"), address(erc721)); + assertEq(Permissions(marketplace).hasRole(keccak256("ASSET_ROLE"), address(erc721)), false); + + vm.stopPrank(); + + vm.prank(buyer); + vm.expectRevert("!ASSET_ROLE"); + OffersLogic(marketplace).makeOffer(offerParams); + } + + /*/////////////////////////////////////////////////////////////// + Cancel Offer + //////////////////////////////////////////////////////////////*/ + + function test_state_cancelOffer() public { + // Sample offer parameters. + address assetContract = address(erc721); + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = address(erc20); + uint256 totalPrice = 1 ether; + uint256 expirationTimestamp = 200; + + // mint total-price to buyer + erc20.mint(buyer, totalPrice); + + // Approve Marketplace to transfer currency tokens. + vm.prank(buyer); + erc20.approve(marketplace, totalPrice); + + // Make offer. + IOffers.OfferParams memory offerParams = IOffers.OfferParams( + assetContract, + tokenId, + quantity, + currency, + totalPrice, + expirationTimestamp + ); + + vm.prank(buyer); + uint256 offerId = OffersLogic(marketplace).makeOffer(offerParams); + + IOffers.Offer memory offer = OffersLogic(marketplace).getOffer(offerId); + + assertEq(offer.offerId, offerId); + assertEq(offer.offeror, buyer); + assertEq(offer.assetContract, assetContract); + assertEq(offer.tokenId, tokenId); + assertEq(offer.quantity, quantity); + assertEq(offer.currency, currency); + assertEq(offer.totalPrice, totalPrice); + assertEq(offer.expirationTimestamp, expirationTimestamp); + assertEq(uint256(offer.tokenType), uint256(IOffers.TokenType.ERC721)); + + vm.prank(buyer); + OffersLogic(marketplace).cancelOffer(offerId); + + // Total offers count shouldn't change + assertEq(OffersLogic(marketplace).totalOffers(), 1); + + // status should be `CANCELLED` + IOffers.Offer memory cancelledOffer = OffersLogic(marketplace).getOffer(offerId); + assertTrue(cancelledOffer.status == IOffers.Status.CANCELLED); + } + + function test_revert_cancelOffer_callerNotOfferor() public { + // Sample offer parameters. + address assetContract = address(erc721); + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = address(erc20); + uint256 totalPrice = 1 ether; + uint256 expirationTimestamp = 200; + + // mint total-price to buyer + erc20.mint(buyer, totalPrice); + + // Approve Marketplace to transfer currency tokens. + vm.prank(buyer); + erc20.approve(marketplace, totalPrice); + + // Make offer. + IOffers.OfferParams memory offerParams = IOffers.OfferParams( + assetContract, + tokenId, + quantity, + currency, + totalPrice, + expirationTimestamp + ); + + vm.prank(buyer); + uint256 offerId = OffersLogic(marketplace).makeOffer(offerParams); + + vm.prank(address(0x345)); + vm.expectRevert("!Offeror"); + OffersLogic(marketplace).cancelOffer(offerId); + } + + /*/////////////////////////////////////////////////////////////// + Accept Offer + //////////////////////////////////////////////////////////////*/ + + function test_state_acceptOffer() public { + // set owner of NFT + erc721.mint(seller, 1); + + // Sample offer parameters. + address assetContract = address(erc721); + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = address(erc20); + uint256 totalPrice = 1 ether; + uint256 expirationTimestamp = 200; + + // mint total-price to buyer + erc20.mint(buyer, totalPrice); + + // Approve Marketplace to transfer currency tokens. + vm.prank(buyer); + erc20.approve(marketplace, totalPrice); + + // Make offer. + IOffers.OfferParams memory offerParams = IOffers.OfferParams( + assetContract, + tokenId, + quantity, + currency, + totalPrice, + expirationTimestamp + ); + + vm.prank(buyer); + uint256 offerId = OffersLogic(marketplace).makeOffer(offerParams); + + // accept offer + vm.startPrank(seller); + erc721.setApprovalForAll(marketplace, true); + OffersLogic(marketplace).acceptOffer(offerId); + vm.stopPrank(); + + // Total offers count shouldn't change + assertEq(OffersLogic(marketplace).totalOffers(), 1); + + // status should be `COMPLETED` + IOffers.Offer memory completedOffer = OffersLogic(marketplace).getOffer(offerId); + assertTrue(completedOffer.status == IOffers.Status.COMPLETED); + + // check states after accepting offer + assertEq(erc721.ownerOf(tokenId), buyer); + assertEq(erc20.balanceOf(seller), totalPrice); + assertEq(erc20.balanceOf(buyer), 0); + } + + function test_revert_acceptOffer_notOwnedRequiredTokens() public { + // set owner of NFT to address other than seller + erc721.mint(address(0x345), 1); + + // Sample offer parameters. + address assetContract = address(erc721); + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = address(erc20); + uint256 totalPrice = 1 ether; + uint256 expirationTimestamp = 200; + + // mint total-price to buyer + erc20.mint(buyer, totalPrice); + + // Approve Marketplace to transfer currency tokens. (but not owned) + vm.prank(buyer); + erc20.approve(marketplace, totalPrice); + + // Make offer. + IOffers.OfferParams memory offerParams = IOffers.OfferParams( + assetContract, + tokenId, + quantity, + currency, + totalPrice, + expirationTimestamp + ); + + vm.prank(buyer); + uint256 offerId = OffersLogic(marketplace).makeOffer(offerParams); + + // accept offer + vm.startPrank(seller); + erc721.setApprovalForAll(marketplace, true); + vm.expectRevert("Marketplace: not owner or approved tokens."); + OffersLogic(marketplace).acceptOffer(offerId); + vm.stopPrank(); + } + + function test_revert_acceptOffer_notApprovedMarketplaceToTransferOfferedTokens() public { + // set owner of NFT + erc721.mint(seller, 1); + + // Sample offer parameters. + address assetContract = address(erc721); + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = address(erc20); + uint256 totalPrice = 1 ether; + uint256 expirationTimestamp = 200; + + // mint total-price to buyer + erc20.mint(buyer, totalPrice); + + // Approve Marketplace to transfer currency tokens. (but not owned) + vm.prank(buyer); + erc20.approve(marketplace, totalPrice); + + // Make offer. + IOffers.OfferParams memory offerParams = IOffers.OfferParams( + assetContract, + tokenId, + quantity, + currency, + totalPrice, + expirationTimestamp + ); + + vm.prank(buyer); + uint256 offerId = OffersLogic(marketplace).makeOffer(offerParams); + + // accept offer, without approving NFT to marketplace + vm.startPrank(seller); + vm.expectRevert("Marketplace: not owner or approved tokens."); + OffersLogic(marketplace).acceptOffer(offerId); + vm.stopPrank(); + } + + function test_revert_acceptOffer_offerorBalanceLessThanPrice() public { + // set owner of NFT + erc721.mint(seller, 1); + + // Sample offer parameters. + address assetContract = address(erc721); + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = address(erc20); + uint256 totalPrice = 1 ether; + uint256 expirationTimestamp = 200; + + // mint total-price to buyer + erc20.mint(buyer, totalPrice); + + // Approve Marketplace to transfer currency tokens. (but not owned) + vm.prank(buyer); + erc20.approve(marketplace, totalPrice); + + // Make offer. + IOffers.OfferParams memory offerParams = IOffers.OfferParams( + assetContract, + tokenId, + quantity, + currency, + totalPrice, + expirationTimestamp + ); + + vm.prank(buyer); + uint256 offerId = OffersLogic(marketplace).makeOffer(offerParams); + + // reduce erc20 balance of buyer + vm.prank(buyer); + erc20.burn(totalPrice); + + // accept offer + vm.startPrank(seller); + erc721.setApprovalForAll(marketplace, true); + vm.expectRevert("Marketplace: insufficient currency balance."); + OffersLogic(marketplace).acceptOffer(offerId); + vm.stopPrank(); + } + + function test_revert_acceptOffer_notApprovedMarketplaceToTransferPrice() public { + // set owner of NFT + erc721.mint(seller, 1); + + // Sample offer parameters. + address assetContract = address(erc721); + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = address(erc20); + uint256 totalPrice = 1 ether; + uint256 expirationTimestamp = 200; + + // mint total-price to buyer + erc20.mint(buyer, totalPrice); + + // Approve Marketplace to transfer currency tokens. (but not owned) + vm.prank(buyer); + erc20.approve(marketplace, totalPrice); + + // Make offer. + IOffers.OfferParams memory offerParams = IOffers.OfferParams( + assetContract, + tokenId, + quantity, + currency, + totalPrice, + expirationTimestamp + ); + + vm.prank(buyer); + uint256 offerId = OffersLogic(marketplace).makeOffer(offerParams); + + // remove erc20 approval + vm.prank(buyer); + erc20.approve(marketplace, 0); + + // accept offer + vm.startPrank(seller); + erc721.setApprovalForAll(marketplace, true); + vm.expectRevert("Marketplace: insufficient currency balance."); + OffersLogic(marketplace).acceptOffer(offerId); + vm.stopPrank(); + } + + /*/////////////////////////////////////////////////////////////// + View functions + //////////////////////////////////////////////////////////////*/ + + function test_state_getAllOffers() public { + uint256[] memory offerIds = new uint256[](5); + uint256[] memory tokenIds = new uint256[](5); + + // mint total-price to buyer + erc20.mint(buyer, 1000 ether); + + // Approve Marketplace to transfer currency tokens. (but not owned) + vm.prank(buyer); + erc20.approve(marketplace, 1000 ether); + + // Sample offer parameters. + address assetContract = address(erc721); + uint256 quantity = 1; + address currency = address(erc20); + uint256 totalPrice = 1 ether; + uint256 expirationTimestamp = 200; + + IOffers.OfferParams memory offerParams; + + for (uint256 i = 0; i < 5; i += 1) { + tokenIds[i] = i; + + // make offer + offerParams = IOffers.OfferParams( + assetContract, + tokenIds[i], + quantity, + currency, + totalPrice, + expirationTimestamp + ); + + vm.prank(buyer); + offerIds[i] = OffersLogic(marketplace).makeOffer(offerParams); + } + + IOffers.Offer[] memory allOffers = OffersLogic(marketplace).getAllOffers(0, 4); + assertEq(allOffers.length, 5); + + for (uint256 i = 0; i < 5; i += 1) { + assertEq(allOffers[i].offerId, offerIds[i]); + assertEq(allOffers[i].offeror, buyer); + assertEq(allOffers[i].assetContract, assetContract); + assertEq(allOffers[i].tokenId, tokenIds[i]); + assertEq(allOffers[i].quantity, quantity); + assertEq(allOffers[i].currency, currency); + assertEq(allOffers[i].totalPrice, totalPrice); + assertEq(allOffers[i].expirationTimestamp, expirationTimestamp); + assertEq(uint256(allOffers[i].tokenType), uint256(IOffers.TokenType.ERC721)); + } + } + + function test_state_getAllValidOffers() public { + uint256[] memory offerIds = new uint256[](5); + uint256[] memory tokenIds = new uint256[](5); + + // mint total-price to buyer + erc20.mint(buyer, 5 ether); + + // Approve Marketplace to transfer currency tokens. (but not owned) + vm.prank(buyer); + erc20.approve(marketplace, 5 ether); + + // Sample offer parameters. + address assetContract = address(erc721); + uint256 quantity = 1; + address currency = address(erc20); + uint256 expirationTimestamp = 200; + + IOffers.OfferParams memory offerParams; + + for (uint256 i = 0; i < 5; i += 1) { + tokenIds[i] = i; + + // make offer, with total-price as i + offerParams = IOffers.OfferParams( + assetContract, + tokenIds[i], + quantity, + currency, + (i + 1) * 1 ether, + expirationTimestamp + ); + + vm.prank(buyer); + offerIds[i] = OffersLogic(marketplace).makeOffer(offerParams); + } + + vm.prank(buyer); + erc20.burn(2 ether); // reduce balance to make some offers invalid + + IOffers.Offer[] memory allOffers = OffersLogic(marketplace).getAllValidOffers(0, 4); + assertEq(allOffers.length, 3); + + for (uint256 i = 0; i < 3; i += 1) { + assertEq(allOffers[i].offerId, offerIds[i]); + assertEq(allOffers[i].offeror, buyer); + assertEq(allOffers[i].assetContract, assetContract); + assertEq(allOffers[i].tokenId, tokenIds[i]); + assertEq(allOffers[i].quantity, quantity); + assertEq(allOffers[i].currency, currency); + assertEq(allOffers[i].totalPrice, (i + 1) * 1 ether); + assertEq(allOffers[i].expirationTimestamp, expirationTimestamp); + assertEq(uint256(allOffers[i].tokenType), uint256(IOffers.TokenType.ERC721)); + } + + // create an offer, and check the offers returned post its expiry + offerParams = IOffers.OfferParams(assetContract, 5, quantity, currency, 10, 10); + + vm.prank(buyer); + OffersLogic(marketplace).makeOffer(offerParams); + + vm.warp(10); + allOffers = OffersLogic(marketplace).getAllValidOffers(0, 5); + assertEq(allOffers.length, 3); + } +} diff --git a/src/test/marketplace/direct-listings/_payout/_payout.t.sol b/src/test/marketplace/direct-listings/_payout/_payout.t.sol new file mode 100644 index 000000000..3c9d167d9 --- /dev/null +++ b/src/test/marketplace/direct-listings/_payout/_payout.t.sol @@ -0,0 +1,343 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "../../../utils/BaseTest.sol"; +import "@thirdweb-dev/dynamic-contracts/src/interface/IExtension.sol"; + +import { RoyaltyPaymentsLogic } from "contracts/extension/plugin/RoyaltyPayments.sol"; +import { PlatformFee } from "contracts/extension/PlatformFee.sol"; +import { TWProxy } from "contracts/infra/TWProxy.sol"; +import { MarketplaceV3 } from "contracts/prebuilts/marketplace/entrypoint/MarketplaceV3.sol"; +import { DirectListingsLogic } from "contracts/prebuilts/marketplace/direct-listings/DirectListingsLogic.sol"; +import { IDirectListings } from "contracts/prebuilts/marketplace/IMarketplace.sol"; +import { MockRoyaltyEngineV1 } from "../../../mocks/MockRoyaltyEngineV1.sol"; + +contract PayoutTest is BaseTest, IExtension { + // Target contract + address public marketplace; + + // Participants + address public marketplaceDeployer; + address public seller; + address public buyer; + + // Default listing parameters + IDirectListings.ListingParameters internal listingParams; + uint256 internal listingId = 0; + + // Events to test + + /// @notice Emitted when a listing is updated. + event UpdatedListing( + address indexed listingCreator, + uint256 indexed listingId, + address indexed assetContract, + IDirectListings.Listing listing + ); + + function setUp() public override { + super.setUp(); + + marketplaceDeployer = getActor(1); + seller = getActor(2); + buyer = getActor(3); + + // Deploy implementation. + Extension[] memory extensions = _setupExtensions(); + address impl = address( + new MarketplaceV3(MarketplaceV3.MarketplaceConstructorParams(extensions, address(0), address(weth))) + ); + + vm.prank(marketplaceDeployer); + marketplace = address( + new TWProxy( + impl, + abi.encodeCall( + MarketplaceV3.initialize, + (marketplaceDeployer, "", new address[](0), platformFeeRecipient, uint16(platformFeeBps)) + ) + ) + ); + + // Setup listing params + address assetContract = address(erc721); + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = address(erc20); + uint256 pricePerToken = 1 ether; + uint128 startTimestamp = 100 minutes; + uint128 endTimestamp = 200 minutes; + bool reserved = false; + + listingParams = IDirectListings.ListingParameters( + assetContract, + tokenId, + quantity, + currency, + pricePerToken, + startTimestamp, + endTimestamp, + reserved + ); + + // Mint 1 ERC721 NFT to seller + erc721.mint(seller, listingParams.quantity); + + vm.label(impl, "MarketplaceV3_Impl"); + vm.label(marketplace, "Marketplace"); + vm.label(seller, "Seller"); + vm.label(address(erc721), "ERC721_Token"); + vm.label(address(erc1155), "ERC1155_Token"); + } + + function _setupExtensions() internal returns (Extension[] memory extensions) { + extensions = new Extension[](1); + + // Deploy `DirectListings` + address directListings = address(new DirectListingsLogic(address(weth))); + vm.label(directListings, "DirectListings_Extension"); + + // Extension: DirectListingsLogic + Extension memory extension_directListings; + extension_directListings.metadata = ExtensionMetadata({ + name: "DirectListingsLogic", + metadataURI: "ipfs://DirectListings", + implementation: directListings + }); + + extension_directListings.functions = new ExtensionFunction[](13); + extension_directListings.functions[0] = ExtensionFunction( + DirectListingsLogic.totalListings.selector, + "totalListings()" + ); + extension_directListings.functions[1] = ExtensionFunction( + DirectListingsLogic.isBuyerApprovedForListing.selector, + "isBuyerApprovedForListing(uint256,address)" + ); + extension_directListings.functions[2] = ExtensionFunction( + DirectListingsLogic.isCurrencyApprovedForListing.selector, + "isCurrencyApprovedForListing(uint256,address)" + ); + extension_directListings.functions[3] = ExtensionFunction( + DirectListingsLogic.currencyPriceForListing.selector, + "currencyPriceForListing(uint256,address)" + ); + extension_directListings.functions[4] = ExtensionFunction( + DirectListingsLogic.createListing.selector, + "createListing((address,uint256,uint256,address,uint256,uint128,uint128,bool))" + ); + extension_directListings.functions[5] = ExtensionFunction( + DirectListingsLogic.updateListing.selector, + "updateListing(uint256,(address,uint256,uint256,address,uint256,uint128,uint128,bool))" + ); + extension_directListings.functions[6] = ExtensionFunction( + DirectListingsLogic.cancelListing.selector, + "cancelListing(uint256)" + ); + extension_directListings.functions[7] = ExtensionFunction( + DirectListingsLogic.approveBuyerForListing.selector, + "approveBuyerForListing(uint256,address,bool)" + ); + extension_directListings.functions[8] = ExtensionFunction( + DirectListingsLogic.approveCurrencyForListing.selector, + "approveCurrencyForListing(uint256,address,uint256)" + ); + extension_directListings.functions[9] = ExtensionFunction( + DirectListingsLogic.buyFromListing.selector, + "buyFromListing(uint256,address,uint256,address,uint256)" + ); + extension_directListings.functions[10] = ExtensionFunction( + DirectListingsLogic.getAllListings.selector, + "getAllListings(uint256,uint256)" + ); + extension_directListings.functions[11] = ExtensionFunction( + DirectListingsLogic.getAllValidListings.selector, + "getAllValidListings(uint256,uint256)" + ); + extension_directListings.functions[12] = ExtensionFunction( + DirectListingsLogic.getListing.selector, + "getListing(uint256)" + ); + + extensions[0] = extension_directListings; + } + + address payable[] internal mockRecipients; + uint256[] internal mockAmounts; + MockRoyaltyEngineV1 internal royaltyEngine; + + function _setupRoyaltyEngine() private { + mockRecipients.push(payable(address(0x12345))); + mockRecipients.push(payable(address(0x56789))); + + mockAmounts.push(10 ether); + mockAmounts.push(15 ether); + + royaltyEngine = new MockRoyaltyEngineV1(mockRecipients, mockAmounts); + } + + function _setupListingForRoyaltyTests(address erc721TokenAddress) private returns (uint256 _listingId) { + // Sample listing parameters. + address assetContract = erc721TokenAddress; + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = address(erc20); + uint256 pricePerToken = 100 ether; + uint128 startTimestamp = 100; + uint128 endTimestamp = 200; + bool reserved = false; + + // Approve Marketplace to transfer token. + vm.prank(seller); + IERC721(erc721TokenAddress).setApprovalForAll(marketplace, true); + + // List tokens. + IDirectListings.ListingParameters memory listingParameters = IDirectListings.ListingParameters( + assetContract, + tokenId, + quantity, + currency, + pricePerToken, + startTimestamp, + endTimestamp, + reserved + ); + + vm.prank(seller); + _listingId = DirectListingsLogic(marketplace).createListing(listingParameters); + } + + function _buyFromListingForRoyaltyTests(uint256 _listingId) private returns (uint256 totalPrice) { + IDirectListings.Listing memory listing = DirectListingsLogic(marketplace).getListing(_listingId); + + address buyFor = buyer; + uint256 quantityToBuy = listing.quantity; + address currency = listing.currency; + uint256 pricePerToken = listing.pricePerToken; + totalPrice = pricePerToken * quantityToBuy; + + // Mint requisite total price to buyer. + erc20.mint(buyer, totalPrice); + + // Approve marketplace to transfer currency + vm.prank(buyer); + erc20.increaseAllowance(marketplace, totalPrice); + + // Buy tokens from listing. + vm.warp(listing.startTimestamp); + vm.prank(buyer); + DirectListingsLogic(marketplace).buyFromListing(_listingId, buyFor, quantityToBuy, currency, totalPrice); + } + + function test_payout_whenZeroRoyaltyRecipients() public { + // 1. ========= Create listing ========= + vm.startPrank(seller); + erc721.setApprovalForAll(marketplace, true); + listingId = DirectListingsLogic(marketplace).createListing(listingParams); + vm.stopPrank(); + + // 2. ========= Buy from listing ========= + + uint256 totalPrice = listingParams.pricePerToken; + + // Mint requisite total price to buyer. + erc20.mint(buyer, totalPrice); + + // Approve marketplace to transfer currency + vm.prank(buyer); + erc20.increaseAllowance(marketplace, totalPrice); + + // Buy tokens from listing. + vm.warp(listingParams.startTimestamp); + vm.prank(buyer); + DirectListingsLogic(marketplace).buyFromListing( + listingId, + buyer, + listingParams.quantity, + listingParams.currency, + totalPrice + ); + + // 3. ======== Check balances after royalty payments ======== + + uint256 platformFees = (totalPrice * platformFeeBps) / 10_000; + + { + // Platform fee recipient receives correct amount + assertBalERC20Eq(address(erc20), platformFeeRecipient, platformFees); + + // Seller gets total price minus royalty amounts + assertBalERC20Eq(address(erc20), seller, totalPrice - platformFees); + } + } + + modifier whenNonZeroRoyaltyRecipients() { + _setupRoyaltyEngine(); + + // Add RoyaltyEngine to marketplace + vm.prank(marketplaceDeployer); + RoyaltyPaymentsLogic(marketplace).setRoyaltyEngine(address(royaltyEngine)); + + _; + } + + function test_payout_whenInsufficientFundsToPayRoyaltyAfterPlatformFeePayout() public whenNonZeroRoyaltyRecipients { + vm.prank(marketplaceDeployer); + PlatformFee(marketplace).setPlatformFeeInfo(platformFeeRecipient, 9999); // 99.99% fees + + // Mint the ERC721 tokens to seller. These tokens will be listed. + erc721.mint(seller, 1); + listingId = _setupListingForRoyaltyTests(address(erc721)); + + IDirectListings.Listing memory listing = DirectListingsLogic(marketplace).getListing(listingId); + + address buyFor = buyer; + uint256 quantityToBuy = listing.quantity; + address currency = listing.currency; + uint256 pricePerToken = listing.pricePerToken; + uint256 totalPrice = pricePerToken * quantityToBuy; + + // Mint requisite total price to buyer. + erc20.mint(buyer, totalPrice); + + // Approve marketplace to transfer currency + vm.prank(buyer); + erc20.increaseAllowance(marketplace, totalPrice); + + // Buy tokens from listing. + vm.warp(listing.startTimestamp); + vm.prank(buyer); + vm.expectRevert("fees exceed the price"); + DirectListingsLogic(marketplace).buyFromListing(listingId, buyFor, quantityToBuy, currency, totalPrice); + } + + function test_payout_whenSufficientFundsToPayRoyaltyAfterPlatformFeePayout() public whenNonZeroRoyaltyRecipients { + assertEq(RoyaltyPaymentsLogic(marketplace).getRoyaltyEngineAddress(), address(royaltyEngine)); + + // 1. ========= Create listing ========= + + // Mint the ERC721 tokens to seller. These tokens will be listed. + erc721.mint(seller, 1); + listingId = _setupListingForRoyaltyTests(address(erc721)); + + // 2. ========= Buy from listing ========= + + uint256 totalPrice = _buyFromListingForRoyaltyTests(listingId); + + // 3. ======== Check balances after royalty payments ======== + + uint256 platformFees = (totalPrice * platformFeeBps) / 10_000; + + { + // Royalty recipients receive correct amounts + assertBalERC20Eq(address(erc20), mockRecipients[0], mockAmounts[0]); + assertBalERC20Eq(address(erc20), mockRecipients[1], mockAmounts[1]); + + // Platform fee recipient receives correct amount + assertBalERC20Eq(address(erc20), platformFeeRecipient, platformFees); + + // Seller gets total price minus royalty amounts + assertBalERC20Eq(address(erc20), seller, totalPrice - mockAmounts[0] - mockAmounts[1] - platformFees); + } + } +} diff --git a/src/test/marketplace/direct-listings/_payout/_payout.tree b/src/test/marketplace/direct-listings/_payout/_payout.tree new file mode 100644 index 000000000..3d09e5d13 --- /dev/null +++ b/src/test/marketplace/direct-listings/_payout/_payout.tree @@ -0,0 +1,17 @@ +function _payout( + address _payer, + address _payee, + address _currencyToUse, + uint256 _totalPayoutAmount, + Listing memory _listing +) +├── when there are zero royalty recipients ✅ +│ ├── it should transfer platform fee from payer to platform fee recipient +│ └── it should transfer remainder of currency from payer to payee +└── when there are non-zero royalty recipients + ├── when the total royalty payout exceeds remainder payout after having paid platform fee + │ └── it should revert ✅ + └── when the total royalty payout does not exceed remainder payout after having paid platform fee ✅ + ├── it should transfer platform fee from payer to platform fee recipient + ├── it should transfer royalty fee from payer to royalty recipients + └── it should transfer remainder of currency from payer to payee \ No newline at end of file diff --git a/src/test/marketplace/direct-listings/_transferListingTokens/_transferListingTokens.t.sol b/src/test/marketplace/direct-listings/_transferListingTokens/_transferListingTokens.t.sol new file mode 100644 index 000000000..3673ef854 --- /dev/null +++ b/src/test/marketplace/direct-listings/_transferListingTokens/_transferListingTokens.t.sol @@ -0,0 +1,175 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "../../../utils/BaseTest.sol"; +import "@thirdweb-dev/dynamic-contracts/src/interface/IExtension.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; +import { MarketplaceV3 } from "contracts/prebuilts/marketplace/entrypoint/MarketplaceV3.sol"; +import { DirectListingsLogic } from "contracts/prebuilts/marketplace/direct-listings/DirectListingsLogic.sol"; +import { IDirectListings } from "contracts/prebuilts/marketplace/IMarketplace.sol"; + +contract MockTransferListingTokens is DirectListingsLogic { + constructor(address _nativeTokenWrapper) DirectListingsLogic(_nativeTokenWrapper) {} + + function transferListingTokens( + address _from, + address _to, + uint256 _quantity, + IDirectListings.Listing memory _listing + ) external { + _transferListingTokens(_from, _to, _quantity, _listing); + } +} + +contract TransferListingTokensTest is BaseTest, IExtension { + // Target contract + address public marketplace; + + // Participants + address public marketplaceDeployer; + address public seller; + address public recipient; + + // Default listing parameters + IDirectListings.ListingParameters internal listingParams; + uint256 listingId_erc721 = 0; + uint256 listingId_erc1155 = 1; + + function setUp() public override { + super.setUp(); + + marketplaceDeployer = getActor(1); + seller = getActor(2); + recipient = getActor(3); + + // Deploy implementation. + Extension[] memory extensions = _setupExtensions(); + address impl = address( + new MarketplaceV3(MarketplaceV3.MarketplaceConstructorParams(extensions, address(0), address(weth))) + ); + + vm.prank(marketplaceDeployer); + marketplace = address( + new TWProxy( + impl, + abi.encodeCall( + MarketplaceV3.initialize, + (marketplaceDeployer, "", new address[](0), marketplaceDeployer, 0) + ) + ) + ); + + // Setup roles + vm.startPrank(marketplaceDeployer); + Permissions(marketplace).revokeRole(keccak256("ASSET_ROLE"), address(0)); + Permissions(marketplace).revokeRole(keccak256("LISTER_ROLE"), address(0)); + + vm.stopPrank(); + + // Setup listing params + address assetContract = address(erc721); + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = address(erc20); + uint256 pricePerToken = 1 ether; + uint128 startTimestamp = 100; + uint128 endTimestamp = 200; + bool reserved = true; + + listingParams = IDirectListings.ListingParameters( + assetContract, + tokenId, + quantity, + currency, + pricePerToken, + startTimestamp, + endTimestamp, + reserved + ); + + // Mint 1 ERC721 NFT to seller + erc721.mint(seller, listingParams.quantity); + // Mint 100 ERC1155 NFT to seller + erc1155.mint(seller, listingParams.tokenId, 100); + + vm.label(impl, "MarketplaceV3_Impl"); + vm.label(marketplace, "Marketplace"); + vm.label(seller, "Seller"); + vm.label(address(erc721), "ERC721_Token"); + vm.label(address(erc1155), "ERC1155_Token"); + + // Create listings + vm.startPrank(marketplaceDeployer); + Permissions(marketplace).grantRole(keccak256("ASSET_ROLE"), address(erc721)); + Permissions(marketplace).grantRole(keccak256("ASSET_ROLE"), address(erc1155)); + Permissions(marketplace).grantRole(keccak256("LISTER_ROLE"), seller); + vm.stopPrank(); + + vm.startPrank(seller); + + erc721.setApprovalForAll(marketplace, true); + erc1155.setApprovalForAll(marketplace, true); + + listingId_erc721 = DirectListingsLogic(marketplace).createListing(listingParams); + + listingParams.assetContract = address(erc1155); + listingParams.quantity = 100; + listingId_erc1155 = DirectListingsLogic(marketplace).createListing(listingParams); + + vm.stopPrank(); + } + + function _setupExtensions() internal returns (Extension[] memory extensions) { + extensions = new Extension[](1); + + // Deploy `MockTransferListingTokens` + address directListings = address(new MockTransferListingTokens(address(weth))); + vm.label(directListings, "DirectListings_Extension"); + + // Extension: DirectListingsLogic + Extension memory extension_directListings; + extension_directListings.metadata = ExtensionMetadata({ + name: "MockTransferListingTokens", + metadataURI: "ipfs://MockTransferListingTokens", + implementation: directListings + }); + + extension_directListings.functions = new ExtensionFunction[](3); + extension_directListings.functions[0] = ExtensionFunction( + MockTransferListingTokens.transferListingTokens.selector, + "transferListingTokens(address,address,uint256,(uint256,uint256,uint256,uint256,uint128,uint128,address,address,address,uint8,uint8,bool))" + ); + extension_directListings.functions[1] = ExtensionFunction( + DirectListingsLogic.createListing.selector, + "createListing((address,uint256,uint256,address,uint256,uint128,uint128,bool))" + ); + extension_directListings.functions[2] = ExtensionFunction( + DirectListingsLogic.getListing.selector, + "getListing(uint256)" + ); + extensions[0] = extension_directListings; + } + + function test_transferListingTokens_erc1155() public { + IDirectListings.Listing memory listing = DirectListingsLogic(marketplace).getListing(listingId_erc1155); + + assertEq(erc1155.balanceOf(seller, listing.tokenId), 100); + assertEq(erc1155.balanceOf(recipient, listing.tokenId), 0); + + MockTransferListingTokens(marketplace).transferListingTokens(seller, recipient, 100, listing); + + assertEq(erc1155.balanceOf(seller, listing.tokenId), 0); + assertEq(erc1155.balanceOf(recipient, listing.tokenId), 100); + } + + function test_transferListingTokens_erc721() public { + IDirectListings.Listing memory listing = DirectListingsLogic(marketplace).getListing(listingId_erc721); + + assertEq(erc721.ownerOf(listing.tokenId), seller); + + MockTransferListingTokens(marketplace).transferListingTokens(seller, recipient, 1, listing); + + assertEq(erc721.ownerOf(listing.tokenId), recipient); + } +} diff --git a/src/test/marketplace/direct-listings/_transferListingTokens/_transferListingTokens.tree b/src/test/marketplace/direct-listings/_transferListingTokens/_transferListingTokens.tree new file mode 100644 index 000000000..02204ec44 --- /dev/null +++ b/src/test/marketplace/direct-listings/_transferListingTokens/_transferListingTokens.tree @@ -0,0 +1,10 @@ +function _transferListingTokens( + address _from, + address _to, + uint256 _quantity, + Listing memory _listing +) +├── when the token is ERC1155 +│ └── it should transfer ERC1155 tokens from the specified owner to recipient +└── when the token is ERC721 + └── it should transfer ERC721 tokens from the specified owner to recipient \ No newline at end of file diff --git a/src/test/marketplace/direct-listings/_validateERC20BalAndAllowance/_validateERC20BalAndAllowance.t.sol b/src/test/marketplace/direct-listings/_validateERC20BalAndAllowance/_validateERC20BalAndAllowance.t.sol new file mode 100644 index 000000000..f57643ca0 --- /dev/null +++ b/src/test/marketplace/direct-listings/_validateERC20BalAndAllowance/_validateERC20BalAndAllowance.t.sol @@ -0,0 +1,156 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "../../../utils/BaseTest.sol"; +import "@thirdweb-dev/dynamic-contracts/src/interface/IExtension.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; +import { MarketplaceV3 } from "contracts/prebuilts/marketplace/entrypoint/MarketplaceV3.sol"; +import { DirectListingsLogic } from "contracts/prebuilts/marketplace/direct-listings/DirectListingsLogic.sol"; +import { IDirectListings } from "contracts/prebuilts/marketplace/IMarketplace.sol"; + +contract MockValidateERC20BalAndAllowance is DirectListingsLogic { + constructor(address _nativeTokenWrapper) DirectListingsLogic(_nativeTokenWrapper) {} + + function validateERC20BalAndAllowance( + address _tokenOwner, + address _currency, + uint256 _amount + ) external returns (bool) { + _validateERC20BalAndAllowance(_tokenOwner, _currency, _amount); + return true; + } +} + +contract ValidateERC20BalAndAllowanceTest is BaseTest, IExtension { + // Target contract + address public marketplace; + + // Participants + address public marketplaceDeployer; + address public seller; + + // Default listing parameters + IDirectListings.ListingParameters internal listingParams; + + function setUp() public override { + super.setUp(); + + marketplaceDeployer = getActor(1); + seller = getActor(2); + + // Deploy implementation. + Extension[] memory extensions = _setupExtensions(); + address impl = address( + new MarketplaceV3(MarketplaceV3.MarketplaceConstructorParams(extensions, address(0), address(weth))) + ); + + vm.prank(marketplaceDeployer); + marketplace = address( + new TWProxy( + impl, + abi.encodeCall( + MarketplaceV3.initialize, + (marketplaceDeployer, "", new address[](0), marketplaceDeployer, 0) + ) + ) + ); + + // Setup roles + vm.startPrank(marketplaceDeployer); + Permissions(marketplace).revokeRole(keccak256("ASSET_ROLE"), address(0)); + Permissions(marketplace).revokeRole(keccak256("LISTER_ROLE"), address(0)); + + vm.stopPrank(); + + // Setup listing params + address assetContract = address(erc721); + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = address(erc20); + uint256 pricePerToken = 1 ether; + uint128 startTimestamp = 100; + uint128 endTimestamp = 200; + bool reserved = true; + + listingParams = IDirectListings.ListingParameters( + assetContract, + tokenId, + quantity, + currency, + pricePerToken, + startTimestamp, + endTimestamp, + reserved + ); + + // Mint 1 ERC721 NFT to seller + erc721.mint(seller, listingParams.quantity); + // Mint 100 ERC1155 NFT to seller + erc1155.mint(seller, listingParams.tokenId, 100); + // Mint some ERC20 tokens to seller + erc20.mint(seller, 100 ether); + + vm.label(impl, "MarketplaceV3_Impl"); + vm.label(marketplace, "Marketplace"); + vm.label(seller, "Seller"); + vm.label(address(erc721), "ERC721_Token"); + vm.label(address(erc1155), "ERC1155_Token"); + } + + function _setupExtensions() internal returns (Extension[] memory extensions) { + extensions = new Extension[](1); + + // Deploy `MockValidateERC20BalAndAllowance` + address directListings = address(new MockValidateERC20BalAndAllowance(address(weth))); + vm.label(directListings, "DirectListings_Extension"); + + // Extension: DirectListingsLogic + Extension memory extension_directListings; + extension_directListings.metadata = ExtensionMetadata({ + name: "MockValidateERC20BalAndAllowance", + metadataURI: "ipfs://MockValidateERC20BalAndAllowance", + implementation: directListings + }); + + extension_directListings.functions = new ExtensionFunction[](1); + extension_directListings.functions[0] = ExtensionFunction( + MockValidateERC20BalAndAllowance.validateERC20BalAndAllowance.selector, + "validateERC20BalAndAllowance(address,address,uint256)" + ); + extensions[0] = extension_directListings; + } + + function test_validateERC20BalAndAllowance_whenInsufficientTokensOwned() public { + vm.startPrank(seller); + + erc20.approve(marketplace, 100 ether); + erc20.burn(1 ether); + + vm.stopPrank(); + + vm.expectRevert("!BAL20"); + MockValidateERC20BalAndAllowance(marketplace).validateERC20BalAndAllowance(seller, address(erc20), 100 ether); + } + + function test_validateERC20BalAndAllowance_whenTokensNotApprovedToTransfer() public { + vm.startPrank(seller); + erc20.approve(marketplace, 0); + vm.stopPrank(); + + vm.expectRevert("!BAL20"); + MockValidateERC20BalAndAllowance(marketplace).validateERC20BalAndAllowance(seller, address(erc20), 100 ether); + } + + function test_validateERC20BalAndAllowance_whenTokensOwnedAndApproved() public { + vm.prank(seller); + erc20.approve(marketplace, 100 ether); + + bool result = MockValidateERC20BalAndAllowance(marketplace).validateERC20BalAndAllowance( + seller, + address(erc20), + 100 ether + ); + assertEq(result, true); + } +} diff --git a/src/test/marketplace/direct-listings/_validateERC20BalAndAllowance/_validateERC20BalAndAllowance.tree b/src/test/marketplace/direct-listings/_validateERC20BalAndAllowance/_validateERC20BalAndAllowance.tree new file mode 100644 index 000000000..04b6010d4 --- /dev/null +++ b/src/test/marketplace/direct-listings/_validateERC20BalAndAllowance/_validateERC20BalAndAllowance.tree @@ -0,0 +1,11 @@ +function _validateERC20BalAndAllowance( + address _tokenOwner, + address _currency, + uint256 _amount +) +├── when the balance of token owner is less than expected _amount +│ └── it should revert ✅ +├── when marketplace is not approved to spend token owner's token +│ └── it should revert ✅ +└── when the balance of token owner is greater than or equal to expected _amount and marketplace is approved to spend token owner's token + └── it should return ✅ \ No newline at end of file diff --git a/src/test/marketplace/direct-listings/_validateNewListing/_validateNewListing.t.sol b/src/test/marketplace/direct-listings/_validateNewListing/_validateNewListing.t.sol new file mode 100644 index 000000000..51b681e34 --- /dev/null +++ b/src/test/marketplace/direct-listings/_validateNewListing/_validateNewListing.t.sol @@ -0,0 +1,292 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "../../../utils/BaseTest.sol"; +import "@thirdweb-dev/dynamic-contracts/src/interface/IExtension.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; +import { MarketplaceV3 } from "contracts/prebuilts/marketplace/entrypoint/MarketplaceV3.sol"; +import { DirectListingsLogic } from "contracts/prebuilts/marketplace/direct-listings/DirectListingsLogic.sol"; +import { IDirectListings } from "contracts/prebuilts/marketplace/IMarketplace.sol"; + +contract MockValidateListing is DirectListingsLogic { + constructor(address _nativeTokenWrapper) DirectListingsLogic(_nativeTokenWrapper) {} + + function validateNewListing(ListingParameters memory _params, TokenType _tokenType) external returns (bool) { + _validateNewListing(_params, _tokenType); + return true; + } +} + +contract ValidateNewListingTest is BaseTest, IExtension { + // Target contract + address public marketplace; + + // Participants + address public marketplaceDeployer; + address public seller; + + // Default listing parameters + IDirectListings.ListingParameters internal listingParams; + + function setUp() public override { + super.setUp(); + + marketplaceDeployer = getActor(1); + seller = getActor(2); + + // Deploy implementation. + Extension[] memory extensions = _setupExtensions(); + address impl = address( + new MarketplaceV3(MarketplaceV3.MarketplaceConstructorParams(extensions, address(0), address(weth))) + ); + + vm.prank(marketplaceDeployer); + marketplace = address( + new TWProxy( + impl, + abi.encodeCall( + MarketplaceV3.initialize, + (marketplaceDeployer, "", new address[](0), marketplaceDeployer, 0) + ) + ) + ); + + // Setup roles + vm.startPrank(marketplaceDeployer); + Permissions(marketplace).revokeRole(keccak256("ASSET_ROLE"), address(0)); + Permissions(marketplace).revokeRole(keccak256("LISTER_ROLE"), address(0)); + + vm.stopPrank(); + + // Setup listing params + address assetContract = address(erc721); + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = address(erc20); + uint256 pricePerToken = 1 ether; + uint128 startTimestamp = 100; + uint128 endTimestamp = 200; + bool reserved = true; + + listingParams = IDirectListings.ListingParameters( + assetContract, + tokenId, + quantity, + currency, + pricePerToken, + startTimestamp, + endTimestamp, + reserved + ); + + // Mint 1 ERC721 NFT to seller + erc721.mint(seller, listingParams.quantity); + // Mint 100 ERC1155 NFT to seller + erc1155.mint(seller, listingParams.tokenId, 100); + + vm.label(impl, "MarketplaceV3_Impl"); + vm.label(marketplace, "Marketplace"); + vm.label(seller, "Seller"); + vm.label(address(erc721), "ERC721_Token"); + vm.label(address(erc1155), "ERC1155_Token"); + } + + function _setupExtensions() internal returns (Extension[] memory extensions) { + extensions = new Extension[](1); + + // Deploy `MockValidateListing` + address directListings = address(new MockValidateListing(address(weth))); + vm.label(directListings, "DirectListings_Extension"); + + // Extension: DirectListingsLogic + Extension memory extension_directListings; + extension_directListings.metadata = ExtensionMetadata({ + name: "MockValidateListing", + metadataURI: "ipfs://MockValidateListing", + implementation: directListings + }); + + extension_directListings.functions = new ExtensionFunction[](1); + extension_directListings.functions[0] = ExtensionFunction( + MockValidateListing.validateNewListing.selector, + "validateNewListing((address,uint256,uint256,address,uint256,uint128,uint128,bool),uint8)" + ); + extensions[0] = extension_directListings; + } + + function test_validateNewListing_whenQuantityIsZero() public { + listingParams.quantity = 0; + + vm.expectRevert("Marketplace: listing zero quantity."); + MockValidateListing(marketplace).validateNewListing(listingParams, IDirectListings.TokenType.ERC721); + } + + modifier whenQuantityIsOne() { + listingParams.quantity = 1; + _; + } + + modifier whenQuantityIsGtOne() { + listingParams.quantity = 2; + _; + } + + modifier whenTokenIsERC721() { + listingParams.assetContract = address(erc721); + _; + } + + modifier whenTokenIsERC1155() { + listingParams.assetContract = address(erc1155); + _; + } + + function test_validateNewListing_whenTokenIsERC721() public whenQuantityIsGtOne { + vm.expectRevert("Marketplace: listing invalid quantity."); + MockValidateListing(marketplace).validateNewListing(listingParams, IDirectListings.TokenType.ERC721); + } + + function test_validateNewListing_whenTokenOwnerDoesntOwnSufficientTokens_1() + public + whenQuantityIsGtOne + whenTokenIsERC1155 + { + vm.startPrank(seller); + erc1155.setApprovalForAll(marketplace, true); + erc1155.burn(seller, listingParams.tokenId, 100); + vm.stopPrank(); + + vm.prank(seller); + vm.expectRevert("Marketplace: not owner or approved tokens."); + MockValidateListing(marketplace).validateNewListing(listingParams, IDirectListings.TokenType.ERC1155); + } + + modifier whenTokenOwnerOwnsSufficientTokens() { + _; + } + + function test_validateNewListing_whenTokensNotApprovedForTransfer_1() + public + whenQuantityIsGtOne + whenTokenIsERC1155 + whenTokenOwnerOwnsSufficientTokens + { + vm.prank(seller); + erc1155.setApprovalForAll(marketplace, false); + + vm.prank(seller); + vm.expectRevert("Marketplace: not owner or approved tokens."); + MockValidateListing(marketplace).validateNewListing(listingParams, IDirectListings.TokenType.ERC1155); + } + + modifier whenTokensApprovedForTransfer(IDirectListings.TokenType tokenType) { + vm.prank(seller); + if (tokenType == IDirectListings.TokenType.ERC721) { + erc721.setApprovalForAll(marketplace, true); + } else { + erc1155.setApprovalForAll(marketplace, true); + } + _; + } + + function test_validateNewListing_whenTokensOwnedAndApproved_1() + public + whenQuantityIsGtOne + whenTokenIsERC1155 + whenTokenOwnerOwnsSufficientTokens + whenTokensApprovedForTransfer(IDirectListings.TokenType.ERC1155) + { + vm.prank(seller); + assertEq( + MockValidateListing(marketplace).validateNewListing(listingParams, IDirectListings.TokenType.ERC1155), + true + ); + } + + function test_validateNewListing_whenTokenOwnerDoesntOwnSufficientTokens_2a() + public + whenQuantityIsOne + whenTokenIsERC1155 + { + vm.startPrank(seller); + erc1155.setApprovalForAll(marketplace, true); + erc1155.burn(seller, listingParams.tokenId, 100); + vm.stopPrank(); + + vm.prank(seller); + vm.expectRevert("Marketplace: not owner or approved tokens."); + MockValidateListing(marketplace).validateNewListing(listingParams, IDirectListings.TokenType.ERC1155); + } + + function test_validateNewListing_whenTokenOwnerDoesntOwnSufficientTokens_2b() + public + whenQuantityIsOne + whenTokenIsERC721 + { + vm.startPrank(seller); + erc721.setApprovalForAll(marketplace, true); + erc721.burn(listingParams.tokenId); + vm.stopPrank(); + + vm.prank(seller); + vm.expectRevert("Marketplace: not owner or approved tokens."); + MockValidateListing(marketplace).validateNewListing(listingParams, IDirectListings.TokenType.ERC721); + } + + function test_validateNewListing_whenTokensNotApprovedForTransfer_2a() + public + whenQuantityIsOne + whenTokenIsERC721 + whenTokenOwnerOwnsSufficientTokens + { + vm.prank(seller); + erc721.setApprovalForAll(marketplace, false); + + vm.prank(seller); + vm.expectRevert("Marketplace: not owner or approved tokens."); + MockValidateListing(marketplace).validateNewListing(listingParams, IDirectListings.TokenType.ERC721); + } + + function test_validateNewListing_whenTokensNotApprovedForTransfer_2b() + public + whenQuantityIsOne + whenTokenIsERC1155 + whenTokenOwnerOwnsSufficientTokens + { + vm.prank(seller); + erc1155.setApprovalForAll(marketplace, false); + + vm.prank(seller); + vm.expectRevert("Marketplace: not owner or approved tokens."); + MockValidateListing(marketplace).validateNewListing(listingParams, IDirectListings.TokenType.ERC1155); + } + + function test_validateNewListing_whenTokensOwnedAndApproved_2a() + public + whenQuantityIsOne + whenTokenIsERC1155 + whenTokenOwnerOwnsSufficientTokens + whenTokensApprovedForTransfer(IDirectListings.TokenType.ERC1155) + { + vm.prank(seller); + assertEq( + MockValidateListing(marketplace).validateNewListing(listingParams, IDirectListings.TokenType.ERC1155), + true + ); + } + + function test_validateNewListing_whenTokensOwnedAndApproved_2b() + public + whenQuantityIsOne + whenTokenIsERC721 + whenTokenOwnerOwnsSufficientTokens + whenTokensApprovedForTransfer(IDirectListings.TokenType.ERC721) + { + vm.prank(seller); + assertEq( + MockValidateListing(marketplace).validateNewListing(listingParams, IDirectListings.TokenType.ERC721), + true + ); + } +} diff --git a/src/test/marketplace/direct-listings/_validateNewListing/_validateNewListing.tree b/src/test/marketplace/direct-listings/_validateNewListing/_validateNewListing.tree new file mode 100644 index 000000000..a2520cf27 --- /dev/null +++ b/src/test/marketplace/direct-listings/_validateNewListing/_validateNewListing.tree @@ -0,0 +1,23 @@ +function _validateNewListing(ListingParameters memory _params, TokenType _tokenType) +├── when quantity is zero +│ └── it should revert ✅ +└── when quantity is non zero + ├── when quantity is greater than one + │ ├── when token type is ERC721 + │ │ └── it should revert ✅ + │ └── when the token type is ERC1155 + │ ├── when the token owner owns less than quantity to list + │ │ └── it should revert ✅ + │ └── when the token owner owns sufficient quantity + │ ├── when the marketplace is not approved to transfer tokens + │ │ └── it should revert ✅ + │ └── when the marketplace is approved to transfer tokens + │ └── it should return ✅ + └── when the quantity is one + ├── when the token owner owns less than quantity to list + │ └── it should revert ✅ + └── when the token owner owns sufficient quantity + ├── when the marketplace is not approved to transfer tokens + │ └── it should revert ✅ + └── when the marketplace is approved to transfer tokens + └── it should return ✅ \ No newline at end of file diff --git a/src/test/marketplace/direct-listings/_validateOwnershipAndApproval/_validateOwnershipAndApproval.t.sol b/src/test/marketplace/direct-listings/_validateOwnershipAndApproval/_validateOwnershipAndApproval.t.sol new file mode 100644 index 000000000..5436d89f7 --- /dev/null +++ b/src/test/marketplace/direct-listings/_validateOwnershipAndApproval/_validateOwnershipAndApproval.t.sol @@ -0,0 +1,254 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "../../../utils/BaseTest.sol"; +import "@thirdweb-dev/dynamic-contracts/src/interface/IExtension.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; +import { MarketplaceV3 } from "contracts/prebuilts/marketplace/entrypoint/MarketplaceV3.sol"; +import { DirectListingsLogic } from "contracts/prebuilts/marketplace/direct-listings/DirectListingsLogic.sol"; +import { IDirectListings } from "contracts/prebuilts/marketplace/IMarketplace.sol"; + +contract MockValidateOwnershipAndApproval is DirectListingsLogic { + constructor(address _nativeTokenWrapper) DirectListingsLogic(_nativeTokenWrapper) {} + + function validateOwnershipAndApproval( + address _tokenOwner, + address _assetContract, + uint256 _tokenId, + uint256 _quantity, + TokenType _tokenType + ) external view returns (bool) { + return _validateOwnershipAndApproval(_tokenOwner, _assetContract, _tokenId, _quantity, _tokenType); + } +} + +contract ValidateOwnershipAndApprovalTest is BaseTest, IExtension { + // Target contract + address public marketplace; + + // Participants + address public marketplaceDeployer; + address public seller; + + // Default listing parameters + IDirectListings.ListingParameters internal listingParams; + + function setUp() public override { + super.setUp(); + + marketplaceDeployer = getActor(1); + seller = getActor(2); + + // Deploy implementation. + Extension[] memory extensions = _setupExtensions(); + address impl = address( + new MarketplaceV3(MarketplaceV3.MarketplaceConstructorParams(extensions, address(0), address(weth))) + ); + + vm.prank(marketplaceDeployer); + marketplace = address( + new TWProxy( + impl, + abi.encodeCall( + MarketplaceV3.initialize, + (marketplaceDeployer, "", new address[](0), marketplaceDeployer, 0) + ) + ) + ); + + // Setup roles + vm.startPrank(marketplaceDeployer); + Permissions(marketplace).revokeRole(keccak256("ASSET_ROLE"), address(0)); + Permissions(marketplace).revokeRole(keccak256("LISTER_ROLE"), address(0)); + + vm.stopPrank(); + + // Setup listing params + address assetContract = address(erc721); + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = address(erc20); + uint256 pricePerToken = 1 ether; + uint128 startTimestamp = 100; + uint128 endTimestamp = 200; + bool reserved = true; + + listingParams = IDirectListings.ListingParameters( + assetContract, + tokenId, + quantity, + currency, + pricePerToken, + startTimestamp, + endTimestamp, + reserved + ); + + // Mint 1 ERC721 NFT to seller + erc721.mint(seller, listingParams.quantity); + // Mint 100 ERC1155 NFT to seller + erc1155.mint(seller, listingParams.tokenId, 100); + + vm.label(impl, "MarketplaceV3_Impl"); + vm.label(marketplace, "Marketplace"); + vm.label(seller, "Seller"); + vm.label(address(erc721), "ERC721_Token"); + vm.label(address(erc1155), "ERC1155_Token"); + } + + function _setupExtensions() internal returns (Extension[] memory extensions) { + extensions = new Extension[](1); + + // Deploy `MockValidateListing` + address directListings = address(new MockValidateOwnershipAndApproval(address(weth))); + vm.label(directListings, "DirectListings_Extension"); + + // Extension: DirectListingsLogic + Extension memory extension_directListings; + extension_directListings.metadata = ExtensionMetadata({ + name: "MockValidateOwnershipAndApproval", + metadataURI: "ipfs://MockValidateOwnershipAndApproval", + implementation: directListings + }); + + extension_directListings.functions = new ExtensionFunction[](1); + extension_directListings.functions[0] = ExtensionFunction( + MockValidateOwnershipAndApproval.validateOwnershipAndApproval.selector, + "validateOwnershipAndApproval(address,address,uint256,uint256,uint8)" + ); + extensions[0] = extension_directListings; + } + + modifier whenTokenIsERC1155() { + listingParams.assetContract = address(erc1155); + listingParams.quantity = 100; + _; + } + + modifier whenTokenIsERC721() { + listingParams.assetContract = address(erc721); + listingParams.quantity = 1; + _; + } + + function test_validateOwnershipAndApproval_whenInsufficientTokensOwned_erc1155() public whenTokenIsERC1155 { + vm.prank(seller); + erc1155.setApprovalForAll(marketplace, true); + + vm.prank(seller); + erc1155.burn(seller, listingParams.tokenId, 100); + + bool result = MockValidateOwnershipAndApproval(marketplace).validateOwnershipAndApproval( + seller, + listingParams.assetContract, + listingParams.tokenId, + listingParams.quantity, + IDirectListings.TokenType.ERC1155 + ); + assertEq(result, false); + } + + function test_validateOwnershipAndApproval_whenInsufficientTokensOwned_erc721() public whenTokenIsERC721 { + vm.prank(seller); + erc721.setApprovalForAll(marketplace, true); + + vm.prank(seller); + erc721.burn(listingParams.tokenId); + + bool result = MockValidateOwnershipAndApproval(marketplace).validateOwnershipAndApproval( + seller, + listingParams.assetContract, + listingParams.tokenId, + listingParams.quantity, + IDirectListings.TokenType.ERC721 + ); + assertEq(result, false); + } + + modifier whenSufficientTokensOwned() { + _; + } + + function test_validateOwnershipAndApproval_whenTokensNotApprovedToTransfer_erc1155() + public + whenTokenIsERC1155 + whenSufficientTokensOwned + { + assertEq(erc1155.balanceOf(seller, listingParams.tokenId), listingParams.quantity); + + vm.prank(seller); + erc1155.setApprovalForAll(marketplace, false); + + bool result = MockValidateOwnershipAndApproval(marketplace).validateOwnershipAndApproval( + seller, + listingParams.assetContract, + listingParams.tokenId, + listingParams.quantity, + IDirectListings.TokenType.ERC1155 + ); + assertEq(result, false); + } + + function test_validateOwnershipAndApproval_whenTokensNotApprovedToTransfer_erc721() + public + whenTokenIsERC721 + whenSufficientTokensOwned + { + assertEq(erc721.ownerOf(listingParams.tokenId), seller); + + vm.prank(seller); + erc721.setApprovalForAll(marketplace, false); + + bool result = MockValidateOwnershipAndApproval(marketplace).validateOwnershipAndApproval( + seller, + listingParams.assetContract, + listingParams.tokenId, + listingParams.quantity, + IDirectListings.TokenType.ERC721 + ); + assertEq(result, false); + } + + modifier whenTokensApprovedForTransfer(IDirectListings.TokenType tokenType) { + vm.prank(seller); + if (tokenType == IDirectListings.TokenType.ERC1155) { + erc1155.setApprovalForAll(marketplace, true); + } else { + erc721.setApprovalForAll(marketplace, true); + } + _; + } + + function test_validateOwnershipAndApproval_whenTokensOwnedAndApproved_erc1155() + public + whenTokenIsERC1155 + whenSufficientTokensOwned + whenTokensApprovedForTransfer(IDirectListings.TokenType.ERC1155) + { + bool result = MockValidateOwnershipAndApproval(marketplace).validateOwnershipAndApproval( + seller, + listingParams.assetContract, + listingParams.tokenId, + listingParams.quantity, + IDirectListings.TokenType.ERC1155 + ); + assertEq(result, true); + } + + function test_validateOwnershipAndApproval_whenTokensOwnedAndApproved_erc721() + public + whenTokenIsERC721 + whenSufficientTokensOwned + whenTokensApprovedForTransfer(IDirectListings.TokenType.ERC721) + { + bool result = MockValidateOwnershipAndApproval(marketplace).validateOwnershipAndApproval( + seller, + listingParams.assetContract, + listingParams.tokenId, + listingParams.quantity, + IDirectListings.TokenType.ERC721 + ); + assertEq(result, true); + } +} diff --git a/src/test/marketplace/direct-listings/_validateOwnershipAndApproval/_validateOwnershipAndApproval.tree b/src/test/marketplace/direct-listings/_validateOwnershipAndApproval/_validateOwnershipAndApproval.tree new file mode 100644 index 000000000..2ee82b493 --- /dev/null +++ b/src/test/marketplace/direct-listings/_validateOwnershipAndApproval/_validateOwnershipAndApproval.tree @@ -0,0 +1,21 @@ +function _validateOwnershipAndApproval( + address _tokenOwner, + address _assetContract, + uint256 _tokenId, + uint256 _quantity, + TokenType _tokenType +) +├── when token type is ERC1155 +│ ├── when token balance of owner is less than expected quantity +│ │ └── it should return false ✅ +│ ├── when marketplace is not approved to transfer tokens +│ │ └── it should return false ✅ +│ └── when token balance of owner is gte expected quantity and marketplace is approved to transfer tokens +│ └── it should return true ✅ +└── when token type is ERC721 + ├── when token owner is not the expected owner of the token + │ └── it should return false ✅ + ├── when marketplace is not approved to transfer tokens + │ └── it should return false ✅ + └── when token owner is the expected owner of the token and marketplace is approved to transfer tokens + └── it should return true ✅ diff --git a/src/test/marketplace/direct-listings/approveBuyerForListing/approveBuyerForListing.t.sol b/src/test/marketplace/direct-listings/approveBuyerForListing/approveBuyerForListing.t.sol new file mode 100644 index 000000000..bdd8dbae8 --- /dev/null +++ b/src/test/marketplace/direct-listings/approveBuyerForListing/approveBuyerForListing.t.sol @@ -0,0 +1,222 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "../../../utils/BaseTest.sol"; +import "@thirdweb-dev/dynamic-contracts/src/interface/IExtension.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; +import { MarketplaceV3 } from "contracts/prebuilts/marketplace/entrypoint/MarketplaceV3.sol"; +import { DirectListingsLogic } from "contracts/prebuilts/marketplace/direct-listings/DirectListingsLogic.sol"; +import { IDirectListings } from "contracts/prebuilts/marketplace/IMarketplace.sol"; + +contract ApproveBuyerForListingTest is BaseTest, IExtension { + // Target contract + address public marketplace; + + // Participants + address public marketplaceDeployer; + address public seller; + address public buyer; + + // Default listing parameters + IDirectListings.ListingParameters internal listingParams; + uint256 internal listingId = 0; + + // Events to test + + /// @notice Emitted when a buyer is approved to buy from a reserved listing. + event BuyerApprovedForListing(uint256 indexed listingId, address indexed buyer, bool approved); + + function setUp() public override { + super.setUp(); + + marketplaceDeployer = getActor(1); + seller = getActor(2); + buyer = getActor(3); + + // Deploy implementation. + Extension[] memory extensions = _setupExtensions(); + address impl = address( + new MarketplaceV3(MarketplaceV3.MarketplaceConstructorParams(extensions, address(0), address(weth))) + ); + + vm.prank(marketplaceDeployer); + marketplace = address( + new TWProxy( + impl, + abi.encodeCall( + MarketplaceV3.initialize, + (marketplaceDeployer, "", new address[](0), marketplaceDeployer, 0) + ) + ) + ); + + // Setup roles + vm.startPrank(marketplaceDeployer); + Permissions(marketplace).revokeRole(keccak256("ASSET_ROLE"), address(0)); + Permissions(marketplace).revokeRole(keccak256("LISTER_ROLE"), address(0)); + + vm.stopPrank(); + + // Setup listing params + address assetContract = address(erc721); + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = address(erc20); + uint256 pricePerToken = 1 ether; + uint128 startTimestamp = 100 minutes; + uint128 endTimestamp = 200 minutes; + bool reserved = false; + + listingParams = IDirectListings.ListingParameters( + assetContract, + tokenId, + quantity, + currency, + pricePerToken, + startTimestamp, + endTimestamp, + reserved + ); + + // Mint 1 ERC721 NFT to seller + erc721.mint(seller, listingParams.quantity); + + vm.label(impl, "MarketplaceV3_Impl"); + vm.label(marketplace, "Marketplace"); + vm.label(seller, "Seller"); + vm.label(address(erc721), "ERC721_Token"); + vm.label(address(erc1155), "ERC1155_Token"); + } + + function _setupExtensions() internal returns (Extension[] memory extensions) { + extensions = new Extension[](1); + + // Deploy `DirectListings` + address directListings = address(new DirectListingsLogic(address(weth))); + vm.label(directListings, "DirectListings_Extension"); + + // Extension: DirectListingsLogic + Extension memory extension_directListings; + extension_directListings.metadata = ExtensionMetadata({ + name: "DirectListingsLogic", + metadataURI: "ipfs://DirectListings", + implementation: directListings + }); + + extension_directListings.functions = new ExtensionFunction[](13); + extension_directListings.functions[0] = ExtensionFunction( + DirectListingsLogic.totalListings.selector, + "totalListings()" + ); + extension_directListings.functions[1] = ExtensionFunction( + DirectListingsLogic.isBuyerApprovedForListing.selector, + "isBuyerApprovedForListing(uint256,address)" + ); + extension_directListings.functions[2] = ExtensionFunction( + DirectListingsLogic.isCurrencyApprovedForListing.selector, + "isCurrencyApprovedForListing(uint256,address)" + ); + extension_directListings.functions[3] = ExtensionFunction( + DirectListingsLogic.currencyPriceForListing.selector, + "currencyPriceForListing(uint256,address)" + ); + extension_directListings.functions[4] = ExtensionFunction( + DirectListingsLogic.createListing.selector, + "createListing((address,uint256,uint256,address,uint256,uint128,uint128,bool))" + ); + extension_directListings.functions[5] = ExtensionFunction( + DirectListingsLogic.updateListing.selector, + "updateListing(uint256,(address,uint256,uint256,address,uint256,uint128,uint128,bool))" + ); + extension_directListings.functions[6] = ExtensionFunction( + DirectListingsLogic.cancelListing.selector, + "cancelListing(uint256)" + ); + extension_directListings.functions[7] = ExtensionFunction( + DirectListingsLogic.approveBuyerForListing.selector, + "approveBuyerForListing(uint256,address,bool)" + ); + extension_directListings.functions[8] = ExtensionFunction( + DirectListingsLogic.approveCurrencyForListing.selector, + "approveCurrencyForListing(uint256,address,uint256)" + ); + extension_directListings.functions[9] = ExtensionFunction( + DirectListingsLogic.buyFromListing.selector, + "buyFromListing(uint256,address,uint256,address,uint256)" + ); + extension_directListings.functions[10] = ExtensionFunction( + DirectListingsLogic.getAllListings.selector, + "getAllListings(uint256,uint256)" + ); + extension_directListings.functions[11] = ExtensionFunction( + DirectListingsLogic.getAllValidListings.selector, + "getAllValidListings(uint256,uint256)" + ); + extension_directListings.functions[12] = ExtensionFunction( + DirectListingsLogic.getListing.selector, + "getListing(uint256)" + ); + + extensions[0] = extension_directListings; + } + + function test_approveBuyerForListing_listingDoesntExist() public { + vm.prank(seller); + vm.expectRevert("Marketplace: invalid listing."); + DirectListingsLogic(marketplace).approveBuyerForListing(listingId, buyer, true); + } + + modifier whenListingExists() { + vm.startPrank(marketplaceDeployer); + Permissions(marketplace).grantRole(keccak256("ASSET_ROLE"), address(erc721)); + Permissions(marketplace).grantRole(keccak256("LISTER_ROLE"), seller); + vm.stopPrank(); + + vm.startPrank(seller); + erc721.setApprovalForAll(marketplace, true); + listingId = DirectListingsLogic(marketplace).createListing(listingParams); + vm.stopPrank(); + _; + } + + function test_approveBuyerForListing_whenCallerNotListingCreator() public whenListingExists { + vm.prank(address(0x4353)); + vm.expectRevert("Marketplace: not listing creator."); + DirectListingsLogic(marketplace).approveBuyerForListing(listingId, buyer, true); + } + + modifier whenCallerIsListingCreator() { + _; + } + + function test_approveBuyerForListing_whenListingNotReserved() public whenListingExists whenCallerIsListingCreator { + vm.prank(seller); + vm.expectRevert("Marketplace: listing not reserved."); + DirectListingsLogic(marketplace).approveBuyerForListing(listingId, buyer, true); + } + + modifier whenListingIsReserved() { + listingParams.reserved = true; + + vm.prank(seller); + DirectListingsLogic(marketplace).updateListing(listingId, listingParams); + _; + } + + function test_approveBuyerForListing_whenListingIsReserved() + public + whenListingExists + whenCallerIsListingCreator + whenListingIsReserved + { + assertEq(DirectListingsLogic(marketplace).isBuyerApprovedForListing(listingId, buyer), false); + + vm.prank(seller); + vm.expectEmit(true, true, true, false); + emit BuyerApprovedForListing(listingId, buyer, true); + DirectListingsLogic(marketplace).approveBuyerForListing(listingId, buyer, true); + + assertEq(DirectListingsLogic(marketplace).isBuyerApprovedForListing(listingId, buyer), true); + } +} diff --git a/src/test/marketplace/direct-listings/approveBuyerForListing/approveBuyerForListing.tree b/src/test/marketplace/direct-listings/approveBuyerForListing/approveBuyerForListing.tree new file mode 100644 index 000000000..5b7aeff2a --- /dev/null +++ b/src/test/marketplace/direct-listings/approveBuyerForListing/approveBuyerForListing.tree @@ -0,0 +1,15 @@ +function approveBuyerForListing( + uint256 _listingId, + address _buyer, + bool _toApprove +) +├── when the lisitng does not exist +│ └── it should revert ✅ +└── when the listing exists + ├── when the caller is not listing creator + │ └── it should revert ✅ + └── when the caller is listing creator + ├── when the listing is not reserved + │ └── it should revert ✅ + └── when the listing is reserved + └── it should set the intended approval status for buyer ✅ \ No newline at end of file diff --git a/src/test/marketplace/direct-listings/approveCurrencyForListing/approveCurrencyForListing.t.sol b/src/test/marketplace/direct-listings/approveCurrencyForListing/approveCurrencyForListing.t.sol new file mode 100644 index 000000000..e4e70007d --- /dev/null +++ b/src/test/marketplace/direct-listings/approveCurrencyForListing/approveCurrencyForListing.t.sol @@ -0,0 +1,237 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "../../../utils/BaseTest.sol"; +import "@thirdweb-dev/dynamic-contracts/src/interface/IExtension.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; +import { MarketplaceV3 } from "contracts/prebuilts/marketplace/entrypoint/MarketplaceV3.sol"; +import { DirectListingsLogic } from "contracts/prebuilts/marketplace/direct-listings/DirectListingsLogic.sol"; +import { IDirectListings } from "contracts/prebuilts/marketplace/IMarketplace.sol"; + +contract ApproveCurrencyForListingTest is BaseTest, IExtension { + // Target contract + address public marketplace; + + // Participants + address public marketplaceDeployer; + address public seller; + + // Default listing parameters + IDirectListings.ListingParameters internal listingParams; + uint256 internal listingId = 0; + + // Events to test + + /// @notice Emitted when a currency is approved as a form of payment for the listing. + event CurrencyApprovedForListing(uint256 indexed listingId, address indexed currency, uint256 pricePerToken); + + function setUp() public override { + super.setUp(); + + marketplaceDeployer = getActor(1); + seller = getActor(2); + + // Deploy implementation. + Extension[] memory extensions = _setupExtensions(); + address impl = address( + new MarketplaceV3(MarketplaceV3.MarketplaceConstructorParams(extensions, address(0), address(weth))) + ); + + vm.prank(marketplaceDeployer); + marketplace = address( + new TWProxy( + impl, + abi.encodeCall( + MarketplaceV3.initialize, + (marketplaceDeployer, "", new address[](0), marketplaceDeployer, 0) + ) + ) + ); + + // Setup roles + vm.startPrank(marketplaceDeployer); + Permissions(marketplace).revokeRole(keccak256("ASSET_ROLE"), address(0)); + Permissions(marketplace).revokeRole(keccak256("LISTER_ROLE"), address(0)); + + vm.stopPrank(); + + // Setup listing params + address assetContract = address(erc721); + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = address(erc20); + uint256 pricePerToken = 1 ether; + uint128 startTimestamp = 100 minutes; + uint128 endTimestamp = 200 minutes; + bool reserved = true; + + listingParams = IDirectListings.ListingParameters( + assetContract, + tokenId, + quantity, + currency, + pricePerToken, + startTimestamp, + endTimestamp, + reserved + ); + + // Mint 1 ERC721 NFT to seller + erc721.mint(seller, listingParams.quantity); + + vm.label(impl, "MarketplaceV3_Impl"); + vm.label(marketplace, "Marketplace"); + vm.label(seller, "Seller"); + vm.label(address(erc721), "ERC721_Token"); + vm.label(address(erc1155), "ERC1155_Token"); + } + + function _setupExtensions() internal returns (Extension[] memory extensions) { + extensions = new Extension[](1); + + // Deploy `DirectListings` + address directListings = address(new DirectListingsLogic(address(weth))); + vm.label(directListings, "DirectListings_Extension"); + + // Extension: DirectListingsLogic + Extension memory extension_directListings; + extension_directListings.metadata = ExtensionMetadata({ + name: "DirectListingsLogic", + metadataURI: "ipfs://DirectListings", + implementation: directListings + }); + + extension_directListings.functions = new ExtensionFunction[](13); + extension_directListings.functions[0] = ExtensionFunction( + DirectListingsLogic.totalListings.selector, + "totalListings()" + ); + extension_directListings.functions[1] = ExtensionFunction( + DirectListingsLogic.isBuyerApprovedForListing.selector, + "isBuyerApprovedForListing(uint256,address)" + ); + extension_directListings.functions[2] = ExtensionFunction( + DirectListingsLogic.isCurrencyApprovedForListing.selector, + "isCurrencyApprovedForListing(uint256,address)" + ); + extension_directListings.functions[3] = ExtensionFunction( + DirectListingsLogic.currencyPriceForListing.selector, + "currencyPriceForListing(uint256,address)" + ); + extension_directListings.functions[4] = ExtensionFunction( + DirectListingsLogic.createListing.selector, + "createListing((address,uint256,uint256,address,uint256,uint128,uint128,bool))" + ); + extension_directListings.functions[5] = ExtensionFunction( + DirectListingsLogic.updateListing.selector, + "updateListing(uint256,(address,uint256,uint256,address,uint256,uint128,uint128,bool))" + ); + extension_directListings.functions[6] = ExtensionFunction( + DirectListingsLogic.cancelListing.selector, + "cancelListing(uint256)" + ); + extension_directListings.functions[7] = ExtensionFunction( + DirectListingsLogic.approveBuyerForListing.selector, + "approveBuyerForListing(uint256,address,bool)" + ); + extension_directListings.functions[8] = ExtensionFunction( + DirectListingsLogic.approveCurrencyForListing.selector, + "approveCurrencyForListing(uint256,address,uint256)" + ); + extension_directListings.functions[9] = ExtensionFunction( + DirectListingsLogic.buyFromListing.selector, + "buyFromListing(uint256,address,uint256,address,uint256)" + ); + extension_directListings.functions[10] = ExtensionFunction( + DirectListingsLogic.getAllListings.selector, + "getAllListings(uint256,uint256)" + ); + extension_directListings.functions[11] = ExtensionFunction( + DirectListingsLogic.getAllValidListings.selector, + "getAllValidListings(uint256,uint256)" + ); + extension_directListings.functions[12] = ExtensionFunction( + DirectListingsLogic.getListing.selector, + "getListing(uint256)" + ); + + extensions[0] = extension_directListings; + } + + function test_approveCurrencyForListing_listingDoesntExist() public { + vm.prank(seller); + vm.expectRevert("Marketplace: invalid listing."); + DirectListingsLogic(marketplace).approveCurrencyForListing(listingId, address(weth), 1 ether); + } + + modifier whenListingExists() { + vm.startPrank(marketplaceDeployer); + Permissions(marketplace).grantRole(keccak256("ASSET_ROLE"), address(erc721)); + Permissions(marketplace).grantRole(keccak256("LISTER_ROLE"), seller); + vm.stopPrank(); + + vm.startPrank(seller); + erc721.setApprovalForAll(marketplace, true); + listingId = DirectListingsLogic(marketplace).createListing(listingParams); + erc721.setApprovalForAll(marketplace, false); + vm.stopPrank(); + + vm.prank(marketplaceDeployer); + Permissions(marketplace).revokeRole(keccak256("ASSET_ROLE"), address(erc721)); + _; + } + + function test_approveCurrencyForListing_whenCallerNotListingCreator() public whenListingExists { + vm.prank(address(0x4353)); + vm.expectRevert("Marketplace: not listing creator."); + DirectListingsLogic(marketplace).approveCurrencyForListing(listingId, address(weth), 1 ether); + } + + modifier whenCallerIsListingCreator() { + _; + } + + function test_approveCurrencyForListing_whenApprovingDifferentPriceForListedCurrency() + public + whenListingExists + whenCallerIsListingCreator + { + vm.prank(seller); + vm.expectRevert("Marketplace: approving listing currency with different price."); + DirectListingsLogic(marketplace).approveCurrencyForListing( + listingId, + listingParams.currency, + listingParams.pricePerToken + 1 + ); + } + + function test_approveCurrencyForListing_whenPriceToApproveIsAlreadyApproved() + public + whenListingExists + whenCallerIsListingCreator + { + vm.prank(seller); + DirectListingsLogic(marketplace).approveCurrencyForListing(listingId, address(weth), 1 ether); + + vm.prank(seller); + vm.expectRevert("Marketplace: price unchanged."); + DirectListingsLogic(marketplace).approveCurrencyForListing(listingId, address(weth), 1 ether); + } + + function test_approveCurrencyForListing_whenApprovedPriceForCurrencyIsDifferentThanIncumbent() + public + whenListingExists + whenCallerIsListingCreator + { + vm.expectRevert("Currency not approved for listing"); + DirectListingsLogic(marketplace).currencyPriceForListing(listingId, address(weth)); + + vm.prank(seller); + vm.expectEmit(true, true, true, true); + emit CurrencyApprovedForListing(listingId, address(weth), 1 ether); + DirectListingsLogic(marketplace).approveCurrencyForListing(listingId, address(weth), 1 ether); + + assertEq(DirectListingsLogic(marketplace).currencyPriceForListing(listingId, address(weth)), 1 ether); + } +} diff --git a/src/test/marketplace/direct-listings/approveCurrencyForListing/approveCurrencyForListing.tree b/src/test/marketplace/direct-listings/approveCurrencyForListing/approveCurrencyForListing.tree new file mode 100644 index 000000000..1c7912edc --- /dev/null +++ b/src/test/marketplace/direct-listings/approveCurrencyForListing/approveCurrencyForListing.tree @@ -0,0 +1,19 @@ +function approveCurrencyForListing( + uint256 _listingId, + address _currency, + uint256 _pricePerTokenInCurrency +) +├── when listing does not exist +│ └── it should revert ✅ +└── when the listing exists + ├── when the caller is not listing creator + │ └── it should revert ✅ + └── when the caller is listing creator + ├── when approving different price for listed currency + │ └── it should revert ✅ + └── when not approving different price for listed currency + ├── when prive to approve for currency is already approved + │ └── it should revert ✅ + └── when approving a new price for currency ✅ + ├── it should update the approved price for currency + └── it should emit CurrencyApprovedForListing event with the listing ID, currency and approved price \ No newline at end of file diff --git a/src/test/marketplace/direct-listings/buyFromListing/buyFromListing.t.sol b/src/test/marketplace/direct-listings/buyFromListing/buyFromListing.t.sol new file mode 100644 index 000000000..8c11e6540 --- /dev/null +++ b/src/test/marketplace/direct-listings/buyFromListing/buyFromListing.t.sol @@ -0,0 +1,636 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "../../../utils/BaseTest.sol"; +import "@thirdweb-dev/dynamic-contracts/src/interface/IExtension.sol"; + +import { RoyaltyPaymentsLogic } from "contracts/extension/plugin/RoyaltyPayments.sol"; +import { PlatformFee } from "contracts/extension/PlatformFee.sol"; +import { TWProxy } from "contracts/infra/TWProxy.sol"; +import { MarketplaceV3 } from "contracts/prebuilts/marketplace/entrypoint/MarketplaceV3.sol"; +import { DirectListingsLogic } from "contracts/prebuilts/marketplace/direct-listings/DirectListingsLogic.sol"; +import { IDirectListings } from "contracts/prebuilts/marketplace/IMarketplace.sol"; +import { MockRoyaltyEngineV1 } from "../../../mocks/MockRoyaltyEngineV1.sol"; +import { ERC1155Holder } from "@openzeppelin/contracts/token/ERC1155/utils/ERC1155Holder.sol"; + +contract ReentrantRecipient is ERC1155Holder { + function onERC1155Received( + address operator, + address from, + uint256 id, + uint256 value, + bytes memory data + ) public virtual override returns (bytes4) { + DirectListingsLogic(msg.sender).buyFromListing(0, address(this), 1, address(0), 0); + return super.onERC1155Received(operator, from, id, value, data); + } +} + +contract BuyFromListingTest is BaseTest, IExtension { + // Target contract + address public marketplace; + + // Participants + address public marketplaceDeployer; + address public seller; + address public buyer; + + // Default listing parameters + IDirectListings.ListingParameters internal listingParams; + + uint256 internal listingId = type(uint256).max; + uint256 internal listingId_native_noSpecialPrice = 0; + uint256 internal listingId_native_specialPrice = 1; + uint256 internal listingId_erc20_noSpecialPrice = 2; + uint256 internal listingId_erc20_specialPrice = 3; + + // Events to test + + /// @notice Emitted when NFTs are bought from a listing. + event NewSale( + address indexed listingCreator, + uint256 indexed listingId, + address indexed assetContract, + uint256 tokenId, + address buyer, + uint256 quantityBought, + uint256 totalPricePaid + ); + + function setUp() public override { + super.setUp(); + + marketplaceDeployer = getActor(1); + seller = getActor(2); + buyer = getActor(3); + + // Deploy implementation. + Extension[] memory extensions = _setupExtensions(); + address impl = address( + new MarketplaceV3(MarketplaceV3.MarketplaceConstructorParams(extensions, address(0), address(weth))) + ); + + vm.prank(marketplaceDeployer); + marketplace = address( + new TWProxy( + impl, + abi.encodeCall( + MarketplaceV3.initialize, + (marketplaceDeployer, "", new address[](0), platformFeeRecipient, uint16(platformFeeBps)) + ) + ) + ); + + // Setup listing params + address assetContract = address(erc1155); + uint256 tokenId = 0; + uint256 quantity = 10; + address currency = NATIVE_TOKEN; + uint256 pricePerToken = 1 ether; + uint128 startTimestamp = 100 minutes; + uint128 endTimestamp = 200 minutes; + bool reserved = false; + + listingParams = IDirectListings.ListingParameters( + assetContract, + tokenId, + quantity, + currency, + pricePerToken, + startTimestamp, + endTimestamp, + reserved + ); + + // Mint currency to buyer + vm.deal(buyer, 100 ether); + erc20.mint(buyer, 100 ether); + + // Mint an ERC721 NFTs to seller + erc1155.mint(seller, 0, 100); + vm.prank(seller); + erc1155.setApprovalForAll(marketplace, true); + + // Create 4 listings + vm.startPrank(seller); + + // 1. Native token, no special price + listingParams.currency = NATIVE_TOKEN; + listingId_native_noSpecialPrice = DirectListingsLogic(marketplace).createListing(listingParams); + + // 2. Native token, special price + listingParams.currency = address(erc20); + listingId_native_specialPrice = DirectListingsLogic(marketplace).createListing(listingParams); + DirectListingsLogic(marketplace).approveCurrencyForListing( + listingId_native_specialPrice, + NATIVE_TOKEN, + 2 ether + ); + + // 3. ERC20 token, no special price + listingParams.currency = address(erc20); + listingId_erc20_noSpecialPrice = DirectListingsLogic(marketplace).createListing(listingParams); + + // 4. ERC20 token, special price + listingParams.currency = NATIVE_TOKEN; + listingId_erc20_specialPrice = DirectListingsLogic(marketplace).createListing(listingParams); + DirectListingsLogic(marketplace).approveCurrencyForListing( + listingId_erc20_specialPrice, + address(erc20), + 2 ether + ); + + vm.stopPrank(); + + vm.label(impl, "MarketplaceV3_Impl"); + vm.label(marketplace, "Marketplace"); + vm.label(seller, "Seller"); + vm.label(address(erc721), "ERC721_Token"); + vm.label(address(erc1155), "ERC1155_Token"); + } + + function _setupExtensions() internal returns (Extension[] memory extensions) { + extensions = new Extension[](1); + + // Deploy `DirectListings` + address directListings = address(new DirectListingsLogic(address(weth))); + vm.label(directListings, "DirectListings_Extension"); + + // Extension: DirectListingsLogic + Extension memory extension_directListings; + extension_directListings.metadata = ExtensionMetadata({ + name: "DirectListingsLogic", + metadataURI: "ipfs://DirectListings", + implementation: directListings + }); + + extension_directListings.functions = new ExtensionFunction[](13); + extension_directListings.functions[0] = ExtensionFunction( + DirectListingsLogic.totalListings.selector, + "totalListings()" + ); + extension_directListings.functions[1] = ExtensionFunction( + DirectListingsLogic.isBuyerApprovedForListing.selector, + "isBuyerApprovedForListing(uint256,address)" + ); + extension_directListings.functions[2] = ExtensionFunction( + DirectListingsLogic.isCurrencyApprovedForListing.selector, + "isCurrencyApprovedForListing(uint256,address)" + ); + extension_directListings.functions[3] = ExtensionFunction( + DirectListingsLogic.currencyPriceForListing.selector, + "currencyPriceForListing(uint256,address)" + ); + extension_directListings.functions[4] = ExtensionFunction( + DirectListingsLogic.createListing.selector, + "createListing((address,uint256,uint256,address,uint256,uint128,uint128,bool))" + ); + extension_directListings.functions[5] = ExtensionFunction( + DirectListingsLogic.updateListing.selector, + "updateListing(uint256,(address,uint256,uint256,address,uint256,uint128,uint128,bool))" + ); + extension_directListings.functions[6] = ExtensionFunction( + DirectListingsLogic.cancelListing.selector, + "cancelListing(uint256)" + ); + extension_directListings.functions[7] = ExtensionFunction( + DirectListingsLogic.approveBuyerForListing.selector, + "approveBuyerForListing(uint256,address,bool)" + ); + extension_directListings.functions[8] = ExtensionFunction( + DirectListingsLogic.approveCurrencyForListing.selector, + "approveCurrencyForListing(uint256,address,uint256)" + ); + extension_directListings.functions[9] = ExtensionFunction( + DirectListingsLogic.buyFromListing.selector, + "buyFromListing(uint256,address,uint256,address,uint256)" + ); + extension_directListings.functions[10] = ExtensionFunction( + DirectListingsLogic.getAllListings.selector, + "getAllListings(uint256,uint256)" + ); + extension_directListings.functions[11] = ExtensionFunction( + DirectListingsLogic.getAllValidListings.selector, + "getAllValidListings(uint256,uint256)" + ); + extension_directListings.functions[12] = ExtensionFunction( + DirectListingsLogic.getListing.selector, + "getListing(uint256)" + ); + + extensions[0] = extension_directListings; + } + + modifier whenListingCurrencyIsNativeToken() { + listingId = listingId_native_noSpecialPrice; + listingParams.currency = NATIVE_TOKEN; + _; + } + + modifier whenListingHasSpecialPriceNativeToken() { + listingId = listingId_native_specialPrice; + _; + } + + modifier whenListingCurrencyIsERC20Token() { + listingId = listingId_erc20_noSpecialPrice; + _; + } + + modifier whenListingHasSpecialPriceERC20Token() { + listingId = listingId_erc20_specialPrice; + _; + } + + //////////// ASSUME NATIVE_TOKEN && SPECIAL_PRICE //////////// + + function test_buyFromListing_whenCallIsReentrant() public whenListingHasSpecialPriceNativeToken { + vm.warp(listingParams.startTimestamp); + address reentrantRecipient = address(new ReentrantRecipient()); + + vm.prank(buyer); + vm.expectRevert(); + DirectListingsLogic(marketplace).buyFromListing{ value: 2 ether }( + listingId, + reentrantRecipient, + 1, + NATIVE_TOKEN, + 2 ether + ); + } + + modifier whenCallIsNotReentrant() { + _; + } + + function test_buyFromListing_whenListingDoesNotExist() + public + whenListingHasSpecialPriceNativeToken + whenCallIsNotReentrant + { + vm.warp(listingParams.startTimestamp); + vm.prank(buyer); + vm.expectRevert("Marketplace: invalid listing."); + DirectListingsLogic(marketplace).buyFromListing{ value: 2 ether }(100, buyer, 1, NATIVE_TOKEN, 2 ether); + } + + modifier whenListingExists() { + _; + } + + function test_buyFromListing_whenBuyerIsNotApprovedForListing() + public + whenListingHasSpecialPriceNativeToken + whenCallIsNotReentrant + whenListingExists + { + listingParams.reserved = true; + listingParams.currency = address(erc20); + + vm.prank(seller); + DirectListingsLogic(marketplace).updateListing(listingId, listingParams); + + vm.warp(listingParams.startTimestamp); + + vm.prank(buyer); + vm.expectRevert("buyer not approved"); + DirectListingsLogic(marketplace).buyFromListing{ value: 2 ether }(listingId, buyer, 1, NATIVE_TOKEN, 2 ether); + } + + modifier whenBuyerIsApprovedForListing(address _currency) { + listingParams.reserved = true; + listingParams.currency = _currency; + + vm.prank(seller); + DirectListingsLogic(marketplace).updateListing(listingId, listingParams); + + vm.prank(seller); + DirectListingsLogic(marketplace).approveBuyerForListing(listingId, buyer, true); + _; + } + + function test_buyFromListing_whenQuantityToBuyIsInvalid() + public + whenListingHasSpecialPriceNativeToken + whenCallIsNotReentrant + whenListingExists + whenBuyerIsApprovedForListing(address(erc20)) + { + vm.warp(listingParams.startTimestamp); + + vm.prank(buyer); + vm.expectRevert("Buying invalid quantity"); + DirectListingsLogic(marketplace).buyFromListing{ value: 2 ether }(listingId, buyer, 0, NATIVE_TOKEN, 2 ether); + } + + modifier whenQuantityToBuyIsValid() { + _; + } + + function test_buyFromListing_whenListingIsInactive() + public + whenListingHasSpecialPriceNativeToken + whenCallIsNotReentrant + whenListingExists + whenBuyerIsApprovedForListing(address(erc20)) + whenQuantityToBuyIsValid + { + vm.prank(buyer); + vm.expectRevert("not within sale window."); + DirectListingsLogic(marketplace).buyFromListing{ value: 2 ether }(listingId, buyer, 1, NATIVE_TOKEN, 2 ether); + } + + modifier whenListingIsActive() { + _; + } + + function test_buyFromListing_whenListedAssetNotOwnedOrApprovedToTransfer() + public + whenListingHasSpecialPriceNativeToken + whenCallIsNotReentrant + whenListingExists + whenBuyerIsApprovedForListing(address(erc20)) + whenQuantityToBuyIsValid + whenListingIsActive + { + vm.prank(seller); + erc1155.setApprovalForAll(marketplace, false); + + vm.warp(listingParams.startTimestamp); + + vm.prank(buyer); + vm.expectRevert("Marketplace: not owner or approved tokens."); + DirectListingsLogic(marketplace).buyFromListing{ value: 2 ether }(listingId, buyer, 1, NATIVE_TOKEN, 2 ether); + } + + modifier whenListedAssetOwnedAndApproved() { + _; + } + + function test_buyFromListing_whenExpectedPriceNotActualPrice() + public + whenListingHasSpecialPriceNativeToken + whenCallIsNotReentrant + whenListingExists + whenBuyerIsApprovedForListing(address(erc20)) + whenQuantityToBuyIsValid + whenListingIsActive + whenListedAssetOwnedAndApproved + { + vm.warp(listingParams.startTimestamp); + + vm.prank(buyer); + vm.expectRevert("Unexpected total price"); + DirectListingsLogic(marketplace).buyFromListing{ value: 2 ether }(listingId, buyer, 1, NATIVE_TOKEN, 1 ether); + } + + modifier whenExpectedPriceIsActualPrice() { + _; + } + + function test_buyFromListing_whenMsgValueNotEqTotalPrice() + public + whenListingHasSpecialPriceNativeToken + whenCallIsNotReentrant + whenListingExists + whenBuyerIsApprovedForListing(address(erc20)) + whenQuantityToBuyIsValid + whenListingIsActive + whenListedAssetOwnedAndApproved + whenExpectedPriceIsActualPrice + { + vm.warp(listingParams.startTimestamp); + + vm.prank(buyer); + vm.expectRevert("Marketplace: msg.value must exactly be the total price."); + DirectListingsLogic(marketplace).buyFromListing{ value: 1 ether }(listingId, buyer, 1, NATIVE_TOKEN, 2 ether); + } + + modifier whenMsgValueEqTotalPrice() { + _; + } + + function test_buyFromListing_whenAllRemainingQtyIsBought_nativeToken() + public + whenListingHasSpecialPriceNativeToken + whenCallIsNotReentrant + whenListingExists + whenBuyerIsApprovedForListing(address(erc20)) + whenQuantityToBuyIsValid + whenListingIsActive + whenListedAssetOwnedAndApproved + whenExpectedPriceIsActualPrice + whenMsgValueEqTotalPrice + { + assertEq(erc1155.balanceOf(seller, listingParams.tokenId), 100); + assertEq(erc1155.balanceOf(buyer, listingParams.tokenId), 0); + assertEq( + uint8(DirectListingsLogic(marketplace).getListing(listingId).status), + uint8(IDirectListings.Status.CREATED) + ); + + vm.warp(listingParams.startTimestamp); + vm.prank(buyer); + DirectListingsLogic(marketplace).buyFromListing{ value: 2 ether * listingParams.quantity }( + listingId, + buyer, + listingParams.quantity, + NATIVE_TOKEN, + 2 ether * listingParams.quantity + ); + + assertEq(erc1155.balanceOf(seller, listingParams.tokenId), 100 - listingParams.quantity); + assertEq(erc1155.balanceOf(buyer, listingParams.tokenId), listingParams.quantity); + assertEq( + uint8(DirectListingsLogic(marketplace).getListing(listingId).status), + uint8(IDirectListings.Status.COMPLETED) + ); + } + + function test_buyFromListing_whenSomeRemainingQtyIsBought_nativeToken() + public + whenListingHasSpecialPriceNativeToken + whenCallIsNotReentrant + whenListingExists + whenBuyerIsApprovedForListing(address(erc20)) + whenQuantityToBuyIsValid + whenListingIsActive + whenListedAssetOwnedAndApproved + whenExpectedPriceIsActualPrice + whenMsgValueEqTotalPrice + { + assertEq(erc1155.balanceOf(seller, listingParams.tokenId), 100); + assertEq(erc1155.balanceOf(buyer, listingParams.tokenId), 0); + assertEq( + uint8(DirectListingsLogic(marketplace).getListing(listingId).status), + uint8(IDirectListings.Status.CREATED) + ); + + vm.warp(listingParams.startTimestamp); + vm.prank(buyer); + DirectListingsLogic(marketplace).buyFromListing{ value: 2 ether }(listingId, buyer, 1, NATIVE_TOKEN, 2 ether); + + assertEq(erc1155.balanceOf(seller, listingParams.tokenId), 99); + assertEq(erc1155.balanceOf(buyer, listingParams.tokenId), 1); + assertEq( + uint8(DirectListingsLogic(marketplace).getListing(listingId).status), + uint8(IDirectListings.Status.CREATED) + ); + } + + //////////// ASSUME NATIVE_TOKEN && NO_SPECIAL_PRICE //////////// + + function test_buyFromListing_whenCurrencyToUseNotListedCurrency() + public + whenListingCurrencyIsNativeToken + whenCallIsNotReentrant + whenListingExists + whenBuyerIsApprovedForListing(NATIVE_TOKEN) + whenQuantityToBuyIsValid + whenListingIsActive + whenListedAssetOwnedAndApproved + { + vm.warp(listingParams.startTimestamp); + + vm.prank(buyer); + vm.expectRevert("Paying in invalid currency."); + DirectListingsLogic(marketplace).buyFromListing{ value: 2 ether * listingParams.quantity }( + listingId, + buyer, + listingParams.quantity, + address(erc20), + 2 ether * listingParams.quantity + ); + } + + //////////// ASSUME ERC20 && NO_SPECIAL_PRICE //////////// + + function test_buyFromListing_whenInsufficientTokenBalanceOrAllowance() + public + whenListingCurrencyIsERC20Token + whenCallIsNotReentrant + whenListingExists + whenBuyerIsApprovedForListing(address(erc20)) + whenQuantityToBuyIsValid + whenListingIsActive + whenListedAssetOwnedAndApproved + whenExpectedPriceIsActualPrice + { + vm.warp(listingParams.startTimestamp); + + vm.prank(buyer); + vm.expectRevert("!BAL20"); + DirectListingsLogic(marketplace).buyFromListing( + listingId, + buyer, + listingParams.quantity, + address(erc20), + 1 ether * listingParams.quantity + ); + } + + modifier whenSufficientTokenBalanceOrAllowance() { + vm.prank(buyer); + erc20.approve(marketplace, 100 ether); + _; + } + + function test_buyFromListing_whenMsgValueNotZero() + public + whenListingCurrencyIsERC20Token + whenCallIsNotReentrant + whenListingExists + whenBuyerIsApprovedForListing(address(erc20)) + whenQuantityToBuyIsValid + whenListingIsActive + whenListedAssetOwnedAndApproved + whenExpectedPriceIsActualPrice + whenSufficientTokenBalanceOrAllowance + { + vm.warp(listingParams.startTimestamp); + + vm.prank(buyer); + vm.expectRevert("Marketplace: invalid native tokens sent."); + DirectListingsLogic(marketplace).buyFromListing{ value: 1 ether }( + listingId, + buyer, + listingParams.quantity, + address(erc20), + 1 ether * listingParams.quantity + ); + } + + modifier whenMsgValueIsZero() { + _; + } + + function test_buyFromListing_whenAllRemainingQtyIsBought_erc20() + public + whenListingCurrencyIsERC20Token + whenCallIsNotReentrant + whenListingExists + whenBuyerIsApprovedForListing(address(erc20)) + whenQuantityToBuyIsValid + whenListingIsActive + whenListedAssetOwnedAndApproved + whenExpectedPriceIsActualPrice + whenSufficientTokenBalanceOrAllowance + whenMsgValueIsZero + { + assertEq(erc1155.balanceOf(seller, listingParams.tokenId), 100); + assertEq(erc1155.balanceOf(buyer, listingParams.tokenId), 0); + assertEq( + uint8(DirectListingsLogic(marketplace).getListing(listingId).status), + uint8(IDirectListings.Status.CREATED) + ); + + vm.warp(listingParams.startTimestamp); + vm.prank(buyer); + DirectListingsLogic(marketplace).buyFromListing( + listingId, + buyer, + listingParams.quantity, + address(erc20), + 1 ether * listingParams.quantity + ); + + assertEq(erc1155.balanceOf(seller, listingParams.tokenId), 100 - listingParams.quantity); + assertEq(erc1155.balanceOf(buyer, listingParams.tokenId), listingParams.quantity); + assertEq( + uint8(DirectListingsLogic(marketplace).getListing(listingId).status), + uint8(IDirectListings.Status.COMPLETED) + ); + } + + function test_buyFromListing_whenSomeRemainingQtyIsBought_erc20() + public + whenListingCurrencyIsERC20Token + whenCallIsNotReentrant + whenListingExists + whenBuyerIsApprovedForListing(address(erc20)) + whenQuantityToBuyIsValid + whenListingIsActive + whenListedAssetOwnedAndApproved + whenExpectedPriceIsActualPrice + whenSufficientTokenBalanceOrAllowance + whenMsgValueIsZero + { + assertEq(erc1155.balanceOf(seller, listingParams.tokenId), 100); + assertEq(erc1155.balanceOf(buyer, listingParams.tokenId), 0); + assertEq( + uint8(DirectListingsLogic(marketplace).getListing(listingId).status), + uint8(IDirectListings.Status.CREATED) + ); + + vm.warp(listingParams.startTimestamp); + vm.prank(buyer); + DirectListingsLogic(marketplace).buyFromListing(listingId, buyer, 1, address(erc20), 1 ether); + + assertEq(erc1155.balanceOf(seller, listingParams.tokenId), 99); + assertEq(erc1155.balanceOf(buyer, listingParams.tokenId), 1); + assertEq( + uint8(DirectListingsLogic(marketplace).getListing(listingId).status), + uint8(IDirectListings.Status.CREATED) + ); + } +} diff --git a/src/test/marketplace/direct-listings/buyFromListing/buyFromListing.tree b/src/test/marketplace/direct-listings/buyFromListing/buyFromListing.tree new file mode 100644 index 000000000..0b7ae81fa --- /dev/null +++ b/src/test/marketplace/direct-listings/buyFromListing/buyFromListing.tree @@ -0,0 +1,172 @@ +function buyFromListing( + uint256 _listingId, + address _buyFor, + uint256 _quantity, + address _currency, + uint256 _expectedTotalPrice +) + +// ASSUME NATIVE_TOKEN && SPECIAL_PRICE +. +├── when the call is reentrant +│ └── it should revert +└── when the call is not reentrant + ├── when no listing with the given listing ID exists + │ └── it should revert + └── when listing with the given listing ID exists + ├── when the listing is reserved and caller is not approved for listing + │ └── it should revert + └── when the listing is not reserved OR caller is approved for listing + ├── when quantity to buy is invalid i.e. zero OR exceeds the listing quantity + │ └── it should revert + └── when quantity to buy is valid i.e. non-zero and does not exceed the listing quantity + ├── when the listing is inactive + │ └── it should revert + └── when the listing is active + ├── when the asset is not owned by listing creator or marketplace is not approved for transfer + │ └── it should revert + └── when the asset is owned by listing creator and marketplace is approved for transfer + ├── when the calculated total price is not equal to the expected total price + │ └── it should revert + └── when the calculated total price is equal to the expected total price + ├── when msg.value is not equal to the calculated total price + │ └── it should revert + └── when msg.value is equal to the calculated total price + ├── when the quantity bought is the total remaining listing quantity + │ ├── it should set the status of the lisitng as complete + │ ├── it should subtract the quantity to buy from the listing quantity + │ ├── it should payout platform fees and royalty fees to respective recipients + │ ├── it should transfer the bought tokens from the listing creator to the appropriate recipient + │ └── it should emit NewSale event with the correct total price to paid + └── when the quantity bought is not the total remaining listing quantity + ├── it should subtract the quantity to buy from the listing quantity + ├── it should payout platform fees and royalty fees to respective recipients + ├── it should transfer the bought tokens from the listing creator to the appropriate recipient + └── it should emit NewSale event with the correct total price to paid + +// ASSUME NATIVE_TOKEN && NO_SPECIAL_PRICE +. +├── when the call is reentrant +│ └── it should revert +└── when the call is not reentrant + ├── when no listing with the given listing ID exists + │ └── it should revert + └── when listing with the given listing ID exists + ├── when the listing is reserved and caller is not approved for listing + │ └── it should revert + └── when the listing is not reserved OR caller is approved for listing + ├── when quantity to buy is invalid i.e. zero OR exceeds the listing quantity + │ └── it should revert + └── when quantity to buy is valid i.e. non-zero and does not exceed the listing quantity + ├── when the listing is inactive + │ └── it should revert + └── when the listing is active + ├── when the asset is not owned by listing creator or marketplace is not approved for transfer + │ └── it should revert + └── when the asset is owned by listing creator and marketplace is approved for transfer + ├── when the currency to pay in is not the listing's accepted currency + │ └── it should revert + └── when the currency to pay in is the listing's accepted currency + ├── when the calculated total price is not equal to the expected total price + │ └── it should revert + └── when the calculated total price is equal to the expected total price + ├── when the msg.value is not equal to the calculated total price + │ └── it should revert + └── when the msg.value is equal to the calculated total price + ├── when the quantity bought is the total remaining listing quantity + │ ├── it should set the status of the lisitng as complete + │ ├── it should subtract the quantity to buy from the listing quantity + │ ├── it should payout platform fees and royalty fees to respective recipients + │ ├── it should transfer the bought tokens from the listing creator to the appropriate recipient + │ └── it should emit NewSale event with the correct total price to paid + └── when the quantity bought is not the total remaining listing quantity + ├── it should subtract the quantity to buy from the listing quantity + ├── it should payout platform fees and royalty fees to respective recipients + ├── it should transfer the bought tokens from the listing creator to the appropriate recipient + └── it should emit NewSale event with the correct total price to paid + +// ASSUME ERC20 && NO_SPECIAL_PRICE +. +├── when the call is reentrant +│ └── it should revert +└── when the call is not reentrant + ├── when no listing with the given listing ID exists + │ └── it should revert + └── when listing with the given listing ID exists + ├── when the listing is reserved and caller is not approved for listing + │ └── it should revert + └── when the listing is not reserved OR caller is approved for listing + ├── when quantity to buy is invalid i.e. zero OR exceeds the listing quantity + │ └── it should revert + └── when quantity to buy is valid i.e. non-zero and does not exceed the listing quantity + ├── when the listing is inactive + │ └── it should revert + └── when the listing is active + ├── when the asset is not owned by listing creator or marketplace is not approved for transfer + │ └── it should revert + └── when the asset is owned by listing creator and marketplace is approved for transfer + ├── when the currency to pay in is not the listing's accepted currency + │ └── it should revert + └── when the currency to pay in is the listing's accepted currency + ├── when the calculated total price is not equal to the expected total price + │ └── it should revert + └── when the calculated total price is equal to the expected total price + └── when ERC20 balance and allowance is invalid + ├── it should revert + └── when ERC20 balance and allowance is valid + ├── when msg.value is not zero + │ └── it should revert + └── when msg.value is zero + ├── when the quantity bought is the total remaining listing quantity + │ ├── it should set the status of the lisitng as complete + │ ├── it should subtract the quantity to buy from the listing quantity + │ ├── it should payout platform fees and royalty fees to respective recipients + │ ├── it should transfer the bought tokens from the listing creator to the appropriate recipient + │ └── it should emit NewSale event with the correct total price to paid + └── when the quantity bought is not the total remaining listing quantity + ├── it should subtract the quantity to buy from the listing quantity + ├── it should payout platform fees and royalty fees to respective recipients + ├── it should transfer the bought tokens from the listing creator to the appropriate recipient + └── it should emit NewSale event with the correct total price to paid + + +// ASSUME ERC20 && SPECIAL_PRICE +. +├── when the call is reentrant +│ └── it should revert +└── when the call is not reentrant + ├── when no listing with the given listing ID exists + │ └── it should revert + └── when listing with the given listing ID exists + ├── when the listing is reserved and caller is not approved for listing + │ └── it should revert + └── when the listing is not reserved OR caller is approved for listing + ├── when quantity to buy is invalid i.e. zero OR exceeds the listing quantity + │ └── it should revert + └── when quantity to buy is valid i.e. non-zero and does not exceed the listing quantity + ├── when the listing is inactive + │ └── it should revert + └── when the listing is active + ├── when the asset is not owned by listing creator or marketplace is not approved for transfer + │ └── it should revert + └── when the asset is owned by listing creator and marketplace is approved for transfer + ├── when the calculated total price is not equal to the expected total price + │ └── it should revert + └── when the calculated total price is equal to the expected total price + ├── when ERC20 balance and allowance is invalid + │ └── it should revert + └── when ERC20 balance and allowance is valid + ├── when msg.value is not zero + │ └── it should revert + └── when msg.value is zero + ├── when the quantity bought is the total remaining listing quantity + │ ├── it should set the status of the lisitng as complete + │ ├── it should subtract the quantity to buy from the listing quantity + │ ├── it should payout platform fees and royalty fees to respective recipients + │ ├── it should transfer the bought tokens from the listing creator to the appropriate recipient + │ └── it should emit NewSale event with the correct total price to paid + └── when the quantity bought is not the total remaining listing quantity + ├── it should subtract the quantity to buy from the listing quantity + ├── it should payout platform fees and royalty fees to respective recipients + ├── it should transfer the bought tokens from the listing creator to the appropriate recipient + └── it should emit NewSale event with the correct total price to paid diff --git a/src/test/marketplace/direct-listings/cancelListing/cancelListing.t.sol b/src/test/marketplace/direct-listings/cancelListing/cancelListing.t.sol new file mode 100644 index 000000000..6e77c4fe0 --- /dev/null +++ b/src/test/marketplace/direct-listings/cancelListing/cancelListing.t.sol @@ -0,0 +1,207 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "../../../utils/BaseTest.sol"; +import "@thirdweb-dev/dynamic-contracts/src/interface/IExtension.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; +import { MarketplaceV3 } from "contracts/prebuilts/marketplace/entrypoint/MarketplaceV3.sol"; +import { DirectListingsLogic } from "contracts/prebuilts/marketplace/direct-listings/DirectListingsLogic.sol"; +import { IDirectListings } from "contracts/prebuilts/marketplace/IMarketplace.sol"; + +contract CancelListingTest is BaseTest, IExtension { + // Target contract + address public marketplace; + + // Participants + address public marketplaceDeployer; + address public seller; + + // Default listing parameters + IDirectListings.ListingParameters internal listingParams; + uint256 internal listingId = 0; + + // Events to test + + /// @notice Emitted when a listing is updated. + event CancelledListing(address indexed listingCreator, uint256 indexed listingId); + + function setUp() public override { + super.setUp(); + + marketplaceDeployer = getActor(1); + seller = getActor(2); + + // Deploy implementation. + Extension[] memory extensions = _setupExtensions(); + address impl = address( + new MarketplaceV3(MarketplaceV3.MarketplaceConstructorParams(extensions, address(0), address(weth))) + ); + + vm.prank(marketplaceDeployer); + marketplace = address( + new TWProxy( + impl, + abi.encodeCall( + MarketplaceV3.initialize, + (marketplaceDeployer, "", new address[](0), marketplaceDeployer, 0) + ) + ) + ); + + // Setup roles + vm.startPrank(marketplaceDeployer); + Permissions(marketplace).revokeRole(keccak256("ASSET_ROLE"), address(0)); + Permissions(marketplace).revokeRole(keccak256("LISTER_ROLE"), address(0)); + + vm.stopPrank(); + + // Setup listing params + address assetContract = address(erc721); + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = address(erc20); + uint256 pricePerToken = 1 ether; + uint128 startTimestamp = 100 minutes; + uint128 endTimestamp = 200 minutes; + bool reserved = true; + + listingParams = IDirectListings.ListingParameters( + assetContract, + tokenId, + quantity, + currency, + pricePerToken, + startTimestamp, + endTimestamp, + reserved + ); + + // Mint 1 ERC721 NFT to seller + erc721.mint(seller, listingParams.quantity); + + vm.label(impl, "MarketplaceV3_Impl"); + vm.label(marketplace, "Marketplace"); + vm.label(seller, "Seller"); + vm.label(address(erc721), "ERC721_Token"); + vm.label(address(erc1155), "ERC1155_Token"); + } + + function _setupExtensions() internal returns (Extension[] memory extensions) { + extensions = new Extension[](1); + + // Deploy `DirectListings` + address directListings = address(new DirectListingsLogic(address(weth))); + vm.label(directListings, "DirectListings_Extension"); + + // Extension: DirectListingsLogic + Extension memory extension_directListings; + extension_directListings.metadata = ExtensionMetadata({ + name: "DirectListingsLogic", + metadataURI: "ipfs://DirectListings", + implementation: directListings + }); + + extension_directListings.functions = new ExtensionFunction[](13); + extension_directListings.functions[0] = ExtensionFunction( + DirectListingsLogic.totalListings.selector, + "totalListings()" + ); + extension_directListings.functions[1] = ExtensionFunction( + DirectListingsLogic.isBuyerApprovedForListing.selector, + "isBuyerApprovedForListing(uint256,address)" + ); + extension_directListings.functions[2] = ExtensionFunction( + DirectListingsLogic.isCurrencyApprovedForListing.selector, + "isCurrencyApprovedForListing(uint256,address)" + ); + extension_directListings.functions[3] = ExtensionFunction( + DirectListingsLogic.currencyPriceForListing.selector, + "currencyPriceForListing(uint256,address)" + ); + extension_directListings.functions[4] = ExtensionFunction( + DirectListingsLogic.createListing.selector, + "createListing((address,uint256,uint256,address,uint256,uint128,uint128,bool))" + ); + extension_directListings.functions[5] = ExtensionFunction( + DirectListingsLogic.updateListing.selector, + "updateListing(uint256,(address,uint256,uint256,address,uint256,uint128,uint128,bool))" + ); + extension_directListings.functions[6] = ExtensionFunction( + DirectListingsLogic.cancelListing.selector, + "cancelListing(uint256)" + ); + extension_directListings.functions[7] = ExtensionFunction( + DirectListingsLogic.approveBuyerForListing.selector, + "approveBuyerForListing(uint256,address,bool)" + ); + extension_directListings.functions[8] = ExtensionFunction( + DirectListingsLogic.approveCurrencyForListing.selector, + "approveCurrencyForListing(uint256,address,uint256)" + ); + extension_directListings.functions[9] = ExtensionFunction( + DirectListingsLogic.buyFromListing.selector, + "buyFromListing(uint256,address,uint256,address,uint256)" + ); + extension_directListings.functions[10] = ExtensionFunction( + DirectListingsLogic.getAllListings.selector, + "getAllListings(uint256,uint256)" + ); + extension_directListings.functions[11] = ExtensionFunction( + DirectListingsLogic.getAllValidListings.selector, + "getAllValidListings(uint256,uint256)" + ); + extension_directListings.functions[12] = ExtensionFunction( + DirectListingsLogic.getListing.selector, + "getListing(uint256)" + ); + + extensions[0] = extension_directListings; + } + + function test_cancelListing_whenListingDoesntExist() public { + vm.prank(seller); + vm.expectRevert("Marketplace: invalid listing."); + DirectListingsLogic(marketplace).cancelListing(listingId); + } + + modifier whenListingExists() { + vm.startPrank(marketplaceDeployer); + Permissions(marketplace).grantRole(keccak256("ASSET_ROLE"), address(erc721)); + Permissions(marketplace).grantRole(keccak256("LISTER_ROLE"), seller); + vm.stopPrank(); + + vm.startPrank(seller); + erc721.setApprovalForAll(marketplace, true); + listingId = DirectListingsLogic(marketplace).createListing(listingParams); + erc721.setApprovalForAll(marketplace, false); + vm.stopPrank(); + + vm.prank(marketplaceDeployer); + Permissions(marketplace).revokeRole(keccak256("ASSET_ROLE"), address(erc721)); + _; + } + + function test_cancelListing_whenCallerNotListingCreator() public whenListingExists { + vm.prank(address(0x4567)); + vm.expectRevert("Marketplace: not listing creator."); + DirectListingsLogic(marketplace).cancelListing(listingId); + } + + modifier whenCallerIsListingCreator() { + _; + } + + function test_cancelListing_success() public whenListingExists whenCallerIsListingCreator { + vm.warp(listingParams.startTimestamp + 1); + + assertEq(uint8(DirectListingsLogic(marketplace).getListing(listingId).status), uint8(1)); // CREATED + + vm.prank(seller); + vm.expectEmit(true, true, true, true); + emit CancelledListing(seller, listingId); + DirectListingsLogic(marketplace).cancelListing(listingId); + + assertEq(uint8(DirectListingsLogic(marketplace).getListing(listingId).status), uint8(3)); // CANCELLED + } +} diff --git a/src/test/marketplace/direct-listings/cancelListing/cancelListing.tree b/src/test/marketplace/direct-listings/cancelListing/cancelListing.tree new file mode 100644 index 000000000..dd34eb23d --- /dev/null +++ b/src/test/marketplace/direct-listings/cancelListing/cancelListing.tree @@ -0,0 +1,9 @@ +function cancelListing(uint256 _listingId) +├── when no listing with the given listing ID exists +│ └── it should revert ✅ +└── when listing with the given listing ID exists + ├── when the caller is not listing creator + │ └── it should revert ✅ + └── when the caller is listing creator ✅ + ├── it should set status of listing as cancelled + └── it should emit CancelledListing event \ No newline at end of file diff --git a/src/test/marketplace/direct-listings/createListing/createListing.t.sol b/src/test/marketplace/direct-listings/createListing/createListing.t.sol new file mode 100644 index 000000000..415a1b71d --- /dev/null +++ b/src/test/marketplace/direct-listings/createListing/createListing.t.sol @@ -0,0 +1,363 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "../../../utils/BaseTest.sol"; +import "@thirdweb-dev/dynamic-contracts/src/interface/IExtension.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; +import { MarketplaceV3 } from "contracts/prebuilts/marketplace/entrypoint/MarketplaceV3.sol"; +import { DirectListingsLogic } from "contracts/prebuilts/marketplace/direct-listings/DirectListingsLogic.sol"; +import { IDirectListings } from "contracts/prebuilts/marketplace/IMarketplace.sol"; + +contract CreateListingTest is BaseTest, IExtension { + // Target contract + address public marketplace; + + // Participants + address public marketplaceDeployer; + address public seller; + + // Default listing parameters + IDirectListings.ListingParameters internal listingParams; + + // Events to test + + /// @notice Emitted when a new listing is created. + event NewListing( + address indexed listingCreator, + uint256 indexed listingId, + address indexed assetContract, + IDirectListings.Listing listing + ); + + function setUp() public override { + super.setUp(); + + marketplaceDeployer = getActor(1); + seller = getActor(2); + + // Deploy implementation. + Extension[] memory extensions = _setupExtensions(); + address impl = address( + new MarketplaceV3(MarketplaceV3.MarketplaceConstructorParams(extensions, address(0), address(weth))) + ); + + vm.prank(marketplaceDeployer); + marketplace = address( + new TWProxy( + impl, + abi.encodeCall( + MarketplaceV3.initialize, + (marketplaceDeployer, "", new address[](0), marketplaceDeployer, 0) + ) + ) + ); + + // Setup roles + vm.startPrank(marketplaceDeployer); + Permissions(marketplace).revokeRole(keccak256("ASSET_ROLE"), address(0)); + Permissions(marketplace).revokeRole(keccak256("LISTER_ROLE"), address(0)); + + vm.stopPrank(); + + // Setup listing params + address assetContract = address(erc721); + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = address(erc20); + uint256 pricePerToken = 1 ether; + uint128 startTimestamp = 100; + uint128 endTimestamp = 200; + bool reserved = true; + + listingParams = IDirectListings.ListingParameters( + assetContract, + tokenId, + quantity, + currency, + pricePerToken, + startTimestamp, + endTimestamp, + reserved + ); + + // Mint 1 ERC721 NFT to seller + erc721.mint(seller, listingParams.quantity); + + vm.label(impl, "MarketplaceV3_Impl"); + vm.label(marketplace, "Marketplace"); + vm.label(seller, "Seller"); + vm.label(address(erc721), "ERC721_Token"); + vm.label(address(erc1155), "ERC1155_Token"); + } + + function _setupExtensions() internal returns (Extension[] memory extensions) { + extensions = new Extension[](1); + + // Deploy `DirectListings` + address directListings = address(new DirectListingsLogic(address(weth))); + vm.label(directListings, "DirectListings_Extension"); + + // Extension: DirectListingsLogic + Extension memory extension_directListings; + extension_directListings.metadata = ExtensionMetadata({ + name: "DirectListingsLogic", + metadataURI: "ipfs://DirectListings", + implementation: directListings + }); + + extension_directListings.functions = new ExtensionFunction[](13); + extension_directListings.functions[0] = ExtensionFunction( + DirectListingsLogic.totalListings.selector, + "totalListings()" + ); + extension_directListings.functions[1] = ExtensionFunction( + DirectListingsLogic.isBuyerApprovedForListing.selector, + "isBuyerApprovedForListing(uint256,address)" + ); + extension_directListings.functions[2] = ExtensionFunction( + DirectListingsLogic.isCurrencyApprovedForListing.selector, + "isCurrencyApprovedForListing(uint256,address)" + ); + extension_directListings.functions[3] = ExtensionFunction( + DirectListingsLogic.currencyPriceForListing.selector, + "currencyPriceForListing(uint256,address)" + ); + extension_directListings.functions[4] = ExtensionFunction( + DirectListingsLogic.createListing.selector, + "createListing((address,uint256,uint256,address,uint256,uint128,uint128,bool))" + ); + extension_directListings.functions[5] = ExtensionFunction( + DirectListingsLogic.updateListing.selector, + "updateListing(uint256,(address,uint256,uint256,address,uint256,uint128,uint128,bool))" + ); + extension_directListings.functions[6] = ExtensionFunction( + DirectListingsLogic.cancelListing.selector, + "cancelListing(uint256)" + ); + extension_directListings.functions[7] = ExtensionFunction( + DirectListingsLogic.approveBuyerForListing.selector, + "approveBuyerForListing(uint256,address,bool)" + ); + extension_directListings.functions[8] = ExtensionFunction( + DirectListingsLogic.approveCurrencyForListing.selector, + "approveCurrencyForListing(uint256,address,uint256)" + ); + extension_directListings.functions[9] = ExtensionFunction( + DirectListingsLogic.buyFromListing.selector, + "buyFromListing(uint256,address,uint256,address,uint256)" + ); + extension_directListings.functions[10] = ExtensionFunction( + DirectListingsLogic.getAllListings.selector, + "getAllListings(uint256,uint256)" + ); + extension_directListings.functions[11] = ExtensionFunction( + DirectListingsLogic.getAllValidListings.selector, + "getAllValidListings(uint256,uint256)" + ); + extension_directListings.functions[12] = ExtensionFunction( + DirectListingsLogic.getListing.selector, + "getListing(uint256)" + ); + + extensions[0] = extension_directListings; + } + + function test_createListing_whenCallerDoesNotHaveListerRole() public { + bytes32 role = keccak256("LISTER_ROLE"); + assertEq(Permissions(marketplace).hasRole(role, seller), false); + + vm.prank(seller); + vm.expectRevert("!LISTER_ROLE"); + DirectListingsLogic(marketplace).createListing(listingParams); + } + + modifier whenCallerHasListerRole() { + vm.prank(marketplaceDeployer); + Permissions(marketplace).grantRole(keccak256("LISTER_ROLE"), seller); + _; + } + + function test_createListing_whenAssetDoesNotHaveAssetRole() public whenCallerHasListerRole { + bytes32 role = keccak256("ASSET_ROLE"); + assertEq(Permissions(marketplace).hasRole(role, listingParams.assetContract), false); + + vm.prank(seller); + vm.expectRevert("!ASSET_ROLE"); + DirectListingsLogic(marketplace).createListing(listingParams); + } + + modifier whenAssetHasAssetRole() { + vm.prank(marketplaceDeployer); + Permissions(marketplace).grantRole(keccak256("ASSET_ROLE"), listingParams.assetContract); + _; + } + + function test_createListing_startTimeGteEndTime() public whenCallerHasListerRole whenAssetHasAssetRole { + listingParams.startTimestamp = 200; + listingParams.endTimestamp = 100; + + vm.prank(seller); + vm.expectRevert("Marketplace: endTimestamp not greater than startTimestamp."); + DirectListingsLogic(marketplace).createListing(listingParams); + + listingParams.endTimestamp = 200; + + vm.prank(seller); + vm.expectRevert("Marketplace: endTimestamp not greater than startTimestamp."); + DirectListingsLogic(marketplace).createListing(listingParams); + } + + modifier whenStartTimeLtEndTime() { + listingParams.startTimestamp = 100; + listingParams.endTimestamp = 200; + _; + } + + modifier whenStartTimeLtBlockTimestamp() { + // This warp has no effect on subsequent tests since they include a vm.warp in their own test body. + vm.warp(listingParams.startTimestamp + 1); + _; + } + + function test_createListing_whenStartTimeMoreThanHourBeforeBlockTimestamp() + public + whenCallerHasListerRole + whenAssetHasAssetRole + whenStartTimeLtEndTime + whenStartTimeLtBlockTimestamp + { + vm.warp(listingParams.startTimestamp + (60 minutes + 1)); + + vm.prank(seller); + vm.expectRevert("Marketplace: invalid startTimestamp."); + DirectListingsLogic(marketplace).createListing(listingParams); + } + + modifier whenStartTimeWithinHourOfBlockTimestamp() { + vm.warp(listingParams.startTimestamp + 59 minutes); + _; + } + + function test_createListing_whenListingParamsAreInvalid_1() + public + whenCallerHasListerRole + whenAssetHasAssetRole + whenStartTimeLtEndTime + whenStartTimeLtBlockTimestamp + whenStartTimeWithinHourOfBlockTimestamp + { + // This is one of the ways in which params are considered invalid. + // We've written separate BTT tests for `_validateNewListing` + listingParams.quantity = 0; + + vm.prank(seller); + vm.expectRevert("Marketplace: listing zero quantity."); + DirectListingsLogic(marketplace).createListing(listingParams); + } + + modifier whenListingParamsAreValid() { + // Approve marketplace to transfer tokens -- else listing params are considered invalid. + vm.prank(seller); + erc721.setApprovalForAll(marketplace, true); + _; + } + + function test_createListing_whenListingParamsAreValid_1() + public + whenCallerHasListerRole + whenAssetHasAssetRole + whenStartTimeLtEndTime + whenStartTimeLtBlockTimestamp + whenStartTimeWithinHourOfBlockTimestamp + whenListingParamsAreValid + { + uint256 expectedListingId = 0; + + assertEq(DirectListingsLogic(marketplace).totalListings(), 0); + assertEq(DirectListingsLogic(marketplace).getListing(expectedListingId).assetContract, address(0)); + + IDirectListings.Listing memory listing; + + vm.prank(seller); + vm.expectEmit(true, true, true, false); + emit NewListing(seller, expectedListingId, listingParams.assetContract, listing); + DirectListingsLogic(marketplace).createListing(listingParams); + + listing = DirectListingsLogic(marketplace).getListing(expectedListingId); + assertEq(listing.assetContract, listingParams.assetContract); + assertEq(listing.tokenId, listingParams.tokenId); + assertEq(listing.quantity, listingParams.quantity); + assertEq(listing.currency, listingParams.currency); + assertEq(listing.pricePerToken, listingParams.pricePerToken); + assertEq(listing.endTimestamp, block.timestamp + (listingParams.endTimestamp - listingParams.startTimestamp)); + assertEq(listing.startTimestamp, block.timestamp); + assertEq(listing.listingCreator, seller); + assertEq(listing.reserved, true); + assertEq(uint256(listing.status), 1); // Status.CREATED + assertEq(uint256(listing.tokenType), 0); // TokenType.ERC721 + + assertEq(DirectListingsLogic(marketplace).totalListings(), 1); + assertEq(DirectListingsLogic(marketplace).getAllListings(0, 0).length, 1); + assertEq(DirectListingsLogic(marketplace).getAllValidListings(0, 0).length, 1); + } + + modifier whenStartTimeGteBlockTimestamp() { + vm.warp(listingParams.startTimestamp - 1 minutes); + _; + } + + function test_createListing_whenListingParamsAreInvalid_2() + public + whenCallerHasListerRole + whenAssetHasAssetRole + whenStartTimeLtEndTime + whenStartTimeGteBlockTimestamp + { + // This is one of the ways in which params are considered invalid. + // We've written separate BTT tests for `_validateNewListing` + listingParams.quantity = 0; + + vm.prank(seller); + vm.expectRevert("Marketplace: listing zero quantity."); + DirectListingsLogic(marketplace).createListing(listingParams); + } + + function test_createListing_whenListingParamsAreValid_2() + public + whenCallerHasListerRole + whenAssetHasAssetRole + whenStartTimeLtEndTime + whenStartTimeGteBlockTimestamp + whenListingParamsAreValid + { + uint256 expectedListingId = 0; + + assertEq(DirectListingsLogic(marketplace).totalListings(), 0); + assertEq(DirectListingsLogic(marketplace).getListing(expectedListingId).assetContract, address(0)); + + IDirectListings.Listing memory listing; + + vm.prank(seller); + vm.expectEmit(true, true, true, false); + emit NewListing(seller, expectedListingId, listingParams.assetContract, listing); + DirectListingsLogic(marketplace).createListing(listingParams); + + listing = DirectListingsLogic(marketplace).getListing(expectedListingId); + assertEq(listing.assetContract, listingParams.assetContract); + assertEq(listing.tokenId, listingParams.tokenId); + assertEq(listing.quantity, listingParams.quantity); + assertEq(listing.currency, listingParams.currency); + assertEq(listing.pricePerToken, listingParams.pricePerToken); + assertEq(listing.endTimestamp, listingParams.endTimestamp); + assertEq(listing.startTimestamp, listingParams.startTimestamp); + assertEq(listing.listingCreator, seller); + assertEq(listing.reserved, true); + assertEq(uint256(listing.status), 1); // Status.CREATED + assertEq(uint256(listing.tokenType), 0); // TokenType.ERC721 + + assertEq(DirectListingsLogic(marketplace).totalListings(), 1); + assertEq(DirectListingsLogic(marketplace).getAllListings(0, 0).length, 1); + assertEq(DirectListingsLogic(marketplace).getAllValidListings(0, 0).length, 0); + } +} diff --git a/src/test/marketplace/direct-listings/createListing/createListing.tree b/src/test/marketplace/direct-listings/createListing/createListing.tree new file mode 100644 index 000000000..8964c7798 --- /dev/null +++ b/src/test/marketplace/direct-listings/createListing/createListing.tree @@ -0,0 +1,27 @@ +function createListing(ListingParameters calldata _params) +├── when caller does not have LISTER_ROLE +│ └── it should revert +└── when the caller has lister LISTER_ROLE + ├── when the asset to list does not have ASSET_ROLE + │ └── it should revert + └── when the asset to list has ASSET_ROLE + ├── when the start time is greater i.e. after the end time + │ └── it should revert + └── when the start time is less than i.e. before the end time + ├── when the start time is less than i.e. before block timestamp + │ ├── when the start time is more than 60 minutes before block timestamp + │ │ └── it should revert + │ └── when the start time is less than or equal to 60 minutes before block timestamp + │ ├── when the listing params are invalid + │ │ └── it should revert + │ └── when the listing params are valid + │ ├── it should store the listing at a new listing ID + │ ├── it should return the listing ID + │ └── it should emit NewListing event with listing creator, listing ID, and listing data + └── when the start time is greater than i.e. after, or equal to block timestamp + ├── when the listing params are invalid + │ └── it should revert + └── when the listing params are valid + ├── it should store the listing at a new listing ID + ├── it should return the listing ID + └── it should emit NewListing event with listing creator, listing ID, and listing data \ No newline at end of file diff --git a/src/test/marketplace/direct-listings/updateListing/updateListing.t.sol b/src/test/marketplace/direct-listings/updateListing/updateListing.t.sol new file mode 100644 index 000000000..6b35615ea --- /dev/null +++ b/src/test/marketplace/direct-listings/updateListing/updateListing.t.sol @@ -0,0 +1,502 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "../../../utils/BaseTest.sol"; +import "@thirdweb-dev/dynamic-contracts/src/interface/IExtension.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; +import { MarketplaceV3 } from "contracts/prebuilts/marketplace/entrypoint/MarketplaceV3.sol"; +import { DirectListingsLogic } from "contracts/prebuilts/marketplace/direct-listings/DirectListingsLogic.sol"; +import { IDirectListings } from "contracts/prebuilts/marketplace/IMarketplace.sol"; + +contract UpdateListingTest is BaseTest, IExtension { + // Target contract + address public marketplace; + + // Participants + address public marketplaceDeployer; + address public seller; + + // Default listing parameters + IDirectListings.ListingParameters internal listingParams; + uint256 internal listingId = 0; + + // Events to test + + /// @notice Emitted when a listing is updated. + event UpdatedListing( + address indexed listingCreator, + uint256 indexed listingId, + address indexed assetContract, + IDirectListings.Listing listing + ); + + function setUp() public override { + super.setUp(); + + marketplaceDeployer = getActor(1); + seller = getActor(2); + + // Deploy implementation. + Extension[] memory extensions = _setupExtensions(); + address impl = address( + new MarketplaceV3(MarketplaceV3.MarketplaceConstructorParams(extensions, address(0), address(weth))) + ); + + vm.prank(marketplaceDeployer); + marketplace = address( + new TWProxy( + impl, + abi.encodeCall( + MarketplaceV3.initialize, + (marketplaceDeployer, "", new address[](0), marketplaceDeployer, 0) + ) + ) + ); + + // Setup roles + vm.startPrank(marketplaceDeployer); + Permissions(marketplace).revokeRole(keccak256("ASSET_ROLE"), address(0)); + Permissions(marketplace).revokeRole(keccak256("LISTER_ROLE"), address(0)); + + vm.stopPrank(); + + // Setup listing params + address assetContract = address(erc721); + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = address(erc20); + uint256 pricePerToken = 1 ether; + uint128 startTimestamp = 100 minutes; + uint128 endTimestamp = 200 minutes; + bool reserved = true; + + listingParams = IDirectListings.ListingParameters( + assetContract, + tokenId, + quantity, + currency, + pricePerToken, + startTimestamp, + endTimestamp, + reserved + ); + + // Mint 1 ERC721 NFT to seller + erc721.mint(seller, listingParams.quantity); + + vm.label(impl, "MarketplaceV3_Impl"); + vm.label(marketplace, "Marketplace"); + vm.label(seller, "Seller"); + vm.label(address(erc721), "ERC721_Token"); + vm.label(address(erc1155), "ERC1155_Token"); + } + + function _setupExtensions() internal returns (Extension[] memory extensions) { + extensions = new Extension[](1); + + // Deploy `DirectListings` + address directListings = address(new DirectListingsLogic(address(weth))); + vm.label(directListings, "DirectListings_Extension"); + + // Extension: DirectListingsLogic + Extension memory extension_directListings; + extension_directListings.metadata = ExtensionMetadata({ + name: "DirectListingsLogic", + metadataURI: "ipfs://DirectListings", + implementation: directListings + }); + + extension_directListings.functions = new ExtensionFunction[](13); + extension_directListings.functions[0] = ExtensionFunction( + DirectListingsLogic.totalListings.selector, + "totalListings()" + ); + extension_directListings.functions[1] = ExtensionFunction( + DirectListingsLogic.isBuyerApprovedForListing.selector, + "isBuyerApprovedForListing(uint256,address)" + ); + extension_directListings.functions[2] = ExtensionFunction( + DirectListingsLogic.isCurrencyApprovedForListing.selector, + "isCurrencyApprovedForListing(uint256,address)" + ); + extension_directListings.functions[3] = ExtensionFunction( + DirectListingsLogic.currencyPriceForListing.selector, + "currencyPriceForListing(uint256,address)" + ); + extension_directListings.functions[4] = ExtensionFunction( + DirectListingsLogic.createListing.selector, + "createListing((address,uint256,uint256,address,uint256,uint128,uint128,bool))" + ); + extension_directListings.functions[5] = ExtensionFunction( + DirectListingsLogic.updateListing.selector, + "updateListing(uint256,(address,uint256,uint256,address,uint256,uint128,uint128,bool))" + ); + extension_directListings.functions[6] = ExtensionFunction( + DirectListingsLogic.cancelListing.selector, + "cancelListing(uint256)" + ); + extension_directListings.functions[7] = ExtensionFunction( + DirectListingsLogic.approveBuyerForListing.selector, + "approveBuyerForListing(uint256,address,bool)" + ); + extension_directListings.functions[8] = ExtensionFunction( + DirectListingsLogic.approveCurrencyForListing.selector, + "approveCurrencyForListing(uint256,address,uint256)" + ); + extension_directListings.functions[9] = ExtensionFunction( + DirectListingsLogic.buyFromListing.selector, + "buyFromListing(uint256,address,uint256,address,uint256)" + ); + extension_directListings.functions[10] = ExtensionFunction( + DirectListingsLogic.getAllListings.selector, + "getAllListings(uint256,uint256)" + ); + extension_directListings.functions[11] = ExtensionFunction( + DirectListingsLogic.getAllValidListings.selector, + "getAllValidListings(uint256,uint256)" + ); + extension_directListings.functions[12] = ExtensionFunction( + DirectListingsLogic.getListing.selector, + "getListing(uint256)" + ); + + extensions[0] = extension_directListings; + } + + function test_updateListing_whenListingDoesNotExist() public { + vm.prank(seller); + vm.expectRevert("Marketplace: invalid listing."); + DirectListingsLogic(marketplace).updateListing(listingId, listingParams); + } + + modifier whenListingExists() { + vm.startPrank(marketplaceDeployer); + Permissions(marketplace).grantRole(keccak256("ASSET_ROLE"), address(erc721)); + Permissions(marketplace).grantRole(keccak256("LISTER_ROLE"), seller); + vm.stopPrank(); + + vm.startPrank(seller); + erc721.setApprovalForAll(marketplace, true); + listingId = DirectListingsLogic(marketplace).createListing(listingParams); + erc721.setApprovalForAll(marketplace, false); + vm.stopPrank(); + + vm.prank(marketplaceDeployer); + Permissions(marketplace).revokeRole(keccak256("ASSET_ROLE"), address(erc721)); + _; + } + + function test_updateListing_whenAssetDoesntHaveAssetRole() public whenListingExists { + vm.prank(seller); + vm.expectRevert("!ASSET_ROLE"); + DirectListingsLogic(marketplace).updateListing(listingId, listingParams); + } + + modifier whenAssetHasAssetRole() { + vm.prank(marketplaceDeployer); + Permissions(marketplace).grantRole(keccak256("ASSET_ROLE"), address(erc721)); + _; + } + + function test_updateListing_whenCallerIsNotListingCreator() public whenListingExists whenAssetHasAssetRole { + vm.prank(address(0x4567)); + vm.expectRevert("Marketplace: not listing creator."); + DirectListingsLogic(marketplace).updateListing(listingId, listingParams); + } + + modifier whenCallerIsListingCreator() { + _; + } + + function test_updateListing_whenListingHasExpired() + public + whenListingExists + whenAssetHasAssetRole + whenCallerIsListingCreator + { + vm.warp(listingParams.endTimestamp + 1); + + vm.prank(seller); + vm.expectRevert("Marketplace: listing expired."); + DirectListingsLogic(marketplace).updateListing(listingId, listingParams); + } + + modifier whenListingNotExpired() { + vm.warp(0); + _; + } + + function test_updateListing_whenUpdatedAssetIsDifferent() + public + whenListingExists + whenAssetHasAssetRole + whenCallerIsListingCreator + whenListingNotExpired + { + vm.prank(marketplaceDeployer); + Permissions(marketplace).grantRole(keccak256("ASSET_ROLE"), address(erc1155)); + listingParams.assetContract = address(erc1155); + + vm.prank(seller); + vm.expectRevert("Marketplace: cannot update what token is listed."); + DirectListingsLogic(marketplace).updateListing(listingId, listingParams); + + listingParams.assetContract = address(erc721); + listingParams.tokenId = 10; + + vm.prank(seller); + vm.expectRevert("Marketplace: cannot update what token is listed."); + DirectListingsLogic(marketplace).updateListing(listingId, listingParams); + } + + modifier whenUpdatedAssetIsSame() { + _; + } + + function test_updateListing_whenUpdatedStartTimeGteEndTime() + public + whenListingExists + whenAssetHasAssetRole + whenCallerIsListingCreator + whenListingNotExpired + whenUpdatedAssetIsSame + { + listingParams.startTimestamp = 200; + listingParams.endTimestamp = 100; + + vm.prank(seller); + vm.expectRevert("Marketplace: endTimestamp not greater than startTimestamp."); + DirectListingsLogic(marketplace).updateListing(listingId, listingParams); + } + + modifier whenUpdatedStartTimeLtUpdatedEndTime() { + _; + } + + function test_updateListing_whenUpdateMakesActiveListingInactive() + public + whenListingExists + whenAssetHasAssetRole + whenCallerIsListingCreator + whenListingNotExpired + whenUpdatedAssetIsSame + whenUpdatedStartTimeLtUpdatedEndTime + { + vm.warp(listingParams.startTimestamp + 1); + + listingParams.startTimestamp += 50; + + vm.prank(seller); + vm.expectRevert("Marketplace: listing already active."); + DirectListingsLogic(marketplace).updateListing(listingId, listingParams); + } + + modifier whenUpdateDoesntMakeActiveListingInactive() { + _; + } + + modifier whenUpdatedStartIsDiffAndInPast() { + vm.warp(listingParams.startTimestamp - 1 minutes); + listingParams.startTimestamp -= 2 minutes; + _; + } + + function test_updateListing_whenUpdatedStartIsMoreThanHourInPast() + public + whenListingExists + whenAssetHasAssetRole + whenCallerIsListingCreator + whenListingNotExpired + whenUpdatedAssetIsSame + whenUpdatedStartTimeLtUpdatedEndTime + whenUpdateDoesntMakeActiveListingInactive + whenUpdatedStartIsDiffAndInPast + { + listingParams.startTimestamp = 30 minutes; + + vm.prank(seller); + vm.expectRevert("Marketplace: invalid startTimestamp."); + DirectListingsLogic(marketplace).updateListing(listingId, listingParams); + } + + modifier whenUpdatedStartIsWithinPastHour() { + listingParams.startTimestamp = 90 minutes; + _; + } + + function test_updateListing_whenUpdatedPriceIsDifferentFromApprovedPrice_1() + public + whenListingExists + whenAssetHasAssetRole + whenCallerIsListingCreator + whenListingNotExpired + whenUpdatedAssetIsSame + whenUpdatedStartTimeLtUpdatedEndTime + whenUpdateDoesntMakeActiveListingInactive + whenUpdatedStartIsDiffAndInPast + whenUpdatedStartIsWithinPastHour + { + vm.prank(seller); + DirectListingsLogic(marketplace).approveCurrencyForListing(listingId, address(weth), 2 ether); + + listingParams.currency = address(weth); + + vm.prank(seller); + vm.expectRevert("Marketplace: price different from approved price"); + DirectListingsLogic(marketplace).updateListing(listingId, listingParams); + } + + modifier whenUpdatedPriceIsSameAsApprovedPrice() { + _; + } + + function test_updateListing_whenListingParamsAreInvalid_1() + public + whenListingExists + whenAssetHasAssetRole + whenCallerIsListingCreator + whenListingNotExpired + whenUpdatedAssetIsSame + whenUpdatedStartTimeLtUpdatedEndTime + whenUpdateDoesntMakeActiveListingInactive + whenUpdatedStartIsDiffAndInPast + whenUpdatedStartIsWithinPastHour + whenUpdatedPriceIsSameAsApprovedPrice + { + // This is one of the ways in which params can be invalid. + // Separate tests for `_validateNewListingParams` + listingParams.quantity = 0; + + vm.prank(seller); + vm.expectRevert("Marketplace: listing zero quantity."); + DirectListingsLogic(marketplace).updateListing(listingId, listingParams); + } + + modifier whenListingParamsAreValid() { + _; + } + + function test_updateListing_whenListingParamsAreValid_1() + public + whenListingExists + whenAssetHasAssetRole + whenCallerIsListingCreator + whenListingNotExpired + whenUpdatedAssetIsSame + whenUpdatedStartTimeLtUpdatedEndTime + whenUpdateDoesntMakeActiveListingInactive + whenUpdatedStartIsDiffAndInPast + whenUpdatedStartIsWithinPastHour + whenUpdatedPriceIsSameAsApprovedPrice + whenListingParamsAreValid + { + vm.prank(seller); + erc721.setApprovalForAll(marketplace, true); + + IDirectListings.Listing memory listing; + + vm.prank(seller); + vm.expectEmit(true, true, true, false); + emit UpdatedListing(seller, listingId, listingParams.assetContract, listing); + DirectListingsLogic(marketplace).updateListing(listingId, listingParams); + + IDirectListings.Listing memory updatedListing = DirectListingsLogic(marketplace).getListing(listingId); + + assertEq(updatedListing.assetContract, listingParams.assetContract); + assertEq(updatedListing.tokenId, listingParams.tokenId); + assertEq(updatedListing.quantity, listingParams.quantity); + assertEq(updatedListing.currency, listingParams.currency); + assertEq(updatedListing.pricePerToken, listingParams.pricePerToken); + assertEq(updatedListing.endTimestamp, listingParams.endTimestamp); + assertEq(updatedListing.startTimestamp, block.timestamp); + assertEq(updatedListing.listingCreator, seller); + assertEq(updatedListing.reserved, true); + assertEq(uint256(updatedListing.status), 1); // Status.CREATED + assertEq(uint256(updatedListing.tokenType), 0); // TokenType.ERC721 + } + + modifier whenUpdatedStartIsSameAsCurrentStart() { + _; + } + + function test_updateListing_whenUpdatedPriceIsDifferentFromApprovedPrice_2() + public + whenListingExists + whenAssetHasAssetRole + whenCallerIsListingCreator + whenListingNotExpired + whenUpdatedAssetIsSame + whenUpdatedStartTimeLtUpdatedEndTime + whenUpdateDoesntMakeActiveListingInactive + whenUpdatedStartIsSameAsCurrentStart + { + vm.prank(seller); + DirectListingsLogic(marketplace).approveCurrencyForListing(listingId, address(weth), 2 ether); + + listingParams.currency = address(weth); + + vm.prank(seller); + vm.expectRevert("Marketplace: price different from approved price"); + DirectListingsLogic(marketplace).updateListing(listingId, listingParams); + } + + function test_updateListing_whenListingParamsAreInvalid_2() + public + whenListingExists + whenAssetHasAssetRole + whenCallerIsListingCreator + whenListingNotExpired + whenUpdatedAssetIsSame + whenUpdatedStartTimeLtUpdatedEndTime + whenUpdateDoesntMakeActiveListingInactive + whenUpdatedStartIsSameAsCurrentStart + whenUpdatedPriceIsSameAsApprovedPrice + { + // This is one of the ways in which params can be invalid. + // Separate tests for `_validateNewListingParams` + listingParams.quantity = 0; + + vm.prank(seller); + vm.expectRevert("Marketplace: listing zero quantity."); + DirectListingsLogic(marketplace).updateListing(listingId, listingParams); + } + + function test_updateListing_whenListingParamsAreValid_2() + public + whenListingExists + whenAssetHasAssetRole + whenCallerIsListingCreator + whenListingNotExpired + whenUpdatedAssetIsSame + whenUpdatedStartTimeLtUpdatedEndTime + whenUpdateDoesntMakeActiveListingInactive + whenUpdatedStartIsSameAsCurrentStart + whenUpdatedPriceIsSameAsApprovedPrice + whenListingParamsAreValid + { + vm.prank(seller); + erc721.setApprovalForAll(marketplace, true); + + IDirectListings.Listing memory listing; + + vm.prank(seller); + vm.expectEmit(true, true, true, false); + emit UpdatedListing(seller, listingId, listingParams.assetContract, listing); + DirectListingsLogic(marketplace).updateListing(listingId, listingParams); + + IDirectListings.Listing memory updatedListing = DirectListingsLogic(marketplace).getListing(listingId); + + assertEq(updatedListing.assetContract, listingParams.assetContract); + assertEq(updatedListing.tokenId, listingParams.tokenId); + assertEq(updatedListing.quantity, listingParams.quantity); + assertEq(updatedListing.currency, listingParams.currency); + assertEq(updatedListing.pricePerToken, listingParams.pricePerToken); + assertEq(updatedListing.endTimestamp, listingParams.endTimestamp); + assertEq(updatedListing.startTimestamp, listingParams.startTimestamp); + assertEq(updatedListing.listingCreator, seller); + assertEq(updatedListing.reserved, true); + assertEq(uint256(updatedListing.status), 1); // Status.CREATED + assertEq(uint256(updatedListing.tokenType), 0); // TokenType.ERC721 + } +} diff --git a/src/test/marketplace/direct-listings/updateListing/updateListing.tree b/src/test/marketplace/direct-listings/updateListing/updateListing.tree new file mode 100644 index 000000000..982ed1076 --- /dev/null +++ b/src/test/marketplace/direct-listings/updateListing/updateListing.tree @@ -0,0 +1,43 @@ +function updateListing(uint256 _listingId, ListingParameters memory _params) +├── when the listing does not exist +│ └── it should revert ✅ +└── when listing exists + ├── when asset does not have ASSET_ROLE + │ └── it should revert ✅ + └── when asset has ASSET_ROLE + ├── when caller is not listing creator + │ └── it should revert ✅ + └── when caller is listing creator + ├── when listing has expired + │ └── it should revert ✅ + └── when listing has not expired + ├── when the updated asset is different from the listed asset + │ └── it should revert ✅ + └── when the updated asset is the same as the listed asset + ├── when the updated start time is greater or equal to than the updated end time + │ └── it should revert ✅ + └── when the updated start time is less than the updated end time + ├── when update makes active listing inactive + │ └── it should revert ✅ + └── when update does not make active listing inactive + ├── when the updated start time is in the past and different from the listed start time + │ ├── when the updated start time is more than 60 minutes before block timestamp + │ │ └── it should revert ✅ + │ └── when the updated start time is within 60 minutes past block timestamp + │ ├── when updated price in updated currency different from approved price for updated currency + │ │ └── it should revert ✅ + │ └── when updated price in updated currency is same as approved price for updated currency + │ ├── when updated listing params are invalid + │ │ └── it should revert ✅ + │ └── when updated listing params are valid ✅ + │ ├── it should store updated listing at the same listing ID + │ └── it should emit UpdatedListing event with listing creator, listing ID, updated asset contract and listing data + └── when the updated start time is same as listed start time + ├── when updated price in updated currency different from approved price for updated currency + │ └── it should revert ✅ + └── when updated price in updated currency is same as approved price for updated currency + ├── when updated listing params are invalid + │ └── it should revert ✅ + └── when updated listing params are valid ✅ + ├── it should store updated listing at the same listing ID + └── it should emit UpdatedListing event with listing creator, listing ID, updated asset contract and listing data \ No newline at end of file diff --git a/src/test/marketplace/english-auctions/_payout/_payout.t.sol b/src/test/marketplace/english-auctions/_payout/_payout.t.sol new file mode 100644 index 000000000..a0ebbfda9 --- /dev/null +++ b/src/test/marketplace/english-auctions/_payout/_payout.t.sol @@ -0,0 +1,297 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +// Test helper imports +import "../../../utils/BaseTest.sol"; + +// Test contracts and interfaces +import { RoyaltyPaymentsLogic } from "contracts/extension/plugin/RoyaltyPayments.sol"; +import { MarketplaceV3, IPlatformFee } from "contracts/prebuilts/marketplace/entrypoint/MarketplaceV3.sol"; +import { EnglishAuctionsLogic } from "contracts/prebuilts/marketplace/english-auctions/EnglishAuctionsLogic.sol"; +import { TWProxy } from "contracts/infra/TWProxy.sol"; +import { ERC721Base } from "contracts/base/ERC721Base.sol"; +import { MockRoyaltyEngineV1 } from "../../../mocks/MockRoyaltyEngineV1.sol"; +import { PlatformFee } from "contracts/extension/PlatformFee.sol"; + +import { IEnglishAuctions } from "contracts/prebuilts/marketplace/IMarketplace.sol"; + +import "@thirdweb-dev/dynamic-contracts/src/interface/IExtension.sol"; + +contract ReentrantRecipient is ERC1155Holder { + function onERC1155Received( + address operator, + address from, + uint256 id, + uint256 value, + bytes memory data + ) public virtual override returns (bytes4) { + uint256 auctionId = 0; + uint256 bidAmount = 10 ether; + EnglishAuctionsLogic(msg.sender).bidInAuction(auctionId, bidAmount); + return super.onERC1155Received(operator, from, id, value, data); + } +} + +contract EnglishAuctionsPayoutTest is BaseTest, IExtension { + // Target contract + address public marketplace; + + // Participants + address public marketplaceDeployer; + address public seller; + address public buyer; + + // Auction parameters + uint256 internal auctionId; + uint256 internal bidAmount; + address internal winningBidder = address(0x123); + IEnglishAuctions.AuctionParameters internal auctionParams; + + // Events + event NewBid( + uint256 indexed auctionId, + address indexed bidder, + address indexed assetContract, + uint256 bidAmount, + IEnglishAuctions.Auction auction + ); + + function setUp() public override { + super.setUp(); + + marketplaceDeployer = getActor(1); + seller = getActor(2); + buyer = getActor(3); + + // Deploy implementation. + Extension[] memory extensions = _setupExtensions(); + address impl = address( + new MarketplaceV3(MarketplaceV3.MarketplaceConstructorParams(extensions, address(0), address(weth))) + ); + + vm.prank(marketplaceDeployer); + marketplace = address( + new TWProxy( + impl, + abi.encodeCall( + MarketplaceV3.initialize, + (marketplaceDeployer, "", new address[](0), platformFeeRecipient, uint16(platformFeeBps)) + ) + ) + ); + + // Setup roles for seller and assets + vm.startPrank(marketplaceDeployer); + Permissions(marketplace).revokeRole(keccak256("ASSET_ROLE"), address(0)); + Permissions(marketplace).revokeRole(keccak256("LISTER_ROLE"), address(0)); + Permissions(marketplace).grantRole(keccak256("ASSET_ROLE"), address(erc1155)); + Permissions(marketplace).grantRole(keccak256("LISTER_ROLE"), seller); + vm.stopPrank(); + + vm.label(impl, "MarketplaceV3_Impl"); + vm.label(marketplace, "Marketplace"); + vm.label(seller, "Seller"); + vm.label(buyer, "Buyer"); + vm.label(address(erc721), "ERC721_Token"); + vm.label(address(erc1155), "ERC1155_Token"); + + // Sample auction parameters. + address assetContract = address(erc1155); + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = address(erc20); + uint256 minimumBidAmount = 1 ether; + uint256 buyoutBidAmount = 100 ether; + uint64 timeBufferInSeconds = 10 seconds; + uint64 bidBufferBps = 1000; + uint64 startTimestamp = 100 minutes; + uint64 endTimestamp = 200 minutes; + + // Auction tokens. + auctionParams = IEnglishAuctions.AuctionParameters( + assetContract, + tokenId, + quantity, + currency, + minimumBidAmount, + buyoutBidAmount, + timeBufferInSeconds, + bidBufferBps, + startTimestamp, + endTimestamp + ); + + // Set bidAmount + bidAmount = auctionParams.minimumBidAmount; + + // Mint NFT to seller. + erc721.mint(seller, 1); // to, amount + erc1155.mint(seller, 0, 100); // to, id, amount + + // Create auction + vm.startPrank(seller); + erc721.setApprovalForAll(marketplace, true); + erc1155.setApprovalForAll(marketplace, true); + auctionId = EnglishAuctionsLogic(marketplace).createAuction(auctionParams); + vm.stopPrank(); + + // Mint currency to bidder. + erc20.mint(buyer, 10_000 ether); + + vm.prank(buyer); + erc20.approve(marketplace, 100 ether); + } + + function _setupExtensions() internal returns (Extension[] memory extensions) { + extensions = new Extension[](1); + + // Deploy `EnglishAuctions` + address englishAuctions = address(new EnglishAuctionsLogic(address(weth))); + vm.label(englishAuctions, "EnglishAuctions_Extension"); + + // Extension: EnglishAuctionsLogic + Extension memory extension_englishAuctions; + extension_englishAuctions.metadata = ExtensionMetadata({ + name: "EnglishAuctionsLogic", + metadataURI: "ipfs://EnglishAuctions", + implementation: englishAuctions + }); + + extension_englishAuctions.functions = new ExtensionFunction[](12); + extension_englishAuctions.functions[0] = ExtensionFunction( + EnglishAuctionsLogic.totalAuctions.selector, + "totalAuctions()" + ); + extension_englishAuctions.functions[1] = ExtensionFunction( + EnglishAuctionsLogic.createAuction.selector, + "createAuction((address,uint256,uint256,address,uint256,uint256,uint64,uint64,uint64,uint64))" + ); + extension_englishAuctions.functions[2] = ExtensionFunction( + EnglishAuctionsLogic.cancelAuction.selector, + "cancelAuction(uint256)" + ); + extension_englishAuctions.functions[3] = ExtensionFunction( + EnglishAuctionsLogic.collectAuctionPayout.selector, + "collectAuctionPayout(uint256)" + ); + extension_englishAuctions.functions[4] = ExtensionFunction( + EnglishAuctionsLogic.collectAuctionTokens.selector, + "collectAuctionTokens(uint256)" + ); + extension_englishAuctions.functions[5] = ExtensionFunction( + EnglishAuctionsLogic.bidInAuction.selector, + "bidInAuction(uint256,uint256)" + ); + extension_englishAuctions.functions[6] = ExtensionFunction( + EnglishAuctionsLogic.isNewWinningBid.selector, + "isNewWinningBid(uint256,uint256)" + ); + extension_englishAuctions.functions[7] = ExtensionFunction( + EnglishAuctionsLogic.getAuction.selector, + "getAuction(uint256)" + ); + extension_englishAuctions.functions[8] = ExtensionFunction( + EnglishAuctionsLogic.getAllAuctions.selector, + "getAllAuctions(uint256,uint256)" + ); + extension_englishAuctions.functions[9] = ExtensionFunction( + EnglishAuctionsLogic.getAllValidAuctions.selector, + "getAllValidAuctions(uint256,uint256)" + ); + extension_englishAuctions.functions[10] = ExtensionFunction( + EnglishAuctionsLogic.getWinningBid.selector, + "getWinningBid(uint256)" + ); + extension_englishAuctions.functions[11] = ExtensionFunction( + EnglishAuctionsLogic.isAuctionExpired.selector, + "isAuctionExpired(uint256)" + ); + + extensions[0] = extension_englishAuctions; + } + + address payable[] internal mockRecipients; + uint256[] internal mockAmounts; + MockRoyaltyEngineV1 internal royaltyEngine; + + function _setupRoyaltyEngine() private { + mockRecipients.push(payable(address(0x12345))); + mockRecipients.push(payable(address(0x56789))); + + mockAmounts.push(10 ether); + mockAmounts.push(15 ether); + + royaltyEngine = new MockRoyaltyEngineV1(mockRecipients, mockAmounts); + } + + function test_payout_whenZeroRoyaltyRecipients() public { + vm.warp(auctionParams.startTimestamp); + vm.prank(buyer); + EnglishAuctionsLogic(marketplace).bidInAuction(auctionId, auctionParams.buyoutBidAmount); + + vm.prank(seller); + EnglishAuctionsLogic(marketplace).collectAuctionPayout(auctionId); + + uint256 totalPrice = auctionParams.buyoutBidAmount; + + uint256 platformFees = (totalPrice * platformFeeBps) / 10_000; + + { + // Platform fee recipient receives correct amount + assertBalERC20Eq(address(erc20), platformFeeRecipient, platformFees); + + // Seller gets total price minus royalty amounts + assertBalERC20Eq(address(erc20), seller, totalPrice - platformFees); + } + } + + modifier whenNonZeroRoyaltyRecipients() { + _setupRoyaltyEngine(); + + // Add RoyaltyEngine to marketplace + vm.prank(marketplaceDeployer); + RoyaltyPaymentsLogic(marketplace).setRoyaltyEngine(address(royaltyEngine)); + + _; + } + + function test_payout_whenInsufficientFundsToPayRoyaltyAfterPlatformFeePayout() public whenNonZeroRoyaltyRecipients { + vm.prank(marketplaceDeployer); + PlatformFee(marketplace).setPlatformFeeInfo(platformFeeRecipient, 9999); // 99.99% fees; + + // Buy tokens from listing. + vm.warp(auctionParams.startTimestamp); + vm.prank(buyer); + EnglishAuctionsLogic(marketplace).bidInAuction(auctionId, auctionParams.buyoutBidAmount); + + vm.prank(seller); + vm.expectRevert("fees exceed the price"); + EnglishAuctionsLogic(marketplace).collectAuctionPayout(auctionId); + } + + function test_payout_whenSufficientFundsToPayRoyaltyAfterPlatformFeePayout() public whenNonZeroRoyaltyRecipients { + assertEq(RoyaltyPaymentsLogic(marketplace).getRoyaltyEngineAddress(), address(royaltyEngine)); + + vm.warp(auctionParams.startTimestamp); + vm.prank(buyer); + EnglishAuctionsLogic(marketplace).bidInAuction(auctionId, auctionParams.buyoutBidAmount); + + vm.prank(seller); + EnglishAuctionsLogic(marketplace).collectAuctionPayout(auctionId); + + uint256 totalPrice = auctionParams.buyoutBidAmount; + uint256 platformFees = (totalPrice * platformFeeBps) / 10_000; + + { + // Royalty recipients receive correct amounts + assertBalERC20Eq(address(erc20), mockRecipients[0], mockAmounts[0]); + assertBalERC20Eq(address(erc20), mockRecipients[1], mockAmounts[1]); + + // Platform fee recipient receives correct amount + assertBalERC20Eq(address(erc20), platformFeeRecipient, platformFees); + + // Seller gets total price minus royalty amounts + assertBalERC20Eq(address(erc20), seller, totalPrice - mockAmounts[0] - mockAmounts[1] - platformFees); + } + } +} diff --git a/src/test/marketplace/english-auctions/_payout/_payout.tree b/src/test/marketplace/english-auctions/_payout/_payout.tree new file mode 100644 index 000000000..36b930e11 --- /dev/null +++ b/src/test/marketplace/english-auctions/_payout/_payout.tree @@ -0,0 +1,17 @@ +function _payout( + address _payer, + address _payee, + address _currencyToUse, + uint256 _totalPayoutAmount, + Auction memory _targetAuction +) +├── when there are zero royalty recipients ✅ +│ ├── it should transfer platform fee from payer to platform fee recipient +│ └── it should transfer remainder of currency from payer to payee +└── when there are non-zero royalty recipients + ├── when the total royalty payout exceeds remainder payout after having paid platform fee + │ └── it should revert ✅ + └── when the total royalty payout does not exceed remainder payout after having paid platform fee ✅ + ├── it should transfer platform fee from payer to platform fee recipient + ├── it should transfer royalty fee from payer to royalty recipients + └── it should transfer remainder of currency from payer to payeew \ No newline at end of file diff --git a/src/test/marketplace/english-auctions/_transferAuctionTokens/_transferAuctionTokens.t.sol b/src/test/marketplace/english-auctions/_transferAuctionTokens/_transferAuctionTokens.t.sol new file mode 100644 index 000000000..5c36a091d --- /dev/null +++ b/src/test/marketplace/english-auctions/_transferAuctionTokens/_transferAuctionTokens.t.sol @@ -0,0 +1,198 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +// Test helper imports +import "../../../utils/BaseTest.sol"; + +// Test contracts and interfaces +import { RoyaltyPaymentsLogic } from "contracts/extension/plugin/RoyaltyPayments.sol"; +import { MarketplaceV3, IPlatformFee } from "contracts/prebuilts/marketplace/entrypoint/MarketplaceV3.sol"; +import { EnglishAuctionsLogic } from "contracts/prebuilts/marketplace/english-auctions/EnglishAuctionsLogic.sol"; +import { TWProxy } from "contracts/infra/TWProxy.sol"; +import { ERC721Base } from "contracts/base/ERC721Base.sol"; +import { MockRoyaltyEngineV1 } from "../../../mocks/MockRoyaltyEngineV1.sol"; +import { PlatformFee } from "contracts/extension/PlatformFee.sol"; + +import { IEnglishAuctions } from "contracts/prebuilts/marketplace/IMarketplace.sol"; + +import "@thirdweb-dev/dynamic-contracts/src/interface/IExtension.sol"; + +contract MockTransferAuctionTokens is EnglishAuctionsLogic { + constructor(address _nativeTokenWrapper) EnglishAuctionsLogic(_nativeTokenWrapper) {} + + function transferAuctionTokens(address _from, address _to, Auction memory _auction) external { + _transferAuctionTokens(_from, _to, _auction); + } +} + +contract TransferAuctionTokensTest is BaseTest, IExtension { + // Target contract + address public marketplace; + + // Participants + address public marketplaceDeployer; + address public seller; + address public buyer; + + // Auction parameters + uint256 internal auctionId_erc1155; + uint256 internal auctionId_erc721; + uint256 internal bidAmount; + address internal winningBidder = address(0x123); + IEnglishAuctions.AuctionParameters internal auctionParams; + + // Events + event NewBid( + uint256 indexed auctionId, + address indexed bidder, + address indexed assetContract, + uint256 bidAmount, + IEnglishAuctions.Auction auction + ); + + function setUp() public override { + super.setUp(); + + marketplaceDeployer = getActor(1); + seller = getActor(2); + buyer = getActor(3); + + // Deploy implementation. + Extension[] memory extensions = _setupExtensions(); + address impl = address( + new MarketplaceV3(MarketplaceV3.MarketplaceConstructorParams(extensions, address(0), address(weth))) + ); + + vm.prank(marketplaceDeployer); + marketplace = address( + new TWProxy( + impl, + abi.encodeCall( + MarketplaceV3.initialize, + (marketplaceDeployer, "", new address[](0), platformFeeRecipient, uint16(platformFeeBps)) + ) + ) + ); + + // Setup roles for seller and assets + vm.startPrank(marketplaceDeployer); + Permissions(marketplace).revokeRole(keccak256("ASSET_ROLE"), address(0)); + Permissions(marketplace).revokeRole(keccak256("LISTER_ROLE"), address(0)); + Permissions(marketplace).grantRole(keccak256("ASSET_ROLE"), address(erc1155)); + Permissions(marketplace).grantRole(keccak256("ASSET_ROLE"), address(erc721)); + Permissions(marketplace).grantRole(keccak256("LISTER_ROLE"), seller); + vm.stopPrank(); + + vm.label(impl, "MarketplaceV3_Impl"); + vm.label(marketplace, "Marketplace"); + vm.label(seller, "Seller"); + vm.label(buyer, "Buyer"); + vm.label(address(erc721), "ERC721_Token"); + vm.label(address(erc1155), "ERC1155_Token"); + + // Sample auction parameters. + address assetContract = address(erc1155); + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = address(erc20); + uint256 minimumBidAmount = 1 ether; + uint256 buyoutBidAmount = 100 ether; + uint64 timeBufferInSeconds = 10 seconds; + uint64 bidBufferBps = 1000; + uint64 startTimestamp = 100 minutes; + uint64 endTimestamp = 200 minutes; + + // Auction tokens. + auctionParams = IEnglishAuctions.AuctionParameters( + assetContract, + tokenId, + quantity, + currency, + minimumBidAmount, + buyoutBidAmount, + timeBufferInSeconds, + bidBufferBps, + startTimestamp, + endTimestamp + ); + + // Set bidAmount + bidAmount = auctionParams.minimumBidAmount; + + // Mint NFT to seller. + erc721.mint(seller, 1); // to, amount + erc1155.mint(seller, 0, 100); // to, id, amount + + // Create auction + vm.startPrank(seller); + erc721.setApprovalForAll(marketplace, true); + erc1155.setApprovalForAll(marketplace, true); + + auctionId_erc1155 = EnglishAuctionsLogic(marketplace).createAuction(auctionParams); + + auctionParams.assetContract = address(erc721); + auctionId_erc721 = EnglishAuctionsLogic(marketplace).createAuction(auctionParams); + + vm.stopPrank(); + + // Mint currency to bidder. + erc20.mint(buyer, 10_000 ether); + + vm.prank(buyer); + erc20.approve(marketplace, 100 ether); + } + + function _setupExtensions() internal returns (Extension[] memory extensions) { + extensions = new Extension[](1); + + // Deploy `EnglishAuctions` + address englishAuctions = address(new MockTransferAuctionTokens(address(weth))); + vm.label(englishAuctions, "EnglishAuctions_Extension"); + + // Extension: EnglishAuctionsLogic + Extension memory extension_englishAuctions; + extension_englishAuctions.metadata = ExtensionMetadata({ + name: "EnglishAuctionsLogic", + metadataURI: "ipfs://EnglishAuctions", + implementation: englishAuctions + }); + + extension_englishAuctions.functions = new ExtensionFunction[](3); + extension_englishAuctions.functions[0] = ExtensionFunction( + MockTransferAuctionTokens.transferAuctionTokens.selector, + "transferAuctionTokens(address,address,(uint256,uint256,uint256,uint256,uint256,uint64,uint64,uint64,uint64,address,address,address,uint8,uint8))" + ); + extension_englishAuctions.functions[1] = ExtensionFunction( + EnglishAuctionsLogic.createAuction.selector, + "createAuction((address,uint256,uint256,address,uint256,uint256,uint64,uint64,uint64,uint64))" + ); + extension_englishAuctions.functions[2] = ExtensionFunction( + EnglishAuctionsLogic.getAuction.selector, + "getAuction(uint256)" + ); + + extensions[0] = extension_englishAuctions; + } + + function test_transferAuctionTokens_erc1155() public { + IEnglishAuctions.Auction memory auction = EnglishAuctionsLogic(marketplace).getAuction(auctionId_erc1155); + + assertEq(erc1155.balanceOf(address(marketplace), auction.tokenId), 1); + assertEq(erc1155.balanceOf(buyer, auction.tokenId), 0); + + MockTransferAuctionTokens(marketplace).transferAuctionTokens(address(marketplace), buyer, auction); + + assertEq(erc1155.balanceOf(address(marketplace), auction.tokenId), 0); + assertEq(erc1155.balanceOf(buyer, auction.tokenId), 1); + } + + function test_transferAuctionTokens_erc721() public { + IEnglishAuctions.Auction memory auction = EnglishAuctionsLogic(marketplace).getAuction(auctionId_erc721); + + assertEq(erc721.ownerOf(auction.tokenId), address(marketplace)); + + MockTransferAuctionTokens(marketplace).transferAuctionTokens(address(marketplace), buyer, auction); + + assertEq(erc721.ownerOf(auction.tokenId), buyer); + } +} diff --git a/src/test/marketplace/english-auctions/_transferAuctionTokens/_transferAuctionTokens.tree b/src/test/marketplace/english-auctions/_transferAuctionTokens/_transferAuctionTokens.tree new file mode 100644 index 000000000..c44dc8c55 --- /dev/null +++ b/src/test/marketplace/english-auctions/_transferAuctionTokens/_transferAuctionTokens.tree @@ -0,0 +1,9 @@ +function _transferAuctionTokens( + address _from, + address _to, + Auction memory _auction +) +├── when the token is ERC1155 +│ └── it should transfer ERC1155 tokens from the specified owner to recipient +└── when the token is ERC721 + └── it should transfer ERC721 tokens from the specified owner to recipient \ No newline at end of file diff --git a/src/test/marketplace/english-auctions/_validateNewAuction/_validateNewAuction.t.sol b/src/test/marketplace/english-auctions/_validateNewAuction/_validateNewAuction.t.sol new file mode 100644 index 000000000..5f716875f --- /dev/null +++ b/src/test/marketplace/english-auctions/_validateNewAuction/_validateNewAuction.t.sol @@ -0,0 +1,304 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +// Test helper imports +import "../../../utils/BaseTest.sol"; + +// Test contracts and interfaces +import { RoyaltyPaymentsLogic } from "contracts/extension/plugin/RoyaltyPayments.sol"; +import { MarketplaceV3, IPlatformFee } from "contracts/prebuilts/marketplace/entrypoint/MarketplaceV3.sol"; +import { EnglishAuctionsLogic } from "contracts/prebuilts/marketplace/english-auctions/EnglishAuctionsLogic.sol"; +import { TWProxy } from "contracts/infra/TWProxy.sol"; +import { ERC721Base } from "contracts/base/ERC721Base.sol"; + +import { IEnglishAuctions } from "contracts/prebuilts/marketplace/IMarketplace.sol"; + +import "@thirdweb-dev/dynamic-contracts/src/interface/IExtension.sol"; + +contract InvalidToken { + function supportsInterface(bytes4) public pure returns (bool) { + return false; + } +} + +contract ValidateNewAuctionTest is BaseTest, IExtension { + // Target contract + address public marketplace; + + // Participants + address public marketplaceDeployer; + address public seller; + address public buyer; + + // Auction parameters + IEnglishAuctions.AuctionParameters internal auctionParams; + + // Events + /// @dev Emitted when a new auction is created. + event NewAuction( + address indexed auctionCreator, + uint256 indexed auctionId, + address indexed assetContract, + IEnglishAuctions.Auction auction + ); + + function setUp() public override { + super.setUp(); + + marketplaceDeployer = getActor(1); + seller = getActor(2); + buyer = getActor(3); + + // Deploy implementation. + Extension[] memory extensions = _setupExtensions(); + address impl = address( + new MarketplaceV3(MarketplaceV3.MarketplaceConstructorParams(extensions, address(0), address(weth))) + ); + + vm.prank(marketplaceDeployer); + marketplace = address( + new TWProxy( + impl, + abi.encodeCall( + MarketplaceV3.initialize, + (marketplaceDeployer, "", new address[](0), marketplaceDeployer, 0) + ) + ) + ); + + // Setup roles for seller and assets + vm.startPrank(marketplaceDeployer); + Permissions(marketplace).revokeRole(keccak256("ASSET_ROLE"), address(0)); + Permissions(marketplace).revokeRole(keccak256("LISTER_ROLE"), address(0)); + Permissions(marketplace).grantRole(keccak256("LISTER_ROLE"), seller); + Permissions(marketplace).grantRole(keccak256("ASSET_ROLE"), address(erc1155)); + Permissions(marketplace).grantRole(keccak256("ASSET_ROLE"), address(erc721)); + + vm.stopPrank(); + + vm.label(impl, "MarketplaceV3_Impl"); + vm.label(marketplace, "Marketplace"); + vm.label(seller, "Seller"); + vm.label(buyer, "Buyer"); + vm.label(address(erc721), "ERC721_Token"); + vm.label(address(erc1155), "ERC1155_Token"); + + // Sample auction parameters. + address assetContract = address(erc721); + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = address(erc20); + uint256 minimumBidAmount = 1 ether; + uint256 buyoutBidAmount = 10 ether; + uint64 timeBufferInSeconds = 10 seconds; + uint64 bidBufferBps = 1000; + uint64 startTimestamp = 100; + uint64 endTimestamp = 200; + + // Auction tokens. + auctionParams = IEnglishAuctions.AuctionParameters( + assetContract, + tokenId, + quantity, + currency, + minimumBidAmount, + buyoutBidAmount, + timeBufferInSeconds, + bidBufferBps, + startTimestamp, + endTimestamp + ); + + // Mint NFT to seller. + erc721.mint(seller, 1); // to, amount + erc1155.mint(seller, 0, 100); // to, id, amount + } + + function _setupExtensions() internal returns (Extension[] memory extensions) { + extensions = new Extension[](1); + + // Deploy `EnglishAuctions` + address englishAuctions = address(new EnglishAuctionsLogic(address(weth))); + vm.label(englishAuctions, "EnglishAuctions_Extension"); + + // Extension: EnglishAuctionsLogic + Extension memory extension_englishAuctions; + extension_englishAuctions.metadata = ExtensionMetadata({ + name: "EnglishAuctionsLogic", + metadataURI: "ipfs://EnglishAuctions", + implementation: englishAuctions + }); + + extension_englishAuctions.functions = new ExtensionFunction[](12); + extension_englishAuctions.functions[0] = ExtensionFunction( + EnglishAuctionsLogic.totalAuctions.selector, + "totalAuctions()" + ); + extension_englishAuctions.functions[1] = ExtensionFunction( + EnglishAuctionsLogic.createAuction.selector, + "createAuction((address,uint256,uint256,address,uint256,uint256,uint64,uint64,uint64,uint64))" + ); + extension_englishAuctions.functions[2] = ExtensionFunction( + EnglishAuctionsLogic.cancelAuction.selector, + "cancelAuction(uint256)" + ); + extension_englishAuctions.functions[3] = ExtensionFunction( + EnglishAuctionsLogic.collectAuctionPayout.selector, + "collectAuctionPayout(uint256)" + ); + extension_englishAuctions.functions[4] = ExtensionFunction( + EnglishAuctionsLogic.collectAuctionTokens.selector, + "collectAuctionTokens(uint256)" + ); + extension_englishAuctions.functions[5] = ExtensionFunction( + EnglishAuctionsLogic.bidInAuction.selector, + "bidInAuction(uint256,uint256)" + ); + extension_englishAuctions.functions[6] = ExtensionFunction( + EnglishAuctionsLogic.isNewWinningBid.selector, + "isNewWinningBid(uint256,uint256)" + ); + extension_englishAuctions.functions[7] = ExtensionFunction( + EnglishAuctionsLogic.getAuction.selector, + "getAuction(uint256)" + ); + extension_englishAuctions.functions[8] = ExtensionFunction( + EnglishAuctionsLogic.getAllAuctions.selector, + "getAllAuctions(uint256,uint256)" + ); + extension_englishAuctions.functions[9] = ExtensionFunction( + EnglishAuctionsLogic.getAllValidAuctions.selector, + "getAllValidAuctions(uint256,uint256)" + ); + extension_englishAuctions.functions[10] = ExtensionFunction( + EnglishAuctionsLogic.getWinningBid.selector, + "getWinningBid(uint256)" + ); + extension_englishAuctions.functions[11] = ExtensionFunction( + EnglishAuctionsLogic.isAuctionExpired.selector, + "isAuctionExpired(uint256)" + ); + + extensions[0] = extension_englishAuctions; + } + + function test_validateNewAuction_whenQuantityIsZero() public { + auctionParams.quantity = 0; + + vm.prank(seller); + vm.expectRevert("Marketplace: auctioning zero quantity."); + EnglishAuctionsLogic(marketplace).createAuction(auctionParams); + } + + modifier whenNonZeroQuantity() { + auctionParams.quantity = 1; + _; + } + + function test_validateNewAuction_whenQuantityGtOneAndAssetERC721() public whenNonZeroQuantity { + auctionParams.quantity = 2; + auctionParams.assetContract = address(erc721); + + vm.prank(seller); + vm.expectRevert("Marketplace: auctioning invalid quantity."); + EnglishAuctionsLogic(marketplace).createAuction(auctionParams); + } + + modifier whenQtyOneOrAssetERC1155() { + auctionParams.quantity = 1; + auctionParams.assetContract = address(erc721); + _; + } + + function test_validateNewAuction_whenTimeBufferIsZero() public whenNonZeroQuantity whenQtyOneOrAssetERC1155 { + auctionParams.timeBufferInSeconds = 0; + + vm.prank(seller); + vm.expectRevert("Marketplace: no time-buffer."); + EnglishAuctionsLogic(marketplace).createAuction(auctionParams); + } + + modifier whenNonZeroTimeBuffer() { + _; + } + + function test_validateNewAuction_whenBidBufferIsZero() + public + whenNonZeroQuantity + whenQtyOneOrAssetERC1155 + whenNonZeroTimeBuffer + { + auctionParams.bidBufferBps = 0; + + vm.prank(seller); + vm.expectRevert("Marketplace: no bid-buffer."); + EnglishAuctionsLogic(marketplace).createAuction(auctionParams); + } + + modifier whenNonZeroBidBuffer() { + _; + } + + function test_validateNewAuction_whenInvalidTimestamps() + public + whenNonZeroQuantity + whenQtyOneOrAssetERC1155 + whenNonZeroTimeBuffer + whenNonZeroBidBuffer + { + vm.warp(auctionParams.startTimestamp + 61 minutes); + + vm.prank(seller); + vm.expectRevert("Marketplace: invalid timestamps."); + EnglishAuctionsLogic(marketplace).createAuction(auctionParams); + + vm.warp(auctionParams.startTimestamp); + + auctionParams.endTimestamp = auctionParams.startTimestamp - 1; + + vm.prank(seller); + vm.expectRevert("Marketplace: invalid timestamps."); + EnglishAuctionsLogic(marketplace).createAuction(auctionParams); + } + + modifier whenValidTimestamps() { + _; + } + + function test_validateNewAuction_whenBuyoutLtMinimumBidAmt() + public + whenNonZeroQuantity + whenQtyOneOrAssetERC1155 + whenNonZeroTimeBuffer + whenNonZeroBidBuffer + whenValidTimestamps + { + auctionParams.buyoutBidAmount = auctionParams.minimumBidAmount - 1; + + vm.prank(seller); + vm.expectRevert("Marketplace: invalid bid amounts."); + EnglishAuctionsLogic(marketplace).createAuction(auctionParams); + } + + modifier whenBuyoutGteMinimumBidAmt() { + _; + } + + function test_validateNewAuction_buyoutGteMinimumBidAmt() + public + whenNonZeroQuantity + whenQtyOneOrAssetERC1155 + whenNonZeroTimeBuffer + whenNonZeroBidBuffer + whenValidTimestamps + whenBuyoutGteMinimumBidAmt + { + vm.prank(seller); + erc721.setApprovalForAll(marketplace, true); + + vm.prank(seller); + EnglishAuctionsLogic(marketplace).createAuction(auctionParams); + + assertEq(EnglishAuctionsLogic(marketplace).totalAuctions(), 1); + } +} diff --git a/src/test/marketplace/english-auctions/_validateNewAuction/_validateNewAuction.tree b/src/test/marketplace/english-auctions/_validateNewAuction/_validateNewAuction.tree new file mode 100644 index 000000000..24429d6c5 --- /dev/null +++ b/src/test/marketplace/english-auctions/_validateNewAuction/_validateNewAuction.tree @@ -0,0 +1,20 @@ +function _validateNewAuction(AuctionParameters memory _params, TokenType _tokenType) internal view +. +├── when quantity is zero +│ └── it should revert ✅ +└── when the quantity is non zero + ├── when the quantity is greater than one and token type is ERC721 + │ └── it should revert ✅ + └── when the quantity is one or token type is ERC1155 + ├── when the time buffer is zero + │ └── it should revert ✅ + └── when the time buffer is non zero + ├── when the bid buffer is zero + │ └── it should revert ✅ + └── when the bid buffer is non zero + ├── when start and end timestamps are invalid + │ └── it should revert ✅ + └── when start and end timestamps are valid + ├── when buyout amount is less than minimum bid amount + │ └── it should revert ✅ + └── when buyout amount is zero or gte minimum bid amount ✅ \ No newline at end of file diff --git a/src/test/marketplace/english-auctions/bidInAuction/bidInAuction.t.sol b/src/test/marketplace/english-auctions/bidInAuction/bidInAuction.t.sol new file mode 100644 index 000000000..ad7041106 --- /dev/null +++ b/src/test/marketplace/english-auctions/bidInAuction/bidInAuction.t.sol @@ -0,0 +1,683 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +// Test helper imports +import "../../../utils/BaseTest.sol"; + +// Test contracts and interfaces +import { RoyaltyPaymentsLogic } from "contracts/extension/plugin/RoyaltyPayments.sol"; +import { MarketplaceV3, IPlatformFee } from "contracts/prebuilts/marketplace/entrypoint/MarketplaceV3.sol"; +import { EnglishAuctionsLogic } from "contracts/prebuilts/marketplace/english-auctions/EnglishAuctionsLogic.sol"; +import { TWProxy } from "contracts/infra/TWProxy.sol"; +import { ERC721Base } from "contracts/base/ERC721Base.sol"; + +import { IEnglishAuctions } from "contracts/prebuilts/marketplace/IMarketplace.sol"; + +import "@thirdweb-dev/dynamic-contracts/src/interface/IExtension.sol"; + +contract ReentrantRecipient is ERC1155Holder { + function onERC1155Received( + address operator, + address from, + uint256 id, + uint256 value, + bytes memory data + ) public virtual override returns (bytes4) { + uint256 auctionId = 0; + uint256 bidAmount = 10 ether; + EnglishAuctionsLogic(msg.sender).bidInAuction(auctionId, bidAmount); + return super.onERC1155Received(operator, from, id, value, data); + } +} + +contract BidInAuctionTest is BaseTest, IExtension { + // Target contract + address public marketplace; + + // Participants + address public marketplaceDeployer; + address public seller; + address public buyer; + + // Auction parameters + uint256 internal auctionId; + uint256 internal bidAmount; + address internal winningBidder = address(0x123); + IEnglishAuctions.AuctionParameters internal auctionParams; + + // Events + event NewBid( + uint256 indexed auctionId, + address indexed bidder, + address indexed assetContract, + uint256 bidAmount, + IEnglishAuctions.Auction auction + ); + + function setUp() public override { + super.setUp(); + + marketplaceDeployer = getActor(1); + seller = getActor(2); + buyer = getActor(3); + + // Deploy implementation. + Extension[] memory extensions = _setupExtensions(); + address impl = address( + new MarketplaceV3(MarketplaceV3.MarketplaceConstructorParams(extensions, address(0), address(weth))) + ); + + vm.prank(marketplaceDeployer); + marketplace = address( + new TWProxy( + impl, + abi.encodeCall( + MarketplaceV3.initialize, + (marketplaceDeployer, "", new address[](0), marketplaceDeployer, 0) + ) + ) + ); + + // Setup roles for seller and assets + vm.startPrank(marketplaceDeployer); + Permissions(marketplace).revokeRole(keccak256("ASSET_ROLE"), address(0)); + Permissions(marketplace).revokeRole(keccak256("LISTER_ROLE"), address(0)); + Permissions(marketplace).grantRole(keccak256("ASSET_ROLE"), address(erc1155)); + Permissions(marketplace).grantRole(keccak256("LISTER_ROLE"), seller); + vm.stopPrank(); + + vm.label(impl, "MarketplaceV3_Impl"); + vm.label(marketplace, "Marketplace"); + vm.label(seller, "Seller"); + vm.label(buyer, "Buyer"); + vm.label(address(erc721), "ERC721_Token"); + vm.label(address(erc1155), "ERC1155_Token"); + + // Sample auction parameters. + address assetContract = address(erc1155); + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = address(erc20); + uint256 minimumBidAmount = 1 ether; + uint256 buyoutBidAmount = 10 ether; + uint64 timeBufferInSeconds = 10 seconds; + uint64 bidBufferBps = 1000; + uint64 startTimestamp = 100 minutes; + uint64 endTimestamp = 200 minutes; + + // Auction tokens. + auctionParams = IEnglishAuctions.AuctionParameters( + assetContract, + tokenId, + quantity, + currency, + minimumBidAmount, + buyoutBidAmount, + timeBufferInSeconds, + bidBufferBps, + startTimestamp, + endTimestamp + ); + + // Set bidAmount + bidAmount = auctionParams.minimumBidAmount; + + // Mint NFT to seller. + erc721.mint(seller, 1); // to, amount + erc1155.mint(seller, 0, 100); // to, id, amount + + // Create auction + vm.startPrank(seller); + erc721.setApprovalForAll(marketplace, true); + erc1155.setApprovalForAll(marketplace, true); + auctionId = EnglishAuctionsLogic(marketplace).createAuction(auctionParams); + vm.stopPrank(); + + // Mint currency to bidder. + erc20.mint(buyer, 10_000 ether); + + vm.prank(buyer); + erc20.approve(marketplace, 100 ether); + } + + function _setupExtensions() internal returns (Extension[] memory extensions) { + extensions = new Extension[](1); + + // Deploy `EnglishAuctions` + address englishAuctions = address(new EnglishAuctionsLogic(address(weth))); + vm.label(englishAuctions, "EnglishAuctions_Extension"); + + // Extension: EnglishAuctionsLogic + Extension memory extension_englishAuctions; + extension_englishAuctions.metadata = ExtensionMetadata({ + name: "EnglishAuctionsLogic", + metadataURI: "ipfs://EnglishAuctions", + implementation: englishAuctions + }); + + extension_englishAuctions.functions = new ExtensionFunction[](12); + extension_englishAuctions.functions[0] = ExtensionFunction( + EnglishAuctionsLogic.totalAuctions.selector, + "totalAuctions()" + ); + extension_englishAuctions.functions[1] = ExtensionFunction( + EnglishAuctionsLogic.createAuction.selector, + "createAuction((address,uint256,uint256,address,uint256,uint256,uint64,uint64,uint64,uint64))" + ); + extension_englishAuctions.functions[2] = ExtensionFunction( + EnglishAuctionsLogic.cancelAuction.selector, + "cancelAuction(uint256)" + ); + extension_englishAuctions.functions[3] = ExtensionFunction( + EnglishAuctionsLogic.collectAuctionPayout.selector, + "collectAuctionPayout(uint256)" + ); + extension_englishAuctions.functions[4] = ExtensionFunction( + EnglishAuctionsLogic.collectAuctionTokens.selector, + "collectAuctionTokens(uint256)" + ); + extension_englishAuctions.functions[5] = ExtensionFunction( + EnglishAuctionsLogic.bidInAuction.selector, + "bidInAuction(uint256,uint256)" + ); + extension_englishAuctions.functions[6] = ExtensionFunction( + EnglishAuctionsLogic.isNewWinningBid.selector, + "isNewWinningBid(uint256,uint256)" + ); + extension_englishAuctions.functions[7] = ExtensionFunction( + EnglishAuctionsLogic.getAuction.selector, + "getAuction(uint256)" + ); + extension_englishAuctions.functions[8] = ExtensionFunction( + EnglishAuctionsLogic.getAllAuctions.selector, + "getAllAuctions(uint256,uint256)" + ); + extension_englishAuctions.functions[9] = ExtensionFunction( + EnglishAuctionsLogic.getAllValidAuctions.selector, + "getAllValidAuctions(uint256,uint256)" + ); + extension_englishAuctions.functions[10] = ExtensionFunction( + EnglishAuctionsLogic.getWinningBid.selector, + "getWinningBid(uint256)" + ); + extension_englishAuctions.functions[11] = ExtensionFunction( + EnglishAuctionsLogic.isAuctionExpired.selector, + "isAuctionExpired(uint256)" + ); + + extensions[0] = extension_englishAuctions; + } + + function test_bidInAuction_callIsReentrant() public { + vm.warp(auctionParams.startTimestamp + 1); + address reentrantRecipient = address(new ReentrantRecipient()); + + erc20.mint(reentrantRecipient, 100 ether); + + vm.prank(marketplaceDeployer); + Permissions(marketplace).grantRole(keccak256("LISTER_ROLE"), reentrantRecipient); + + vm.startPrank(reentrantRecipient); + erc20.approve(marketplace, 100 ether); + vm.expectRevert(); + EnglishAuctionsLogic(marketplace).bidInAuction(auctionId, auctionParams.buyoutBidAmount); + vm.stopPrank(); + } + + modifier whenCallIsNotReentrant() { + _; + } + + function test_bidInAuction_whenAuctionDoesNotExist() public whenCallIsNotReentrant { + vm.prank(buyer); + vm.expectRevert("Marketplace: invalid auction."); + EnglishAuctionsLogic(marketplace).bidInAuction(auctionId + 1, bidAmount); + } + + modifier whenAuctionExists() { + _; + } + + function test_bidInAuction_whenAuctionIsNotActive() public whenCallIsNotReentrant whenAuctionExists { + vm.prank(buyer); + vm.expectRevert("Marketplace: inactive auction."); + EnglishAuctionsLogic(marketplace).bidInAuction(auctionId, bidAmount); + } + + modifier whenAuctionIsActive() { + vm.warp(auctionParams.startTimestamp + 1); + _; + } + + function test_bidInAuction_whenBidAmountIsZero() + public + whenCallIsNotReentrant + whenAuctionExists + whenAuctionIsActive + { + vm.prank(buyer); + vm.expectRevert("Marketplace: Bidding with zero amount."); + EnglishAuctionsLogic(marketplace).bidInAuction(auctionId, 0); + } + + modifier whenBidAmountIsNotZero() { + bidAmount = auctionParams.minimumBidAmount; + _; + } + + function test_bidInAuction_whenAuctionCurrencyIsERC20AndMsgValueSent() + public + whenCallIsNotReentrant + whenAuctionExists + whenAuctionIsActive + whenBidAmountIsNotZero + { + vm.deal(buyer, 1 ether); + + vm.prank(buyer); + vm.expectRevert("Marketplace: invalid native tokens sent."); + EnglishAuctionsLogic(marketplace).bidInAuction{ value: 1 }(auctionId, auctionParams.buyoutBidAmount); + } + + function test_bidInAuction_whenBidAmountIsGtBuyoutPrice() + public + whenCallIsNotReentrant + whenAuctionExists + whenAuctionIsActive + whenBidAmountIsNotZero + { + vm.prank(buyer); + vm.expectRevert("Marketplace: Bidding above buyout price."); + EnglishAuctionsLogic(marketplace).bidInAuction(auctionId, auctionParams.buyoutBidAmount + 1); + } + + modifier whenBidAmountLtBuyoutPrice() { + bidAmount = auctionParams.buyoutBidAmount - 1; + _; + } + + modifier whenBidAmountEqBuyoutPrice() { + bidAmount = auctionParams.buyoutBidAmount; + _; + } + + modifier whenCurrentWinningBid() { + // Existing winning bid. + erc20.mint(winningBidder, 100 ether); + + vm.prank(winningBidder); + erc20.approve(marketplace, 100 ether); + + vm.prank(winningBidder); + EnglishAuctionsLogic(marketplace).bidInAuction(auctionId, auctionParams.minimumBidAmount + 1); + _; + } + + modifier whenNoCurrentWinningBid() { + _; + } + + function test_bidInAuction_buyoutAndExistingWinningBid() + public + whenCallIsNotReentrant + whenAuctionExists + whenAuctionIsActive + whenBidAmountIsNotZero + whenBidAmountEqBuyoutPrice + whenCurrentWinningBid + { + uint256 winningBidderBal = erc20.balanceOf(winningBidder); + + assertEq(erc20.balanceOf(marketplace), auctionParams.minimumBidAmount + 1); + + assertEq(erc1155.balanceOf(marketplace, auctionParams.tokenId), 1); + assertEq(erc1155.balanceOf(buyer, auctionParams.tokenId), 0); + assertEq(erc1155.balanceOf(seller, auctionParams.tokenId), 99); + + assertEq( + uint256(EnglishAuctionsLogic(marketplace).getAuction(auctionId).status), + uint256(IEnglishAuctions.Status.CREATED) + ); + + vm.prank(buyer); + EnglishAuctionsLogic(marketplace).bidInAuction(auctionId, bidAmount); + + assertEq(erc20.balanceOf(winningBidder), winningBidderBal + auctionParams.minimumBidAmount + 1); + assertEq(erc20.balanceOf(marketplace), bidAmount); + + assertEq(erc1155.balanceOf(marketplace, auctionParams.tokenId), 0); + assertEq(erc1155.balanceOf(buyer, auctionParams.tokenId), 1); + assertEq(erc1155.balanceOf(seller, auctionParams.tokenId), 99); + + // Auction is marked CLOSED in auction state when creator collected payout + assertEq( + uint256(EnglishAuctionsLogic(marketplace).getAuction(auctionId).status), + uint256(IEnglishAuctions.Status.CREATED) + ); + } + + function test_bidInAuction_buyoutAndNoWinningBid() + public + whenCallIsNotReentrant + whenAuctionExists + whenAuctionIsActive + whenBidAmountIsNotZero + whenBidAmountEqBuyoutPrice + whenNoCurrentWinningBid + { + assertEq(erc20.balanceOf(marketplace), 0); + + assertEq(erc1155.balanceOf(marketplace, auctionParams.tokenId), 1); + assertEq(erc1155.balanceOf(buyer, auctionParams.tokenId), 0); + assertEq(erc1155.balanceOf(seller, auctionParams.tokenId), 99); + + assertEq( + uint256(EnglishAuctionsLogic(marketplace).getAuction(auctionId).status), + uint256(IEnglishAuctions.Status.CREATED) + ); + + vm.prank(buyer); + EnglishAuctionsLogic(marketplace).bidInAuction(auctionId, bidAmount); + + assertEq(erc20.balanceOf(marketplace), bidAmount); + + assertEq(erc1155.balanceOf(marketplace, auctionParams.tokenId), 0); + assertEq(erc1155.balanceOf(buyer, auctionParams.tokenId), 1); + assertEq(erc1155.balanceOf(seller, auctionParams.tokenId), 99); + + // Auction is marked CLOSED in auction state when creator collected payout + assertEq( + uint256(EnglishAuctionsLogic(marketplace).getAuction(auctionId).status), + uint256(IEnglishAuctions.Status.CREATED) + ); + } + + function test_bidInAuction_whenBidIsNotNewWinningBid() + public + whenCallIsNotReentrant + whenAuctionExists + whenAuctionIsActive + whenCurrentWinningBid + whenBidAmountIsNotZero + whenBidAmountLtBuyoutPrice + { + assertEq(EnglishAuctionsLogic(marketplace).isNewWinningBid(auctionId, auctionParams.minimumBidAmount), false); + + vm.prank(buyer); + vm.expectRevert("Marketplace: not winning bid."); + EnglishAuctionsLogic(marketplace).bidInAuction(auctionId, auctionParams.minimumBidAmount); + } + + modifier whenBidIsNewWinningBig() { + bidAmount = auctionParams.buyoutBidAmount - 1; + assertEq(EnglishAuctionsLogic(marketplace).isNewWinningBid(auctionId, bidAmount), true); + _; + } + + modifier whenBidWithinTimeBuffer() { + vm.warp(auctionParams.endTimestamp - auctionParams.timeBufferInSeconds); + _; + } + + function test_bidInAuction_noBuyoutAndExistingWinningBid() + public + whenCallIsNotReentrant + whenAuctionExists + whenAuctionIsActive + whenBidAmountIsNotZero + whenBidAmountLtBuyoutPrice + whenBidIsNewWinningBig + whenCurrentWinningBid + { + uint256 winningBidderBal = erc20.balanceOf(winningBidder); + + assertEq(erc20.balanceOf(marketplace), auctionParams.minimumBidAmount + 1); + + assertEq(erc1155.balanceOf(marketplace, auctionParams.tokenId), 1); + assertEq(erc1155.balanceOf(buyer, auctionParams.tokenId), 0); + assertEq(erc1155.balanceOf(seller, auctionParams.tokenId), 99); + + assertEq( + uint256(EnglishAuctionsLogic(marketplace).getAuction(auctionId).status), + uint256(IEnglishAuctions.Status.CREATED) + ); + + (address bidderBefore, address currencyBefore, uint256 bidAmountBefore) = EnglishAuctionsLogic(marketplace) + .getWinningBid(auctionId); + + assertEq(bidderBefore, winningBidder); + assertEq(currencyBefore, address(erc20)); + assertEq(bidAmountBefore, auctionParams.minimumBidAmount + 1); + + assertEq(EnglishAuctionsLogic(marketplace).isNewWinningBid(auctionId, bidAmount), true); + + vm.startPrank(buyer); + vm.expectEmit(true, true, true, false); + emit NewBid( + auctionId, + buyer, + address(erc1155), + bidAmount, + EnglishAuctionsLogic(marketplace).getAuction(auctionId) + ); + EnglishAuctionsLogic(marketplace).bidInAuction(auctionId, bidAmount); + vm.stopPrank(); + + (address bidderAfter, address currencyAfter, uint256 bidAmountAfter) = EnglishAuctionsLogic(marketplace) + .getWinningBid(auctionId); + + assertEq(bidderAfter, buyer); + assertEq(currencyAfter, address(erc20)); + assertEq(bidAmountAfter, bidAmount); + + assertEq(erc20.balanceOf(winningBidder), winningBidderBal + auctionParams.minimumBidAmount + 1); + assertEq(erc20.balanceOf(marketplace), bidAmount); + + assertEq(erc1155.balanceOf(marketplace, auctionParams.tokenId), 1); + assertEq(erc1155.balanceOf(buyer, auctionParams.tokenId), 0); + assertEq(erc1155.balanceOf(seller, auctionParams.tokenId), 99); + + // Auction is marked CLOSED in auction state when creator collected payout + assertEq( + uint256(EnglishAuctionsLogic(marketplace).getAuction(auctionId).status), + uint256(IEnglishAuctions.Status.CREATED) + ); + } + + function test_bidInAuction_noBuyoutAndNoWinningBid() + public + whenCallIsNotReentrant + whenAuctionExists + whenAuctionIsActive + whenBidAmountIsNotZero + whenBidAmountLtBuyoutPrice + whenBidIsNewWinningBig + whenNoCurrentWinningBid + { + assertEq(erc20.balanceOf(marketplace), 0); + + assertEq(erc1155.balanceOf(marketplace, auctionParams.tokenId), 1); + assertEq(erc1155.balanceOf(buyer, auctionParams.tokenId), 0); + assertEq(erc1155.balanceOf(seller, auctionParams.tokenId), 99); + + assertEq( + uint256(EnglishAuctionsLogic(marketplace).getAuction(auctionId).status), + uint256(IEnglishAuctions.Status.CREATED) + ); + + (address bidderBefore, address currencyBefore, uint256 bidAmountBefore) = EnglishAuctionsLogic(marketplace) + .getWinningBid(auctionId); + + assertEq(bidderBefore, address(0)); + assertEq(currencyBefore, address(erc20)); + assertEq(bidAmountBefore, 0); + + vm.startPrank(buyer); + vm.expectEmit(true, true, true, false); + emit NewBid( + auctionId, + buyer, + address(erc1155), + bidAmount, + EnglishAuctionsLogic(marketplace).getAuction(auctionId) + ); + EnglishAuctionsLogic(marketplace).bidInAuction(auctionId, bidAmount); + vm.stopPrank(); + + (address bidderAfter, address currencyAfter, uint256 bidAmountAfter) = EnglishAuctionsLogic(marketplace) + .getWinningBid(auctionId); + + assertEq(bidderAfter, buyer); + assertEq(currencyAfter, address(erc20)); + assertEq(bidAmountAfter, bidAmount); + + assertEq(erc20.balanceOf(marketplace), bidAmount); + + assertEq(erc1155.balanceOf(marketplace, auctionParams.tokenId), 1); + assertEq(erc1155.balanceOf(buyer, auctionParams.tokenId), 0); + assertEq(erc1155.balanceOf(seller, auctionParams.tokenId), 99); + + assertEq( + uint256(EnglishAuctionsLogic(marketplace).getAuction(auctionId).status), + uint256(IEnglishAuctions.Status.CREATED) + ); + } + + function test_bidInAuction_noBuyoutAndExistingWinningBid_withinTimeBuffer() + public + whenCallIsNotReentrant + whenAuctionExists + whenAuctionIsActive + whenBidAmountIsNotZero + whenBidAmountLtBuyoutPrice + whenBidIsNewWinningBig + whenCurrentWinningBid + whenBidWithinTimeBuffer + { + uint256 winningBidderBal = erc20.balanceOf(winningBidder); + + assertEq(erc20.balanceOf(marketplace), auctionParams.minimumBidAmount + 1); + + assertEq(erc1155.balanceOf(marketplace, auctionParams.tokenId), 1); + assertEq(erc1155.balanceOf(buyer, auctionParams.tokenId), 0); + assertEq(erc1155.balanceOf(seller, auctionParams.tokenId), 99); + + assertEq( + uint256(EnglishAuctionsLogic(marketplace).getAuction(auctionId).status), + uint256(IEnglishAuctions.Status.CREATED) + ); + + (address bidderBefore, address currencyBefore, uint256 bidAmountBefore) = EnglishAuctionsLogic(marketplace) + .getWinningBid(auctionId); + + assertEq(bidderBefore, winningBidder); + assertEq(currencyBefore, address(erc20)); + assertEq(bidAmountBefore, auctionParams.minimumBidAmount + 1); + + assertEq(EnglishAuctionsLogic(marketplace).getAuction(auctionId).endTimestamp, auctionParams.endTimestamp); + + vm.startPrank(buyer); + vm.expectEmit(true, true, true, false); + emit NewBid( + auctionId, + buyer, + address(erc1155), + bidAmount, + EnglishAuctionsLogic(marketplace).getAuction(auctionId) + ); + EnglishAuctionsLogic(marketplace).bidInAuction(auctionId, bidAmount); + vm.stopPrank(); + + assertEq( + EnglishAuctionsLogic(marketplace).getAuction(auctionId).endTimestamp, + auctionParams.endTimestamp + auctionParams.timeBufferInSeconds + ); + + (address bidderAfter, address currencyAfter, uint256 bidAmountAfter) = EnglishAuctionsLogic(marketplace) + .getWinningBid(auctionId); + + assertEq(bidderAfter, buyer); + assertEq(currencyAfter, address(erc20)); + assertEq(bidAmountAfter, bidAmount); + + assertEq(erc20.balanceOf(winningBidder), winningBidderBal + auctionParams.minimumBidAmount + 1); + assertEq(erc20.balanceOf(marketplace), bidAmount); + + assertEq(erc1155.balanceOf(marketplace, auctionParams.tokenId), 1); + assertEq(erc1155.balanceOf(buyer, auctionParams.tokenId), 0); + assertEq(erc1155.balanceOf(seller, auctionParams.tokenId), 99); + + // Auction is marked CLOSED in auction state when creator collected payout + assertEq( + uint256(EnglishAuctionsLogic(marketplace).getAuction(auctionId).status), + uint256(IEnglishAuctions.Status.CREATED) + ); + } + + function test_bidInAuction_noBuyoutAndNoWinningBid_withinTimeBuffer() + public + whenCallIsNotReentrant + whenAuctionExists + whenAuctionIsActive + whenBidAmountIsNotZero + whenBidAmountLtBuyoutPrice + whenBidIsNewWinningBig + whenBidWithinTimeBuffer + whenNoCurrentWinningBid + { + assertEq(erc20.balanceOf(marketplace), 0); + + assertEq(erc1155.balanceOf(marketplace, auctionParams.tokenId), 1); + assertEq(erc1155.balanceOf(buyer, auctionParams.tokenId), 0); + assertEq(erc1155.balanceOf(seller, auctionParams.tokenId), 99); + + assertEq( + uint256(EnglishAuctionsLogic(marketplace).getAuction(auctionId).status), + uint256(IEnglishAuctions.Status.CREATED) + ); + + (address bidderBefore, address currencyBefore, uint256 bidAmountBefore) = EnglishAuctionsLogic(marketplace) + .getWinningBid(auctionId); + + assertEq(bidderBefore, address(0)); + assertEq(currencyBefore, address(erc20)); + assertEq(bidAmountBefore, 0); + + assertEq(EnglishAuctionsLogic(marketplace).getAuction(auctionId).endTimestamp, auctionParams.endTimestamp); + + vm.startPrank(buyer); + vm.expectEmit(true, true, true, false); + emit NewBid( + auctionId, + buyer, + address(erc1155), + bidAmount, + EnglishAuctionsLogic(marketplace).getAuction(auctionId) + ); + EnglishAuctionsLogic(marketplace).bidInAuction(auctionId, bidAmount); + vm.stopPrank(); + + assertEq( + EnglishAuctionsLogic(marketplace).getAuction(auctionId).endTimestamp, + auctionParams.endTimestamp + auctionParams.timeBufferInSeconds + ); + + (address bidderAfter, address currencyAfter, uint256 bidAmountAfter) = EnglishAuctionsLogic(marketplace) + .getWinningBid(auctionId); + + assertEq(bidderAfter, buyer); + assertEq(currencyAfter, address(erc20)); + assertEq(bidAmountAfter, bidAmount); + + assertEq(erc20.balanceOf(marketplace), bidAmount); + + assertEq(erc1155.balanceOf(marketplace, auctionParams.tokenId), 1); + assertEq(erc1155.balanceOf(buyer, auctionParams.tokenId), 0); + assertEq(erc1155.balanceOf(seller, auctionParams.tokenId), 99); + + assertEq( + uint256(EnglishAuctionsLogic(marketplace).getAuction(auctionId).status), + uint256(IEnglishAuctions.Status.CREATED) + ); + } +} diff --git a/src/test/marketplace/english-auctions/bidInAuction/bidInAuction.tree b/src/test/marketplace/english-auctions/bidInAuction/bidInAuction.tree new file mode 100644 index 000000000..bcb182035 --- /dev/null +++ b/src/test/marketplace/english-auctions/bidInAuction/bidInAuction.tree @@ -0,0 +1,52 @@ +function bidInAuction(uint256 _auctionId, uint256 _bidAmount) +├── when the call is reentrant +│ └── it should revert ✅ +└── when the call is not reentrant + ├── when the auction does not exist + │ └── it should revert ✅ + └── when the auction exists + ├── when the auction is not active + │ └── it should revert ✅ + └── when the auction is active + ├── when the bid amount is zero + │ └── it should revert ✅ + └── when the bid amount is not zero + ├── when the bid amount is greater than buyout price + │ └── it should revert ✅ + └── when the bid amount is less than or equal to buyout price + ├── when the bid amount is equal to buyout price + │ ├── when there is a current winning bid ✅ + │ │ ├── it should transfer previous winning bid back to previous winning bidder + │ │ ├── it should transfer auctioned tokens to bidder + │ │ ├── it should escrow incoming bid + │ │ └── it should emit a NewBid event + │ └── when there is no current winning bid ✅ + │ ├── it should transfer auctioned tokens to bidder + │ ├── it should escrow incoming bid + │ └── it should emit a NewBid event + └── when the bid amount is less than buyout price + ├── when the bid is not a new winning bid + │ └── it should revert ✅ + └── when the bid is a new winning bid + ├── when the remaining auction duration is less than time buffer + │ ├── when there is a current winning bid ✅ + │ │ ├── it should add time buffer to auction duration + │ │ ├── it should transfer previous winning bid back to previous winning bidder + │ │ ├── it should escrow incoming bid + │ │ └── it should emit a NewBid event + │ │ └── it set auction status as completed + │ └── when there is no current winning bid ✅ + │ ├── it should add time buffer to auction duration + │ ├── it should escrow incoming bid + │ └── it should emit a NewBid event + │ └── it set auction status as completed + └── when the remaining auction duration is not less than time buffer + ├── when there is a current winning bid ✅ + │ ├── it should transfer previous winning bid back to previous winning bidder + │ ├── it should escrow incoming bid + │ └── it should emit a NewBid event + │ └── it set auction status as completed + └── when there is no current winning bid ✅ + ├── it should escrow incoming bid + └── it should emit a NewBid event + └── it set auction status as completed \ No newline at end of file diff --git a/src/test/marketplace/english-auctions/cancelAuction/cancelAuction.t.sol b/src/test/marketplace/english-auctions/cancelAuction/cancelAuction.t.sol new file mode 100644 index 000000000..aa69aacb2 --- /dev/null +++ b/src/test/marketplace/english-auctions/cancelAuction/cancelAuction.t.sol @@ -0,0 +1,261 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +// Test helper imports +import "../../../utils/BaseTest.sol"; + +// Test contracts and interfaces +import { RoyaltyPaymentsLogic } from "contracts/extension/plugin/RoyaltyPayments.sol"; +import { MarketplaceV3, IPlatformFee } from "contracts/prebuilts/marketplace/entrypoint/MarketplaceV3.sol"; +import { EnglishAuctionsLogic } from "contracts/prebuilts/marketplace/english-auctions/EnglishAuctionsLogic.sol"; +import { TWProxy } from "contracts/infra/TWProxy.sol"; +import { ERC721Base } from "contracts/base/ERC721Base.sol"; + +import { IEnglishAuctions } from "contracts/prebuilts/marketplace/IMarketplace.sol"; + +import "@thirdweb-dev/dynamic-contracts/src/interface/IExtension.sol"; + +contract ReentrantRecipient is ERC1155Holder { + function onERC1155Received( + address operator, + address from, + uint256 id, + uint256 value, + bytes memory data + ) public virtual override returns (bytes4) { + uint256 auctionId = 0; + uint256 bidAmount = 10 ether; + EnglishAuctionsLogic(msg.sender).bidInAuction(auctionId, bidAmount); + return super.onERC1155Received(operator, from, id, value, data); + } +} + +contract CancelAuctionTest is BaseTest, IExtension { + // Target contract + address public marketplace; + + // Participants + address public marketplaceDeployer; + address public seller; + address public buyer; + + // Auction parameters + uint256 internal auctionId; + uint256 internal bidAmount; + address internal winningBidder = address(0x123); + IEnglishAuctions.AuctionParameters internal auctionParams; + + // Events + event CancelledAuction(address indexed auctionCreator, uint256 indexed auctionId); + + function setUp() public override { + super.setUp(); + + marketplaceDeployer = getActor(1); + seller = getActor(2); + buyer = getActor(3); + + // Deploy implementation. + Extension[] memory extensions = _setupExtensions(); + address impl = address( + new MarketplaceV3(MarketplaceV3.MarketplaceConstructorParams(extensions, address(0), address(weth))) + ); + + vm.prank(marketplaceDeployer); + marketplace = address( + new TWProxy( + impl, + abi.encodeCall( + MarketplaceV3.initialize, + (marketplaceDeployer, "", new address[](0), marketplaceDeployer, 0) + ) + ) + ); + + // Setup roles for seller and assets + vm.startPrank(marketplaceDeployer); + Permissions(marketplace).revokeRole(keccak256("ASSET_ROLE"), address(0)); + Permissions(marketplace).revokeRole(keccak256("LISTER_ROLE"), address(0)); + Permissions(marketplace).grantRole(keccak256("ASSET_ROLE"), address(erc1155)); + Permissions(marketplace).grantRole(keccak256("LISTER_ROLE"), seller); + vm.stopPrank(); + + vm.label(impl, "MarketplaceV3_Impl"); + vm.label(marketplace, "Marketplace"); + vm.label(seller, "Seller"); + vm.label(buyer, "Buyer"); + vm.label(address(erc721), "ERC721_Token"); + vm.label(address(erc1155), "ERC1155_Token"); + + // Sample auction parameters. + address assetContract = address(erc1155); + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = address(erc20); + uint256 minimumBidAmount = 1 ether; + uint256 buyoutBidAmount = 10 ether; + uint64 timeBufferInSeconds = 10 seconds; + uint64 bidBufferBps = 1000; + uint64 startTimestamp = 100 minutes; + uint64 endTimestamp = 200 minutes; + + // Auction tokens. + auctionParams = IEnglishAuctions.AuctionParameters( + assetContract, + tokenId, + quantity, + currency, + minimumBidAmount, + buyoutBidAmount, + timeBufferInSeconds, + bidBufferBps, + startTimestamp, + endTimestamp + ); + + // Set bidAmount + bidAmount = auctionParams.minimumBidAmount; + + // Mint NFT to seller. + erc721.mint(seller, 1); // to, amount + erc1155.mint(seller, 0, 100); // to, id, amount + + // Create auction + vm.startPrank(seller); + erc721.setApprovalForAll(marketplace, true); + erc1155.setApprovalForAll(marketplace, true); + auctionId = EnglishAuctionsLogic(marketplace).createAuction(auctionParams); + vm.stopPrank(); + + // Mint currency to bidder. + erc20.mint(buyer, 10_000 ether); + + vm.prank(buyer); + erc20.approve(marketplace, 100 ether); + } + + function _setupExtensions() internal returns (Extension[] memory extensions) { + extensions = new Extension[](1); + + // Deploy `EnglishAuctions` + address englishAuctions = address(new EnglishAuctionsLogic(address(weth))); + vm.label(englishAuctions, "EnglishAuctions_Extension"); + + // Extension: EnglishAuctionsLogic + Extension memory extension_englishAuctions; + extension_englishAuctions.metadata = ExtensionMetadata({ + name: "EnglishAuctionsLogic", + metadataURI: "ipfs://EnglishAuctions", + implementation: englishAuctions + }); + + extension_englishAuctions.functions = new ExtensionFunction[](12); + extension_englishAuctions.functions[0] = ExtensionFunction( + EnglishAuctionsLogic.totalAuctions.selector, + "totalAuctions()" + ); + extension_englishAuctions.functions[1] = ExtensionFunction( + EnglishAuctionsLogic.createAuction.selector, + "createAuction((address,uint256,uint256,address,uint256,uint256,uint64,uint64,uint64,uint64))" + ); + extension_englishAuctions.functions[2] = ExtensionFunction( + EnglishAuctionsLogic.cancelAuction.selector, + "cancelAuction(uint256)" + ); + extension_englishAuctions.functions[3] = ExtensionFunction( + EnglishAuctionsLogic.collectAuctionPayout.selector, + "collectAuctionPayout(uint256)" + ); + extension_englishAuctions.functions[4] = ExtensionFunction( + EnglishAuctionsLogic.collectAuctionTokens.selector, + "collectAuctionTokens(uint256)" + ); + extension_englishAuctions.functions[5] = ExtensionFunction( + EnglishAuctionsLogic.bidInAuction.selector, + "bidInAuction(uint256,uint256)" + ); + extension_englishAuctions.functions[6] = ExtensionFunction( + EnglishAuctionsLogic.isNewWinningBid.selector, + "isNewWinningBid(uint256,uint256)" + ); + extension_englishAuctions.functions[7] = ExtensionFunction( + EnglishAuctionsLogic.getAuction.selector, + "getAuction(uint256)" + ); + extension_englishAuctions.functions[8] = ExtensionFunction( + EnglishAuctionsLogic.getAllAuctions.selector, + "getAllAuctions(uint256,uint256)" + ); + extension_englishAuctions.functions[9] = ExtensionFunction( + EnglishAuctionsLogic.getAllValidAuctions.selector, + "getAllValidAuctions(uint256,uint256)" + ); + extension_englishAuctions.functions[10] = ExtensionFunction( + EnglishAuctionsLogic.getWinningBid.selector, + "getWinningBid(uint256)" + ); + extension_englishAuctions.functions[11] = ExtensionFunction( + EnglishAuctionsLogic.isAuctionExpired.selector, + "isAuctionExpired(uint256)" + ); + + extensions[0] = extension_englishAuctions; + } + + function test_cancelAuction_whenAuctionDoesntExist() public { + vm.prank(seller); + vm.expectRevert("Marketplace: invalid auction."); + EnglishAuctionsLogic(marketplace).cancelAuction(auctionId + 100); + } + + modifier whenAuctionExists() { + _; + } + + function test_cancelAuction_whenCallerNotCreator() public whenAuctionExists { + vm.prank(buyer); + vm.expectRevert("Marketplace: not auction creator."); + EnglishAuctionsLogic(marketplace).cancelAuction(auctionId); + } + + modifier whenCallerIsCreator() { + _; + } + + function test_cancelAuction_whenWinningBid() public whenAuctionExists whenCallerIsCreator { + vm.warp(auctionParams.startTimestamp + 1); + + vm.prank(buyer); + EnglishAuctionsLogic(marketplace).bidInAuction(auctionId, bidAmount); + + vm.prank(seller); + vm.expectRevert("Marketplace: bids already made."); + EnglishAuctionsLogic(marketplace).cancelAuction(auctionId); + } + + modifier whenNoWinningBid() { + _; + } + + function test_cancelAuction_whenNoWinningBid() public whenAuctionExists whenCallerIsCreator whenNoWinningBid { + assertEq( + uint256(EnglishAuctionsLogic(marketplace).getAuction(auctionId).status), + uint256(IEnglishAuctions.Status.CREATED) + ); + assertEq(erc1155.balanceOf(address(marketplace), 0), 1); + assertEq(erc1155.balanceOf(seller, 0), 99); + + vm.prank(seller); + vm.expectEmit(true, true, true, true); + emit CancelledAuction(seller, auctionId); + EnglishAuctionsLogic(marketplace).cancelAuction(auctionId); + + assertEq( + uint256(EnglishAuctionsLogic(marketplace).getAuction(auctionId).status), + uint256(IEnglishAuctions.Status.CANCELLED) + ); + + assertEq(erc1155.balanceOf(address(marketplace), 0), 0); + assertEq(erc1155.balanceOf(seller, 0), 100); + } +} diff --git a/src/test/marketplace/english-auctions/cancelAuction/cancelAuction.tree b/src/test/marketplace/english-auctions/cancelAuction/cancelAuction.tree new file mode 100644 index 000000000..397d6bc6d --- /dev/null +++ b/src/test/marketplace/english-auctions/cancelAuction/cancelAuction.tree @@ -0,0 +1,14 @@ +function cancelAuction(uint256 _auctionId) external +. +├── when auction does not exist +│ └── it should revert ✅ +└── when auction exists + ├── when the caller is not auction creator + │ └── it should revert ✅ + └── when the caller is auction creator + ├── when there is a winning bidder + │ └── it should revert ✅ + └── when there is no winning bidder ✅ + ├── it should set auction status as cancelled + ├── it should transfer auction tokens back to creator + └── it should emit CancelledAuction event diff --git a/src/test/marketplace/english-auctions/collectAuctionPayout/collectAuctionPayout.t.sol b/src/test/marketplace/english-auctions/collectAuctionPayout/collectAuctionPayout.t.sol new file mode 100644 index 000000000..0ea873384 --- /dev/null +++ b/src/test/marketplace/english-auctions/collectAuctionPayout/collectAuctionPayout.t.sol @@ -0,0 +1,298 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +// Test helper imports +import "../../../utils/BaseTest.sol"; + +// Test contracts and interfaces +import { RoyaltyPaymentsLogic } from "contracts/extension/plugin/RoyaltyPayments.sol"; +import { MarketplaceV3, IPlatformFee } from "contracts/prebuilts/marketplace/entrypoint/MarketplaceV3.sol"; +import { EnglishAuctionsLogic } from "contracts/prebuilts/marketplace/english-auctions/EnglishAuctionsLogic.sol"; +import { TWProxy } from "contracts/infra/TWProxy.sol"; +import { ERC721Base } from "contracts/base/ERC721Base.sol"; + +import { IEnglishAuctions } from "contracts/prebuilts/marketplace/IMarketplace.sol"; + +import "@thirdweb-dev/dynamic-contracts/src/interface/IExtension.sol"; + +contract ReentrantRecipient is ERC1155Holder { + function onERC1155Received( + address operator, + address from, + uint256 id, + uint256 value, + bytes memory data + ) public virtual override returns (bytes4) { + uint256 auctionId = 0; + uint256 bidAmount = 10 ether; + EnglishAuctionsLogic(msg.sender).bidInAuction(auctionId, bidAmount); + return super.onERC1155Received(operator, from, id, value, data); + } +} + +contract CollectAuctionPayoutTest is BaseTest, IExtension { + // Target contract + address public marketplace; + + // Participants + address public marketplaceDeployer; + address public seller; + address public buyer; + + // Auction parameters + uint256 internal auctionId; + address internal winningBidder = address(0x123); + IEnglishAuctions.AuctionParameters internal auctionParams; + + // Events + event AuctionClosed( + uint256 indexed auctionId, + address indexed assetContract, + address indexed closer, + uint256 tokenId, + address auctionCreator, + address winningBidder + ); + + function setUp() public override { + super.setUp(); + + marketplaceDeployer = getActor(1); + seller = getActor(2); + buyer = getActor(3); + + // Deploy implementation. + Extension[] memory extensions = _setupExtensions(); + address impl = address( + new MarketplaceV3(MarketplaceV3.MarketplaceConstructorParams(extensions, address(0), address(weth))) + ); + + vm.prank(marketplaceDeployer); + marketplace = address( + new TWProxy( + impl, + abi.encodeCall( + MarketplaceV3.initialize, + (marketplaceDeployer, "", new address[](0), marketplaceDeployer, 0) + ) + ) + ); + + // Setup roles for seller and assets + vm.startPrank(marketplaceDeployer); + Permissions(marketplace).revokeRole(keccak256("ASSET_ROLE"), address(0)); + Permissions(marketplace).revokeRole(keccak256("LISTER_ROLE"), address(0)); + Permissions(marketplace).grantRole(keccak256("ASSET_ROLE"), address(erc1155)); + Permissions(marketplace).grantRole(keccak256("LISTER_ROLE"), seller); + vm.stopPrank(); + + vm.label(impl, "MarketplaceV3_Impl"); + vm.label(marketplace, "Marketplace"); + vm.label(seller, "Seller"); + vm.label(buyer, "Buyer"); + vm.label(address(erc721), "ERC721_Token"); + vm.label(address(erc1155), "ERC1155_Token"); + + // Sample auction parameters. + address assetContract = address(erc1155); + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = address(erc20); + uint256 minimumBidAmount = 1 ether; + uint256 buyoutBidAmount = 10 ether; + uint64 timeBufferInSeconds = 10 seconds; + uint64 bidBufferBps = 1000; + uint64 startTimestamp = 100 minutes; + uint64 endTimestamp = 200 minutes; + + // Auction tokens. + auctionParams = IEnglishAuctions.AuctionParameters( + assetContract, + tokenId, + quantity, + currency, + minimumBidAmount, + buyoutBidAmount, + timeBufferInSeconds, + bidBufferBps, + startTimestamp, + endTimestamp + ); + + // Mint NFT to seller. + erc721.mint(seller, 1); // to, amount + erc1155.mint(seller, 0, 100); // to, id, amount + + // Create auction + vm.startPrank(seller); + erc721.setApprovalForAll(marketplace, true); + erc1155.setApprovalForAll(marketplace, true); + auctionId = EnglishAuctionsLogic(marketplace).createAuction(auctionParams); + vm.stopPrank(); + + // Mint currency to bidder. + erc20.mint(buyer, 10_000 ether); + + vm.prank(buyer); + erc20.approve(marketplace, 100 ether); + } + + function _setupExtensions() internal returns (Extension[] memory extensions) { + extensions = new Extension[](1); + + // Deploy `EnglishAuctions` + address englishAuctions = address(new EnglishAuctionsLogic(address(weth))); + vm.label(englishAuctions, "EnglishAuctions_Extension"); + + // Extension: EnglishAuctionsLogic + Extension memory extension_englishAuctions; + extension_englishAuctions.metadata = ExtensionMetadata({ + name: "EnglishAuctionsLogic", + metadataURI: "ipfs://EnglishAuctions", + implementation: englishAuctions + }); + + extension_englishAuctions.functions = new ExtensionFunction[](12); + extension_englishAuctions.functions[0] = ExtensionFunction( + EnglishAuctionsLogic.totalAuctions.selector, + "totalAuctions()" + ); + extension_englishAuctions.functions[1] = ExtensionFunction( + EnglishAuctionsLogic.createAuction.selector, + "createAuction((address,uint256,uint256,address,uint256,uint256,uint64,uint64,uint64,uint64))" + ); + extension_englishAuctions.functions[2] = ExtensionFunction( + EnglishAuctionsLogic.cancelAuction.selector, + "cancelAuction(uint256)" + ); + extension_englishAuctions.functions[3] = ExtensionFunction( + EnglishAuctionsLogic.collectAuctionPayout.selector, + "collectAuctionPayout(uint256)" + ); + extension_englishAuctions.functions[4] = ExtensionFunction( + EnglishAuctionsLogic.collectAuctionTokens.selector, + "collectAuctionTokens(uint256)" + ); + extension_englishAuctions.functions[5] = ExtensionFunction( + EnglishAuctionsLogic.bidInAuction.selector, + "bidInAuction(uint256,uint256)" + ); + extension_englishAuctions.functions[6] = ExtensionFunction( + EnglishAuctionsLogic.isNewWinningBid.selector, + "isNewWinningBid(uint256,uint256)" + ); + extension_englishAuctions.functions[7] = ExtensionFunction( + EnglishAuctionsLogic.getAuction.selector, + "getAuction(uint256)" + ); + extension_englishAuctions.functions[8] = ExtensionFunction( + EnglishAuctionsLogic.getAllAuctions.selector, + "getAllAuctions(uint256,uint256)" + ); + extension_englishAuctions.functions[9] = ExtensionFunction( + EnglishAuctionsLogic.getAllValidAuctions.selector, + "getAllValidAuctions(uint256,uint256)" + ); + extension_englishAuctions.functions[10] = ExtensionFunction( + EnglishAuctionsLogic.getWinningBid.selector, + "getWinningBid(uint256)" + ); + extension_englishAuctions.functions[11] = ExtensionFunction( + EnglishAuctionsLogic.isAuctionExpired.selector, + "isAuctionExpired(uint256)" + ); + + extensions[0] = extension_englishAuctions; + } + + function test_collectAuctionPayout_whenAuctionIsCancelled() public { + vm.prank(seller); + EnglishAuctionsLogic(marketplace).cancelAuction(auctionId); + + vm.prank(seller); + vm.expectRevert("Marketplace: invalid auction."); + EnglishAuctionsLogic(marketplace).collectAuctionPayout(auctionId); + } + + modifier whenAuctionNotCancelled() { + _; + } + + function test_collectAuctionPayout_whenAuctionIsActive() public whenAuctionNotCancelled { + vm.warp(auctionParams.startTimestamp + 1); + + vm.prank(seller); + vm.expectRevert("Marketplace: auction still active."); + EnglishAuctionsLogic(marketplace).collectAuctionPayout(auctionId); + } + + modifier whenAuctionHasEnded() { + vm.warp(auctionParams.endTimestamp + 1); + _; + } + + function test_collectAuctionPayout_whenNoWinningBid() public whenAuctionNotCancelled whenAuctionHasEnded { + vm.warp(auctionParams.endTimestamp + 1); + + vm.prank(seller); + vm.expectRevert("Marketplace: no bids were made."); + EnglishAuctionsLogic(marketplace).collectAuctionPayout(auctionId); + } + + modifier whenAuctionHasWinningBid() { + vm.warp(auctionParams.startTimestamp + 1); + + // Bid in auction + vm.prank(buyer); + EnglishAuctionsLogic(marketplace).bidInAuction(auctionId, auctionParams.minimumBidAmount); + _; + } + + function test_collectAuctionPayout_whenAuctionTokensAlreadyPaidOut() + public + whenAuctionNotCancelled + whenAuctionHasWinningBid + whenAuctionHasEnded + { + vm.prank(seller); + EnglishAuctionsLogic(marketplace).collectAuctionPayout(auctionId); + + vm.prank(seller); + vm.expectRevert("Marketplace: payout already completed."); + EnglishAuctionsLogic(marketplace).collectAuctionPayout(auctionId); + } + + modifier whenAuctionTokensNotPaidOut() { + _; + } + + function test_collectAuctionPayout_whenAuctionTokensNotYetPaidOut() + public + whenAuctionNotCancelled + whenAuctionHasWinningBid + whenAuctionHasEnded + whenAuctionTokensNotPaidOut + { + assertEq( + uint256(EnglishAuctionsLogic(marketplace).getAuction(auctionId).status), + uint256(IEnglishAuctions.Status.CREATED) + ); + + uint256 marketplaceBal = erc20.balanceOf(address(marketplace)); + assertEq(marketplaceBal, auctionParams.minimumBidAmount); + assertEq(erc20.balanceOf(seller), 0); + + vm.prank(seller); + vm.expectEmit(true, true, true, true); + emit AuctionClosed(auctionId, address(erc1155), seller, auctionParams.tokenId, seller, buyer); + EnglishAuctionsLogic(marketplace).collectAuctionPayout(auctionId); + + assertEq( + uint256(EnglishAuctionsLogic(marketplace).getAuction(auctionId).status), + uint256(IEnglishAuctions.Status.COMPLETED) + ); + + assertEq(erc20.balanceOf(address(marketplace)), 0); + assertEq(erc20.balanceOf(seller), marketplaceBal); + } +} diff --git a/src/test/marketplace/english-auctions/collectAuctionPayout/collectAuctionPayout.tree b/src/test/marketplace/english-auctions/collectAuctionPayout/collectAuctionPayout.tree new file mode 100644 index 000000000..571fd4a61 --- /dev/null +++ b/src/test/marketplace/english-auctions/collectAuctionPayout/collectAuctionPayout.tree @@ -0,0 +1,17 @@ +function collectAuctionPayout(uint256 _auctionId) external +. +├── when auction is cancelled +│ └── it should revert ✅ +└── when auction is not cancelled + ├── when auction has not ended + │ └── it should revert ✅ + └── when auction has ended + ├── when there is no winning bid + │ └── it should revert ✅ + └── when there is a winning bid + ├── when creator already paid out + │ └── it should revert ✅ + └── when creator not already paid out ✅ + ├── it should set auction status to completed + ├── it should pay the auction winning bid to creator + └── it should emit AuctionClosed event diff --git a/src/test/marketplace/english-auctions/collectAuctionTokens/collectAuctionTokens.t.sol b/src/test/marketplace/english-auctions/collectAuctionTokens/collectAuctionTokens.t.sol new file mode 100644 index 000000000..11b451d67 --- /dev/null +++ b/src/test/marketplace/english-auctions/collectAuctionTokens/collectAuctionTokens.t.sol @@ -0,0 +1,298 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +// Test helper imports +import "../../../utils/BaseTest.sol"; + +// Test contracts and interfaces +import { RoyaltyPaymentsLogic } from "contracts/extension/plugin/RoyaltyPayments.sol"; +import { MarketplaceV3, IPlatformFee } from "contracts/prebuilts/marketplace/entrypoint/MarketplaceV3.sol"; +import { EnglishAuctionsLogic } from "contracts/prebuilts/marketplace/english-auctions/EnglishAuctionsLogic.sol"; +import { TWProxy } from "contracts/infra/TWProxy.sol"; +import { ERC721Base } from "contracts/base/ERC721Base.sol"; + +import { IEnglishAuctions } from "contracts/prebuilts/marketplace/IMarketplace.sol"; + +import "@thirdweb-dev/dynamic-contracts/src/interface/IExtension.sol"; + +contract ReentrantRecipient is ERC1155Holder { + function onERC1155Received( + address operator, + address from, + uint256 id, + uint256 value, + bytes memory data + ) public virtual override returns (bytes4) { + uint256 auctionId = 0; + uint256 bidAmount = 10 ether; + EnglishAuctionsLogic(msg.sender).bidInAuction(auctionId, bidAmount); + return super.onERC1155Received(operator, from, id, value, data); + } +} + +contract CollectAuctionTokensTest is BaseTest, IExtension { + // Target contract + address public marketplace; + + // Participants + address public marketplaceDeployer; + address public seller; + address public buyer; + + // Auction parameters + uint256 internal auctionId; + address internal winningBidder = address(0x123); + IEnglishAuctions.AuctionParameters internal auctionParams; + + // Events + event AuctionClosed( + uint256 indexed auctionId, + address indexed assetContract, + address indexed closer, + uint256 tokenId, + address auctionCreator, + address winningBidder + ); + + function setUp() public override { + super.setUp(); + + marketplaceDeployer = getActor(1); + seller = getActor(2); + buyer = getActor(3); + + // Deploy implementation. + Extension[] memory extensions = _setupExtensions(); + address impl = address( + new MarketplaceV3(MarketplaceV3.MarketplaceConstructorParams(extensions, address(0), address(weth))) + ); + + vm.prank(marketplaceDeployer); + marketplace = address( + new TWProxy( + impl, + abi.encodeCall( + MarketplaceV3.initialize, + (marketplaceDeployer, "", new address[](0), marketplaceDeployer, 0) + ) + ) + ); + + // Setup roles for seller and assets + vm.startPrank(marketplaceDeployer); + Permissions(marketplace).revokeRole(keccak256("ASSET_ROLE"), address(0)); + Permissions(marketplace).revokeRole(keccak256("LISTER_ROLE"), address(0)); + Permissions(marketplace).grantRole(keccak256("ASSET_ROLE"), address(erc1155)); + Permissions(marketplace).grantRole(keccak256("LISTER_ROLE"), seller); + vm.stopPrank(); + + vm.label(impl, "MarketplaceV3_Impl"); + vm.label(marketplace, "Marketplace"); + vm.label(seller, "Seller"); + vm.label(buyer, "Buyer"); + vm.label(address(erc721), "ERC721_Token"); + vm.label(address(erc1155), "ERC1155_Token"); + + // Sample auction parameters. + address assetContract = address(erc1155); + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = address(erc20); + uint256 minimumBidAmount = 1 ether; + uint256 buyoutBidAmount = 10 ether; + uint64 timeBufferInSeconds = 10 seconds; + uint64 bidBufferBps = 1000; + uint64 startTimestamp = 100 minutes; + uint64 endTimestamp = 200 minutes; + + // Auction tokens. + auctionParams = IEnglishAuctions.AuctionParameters( + assetContract, + tokenId, + quantity, + currency, + minimumBidAmount, + buyoutBidAmount, + timeBufferInSeconds, + bidBufferBps, + startTimestamp, + endTimestamp + ); + + // Mint NFT to seller. + erc721.mint(seller, 1); // to, amount + erc1155.mint(seller, 0, 100); // to, id, amount + + // Create auction + vm.startPrank(seller); + erc721.setApprovalForAll(marketplace, true); + erc1155.setApprovalForAll(marketplace, true); + auctionId = EnglishAuctionsLogic(marketplace).createAuction(auctionParams); + vm.stopPrank(); + + // Mint currency to bidder. + erc20.mint(buyer, 10_000 ether); + + vm.prank(buyer); + erc20.approve(marketplace, 100 ether); + } + + function _setupExtensions() internal returns (Extension[] memory extensions) { + extensions = new Extension[](1); + + // Deploy `EnglishAuctions` + address englishAuctions = address(new EnglishAuctionsLogic(address(weth))); + vm.label(englishAuctions, "EnglishAuctions_Extension"); + + // Extension: EnglishAuctionsLogic + Extension memory extension_englishAuctions; + extension_englishAuctions.metadata = ExtensionMetadata({ + name: "EnglishAuctionsLogic", + metadataURI: "ipfs://EnglishAuctions", + implementation: englishAuctions + }); + + extension_englishAuctions.functions = new ExtensionFunction[](12); + extension_englishAuctions.functions[0] = ExtensionFunction( + EnglishAuctionsLogic.totalAuctions.selector, + "totalAuctions()" + ); + extension_englishAuctions.functions[1] = ExtensionFunction( + EnglishAuctionsLogic.createAuction.selector, + "createAuction((address,uint256,uint256,address,uint256,uint256,uint64,uint64,uint64,uint64))" + ); + extension_englishAuctions.functions[2] = ExtensionFunction( + EnglishAuctionsLogic.cancelAuction.selector, + "cancelAuction(uint256)" + ); + extension_englishAuctions.functions[3] = ExtensionFunction( + EnglishAuctionsLogic.collectAuctionPayout.selector, + "collectAuctionPayout(uint256)" + ); + extension_englishAuctions.functions[4] = ExtensionFunction( + EnglishAuctionsLogic.collectAuctionTokens.selector, + "collectAuctionTokens(uint256)" + ); + extension_englishAuctions.functions[5] = ExtensionFunction( + EnglishAuctionsLogic.bidInAuction.selector, + "bidInAuction(uint256,uint256)" + ); + extension_englishAuctions.functions[6] = ExtensionFunction( + EnglishAuctionsLogic.isNewWinningBid.selector, + "isNewWinningBid(uint256,uint256)" + ); + extension_englishAuctions.functions[7] = ExtensionFunction( + EnglishAuctionsLogic.getAuction.selector, + "getAuction(uint256)" + ); + extension_englishAuctions.functions[8] = ExtensionFunction( + EnglishAuctionsLogic.getAllAuctions.selector, + "getAllAuctions(uint256,uint256)" + ); + extension_englishAuctions.functions[9] = ExtensionFunction( + EnglishAuctionsLogic.getAllValidAuctions.selector, + "getAllValidAuctions(uint256,uint256)" + ); + extension_englishAuctions.functions[10] = ExtensionFunction( + EnglishAuctionsLogic.getWinningBid.selector, + "getWinningBid(uint256)" + ); + extension_englishAuctions.functions[11] = ExtensionFunction( + EnglishAuctionsLogic.isAuctionExpired.selector, + "isAuctionExpired(uint256)" + ); + + extensions[0] = extension_englishAuctions; + } + + function test_collectAuctionTokens_whenAuctionIsCancelled() public { + vm.prank(seller); + EnglishAuctionsLogic(marketplace).cancelAuction(auctionId); + + vm.prank(buyer); + vm.expectRevert("Marketplace: invalid auction."); + EnglishAuctionsLogic(marketplace).collectAuctionTokens(auctionId); + } + + modifier whenAuctionNotCancelled() { + _; + } + + function test_collectAuctionTokens_whenAuctionIsActive() public whenAuctionNotCancelled { + vm.warp(auctionParams.startTimestamp + 1); + + vm.prank(buyer); + vm.expectRevert("Marketplace: auction still active."); + EnglishAuctionsLogic(marketplace).collectAuctionTokens(auctionId); + } + + modifier whenAuctionHasEnded() { + vm.warp(auctionParams.endTimestamp + 1); + _; + } + + function test_collectAuctionTokens_whenNoWinningBid() public whenAuctionNotCancelled whenAuctionHasEnded { + vm.warp(auctionParams.endTimestamp + 1); + + vm.prank(buyer); + vm.expectRevert("Marketplace: no bids were made."); + EnglishAuctionsLogic(marketplace).collectAuctionTokens(auctionId); + } + + modifier whenAuctionHasWinningBid() { + vm.warp(auctionParams.startTimestamp + 1); + + // Bid in auction + vm.prank(buyer); + EnglishAuctionsLogic(marketplace).bidInAuction(auctionId, auctionParams.minimumBidAmount); + _; + } + + function test_collectAuctionTokens_whenAuctionTokensAlreadyPaidOut() + public + whenAuctionNotCancelled + whenAuctionHasWinningBid + whenAuctionHasEnded + { + vm.prank(buyer); + EnglishAuctionsLogic(marketplace).collectAuctionTokens(auctionId); + + vm.prank(buyer); + vm.expectRevert("Marketplace: payout already completed."); + EnglishAuctionsLogic(marketplace).collectAuctionTokens(auctionId); + } + + modifier whenAuctionTokensNotPaidOut() { + _; + } + + function test_collectAuctionTokens_whenAuctionTokensNotYetPaidOut() + public + whenAuctionNotCancelled + whenAuctionHasWinningBid + whenAuctionHasEnded + whenAuctionTokensNotPaidOut + { + assertEq( + uint256(EnglishAuctionsLogic(marketplace).getAuction(auctionId).status), + uint256(IEnglishAuctions.Status.CREATED) + ); + + assertEq(erc1155.balanceOf(address(marketplace), auctionParams.tokenId), 1); + assertEq(erc1155.balanceOf(buyer, auctionParams.tokenId), 0); + + vm.prank(buyer); + vm.expectEmit(true, true, true, true); + emit AuctionClosed(auctionId, address(erc1155), buyer, auctionParams.tokenId, seller, buyer); + EnglishAuctionsLogic(marketplace).collectAuctionTokens(auctionId); + + assertEq( + uint256(EnglishAuctionsLogic(marketplace).getAuction(auctionId).status), + uint256(IEnglishAuctions.Status.COMPLETED) + ); + assertEq(EnglishAuctionsLogic(marketplace).getAuction(auctionId).endTimestamp, uint64(block.timestamp)); + + assertEq(erc1155.balanceOf(address(marketplace), auctionParams.tokenId), 0); + assertEq(erc1155.balanceOf(buyer, auctionParams.tokenId), 1); + } +} diff --git a/src/test/marketplace/english-auctions/collectAuctionTokens/collectAuctionTokens.tree b/src/test/marketplace/english-auctions/collectAuctionTokens/collectAuctionTokens.tree new file mode 100644 index 000000000..d54bd1ba3 --- /dev/null +++ b/src/test/marketplace/english-auctions/collectAuctionTokens/collectAuctionTokens.tree @@ -0,0 +1,18 @@ +function collectAuctionTokens(uint256 _auctionId) external +. +├── when the auction cancelled +│ └── it reverts ✅ +└── when the auction is not cancelled + ├── when the auction is still active + │ └── it should reverts ✅ + └── when the auction is not active + ├── when the auction has no wining bid + │ └── it should reverts ✅ + └── when the auction has a wining bid + ├── when auction bidder has already been paid out tokens + │ └── it should reverts ✅ + └── when auction creator has not been paid out tokens ✅ + ├── it should set auction timestamp to block timestamp + ├── it should set auction state to completed + ├── it should transfer auction tokens to bidder + └── it should emit AuctionClosed event with auction ID, asset contract, caller, tokenId, creator, bidder \ No newline at end of file diff --git a/src/test/marketplace/english-auctions/createAuction/createAuction.t.sol b/src/test/marketplace/english-auctions/createAuction/createAuction.t.sol new file mode 100644 index 000000000..8bc759b31 --- /dev/null +++ b/src/test/marketplace/english-auctions/createAuction/createAuction.t.sol @@ -0,0 +1,318 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +// Test helper imports +import "../../../utils/BaseTest.sol"; + +// Test contracts and interfaces +import { RoyaltyPaymentsLogic } from "contracts/extension/plugin/RoyaltyPayments.sol"; +import { MarketplaceV3, IPlatformFee } from "contracts/prebuilts/marketplace/entrypoint/MarketplaceV3.sol"; +import { EnglishAuctionsLogic } from "contracts/prebuilts/marketplace/english-auctions/EnglishAuctionsLogic.sol"; +import { TWProxy } from "contracts/infra/TWProxy.sol"; +import { ERC721Base } from "contracts/base/ERC721Base.sol"; + +import { IEnglishAuctions } from "contracts/prebuilts/marketplace/IMarketplace.sol"; + +import "@thirdweb-dev/dynamic-contracts/src/interface/IExtension.sol"; + +contract InvalidToken { + function supportsInterface(bytes4) public pure returns (bool) { + return false; + } +} + +contract CreateAuctionTest is BaseTest, IExtension { + // Target contract + address public marketplace; + + // Participants + address public marketplaceDeployer; + address public seller; + address public buyer; + + // Auction parameters + IEnglishAuctions.AuctionParameters internal auctionParams; + + // Events + /// @dev Emitted when a new auction is created. + event NewAuction( + address indexed auctionCreator, + uint256 indexed auctionId, + address indexed assetContract, + IEnglishAuctions.Auction auction + ); + + function setUp() public override { + super.setUp(); + + marketplaceDeployer = getActor(1); + seller = getActor(2); + buyer = getActor(3); + + // Deploy implementation. + Extension[] memory extensions = _setupExtensions(); + address impl = address( + new MarketplaceV3(MarketplaceV3.MarketplaceConstructorParams(extensions, address(0), address(weth))) + ); + + vm.prank(marketplaceDeployer); + marketplace = address( + new TWProxy( + impl, + abi.encodeCall( + MarketplaceV3.initialize, + (marketplaceDeployer, "", new address[](0), marketplaceDeployer, 0) + ) + ) + ); + + // Setup roles for seller and assets + vm.startPrank(marketplaceDeployer); + Permissions(marketplace).revokeRole(keccak256("ASSET_ROLE"), address(0)); + Permissions(marketplace).revokeRole(keccak256("LISTER_ROLE"), address(0)); + + vm.stopPrank(); + + vm.label(impl, "MarketplaceV3_Impl"); + vm.label(marketplace, "Marketplace"); + vm.label(seller, "Seller"); + vm.label(buyer, "Buyer"); + vm.label(address(erc721), "ERC721_Token"); + vm.label(address(erc1155), "ERC1155_Token"); + + // Sample auction parameters. + address assetContract = address(erc721); + uint256 tokenId = 0; + uint256 quantity = 1; + address currency = address(erc20); + uint256 minimumBidAmount = 1 ether; + uint256 buyoutBidAmount = 10 ether; + uint64 timeBufferInSeconds = 10 seconds; + uint64 bidBufferBps = 1000; + uint64 startTimestamp = 100; + uint64 endTimestamp = 200; + + // Auction tokens. + auctionParams = IEnglishAuctions.AuctionParameters( + assetContract, + tokenId, + quantity, + currency, + minimumBidAmount, + buyoutBidAmount, + timeBufferInSeconds, + bidBufferBps, + startTimestamp, + endTimestamp + ); + + // Mint NFT to seller. + erc721.mint(seller, 1); // to, amount + erc1155.mint(seller, 0, 100); // to, id, amount + } + + function _setupExtensions() internal returns (Extension[] memory extensions) { + extensions = new Extension[](1); + + // Deploy `EnglishAuctions` + address englishAuctions = address(new EnglishAuctionsLogic(address(weth))); + vm.label(englishAuctions, "EnglishAuctions_Extension"); + + // Extension: EnglishAuctionsLogic + Extension memory extension_englishAuctions; + extension_englishAuctions.metadata = ExtensionMetadata({ + name: "EnglishAuctionsLogic", + metadataURI: "ipfs://EnglishAuctions", + implementation: englishAuctions + }); + + extension_englishAuctions.functions = new ExtensionFunction[](12); + extension_englishAuctions.functions[0] = ExtensionFunction( + EnglishAuctionsLogic.totalAuctions.selector, + "totalAuctions()" + ); + extension_englishAuctions.functions[1] = ExtensionFunction( + EnglishAuctionsLogic.createAuction.selector, + "createAuction((address,uint256,uint256,address,uint256,uint256,uint64,uint64,uint64,uint64))" + ); + extension_englishAuctions.functions[2] = ExtensionFunction( + EnglishAuctionsLogic.cancelAuction.selector, + "cancelAuction(uint256)" + ); + extension_englishAuctions.functions[3] = ExtensionFunction( + EnglishAuctionsLogic.collectAuctionPayout.selector, + "collectAuctionPayout(uint256)" + ); + extension_englishAuctions.functions[4] = ExtensionFunction( + EnglishAuctionsLogic.collectAuctionTokens.selector, + "collectAuctionTokens(uint256)" + ); + extension_englishAuctions.functions[5] = ExtensionFunction( + EnglishAuctionsLogic.bidInAuction.selector, + "bidInAuction(uint256,uint256)" + ); + extension_englishAuctions.functions[6] = ExtensionFunction( + EnglishAuctionsLogic.isNewWinningBid.selector, + "isNewWinningBid(uint256,uint256)" + ); + extension_englishAuctions.functions[7] = ExtensionFunction( + EnglishAuctionsLogic.getAuction.selector, + "getAuction(uint256)" + ); + extension_englishAuctions.functions[8] = ExtensionFunction( + EnglishAuctionsLogic.getAllAuctions.selector, + "getAllAuctions(uint256,uint256)" + ); + extension_englishAuctions.functions[9] = ExtensionFunction( + EnglishAuctionsLogic.getAllValidAuctions.selector, + "getAllValidAuctions(uint256,uint256)" + ); + extension_englishAuctions.functions[10] = ExtensionFunction( + EnglishAuctionsLogic.getWinningBid.selector, + "getWinningBid(uint256)" + ); + extension_englishAuctions.functions[11] = ExtensionFunction( + EnglishAuctionsLogic.isAuctionExpired.selector, + "isAuctionExpired(uint256)" + ); + + extensions[0] = extension_englishAuctions; + } + + function test_createAuction_whenCallerDoesntHaveListerRole() public { + assertEq(Permissions(marketplace).hasRole(keccak256("LISTER_ROLE"), seller), false); + + vm.prank(seller); + vm.expectRevert("!LISTER_ROLE"); + EnglishAuctionsLogic(marketplace).createAuction(auctionParams); + } + + modifier whenCallerHasListerRole() { + vm.prank(marketplaceDeployer); + Permissions(marketplace).grantRole(keccak256("LISTER_ROLE"), seller); + _; + } + + function test_createAuction_whenAssetDoesnHaveAssetRole() public whenCallerHasListerRole { + assertEq(Permissions(marketplace).hasRole(keccak256("ASSET_ROLE"), address(erc721)), false); + + vm.prank(seller); + vm.expectRevert("!ASSET_ROLE"); + EnglishAuctionsLogic(marketplace).createAuction(auctionParams); + } + + modifier whenAssetHasAssetRole() { + vm.prank(marketplaceDeployer); + Permissions(marketplace).grantRole(keccak256("ASSET_ROLE"), address(erc721)); + _; + } + + function test_createAuction_whenTokenIsInvalid() public whenCallerHasListerRole whenAssetHasAssetRole { + address newToken = address(new InvalidToken()); + + vm.prank(marketplaceDeployer); + Permissions(marketplace).grantRole(keccak256("ASSET_ROLE"), newToken); + + auctionParams.assetContract = newToken; + + vm.prank(seller); + vm.expectRevert("Marketplace: auctioned token must be ERC1155 or ERC721."); + EnglishAuctionsLogic(marketplace).createAuction(auctionParams); + } + + modifier whenTokenIsValid() { + _; + } + + function test_createAuction_whenAuctionParamsAreInvalid() + public + whenCallerHasListerRole + whenAssetHasAssetRole + whenTokenIsValid + { + // This is one way for params to be invalid. `_validateNewAuction` has its own tests. + auctionParams.quantity = 0; + + vm.prank(seller); + vm.expectRevert("Marketplace: auctioning zero quantity."); + EnglishAuctionsLogic(marketplace).createAuction(auctionParams); + } + + modifier whenAuctionParamsAreValid() { + _; + } + + function test_createAuction_whenAuctionParamsAreValid() + public + whenCallerHasListerRole + whenAssetHasAssetRole + whenTokenIsValid + whenAuctionParamsAreValid + { + uint256 expectedAuctionId = 0; + + assertEq(EnglishAuctionsLogic(marketplace).totalAuctions(), 0); + assertEq(erc721.ownerOf(0), seller); + assertEq(EnglishAuctionsLogic(marketplace).getAuction(expectedAuctionId).assetContract, address(0)); + + vm.prank(seller); + erc721.setApprovalForAll(marketplace, true); + + IEnglishAuctions.Auction memory dummyAuction; + + vm.prank(seller); + vm.expectEmit(true, true, true, false); + emit NewAuction(seller, expectedAuctionId, auctionParams.assetContract, dummyAuction); + EnglishAuctionsLogic(marketplace).createAuction(auctionParams); + + assertEq(EnglishAuctionsLogic(marketplace).totalAuctions(), 1); + assertEq(erc721.ownerOf(0), marketplace); + assertEq(EnglishAuctionsLogic(marketplace).getAllAuctions(0, 0).length, 1); + + assertEq(EnglishAuctionsLogic(marketplace).getAllValidAuctions(0, 0).length, 0); + vm.warp(auctionParams.startTimestamp); + assertEq(EnglishAuctionsLogic(marketplace).getAllValidAuctions(0, 0).length, 1); + + assertEq(EnglishAuctionsLogic(marketplace).getAllAuctions(0, 0).length, 1); + assertEq(EnglishAuctionsLogic(marketplace).getAuction(expectedAuctionId).auctionId, expectedAuctionId); + assertEq( + EnglishAuctionsLogic(marketplace).getAuction(expectedAuctionId).assetContract, + auctionParams.assetContract + ); + assertEq(EnglishAuctionsLogic(marketplace).getAuction(expectedAuctionId).tokenId, auctionParams.tokenId); + assertEq( + EnglishAuctionsLogic(marketplace).getAuction(expectedAuctionId).minimumBidAmount, + auctionParams.minimumBidAmount + ); + assertEq( + EnglishAuctionsLogic(marketplace).getAuction(expectedAuctionId).buyoutBidAmount, + auctionParams.buyoutBidAmount + ); + assertEq( + EnglishAuctionsLogic(marketplace).getAuction(expectedAuctionId).timeBufferInSeconds, + auctionParams.timeBufferInSeconds + ); + assertEq( + EnglishAuctionsLogic(marketplace).getAuction(expectedAuctionId).bidBufferBps, + auctionParams.bidBufferBps + ); + assertEq( + EnglishAuctionsLogic(marketplace).getAuction(expectedAuctionId).startTimestamp, + auctionParams.startTimestamp + ); + assertEq( + EnglishAuctionsLogic(marketplace).getAuction(expectedAuctionId).endTimestamp, + auctionParams.endTimestamp + ); + assertEq(EnglishAuctionsLogic(marketplace).getAuction(expectedAuctionId).auctionCreator, seller); + assertEq(EnglishAuctionsLogic(marketplace).getAuction(expectedAuctionId).currency, auctionParams.currency); + assertEq( + uint256(EnglishAuctionsLogic(marketplace).getAuction(expectedAuctionId).tokenType), + uint256(IEnglishAuctions.TokenType.ERC721) + ); + assertEq( + uint256(EnglishAuctionsLogic(marketplace).getAuction(expectedAuctionId).status), + uint256(IEnglishAuctions.Status.CREATED) + ); + } +} diff --git a/src/test/marketplace/english-auctions/createAuction/createAuction.tree b/src/test/marketplace/english-auctions/createAuction/createAuction.tree new file mode 100644 index 000000000..dcfa71e4c --- /dev/null +++ b/src/test/marketplace/english-auctions/createAuction/createAuction.tree @@ -0,0 +1,13 @@ +function createAuction(AuctionParameters calldata _params) +├── when the caller does not have LISTER_ROLE +│ └── it should revert ✅ +└── when the caller has LISTER_ROLE + ├── when the asset does not have ASSET_ROLE + │ └── it should revert ✅ + └── when the asset has ASSET_ROLE + ├── when the auction params are invalid + │ └── it should revert ✅ + └── when the auction params are valid ✅ + ├── it should create the intended auction + ├── it should escrow asset to auction + └── it should emit an AuctionCreated event with auction creator, auction ID, asset contract, auction data \ No newline at end of file diff --git a/src/test/minimal-factory/MinimalFactory.t.sol b/src/test/minimal-factory/MinimalFactory.t.sol new file mode 100644 index 000000000..a819ee55b --- /dev/null +++ b/src/test/minimal-factory/MinimalFactory.t.sol @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "contracts/infra/TWMinimalFactory.sol"; +import "contracts/infra/TWProxy.sol"; + +import "@openzeppelin/contracts/proxy/Clones.sol"; + +import "../utils/BaseTest.sol"; + +contract DummyUpgradeable { + uint256 public number; + + constructor() {} + + function initialize(uint256 _num) public { + number = _num; + } +} + +contract TWNotMinimalFactory { + /// @dev Deploys a proxy that points to the given implementation. + function deployProxyByImplementation(address _implementation, bytes memory _data, bytes32 _salt) public { + address deployedProxy = Clones.cloneDeterministic(_implementation, _salt); + + if (_data.length > 0) { + // slither-disable-next-line unused-return + Address.functionCall(deployedProxy, _data); + } + } +} + +contract MinimalFactoryTest is BaseTest { + address internal implementation; + bytes32 internal salt; + bytes internal data; + address admin; + + TWNotMinimalFactory notMinimal; + + function setUp() public override { + super.setUp(); + admin = getActor(5000); + vm.startPrank(admin); + implementation = getContract("TokenERC20"); + salt = keccak256("yooo"); + data = abi.encodeWithSelector( + TokenERC20.initialize.selector, + admin, + "MinimalToken", + "MT", + "ipfs://notCentralized", + new address[](0), + admin, + admin, + 50 + ); + + notMinimal = new TWNotMinimalFactory(); + } + + // gas: Baseline + 140k + function test_gas_twProxy() public { + new TWProxy(implementation, data); + } + + // gas: Baseline + 41.5k + function test_gas_notMinimalFactory() public { + notMinimal.deployProxyByImplementation(implementation, data, salt); + } + + // gas: Baseline + function test_gas_minimal() public { + new TWMinimalFactory(implementation, data, salt); + } + + function test_verify_deployedProxy() public { + vm.stopPrank(); + vm.prank(address(0x123456)); + address minimalFactory = address(new TWMinimalFactory(implementation, data, salt)); + bytes32 salthash = keccak256(abi.encodePacked(address(0x123456), salt)); + address deployedProxy = Clones.predictDeterministicAddress(implementation, salthash, minimalFactory); + + bytes32 contractType = TokenERC20(deployedProxy).contractType(); + assertEq(contractType, bytes32("TokenERC20")); + } +} diff --git a/src/test/mocks/Mock.sol b/src/test/mocks/Mock.sol new file mode 100644 index 000000000..6948d05fb --- /dev/null +++ b/src/test/mocks/Mock.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/// @author thirdweb + +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "contracts/eip/interface/IERC721.sol"; +import "@openzeppelin/contracts/token/ERC1155/IERC1155.sol"; + +/* + * @dev Mock contract for typechain types generation purposes :) + */ +contract Mock { + IERC20 public erc20; + IERC721 public erc721; + IERC1155 public erc1155; +} + +contract MockContract { + bytes32 private name; + uint8 private version; + + constructor(bytes32 _name, uint8 _version) { + name = _name; + version = _version; + } + + /// @dev Returns the module type of the contract. + function contractType() external view returns (bytes32) { + return name; + } + + /// @dev Returns the version of the contract. + function contractVersion() external view returns (uint8) { + return version; + } +} diff --git a/src/test/mocks/MockContractPublisher.sol b/src/test/mocks/MockContractPublisher.sol new file mode 100644 index 000000000..3ce6a09f4 --- /dev/null +++ b/src/test/mocks/MockContractPublisher.sol @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +/// @author thirdweb + +import "contracts/infra/interface/IContractPublisher.sol"; + +// solhint-disable const-name-snakecase +contract MockContractPublisher is IContractPublisher { + function getAllPublishedContracts( + address + ) external pure override returns (CustomContractInstance[] memory published) { + CustomContractInstance[] memory mocks = new CustomContractInstance[](1); + mocks[0] = CustomContractInstance( + "MockContract", + 123, + "ipfs://mock", + 0x0000000000000000000000000000000000000000000000000000000000000001, + address(0x0000000000000000000000000000000000000000) + ); + return mocks; + } + + function getPublishedContractVersions( + address, + string memory + ) external pure returns (CustomContractInstance[] memory published) { + return new CustomContractInstance[](0); + } + + function getPublishedContract( + address, + string memory + ) external pure returns (CustomContractInstance memory published) { + return CustomContractInstance("", 0, "", "", address(0)); + } + + function publishContract( + address publisher, + string memory contractId, + string memory publishMetadataUri, + string memory compilerMetadataUri, + bytes32 bytecodeHash, + address implementation + ) external {} + + function unpublishContract(address publisher, string memory contractId) external {} + + function setPublisherProfileUri(address, string memory) external {} + + function getPublisherProfileUri(address) external pure returns (string memory uri) { + return ""; + } + + function getPublishedUriFromCompilerUri( + string memory + ) external pure returns (string[] memory publishedMetadataUris) { + return new string[](0); + } +} diff --git a/src/test/mocks/MockERC1155.sol b/src/test/mocks/MockERC1155.sol new file mode 100644 index 000000000..c1af0dc5a --- /dev/null +++ b/src/test/mocks/MockERC1155.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "@openzeppelin/contracts/token/ERC1155/presets/ERC1155PresetMinterPauser.sol"; + +contract MockERC1155 is ERC1155PresetMinterPauser { + constructor() ERC1155PresetMinterPauser("ipfs://BaseURI") {} + + function mint(address to, uint256 id, uint256 amount) public virtual { + _mint(to, id, amount, ""); + } + + function hasRole(bytes32, address) public pure override(AccessControl, IAccessControl) returns (bool) { + return true; + } + + function mintBatch(address to, uint256[] memory ids, uint256[] memory amounts) public virtual { + require(hasRole(MINTER_ROLE, _msgSender()), "ERC1155PresetMinterPauser: must have minter role to mint"); + + _mintBatch(to, ids, amounts, ""); + } + + function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) { + return super.supportsInterface(interfaceId); + } +} diff --git a/src/test/mocks/MockERC1155NonBurnable.sol b/src/test/mocks/MockERC1155NonBurnable.sol new file mode 100644 index 000000000..dff355c7f --- /dev/null +++ b/src/test/mocks/MockERC1155NonBurnable.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "@openzeppelin/contracts/token/ERC1155/ERC1155.sol"; + +contract MockERC1155NonBurnable is ERC1155 { + constructor() ERC1155("ipfs://BaseURI") {} + + function mint(address to, uint256 id, uint256 amount) public virtual { + _mint(to, id, amount, ""); + } + + function mintBatch(address to, uint256[] memory ids, uint256[] memory amounts) public virtual { + _mintBatch(to, ids, amounts, ""); + } + + function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) { + return super.supportsInterface(interfaceId); + } +} diff --git a/src/test/mocks/MockERC20.sol b/src/test/mocks/MockERC20.sol new file mode 100644 index 000000000..6b3a5bb8a --- /dev/null +++ b/src/test/mocks/MockERC20.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/presets/ERC20PresetMinterPauser.sol"; +import "@openzeppelin/contracts/token/ERC20/extensions/draft-ERC20Permit.sol"; + +contract MockERC20 is ERC20PresetMinterPauser, ERC20Permit { + bool internal taxActive; + + constructor() ERC20PresetMinterPauser("Mock Coin", "MOCK") ERC20Permit("Mock Coin") {} + + function mint(address to, uint256 amount) public override(ERC20PresetMinterPauser) { + _mint(to, amount); + } + + function toggleTax() external { + taxActive = !taxActive; + } + + function _transfer(address from, address to, uint256 amount) internal override { + if (taxActive) { + uint256 tax = (amount * 10) / 100; + amount -= tax; + super._transfer(from, address(this), tax); + } + super._transfer(from, to, amount); + } + + function _beforeTokenTransfer( + address from, + address to, + uint256 amount + ) internal override(ERC20PresetMinterPauser, ERC20) { + super._beforeTokenTransfer(from, to, amount); + } +} diff --git a/src/test/mocks/MockERC20NonCompliant.sol b/src/test/mocks/MockERC20NonCompliant.sol new file mode 100644 index 000000000..0e938b1de --- /dev/null +++ b/src/test/mocks/MockERC20NonCompliant.sol @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +contract MockERC20NonCompliant { + mapping(address => uint256) private _balances; + mapping(address => mapping(address => uint256)) private _allowances; + uint256 private _totalSupply; + + constructor() {} + + function decimals() public view virtual returns (uint8) { + return 18; + } + + function totalSupply() public view virtual returns (uint256) { + return _totalSupply; + } + + function balanceOf(address account) public view virtual returns (uint256) { + return _balances[account]; + } + + function allowance(address owner, address spender) public view virtual returns (uint256) { + return _allowances[owner][spender]; + } + + function approve(address spender, uint256 amount) public virtual returns (bool) { + address owner = msg.sender; + _approve(owner, spender, amount); + return true; + } + + // non-compliant ERC20 as transfer doesn't return a bool + function transfer(address to, uint256 amount) public virtual { + address owner = msg.sender; + _transfer(owner, to, amount); + } + + // non-compliant ERC20 as transferFrom doesn't return a bool + function transferFrom(address from, address to, uint256 amount) public { + address spender = msg.sender; + _spendAllowance(from, spender, amount); + _transfer(from, to, amount); + } + + function _transfer(address from, address to, uint256 amount) internal virtual { + require(from != address(0), "ERC20: transfer from the zero address"); + require(to != address(0), "ERC20: transfer to the zero address"); + + uint256 fromBalance = _balances[from]; + require(fromBalance >= amount, "ERC20: transfer amount exceeds balance"); + unchecked { + _balances[from] = fromBalance - amount; + } + _balances[to] += amount; + } + + function _mint(address account, uint256 amount) internal virtual { + require(account != address(0), "ERC20: mint to the zero address"); + + _totalSupply += amount; + unchecked { + // Overflow not possible: balance + amount is at most totalSupply + amount, which is checked above. + _balances[account] += amount; + } + } + + function mint(address to, uint256 amount) public { + _mint(to, amount); + } + + function _approve(address owner, address spender, uint256 amount) internal virtual { + require(owner != address(0), "ERC20: approve from the zero address"); + require(spender != address(0), "ERC20: approve to the zero address"); + + _allowances[owner][spender] = amount; + } + + function _spendAllowance(address owner, address spender, uint256 amount) internal virtual { + uint256 currentAllowance = allowance(owner, spender); + if (currentAllowance != type(uint256).max) { + require(currentAllowance >= amount, "ERC20: insufficient allowance"); + unchecked { + _approve(owner, spender, currentAllowance - amount); + } + } + } +} diff --git a/src/test/mocks/MockERC721.sol b/src/test/mocks/MockERC721.sol new file mode 100644 index 000000000..68cf3d14c --- /dev/null +++ b/src/test/mocks/MockERC721.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Burnable.sol"; + +contract MockERC721 is ERC721Burnable { + uint256 public nextTokenIdToMint; + + constructor() ERC721("MockERC721", "MOCK") {} + + function mint(address _receiver, uint256 _amount) external { + uint256 tokenId = nextTokenIdToMint; + nextTokenIdToMint += _amount; + + for (uint256 i = 0; i < _amount; i += 1) { + _mint(_receiver, tokenId); + tokenId += 1; + } + } + + function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) { + return super.supportsInterface(interfaceId); + } +} diff --git a/src/test/mocks/MockERC721NonBurnable.sol b/src/test/mocks/MockERC721NonBurnable.sol new file mode 100644 index 000000000..4668d2e75 --- /dev/null +++ b/src/test/mocks/MockERC721NonBurnable.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "@openzeppelin/contracts/token/ERC721/ERC721.sol"; + +contract MockERC721NonBurnable is ERC721 { + uint256 public nextTokenIdToMint; + + constructor() ERC721("MockERC721", "MOCK") {} + + function mint(address _receiver, uint256 _amount) external { + uint256 tokenId = nextTokenIdToMint; + nextTokenIdToMint += _amount; + + for (uint256 i = 0; i < _amount; i += 1) { + _mint(_receiver, tokenId); + tokenId += 1; + } + } + + function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) { + return super.supportsInterface(interfaceId); + } +} diff --git a/src/test/mocks/MockRoyaltyEngineV1.sol b/src/test/mocks/MockRoyaltyEngineV1.sol new file mode 100644 index 000000000..429c86f41 --- /dev/null +++ b/src/test/mocks/MockRoyaltyEngineV1.sol @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "contracts/extension/interface/IRoyaltyEngineV1.sol"; +import { IERC2981 } from "contracts/eip/interface/IERC2981.sol"; +import { ERC165 } from "contracts/eip/ERC165.sol"; + +contract MockRoyaltyEngineV1 is ERC165, IRoyaltyEngineV1 { + address payable[] public mockRecipients; + uint256[] public mockAmounts; + + constructor(address payable[] memory _mockRecipients, uint256[] memory _mockAmounts) { + mockRecipients = _mockRecipients; + mockAmounts = _mockAmounts; + } + + function supportsInterface(bytes4 interfaceId) public view virtual override(ERC165, IERC165) returns (bool) { + return interfaceId == type(IRoyaltyEngineV1).interfaceId || super.supportsInterface(interfaceId); + } + + function getRoyalty( + address tokenAddress, + uint256 tokenId, + uint256 value + ) public view override returns (address payable[] memory recipients, uint256[] memory amounts) { + try IERC2981(tokenAddress).royaltyInfo(tokenId, value) returns (address recipient, uint256 amount) { + // Supports EIP2981. Return amounts + recipients = new address payable[](1); + amounts = new uint256[](1); + recipients[0] = payable(recipient); + amounts[0] = amount; + return (recipients, amounts); + } catch {} + + // Non ERC2981. Return mock recipients/amounts. + recipients = mockRecipients; + amounts = mockAmounts; + return (recipients, amounts); + } + + function getRoyaltyView( + address tokenAddress, + uint256 tokenId, + uint256 value + ) public view override returns (address payable[] memory recipients, uint256[] memory amounts) {} +} diff --git a/src/test/mocks/MockThirdwebContract.sol b/src/test/mocks/MockThirdwebContract.sol new file mode 100644 index 000000000..5cf31f1a7 --- /dev/null +++ b/src/test/mocks/MockThirdwebContract.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "contracts/infra/interface/IThirdwebContract.sol"; + +// solhint-disable const-name-snakecase +contract MockThirdwebContract is IThirdwebContract { + string public contractURI; + bytes32 public constant contractType = bytes32("MOCK"); + uint8 public constant contractVersion = 1; + + function setContractURI(string calldata _uri) external { + contractURI = _uri; + } +} + +contract MockThirdwebContractV2 is IThirdwebContract { + string public contractURI; + bytes32 public constant contractType = bytes32("MOCK"); + uint8 public constant contractVersion = 2; + + function setContractURI(string calldata _uri) external { + contractURI = _uri; + } +} diff --git a/src/test/mocks/WETH9.sol b/src/test/mocks/WETH9.sol new file mode 100644 index 000000000..357229903 --- /dev/null +++ b/src/test/mocks/WETH9.sol @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +contract WETH9 is ERC20 { + event Deposit(address indexed sender, uint256 amount); + event Withdrawal(address indexed sender, uint256 amount); + + constructor() ERC20("Wrapped Ether", "WETH") {} + + receive() external payable virtual { + deposit(); + } + + function deposit() public payable { + _mint(msg.sender, msg.value); + emit Deposit(msg.sender, msg.value); + } + + function withdraw(uint256 amount) public { + _burn(msg.sender, amount); + payable(msg.sender).transfer(amount); + emit Withdrawal(msg.sender, amount); + } + + function totalSupply() public view override returns (uint256) { + return address(this).balance; + } +} diff --git a/src/test/open-edition-flat-fee/_beforeTokenTransfers/_beforeTokenTransfers.t.sol b/src/test/open-edition-flat-fee/_beforeTokenTransfers/_beforeTokenTransfers.t.sol new file mode 100644 index 000000000..4ea304d13 --- /dev/null +++ b/src/test/open-edition-flat-fee/_beforeTokenTransfers/_beforeTokenTransfers.t.sol @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { OpenEditionERC721FlatFee } from "contracts/prebuilts/open-edition/OpenEditionERC721FlatFee.sol"; +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +// Test imports +import "src/test/utils/BaseTest.sol"; + +contract OpenEditionERC721FlatFeeHarness is OpenEditionERC721FlatFee { + function beforeTokenTransfers(address from, address to, uint256 startTokenId_, uint256 quantity) public { + _beforeTokenTransfers(from, to, startTokenId_, quantity); + } +} + +contract OpenEditionERC721FlatFeeTest_beforeTokenTransfers is BaseTest { + OpenEditionERC721FlatFeeHarness public openEdition; + + address private openEditionImpl; + + function setUp() public override { + super.setUp(); + openEditionImpl = address(new OpenEditionERC721FlatFeeHarness()); + vm.prank(deployer); + openEdition = OpenEditionERC721FlatFeeHarness( + address( + new TWProxy( + openEditionImpl, + abi.encodeCall( + OpenEditionERC721FlatFee.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ) + ) + ); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: misc + //////////////////////////////////////////////////////////////*/ + + function test_revert_transfersRestricted() public { + address from = address(0x1); + address to = address(0x2); + bytes32 role = keccak256("TRANSFER_ROLE"); + vm.prank(deployer); + openEdition.revokeRole(role, address(0)); + + vm.expectRevert(bytes("!T")); + openEdition.beforeTokenTransfers(from, to, 0, 1); + } +} diff --git a/src/test/open-edition-flat-fee/_beforeTokenTransfers/_beforeTokenTransfers.tree b/src/test/open-edition-flat-fee/_beforeTokenTransfers/_beforeTokenTransfers.tree new file mode 100644 index 000000000..078bd6a79 --- /dev/null +++ b/src/test/open-edition-flat-fee/_beforeTokenTransfers/_beforeTokenTransfers.tree @@ -0,0 +1,12 @@ +function _beforeTokenTransfers( + address from, + address to, + uint256 startTokenId_, + uint256 quantity +) +└── when address(0) does not have the transfer role + └── when from does not equal address(0) + └── when to does not equal address(0) + └── when from does not have the transfer role + └── when to does not have the transfer role + └── it should revert ✅ diff --git a/src/test/open-edition-flat-fee/_canSetFunctions/_canSetFunctions.t.sol b/src/test/open-edition-flat-fee/_canSetFunctions/_canSetFunctions.t.sol new file mode 100644 index 000000000..c45ca2514 --- /dev/null +++ b/src/test/open-edition-flat-fee/_canSetFunctions/_canSetFunctions.t.sol @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { OpenEditionERC721FlatFee } from "contracts/prebuilts/open-edition/OpenEditionERC721FlatFee.sol"; +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +// Test imports +import "src/test/utils/BaseTest.sol"; + +contract OpenEditionERC721FlatFeeHarness is OpenEditionERC721FlatFee { + function canSetPrimarySaleRecipient() external view returns (bool) { + return _canSetPrimarySaleRecipient(); + } + + function canSetOwner() external view returns (bool) { + return _canSetOwner(); + } + + /// @dev Checks whether royalty info can be set in the given execution context. + function canSetRoyaltyInfo() external view returns (bool) { + return _canSetRoyaltyInfo(); + } + + /// @dev Checks whether contract metadata can be set in the given execution context. + function canSetContractURI() external view returns (bool) { + return _canSetContractURI(); + } + + /// @dev Checks whether platform fee info can be set in the given execution context. + function canSetClaimConditions() external view returns (bool) { + return _canSetClaimConditions(); + } + + /// @dev Returns whether the shared metadata of tokens can be set in the given execution context. + function canSetSharedMetadata() external view virtual returns (bool) { + return _canSetSharedMetadata(); + } +} + +contract OpenEditionERC721FlatFeeTest_canSetFunctions is BaseTest { + OpenEditionERC721FlatFeeHarness public openEdition; + + address private openEditionImpl; + + function setUp() public override { + super.setUp(); + openEditionImpl = address(new OpenEditionERC721FlatFeeHarness()); + vm.prank(deployer); + openEdition = OpenEditionERC721FlatFeeHarness( + address( + new TWProxy( + openEditionImpl, + abi.encodeCall( + OpenEditionERC721FlatFee.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ) + ) + ); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: misc + //////////////////////////////////////////////////////////////*/ + + function test_canSetPrimarySaleRecipient_returnTrue() public { + vm.prank(deployer); + assertTrue(openEdition.canSetPrimarySaleRecipient()); + } + + function test_canSetPrimarySaleRecipient_returnFalse() public { + assertFalse(openEdition.canSetPrimarySaleRecipient()); + } + + function test_canSetOwner_returnTrue() public { + vm.prank(deployer); + assertTrue(openEdition.canSetOwner()); + } + + function test_canSetOwner_returnFalse() public { + assertFalse(openEdition.canSetOwner()); + } + + function test_canSetRoyaltyInfo_returnTrue() public { + vm.prank(deployer); + assertTrue(openEdition.canSetRoyaltyInfo()); + } + + function test_canSetRoyaltyInfo_returnFalse() public { + assertFalse(openEdition.canSetRoyaltyInfo()); + } + + function test_canSetContractURI_returnTrue() public { + vm.prank(deployer); + assertTrue(openEdition.canSetContractURI()); + } + + function test_canSetContractURI_returnFalse() public { + assertFalse(openEdition.canSetContractURI()); + } + + function test_canSetClaimConditions_returnTrue() public { + vm.prank(deployer); + assertTrue(openEdition.canSetClaimConditions()); + } + + function test_canSetClaimConditions_returnFalse() public { + assertFalse(openEdition.canSetClaimConditions()); + } + + function test_canSetSharedMetadata_returnTrue() public { + vm.prank(deployer); + assertTrue(openEdition.canSetSharedMetadata()); + } + + function test_canSetSharedMetadata_returnFalse() public { + assertFalse(openEdition.canSetSharedMetadata()); + } +} diff --git a/src/test/open-edition-flat-fee/_canSetFunctions/_canSetFunctions.tree b/src/test/open-edition-flat-fee/_canSetFunctions/_canSetFunctions.tree new file mode 100644 index 000000000..1ccb478fd --- /dev/null +++ b/src/test/open-edition-flat-fee/_canSetFunctions/_canSetFunctions.tree @@ -0,0 +1,39 @@ +function _canSetPrimarySaleRecipient() +├── when _msgSender has DEFAULT_ADMIN_ROLE +│ └── it should return true ✅ +└── when _msgSender does not have DEFAULT_ADMIN_ROLE + └── it should return false ✅ + +function _canSetOwner() +├── when _msgSender has DEFAULT_ADMIN_ROLE +│ └── it should return true ✅ +└── when _msgSender does not have DEFAULT_ADMIN_ROLE + └── it should return false ✅ + + +function _canSetRoyaltyInfo() +├── when _msgSender has DEFAULT_ADMIN_ROLE +│ └── it should return true ✅ +└── when _msgSender does not have DEFAULT_ADMIN_ROLE + └── it should return false ✅ + + +function _canSetContractURI() +├── when _msgSender has DEFAULT_ADMIN_ROLE +│ └── it should return true ✅ +└── when _msgSender does not have DEFAULT_ADMIN_ROLE + └── it should return false ✅ + + +function _canSetClaimConditions() +├── when _msgSender has DEFAULT_ADMIN_ROLE +│ └── it should return true ✅ +└── when _msgSender does not have DEFAULT_ADMIN_ROLE + └── it should return false ✅ + + +function _canSetSharedMetadata() +├── when _msgSender has minter role +│ └── it should return true ✅ +└── when _msgSender does not have minter role + └── it should return false ✅ diff --git a/src/test/open-edition-flat-fee/_collectPriceOnClaim/_collectPriceOnClaim.t.sol b/src/test/open-edition-flat-fee/_collectPriceOnClaim/_collectPriceOnClaim.t.sol new file mode 100644 index 000000000..bc165f600 --- /dev/null +++ b/src/test/open-edition-flat-fee/_collectPriceOnClaim/_collectPriceOnClaim.t.sol @@ -0,0 +1,209 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { OpenEditionERC721FlatFee } from "contracts/prebuilts/open-edition/OpenEditionERC721FlatFee.sol"; +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +// Test imports +import "src/test/utils/BaseTest.sol"; + +contract OpenEditionERC721FlatFeeHarness is OpenEditionERC721FlatFee { + function collectPriceOnClaim( + address _primarySaleRecipient, + uint256 _quantityToClaim, + address _currency, + uint256 _pricePerToken + ) external payable { + _collectPriceOnClaim(_primarySaleRecipient, _quantityToClaim, _currency, _pricePerToken); + } +} + +contract OpenEditionERC721FlatFeeTest_collectPrice is BaseTest { + OpenEditionERC721FlatFeeHarness public openEdition; + + address private openEditionImpl; + + address private currency; + address private primarySaleRecipient; + uint256 private msgValue; + uint256 private pricePerToken; + uint256 private qty = 1; + + function setUp() public override { + super.setUp(); + openEditionImpl = address(new OpenEditionERC721FlatFeeHarness()); + vm.prank(deployer); + openEdition = OpenEditionERC721FlatFeeHarness( + address( + new TWProxy( + openEditionImpl, + abi.encodeCall( + OpenEditionERC721FlatFee.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ) + ) + ); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: misc + //////////////////////////////////////////////////////////////*/ + + modifier pricePerTokenZero() { + _; + } + + modifier pricePerTokenNotZero() { + pricePerToken = 1 ether; + _; + } + + modifier msgValueZero() { + _; + } + + modifier msgValueNotZero() { + msgValue = 1 ether; + _; + } + + modifier valuePriceMismatch() { + msgValue = 1 ether; + pricePerToken = 2 ether; + _; + } + + modifier primarySaleRecipientZeroAddress() { + primarySaleRecipient = address(0); + _; + } + + modifier primarySaleRecipientNotZeroAddress() { + primarySaleRecipient = address(0x0999); + _; + } + + modifier currencyNativeToken() { + currency = NATIVE_TOKEN; + _; + } + + modifier currencyNotNativeToken() { + currency = address(erc20); + _; + } + + function test_revert_pricePerTokenZeroMsgValueNotZero() public pricePerTokenZero msgValueNotZero { + vm.expectRevert("!Value"); + openEdition.collectPriceOnClaim{ value: msgValue }(primarySaleRecipient, qty, currency, pricePerToken); + } + + function test_revert_nativeCurrencyValuePriceMismatch() public currencyNativeToken valuePriceMismatch { + vm.expectRevert(bytes("!V")); + openEdition.collectPriceOnClaim{ value: msgValue }(primarySaleRecipient, qty, currency, pricePerToken); + } + + function test_revert_erc20ValuePriceMismatch() public currencyNotNativeToken valuePriceMismatch { + vm.expectRevert(bytes("!V")); + openEdition.collectPriceOnClaim{ value: msgValue }(primarySaleRecipient, qty, currency, pricePerToken); + } + + function test_state_nativeCurrency() + public + currencyNativeToken + pricePerTokenNotZero + msgValueNotZero + primarySaleRecipientNotZeroAddress + { + uint256 beforeBalancePrimarySaleRecipient = address(primarySaleRecipient).balance; + + openEdition.collectPriceOnClaim{ value: msgValue }(primarySaleRecipient, qty, currency, pricePerToken); + + uint256 afterBalancePrimarySaleRecipient = address(primarySaleRecipient).balance; + + uint256 platformFeeVal = (msgValue * platformFeeBps) / 10_000; + uint256 primarySaleRecipientVal = msgValue - platformFeeVal; + + assertEq(beforeBalancePrimarySaleRecipient + primarySaleRecipientVal, afterBalancePrimarySaleRecipient); + } + + function test_revert_erc20_msgValueNotZero() + public + currencyNotNativeToken + msgValueNotZero + primarySaleRecipientNotZeroAddress + { + vm.expectRevert("!Value"); + openEdition.collectPriceOnClaim{ value: msgValue }(primarySaleRecipient, qty, currency, pricePerToken); + } + + function test_state_erc20() public currencyNotNativeToken pricePerTokenNotZero primarySaleRecipientNotZeroAddress { + erc20.mint(address(this), pricePerToken); + ERC20(erc20).approve(address(openEdition), pricePerToken); + uint256 beforeBalancePrimarySaleRecipient = erc20.balanceOf(primarySaleRecipient); + + openEdition.collectPriceOnClaim(primarySaleRecipient, qty, currency, pricePerToken); + + uint256 afterBalancePrimarySaleRecipient = erc20.balanceOf(primarySaleRecipient); + + uint256 platformFeeVal = (1 ether * platformFeeBps) / 10_000; + uint256 primarySaleRecipientVal = 1 ether - platformFeeVal; + + assertEq(beforeBalancePrimarySaleRecipient + primarySaleRecipientVal, afterBalancePrimarySaleRecipient); + } + + function test_state_erc20StoredPrimarySaleRecipient() + public + currencyNotNativeToken + pricePerTokenNotZero + primarySaleRecipientZeroAddress + { + address storedPrimarySaleRecipient = openEdition.primarySaleRecipient(); + + erc20.mint(address(this), pricePerToken); + ERC20(erc20).approve(address(openEdition), pricePerToken); + uint256 beforeBalancePrimarySaleRecipient = erc20.balanceOf(storedPrimarySaleRecipient); + + openEdition.collectPriceOnClaim(primarySaleRecipient, qty, currency, pricePerToken); + + uint256 afterBalancePrimarySaleRecipient = erc20.balanceOf(storedPrimarySaleRecipient); + + uint256 platformFeeVal = (1 ether * platformFeeBps) / 10_000; + uint256 primarySaleRecipientVal = 1 ether - platformFeeVal; + + assertEq(beforeBalancePrimarySaleRecipient + primarySaleRecipientVal, afterBalancePrimarySaleRecipient); + } + + function test_state_nativeCurrencyStoredPrimarySaleRecipient() + public + currencyNativeToken + pricePerTokenNotZero + primarySaleRecipientZeroAddress + msgValueNotZero + { + address storedPrimarySaleRecipient = openEdition.primarySaleRecipient(); + + uint256 beforeBalancePrimarySaleRecipient = address(storedPrimarySaleRecipient).balance; + + openEdition.collectPriceOnClaim{ value: msgValue }(primarySaleRecipient, qty, currency, pricePerToken); + + uint256 afterBalancePrimarySaleRecipient = address(storedPrimarySaleRecipient).balance; + + uint256 platformFeeVal = (msgValue * platformFeeBps) / 10_000; + uint256 primarySaleRecipientVal = msgValue - platformFeeVal; + + assertEq(beforeBalancePrimarySaleRecipient + primarySaleRecipientVal, afterBalancePrimarySaleRecipient); + } +} diff --git a/src/test/open-edition-flat-fee/_collectPriceOnClaim/_collectPriceOnClaim.tree b/src/test/open-edition-flat-fee/_collectPriceOnClaim/_collectPriceOnClaim.tree new file mode 100644 index 000000000..2054cf049 --- /dev/null +++ b/src/test/open-edition-flat-fee/_collectPriceOnClaim/_collectPriceOnClaim.tree @@ -0,0 +1,37 @@ +function _collectPriceOnClaim( + address _primarySaleRecipient, + uint256 _quantityToClaim, + address _currency, + uint256 _pricePerToken +) +├── when _pricePerToken is equal to zero +│ ├── when msg.value does not equal to zero +│ │ └── it should revert ✅ +│ └── when msg.value is equal to zero +│ └── it should return ✅ +└── when _pricePerToken is not equal to zero + ├── when _primarySaleRecipient is equal to address(0) + │ ├── when saleRecipient for _tokenId is equal to address(0) + │ │ ├── when currency is native token + │ │ │ ├── when msg.value does not equal totalPrice + │ │ │ │ └── it should revert ✅ + │ │ │ └── when msg.value does equal totalPrice + │ │ │ └── it should transfer totalPrice to primarySaleRecipient in native token ✅ + │ │ └── when currency is not native token + │ │ └── it should transfer totalPrice to primarySaleRecipient in _currency token ✅ + │ └── when salerecipient for _tokenId is not equal to address(0) + │ ├── when currency is native token + │ │ ├── when msg.value does not equal totalPrice + │ │ │ └── it should revert ✅ + │ │ └── when msg.value does equal totalPrice + │ │ └── it should transfer totalPrice to saleRecipient for _tokenId in native token ✅ + │ └── when currency is not native token + │ └── it should transfer totalPrice to saleRecipient for _tokenId in _currency token ✅ + └── when _primarySaleRecipient is not equal to address(0) + ├── when currency is native token + │ ├── when msg.value does not equal totalPrice + │ │ └── it should revert ✅ + │ └── when msg.value does equal totalPrice + │ └── it should transfer totalPrice to _primarySaleRecipient in native token ✅ + └── when currency is not native token + └── it should transfer totalPrice to _primarySaleRecipient in _currency token ✅ \ No newline at end of file diff --git a/src/test/open-edition-flat-fee/_transferTokensOnClaim/_transferTokensOnClaim.t.sol b/src/test/open-edition-flat-fee/_transferTokensOnClaim/_transferTokensOnClaim.t.sol new file mode 100644 index 000000000..681ca6caa --- /dev/null +++ b/src/test/open-edition-flat-fee/_transferTokensOnClaim/_transferTokensOnClaim.t.sol @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { OpenEditionERC721FlatFee, IERC721AUpgradeable } from "contracts/prebuilts/open-edition/OpenEditionERC721FlatFee.sol"; +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +// Test imports +import "src/test/utils/BaseTest.sol"; + +contract OpenEditionERC721FlatFeeHarness is OpenEditionERC721FlatFee { + function transferTokensOnClaim(address _to, uint256 quantityBeingClaimed) public { + _transferTokensOnClaim(_to, quantityBeingClaimed); + } +} + +contract MockERC721Receiver { + function onERC721Received(address, address, uint256, bytes memory) external pure returns (bytes4) { + return this.onERC721Received.selector; + } +} + +contract MockERC721NotReceiver {} + +contract OpenEditionERC721FlatFeeTest_transferTokensOnClaim is BaseTest { + OpenEditionERC721FlatFeeHarness public openEdition; + + MockERC721NotReceiver private notReceiver; + MockERC721Receiver private receiver; + + address private openEditionImpl; + + function setUp() public override { + super.setUp(); + openEditionImpl = address(new OpenEditionERC721FlatFeeHarness()); + vm.prank(deployer); + openEdition = OpenEditionERC721FlatFeeHarness( + address( + new TWProxy( + openEditionImpl, + abi.encodeCall( + OpenEditionERC721FlatFee.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ) + ) + ); + + receiver = new MockERC721Receiver(); + notReceiver = new MockERC721NotReceiver(); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: misc + //////////////////////////////////////////////////////////////*/ + + function test_revert_TransferToNonReceiverContract() public { + vm.expectRevert(IERC721AUpgradeable.TransferToNonERC721ReceiverImplementer.selector); + openEdition.transferTokensOnClaim(address(notReceiver), 1); + } + + function test_state_transferToReceiverContract() public { + uint256 receiverBalanceBefore = openEdition.balanceOf(address(receiver)); + uint256 nextTokenToMintBefore = openEdition.nextTokenIdToMint(); + + openEdition.transferTokensOnClaim(address(receiver), 1); + + uint256 receiverBalanceAfter = openEdition.balanceOf(address(receiver)); + uint256 nextTokenToMintAfter = openEdition.nextTokenIdToMint(); + + assertEq(receiverBalanceAfter, receiverBalanceBefore + 1); + assertEq(nextTokenToMintAfter, nextTokenToMintBefore + 1); + } + + function test_state_transferToEOA() public { + address to = address(0x01); + uint256 receiverBalanceBefore = openEdition.balanceOf(to); + uint256 nextTokenToMintBefore = openEdition.nextTokenIdToMint(); + + openEdition.transferTokensOnClaim(to, 1); + + uint256 receiverBalanceAfter = openEdition.balanceOf(to); + uint256 nextTokenToMintAfter = openEdition.nextTokenIdToMint(); + + assertEq(receiverBalanceAfter, receiverBalanceBefore + 1); + assertEq(nextTokenToMintAfter, nextTokenToMintBefore + 1); + } +} diff --git a/src/test/open-edition-flat-fee/_transferTokensOnClaim/_transferTokensOnClaim.tree b/src/test/open-edition-flat-fee/_transferTokensOnClaim/_transferTokensOnClaim.tree new file mode 100644 index 000000000..bddcf87f6 --- /dev/null +++ b/src/test/open-edition-flat-fee/_transferTokensOnClaim/_transferTokensOnClaim.tree @@ -0,0 +1,8 @@ +function _transferTokensOnClaim(address _to, uint256 _quantityBeingClaimed) +├── when _to is a smart contract +│ ├── when _to has not implemented ERC721Receiver +│ │ └── it should revert ✅ +│ └── when _to has implemented ERC721Receiver +│ └── it should mint _quantityBeingClaimed tokens to _to ✅ +└── when _to is an EOA + └── it should mint _quantityBeingClaimed tokens to _to ✅ \ No newline at end of file diff --git a/src/test/open-edition-flat-fee/initialize/initialize.t.sol b/src/test/open-edition-flat-fee/initialize/initialize.t.sol new file mode 100644 index 000000000..7e60c0903 --- /dev/null +++ b/src/test/open-edition-flat-fee/initialize/initialize.t.sol @@ -0,0 +1,255 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { OpenEditionERC721FlatFee, Royalty } from "contracts/prebuilts/open-edition/OpenEditionERC721FlatFee.sol"; +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +// Test imports +import "src/test/utils/BaseTest.sol"; + +contract OpenEditionERC721FlatFeeTest_initialize is BaseTest { + event ContractURIUpdated(string prevURI, string newURI); + event OwnerUpdated(address indexed prevOwner, address indexed newOwner); + event RoleGranted(bytes32 indexed role, address indexed account, address indexed sender); + event PrimarySaleRecipientUpdated(address indexed recipient); + + OpenEditionERC721FlatFee public openEdition; + + address private openEditionImpl; + + function deployOpenEdition( + address _defaultAdmin, + string memory _name, + string memory _symbol, + string memory _contractURI, + address[] memory _trustedForwarders, + address _saleRecipient, + address _royaltyRecipient, + uint128 _royaltyBps, + uint128 _platformFeeBps, + address _platformFeeRecipient, + address _imp + ) public { + vm.prank(deployer); + openEdition = OpenEditionERC721FlatFee( + address( + new TWProxy( + _imp, + abi.encodeCall( + OpenEditionERC721FlatFee.initialize, + ( + _defaultAdmin, + _name, + _symbol, + _contractURI, + _trustedForwarders, + _saleRecipient, + _royaltyRecipient, + _royaltyBps, + _platformFeeBps, + _platformFeeRecipient + ) + ) + ) + ) + ); + } + + function setUp() public override { + super.setUp(); + openEditionImpl = address(new OpenEditionERC721FlatFee()); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: initialize + //////////////////////////////////////////////////////////////*/ + + function test_state() public { + deployOpenEdition( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient, + openEditionImpl + ); + + address _saleRecipient = openEdition.primarySaleRecipient(); + (address _royaltyRecipient, uint16 _royaltyBps) = openEdition.getDefaultRoyaltyInfo(); + string memory _name = openEdition.name(); + string memory _symbol = openEdition.symbol(); + string memory _contractURI = openEdition.contractURI(); + address _owner = openEdition.owner(); + + assertEq(_name, NAME); + assertEq(_symbol, SYMBOL); + assertEq(_contractURI, CONTRACT_URI); + assertEq(_saleRecipient, saleRecipient); + assertEq(_royaltyRecipient, royaltyRecipient); + assertEq(_royaltyBps, royaltyBps); + assertEq(_owner, deployer); + + for (uint256 i = 0; i < forwarders().length; i++) { + assertEq(openEdition.isTrustedForwarder(forwarders()[i]), true); + } + + assertTrue(openEdition.hasRole(openEdition.DEFAULT_ADMIN_ROLE(), deployer)); + assertTrue(openEdition.hasRole(keccak256("TRANSFER_ROLE"), deployer)); + assertTrue(openEdition.hasRole(keccak256("MINTER_ROLE"), deployer)); + assertTrue(openEdition.hasRole(keccak256("TRANSFER_ROLE"), address(0))); + } + + function test_revert_RoyaltyTooHigh() public { + uint128 _royaltyBps = 10001; + + vm.expectRevert(abi.encodeWithSelector(Royalty.RoyaltyExceededMaxFeeBps.selector, 10_000, _royaltyBps)); + deployOpenEdition( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + _royaltyBps, + platformFeeBps, + platformFeeRecipient, + openEditionImpl + ); + } + + function test_event_ContractURIUpdated() public { + vm.expectEmit(false, false, false, true); + emit ContractURIUpdated("", CONTRACT_URI); + deployOpenEdition( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient, + openEditionImpl + ); + } + + function test_event_OwnerUpdated() public { + vm.expectEmit(true, true, false, false); + emit OwnerUpdated(address(0), deployer); + deployOpenEdition( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient, + openEditionImpl + ); + } + + function test_event_TransferRoleAddressZero() public { + bytes32 role = keccak256("TRANSFER_ROLE"); + vm.expectEmit(true, true, false, false); + emit RoleGranted(role, address(0), deployer); + deployOpenEdition( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient, + openEditionImpl + ); + } + + function test_event_TransferRoleAdmin() public { + bytes32 role = keccak256("TRANSFER_ROLE"); + vm.expectEmit(true, true, false, false); + emit RoleGranted(role, deployer, deployer); + deployOpenEdition( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient, + openEditionImpl + ); + } + + function test_event_MinterRoleAdmin() public { + bytes32 role = keccak256("MINTER_ROLE"); + vm.expectEmit(true, true, false, false); + emit RoleGranted(role, deployer, deployer); + deployOpenEdition( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient, + openEditionImpl + ); + } + + function test_event_DefaultAdminRoleAdmin() public { + bytes32 role = bytes32(0x00); + vm.expectEmit(true, true, false, false); + emit RoleGranted(role, deployer, deployer); + deployOpenEdition( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient, + openEditionImpl + ); + } + + function test_event_PrimarysaleRecipientUpdated() public { + vm.expectEmit(true, false, false, false); + emit PrimarySaleRecipientUpdated(saleRecipient); + deployOpenEdition( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient, + openEditionImpl + ); + } +} diff --git a/src/test/open-edition-flat-fee/initialize/initialize.tree b/src/test/open-edition-flat-fee/initialize/initialize.tree new file mode 100644 index 000000000..f56ad144b --- /dev/null +++ b/src/test/open-edition-flat-fee/initialize/initialize.tree @@ -0,0 +1,39 @@ +function initialize( + address _defaultAdmin, + string memory _name, + string memory _symbol, + string memory _contractURI, + address[] memory _trustedForwarders, + address _saleRecipient, + address _royaltyRecipient, + uint128 _royaltyBps, + uint128 _platformFeeBps, + address _platformFeeRecipient +) +├── when _trustedForwarders.length > 0 +│ └── it should set _trustedForwarder[_trustedForwarders[i]] as true for each address in _trustedForwarders ✅ +├── it should set _name as the value provided in _name ✅ +├── it should set _symbol as the value provided in _symbol ✅ +├── it should set _currentIndex as 0 ✅ +├── it should set contractURI as _contractURI ✅ +├── it should emit ContractURIUpdated with the parameters: prevURI, _uri ✅ +├── it should set _defaultAdmin as the owner of the contract ✅ +├── it should emit OwnerUpdated with the parameters: _prevOwner, _defaultAdmin ✅ +├── it should assign the role DEFAULT_ADMIN_ROLE to _defaultAdmin ✅ +├── it should emit RoleGranted with the parameters: DEFAULT_ADMIN_ROLE, _defaultAdmin, msg.sender ✅ +├── it should assign the role _minterRole to _defaultAdmin ✅ +├── it should emit RoleGranted with the parameters: _minterRole, _defaultAdmin, msg.sender ✅ +├── it should assign the role _transferRole to _defaultAdmin ✅ +├── it should emit RoleGranted with the parameters: _transferRole, _defaultAdmin, msg.sender ✅ +├── it should assign the role _transferRole to address(0) ✅ +├── it should emit RoleGranted with the parameters: _transferRole, address(0), msg.sender ✅ +├── when _royaltyBps is greater than 10_000 +│ └── it should revert ✅ +├── when _royaltyBps is less than or equal to 10_000 +│ ├── it should set royaltyRecipient as _royaltyRecipient ✅ +│ ├── it should set royaltyBps as uint16(_royaltyBps) ✅ +│ └── it should emit DefaultRoyalty with the parameters _royaltyRecipient, _royaltyBps +├── it should set recipient as _primarySaleRecipient ✅ +├── it should emit PrimarySaleRecipientUpdated with the parameters _primarySaleRecipient ✅ +├── it should set transferRole as keccak256("TRANSFER_ROLE") ✅ +└── it should set minterRole as keccak256("MINTER_ROLE") ✅ diff --git a/src/test/open-edition-flat-fee/misc/misc.t.sol b/src/test/open-edition-flat-fee/misc/misc.t.sol new file mode 100644 index 000000000..760373600 --- /dev/null +++ b/src/test/open-edition-flat-fee/misc/misc.t.sol @@ -0,0 +1,233 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { IERC721AUpgradeable, OpenEditionERC721FlatFee, ISharedMetadata } from "contracts/prebuilts/open-edition/OpenEditionERC721FlatFee.sol"; +import { NFTMetadataRenderer } from "contracts/lib/NFTMetadataRenderer.sol"; +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +// Test imports +import "src/test/utils/BaseTest.sol"; +import "@openzeppelin/contracts-upgradeable/interfaces/IERC2981Upgradeable.sol"; + +contract HarnessOpenEditionERC721FlatFee is OpenEditionERC721FlatFee { + function msgData() public view returns (bytes memory) { + return _msgData(); + } +} + +contract OpenEditionERC721FlatFeeTest_misc is BaseTest { + OpenEditionERC721FlatFee public openEdition; + HarnessOpenEditionERC721FlatFee public harnessOpenEdition; + + address private openEditionImpl; + address private harnessImpl; + + address private receiver = 0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd; + + ISharedMetadata.SharedMetadataInfo public sharedMetadata; + + function setUp() public override { + super.setUp(); + openEditionImpl = address(new OpenEditionERC721FlatFee()); + vm.prank(deployer); + openEdition = OpenEditionERC721FlatFee( + address( + new TWProxy( + openEditionImpl, + abi.encodeCall( + OpenEditionERC721FlatFee.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ) + ) + ); + + sharedMetadata = ISharedMetadata.SharedMetadataInfo({ + name: "Test", + description: "Test", + imageURI: "https://test.com", + animationURI: "https://test.com" + }); + } + + function deployHarness() internal { + harnessImpl = address(new HarnessOpenEditionERC721FlatFee()); + harnessOpenEdition = HarnessOpenEditionERC721FlatFee( + address( + new TWProxy( + harnessImpl, + abi.encodeCall( + OpenEditionERC721FlatFee.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ) + ) + ); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: misc + //////////////////////////////////////////////////////////////*/ + + modifier claimTokens() { + string[] memory inputs = new string[](5); + + inputs[0] = "node"; + inputs[1] = "src/test/scripts/generateRoot.ts"; + inputs[2] = "300"; + inputs[3] = "0"; + inputs[4] = Strings.toHexString(uint160(address(erc20))); // address of erc20 + + bytes memory result = vm.ffi(inputs); + bytes32 root = abi.decode(result, (bytes32)); + + inputs[1] = "src/test/scripts/getProof.ts"; + result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + OpenEditionERC721FlatFee.AllowlistProof memory alp; + alp.proof = proofs; + alp.quantityLimitPerWallet = 300; + alp.pricePerToken = 0; + alp.currency = address(erc20); + + vm.warp(1); + + OpenEditionERC721FlatFee.ClaimCondition[] memory conditions = new OpenEditionERC721FlatFee.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 500; + conditions[0].quantityLimitPerWallet = 10; + conditions[0].merkleRoot = root; + conditions[0].pricePerToken = 10; + conditions[0].currency = address(erc20); + + vm.prank(deployer); + openEdition.setClaimConditions(conditions, false); + + vm.prank(receiver, receiver); + openEdition.claim(receiver, 100, address(erc20), 0, alp, ""); + _; + } + + modifier callerOwner() { + vm.startPrank(receiver); + _; + } + + modifier callerNotOwner() { + _; + } + + function test_tokenURI_revert_tokenDoesNotExist() public { + vm.expectRevert(bytes("!ID")); + openEdition.tokenURI(1); + } + + function test_tokenURI_returnMetadata() public claimTokens { + vm.prank(deployer); + openEdition.setSharedMetadata(sharedMetadata); + + string memory uri = openEdition.tokenURI(1); + assertEq( + uri, + NFTMetadataRenderer.createMetadataEdition({ + name: sharedMetadata.name, + description: sharedMetadata.description, + imageURI: sharedMetadata.imageURI, + animationURI: sharedMetadata.animationURI, + tokenOfEdition: 1 + }) + ); + } + + function test_startTokenId_returnOne() public { + assertEq(openEdition.startTokenId(), 1); + } + + function test_totalMinted_returnZero() public { + assertEq(openEdition.totalMinted(), 0); + } + + function test_totalMinted_returnOneHundred() public claimTokens { + assertEq(openEdition.totalMinted(), 100); + } + + function test_nextTokenIdToMint_returnOne() public { + assertEq(openEdition.nextTokenIdToMint(), 1); + } + + function test_nextTokenIdToMint_returnOneHundredAndOne() public claimTokens { + assertEq(openEdition.nextTokenIdToMint(), 101); + } + + function test_nextTokenIdToClaim_returnOne() public { + assertEq(openEdition.nextTokenIdToClaim(), 1); + } + + function test_nextTokenIdToClaim_returnOneHundredAndOne() public claimTokens { + assertEq(openEdition.nextTokenIdToClaim(), 101); + } + + function test_burn_revert_callerNotOwner() public claimTokens callerNotOwner { + vm.expectRevert(IERC721AUpgradeable.TransferCallerNotOwnerNorApproved.selector); + openEdition.burn(1); + } + + function test_burn_state_callerOwner() public claimTokens callerOwner { + uint256 balanceBeforeBurn = openEdition.balanceOf(receiver); + + openEdition.burn(1); + + uint256 balanceAfterBurn = openEdition.balanceOf(receiver); + + assertEq(balanceBeforeBurn - balanceAfterBurn, 1); + } + + function test_burn_state_callerApproved() public claimTokens { + uint256 balanceBeforeBurn = openEdition.balanceOf(receiver); + + vm.prank(receiver); + openEdition.setApprovalForAll(deployer, true); + + vm.prank(deployer); + openEdition.burn(1); + + uint256 balanceAfterBurn = openEdition.balanceOf(receiver); + + assertEq(balanceBeforeBurn - balanceAfterBurn, 1); + } + + function test_supportsInterface() public { + assertEq(openEdition.supportsInterface(type(IERC2981Upgradeable).interfaceId), true); + bytes4 invalidId = bytes4(0); + assertEq(openEdition.supportsInterface(invalidId), false); + } + + function test_msgData_returnValue() public { + deployHarness(); + bytes memory msgData = harnessOpenEdition.msgData(); + bytes4 expectedData = harnessOpenEdition.msgData.selector; + assertEq(bytes4(msgData), expectedData); + } +} diff --git a/src/test/open-edition-flat-fee/misc/misc.tree b/src/test/open-edition-flat-fee/misc/misc.tree new file mode 100644 index 000000000..07abb950c --- /dev/null +++ b/src/test/open-edition-flat-fee/misc/misc.tree @@ -0,0 +1,33 @@ +function tokenURI(uint256 _tokenId) +├── when _tokenId does not exist +│ └── it should revert ✅ +└── when _tokenID does exist + └── it should return the shared metadata ✅ + +function supportsInterface(bytes4 interfaceId) +├── it should return true for any of the listed interface ids ✅ +└── it should return false for any interfaces ids that are not listed ✅ + +function _startTokenId() +└── it should return 1 ✅ + +function startTokenId() +└── it should return _startTokenId (1) ✅ + +function totalminted() +└── it should return the total number of NFTs minted ✅ + +function nextTokenIdToMint() +└── it should return the next token ID to mint (last minted + 1) ✅ + +function nextTokenIdToClaim() +└── it should return the next token ID to mint (last minted + 1) ✅ + +function burn(uint256 tokenId) +├── when caller is not the owner of tokenId +│ ├── when caller is not an approved operator of the owner of tokenId +│ │ └── it should revert ✅ +│ └── when caller is an approved operator of the owner of tokenId +│ └── it should burn the token ✅ +└── when caller is the owner of tokenId + └── it should burn the token ✅ \ No newline at end of file diff --git a/src/test/open-edition/_beforeTokenTransfers/_beforeTokenTransfers.t.sol b/src/test/open-edition/_beforeTokenTransfers/_beforeTokenTransfers.t.sol new file mode 100644 index 000000000..1ed885681 --- /dev/null +++ b/src/test/open-edition/_beforeTokenTransfers/_beforeTokenTransfers.t.sol @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { OpenEditionERC721 } from "contracts/prebuilts/open-edition/OpenEditionERC721.sol"; +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +// Test imports +import "src/test/utils/BaseTest.sol"; + +contract OpenEditionERC721Harness is OpenEditionERC721 { + function beforeTokenTransfers(address from, address to, uint256 startTokenId_, uint256 quantity) public { + _beforeTokenTransfers(from, to, startTokenId_, quantity); + } +} + +contract OpenEditionERC721Test_beforeTokenTransfers is BaseTest { + OpenEditionERC721Harness public openEdition; + + address private openEditionImpl; + + function setUp() public override { + super.setUp(); + openEditionImpl = address(new OpenEditionERC721Harness()); + vm.prank(deployer); + openEdition = OpenEditionERC721Harness( + address( + new TWProxy( + openEditionImpl, + abi.encodeCall( + OpenEditionERC721.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps + ) + ) + ) + ) + ); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: misc + //////////////////////////////////////////////////////////////*/ + + function test_revert_transfersRestricted() public { + address from = address(0x1); + address to = address(0x2); + bytes32 role = keccak256("TRANSFER_ROLE"); + vm.prank(deployer); + openEdition.revokeRole(role, address(0)); + + vm.expectRevert(bytes("!T")); + openEdition.beforeTokenTransfers(from, to, 0, 1); + } +} diff --git a/src/test/open-edition/_beforeTokenTransfers/_beforeTokenTransfers.tree b/src/test/open-edition/_beforeTokenTransfers/_beforeTokenTransfers.tree new file mode 100644 index 000000000..078bd6a79 --- /dev/null +++ b/src/test/open-edition/_beforeTokenTransfers/_beforeTokenTransfers.tree @@ -0,0 +1,12 @@ +function _beforeTokenTransfers( + address from, + address to, + uint256 startTokenId_, + uint256 quantity +) +└── when address(0) does not have the transfer role + └── when from does not equal address(0) + └── when to does not equal address(0) + └── when from does not have the transfer role + └── when to does not have the transfer role + └── it should revert ✅ diff --git a/src/test/open-edition/_canSetFunctions/_canSetFunctions.t.sol b/src/test/open-edition/_canSetFunctions/_canSetFunctions.t.sol new file mode 100644 index 000000000..6903bdae7 --- /dev/null +++ b/src/test/open-edition/_canSetFunctions/_canSetFunctions.t.sol @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { OpenEditionERC721 } from "contracts/prebuilts/open-edition/OpenEditionERC721.sol"; +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +// Test imports +import "src/test/utils/BaseTest.sol"; + +contract OpenEditionERC721Harness is OpenEditionERC721 { + function canSetPrimarySaleRecipient() external view returns (bool) { + return _canSetPrimarySaleRecipient(); + } + + function canSetOwner() external view returns (bool) { + return _canSetOwner(); + } + + /// @dev Checks whether royalty info can be set in the given execution context. + function canSetRoyaltyInfo() external view returns (bool) { + return _canSetRoyaltyInfo(); + } + + /// @dev Checks whether contract metadata can be set in the given execution context. + function canSetContractURI() external view returns (bool) { + return _canSetContractURI(); + } + + /// @dev Checks whether platform fee info can be set in the given execution context. + function canSetClaimConditions() external view returns (bool) { + return _canSetClaimConditions(); + } + + /// @dev Returns whether the shared metadata of tokens can be set in the given execution context. + function canSetSharedMetadata() external view virtual returns (bool) { + return _canSetSharedMetadata(); + } +} + +contract OpenEditionERC721Test_canSetFunctions is BaseTest { + OpenEditionERC721Harness public openEdition; + + address private openEditionImpl; + + function setUp() public override { + super.setUp(); + openEditionImpl = address(new OpenEditionERC721Harness()); + vm.prank(deployer); + openEdition = OpenEditionERC721Harness( + address( + new TWProxy( + openEditionImpl, + abi.encodeCall( + OpenEditionERC721.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps + ) + ) + ) + ) + ); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: misc + //////////////////////////////////////////////////////////////*/ + + function test_canSetPrimarySaleRecipient_returnTrue() public { + vm.prank(deployer); + assertTrue(openEdition.canSetPrimarySaleRecipient()); + } + + function test_canSetPrimarySaleRecipient_returnFalse() public { + assertFalse(openEdition.canSetPrimarySaleRecipient()); + } + + function test_canSetOwner_returnTrue() public { + vm.prank(deployer); + assertTrue(openEdition.canSetOwner()); + } + + function test_canSetOwner_returnFalse() public { + assertFalse(openEdition.canSetOwner()); + } + + function test_canSetRoyaltyInfo_returnTrue() public { + vm.prank(deployer); + assertTrue(openEdition.canSetRoyaltyInfo()); + } + + function test_canSetRoyaltyInfo_returnFalse() public { + assertFalse(openEdition.canSetRoyaltyInfo()); + } + + function test_canSetContractURI_returnTrue() public { + vm.prank(deployer); + assertTrue(openEdition.canSetContractURI()); + } + + function test_canSetContractURI_returnFalse() public { + assertFalse(openEdition.canSetContractURI()); + } + + function test_canSetClaimConditions_returnTrue() public { + vm.prank(deployer); + assertTrue(openEdition.canSetClaimConditions()); + } + + function test_canSetClaimConditions_returnFalse() public { + assertFalse(openEdition.canSetClaimConditions()); + } + + function test_canSetSharedMetadata_returnTrue() public { + vm.prank(deployer); + assertTrue(openEdition.canSetSharedMetadata()); + } + + function test_canSetSharedMetadata_returnFalse() public { + assertFalse(openEdition.canSetSharedMetadata()); + } +} diff --git a/src/test/open-edition/_canSetFunctions/_canSetFunctions.tree b/src/test/open-edition/_canSetFunctions/_canSetFunctions.tree new file mode 100644 index 000000000..1ccb478fd --- /dev/null +++ b/src/test/open-edition/_canSetFunctions/_canSetFunctions.tree @@ -0,0 +1,39 @@ +function _canSetPrimarySaleRecipient() +├── when _msgSender has DEFAULT_ADMIN_ROLE +│ └── it should return true ✅ +└── when _msgSender does not have DEFAULT_ADMIN_ROLE + └── it should return false ✅ + +function _canSetOwner() +├── when _msgSender has DEFAULT_ADMIN_ROLE +│ └── it should return true ✅ +└── when _msgSender does not have DEFAULT_ADMIN_ROLE + └── it should return false ✅ + + +function _canSetRoyaltyInfo() +├── when _msgSender has DEFAULT_ADMIN_ROLE +│ └── it should return true ✅ +└── when _msgSender does not have DEFAULT_ADMIN_ROLE + └── it should return false ✅ + + +function _canSetContractURI() +├── when _msgSender has DEFAULT_ADMIN_ROLE +│ └── it should return true ✅ +└── when _msgSender does not have DEFAULT_ADMIN_ROLE + └── it should return false ✅ + + +function _canSetClaimConditions() +├── when _msgSender has DEFAULT_ADMIN_ROLE +│ └── it should return true ✅ +└── when _msgSender does not have DEFAULT_ADMIN_ROLE + └── it should return false ✅ + + +function _canSetSharedMetadata() +├── when _msgSender has minter role +│ └── it should return true ✅ +└── when _msgSender does not have minter role + └── it should return false ✅ diff --git a/src/test/open-edition/_collectPriceOnClaim/_collectPriceOnClaim.t.sol b/src/test/open-edition/_collectPriceOnClaim/_collectPriceOnClaim.t.sol new file mode 100644 index 000000000..5b49bcdd7 --- /dev/null +++ b/src/test/open-edition/_collectPriceOnClaim/_collectPriceOnClaim.t.sol @@ -0,0 +1,203 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { OpenEditionERC721 } from "contracts/prebuilts/open-edition/OpenEditionERC721.sol"; +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +// Test imports +import "src/test/utils/BaseTest.sol"; + +contract OpenEditionERC721Harness is OpenEditionERC721 { + function collectPriceOnClaim( + address _primarySaleRecipient, + uint256 _quantityToClaim, + address _currency, + uint256 _pricePerToken + ) external payable { + _collectPriceOnClaim(_primarySaleRecipient, _quantityToClaim, _currency, _pricePerToken); + } +} + +contract OpenEditionERC721Test_collectPrice is BaseTest { + OpenEditionERC721Harness public openEdition; + + address private openEditionImpl; + + address private currency; + address private primarySaleRecipient; + uint256 private msgValue; + uint256 private pricePerToken; + uint256 private qty = 1; + + function setUp() public override { + super.setUp(); + openEditionImpl = address(new OpenEditionERC721Harness()); + vm.prank(deployer); + openEdition = OpenEditionERC721Harness( + address( + new TWProxy( + openEditionImpl, + abi.encodeCall( + OpenEditionERC721.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps + ) + ) + ) + ) + ); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: misc + //////////////////////////////////////////////////////////////*/ + + modifier pricePerTokenZero() { + _; + } + + modifier pricePerTokenNotZero() { + pricePerToken = 1 ether; + _; + } + + modifier msgValueZero() { + _; + } + + modifier msgValueNotZero() { + msgValue = 1 ether; + _; + } + + modifier valuePriceMismatch() { + msgValue = 1 ether; + pricePerToken = 2 ether; + _; + } + + modifier primarySaleRecipientZeroAddress() { + primarySaleRecipient = address(0); + _; + } + + modifier primarySaleRecipientNotZeroAddress() { + primarySaleRecipient = address(0x0999); + _; + } + + modifier currencyNativeToken() { + currency = NATIVE_TOKEN; + _; + } + + modifier currencyNotNativeToken() { + currency = address(erc20); + _; + } + + function test_revert_pricePerTokenZeroMsgValueNotZero() public pricePerTokenZero msgValueNotZero { + vm.expectRevert("!Value"); + openEdition.collectPriceOnClaim{ value: msgValue }(primarySaleRecipient, qty, currency, pricePerToken); + } + + function test_revert_nativeCurrencyValuePriceMismatch() public currencyNativeToken valuePriceMismatch { + vm.expectRevert(bytes("!V")); + openEdition.collectPriceOnClaim{ value: msgValue }(primarySaleRecipient, qty, currency, pricePerToken); + } + + function test_revert_erc20ValuePriceMismatch() public currencyNotNativeToken valuePriceMismatch { + vm.expectRevert(bytes("!V")); + openEdition.collectPriceOnClaim{ value: msgValue }(primarySaleRecipient, qty, currency, pricePerToken); + } + + function test_state_nativeCurrency() + public + currencyNativeToken + pricePerTokenNotZero + msgValueNotZero + primarySaleRecipientNotZeroAddress + { + uint256 beforeBalancePrimarySaleRecipient = address(primarySaleRecipient).balance; + + openEdition.collectPriceOnClaim{ value: msgValue }(primarySaleRecipient, qty, currency, pricePerToken); + + uint256 afterBalancePrimarySaleRecipient = address(primarySaleRecipient).balance; + + uint256 primarySaleRecipientVal = msgValue; + + assertEq(beforeBalancePrimarySaleRecipient + primarySaleRecipientVal, afterBalancePrimarySaleRecipient); + } + + function test_revert_erc20_msgValueNotZero() + public + currencyNotNativeToken + msgValueNotZero + primarySaleRecipientNotZeroAddress + { + vm.expectRevert("!Value"); + openEdition.collectPriceOnClaim{ value: msgValue }(primarySaleRecipient, qty, currency, pricePerToken); + } + + function test_state_erc20() public currencyNotNativeToken pricePerTokenNotZero primarySaleRecipientNotZeroAddress { + erc20.mint(address(this), pricePerToken); + ERC20(erc20).approve(address(openEdition), pricePerToken); + uint256 beforeBalancePrimarySaleRecipient = erc20.balanceOf(primarySaleRecipient); + + openEdition.collectPriceOnClaim(primarySaleRecipient, qty, currency, pricePerToken); + + uint256 afterBalancePrimarySaleRecipient = erc20.balanceOf(primarySaleRecipient); + + uint256 primarySaleRecipientVal = 1 ether; + + assertEq(beforeBalancePrimarySaleRecipient + primarySaleRecipientVal, afterBalancePrimarySaleRecipient); + } + + function test_state_erc20StoredPrimarySaleRecipient() + public + currencyNotNativeToken + pricePerTokenNotZero + primarySaleRecipientZeroAddress + { + address storedPrimarySaleRecipient = openEdition.primarySaleRecipient(); + + erc20.mint(address(this), pricePerToken); + ERC20(erc20).approve(address(openEdition), pricePerToken); + uint256 beforeBalancePrimarySaleRecipient = erc20.balanceOf(storedPrimarySaleRecipient); + + openEdition.collectPriceOnClaim(primarySaleRecipient, qty, currency, pricePerToken); + + uint256 afterBalancePrimarySaleRecipient = erc20.balanceOf(storedPrimarySaleRecipient); + + uint256 primarySaleRecipientVal = 1 ether; + + assertEq(beforeBalancePrimarySaleRecipient + primarySaleRecipientVal, afterBalancePrimarySaleRecipient); + } + + function test_state_nativeCurrencyStoredPrimarySaleRecipient() + public + currencyNativeToken + pricePerTokenNotZero + primarySaleRecipientZeroAddress + msgValueNotZero + { + address storedPrimarySaleRecipient = openEdition.primarySaleRecipient(); + + uint256 beforeBalancePrimarySaleRecipient = address(storedPrimarySaleRecipient).balance; + + openEdition.collectPriceOnClaim{ value: msgValue }(primarySaleRecipient, qty, currency, pricePerToken); + + uint256 afterBalancePrimarySaleRecipient = address(storedPrimarySaleRecipient).balance; + + uint256 primarySaleRecipientVal = msgValue; + + assertEq(beforeBalancePrimarySaleRecipient + primarySaleRecipientVal, afterBalancePrimarySaleRecipient); + } +} diff --git a/src/test/open-edition/_collectPriceOnClaim/_collectPriceOnClaim.tree b/src/test/open-edition/_collectPriceOnClaim/_collectPriceOnClaim.tree new file mode 100644 index 000000000..2054cf049 --- /dev/null +++ b/src/test/open-edition/_collectPriceOnClaim/_collectPriceOnClaim.tree @@ -0,0 +1,37 @@ +function _collectPriceOnClaim( + address _primarySaleRecipient, + uint256 _quantityToClaim, + address _currency, + uint256 _pricePerToken +) +├── when _pricePerToken is equal to zero +│ ├── when msg.value does not equal to zero +│ │ └── it should revert ✅ +│ └── when msg.value is equal to zero +│ └── it should return ✅ +└── when _pricePerToken is not equal to zero + ├── when _primarySaleRecipient is equal to address(0) + │ ├── when saleRecipient for _tokenId is equal to address(0) + │ │ ├── when currency is native token + │ │ │ ├── when msg.value does not equal totalPrice + │ │ │ │ └── it should revert ✅ + │ │ │ └── when msg.value does equal totalPrice + │ │ │ └── it should transfer totalPrice to primarySaleRecipient in native token ✅ + │ │ └── when currency is not native token + │ │ └── it should transfer totalPrice to primarySaleRecipient in _currency token ✅ + │ └── when salerecipient for _tokenId is not equal to address(0) + │ ├── when currency is native token + │ │ ├── when msg.value does not equal totalPrice + │ │ │ └── it should revert ✅ + │ │ └── when msg.value does equal totalPrice + │ │ └── it should transfer totalPrice to saleRecipient for _tokenId in native token ✅ + │ └── when currency is not native token + │ └── it should transfer totalPrice to saleRecipient for _tokenId in _currency token ✅ + └── when _primarySaleRecipient is not equal to address(0) + ├── when currency is native token + │ ├── when msg.value does not equal totalPrice + │ │ └── it should revert ✅ + │ └── when msg.value does equal totalPrice + │ └── it should transfer totalPrice to _primarySaleRecipient in native token ✅ + └── when currency is not native token + └── it should transfer totalPrice to _primarySaleRecipient in _currency token ✅ \ No newline at end of file diff --git a/src/test/open-edition/_transferTokensOnClaim/_transferTokensOnClaim.t.sol b/src/test/open-edition/_transferTokensOnClaim/_transferTokensOnClaim.t.sol new file mode 100644 index 000000000..d0d2f2173 --- /dev/null +++ b/src/test/open-edition/_transferTokensOnClaim/_transferTokensOnClaim.t.sol @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { OpenEditionERC721, IERC721AUpgradeable } from "contracts/prebuilts/open-edition/OpenEditionERC721.sol"; +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +// Test imports +import "src/test/utils/BaseTest.sol"; + +contract OpenEditionERC721Harness is OpenEditionERC721 { + function transferTokensOnClaim(address _to, uint256 quantityBeingClaimed) public { + _transferTokensOnClaim(_to, quantityBeingClaimed); + } +} + +contract MockERC721Receiver { + function onERC721Received(address, address, uint256, bytes memory) external pure returns (bytes4) { + return this.onERC721Received.selector; + } +} + +contract MockERC721NotReceiver {} + +contract OpenEditionERC721Test_transferTokensOnClaim is BaseTest { + OpenEditionERC721Harness public openEdition; + + MockERC721NotReceiver private notReceiver; + MockERC721Receiver private receiver; + + address private openEditionImpl; + + function setUp() public override { + super.setUp(); + openEditionImpl = address(new OpenEditionERC721Harness()); + vm.prank(deployer); + openEdition = OpenEditionERC721Harness( + address( + new TWProxy( + openEditionImpl, + abi.encodeCall( + OpenEditionERC721.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps + ) + ) + ) + ) + ); + + receiver = new MockERC721Receiver(); + notReceiver = new MockERC721NotReceiver(); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: misc + //////////////////////////////////////////////////////////////*/ + + function test_revert_TransferToNonReceiverContract() public { + vm.expectRevert(IERC721AUpgradeable.TransferToNonERC721ReceiverImplementer.selector); + openEdition.transferTokensOnClaim(address(notReceiver), 1); + } + + function test_state_transferToReceiverContract() public { + uint256 receiverBalanceBefore = openEdition.balanceOf(address(receiver)); + uint256 nextTokenToMintBefore = openEdition.nextTokenIdToMint(); + + openEdition.transferTokensOnClaim(address(receiver), 1); + + uint256 receiverBalanceAfter = openEdition.balanceOf(address(receiver)); + uint256 nextTokenToMintAfter = openEdition.nextTokenIdToMint(); + + assertEq(receiverBalanceAfter, receiverBalanceBefore + 1); + assertEq(nextTokenToMintAfter, nextTokenToMintBefore + 1); + } + + function test_state_transferToEOA() public { + address to = address(0x01); + uint256 receiverBalanceBefore = openEdition.balanceOf(to); + uint256 nextTokenToMintBefore = openEdition.nextTokenIdToMint(); + + openEdition.transferTokensOnClaim(to, 1); + + uint256 receiverBalanceAfter = openEdition.balanceOf(to); + uint256 nextTokenToMintAfter = openEdition.nextTokenIdToMint(); + + assertEq(receiverBalanceAfter, receiverBalanceBefore + 1); + assertEq(nextTokenToMintAfter, nextTokenToMintBefore + 1); + } +} diff --git a/src/test/open-edition/_transferTokensOnClaim/_transferTokensOnClaim.tree b/src/test/open-edition/_transferTokensOnClaim/_transferTokensOnClaim.tree new file mode 100644 index 000000000..bddcf87f6 --- /dev/null +++ b/src/test/open-edition/_transferTokensOnClaim/_transferTokensOnClaim.tree @@ -0,0 +1,8 @@ +function _transferTokensOnClaim(address _to, uint256 _quantityBeingClaimed) +├── when _to is a smart contract +│ ├── when _to has not implemented ERC721Receiver +│ │ └── it should revert ✅ +│ └── when _to has implemented ERC721Receiver +│ └── it should mint _quantityBeingClaimed tokens to _to ✅ +└── when _to is an EOA + └── it should mint _quantityBeingClaimed tokens to _to ✅ \ No newline at end of file diff --git a/src/test/open-edition/initialize/initialize.t.sol b/src/test/open-edition/initialize/initialize.t.sol new file mode 100644 index 000000000..9f759ffb6 --- /dev/null +++ b/src/test/open-edition/initialize/initialize.t.sol @@ -0,0 +1,233 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { OpenEditionERC721, Royalty } from "contracts/prebuilts/open-edition/OpenEditionERC721.sol"; +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +// Test imports +import "src/test/utils/BaseTest.sol"; + +contract OpenEditionERC721Test_initialize is BaseTest { + event ContractURIUpdated(string prevURI, string newURI); + event OwnerUpdated(address indexed prevOwner, address indexed newOwner); + event RoleGranted(bytes32 indexed role, address indexed account, address indexed sender); + event PrimarySaleRecipientUpdated(address indexed recipient); + + OpenEditionERC721 public openEdition; + + address private openEditionImpl; + + function deployOpenEdition( + address _defaultAdmin, + string memory _name, + string memory _symbol, + string memory _contractURI, + address[] memory _trustedForwarders, + address _saleRecipient, + address _royaltyRecipient, + uint128 _royaltyBps, + address _imp + ) public { + vm.prank(deployer); + openEdition = OpenEditionERC721( + address( + new TWProxy( + _imp, + abi.encodeCall( + OpenEditionERC721.initialize, + ( + _defaultAdmin, + _name, + _symbol, + _contractURI, + _trustedForwarders, + _saleRecipient, + _royaltyRecipient, + _royaltyBps + ) + ) + ) + ) + ); + } + + function setUp() public override { + super.setUp(); + openEditionImpl = address(new OpenEditionERC721()); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: initialize + //////////////////////////////////////////////////////////////*/ + + function test_state() public { + deployOpenEdition( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + openEditionImpl + ); + + address _saleRecipient = openEdition.primarySaleRecipient(); + (address _royaltyRecipient, uint16 _royaltyBps) = openEdition.getDefaultRoyaltyInfo(); + string memory _name = openEdition.name(); + string memory _symbol = openEdition.symbol(); + string memory _contractURI = openEdition.contractURI(); + address _owner = openEdition.owner(); + + assertEq(_name, NAME); + assertEq(_symbol, SYMBOL); + assertEq(_contractURI, CONTRACT_URI); + assertEq(_saleRecipient, saleRecipient); + assertEq(_royaltyRecipient, royaltyRecipient); + assertEq(_royaltyBps, royaltyBps); + assertEq(_owner, deployer); + + for (uint256 i = 0; i < forwarders().length; i++) { + assertEq(openEdition.isTrustedForwarder(forwarders()[i]), true); + } + + assertTrue(openEdition.hasRole(openEdition.DEFAULT_ADMIN_ROLE(), deployer)); + assertTrue(openEdition.hasRole(keccak256("TRANSFER_ROLE"), deployer)); + assertTrue(openEdition.hasRole(keccak256("MINTER_ROLE"), deployer)); + assertTrue(openEdition.hasRole(keccak256("TRANSFER_ROLE"), address(0))); + } + + function test_revert_RoyaltyTooHigh() public { + uint128 _royaltyBps = 10001; + + vm.expectRevert(abi.encodeWithSelector(Royalty.RoyaltyExceededMaxFeeBps.selector, 10_000, _royaltyBps)); + deployOpenEdition( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + _royaltyBps, + openEditionImpl + ); + } + + function test_event_ContractURIUpdated() public { + vm.expectEmit(false, false, false, true); + emit ContractURIUpdated("", CONTRACT_URI); + deployOpenEdition( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + openEditionImpl + ); + } + + function test_event_OwnerUpdated() public { + vm.expectEmit(true, true, false, false); + emit OwnerUpdated(address(0), deployer); + deployOpenEdition( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + openEditionImpl + ); + } + + function test_event_TransferRoleAddressZero() public { + bytes32 role = keccak256("TRANSFER_ROLE"); + vm.expectEmit(true, true, false, false); + emit RoleGranted(role, address(0), deployer); + deployOpenEdition( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + openEditionImpl + ); + } + + function test_event_TransferRoleAdmin() public { + bytes32 role = keccak256("TRANSFER_ROLE"); + vm.expectEmit(true, true, false, false); + emit RoleGranted(role, deployer, deployer); + deployOpenEdition( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + openEditionImpl + ); + } + + function test_event_MinterRoleAdmin() public { + bytes32 role = keccak256("MINTER_ROLE"); + vm.expectEmit(true, true, false, false); + emit RoleGranted(role, deployer, deployer); + deployOpenEdition( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + openEditionImpl + ); + } + + function test_event_DefaultAdminRoleAdmin() public { + bytes32 role = bytes32(0x00); + vm.expectEmit(true, true, false, false); + emit RoleGranted(role, deployer, deployer); + deployOpenEdition( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + openEditionImpl + ); + } + + function test_event_PrimarysaleRecipientUpdated() public { + vm.expectEmit(true, false, false, false); + emit PrimarySaleRecipientUpdated(saleRecipient); + deployOpenEdition( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + openEditionImpl + ); + } +} diff --git a/src/test/open-edition/initialize/initialize.tree b/src/test/open-edition/initialize/initialize.tree new file mode 100644 index 000000000..f56ad144b --- /dev/null +++ b/src/test/open-edition/initialize/initialize.tree @@ -0,0 +1,39 @@ +function initialize( + address _defaultAdmin, + string memory _name, + string memory _symbol, + string memory _contractURI, + address[] memory _trustedForwarders, + address _saleRecipient, + address _royaltyRecipient, + uint128 _royaltyBps, + uint128 _platformFeeBps, + address _platformFeeRecipient +) +├── when _trustedForwarders.length > 0 +│ └── it should set _trustedForwarder[_trustedForwarders[i]] as true for each address in _trustedForwarders ✅ +├── it should set _name as the value provided in _name ✅ +├── it should set _symbol as the value provided in _symbol ✅ +├── it should set _currentIndex as 0 ✅ +├── it should set contractURI as _contractURI ✅ +├── it should emit ContractURIUpdated with the parameters: prevURI, _uri ✅ +├── it should set _defaultAdmin as the owner of the contract ✅ +├── it should emit OwnerUpdated with the parameters: _prevOwner, _defaultAdmin ✅ +├── it should assign the role DEFAULT_ADMIN_ROLE to _defaultAdmin ✅ +├── it should emit RoleGranted with the parameters: DEFAULT_ADMIN_ROLE, _defaultAdmin, msg.sender ✅ +├── it should assign the role _minterRole to _defaultAdmin ✅ +├── it should emit RoleGranted with the parameters: _minterRole, _defaultAdmin, msg.sender ✅ +├── it should assign the role _transferRole to _defaultAdmin ✅ +├── it should emit RoleGranted with the parameters: _transferRole, _defaultAdmin, msg.sender ✅ +├── it should assign the role _transferRole to address(0) ✅ +├── it should emit RoleGranted with the parameters: _transferRole, address(0), msg.sender ✅ +├── when _royaltyBps is greater than 10_000 +│ └── it should revert ✅ +├── when _royaltyBps is less than or equal to 10_000 +│ ├── it should set royaltyRecipient as _royaltyRecipient ✅ +│ ├── it should set royaltyBps as uint16(_royaltyBps) ✅ +│ └── it should emit DefaultRoyalty with the parameters _royaltyRecipient, _royaltyBps +├── it should set recipient as _primarySaleRecipient ✅ +├── it should emit PrimarySaleRecipientUpdated with the parameters _primarySaleRecipient ✅ +├── it should set transferRole as keccak256("TRANSFER_ROLE") ✅ +└── it should set minterRole as keccak256("MINTER_ROLE") ✅ diff --git a/src/test/open-edition/misc/misc.t.sol b/src/test/open-edition/misc/misc.t.sol new file mode 100644 index 000000000..44e6b20cb --- /dev/null +++ b/src/test/open-edition/misc/misc.t.sol @@ -0,0 +1,229 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { IERC721AUpgradeable, OpenEditionERC721, ISharedMetadata } from "contracts/prebuilts/open-edition/OpenEditionERC721.sol"; +import { NFTMetadataRenderer } from "contracts/lib/NFTMetadataRenderer.sol"; +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +// Test imports +import "src/test/utils/BaseTest.sol"; +import "@openzeppelin/contracts-upgradeable/interfaces/IERC2981Upgradeable.sol"; + +contract HarnessOpenEditionERC721 is OpenEditionERC721 { + function msgData() public view returns (bytes memory) { + return _msgData(); + } +} + +contract OpenEditionERC721Test_misc is BaseTest { + OpenEditionERC721 public openEdition; + HarnessOpenEditionERC721 public harnessOpenEdition; + + address private openEditionImpl; + address private harnessImpl; + + address private receiver = 0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd; + + ISharedMetadata.SharedMetadataInfo public sharedMetadata; + + function setUp() public override { + super.setUp(); + openEditionImpl = address(new OpenEditionERC721()); + vm.prank(deployer); + openEdition = OpenEditionERC721( + address( + new TWProxy( + openEditionImpl, + abi.encodeCall( + OpenEditionERC721.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps + ) + ) + ) + ) + ); + + sharedMetadata = ISharedMetadata.SharedMetadataInfo({ + name: "Test", + description: "Test", + imageURI: "https://test.com", + animationURI: "https://test.com" + }); + } + + function deployHarness() internal { + harnessImpl = address(new HarnessOpenEditionERC721()); + harnessOpenEdition = HarnessOpenEditionERC721( + address( + new TWProxy( + harnessImpl, + abi.encodeCall( + OpenEditionERC721.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps + ) + ) + ) + ) + ); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: misc + //////////////////////////////////////////////////////////////*/ + + modifier claimTokens() { + string[] memory inputs = new string[](5); + + inputs[0] = "node"; + inputs[1] = "src/test/scripts/generateRoot.ts"; + inputs[2] = "300"; + inputs[3] = "0"; + inputs[4] = Strings.toHexString(uint160(address(erc20))); // address of erc20 + + bytes memory result = vm.ffi(inputs); + bytes32 root = abi.decode(result, (bytes32)); + + inputs[1] = "src/test/scripts/getProof.ts"; + result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + OpenEditionERC721.AllowlistProof memory alp; + alp.proof = proofs; + alp.quantityLimitPerWallet = 300; + alp.pricePerToken = 0; + alp.currency = address(erc20); + + vm.warp(1); + + OpenEditionERC721.ClaimCondition[] memory conditions = new OpenEditionERC721.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 500; + conditions[0].quantityLimitPerWallet = 10; + conditions[0].merkleRoot = root; + conditions[0].pricePerToken = 10; + conditions[0].currency = address(erc20); + + vm.prank(deployer); + openEdition.setClaimConditions(conditions, false); + + vm.prank(receiver, receiver); + openEdition.claim(receiver, 100, address(erc20), 0, alp, ""); + _; + } + + modifier callerOwner() { + vm.startPrank(receiver); + _; + } + + modifier callerNotOwner() { + _; + } + + function test_tokenURI_revert_tokenDoesNotExist() public { + vm.expectRevert(bytes("!ID")); + openEdition.tokenURI(1); + } + + function test_tokenURI_returnMetadata() public claimTokens { + vm.prank(deployer); + openEdition.setSharedMetadata(sharedMetadata); + + string memory uri = openEdition.tokenURI(1); + assertEq( + uri, + NFTMetadataRenderer.createMetadataEdition({ + name: sharedMetadata.name, + description: sharedMetadata.description, + imageURI: sharedMetadata.imageURI, + animationURI: sharedMetadata.animationURI, + tokenOfEdition: 1 + }) + ); + } + + function test_startTokenId_returnOne() public { + assertEq(openEdition.startTokenId(), 1); + } + + function test_totalMinted_returnZero() public { + assertEq(openEdition.totalMinted(), 0); + } + + function test_totalMinted_returnOneHundred() public claimTokens { + assertEq(openEdition.totalMinted(), 100); + } + + function test_nextTokenIdToMint_returnOne() public { + assertEq(openEdition.nextTokenIdToMint(), 1); + } + + function test_nextTokenIdToMint_returnOneHundredAndOne() public claimTokens { + assertEq(openEdition.nextTokenIdToMint(), 101); + } + + function test_nextTokenIdToClaim_returnOne() public { + assertEq(openEdition.nextTokenIdToClaim(), 1); + } + + function test_nextTokenIdToClaim_returnOneHundredAndOne() public claimTokens { + assertEq(openEdition.nextTokenIdToClaim(), 101); + } + + function test_burn_revert_callerNotOwner() public claimTokens callerNotOwner { + vm.expectRevert(IERC721AUpgradeable.TransferCallerNotOwnerNorApproved.selector); + openEdition.burn(1); + } + + function test_burn_state_callerOwner() public claimTokens callerOwner { + uint256 balanceBeforeBurn = openEdition.balanceOf(receiver); + + openEdition.burn(1); + + uint256 balanceAfterBurn = openEdition.balanceOf(receiver); + + assertEq(balanceBeforeBurn - balanceAfterBurn, 1); + } + + function test_burn_state_callerApproved() public claimTokens { + uint256 balanceBeforeBurn = openEdition.balanceOf(receiver); + + vm.prank(receiver); + openEdition.setApprovalForAll(deployer, true); + + vm.prank(deployer); + openEdition.burn(1); + + uint256 balanceAfterBurn = openEdition.balanceOf(receiver); + + assertEq(balanceBeforeBurn - balanceAfterBurn, 1); + } + + function test_supportsInterface() public { + assertEq(openEdition.supportsInterface(type(IERC2981Upgradeable).interfaceId), true); + bytes4 invalidId = bytes4(0); + assertEq(openEdition.supportsInterface(invalidId), false); + } + + function test_msgData_returnValue() public { + deployHarness(); + bytes memory msgData = harnessOpenEdition.msgData(); + bytes4 expectedData = harnessOpenEdition.msgData.selector; + assertEq(bytes4(msgData), expectedData); + } +} diff --git a/src/test/open-edition/misc/misc.tree b/src/test/open-edition/misc/misc.tree new file mode 100644 index 000000000..07abb950c --- /dev/null +++ b/src/test/open-edition/misc/misc.tree @@ -0,0 +1,33 @@ +function tokenURI(uint256 _tokenId) +├── when _tokenId does not exist +│ └── it should revert ✅ +└── when _tokenID does exist + └── it should return the shared metadata ✅ + +function supportsInterface(bytes4 interfaceId) +├── it should return true for any of the listed interface ids ✅ +└── it should return false for any interfaces ids that are not listed ✅ + +function _startTokenId() +└── it should return 1 ✅ + +function startTokenId() +└── it should return _startTokenId (1) ✅ + +function totalminted() +└── it should return the total number of NFTs minted ✅ + +function nextTokenIdToMint() +└── it should return the next token ID to mint (last minted + 1) ✅ + +function nextTokenIdToClaim() +└── it should return the next token ID to mint (last minted + 1) ✅ + +function burn(uint256 tokenId) +├── when caller is not the owner of tokenId +│ ├── when caller is not an approved operator of the owner of tokenId +│ │ └── it should revert ✅ +│ └── when caller is an approved operator of the owner of tokenId +│ └── it should burn the token ✅ +└── when caller is the owner of tokenId + └── it should burn the token ✅ \ No newline at end of file diff --git a/src/test/pack/Pack.t.sol b/src/test/pack/Pack.t.sol new file mode 100644 index 000000000..37fdae635 --- /dev/null +++ b/src/test/pack/Pack.t.sol @@ -0,0 +1,1253 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { Pack, IERC2981Upgradeable, IERC721Receiver, IERC1155Upgradeable } from "contracts/prebuilts/pack/Pack.sol"; +import { IPack } from "contracts/prebuilts/interface/IPack.sol"; +import { ITokenBundle } from "contracts/extension/interface/ITokenBundle.sol"; +import { CurrencyTransferLib } from "contracts/lib/CurrencyTransferLib.sol"; + +// Test imports +import { MockERC20 } from "../mocks/MockERC20.sol"; +import { Wallet } from "../utils/Wallet.sol"; +import "../utils/BaseTest.sol"; + +contract PackTest is BaseTest { + /// @notice Emitted when a set of packs is created. + event PackCreated(uint256 indexed packId, address recipient, uint256 totalPacksCreated); + + /// @notice Emitted when a pack is opened. + event PackOpened( + uint256 indexed packId, + address indexed opener, + uint256 numOfPacksOpened, + ITokenBundle.Token[] rewardUnitsDistributed + ); + + Pack internal pack; + + Wallet internal tokenOwner; + string internal packUri; + ITokenBundle.Token[] internal packContents; + ITokenBundle.Token[] internal additionalContents; + uint256[] internal numOfRewardUnits; + uint256[] internal additionalContentsRewardUnits; + + function setUp() public override { + super.setUp(); + + pack = Pack(payable(getContract("Pack"))); + + tokenOwner = getWallet(); + packUri = "ipfs://"; + + packContents.push( + ITokenBundle.Token({ + assetContract: address(erc721), + tokenType: ITokenBundle.TokenType.ERC721, + tokenId: 0, + totalAmount: 1 + }) + ); + numOfRewardUnits.push(1); + + packContents.push( + ITokenBundle.Token({ + assetContract: address(erc1155), + tokenType: ITokenBundle.TokenType.ERC1155, + tokenId: 0, + totalAmount: 100 + }) + ); + numOfRewardUnits.push(20); + + packContents.push( + ITokenBundle.Token({ + assetContract: address(erc20), + tokenType: ITokenBundle.TokenType.ERC20, + tokenId: 0, + totalAmount: 1000 ether + }) + ); + numOfRewardUnits.push(50); + + packContents.push( + ITokenBundle.Token({ + assetContract: address(erc721), + tokenType: ITokenBundle.TokenType.ERC721, + tokenId: 1, + totalAmount: 1 + }) + ); + numOfRewardUnits.push(1); + + packContents.push( + ITokenBundle.Token({ + assetContract: address(erc721), + tokenType: ITokenBundle.TokenType.ERC721, + tokenId: 2, + totalAmount: 1 + }) + ); + numOfRewardUnits.push(1); + + packContents.push( + ITokenBundle.Token({ + assetContract: address(erc20), + tokenType: ITokenBundle.TokenType.ERC20, + tokenId: 0, + totalAmount: 1000 ether + }) + ); + numOfRewardUnits.push(100); + + packContents.push( + ITokenBundle.Token({ + assetContract: address(erc721), + tokenType: ITokenBundle.TokenType.ERC721, + tokenId: 3, + totalAmount: 1 + }) + ); + numOfRewardUnits.push(1); + + packContents.push( + ITokenBundle.Token({ + assetContract: address(erc721), + tokenType: ITokenBundle.TokenType.ERC721, + tokenId: 4, + totalAmount: 1 + }) + ); + numOfRewardUnits.push(1); + + packContents.push( + ITokenBundle.Token({ + assetContract: address(erc721), + tokenType: ITokenBundle.TokenType.ERC721, + tokenId: 5, + totalAmount: 1 + }) + ); + numOfRewardUnits.push(1); + + packContents.push( + ITokenBundle.Token({ + assetContract: address(erc1155), + tokenType: ITokenBundle.TokenType.ERC1155, + tokenId: 1, + totalAmount: 500 + }) + ); + numOfRewardUnits.push(50); + + erc20.mint(address(tokenOwner), 2000 ether); + erc721.mint(address(tokenOwner), 6); + erc1155.mint(address(tokenOwner), 0, 100); + erc1155.mint(address(tokenOwner), 1, 500); + + // additional contents, to check `addPackContents` + additionalContents.push( + ITokenBundle.Token({ + assetContract: address(erc1155), + tokenType: ITokenBundle.TokenType.ERC1155, + tokenId: 2, + totalAmount: 200 + }) + ); + additionalContentsRewardUnits.push(50); + + additionalContents.push( + ITokenBundle.Token({ + assetContract: address(erc20), + tokenType: ITokenBundle.TokenType.ERC20, + tokenId: 0, + totalAmount: 1000 ether + }) + ); + additionalContentsRewardUnits.push(100); + + tokenOwner.setAllowanceERC20(address(erc20), address(pack), type(uint256).max); + tokenOwner.setApprovalForAllERC721(address(erc721), address(pack), true); + tokenOwner.setApprovalForAllERC1155(address(erc1155), address(pack), true); + + vm.prank(deployer); + pack.grantRole(keccak256("MINTER_ROLE"), address(tokenOwner)); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: Miscellaneous + //////////////////////////////////////////////////////////////*/ + + function test_revert_addPackContents_RandomAccountGrief() public { + uint256 packId = pack.nextTokenIdToMint(); + address recipient = address(1); + + vm.prank(address(tokenOwner)); + pack.createPack(packContents, numOfRewardUnits, packUri, 0, 1, recipient); + + // random address tries to transfer zero amount + address randomAccount = address(0x123); + vm.prank(randomAccount); + pack.safeTransferFrom(randomAccount, address(567), packId, 0, ""); // zero transfer + + // canUpdatePack should remain true, since no packs were transferred + assertTrue(pack.canUpdatePack(packId)); + + erc20.mint(address(tokenOwner), 1000 ether); + erc1155.mint(address(tokenOwner), 2, 200); + + vm.prank(address(tokenOwner)); + // Should not revert + pack.addPackContents(packId, additionalContents, additionalContentsRewardUnits, recipient); + } + + function test_checkForwarders() public { + assertFalse(pack.isTrustedForwarder(eoaForwarder)); + assertFalse(pack.isTrustedForwarder(forwarder)); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `createPack` + //////////////////////////////////////////////////////////////*/ + + function test_interface() public pure { + console2.logBytes4(type(IERC20).interfaceId); + console2.logBytes4(type(IERC721).interfaceId); + console2.logBytes4(type(IERC1155).interfaceId); + } + + function test_supportsInterface() public { + assertEq(pack.supportsInterface(type(IERC2981Upgradeable).interfaceId), true); + assertEq(pack.supportsInterface(type(IERC721Receiver).interfaceId), true); + assertEq(pack.supportsInterface(type(IERC1155Receiver).interfaceId), true); + assertEq(pack.supportsInterface(type(IERC1155Upgradeable).interfaceId), true); + } + + /** + * note: Testing state changes; token owner calls `createPack` to pack owned tokens. + */ + function test_state_createPack() public { + uint256 packId = pack.nextTokenIdToMint(); + address recipient = address(1); + + vm.prank(address(tokenOwner)); + pack.createPack(packContents, numOfRewardUnits, packUri, 0, 1, recipient); + + assertEq(packId + 1, pack.nextTokenIdToMint()); + + (ITokenBundle.Token[] memory packed, ) = pack.getPackContents(packId); + assertEq(packed.length, packContents.length); + for (uint256 i = 0; i < packed.length; i += 1) { + assertEq(packed[i].assetContract, packContents[i].assetContract); + assertEq(uint256(packed[i].tokenType), uint256(packContents[i].tokenType)); + assertEq(packed[i].tokenId, packContents[i].tokenId); + assertEq(packed[i].totalAmount, packContents[i].totalAmount); + } + + assertEq(packUri, pack.uri(packId)); + } + + /* + * note: Testing state changes; token owner calls `createPack` to pack native tokens. + */ + function test_state_createPack_nativeTokens() public { + uint256 packId = pack.nextTokenIdToMint(); + address recipient = address(0x123); + + vm.deal(address(tokenOwner), 100 ether); + packContents.push( + ITokenBundle.Token({ + assetContract: 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE, + tokenType: ITokenBundle.TokenType.ERC20, + tokenId: 0, + totalAmount: 20 ether + }) + ); + numOfRewardUnits.push(20); + + vm.prank(address(tokenOwner)); + pack.createPack{ value: 20 ether }(packContents, numOfRewardUnits, packUri, 0, 1, recipient); + + assertEq(packId + 1, pack.nextTokenIdToMint()); + + (ITokenBundle.Token[] memory packed, ) = pack.getPackContents(packId); + assertEq(packed.length, packContents.length); + for (uint256 i = 0; i < packed.length; i += 1) { + assertEq(packed[i].assetContract, packContents[i].assetContract); + assertEq(uint256(packed[i].tokenType), uint256(packContents[i].tokenType)); + assertEq(packed[i].tokenId, packContents[i].tokenId); + assertEq(packed[i].totalAmount, packContents[i].totalAmount); + } + + assertEq(packUri, pack.uri(packId)); + } + + /** + * note: Testing state changes; token owner calls `createPack` to pack owned tokens. + * Only assets with ASSET_ROLE can be packed. + */ + function test_state_createPack_withAssetRoleRestriction() public { + vm.startPrank(deployer); + pack.revokeRole(keccak256("ASSET_ROLE"), address(0)); + for (uint256 i = 0; i < packContents.length; i += 1) { + if (!pack.hasRole(keccak256("ASSET_ROLE"), packContents[i].assetContract)) { + pack.grantRole(keccak256("ASSET_ROLE"), packContents[i].assetContract); + } + } + vm.stopPrank(); + + uint256 packId = pack.nextTokenIdToMint(); + address recipient = address(0x123); + + vm.prank(address(tokenOwner)); + pack.createPack(packContents, numOfRewardUnits, packUri, 0, 1, recipient); + + assertEq(packId + 1, pack.nextTokenIdToMint()); + + (ITokenBundle.Token[] memory packed, ) = pack.getPackContents(packId); + assertEq(packed.length, packContents.length); + for (uint256 i = 0; i < packed.length; i += 1) { + assertEq(packed[i].assetContract, packContents[i].assetContract); + assertEq(uint256(packed[i].tokenType), uint256(packContents[i].tokenType)); + assertEq(packed[i].tokenId, packContents[i].tokenId); + assertEq(packed[i].totalAmount, packContents[i].totalAmount); + } + + assertEq(packUri, pack.uri(packId)); + } + + /** + * note: Testing event emission; token owner calls `createPack` to pack owned tokens. + */ + function test_event_createPack_PackCreated() public { + uint256 packId = pack.nextTokenIdToMint(); + address recipient = address(0x123); + + vm.startPrank(address(tokenOwner)); + vm.expectEmit(true, true, true, true); + emit PackCreated(packId, recipient, 226); + + pack.createPack(packContents, numOfRewardUnits, packUri, 0, 1, recipient); + + vm.stopPrank(); + } + + /** + * note: Testing token balances; token owner calls `createPack` to pack owned tokens. + */ + function test_balances_createPack() public { + // ERC20 balance + assertEq(erc20.balanceOf(address(tokenOwner)), 2000 ether); + assertEq(erc20.balanceOf(address(pack)), 0); + + // ERC721 balance + assertEq(erc721.ownerOf(0), address(tokenOwner)); + assertEq(erc721.ownerOf(1), address(tokenOwner)); + assertEq(erc721.ownerOf(2), address(tokenOwner)); + assertEq(erc721.ownerOf(3), address(tokenOwner)); + assertEq(erc721.ownerOf(4), address(tokenOwner)); + assertEq(erc721.ownerOf(5), address(tokenOwner)); + + // ERC1155 balance + assertEq(erc1155.balanceOf(address(tokenOwner), 0), 100); + assertEq(erc1155.balanceOf(address(pack), 0), 0); + + assertEq(erc1155.balanceOf(address(tokenOwner), 1), 500); + assertEq(erc1155.balanceOf(address(pack), 1), 0); + + uint256 packId = pack.nextTokenIdToMint(); + address recipient = address(1); + + vm.prank(address(tokenOwner)); + (, uint256 totalSupply) = pack.createPack(packContents, numOfRewardUnits, packUri, 0, 1, recipient); + + // ERC20 balance + assertEq(erc20.balanceOf(address(tokenOwner)), 0); + assertEq(erc20.balanceOf(address(pack)), 2000 ether); + + // ERC721 balance + assertEq(erc721.ownerOf(0), address(pack)); + assertEq(erc721.ownerOf(1), address(pack)); + assertEq(erc721.ownerOf(2), address(pack)); + assertEq(erc721.ownerOf(3), address(pack)); + assertEq(erc721.ownerOf(4), address(pack)); + assertEq(erc721.ownerOf(5), address(pack)); + + // ERC1155 balance + assertEq(erc1155.balanceOf(address(tokenOwner), 0), 0); + assertEq(erc1155.balanceOf(address(pack), 0), 100); + + assertEq(erc1155.balanceOf(address(tokenOwner), 1), 0); + assertEq(erc1155.balanceOf(address(pack), 1), 500); + + // Pack wrapped token balance + assertEq(pack.balanceOf(address(recipient), packId), totalSupply); + } + + /** + * note: Testing revert condition; token owner calls `createPack` to pack owned tokens. + * Only assets with ASSET_ROLE can be packed, but assets being packed don't have that role. + */ + function test_revert_createPack_access_ASSET_ROLE() public { + vm.prank(deployer); + pack.revokeRole(keccak256("ASSET_ROLE"), address(0)); + + address recipient = address(0x123); + + vm.prank(address(tokenOwner)); + vm.expectRevert( + abi.encodeWithSelector( + Permissions.PermissionsUnauthorizedAccount.selector, + address(erc721), + keccak256("ASSET_ROLE") + ) + ); + pack.createPack(packContents, numOfRewardUnits, packUri, 0, 1, recipient); + } + + /** + * note: Testing revert condition; token owner calls `createPack` to pack owned tokens, without MINTER_ROLE. + */ + function test_revert_createPack_access_MINTER_ROLE() public { + vm.prank(address(tokenOwner)); + pack.renounceRole(keccak256("MINTER_ROLE"), address(tokenOwner)); + + address recipient = address(0x123); + + vm.prank(address(tokenOwner)); + vm.expectRevert( + abi.encodeWithSelector( + Permissions.PermissionsUnauthorizedAccount.selector, + address(tokenOwner), + keccak256("MINTER_ROLE") + ) + ); + pack.createPack(packContents, numOfRewardUnits, packUri, 0, 1, recipient); + } + + /** + * note: Testing revert condition; token owner calls `createPack` with insufficient value when packing native tokens. + */ + function test_revert_createPack_nativeTokens_insufficientValue() public { + address recipient = address(0x123); + + vm.deal(address(tokenOwner), 100 ether); + + packContents.push( + ITokenBundle.Token({ + assetContract: 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE, + tokenType: ITokenBundle.TokenType.ERC20, + tokenId: 0, + totalAmount: 20 ether + }) + ); + numOfRewardUnits.push(1); + + vm.prank(address(tokenOwner)); + vm.expectRevert( + abi.encodeWithSelector(CurrencyTransferLib.CurrencyTransferLibMismatchedValue.selector, 0, 20 ether) + ); + pack.createPack(packContents, numOfRewardUnits, packUri, 0, 1, recipient); + } + + /** + * note: Testing revert condition; token owner calls `createPack` to pack un-owned ERC20 tokens. + */ + function test_revert_createPack_notOwner_ERC20() public { + tokenOwner.transferERC20(address(erc20), address(0x12), 1000 ether); + + address recipient = address(0x123); + + vm.startPrank(address(tokenOwner)); + vm.expectRevert("ERC20: transfer amount exceeds balance"); + pack.createPack(packContents, numOfRewardUnits, packUri, 0, 1, recipient); + } + + /** + * note: Testing revert condition; token owner calls `createPack` to pack un-owned ERC721 tokens. + */ + function test_revert_createPack_notOwner_ERC721() public { + tokenOwner.transferERC721(address(erc721), address(0x12), 0); + + address recipient = address(0x123); + + vm.startPrank(address(tokenOwner)); + vm.expectRevert("ERC721: caller is not token owner or approved"); + pack.createPack(packContents, numOfRewardUnits, packUri, 0, 1, recipient); + } + + /** + * note: Testing revert condition; token owner calls `createPack` to pack un-owned ERC1155 tokens. + */ + function test_revert_createPack_notOwner_ERC1155() public { + tokenOwner.transferERC1155(address(erc1155), address(0x12), 0, 100, ""); + + address recipient = address(0x123); + + vm.startPrank(address(tokenOwner)); + vm.expectRevert("ERC1155: insufficient balance for transfer"); + pack.createPack(packContents, numOfRewardUnits, packUri, 0, 1, recipient); + } + + /** + * note: Testing revert condition; token owner calls `createPack` to pack un-approved ERC20 tokens. + */ + function test_revert_createPack_notApprovedTransfer_ERC20() public { + tokenOwner.setAllowanceERC20(address(erc20), address(pack), 0); + + address recipient = address(0x123); + + vm.startPrank(address(tokenOwner)); + vm.expectRevert("ERC20: insufficient allowance"); + pack.createPack(packContents, numOfRewardUnits, packUri, 0, 1, recipient); + } + + /** + * note: Testing revert condition; token owner calls `createPack` to pack un-approved ERC721 tokens. + */ + function test_revert_createPack_notApprovedTransfer_ERC721() public { + tokenOwner.setApprovalForAllERC721(address(erc721), address(pack), false); + + address recipient = address(0x123); + + vm.startPrank(address(tokenOwner)); + vm.expectRevert("ERC721: caller is not token owner or approved"); + pack.createPack(packContents, numOfRewardUnits, packUri, 0, 1, recipient); + } + + /** + * note: Testing revert condition; token owner calls `createPack` to pack un-approved ERC1155 tokens. + */ + function test_revert_createPack_notApprovedTransfer_ERC1155() public { + tokenOwner.setApprovalForAllERC1155(address(erc1155), address(pack), false); + + address recipient = address(0x123); + + vm.startPrank(address(tokenOwner)); + vm.expectRevert("ERC1155: caller is not token owner or approved"); + pack.createPack(packContents, numOfRewardUnits, packUri, 0, 1, recipient); + } + + /** + * note: Testing revert condition; token owner calls `createPack` with invalid token-type. + */ + function test_revert_createPack_invalidTokenType() public { + ITokenBundle.Token[] memory invalidContent = new ITokenBundle.Token[](1); + uint256[] memory rewardUnits = new uint256[](1); + + invalidContent[0] = ITokenBundle.Token({ + assetContract: address(erc721), + tokenType: ITokenBundle.TokenType.ERC20, + tokenId: 0, + totalAmount: 1 + }); + rewardUnits[0] = 1; + + address recipient = address(0x123); + + vm.startPrank(address(tokenOwner)); + vm.expectRevert("!TokenType"); + pack.createPack(invalidContent, rewardUnits, packUri, 0, 1, recipient); + } + + /** + * note: Testing revert condition; token owner calls `createPack` with total-amount as 0. + */ + function test_revert_createPack_zeroTotalAmount() public { + ITokenBundle.Token[] memory invalidContent = new ITokenBundle.Token[](1); + uint256[] memory rewardUnits = new uint256[](1); + + invalidContent[0] = ITokenBundle.Token({ + assetContract: address(erc20), + tokenType: ITokenBundle.TokenType.ERC20, + tokenId: 0, + totalAmount: 0 + }); + rewardUnits[0] = 10; + + address recipient = address(0x123); + + vm.startPrank(address(tokenOwner)); + vm.expectRevert("0 amt"); + pack.createPack(invalidContent, rewardUnits, packUri, 0, 1, recipient); + } + + /** + * note: Testing revert condition; token owner calls `createPack` with no tokens to pack. + */ + function test_revert_createPack_noTokensToPack() public { + ITokenBundle.Token[] memory emptyContent; + uint256[] memory rewardUnits; + + address recipient = address(0x123); + + bytes memory err = "!Len"; + vm.startPrank(address(tokenOwner)); + vm.expectRevert(err); + pack.createPack(emptyContent, rewardUnits, packUri, 0, 1, recipient); + } + + /** + * note: Testing revert condition; token owner calls `createPack` with unequal length of contents and rewardUnits. + */ + function test_revert_createPack_invalidRewardUnits() public { + uint256[] memory rewardUnits; + + address recipient = address(0x123); + + bytes memory err = "!Len"; + vm.startPrank(address(tokenOwner)); + vm.expectRevert(err); + pack.createPack(packContents, rewardUnits, packUri, 0, 1, recipient); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `addPackContents` + //////////////////////////////////////////////////////////////*/ + + /** + * note: Testing state changes; token owner calls `addPackContents` to pack more tokens. + */ + function test_state_addPackContents() public { + uint256 packId = pack.nextTokenIdToMint(); + address recipient = address(1); + + vm.prank(address(tokenOwner)); + pack.createPack(packContents, numOfRewardUnits, packUri, 0, 1, recipient); + + (ITokenBundle.Token[] memory packed, ) = pack.getPackContents(packId); + assertEq(packed.length, packContents.length); + for (uint256 i = 0; i < packed.length; i += 1) { + assertEq(packed[i].assetContract, packContents[i].assetContract); + assertEq(uint256(packed[i].tokenType), uint256(packContents[i].tokenType)); + assertEq(packed[i].tokenId, packContents[i].tokenId); + assertEq(packed[i].totalAmount, packContents[i].totalAmount); + } + + erc20.mint(address(tokenOwner), 1000 ether); + erc1155.mint(address(tokenOwner), 2, 200); + + vm.prank(address(tokenOwner)); + pack.addPackContents(packId, additionalContents, additionalContentsRewardUnits, recipient); + + (packed, ) = pack.getPackContents(packId); + assertEq(packed.length, packContents.length + additionalContents.length); + for (uint256 i = packContents.length; i < packed.length; i += 1) { + assertEq(packed[i].assetContract, additionalContents[i - packContents.length].assetContract); + assertEq(uint256(packed[i].tokenType), uint256(additionalContents[i - packContents.length].tokenType)); + assertEq(packed[i].tokenId, additionalContents[i - packContents.length].tokenId); + assertEq(packed[i].totalAmount, additionalContents[i - packContents.length].totalAmount); + } + } + + /** + * note: Testing token balances; token owner calls `addPackContents` to pack more tokens + * in an already existing pack. + */ + function test_balances_addPackContents() public { + uint256 packId = pack.nextTokenIdToMint(); + address recipient = address(1); + + vm.prank(address(tokenOwner)); + (, uint256 totalSupply) = pack.createPack(packContents, numOfRewardUnits, packUri, 0, 1, recipient); + + // ERC20 balance + assertEq(erc20.balanceOf(address(tokenOwner)), 0); + assertEq(erc20.balanceOf(address(pack)), 2000 ether); + + // ERC721 balance + assertEq(erc721.ownerOf(0), address(pack)); + assertEq(erc721.ownerOf(1), address(pack)); + assertEq(erc721.ownerOf(2), address(pack)); + assertEq(erc721.ownerOf(3), address(pack)); + assertEq(erc721.ownerOf(4), address(pack)); + assertEq(erc721.ownerOf(5), address(pack)); + + // ERC1155 balance + assertEq(erc1155.balanceOf(address(tokenOwner), 0), 0); + assertEq(erc1155.balanceOf(address(pack), 0), 100); + + assertEq(erc1155.balanceOf(address(tokenOwner), 1), 0); + assertEq(erc1155.balanceOf(address(pack), 1), 500); + + // Pack wrapped token balance + assertEq(pack.balanceOf(address(recipient), packId), totalSupply); + + erc20.mint(address(tokenOwner), 1000 ether); + erc1155.mint(address(tokenOwner), 2, 200); + + vm.prank(address(tokenOwner)); + (uint256 newTotalSupply, uint256 additionalSupply) = pack.addPackContents( + packId, + additionalContents, + additionalContentsRewardUnits, + recipient + ); + + // ERC20 balance after adding more tokens + assertEq(erc20.balanceOf(address(tokenOwner)), 0); + assertEq(erc20.balanceOf(address(pack)), 3000 ether); + + // ERC1155 balance after adding more tokens + assertEq(erc1155.balanceOf(address(tokenOwner), 2), 0); + assertEq(erc1155.balanceOf(address(pack), 2), 200); + + // Pack wrapped token balance + assertEq(pack.balanceOf(address(recipient), packId), newTotalSupply); + assertEq(totalSupply + additionalSupply, newTotalSupply); + } + + /** + * note: Testing revert condition; non-creator calls `addPackContents`. + */ + function test_revert_addPackContents_NotMinterRole() public { + uint256 packId = pack.nextTokenIdToMint(); + address recipient = address(1); + + vm.prank(address(tokenOwner)); + pack.createPack(packContents, numOfRewardUnits, packUri, 0, 1, recipient); + + address randomAccount = address(0x123); + + vm.prank(randomAccount); + vm.expectRevert( + abi.encodeWithSelector( + Permissions.PermissionsUnauthorizedAccount.selector, + randomAccount, + keccak256("MINTER_ROLE") + ) + ); + pack.addPackContents(packId, additionalContents, additionalContentsRewardUnits, recipient); + } + + /** + * note: Testing revert condition; adding tokens to non-existent pack. + */ + function test_revert_addPackContents_PackNonExistent() public { + vm.prank(address(tokenOwner)); + vm.expectRevert("!Allowed"); + pack.addPackContents(0, packContents, numOfRewardUnits, address(1)); + } + + /** + * note: Testing revert condition; adding tokens after packs have been distributed. + */ + function test_revert_addPackContents_CantUpdateAnymore() public { + uint256 packId = pack.nextTokenIdToMint(); + address recipient = address(1); + + vm.prank(address(tokenOwner)); + pack.createPack(packContents, numOfRewardUnits, packUri, 0, 1, recipient); + + vm.prank(recipient); + pack.safeTransferFrom(recipient, address(567), packId, 1, ""); + + vm.prank(address(tokenOwner)); + vm.expectRevert("!Allowed"); + pack.addPackContents(packId, additionalContents, additionalContentsRewardUnits, recipient); + } + + /** + * note: Testing revert condition; adding tokens with a different recipient. + */ + function test_revert_addPackContents_NotRecipient() public { + uint256 packId = pack.nextTokenIdToMint(); + address recipient = address(1); + + vm.prank(address(tokenOwner)); + pack.createPack(packContents, numOfRewardUnits, packUri, 0, 1, recipient); + + address randomRecipient = address(0x12345); + + bytes memory err = "!Bal"; + vm.expectRevert(err); + vm.prank(address(tokenOwner)); + pack.addPackContents(packId, additionalContents, additionalContentsRewardUnits, randomRecipient); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `openPack` + //////////////////////////////////////////////////////////////*/ + + /** + * note: Testing state changes; pack owner calls `openPack` to redeem underlying rewards. + */ + function test_state_openPack() public { + vm.warp(1000); + uint256 packId = pack.nextTokenIdToMint(); + uint256 packsToOpen = 3; + address recipient = address(1); + + vm.prank(address(tokenOwner)); + (, uint256 totalSupply) = pack.createPack(packContents, numOfRewardUnits, packUri, 0, 2, recipient); + + vm.prank(recipient, recipient); + ITokenBundle.Token[] memory rewardUnits = pack.openPack(packId, packsToOpen); + console2.log("total reward units: ", rewardUnits.length); + + for (uint256 i = 0; i < rewardUnits.length; i++) { + console2.log("----- reward unit number: ", i, "------"); + console2.log("asset contract: ", rewardUnits[i].assetContract); + console2.log("token type: ", uint256(rewardUnits[i].tokenType)); + console2.log("tokenId: ", rewardUnits[i].tokenId); + if (rewardUnits[i].tokenType == ITokenBundle.TokenType.ERC20) { + console2.log("total amount: ", rewardUnits[i].totalAmount / 1 ether, "ether"); + } else { + console2.log("total amount: ", rewardUnits[i].totalAmount); + } + console2.log(""); + } + + assertEq(packUri, pack.uri(packId)); + assertEq(pack.totalSupply(packId), totalSupply - packsToOpen); + + (ITokenBundle.Token[] memory packed, ) = pack.getPackContents(packId); + assertEq(packed.length, packContents.length); + } + + /** + * note: Total amount should get updated correctly -- reduce perUnitAmount from totalAmount of the token content, for each reward + */ + function test_state_openPack_totalAmounts_ERC721() public { + vm.warp(1000); + uint256 packId = pack.nextTokenIdToMint(); + uint256 packsToOpen = 1; + address recipient = address(1); + + erc721.mint(address(tokenOwner), 6); + + ITokenBundle.Token[] memory tempContents = new ITokenBundle.Token[](1); + uint256[] memory tempNumRewardUnits = new uint256[](1); + + tempContents[0] = ITokenBundle.Token({ + assetContract: address(erc721), + tokenType: ITokenBundle.TokenType.ERC721, + tokenId: 0, + totalAmount: 1 + }); + tempNumRewardUnits[0] = 1; + + vm.prank(address(tokenOwner)); + (, uint256 totalSupply) = pack.createPack(tempContents, tempNumRewardUnits, packUri, 0, 1, recipient); + + vm.prank(recipient, recipient); + ITokenBundle.Token[] memory rewardUnits = pack.openPack(packId, packsToOpen); + + assertEq(packUri, pack.uri(packId)); + assertEq(pack.totalSupply(packId), totalSupply - packsToOpen); + + (ITokenBundle.Token[] memory packed, ) = pack.getPackContents(packId); + assertEq(packed.length, tempContents.length); + assertEq(packed[0].totalAmount, tempContents[0].totalAmount - rewardUnits[0].totalAmount); + } + + /** + * note: Total amount should get updated correctly -- reduce perUnitAmount from totalAmount of the token content, for each reward + */ + function test_state_openPack_totalAmounts_ERC1155() public { + vm.warp(1000); + uint256 packId = pack.nextTokenIdToMint(); + uint256 packsToOpen = 1; + address recipient = address(1); + + erc1155.mint(address(tokenOwner), 0, 100); + + ITokenBundle.Token[] memory tempContents = new ITokenBundle.Token[](1); + uint256[] memory tempNumRewardUnits = new uint256[](1); + + tempContents[0] = ITokenBundle.Token({ + assetContract: address(erc1155), + tokenType: ITokenBundle.TokenType.ERC1155, + tokenId: 0, + totalAmount: 100 + }); + tempNumRewardUnits[0] = 10; + + vm.prank(address(tokenOwner)); + (, uint256 totalSupply) = pack.createPack(tempContents, tempNumRewardUnits, packUri, 0, 1, recipient); + + vm.prank(recipient, recipient); + ITokenBundle.Token[] memory rewardUnits = pack.openPack(packId, packsToOpen); + + assertEq(packUri, pack.uri(packId)); + assertEq(pack.totalSupply(packId), totalSupply - packsToOpen); + + (ITokenBundle.Token[] memory packed, ) = pack.getPackContents(packId); + assertEq(packed.length, tempContents.length); + assertEq(packed[0].totalAmount, tempContents[0].totalAmount - rewardUnits[0].totalAmount); + } + + /** + * note: Total amount should get updated correctly -- reduce perUnitAmount from totalAmount of the token content, for each reward + */ + function test_state_openPack_totalAmounts_ERC20() public { + vm.warp(1000); + uint256 packId = pack.nextTokenIdToMint(); + uint256 packsToOpen = 1; + address recipient = address(1); + + erc20.mint(address(tokenOwner), 2000 ether); + + ITokenBundle.Token[] memory tempContents = new ITokenBundle.Token[](1); + uint256[] memory tempNumRewardUnits = new uint256[](1); + + tempContents[0] = ITokenBundle.Token({ + assetContract: address(erc20), + tokenType: ITokenBundle.TokenType.ERC20, + tokenId: 0, + totalAmount: 1000 ether + }); + tempNumRewardUnits[0] = 50; + + vm.prank(address(tokenOwner)); + (, uint256 totalSupply) = pack.createPack(tempContents, tempNumRewardUnits, packUri, 0, 1, recipient); + + vm.prank(recipient, recipient); + ITokenBundle.Token[] memory rewardUnits = pack.openPack(packId, packsToOpen); + + assertEq(packUri, pack.uri(packId)); + assertEq(pack.totalSupply(packId), totalSupply - packsToOpen); + + (ITokenBundle.Token[] memory packed, ) = pack.getPackContents(packId); + assertEq(packed.length, tempContents.length); + assertEq(packed[0].totalAmount, tempContents[0].totalAmount - rewardUnits[0].totalAmount); + } + + /** + * note: Testing event emission; pack owner calls `openPack` to open owned packs. + */ + function test_event_openPack_PackOpened() public { + uint256 packId = pack.nextTokenIdToMint(); + address recipient = address(0x123); + + ITokenBundle.Token[] memory emptyRewardUnitsForTestingEvent; + + vm.prank(address(tokenOwner)); + pack.createPack(packContents, numOfRewardUnits, packUri, 0, 1, recipient); + + vm.expectEmit(true, true, false, false); + emit PackOpened(packId, recipient, 1, emptyRewardUnitsForTestingEvent); + + vm.prank(recipient, recipient); + pack.openPack(packId, 1); + } + + function test_balances_openPack() public { + uint256 packId = pack.nextTokenIdToMint(); + uint256 packsToOpen = 3; + address recipient = address(1); + + vm.prank(address(tokenOwner)); + pack.createPack(packContents, numOfRewardUnits, packUri, 0, 2, recipient); + + // ERC20 balance + assertEq(erc20.balanceOf(address(recipient)), 0); + assertEq(erc20.balanceOf(address(pack)), 2000 ether); + + // ERC721 balance + assertEq(erc721.ownerOf(0), address(pack)); + assertEq(erc721.ownerOf(1), address(pack)); + assertEq(erc721.ownerOf(2), address(pack)); + assertEq(erc721.ownerOf(3), address(pack)); + assertEq(erc721.ownerOf(4), address(pack)); + assertEq(erc721.ownerOf(5), address(pack)); + + // ERC1155 balance + assertEq(erc1155.balanceOf(address(recipient), 0), 0); + assertEq(erc1155.balanceOf(address(pack), 0), 100); + + assertEq(erc1155.balanceOf(address(recipient), 1), 0); + assertEq(erc1155.balanceOf(address(pack), 1), 500); + + vm.prank(recipient, recipient); + ITokenBundle.Token[] memory rewardUnits = pack.openPack(packId, packsToOpen); + console2.log("total reward units: ", rewardUnits.length); + + uint256 erc20Amount; + uint256[] memory erc1155Amounts = new uint256[](2); + uint256 erc721Amount; + + for (uint256 i = 0; i < rewardUnits.length; i++) { + console2.log("----- reward unit number: ", i, "------"); + console2.log("asset contract: ", rewardUnits[i].assetContract); + console2.log("token type: ", uint256(rewardUnits[i].tokenType)); + console2.log("tokenId: ", rewardUnits[i].tokenId); + if (rewardUnits[i].tokenType == ITokenBundle.TokenType.ERC20) { + console2.log("total amount: ", rewardUnits[i].totalAmount / 1 ether, "ether"); + console.log("balance of recipient: ", erc20.balanceOf(address(recipient)) / 1 ether, "ether"); + erc20Amount += rewardUnits[i].totalAmount; + } else if (rewardUnits[i].tokenType == ITokenBundle.TokenType.ERC1155) { + console2.log("total amount: ", rewardUnits[i].totalAmount); + console.log("balance of recipient: ", erc1155.balanceOf(address(recipient), rewardUnits[i].tokenId)); + erc1155Amounts[rewardUnits[i].tokenId] += rewardUnits[i].totalAmount; + } else if (rewardUnits[i].tokenType == ITokenBundle.TokenType.ERC721) { + console2.log("total amount: ", rewardUnits[i].totalAmount); + console.log("balance of recipient: ", erc721.balanceOf(address(recipient))); + erc721Amount += rewardUnits[i].totalAmount; + } + console2.log(""); + } + + assertEq(erc20.balanceOf(address(recipient)), erc20Amount); + assertEq(erc721.balanceOf(address(recipient)), erc721Amount); + + for (uint256 i = 0; i < erc1155Amounts.length; i += 1) { + assertEq(erc1155.balanceOf(address(recipient), i), erc1155Amounts[i]); + } + } + + /** + * note: Testing revert condition; caller of `openPack` is not EOA. + */ + function test_revert_openPack_notEOA() public { + uint256 packId = pack.nextTokenIdToMint(); + address recipient = address(0x123); + + vm.prank(address(tokenOwner)); + pack.createPack(packContents, numOfRewardUnits, packUri, 0, 1, recipient); + + vm.startPrank(recipient, address(27)); + string memory err = "!EOA"; + vm.expectRevert(bytes(err)); + pack.openPack(packId, 1); + } + + /** + * note: Testing revert condition; pack owner calls `openPack` to open more than owned packs. + */ + function test_revert_openPack_openMoreThanOwned() public { + uint256 packId = pack.nextTokenIdToMint(); + address recipient = address(0x123); + + vm.prank(address(tokenOwner)); + (, uint256 totalSupply) = pack.createPack(packContents, numOfRewardUnits, packUri, 0, 1, recipient); + + bytes memory err = "!Bal"; + vm.startPrank(recipient, recipient); + vm.expectRevert(err); + pack.openPack(packId, totalSupply + 1); + } + + /** + * note: Testing revert condition; pack owner calls `openPack` before start timestamp. + */ + function test_revert_openPack_openBeforeStart() public { + uint256 packId = pack.nextTokenIdToMint(); + address recipient = address(0x123); + vm.prank(address(tokenOwner)); + pack.createPack(packContents, numOfRewardUnits, packUri, 1000, 1, recipient); + + vm.startPrank(recipient, recipient); + vm.expectRevert("cant open"); + pack.openPack(packId, 1); + } + + /** + * note: Testing revert condition; pack owner calls `openPack` with pack-id non-existent or not owned. + */ + function test_revert_openPack_invalidPackId() public { + address recipient = address(0x123); + + vm.prank(address(tokenOwner)); + pack.createPack(packContents, numOfRewardUnits, packUri, 0, 1, recipient); + + bytes memory err = "!Bal"; + vm.startPrank(recipient, recipient); + vm.expectRevert(err); + pack.openPack(2, 1); + } + + /*/////////////////////////////////////////////////////////////// + Fuzz testing + //////////////////////////////////////////////////////////////*/ + + uint256 internal constant MAX_TOKENS = 2000; + + function getTokensToPack( + uint256 len + ) internal returns (ITokenBundle.Token[] memory tokensToPack, uint256[] memory rewardUnits) { + vm.assume(len < MAX_TOKENS); + tokensToPack = new ITokenBundle.Token[](len); + rewardUnits = new uint256[](len); + + for (uint256 i = 0; i < len; i += 1) { + uint256 random = uint256(keccak256(abi.encodePacked(len + i))) % MAX_TOKENS; + uint256 selector = random % 4; + + if (selector == 0) { + tokensToPack[i] = ITokenBundle.Token({ + assetContract: address(erc20), + tokenType: ITokenBundle.TokenType.ERC20, + tokenId: 0, + totalAmount: (random + 1) * 10 ether + }); + rewardUnits[i] = random + 1; + + erc20.mint(address(tokenOwner), tokensToPack[i].totalAmount); + } else if (selector == 1) { + uint256 tokenId = erc721.nextTokenIdToMint(); + + tokensToPack[i] = ITokenBundle.Token({ + assetContract: address(erc721), + tokenType: ITokenBundle.TokenType.ERC721, + tokenId: tokenId, + totalAmount: 1 + }); + rewardUnits[i] = 1; + + erc721.mint(address(tokenOwner), 1); + } else if (selector == 2) { + tokensToPack[i] = ITokenBundle.Token({ + assetContract: address(erc1155), + tokenType: ITokenBundle.TokenType.ERC1155, + tokenId: random, + totalAmount: (random + 1) * 10 + }); + rewardUnits[i] = random + 1; + + erc1155.mint(address(tokenOwner), tokensToPack[i].tokenId, tokensToPack[i].totalAmount); + } else if (selector == 3) { + tokensToPack[i] = ITokenBundle.Token({ + assetContract: address(0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE), + tokenType: ITokenBundle.TokenType.ERC20, + tokenId: 0, + totalAmount: 5 ether + }); + rewardUnits[i] = 5; + } + } + } + + function checkBalances( + ITokenBundle.Token[] memory rewardUnits, + address + ) + internal + pure + returns (uint256 nativeTokenAmount, uint256 erc20Amount, uint256[] memory erc1155Amounts, uint256 erc721Amount) + { + erc1155Amounts = new uint256[](MAX_TOKENS); + + for (uint256 i = 0; i < rewardUnits.length; i++) { + // console2.log("----- reward unit number: ", i, "------"); + // console2.log("asset contract: ", rewardUnits[i].assetContract); + // console2.log("token type: ", uint256(rewardUnits[i].tokenType)); + // console2.log("tokenId: ", rewardUnits[i].tokenId); + if (rewardUnits[i].tokenType == ITokenBundle.TokenType.ERC20) { + if (rewardUnits[i].assetContract == address(0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE)) { + // console2.log("total amount: ", rewardUnits[i].totalAmount / 1 ether, "ether"); + // console.log("balance of recipient: ", address(recipient).balance); + nativeTokenAmount += rewardUnits[i].totalAmount; + } else { + // console2.log("total amount: ", rewardUnits[i].totalAmount / 1 ether, "ether"); + // console.log("balance of recipient: ", erc20.balanceOf(address(recipient)) / 1 ether, "ether"); + erc20Amount += rewardUnits[i].totalAmount; + } + } else if (rewardUnits[i].tokenType == ITokenBundle.TokenType.ERC1155) { + // console2.log("total amount: ", rewardUnits[i].totalAmount); + // console.log("balance of recipient: ", erc1155.balanceOf(address(recipient), rewardUnits[i].tokenId)); + erc1155Amounts[rewardUnits[i].tokenId] += rewardUnits[i].totalAmount; + } else if (rewardUnits[i].tokenType == ITokenBundle.TokenType.ERC721) { + // console2.log("total amount: ", rewardUnits[i].totalAmount); + // console.log("balance of recipient: ", erc721.balanceOf(address(recipient))); + erc721Amount += rewardUnits[i].totalAmount; + } + // console2.log(""); + } + } + + function test_fuzz_state_createPack(uint256 x, uint128 y) public { + (ITokenBundle.Token[] memory tokensToPack, uint256[] memory rewardUnits) = getTokensToPack(x); + if (tokensToPack.length == 0) { + return; + } + + uint256 packId = pack.nextTokenIdToMint(); + address recipient = address(0x123); + uint256 totalRewardUnits; + uint256 nativeTokenPacked; + + for (uint256 i = 0; i < tokensToPack.length; i += 1) { + totalRewardUnits += rewardUnits[i]; + if (tokensToPack[i].assetContract == address(0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE)) { + nativeTokenPacked += tokensToPack[i].totalAmount; + } + } + vm.deal(address(tokenOwner), nativeTokenPacked); + vm.assume(y > 0 && totalRewardUnits % y == 0); + + vm.prank(address(tokenOwner)); + (, uint256 totalSupply) = pack.createPack{ value: nativeTokenPacked }( + tokensToPack, + rewardUnits, + packUri, + 0, + y, + recipient + ); + console2.log("total supply: ", totalSupply); + console2.log("total reward units: ", totalRewardUnits); + + assertEq(packId + 1, pack.nextTokenIdToMint()); + + (ITokenBundle.Token[] memory packed, ) = pack.getPackContents(packId); + assertEq(packed.length, tokensToPack.length); + for (uint256 i = 0; i < packed.length; i += 1) { + assertEq(packed[i].assetContract, tokensToPack[i].assetContract); + assertEq(uint256(packed[i].tokenType), uint256(tokensToPack[i].tokenType)); + assertEq(packed[i].tokenId, tokensToPack[i].tokenId); + assertEq(packed[i].totalAmount, tokensToPack[i].totalAmount); + } + + assertEq(packUri, pack.uri(packId)); + } + + /*/////////////////////////////////////////////////////////////// + Scenario/Exploit tests + //////////////////////////////////////////////////////////////*/ + /** + * note: Testing revert condition; token owner calls `createPack` to pack owned tokens. + */ + function test_revert_createPack_reentrancy() public { + MaliciousERC20 malERC20 = new MaliciousERC20(payable(address(pack))); + ITokenBundle.Token[] memory content = new ITokenBundle.Token[](1); + uint256[] memory rewards = new uint256[](1); + + malERC20.mint(address(tokenOwner), 10 ether); + content[0] = ITokenBundle.Token({ + assetContract: address(malERC20), + tokenType: ITokenBundle.TokenType.ERC20, + tokenId: 0, + totalAmount: 10 ether + }); + rewards[0] = 10; + + tokenOwner.setAllowanceERC20(address(malERC20), address(pack), 10 ether); + + address recipient = address(0x123); + + vm.prank(address(deployer)); + pack.grantRole(keccak256("MINTER_ROLE"), address(malERC20)); + + vm.startPrank(address(tokenOwner)); + vm.expectRevert("ReentrancyGuard: reentrant call"); + pack.createPack(content, rewards, packUri, 0, 1, recipient); + } +} + +contract MaliciousERC20 is MockERC20, ITokenBundle { + Pack public pack; + + constructor(address payable _pack) { + pack = Pack(_pack); + } + + function transferFrom(address from, address to, uint256 amount) public override returns (bool) { + ITokenBundle.Token[] memory content = new ITokenBundle.Token[](1); + uint256[] memory rewards = new uint256[](1); + + address recipient = address(0x123); + pack.createPack(content, rewards, "", 0, 1, recipient); + return super.transferFrom(from, to, amount); + } +} diff --git a/src/test/pack/PackVRFDirect.t.sol b/src/test/pack/PackVRFDirect.t.sol new file mode 100644 index 000000000..55a620e19 --- /dev/null +++ b/src/test/pack/PackVRFDirect.t.sol @@ -0,0 +1,1055 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { PackVRFDirect, IERC2981Upgradeable, IERC721Receiver, IERC1155Upgradeable } from "contracts/prebuilts/pack/PackVRFDirect.sol"; +import { IPack } from "contracts/prebuilts/interface/IPack.sol"; +import { ITokenBundle } from "contracts/extension/interface/ITokenBundle.sol"; +import { CurrencyTransferLib } from "contracts/lib/CurrencyTransferLib.sol"; + +// Test imports +import { MockERC20 } from "../mocks/MockERC20.sol"; +import { Wallet } from "../utils/Wallet.sol"; +import "../utils/BaseTest.sol"; + +contract PackVRFDirectTest is BaseTest { + /// @notice Emitted when a set of packs is created. + event PackCreated(uint256 indexed packId, address recipient, uint256 totalPacksCreated); + + /// @notice Emitted when the opening of a pack is requested. + event PackOpenRequested(address indexed opener, uint256 indexed packId, uint256 amountToOpen, uint256 requestId); + + /// @notice Emitted when Chainlink VRF fulfills a random number request. + event PackRandomnessFulfilled(uint256 indexed packId, uint256 indexed requestId); + + /// @notice Emitted when a pack is opened. + event PackOpened( + uint256 indexed packId, + address indexed opener, + uint256 numOfPacksOpened, + ITokenBundle.Token[] rewardUnitsDistributed + ); + + PackVRFDirect internal pack; + + Wallet internal tokenOwner; + string internal packUri; + ITokenBundle.Token[] internal packContents; + ITokenBundle.Token[] internal additionalContents; + uint256[] internal numOfRewardUnits; + uint256[] internal additionalContentsRewardUnits; + + function setUp() public virtual override { + super.setUp(); + + pack = PackVRFDirect(payable(getContract("PackVRFDirect"))); + + tokenOwner = getWallet(); + packUri = "ipfs://"; + + packContents.push( + ITokenBundle.Token({ + assetContract: address(erc721), + tokenType: ITokenBundle.TokenType.ERC721, + tokenId: 0, + totalAmount: 1 + }) + ); + numOfRewardUnits.push(1); + + packContents.push( + ITokenBundle.Token({ + assetContract: address(erc1155), + tokenType: ITokenBundle.TokenType.ERC1155, + tokenId: 0, + totalAmount: 100 + }) + ); + numOfRewardUnits.push(20); + + packContents.push( + ITokenBundle.Token({ + assetContract: address(erc20), + tokenType: ITokenBundle.TokenType.ERC20, + tokenId: 0, + totalAmount: 1000 ether + }) + ); + numOfRewardUnits.push(50); + + packContents.push( + ITokenBundle.Token({ + assetContract: address(erc721), + tokenType: ITokenBundle.TokenType.ERC721, + tokenId: 1, + totalAmount: 1 + }) + ); + numOfRewardUnits.push(1); + + packContents.push( + ITokenBundle.Token({ + assetContract: address(erc721), + tokenType: ITokenBundle.TokenType.ERC721, + tokenId: 2, + totalAmount: 1 + }) + ); + numOfRewardUnits.push(1); + + packContents.push( + ITokenBundle.Token({ + assetContract: address(erc20), + tokenType: ITokenBundle.TokenType.ERC20, + tokenId: 0, + totalAmount: 1000 ether + }) + ); + numOfRewardUnits.push(100); + + packContents.push( + ITokenBundle.Token({ + assetContract: address(erc721), + tokenType: ITokenBundle.TokenType.ERC721, + tokenId: 3, + totalAmount: 1 + }) + ); + numOfRewardUnits.push(1); + + packContents.push( + ITokenBundle.Token({ + assetContract: address(erc721), + tokenType: ITokenBundle.TokenType.ERC721, + tokenId: 4, + totalAmount: 1 + }) + ); + numOfRewardUnits.push(1); + + packContents.push( + ITokenBundle.Token({ + assetContract: address(erc721), + tokenType: ITokenBundle.TokenType.ERC721, + tokenId: 5, + totalAmount: 1 + }) + ); + numOfRewardUnits.push(1); + + packContents.push( + ITokenBundle.Token({ + assetContract: address(erc1155), + tokenType: ITokenBundle.TokenType.ERC1155, + tokenId: 1, + totalAmount: 500 + }) + ); + numOfRewardUnits.push(50); + + erc20.mint(address(tokenOwner), 2000 ether); + erc721.mint(address(tokenOwner), 6); + erc1155.mint(address(tokenOwner), 0, 100); + erc1155.mint(address(tokenOwner), 1, 500); + + // additional contents, to check `addPackContents` + additionalContents.push( + ITokenBundle.Token({ + assetContract: address(erc1155), + tokenType: ITokenBundle.TokenType.ERC1155, + tokenId: 2, + totalAmount: 200 + }) + ); + additionalContentsRewardUnits.push(50); + + additionalContents.push( + ITokenBundle.Token({ + assetContract: address(erc20), + tokenType: ITokenBundle.TokenType.ERC20, + tokenId: 0, + totalAmount: 1000 ether + }) + ); + additionalContentsRewardUnits.push(100); + + tokenOwner.setAllowanceERC20(address(erc20), address(pack), type(uint256).max); + tokenOwner.setApprovalForAllERC721(address(erc721), address(pack), true); + tokenOwner.setApprovalForAllERC1155(address(erc1155), address(pack), true); + + vm.prank(deployer); + pack.grantRole(keccak256("MINTER_ROLE"), address(tokenOwner)); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `createPack` + //////////////////////////////////////////////////////////////*/ + + function test_interface() public pure { + console2.logBytes4(type(IERC20).interfaceId); + console2.logBytes4(type(IERC721).interfaceId); + console2.logBytes4(type(IERC1155).interfaceId); + } + + function test_supportsInterface() public { + assertEq(pack.supportsInterface(type(IERC2981Upgradeable).interfaceId), true); + assertEq(pack.supportsInterface(type(IERC721Receiver).interfaceId), true); + assertEq(pack.supportsInterface(type(IERC1155Receiver).interfaceId), true); + assertEq(pack.supportsInterface(type(IERC1155Upgradeable).interfaceId), true); + } + + /** + * note: Testing state changes; token owner calls `createPack` to pack owned tokens. + */ + function test_state_createPack() public { + uint256 packId = pack.nextTokenIdToMint(); + address recipient = address(1); + + vm.prank(address(tokenOwner)); + pack.createPack(packContents, numOfRewardUnits, packUri, 0, 1, recipient); + + assertEq(packId + 1, pack.nextTokenIdToMint()); + + (ITokenBundle.Token[] memory packed, ) = pack.getPackContents(packId); + assertEq(packed.length, packContents.length); + for (uint256 i = 0; i < packed.length; i += 1) { + assertEq(packed[i].assetContract, packContents[i].assetContract); + assertEq(uint256(packed[i].tokenType), uint256(packContents[i].tokenType)); + assertEq(packed[i].tokenId, packContents[i].tokenId); + assertEq(packed[i].totalAmount, packContents[i].totalAmount); + } + + assertEq(packUri, pack.uri(packId)); + } + + /* + * note: Testing state changes; token owner calls `createPack` to pack native tokens. + */ + function test_state_createPack_nativeTokens() public { + uint256 packId = pack.nextTokenIdToMint(); + address recipient = address(0x123); + + vm.deal(address(tokenOwner), 100 ether); + packContents.push( + ITokenBundle.Token({ + assetContract: 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE, + tokenType: ITokenBundle.TokenType.ERC20, + tokenId: 0, + totalAmount: 20 ether + }) + ); + numOfRewardUnits.push(20); + + vm.prank(address(tokenOwner)); + pack.createPack{ value: 20 ether }(packContents, numOfRewardUnits, packUri, 0, 1, recipient); + + assertEq(packId + 1, pack.nextTokenIdToMint()); + + (ITokenBundle.Token[] memory packed, ) = pack.getPackContents(packId); + assertEq(packed.length, packContents.length); + for (uint256 i = 0; i < packed.length; i += 1) { + assertEq(packed[i].assetContract, packContents[i].assetContract); + assertEq(uint256(packed[i].tokenType), uint256(packContents[i].tokenType)); + assertEq(packed[i].tokenId, packContents[i].tokenId); + assertEq(packed[i].totalAmount, packContents[i].totalAmount); + } + + assertEq(packUri, pack.uri(packId)); + } + + /** + * note: Testing event emission; token owner calls `createPack` to pack owned tokens. + */ + function test_event_createPack_PackCreated() public { + uint256 packId = pack.nextTokenIdToMint(); + address recipient = address(0x123); + + vm.startPrank(address(tokenOwner)); + vm.expectEmit(true, true, true, true); + emit PackCreated(packId, recipient, 226); + + pack.createPack(packContents, numOfRewardUnits, packUri, 0, 1, recipient); + + vm.stopPrank(); + } + + /** + * note: Testing token balances; token owner calls `createPack` to pack owned tokens. + */ + function test_balances_createPack() public { + // ERC20 balance + assertEq(erc20.balanceOf(address(tokenOwner)), 2000 ether); + assertEq(erc20.balanceOf(address(pack)), 0); + + // ERC721 balance + assertEq(erc721.ownerOf(0), address(tokenOwner)); + assertEq(erc721.ownerOf(1), address(tokenOwner)); + assertEq(erc721.ownerOf(2), address(tokenOwner)); + assertEq(erc721.ownerOf(3), address(tokenOwner)); + assertEq(erc721.ownerOf(4), address(tokenOwner)); + assertEq(erc721.ownerOf(5), address(tokenOwner)); + + // ERC1155 balance + assertEq(erc1155.balanceOf(address(tokenOwner), 0), 100); + assertEq(erc1155.balanceOf(address(pack), 0), 0); + + assertEq(erc1155.balanceOf(address(tokenOwner), 1), 500); + assertEq(erc1155.balanceOf(address(pack), 1), 0); + + uint256 packId = pack.nextTokenIdToMint(); + address recipient = address(1); + + vm.prank(address(tokenOwner)); + (, uint256 totalSupply) = pack.createPack(packContents, numOfRewardUnits, packUri, 0, 1, recipient); + + // ERC20 balance + assertEq(erc20.balanceOf(address(tokenOwner)), 0); + assertEq(erc20.balanceOf(address(pack)), 2000 ether); + + // ERC721 balance + assertEq(erc721.ownerOf(0), address(pack)); + assertEq(erc721.ownerOf(1), address(pack)); + assertEq(erc721.ownerOf(2), address(pack)); + assertEq(erc721.ownerOf(3), address(pack)); + assertEq(erc721.ownerOf(4), address(pack)); + assertEq(erc721.ownerOf(5), address(pack)); + + // ERC1155 balance + assertEq(erc1155.balanceOf(address(tokenOwner), 0), 0); + assertEq(erc1155.balanceOf(address(pack), 0), 100); + + assertEq(erc1155.balanceOf(address(tokenOwner), 1), 0); + assertEq(erc1155.balanceOf(address(pack), 1), 500); + + // Pack wrapped token balance + assertEq(pack.balanceOf(address(recipient), packId), totalSupply); + } + + /** + * note: Testing revert condition; token owner calls `createPack` to pack owned tokens, without MINTER_ROLE. + */ + function test_revert_createPack_access_MINTER_ROLE() public { + vm.prank(address(tokenOwner)); + pack.renounceRole(keccak256("MINTER_ROLE"), address(tokenOwner)); + + address recipient = address(0x123); + + vm.prank(address(tokenOwner)); + vm.expectRevert( + abi.encodeWithSelector( + Permissions.PermissionsUnauthorizedAccount.selector, + address(tokenOwner), + keccak256("MINTER_ROLE") + ) + ); + pack.createPack(packContents, numOfRewardUnits, packUri, 0, 1, recipient); + } + + /** + * note: Testing revert condition; token owner calls `createPack` with insufficient value when packing native tokens. + */ + function test_revert_createPack_nativeTokens_insufficientValue() public { + address recipient = address(0x123); + + vm.deal(address(tokenOwner), 100 ether); + + packContents.push( + ITokenBundle.Token({ + assetContract: 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE, + tokenType: ITokenBundle.TokenType.ERC20, + tokenId: 0, + totalAmount: 20 ether + }) + ); + numOfRewardUnits.push(1); + + vm.prank(address(tokenOwner)); + vm.expectRevert( + abi.encodeWithSelector(CurrencyTransferLib.CurrencyTransferLibMismatchedValue.selector, 0, 20 ether) + ); + pack.createPack(packContents, numOfRewardUnits, packUri, 0, 1, recipient); + } + + /** + * note: Testing revert condition; token owner calls `createPack` to pack un-owned ERC20 tokens. + */ + function test_revert_createPack_notOwner_ERC20() public { + tokenOwner.transferERC20(address(erc20), address(0x12), 1000 ether); + + address recipient = address(0x123); + + vm.startPrank(address(tokenOwner)); + vm.expectRevert("ERC20: transfer amount exceeds balance"); + pack.createPack(packContents, numOfRewardUnits, packUri, 0, 1, recipient); + } + + /** + * note: Testing revert condition; token owner calls `createPack` to pack un-owned ERC721 tokens. + */ + function test_revert_createPack_notOwner_ERC721() public { + tokenOwner.transferERC721(address(erc721), address(0x12), 0); + + address recipient = address(0x123); + + vm.startPrank(address(tokenOwner)); + vm.expectRevert("ERC721: caller is not token owner or approved"); + pack.createPack(packContents, numOfRewardUnits, packUri, 0, 1, recipient); + } + + /** + * note: Testing revert condition; token owner calls `createPack` to pack un-owned ERC1155 tokens. + */ + function test_revert_createPack_notOwner_ERC1155() public { + tokenOwner.transferERC1155(address(erc1155), address(0x12), 0, 100, ""); + + address recipient = address(0x123); + + vm.startPrank(address(tokenOwner)); + vm.expectRevert("ERC1155: insufficient balance for transfer"); + pack.createPack(packContents, numOfRewardUnits, packUri, 0, 1, recipient); + } + + /** + * note: Testing revert condition; token owner calls `createPack` to pack un-approved ERC20 tokens. + */ + function test_revert_createPack_notApprovedTransfer_ERC20() public { + tokenOwner.setAllowanceERC20(address(erc20), address(pack), 0); + + address recipient = address(0x123); + + vm.startPrank(address(tokenOwner)); + vm.expectRevert("ERC20: insufficient allowance"); + pack.createPack(packContents, numOfRewardUnits, packUri, 0, 1, recipient); + } + + /** + * note: Testing revert condition; token owner calls `createPack` to pack un-approved ERC721 tokens. + */ + function test_revert_createPack_notApprovedTransfer_ERC721() public { + tokenOwner.setApprovalForAllERC721(address(erc721), address(pack), false); + + address recipient = address(0x123); + + vm.startPrank(address(tokenOwner)); + vm.expectRevert("ERC721: caller is not token owner or approved"); + pack.createPack(packContents, numOfRewardUnits, packUri, 0, 1, recipient); + } + + /** + * note: Testing revert condition; token owner calls `createPack` to pack un-approved ERC1155 tokens. + */ + function test_revert_createPack_notApprovedTransfer_ERC1155() public { + tokenOwner.setApprovalForAllERC1155(address(erc1155), address(pack), false); + + address recipient = address(0x123); + + vm.startPrank(address(tokenOwner)); + vm.expectRevert("ERC1155: caller is not token owner or approved"); + pack.createPack(packContents, numOfRewardUnits, packUri, 0, 1, recipient); + } + + /** + * note: Testing revert condition; token owner calls `createPack` with invalid token-type. + */ + function test_revert_createPack_invalidTokenType() public { + ITokenBundle.Token[] memory invalidContent = new ITokenBundle.Token[](1); + uint256[] memory rewardUnits = new uint256[](1); + + invalidContent[0] = ITokenBundle.Token({ + assetContract: address(erc721), + tokenType: ITokenBundle.TokenType.ERC20, + tokenId: 0, + totalAmount: 1 + }); + rewardUnits[0] = 1; + + address recipient = address(0x123); + + vm.startPrank(address(tokenOwner)); + vm.expectRevert("!TokenType"); + pack.createPack(invalidContent, rewardUnits, packUri, 0, 1, recipient); + } + + /** + * note: Testing revert condition; token owner calls `createPack` with total-amount as 0. + */ + function test_revert_createPack_zeroTotalAmount() public { + ITokenBundle.Token[] memory invalidContent = new ITokenBundle.Token[](1); + uint256[] memory rewardUnits = new uint256[](1); + + invalidContent[0] = ITokenBundle.Token({ + assetContract: address(erc20), + tokenType: ITokenBundle.TokenType.ERC20, + tokenId: 0, + totalAmount: 0 + }); + rewardUnits[0] = 10; + + address recipient = address(0x123); + + vm.startPrank(address(tokenOwner)); + vm.expectRevert("0 amt"); + pack.createPack(invalidContent, rewardUnits, packUri, 0, 1, recipient); + } + + /** + * note: Testing revert condition; token owner calls `createPack` with no tokens to pack. + */ + function test_revert_createPack_noTokensToPack() public { + ITokenBundle.Token[] memory emptyContent; + uint256[] memory rewardUnits; + + address recipient = address(0x123); + + bytes memory err = "!Len"; + vm.startPrank(address(tokenOwner)); + vm.expectRevert(err); + pack.createPack(emptyContent, rewardUnits, packUri, 0, 1, recipient); + } + + /** + * note: Testing revert condition; token owner calls `createPack` with unequal length of contents and rewardUnits. + */ + function test_revert_createPack_invalidRewardUnits() public { + uint256[] memory rewardUnits; + + address recipient = address(0x123); + + bytes memory err = "!Len"; + vm.startPrank(address(tokenOwner)); + vm.expectRevert(err); + pack.createPack(packContents, rewardUnits, packUri, 0, 1, recipient); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `openPackAndClaimRewards` + //////////////////////////////////////////////////////////////*/ + + /** + * note: Testing state changes; pack owner calls `openPack` to redeem underlying rewards. + */ + function test_state_openPackAndClaimRewards() public { + vm.warp(1000); + uint256 packId = pack.nextTokenIdToMint(); + uint256 packsToOpen = 3; + address recipient = address(1); + + vm.prank(address(tokenOwner)); + pack.createPack(packContents, numOfRewardUnits, packUri, 0, 2, recipient); + + vm.prank(recipient, recipient); + uint256 requestId = pack.openPackAndClaimRewards(packId, packsToOpen, 2_500_000); + console2.log("request ID for opening pack:", requestId); + + uint256[] memory randomValues = new uint256[](1); + randomValues[0] = 12345678; + + ITokenBundle.Token[] memory emptyRewardUnitsForTestingEvent; + + vm.expectEmit(true, true, false, false); + emit PackOpened(packId, recipient, 1, emptyRewardUnitsForTestingEvent); + + vm.prank(vrfV2Wrapper); + pack.rawFulfillRandomWords(requestId, randomValues); + + assertFalse(pack.canClaimRewards(recipient)); + } + + /** + * note: Testing state changes; pack owner calls `openPack` to redeem underlying rewards. + */ + function test_state_openPackAndClaimRewards_lowGasFailsafe() public { + vm.warp(1000); + uint256 packId = pack.nextTokenIdToMint(); + uint256 packsToOpen = 3; + address recipient = address(1); + + vm.prank(address(tokenOwner)); + pack.createPack(packContents, numOfRewardUnits, packUri, 0, 2, recipient); + + vm.prank(recipient, recipient); + uint256 requestId = pack.openPackAndClaimRewards(packId, packsToOpen, 2); + console2.log("request ID for opening pack:", requestId); + + uint256[] memory randomValues = new uint256[](1); + randomValues[0] = 12345678; + + // check state before + assertFalse(pack.canClaimRewards(recipient)); + console.log(pack.canClaimRewards(recipient)); + + // mock the call with low gas, causing revert in _claimRewards + vm.prank(vrfV2Wrapper); + pack.rawFulfillRandomWords{ gas: 100_000 }(requestId, randomValues); + + // check state after + assertTrue(pack.canClaimRewards(recipient)); + console.log(pack.canClaimRewards(recipient)); + } + + /** + * note: Cannot open pack again while a previous openPack request is in flight. + */ + function test_revert_openPackAndClaimRewards_ReqInFlight() public { + vm.warp(1000); + uint256 packId = pack.nextTokenIdToMint(); + uint256 packsToOpen = 3; + address recipient = address(1); + + vm.prank(address(tokenOwner)); + pack.createPack(packContents, numOfRewardUnits, packUri, 0, 2, recipient); + + vm.prank(recipient, recipient); + uint256 requestId = pack.openPackAndClaimRewards(packId, packsToOpen, 2_500_000); + console2.log("request ID for opening pack:", requestId); + + vm.expectRevert("ReqInFlight"); + + vm.prank(recipient, recipient); + pack.openPackAndClaimRewards(packId, packsToOpen, 2_500_000); + + vm.expectRevert("ReqInFlight"); + + vm.prank(recipient, recipient); + pack.openPack(packId, packsToOpen); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `openPack` + //////////////////////////////////////////////////////////////*/ + + /** + * note: Testing state changes; pack owner calls `openPack` to redeem underlying rewards. + */ + function test_state_openPack() public { + vm.warp(1000); + uint256 packId = pack.nextTokenIdToMint(); + uint256 packsToOpen = 3; + address recipient = address(1); + + vm.prank(address(tokenOwner)); + (, uint256 totalSupply) = pack.createPack(packContents, numOfRewardUnits, packUri, 0, 2, recipient); + + vm.prank(recipient, recipient); + uint256 requestId = pack.openPack(packId, packsToOpen); + console2.log("request ID for opening pack:", requestId); + + uint256[] memory randomValues = new uint256[](1); + randomValues[0] = 12345678; + + vm.prank(vrfV2Wrapper); + pack.rawFulfillRandomWords(requestId, randomValues); + + vm.prank(recipient, recipient); + ITokenBundle.Token[] memory rewardUnits = pack.claimRewards(); + console2.log("total reward units: ", rewardUnits.length); + + for (uint256 i = 0; i < rewardUnits.length; i++) { + console2.log("----- reward unit number: ", i, "------"); + console2.log("asset contract: ", rewardUnits[i].assetContract); + console2.log("token type: ", uint256(rewardUnits[i].tokenType)); + console2.log("tokenId: ", rewardUnits[i].tokenId); + if (rewardUnits[i].tokenType == ITokenBundle.TokenType.ERC20) { + console2.log("total amount: ", rewardUnits[i].totalAmount / 1 ether, "ether"); + } else { + console2.log("total amount: ", rewardUnits[i].totalAmount); + } + console2.log(""); + } + + assertEq(packUri, pack.uri(packId)); + assertEq(pack.totalSupply(packId), totalSupply - packsToOpen); + + (ITokenBundle.Token[] memory packed, ) = pack.getPackContents(packId); + assertEq(packed.length, packContents.length); + } + + /** + * note: Testing event emission; pack owner calls `openPack` to open owned packs. + */ + function test_event_openPack() public { + uint256 packId = pack.nextTokenIdToMint(); + address recipient = address(0x123); + + vm.prank(address(tokenOwner)); + pack.createPack(packContents, numOfRewardUnits, packUri, 0, 1, recipient); + + vm.expectEmit(true, true, false, false); + emit PackOpenRequested(recipient, packId, 1, VRFV2Wrapper(vrfV2Wrapper).lastRequestId()); + + vm.prank(recipient, recipient); + uint256 requestId = pack.openPack(packId, 1); + + vm.expectEmit(true, true, false, true); + emit PackRandomnessFulfilled(packId, requestId); + + uint256[] memory randomValues = new uint256[](1); + randomValues[0] = 12345678; + + vm.prank(vrfV2Wrapper); + pack.rawFulfillRandomWords(requestId, randomValues); + + ITokenBundle.Token[] memory emptyRewardUnitsForTestingEvent; + + vm.expectEmit(true, true, false, false); + emit PackOpened(packId, recipient, 1, emptyRewardUnitsForTestingEvent); + + vm.prank(recipient, recipient); + pack.claimRewards(); + } + + function test_balances_openPack() public { + uint256 packId = pack.nextTokenIdToMint(); + uint256 packsToOpen = 3; + address recipient = address(1); + + vm.prank(address(tokenOwner)); + pack.createPack(packContents, numOfRewardUnits, packUri, 0, 2, recipient); + + // ERC20 balance + assertEq(erc20.balanceOf(address(recipient)), 0); + assertEq(erc20.balanceOf(address(pack)), 2000 ether); + + // ERC721 balance + assertEq(erc721.ownerOf(0), address(pack)); + assertEq(erc721.ownerOf(1), address(pack)); + assertEq(erc721.ownerOf(2), address(pack)); + assertEq(erc721.ownerOf(3), address(pack)); + assertEq(erc721.ownerOf(4), address(pack)); + assertEq(erc721.ownerOf(5), address(pack)); + + // ERC1155 balance + assertEq(erc1155.balanceOf(address(recipient), 0), 0); + assertEq(erc1155.balanceOf(address(pack), 0), 100); + + assertEq(erc1155.balanceOf(address(recipient), 1), 0); + assertEq(erc1155.balanceOf(address(pack), 1), 500); + + vm.prank(recipient, recipient); + uint256 requestId = pack.openPack(packId, packsToOpen); + console2.log("request ID for opening pack:", requestId); + + uint256[] memory randomValues = new uint256[](1); + randomValues[0] = 12345678; + + vm.prank(vrfV2Wrapper); + pack.rawFulfillRandomWords(requestId, randomValues); + + vm.prank(recipient, recipient); + ITokenBundle.Token[] memory rewardUnits = pack.claimRewards(); + console2.log("total reward units: ", rewardUnits.length); + + uint256 erc20Amount; + uint256[] memory erc1155Amounts = new uint256[](2); + uint256 erc721Amount; + + for (uint256 i = 0; i < rewardUnits.length; i++) { + console2.log("----- reward unit number: ", i, "------"); + console2.log("asset contract: ", rewardUnits[i].assetContract); + console2.log("token type: ", uint256(rewardUnits[i].tokenType)); + console2.log("tokenId: ", rewardUnits[i].tokenId); + if (rewardUnits[i].tokenType == ITokenBundle.TokenType.ERC20) { + console2.log("total amount: ", rewardUnits[i].totalAmount / 1 ether, "ether"); + console.log("balance of recipient: ", erc20.balanceOf(address(recipient)) / 1 ether, "ether"); + erc20Amount += rewardUnits[i].totalAmount; + } else if (rewardUnits[i].tokenType == ITokenBundle.TokenType.ERC1155) { + console2.log("total amount: ", rewardUnits[i].totalAmount); + console.log("balance of recipient: ", erc1155.balanceOf(address(recipient), rewardUnits[i].tokenId)); + erc1155Amounts[rewardUnits[i].tokenId] += rewardUnits[i].totalAmount; + } else if (rewardUnits[i].tokenType == ITokenBundle.TokenType.ERC721) { + console2.log("total amount: ", rewardUnits[i].totalAmount); + console.log("balance of recipient: ", erc721.balanceOf(address(recipient))); + erc721Amount += rewardUnits[i].totalAmount; + } + console2.log(""); + } + + assertEq(erc20.balanceOf(address(recipient)), erc20Amount); + assertEq(erc721.balanceOf(address(recipient)), erc721Amount); + + for (uint256 i = 0; i < erc1155Amounts.length; i += 1) { + assertEq(erc1155.balanceOf(address(recipient), i), erc1155Amounts[i]); + } + } + + /** + * note: Testing revert condition; caller of `openPack` is not EOA. + */ + function test_revert_openPack_notEOA() public { + uint256 packId = pack.nextTokenIdToMint(); + address recipient = address(0x123); + + vm.prank(address(tokenOwner)); + pack.createPack(packContents, numOfRewardUnits, packUri, 0, 1, recipient); + + vm.startPrank(recipient, address(27)); + string memory err = "!EOA"; + vm.expectRevert(bytes(err)); + pack.openPack(packId, 1); + } + + /** + * note: Cannot open pack again while a previous openPack request is in flight. + */ + function test_revert_openPack_ReqInFlight() public { + vm.warp(1000); + uint256 packId = pack.nextTokenIdToMint(); + uint256 packsToOpen = 3; + address recipient = address(1); + + vm.prank(address(tokenOwner)); + pack.createPack(packContents, numOfRewardUnits, packUri, 0, 2, recipient); + + vm.prank(recipient, recipient); + uint256 requestId = pack.openPack(packId, packsToOpen); + console2.log("request ID for opening pack:", requestId); + + vm.expectRevert("ReqInFlight"); + + vm.prank(recipient, recipient); + pack.openPack(packId, packsToOpen); + + vm.expectRevert("ReqInFlight"); + + vm.prank(recipient, recipient); + pack.openPackAndClaimRewards(packId, packsToOpen, 2_500_000); + } + + /** + * note: Testing revert condition; pack owner calls `openPack` to open more than owned packs. + */ + function test_revert_openPack_openMoreThanOwned() public { + uint256 packId = pack.nextTokenIdToMint(); + address recipient = address(0x123); + + vm.prank(address(tokenOwner)); + (, uint256 totalSupply) = pack.createPack(packContents, numOfRewardUnits, packUri, 0, 1, recipient); + + bytes memory err = "!Bal"; + vm.startPrank(recipient, recipient); + vm.expectRevert(err); + pack.openPack(packId, totalSupply + 1); + } + + /** + * note: Testing revert condition; pack owner calls `openPack` before start timestamp. + */ + function test_revert_openPack_openBeforeStart() public { + uint256 packId = pack.nextTokenIdToMint(); + address recipient = address(0x123); + vm.prank(address(tokenOwner)); + pack.createPack(packContents, numOfRewardUnits, packUri, 1000, 1, recipient); + + vm.startPrank(recipient, recipient); + vm.expectRevert("!Open"); + pack.openPack(packId, 1); + } + + /** + * note: Testing revert condition; pack owner calls `openPack` with pack-id non-existent or not owned. + */ + function test_revert_openPack_invalidPackId() public { + address recipient = address(0x123); + + vm.prank(address(tokenOwner)); + pack.createPack(packContents, numOfRewardUnits, packUri, 0, 1, recipient); + + bytes memory err = "!Bal"; + vm.startPrank(recipient, recipient); + vm.expectRevert(err); + pack.openPack(2, 1); + } + + /*/////////////////////////////////////////////////////////////// + Fuzz testing + //////////////////////////////////////////////////////////////*/ + + uint256 internal constant MAX_TOKENS = 2000; + + function getTokensToPack( + uint256 len + ) internal returns (ITokenBundle.Token[] memory tokensToPack, uint256[] memory rewardUnits) { + vm.assume(len < MAX_TOKENS); + tokensToPack = new ITokenBundle.Token[](len); + rewardUnits = new uint256[](len); + + for (uint256 i = 0; i < len; i += 1) { + uint256 random = uint256(keccak256(abi.encodePacked(len + i))) % MAX_TOKENS; + uint256 selector = random % 4; + + if (selector == 0) { + tokensToPack[i] = ITokenBundle.Token({ + assetContract: address(erc20), + tokenType: ITokenBundle.TokenType.ERC20, + tokenId: 0, + totalAmount: (random + 1) * 10 ether + }); + rewardUnits[i] = random + 1; + + erc20.mint(address(tokenOwner), tokensToPack[i].totalAmount); + } else if (selector == 1) { + uint256 tokenId = erc721.nextTokenIdToMint(); + + tokensToPack[i] = ITokenBundle.Token({ + assetContract: address(erc721), + tokenType: ITokenBundle.TokenType.ERC721, + tokenId: tokenId, + totalAmount: 1 + }); + rewardUnits[i] = 1; + + erc721.mint(address(tokenOwner), 1); + } else if (selector == 2) { + tokensToPack[i] = ITokenBundle.Token({ + assetContract: address(erc1155), + tokenType: ITokenBundle.TokenType.ERC1155, + tokenId: random, + totalAmount: (random + 1) * 10 + }); + rewardUnits[i] = random + 1; + + erc1155.mint(address(tokenOwner), tokensToPack[i].tokenId, tokensToPack[i].totalAmount); + } else if (selector == 3) { + tokensToPack[i] = ITokenBundle.Token({ + assetContract: address(0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE), + tokenType: ITokenBundle.TokenType.ERC20, + tokenId: 0, + totalAmount: 5 ether + }); + rewardUnits[i] = 5; + } + } + } + + function checkBalances( + ITokenBundle.Token[] memory rewardUnits, + address + ) + internal + pure + returns (uint256 nativeTokenAmount, uint256 erc20Amount, uint256[] memory erc1155Amounts, uint256 erc721Amount) + { + erc1155Amounts = new uint256[](MAX_TOKENS); + + for (uint256 i = 0; i < rewardUnits.length; i++) { + // console2.log("----- reward unit number: ", i, "------"); + // console2.log("asset contract: ", rewardUnits[i].assetContract); + // console2.log("token type: ", uint256(rewardUnits[i].tokenType)); + // console2.log("tokenId: ", rewardUnits[i].tokenId); + if (rewardUnits[i].tokenType == ITokenBundle.TokenType.ERC20) { + if (rewardUnits[i].assetContract == address(0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE)) { + // console2.log("total amount: ", rewardUnits[i].totalAmount / 1 ether, "ether"); + // console.log("balance of recipient: ", address(recipient).balance); + nativeTokenAmount += rewardUnits[i].totalAmount; + } else { + // console2.log("total amount: ", rewardUnits[i].totalAmount / 1 ether, "ether"); + // console.log("balance of recipient: ", erc20.balanceOf(address(recipient)) / 1 ether, "ether"); + erc20Amount += rewardUnits[i].totalAmount; + } + } else if (rewardUnits[i].tokenType == ITokenBundle.TokenType.ERC1155) { + // console2.log("total amount: ", rewardUnits[i].totalAmount); + // console.log("balance of recipient: ", erc1155.balanceOf(address(recipient), rewardUnits[i].tokenId)); + erc1155Amounts[rewardUnits[i].tokenId] += rewardUnits[i].totalAmount; + } else if (rewardUnits[i].tokenType == ITokenBundle.TokenType.ERC721) { + // console2.log("total amount: ", rewardUnits[i].totalAmount); + // console.log("balance of recipient: ", erc721.balanceOf(address(recipient))); + erc721Amount += rewardUnits[i].totalAmount; + } + // console2.log(""); + } + } + + function test_fuzz_state_createPack(uint256 x, uint128 y) public { + (ITokenBundle.Token[] memory tokensToPack, uint256[] memory rewardUnits) = getTokensToPack(x); + if (tokensToPack.length == 0) { + return; + } + + uint256 packId = pack.nextTokenIdToMint(); + address recipient = address(0x123); + uint256 totalRewardUnits; + uint256 nativeTokenPacked; + + for (uint256 i = 0; i < tokensToPack.length; i += 1) { + totalRewardUnits += rewardUnits[i]; + if (tokensToPack[i].assetContract == address(0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE)) { + nativeTokenPacked += tokensToPack[i].totalAmount; + } + } + vm.deal(address(tokenOwner), nativeTokenPacked); + vm.assume(y > 0 && totalRewardUnits % y == 0); + + vm.prank(address(tokenOwner)); + (, uint256 totalSupply) = pack.createPack{ value: nativeTokenPacked }( + tokensToPack, + rewardUnits, + packUri, + 0, + y, + recipient + ); + console2.log("total supply: ", totalSupply); + console2.log("total reward units: ", totalRewardUnits); + + assertEq(packId + 1, pack.nextTokenIdToMint()); + + (ITokenBundle.Token[] memory packed, ) = pack.getPackContents(packId); + assertEq(packed.length, tokensToPack.length); + for (uint256 i = 0; i < packed.length; i += 1) { + assertEq(packed[i].assetContract, tokensToPack[i].assetContract); + assertEq(uint256(packed[i].tokenType), uint256(tokensToPack[i].tokenType)); + assertEq(packed[i].tokenId, tokensToPack[i].tokenId); + assertEq(packed[i].totalAmount, tokensToPack[i].totalAmount); + } + + assertEq(packUri, pack.uri(packId)); + } + + /*/////////////////////////////////////////////////////////////// + Scenario/Exploit tests + //////////////////////////////////////////////////////////////*/ + /** + * note: Testing revert condition; token owner calls `createPack` to pack owned tokens. + */ + function test_revert_createPack_reentrancy() public { + MaliciousERC20 malERC20 = new MaliciousERC20(payable(address(pack))); + ITokenBundle.Token[] memory content = new ITokenBundle.Token[](1); + uint256[] memory rewards = new uint256[](1); + + malERC20.mint(address(tokenOwner), 10 ether); + content[0] = ITokenBundle.Token({ + assetContract: address(malERC20), + tokenType: ITokenBundle.TokenType.ERC20, + tokenId: 0, + totalAmount: 10 ether + }); + rewards[0] = 10; + + tokenOwner.setAllowanceERC20(address(malERC20), address(pack), 10 ether); + + address recipient = address(0x123); + + vm.prank(address(deployer)); + pack.grantRole(keccak256("MINTER_ROLE"), address(malERC20)); + + vm.startPrank(address(tokenOwner)); + vm.expectRevert("ReentrancyGuard: reentrant call"); + pack.createPack(content, rewards, packUri, 0, 1, recipient); + } +} + +contract MaliciousERC20 is MockERC20, ITokenBundle { + Pack public pack; + + constructor(address payable _pack) { + pack = Pack(_pack); + } + + function transferFrom(address from, address to, uint256 amount) public override returns (bool) { + ITokenBundle.Token[] memory content = new ITokenBundle.Token[](1); + uint256[] memory rewards = new uint256[](1); + + address recipient = address(0x123); + pack.createPack(content, rewards, "", 0, 1, recipient); + return super.transferFrom(from, to, amount); + } +} diff --git a/src/test/plugin/Map.t.sol b/src/test/plugin/Map.t.sol new file mode 100644 index 000000000..1888b9841 --- /dev/null +++ b/src/test/plugin/Map.t.sol @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { PluginMap, IPluginMap } from "contracts/extension/plugin/PluginMap.sol"; +import "../utils/BaseTest.sol"; + +contract MapTest is BaseTest { + using Strings for uint256; + PluginMap internal map; + + address[] private pluginAddresses; + IPluginMap.Plugin[] private plugins; + + function setUp() public override { + super.setUp(); + + uint256 total = 50; + + address pluginAddress; + + for (uint256 i = 0; i < total; i += 1) { + if (i % 10 == 0) { + pluginAddress = address(uint160(0x50000 + i)); + pluginAddresses.push(pluginAddress); + } + plugins.push( + IPluginMap.Plugin(bytes4(keccak256(abi.encodePacked(i.toString()))), i.toString(), pluginAddress) + ); + } + + map = new PluginMap(plugins); + } + + function test_state_getPluginForFunction() external { + uint256 len = plugins.length; + for (uint256 i = 0; i < len; i += 1) { + address pluginAddress = plugins[i].pluginAddress; + bytes4 selector = plugins[i].functionSelector; + + assertEq(pluginAddress, map.getPluginForFunction(selector)); + } + } + + function test_state_getAllFunctionsOfPlugin() external { + uint256 len = plugins.length; + for (uint256 i = 0; i < len; i += 1) { + address pluginAddress = plugins[i].pluginAddress; + + uint256 expectedNum; + + for (uint256 j = 0; j < plugins.length; j += 1) { + if (plugins[j].pluginAddress == pluginAddress) { + expectedNum += 1; + } + } + + bytes4[] memory expectedFns = new bytes4[](expectedNum); + uint256 idx; + + for (uint256 j = 0; j < plugins.length; j += 1) { + if (plugins[j].pluginAddress == pluginAddress) { + expectedFns[idx] = plugins[j].functionSelector; + idx += 1; + } + } + + bytes4[] memory fns = map.getAllFunctionsOfPlugin(pluginAddress); + + assertEq(fns.length, expectedNum); + + for (uint256 k = 0; k < fns.length; k += 1) { + assertEq(fns[k], expectedFns[k]); + } + } + } + + function test_state_getAllPlugins() external { + IPluginMap.Plugin[] memory pluginsStored = map.getAllPlugins(); + + for (uint256 i = 0; i < pluginsStored.length; i += 1) { + assertEq(pluginsStored[i].pluginAddress, plugins[i].pluginAddress); + assertEq(pluginsStored[i].functionSelector, plugins[i].functionSelector); + } + } +} diff --git a/src/test/plugin/Router.t.sol b/src/test/plugin/Router.t.sol new file mode 100644 index 000000000..532f0e1cb --- /dev/null +++ b/src/test/plugin/Router.t.sol @@ -0,0 +1,270 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "contracts/extension/plugin/PluginMap.sol"; +import "contracts/extension/plugin/Router.sol"; +import { BaseTest } from "../utils/BaseTest.sol"; +import "lib/forge-std/src/console.sol"; + +contract RouterImplementation is Router { + constructor(address _functionMap) Router(_functionMap) {} + + function _canSetPlugin() internal pure override returns (bool) { + return true; + } +} + +library CounterStorage { + /// @custom:storage-location erc7201:counter.storage + /// @dev keccak256(abi.encode(uint256(keccak256("counter.storage")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 public constant COUNTER_STORAGE_POSITION = + 0x3a8940d2c88113c2296117248b8b2aedcf41634993b4c0b4ea1a36805e66c300; + + struct Data { + uint256 number; + } + + function counterStorage() internal pure returns (Data storage counterData) { + bytes32 position = COUNTER_STORAGE_POSITION; + assembly { + counterData.slot := position + } + } +} + +contract Counter { + function number() external view returns (uint256) { + CounterStorage.Data storage data = CounterStorage.counterStorage(); + return data.number; + } + + function setNumber(uint256 _newNum) external { + CounterStorage.Data storage data = CounterStorage.counterStorage(); + data.number = _newNum; + } + + function doubleNumber() external { + CounterStorage.Data storage data = CounterStorage.counterStorage(); + data.number *= 4; // Buggy! + } + + function extraFunction() external pure {} +} + +contract CounterAlternate1 { + function doubleNumber() external { + CounterStorage.Data storage data = CounterStorage.counterStorage(); + data.number *= 2; // Fixed! + } +} + +contract CounterAlternate2 { + function tripleNumber() external { + CounterStorage.Data storage data = CounterStorage.counterStorage(); + data.number *= 3; // Fixed! + } +} + +contract RouterTest is BaseTest { + address internal map; + address internal router; + + address internal counter; + address internal counterAlternate1; + address internal counterAlternate2; + + function setUp() public override { + super.setUp(); + + counter = address(new Counter()); + counterAlternate1 = address(new CounterAlternate1()); + counterAlternate2 = address(new CounterAlternate2()); + + IPluginMap.Plugin[] memory pluginMaps = new IPluginMap.Plugin[](3); + pluginMaps[0] = IPluginMap.Plugin(Counter.number.selector, "number()", counter); + pluginMaps[1] = IPluginMap.Plugin(Counter.setNumber.selector, "setNumber(uint256)", counter); + pluginMaps[2] = IPluginMap.Plugin(Counter.doubleNumber.selector, "doubleNumber()", counter); + + map = address(new PluginMap(pluginMaps)); + router = address(new RouterImplementation(map)); + } + + function test_state_addPlugin() external { + // Set number. + uint256 num = 5; + Counter(router).setNumber(num); + assertEq(Counter(router).number(), num); + + // Add extension for `tripleNumber`. + RouterImplementation(payable(router)).addPlugin( + IPluginMap.Plugin(CounterAlternate2.tripleNumber.selector, "tripleNumber()", counterAlternate2) + ); + + // Triple number. + CounterAlternate2(router).tripleNumber(); + assertEq(Counter(router).number(), num * 3); + + // Get and check all overriden extensions. + IPluginMap.Plugin[] memory pluginsStored = RouterImplementation(payable(router)).getAllPlugins(); + assertEq(pluginsStored.length, 4); + + bool isStored; + + for (uint256 i = 0; i < pluginsStored.length; i += 1) { + if (pluginsStored[i].functionSelector == CounterAlternate2.tripleNumber.selector) { + isStored = true; + assertEq(pluginsStored[i].pluginAddress, counterAlternate2); + } + } + + assertTrue(isStored); + } + + function test_revert_addPlugin_defaultExists() external { + vm.expectRevert("Router: default plugin exists for function."); + RouterImplementation(payable(router)).addPlugin( + IPluginMap.Plugin(Counter.doubleNumber.selector, "doubleNumber()", counterAlternate1) + ); + } + + function test_revert_addPlugin_pluginAlreadyExists() external { + RouterImplementation(payable(router)).addPlugin( + IPluginMap.Plugin(CounterAlternate2.tripleNumber.selector, "tripleNumber()", counterAlternate2) + ); + vm.expectRevert(); + RouterImplementation(payable(router)).addPlugin( + IPluginMap.Plugin(CounterAlternate2.tripleNumber.selector, "tripleNumber()", counterAlternate2) + ); + } + + function test_revert_addPlugin_selectorSignatureMismatch() external { + vm.expectRevert("Router: fn selector and signature mismatch."); + RouterImplementation(payable(router)).addPlugin( + IPluginMap.Plugin(CounterAlternate2.tripleNumber.selector, "doubleNumber()", counterAlternate2) + ); + } + + function test_state_updatePlugin() external { + // Set number. + uint256 num = 5; + Counter(router).setNumber(num); + assertEq(Counter(router).number(), num); + + // Double number. Bug: it quadruples the number. + Counter(router).doubleNumber(); + assertEq(Counter(router).number(), num * 4); + + // Reset number. + Counter(router).setNumber(num); + assertEq(Counter(router).number(), num); + + // Fix the extension for `doubleNumber`. + RouterImplementation(payable(router)).updatePlugin( + IPluginMap.Plugin(Counter.doubleNumber.selector, "doubleNumber()", counterAlternate1) + ); + + // Double number. Fixed: it doubles the number. + Counter(router).doubleNumber(); + assertEq(Counter(router).number(), num * 2); + + // Get and check all overriden extensions. + assertEq( + RouterImplementation(payable(router)).getPluginForFunction(Counter.doubleNumber.selector), + counterAlternate1 + ); + + IPluginMap.Plugin[] memory pluginsStored = RouterImplementation(payable(router)).getAllPlugins(); + assertEq(pluginsStored.length, 3); + + bool isStored; + + for (uint256 i = 0; i < pluginsStored.length; i += 1) { + if (pluginsStored[i].functionSelector == Counter.doubleNumber.selector) { + assertEq(pluginsStored[i].pluginAddress, counterAlternate1); + isStored = true; + } + } + + assertTrue(isStored); + } + + function test_state_getAllFunctionsOfPlugin() public { + // add fixed function from counterAlternate + RouterImplementation(payable(router)).updatePlugin( + IPluginMap.Plugin(Counter.doubleNumber.selector, "doubleNumber()", counterAlternate1) + ); + + // add previously not added function of counter + RouterImplementation(payable(router)).addPlugin( + IPluginMap.Plugin(Counter.extraFunction.selector, "extraFunction()", counter) + ); + + // check plugins for counter + bytes4[] memory functions = RouterImplementation(payable(router)).getAllFunctionsOfPlugin(counter); + assertEq(functions.length, 4); + console.logBytes4(functions[0]); + console.logBytes4(functions[1]); + console.logBytes4(functions[2]); + console.logBytes4(functions[3]); + + // check plugins for counterAlternate + functions = RouterImplementation(payable(router)).getAllFunctionsOfPlugin(counterAlternate1); + assertEq(functions.length, 1); + console.logBytes4(functions[0]); + } + + function test_revert_updatePlugin_selectorSignatureMismatch() external { + vm.expectRevert("Router: fn selector and signature mismatch."); + RouterImplementation(payable(router)).updatePlugin( + IPluginMap.Plugin(CounterAlternate1.doubleNumber.selector, "tripleNumber()", counterAlternate2) + ); + } + + function test_revert_updatePlugin_functionDNE() external { + vm.expectRevert("Map: No plugin available for selector"); + RouterImplementation(payable(router)).updatePlugin( + IPluginMap.Plugin(CounterAlternate2.tripleNumber.selector, "tripleNumber()", counterAlternate2) + ); + } + + function test_state_removePlugin() external { + RouterImplementation(payable(router)).addPlugin( + IPluginMap.Plugin(CounterAlternate2.tripleNumber.selector, "tripleNumber()", counterAlternate2) + ); + + assertEq( + RouterImplementation(payable(router)).getPluginForFunction(CounterAlternate2.tripleNumber.selector), + counterAlternate2 + ); + + RouterImplementation(payable(router)).removePlugin(CounterAlternate2.tripleNumber.selector); + + vm.expectRevert("Map: No plugin available for selector"); + RouterImplementation(payable(router)).getPluginForFunction(CounterAlternate2.tripleNumber.selector); + } + + function test_revert_removePlugin_pluginDNE() external { + vm.expectRevert("Router: No plugin available for selector"); + RouterImplementation(payable(router)).removePlugin(CounterAlternate2.tripleNumber.selector); + } + + function test_state_getPluginForFunction() public { + // add fixed function from counterAlternate + RouterImplementation(payable(router)).updatePlugin( + IPluginMap.Plugin(Counter.doubleNumber.selector, "doubleNumber()", counterAlternate1) + ); + + // add previously not added function of counter + RouterImplementation(payable(router)).addPlugin( + IPluginMap.Plugin(Counter.extraFunction.selector, "extraFunction()", counter) + ); + + address pluginAddress = RouterImplementation(payable(router)).getPluginForFunction( + Counter.doubleNumber.selector + ); + assertEq(pluginAddress, counterAlternate1); + + pluginAddress = RouterImplementation(payable(router)).getPluginForFunction(Counter.extraFunction.selector); + assertEq(pluginAddress, counter); + } +} diff --git a/src/test/plugin/RouterImmutable.t.sol b/src/test/plugin/RouterImmutable.t.sol new file mode 100644 index 000000000..8ad44f4fc --- /dev/null +++ b/src/test/plugin/RouterImmutable.t.sol @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "contracts/extension/plugin/PluginMap.sol"; +import "contracts/extension/plugin/RouterImmutable.sol"; +import { BaseTest } from "../utils/BaseTest.sol"; + +contract Counter { + uint256 private number_; + + function number() external view returns (uint256) { + return number_; + } + + function setNumber(uint256 _newNum) external { + number_ = _newNum; + } + + function doubleNumber() external { + number_ *= 2; + } +} + +contract RouterImmutableTest is BaseTest { + address internal map; + address internal router; + + function setUp() public override { + super.setUp(); + + address counter = address(new Counter()); + + IPluginMap.Plugin[] memory pluginMaps = new IPluginMap.Plugin[](2); + pluginMaps[0] = IPluginMap.Plugin(Counter.number.selector, "number()", counter); + pluginMaps[1] = IPluginMap.Plugin(Counter.setNumber.selector, "setNumber(uint256)", counter); + + map = address(new PluginMap(pluginMaps)); + router = address(new RouterImmutable(map)); + } + + function test_state_callWithRouter() external { + uint256 num = 5; + + Counter(router).setNumber(num); + + assertEq(Counter(router).number(), num); + } + + function test_revert_callWithRouter() external { + vm.expectRevert("Map: No plugin available for selector"); + Counter(router).doubleNumber(); + } +} diff --git a/src/test/scripts/generateRoot.ts b/src/test/scripts/generateRoot.ts new file mode 100644 index 000000000..128499938 --- /dev/null +++ b/src/test/scripts/generateRoot.ts @@ -0,0 +1,31 @@ +const { MerkleTree } = require("@thirdweb-dev/merkletree"); + +const keccak256 = require("keccak256"); +const { ethers } = require("ethers"); + +const process = require("process"); + +const members = [ + "0x92Bb439374a091c7507bE100183d8D1Ed2c9dAD3", + "0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd", + "0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF", +]; + +let val = process.argv[2]; +let price = process.argv[3]; +let currency = process.argv[4]; + +const hashedLeafs = members.map(l => + ethers.utils.solidityKeccak256( + ["address", "uint256", "uint256", "address"], + [l, val, price, currency], + ), +); + +const tree = new MerkleTree(hashedLeafs, keccak256, { + sort: true, + sortLeaves: true, + sortPairs: true, +}); + +process.stdout.write(ethers.utils.defaultAbiCoder.encode(["bytes32"], [tree.getHexRoot()])); diff --git a/src/test/scripts/generateRootAirdrop.ts b/src/test/scripts/generateRootAirdrop.ts new file mode 100644 index 000000000..4e2c1fdfb --- /dev/null +++ b/src/test/scripts/generateRootAirdrop.ts @@ -0,0 +1,26 @@ +const { MerkleTree } = require("@thirdweb-dev/merkletree"); + +const keccak256 = require("keccak256"); +const { ethers } = require("ethers"); + +const process = require("process"); + +const members = [ + "0x92Bb439374a091c7507bE100183d8D1Ed2c9dAD3", + "0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd", + "0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF", +]; + +let val = process.argv[2]; + +const hashedLeafs = members.map(l => + ethers.utils.solidityKeccak256(["address", "uint256"], [l, val]), +); + +const tree = new MerkleTree(hashedLeafs, keccak256, { + sort: true, + sortLeaves: true, + sortPairs: true, +}); + +process.stdout.write(ethers.utils.defaultAbiCoder.encode(["bytes32"], [tree.getHexRoot()])); diff --git a/src/test/scripts/generateRootAirdrop1155.ts b/src/test/scripts/generateRootAirdrop1155.ts new file mode 100644 index 000000000..d76990fad --- /dev/null +++ b/src/test/scripts/generateRootAirdrop1155.ts @@ -0,0 +1,27 @@ +const { MerkleTree } = require("@thirdweb-dev/merkletree"); + +const keccak256 = require("keccak256"); +const { ethers } = require("ethers"); + +const process = require("process"); + +const members = [ + "0x9999999999999999999999999999999999999999", + "0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd", + "0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF", +]; + +let tokenId = process.argv[2]; +let quantity = process.argv[3]; + +const hashedLeafs = members.map(l => + ethers.utils.solidityKeccak256(["address", "uint256", "uint256"], [l, tokenId, quantity]), +); + +const tree = new MerkleTree(hashedLeafs, keccak256, { + sort: true, + sortLeaves: true, + sortPairs: true, +}); + +process.stdout.write(ethers.utils.defaultAbiCoder.encode(["bytes32"], [tree.getHexRoot()])); diff --git a/src/test/scripts/getCloneAddress.ts b/src/test/scripts/getCloneAddress.ts new file mode 100644 index 000000000..27ade61fd --- /dev/null +++ b/src/test/scripts/getCloneAddress.ts @@ -0,0 +1,23 @@ +const { MerkleTree } = require("@thirdweb-dev/merkletree"); + +const keccak256 = require("keccak256"); +const { ethers } = require("ethers"); + +const process = require("process"); + +let implementationAddress = process.argv[2]; +let signer = process.argv[3]; +let salthash = process.argv[4]; + +const cloneBytecode = [ + "0x3d602d80600a3d3981f3363d3d373d3d3d363d73", + implementationAddress.replace(/0x/, "").toLowerCase(), + "5af43d82803e903d91602b57fd5bf3", +].join(""); + +const initCodeHash = ethers.utils.solidityKeccak256(["bytes"], [cloneBytecode]); + +const create2Address = ethers.utils.getCreate2Address(signer, salthash, initCodeHash); + +process.stdout.write(create2Address); +// process.stdout.write(ethers.utils.defaultAbiCoder.encode(["bytes32"], [create2Address])); diff --git a/src/test/scripts/getProof.ts b/src/test/scripts/getProof.ts new file mode 100644 index 000000000..2ef4fd4c5 --- /dev/null +++ b/src/test/scripts/getProof.ts @@ -0,0 +1,38 @@ +const { MerkleTree } = require("@thirdweb-dev/merkletree"); + +const keccak256 = require("keccak256"); +const { ethers } = require("ethers"); + +const process = require("process"); + +const members = [ + "0x92Bb439374a091c7507bE100183d8D1Ed2c9dAD3", + "0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd", + "0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF", +]; + +let val = process.argv[2]; +let price = process.argv[3]; +let currency = process.argv[4]; + +const hashedLeafs = members.map(l => + ethers.utils.solidityKeccak256( + ["address", "uint256", "uint256", "address"], + [l, val, price, currency], + ), +); + +const tree = new MerkleTree(hashedLeafs, keccak256, { + sort: true, + sortLeaves: true, + sortPairs: true, +}); + +const expectedProof = tree.getHexProof( + ethers.utils.solidityKeccak256( + ["address", "uint256", "uint256", "address"], + [members[1], val, price, currency], + ), +); + +process.stdout.write(ethers.utils.defaultAbiCoder.encode(["bytes32[]"], [expectedProof])); diff --git a/src/test/scripts/getProofAirdrop.ts b/src/test/scripts/getProofAirdrop.ts new file mode 100644 index 000000000..f99582602 --- /dev/null +++ b/src/test/scripts/getProofAirdrop.ts @@ -0,0 +1,30 @@ +const { MerkleTree } = require("@thirdweb-dev/merkletree"); + +const keccak256 = require("keccak256"); +const { ethers } = require("ethers"); + +const process = require("process"); + +const members = [ + "0x92Bb439374a091c7507bE100183d8D1Ed2c9dAD3", + "0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd", + "0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF", +]; + +let val = process.argv[2]; + +const hashedLeafs = members.map(l => + ethers.utils.solidityKeccak256(["address", "uint256"], [l, val]), +); + +const tree = new MerkleTree(hashedLeafs, keccak256, { + sort: true, + sortLeaves: true, + sortPairs: true, +}); + +const expectedProof = tree.getHexProof( + ethers.utils.solidityKeccak256(["address", "uint256"], [members[1], val]), +); + +process.stdout.write(ethers.utils.defaultAbiCoder.encode(["bytes32[]"], [expectedProof])); diff --git a/src/test/scripts/getProofAirdrop1155.ts b/src/test/scripts/getProofAirdrop1155.ts new file mode 100644 index 000000000..6701fc479 --- /dev/null +++ b/src/test/scripts/getProofAirdrop1155.ts @@ -0,0 +1,31 @@ +const { MerkleTree } = require("@thirdweb-dev/merkletree"); + +const keccak256 = require("keccak256"); +const { ethers } = require("ethers"); + +const process = require("process"); + +const members = [ + "0x9999999999999999999999999999999999999999", + "0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd", + "0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF", +]; + +let tokenId = process.argv[2]; +let quantity = process.argv[3]; + +const hashedLeafs = members.map(l => + ethers.utils.solidityKeccak256(["address", "uint256", "uint256"], [l, tokenId, quantity]), +); + +const tree = new MerkleTree(hashedLeafs, keccak256, { + sort: true, + sortLeaves: true, + sortPairs: true, +}); + +const expectedProof = tree.getHexProof( + ethers.utils.solidityKeccak256(["address", "uint256", "uint256"], [members[1], tokenId, quantity]), +); + +process.stdout.write(ethers.utils.defaultAbiCoder.encode(["bytes32[]"], [expectedProof])); diff --git a/src/test/sdk/base/BaseUtilTest.sol b/src/test/sdk/base/BaseUtilTest.sol new file mode 100644 index 000000000..7ae0b458d --- /dev/null +++ b/src/test/sdk/base/BaseUtilTest.sol @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; +import "../../utils/Wallet.sol"; +import "../../mocks/WETH9.sol"; +import "../../mocks/MockERC20.sol"; +import "../../mocks/MockERC721.sol"; +import "../../mocks/MockERC1155.sol"; +import "contracts/infra/forwarder/Forwarder.sol"; + +abstract contract BaseUtilTest is DSTest, Test { + string public constant NAME = "NAME"; + string public constant SYMBOL = "SYMBOL"; + string public constant CONTRACT_URI = "CONTRACT_URI"; + address public constant NATIVE_TOKEN = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + + MockERC20 public erc20; + MockERC721 public erc721; + MockERC1155 public erc1155; + WETH9 public weth; + + address public forwarder; + + address public deployer = address(0x20000); + address public saleRecipient = address(0x30000); + address public royaltyRecipient = address(0x30001); + address public platformFeeRecipient = address(0x30002); + uint128 public royaltyBps = 500; // 5% + uint128 public platformFeeBps = 500; // 5% + uint256 public constant MAX_BPS = 10_000; // 100% + + uint256 public privateKey = 1234; + address public signer; + + mapping(bytes32 => address) public contracts; + + function setUp() public virtual { + signer = vm.addr(privateKey); + + erc20 = new MockERC20(); + erc721 = new MockERC721(); + erc1155 = new MockERC1155(); + weth = new WETH9(); + forwarder = address(new Forwarder()); + } + + function getActor(uint160 _index) public pure returns (address) { + return address(uint160(0x50000 + _index)); + } + + function getWallet() public returns (Wallet wallet) { + wallet = new Wallet(); + } + + function assertIsOwnerERC721(address _token, address _owner, uint256[] memory _tokenIds) internal { + for (uint256 i = 0; i < _tokenIds.length; i += 1) { + bool isOwnerOfToken = MockERC721(_token).ownerOf(_tokenIds[i]) == _owner; + assertTrue(isOwnerOfToken); + } + } + + function assertIsNotOwnerERC721(address _token, address _owner, uint256[] memory _tokenIds) internal { + for (uint256 i = 0; i < _tokenIds.length; i += 1) { + bool isOwnerOfToken = MockERC721(_token).ownerOf(_tokenIds[i]) == _owner; + assertTrue(!isOwnerOfToken); + } + } + + function assertBalERC1155Eq( + address _token, + address _owner, + uint256[] memory _tokenIds, + uint256[] memory _amounts + ) internal { + require(_tokenIds.length == _amounts.length, "unequal lengths"); + + for (uint256 i = 0; i < _tokenIds.length; i += 1) { + assertEq(MockERC1155(_token).balanceOf(_owner, _tokenIds[i]), _amounts[i]); + } + } + + function assertBalERC1155Gte( + address _token, + address _owner, + uint256[] memory _tokenIds, + uint256[] memory _amounts + ) internal { + require(_tokenIds.length == _amounts.length, "unequal lengths"); + + for (uint256 i = 0; i < _tokenIds.length; i += 1) { + assertTrue(MockERC1155(_token).balanceOf(_owner, _tokenIds[i]) >= _amounts[i]); + } + } + + function assertBalERC20Eq(address _token, address _owner, uint256 _amount) internal { + assertEq(MockERC20(_token).balanceOf(_owner), _amount); + } + + function assertBalERC20Gte(address _token, address _owner, uint256 _amount) internal { + assertTrue(MockERC20(_token).balanceOf(_owner) >= _amount); + } + + function forwarders() public view returns (address[] memory) { + address[] memory _forwarders = new address[](1); + _forwarders[0] = forwarder; + return _forwarders; + } +} diff --git a/src/test/sdk/base/ERC1155Base.t.sol b/src/test/sdk/base/ERC1155Base.t.sol new file mode 100644 index 000000000..f6976a3da --- /dev/null +++ b/src/test/sdk/base/ERC1155Base.t.sol @@ -0,0 +1,498 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import { ERC1155Base } from "contracts/base/ERC1155Base.sol"; +import { Strings } from "contracts/lib/Strings.sol"; + +contract ERC1155BaseTest is DSTest, Test { + using Strings for uint256; + + // Target contract + ERC1155Base internal base; + + // Signers + address internal admin; + address internal nftHolder; + + function setUp() public { + admin = address(0x123); + nftHolder = address(0x456); + + vm.prank(admin); + base = new ERC1155Base(admin, "name", "symbol", admin, 0); + } + + // ================== `mintTo` tests ======================== + + function test_state_mintTo_newNFTs() public { + uint256 tokenId = type(uint256).max; + string memory tokenURI = "ipfs://"; + uint256 amount = 100; + + uint256 expectedTokenIdMinted = base.nextTokenIdToMint(); + + vm.prank(admin); + base.mintTo(nftHolder, tokenId, tokenURI, amount); + + assertEq(base.balanceOf(nftHolder, expectedTokenIdMinted), amount); + assertEq(base.totalSupply(expectedTokenIdMinted), amount); + assertEq(base.nextTokenIdToMint(), expectedTokenIdMinted + 1); + assertEq(base.uri(expectedTokenIdMinted), tokenURI); + } + + function test_state_mintTo_existingNFTs() public { + string memory tokenURI = "ipfs://"; + uint256 startAmount = 1; + + uint256 tokenIdMinted = base.nextTokenIdToMint(); + + vm.prank(admin); + base.mintTo(admin, type(uint256).max, tokenURI, startAmount); + + assertEq(base.uri(tokenIdMinted), tokenURI); + assertEq(base.totalSupply(tokenIdMinted), startAmount); + assertEq(base.nextTokenIdToMint(), tokenIdMinted + 1); + + uint256 additionalAmount = 100; + + vm.prank(admin); + base.mintTo(nftHolder, tokenIdMinted, "", additionalAmount); + + assertEq(base.balanceOf(nftHolder, tokenIdMinted), additionalAmount); + assertEq(base.totalSupply(tokenIdMinted), additionalAmount + startAmount); + } + + function test_revert_mintTo_unauthorizedCaller() public { + uint256 tokenId = type(uint256).max; + string memory tokenURI = "ipfs://"; + uint256 amount = 100; + + vm.prank(nftHolder); + vm.expectRevert("Not authorized to mint."); + base.mintTo(nftHolder, tokenId, tokenURI, amount); + } + + function test_revert_mintTo_invalidId() public { + string memory tokenURI = "ipfs://"; + uint256 amount = 100; + + uint256 nextId = base.nextTokenIdToMint(); + + vm.prank(admin); + vm.expectRevert("invalid id"); + base.mintTo(nftHolder, nextId, tokenURI, amount); + } + + // ================== `mintTo` tests ======================== + + function test_state_batchMintTo_newNFTs() public { + uint256 numToMint = 3; + uint256[] memory tokenIds = new uint256[](numToMint); + uint256[] memory amounts = new uint256[](numToMint); + uint256[] memory expectedTokenIds = new uint256[](numToMint); + string memory baseURI = "ipfs://"; + + uint256 nextId = base.nextTokenIdToMint(); + + for (uint256 i = 0; i < numToMint; i += 1) { + tokenIds[i] = type(uint256).max; + amounts[i] = 100; + + expectedTokenIds[i] = nextId; + nextId += 1; + } + + vm.prank(admin); + base.batchMintTo(nftHolder, tokenIds, amounts, baseURI); + + for (uint256 i = 0; i < numToMint; i += 1) { + uint256 id = expectedTokenIds[i]; + + assertEq(base.balanceOf(nftHolder, id), amounts[i]); + assertEq(base.totalSupply(id), amounts[i]); + assertEq(base.uri(id), string(abi.encodePacked(baseURI, id.toString()))); + } + } + + function test_state_batchMintTo_existingNFTs() public { + uint256 numToMint = 3; + uint256[] memory tokenIds = new uint256[](numToMint); + uint256[] memory startAmounts = new uint256[](numToMint); + uint256[] memory nextAmounts = new uint256[](numToMint); + uint256[] memory expectedTokenIds = new uint256[](numToMint); + string memory baseURI = "ipfs://"; + + uint256 nextId = base.nextTokenIdToMint(); + + for (uint256 i = 0; i < numToMint; i += 1) { + tokenIds[i] = type(uint256).max; + startAmounts[i] = 1; + nextAmounts[i] = 99; + + expectedTokenIds[i] = nextId; + nextId += 1; + } + + vm.prank(admin); + base.batchMintTo(admin, tokenIds, startAmounts, baseURI); + + for (uint256 i = 0; i < numToMint; i += 1) { + uint256 id = expectedTokenIds[i]; + + assertEq(base.balanceOf(admin, id), startAmounts[i]); + assertEq(base.totalSupply(id), startAmounts[i]); + assertEq(base.uri(id), string(abi.encodePacked(baseURI, id.toString()))); + } + + vm.prank(admin); + base.batchMintTo(nftHolder, expectedTokenIds, nextAmounts, ""); + + for (uint256 i = 0; i < numToMint; i += 1) { + uint256 id = expectedTokenIds[i]; + + assertEq(base.balanceOf(nftHolder, id), nextAmounts[i]); + assertEq(base.totalSupply(id), startAmounts[i] + nextAmounts[i]); + assertEq(base.uri(id), string(abi.encodePacked(baseURI, id.toString()))); + } + } + + function test_state_batchMintTo_newAndExistingNFTs() public { + uint256 numToMint = 3; + uint256[] memory tokenIds = new uint256[](numToMint); + uint256[] memory startAmounts = new uint256[](numToMint); + uint256[] memory nextAmounts = new uint256[](numToMint); + uint256[] memory expectedTokenIds = new uint256[](numToMint); + string memory baseURI = "ipfs://"; + + uint256 nextId = base.nextTokenIdToMint(); + + for (uint256 i = 0; i < numToMint; i += 1) { + tokenIds[i] = type(uint256).max; + startAmounts[i] = 1; + nextAmounts[i] = 99; + + expectedTokenIds[i] = nextId; + nextId += 1; + } + + vm.prank(admin); + base.batchMintTo(admin, tokenIds, startAmounts, baseURI); + + for (uint256 i = 0; i < numToMint; i += 1) { + uint256 id = expectedTokenIds[i]; + + assertEq(base.balanceOf(admin, id), startAmounts[i]); + assertEq(base.totalSupply(id), startAmounts[i]); + assertEq(base.uri(id), string(abi.encodePacked(baseURI, id.toString()))); + } + + uint256[] memory newAndExistingTokenIds = new uint256[](numToMint + 1); + uint256[] memory newAmounts = new uint256[](numToMint + 1); + for (uint256 i = 0; i < numToMint; i += 1) { + newAndExistingTokenIds[i] = expectedTokenIds[i]; + newAmounts[i] = nextAmounts[i]; + } + newAndExistingTokenIds[numToMint] = type(uint256).max; + newAmounts[numToMint] = 100; + + uint256 expectedNewId = base.nextTokenIdToMint(); + string memory baseURIForNewNFT = "newipfs://"; + + vm.prank(admin); + base.batchMintTo(nftHolder, newAndExistingTokenIds, newAmounts, baseURIForNewNFT); + + for (uint256 i = 0; i < newAndExistingTokenIds.length; i += 1) { + if (i < numToMint) { + uint256 id = newAndExistingTokenIds[i]; + + assertEq(base.balanceOf(nftHolder, id), newAmounts[i]); + assertEq(base.totalSupply(id), startAmounts[i] + newAmounts[i]); + assertEq(base.uri(id), string(abi.encodePacked(baseURI, id.toString()))); + } else { + uint256 id = expectedNewId; + assertEq(base.balanceOf(nftHolder, id), newAmounts[i]); + assertEq(base.totalSupply(id), newAmounts[i]); + assertEq(base.uri(id), string(abi.encodePacked(baseURIForNewNFT, id.toString()))); + } + } + } + + function test_revert_batchMintTo_unauthorizedCaller() public { + uint256 numToMint = 3; + uint256[] memory tokenIds = new uint256[](numToMint); + uint256[] memory amounts = new uint256[](numToMint); + uint256[] memory expectedTokenIds = new uint256[](numToMint); + string memory baseURI = "ipfs://"; + + uint256 nextId = base.nextTokenIdToMint(); + + for (uint256 i = 0; i < numToMint; i += 1) { + tokenIds[i] = type(uint256).max; + amounts[i] = 100; + + expectedTokenIds[i] = nextId; + nextId += 1; + } + + vm.prank(nftHolder); + vm.expectRevert("Not authorized to mint."); + base.batchMintTo(nftHolder, tokenIds, amounts, baseURI); + } + + function test_revert_batchMintTo_mintingZeroTokens() public { + uint256 numToMint = 3; + uint256[] memory tokenIds = new uint256[](numToMint); + uint256[] memory amounts = new uint256[](0); + uint256[] memory expectedTokenIds = new uint256[](numToMint); + string memory baseURI = "ipfs://"; + + uint256 nextId = base.nextTokenIdToMint(); + + for (uint256 i = 0; i < numToMint; i += 1) { + tokenIds[i] = type(uint256).max; + + expectedTokenIds[i] = nextId; + nextId += 1; + } + + vm.prank(admin); + vm.expectRevert("Minting zero tokens."); + base.batchMintTo(nftHolder, tokenIds, amounts, baseURI); + } + + function test_revert_batchMintTo_lengthMismatch() public { + uint256 numToMint = 3; + uint256[] memory tokenIds = new uint256[](numToMint + 1); + uint256[] memory amounts = new uint256[](numToMint); + uint256[] memory expectedTokenIds = new uint256[](numToMint); + string memory baseURI = "ipfs://"; + + uint256 nextId = base.nextTokenIdToMint(); + + for (uint256 i = 0; i < numToMint; i += 1) { + tokenIds[i] = type(uint256).max; + amounts[i] = 100; + + expectedTokenIds[i] = nextId; + nextId += 1; + } + + vm.prank(admin); + vm.expectRevert("Length mismatch."); + base.batchMintTo(nftHolder, tokenIds, amounts, baseURI); + } + + function test_revert_batchMintTo_invalidId() public { + uint256 numToMint = 3; + uint256[] memory tokenIds = new uint256[](numToMint); + uint256[] memory amounts = new uint256[](numToMint); + uint256[] memory expectedTokenIds = new uint256[](numToMint); + string memory baseURI = "ipfs://"; + + uint256 nextId = base.nextTokenIdToMint(); + + for (uint256 i = 0; i < numToMint; i += 1) { + tokenIds[i] = i; + amounts[i] = 100; + + expectedTokenIds[i] = nextId; + nextId += 1; + } + + vm.prank(admin); + vm.expectRevert("invalid id"); + base.batchMintTo(nftHolder, tokenIds, amounts, baseURI); + } + + function test_state_burn() public { + uint256 tokenId = type(uint256).max; + string memory tokenURI = "ipfs://"; + uint256 amount = 100; + + uint256 expectedTokenIdMinted = base.nextTokenIdToMint(); + + vm.prank(admin); + base.mintTo(nftHolder, tokenId, tokenURI, amount); + + assertEq(base.balanceOf(nftHolder, expectedTokenIdMinted), amount); + assertEq(base.totalSupply(expectedTokenIdMinted), amount); + + vm.prank(nftHolder); + base.burn(nftHolder, expectedTokenIdMinted, amount); + + assertEq(base.balanceOf(nftHolder, expectedTokenIdMinted), 0); + assertEq(base.totalSupply(expectedTokenIdMinted), 0); + } + + function test_revert_burn_unapprovedCaller() public { + uint256 tokenId = type(uint256).max; + string memory tokenURI = "ipfs://"; + uint256 amount = 100; + + uint256 expectedTokenIdMinted = base.nextTokenIdToMint(); + + vm.prank(admin); + base.mintTo(nftHolder, tokenId, tokenURI, amount); + + assertEq(base.balanceOf(nftHolder, expectedTokenIdMinted), amount); + assertEq(base.totalSupply(expectedTokenIdMinted), amount); + + vm.prank(admin); + vm.expectRevert("Unapproved caller"); + base.burn(nftHolder, expectedTokenIdMinted, amount); + } + + function test_revert_burn_notEnoughTokensOwned() public { + uint256 tokenId = type(uint256).max; + string memory tokenURI = "ipfs://"; + uint256 amount = 100; + + uint256 expectedTokenIdMinted = base.nextTokenIdToMint(); + + vm.prank(admin); + base.mintTo(nftHolder, tokenId, tokenURI, amount); + + assertEq(base.balanceOf(nftHolder, expectedTokenIdMinted), amount); + assertEq(base.totalSupply(expectedTokenIdMinted), amount); + + vm.prank(nftHolder); + vm.expectRevert("Not enough tokens owned"); + base.burn(nftHolder, expectedTokenIdMinted, amount + 1); + } + + function test_state_burnBatch() public { + uint256 numToMint = 3; + uint256[] memory tokenIds = new uint256[](numToMint); + uint256[] memory amounts = new uint256[](numToMint); + uint256[] memory expectedTokenIds = new uint256[](numToMint); + string memory baseURI = "ipfs://"; + + uint256 nextId = base.nextTokenIdToMint(); + + for (uint256 i = 0; i < numToMint; i += 1) { + tokenIds[i] = type(uint256).max; + amounts[i] = 100; + + expectedTokenIds[i] = nextId; + nextId += 1; + } + + vm.prank(admin); + base.batchMintTo(nftHolder, tokenIds, amounts, baseURI); + + for (uint256 i = 0; i < numToMint; i += 1) { + uint256 id = expectedTokenIds[i]; + + assertEq(base.balanceOf(nftHolder, id), amounts[i]); + assertEq(base.totalSupply(id), amounts[i]); + } + + vm.prank(nftHolder); + base.burnBatch(nftHolder, expectedTokenIds, amounts); + + for (uint256 i = 0; i < numToMint; i += 1) { + uint256 id = expectedTokenIds[i]; + + assertEq(base.balanceOf(nftHolder, id), 0); + assertEq(base.totalSupply(id), 0); + } + } + + function test_revert_burnBatch_unapprovedCaller() public { + uint256 numToMint = 3; + uint256[] memory tokenIds = new uint256[](numToMint); + uint256[] memory amounts = new uint256[](numToMint); + uint256[] memory expectedTokenIds = new uint256[](numToMint); + string memory baseURI = "ipfs://"; + + uint256 nextId = base.nextTokenIdToMint(); + + for (uint256 i = 0; i < numToMint; i += 1) { + tokenIds[i] = type(uint256).max; + amounts[i] = 100; + + expectedTokenIds[i] = nextId; + nextId += 1; + } + + vm.prank(admin); + base.batchMintTo(nftHolder, tokenIds, amounts, baseURI); + + for (uint256 i = 0; i < numToMint; i += 1) { + uint256 id = expectedTokenIds[i]; + + assertEq(base.balanceOf(nftHolder, id), amounts[i]); + assertEq(base.totalSupply(id), amounts[i]); + } + + vm.prank(admin); + vm.expectRevert("Unapproved caller"); + base.burnBatch(nftHolder, expectedTokenIds, amounts); + } + + function test_revert_burnBatch_lengthMismatch() public { + uint256 numToMint = 3; + uint256[] memory tokenIds = new uint256[](numToMint); + uint256[] memory amounts = new uint256[](numToMint); + uint256[] memory mockAmounts = new uint256[](numToMint + 1); + uint256[] memory expectedTokenIds = new uint256[](numToMint); + string memory baseURI = "ipfs://"; + + uint256 nextId = base.nextTokenIdToMint(); + + for (uint256 i = 0; i < numToMint; i += 1) { + tokenIds[i] = type(uint256).max; + amounts[i] = 100; + mockAmounts[i] = 100; + + expectedTokenIds[i] = nextId; + nextId += 1; + } + + vm.prank(admin); + base.batchMintTo(nftHolder, tokenIds, amounts, baseURI); + + for (uint256 i = 0; i < numToMint; i += 1) { + uint256 id = expectedTokenIds[i]; + + assertEq(base.balanceOf(nftHolder, id), amounts[i]); + assertEq(base.totalSupply(id), amounts[i]); + } + + vm.prank(nftHolder); + vm.expectRevert("Length mismatch"); + base.burnBatch(nftHolder, expectedTokenIds, mockAmounts); + } + + function test_revert_burnBatch_notEnoughTokensOwned() public { + uint256 numToMint = 3; + uint256[] memory tokenIds = new uint256[](numToMint); + uint256[] memory amounts = new uint256[](numToMint); + uint256[] memory expectedTokenIds = new uint256[](numToMint); + string memory baseURI = "ipfs://"; + + uint256 nextId = base.nextTokenIdToMint(); + + for (uint256 i = 0; i < numToMint; i += 1) { + tokenIds[i] = type(uint256).max; + amounts[i] = 100; + + expectedTokenIds[i] = nextId; + nextId += 1; + } + + vm.prank(admin); + base.batchMintTo(nftHolder, tokenIds, amounts, baseURI); + + for (uint256 i = 0; i < numToMint; i += 1) { + amounts[i] += 1; + } + + vm.prank(nftHolder); + vm.expectRevert("Not enough tokens owned"); + base.burnBatch(nftHolder, expectedTokenIds, amounts); + } +} diff --git a/src/test/sdk/base/ERC1155DelayedReveal.t.sol b/src/test/sdk/base/ERC1155DelayedReveal.t.sol new file mode 100644 index 000000000..ffc0e1a06 --- /dev/null +++ b/src/test/sdk/base/ERC1155DelayedReveal.t.sol @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import { ERC1155DelayedReveal } from "contracts/base/ERC1155DelayedReveal.sol"; +import { Strings } from "contracts/lib/Strings.sol"; + +contract ERC1155DelayedRevealTest is DSTest, Test { + using Strings for uint256; + + // Target contract + ERC1155DelayedReveal internal base; + + // Signers + address internal admin; + address internal nftHolder; + + // Lazy mitning args + uint256 internal lazymintAmount = 10; + string internal baseURI = "ipfs://"; + string internal placeholderURI = "placeholderURI://"; + bytes internal key = "key"; + + function setUp() public { + admin = address(0x123); + nftHolder = address(0x456); + + vm.prank(admin); + base = new ERC1155DelayedReveal(admin, "name", "symbol", admin, 0); + + bytes memory encryptedBaseURI = base.encryptDecrypt(bytes(baseURI), key); + bytes32 provenanceHash = keccak256(abi.encodePacked(baseURI, key, block.chainid)); + vm.prank(admin); + base.lazyMint(lazymintAmount, placeholderURI, abi.encode(encryptedBaseURI, provenanceHash)); + } + + function test_state_reveal() public { + uint256 nextId = base.nextTokenIdToMint(); + + for (uint256 i = 0; i < nextId; i += 1) { + assertEq(base.uri(i), string(abi.encodePacked(placeholderURI, "0"))); + } + + vm.prank(admin); + base.reveal(0, key); + + for (uint256 i = 0; i < nextId; i += 1) { + assertEq(base.uri(i), string(abi.encodePacked(baseURI, i.toString()))); + } + } + + function test_state_reveal_additionalBatch() public { + uint256 nextIdBefore = base.nextTokenIdToMint(); + + string memory newBaseURI = "ipfsNew://"; + string memory newPlaceholderURI = "placeholderURINew://"; + bytes memory newKey = "newkey"; + + bytes memory encryptedBaseURI = base.encryptDecrypt(bytes(newBaseURI), newKey); + bytes32 provenanceHash = keccak256(abi.encodePacked(newBaseURI, newKey, block.chainid)); + vm.prank(admin); + base.lazyMint(lazymintAmount, newPlaceholderURI, abi.encode(encryptedBaseURI, provenanceHash)); + + uint256 nextId = base.nextTokenIdToMint(); + + for (uint256 i = nextIdBefore; i < nextId; i += 1) { + assertEq(base.uri(i), string(abi.encodePacked(newPlaceholderURI, "0"))); + } + + vm.prank(admin); + base.reveal(1, newKey); + + for (uint256 i = nextIdBefore; i < nextId; i += 1) { + assertEq(base.uri(i), string(abi.encodePacked(newBaseURI, i.toString()))); + } + } +} diff --git a/src/test/sdk/base/ERC1155Drop.t.sol b/src/test/sdk/base/ERC1155Drop.t.sol new file mode 100644 index 000000000..580fe3b77 --- /dev/null +++ b/src/test/sdk/base/ERC1155Drop.t.sol @@ -0,0 +1,490 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import { ERC1155Drop } from "contracts/base/ERC1155Drop.sol"; +import { Strings } from "contracts/lib/Strings.sol"; + +contract ERC1155DropTest is DSTest, Test { + using Strings for uint256; + + // Target contract + ERC1155Drop internal base; + + // Signers + uint256 internal adminPkey; + uint256 internal nftHolderPkey; + + address internal admin; + address internal nftHolder; + address internal saleRecipient; + + ERC1155Drop.ClaimCondition condition; + ERC1155Drop.AllowlistProof allowlistProof; + + uint256 internal targetTokenId; + + function setUp() public { + adminPkey = 123; + nftHolderPkey = 456; + + admin = vm.addr(adminPkey); + nftHolder = vm.addr(nftHolderPkey); + saleRecipient = address(0x8910); + + vm.deal(nftHolder, 100 ether); + + vm.prank(admin); + base = new ERC1155Drop(admin, "name", "symbol", admin, 0, saleRecipient); + + targetTokenId = base.nextTokenIdToMint(); + + vm.prank(admin); + base.lazyMint(1, "ipfs://", ""); + } + + function test_state_setClaimConditions() public { + condition.startTimestamp = block.timestamp; + condition.maxClaimableSupply = 100; + condition.supplyClaimed = 0; + condition.quantityLimitPerWallet = 5; + condition.merkleRoot = bytes32(0); + condition.pricePerToken = 0; + condition.currency = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + + (, uint256 maxClaimableSupplyBefore, , , , , , ) = base.claimCondition(targetTokenId); + + assertEq(maxClaimableSupplyBefore, 0); + + vm.prank(admin); + base.setClaimConditions(targetTokenId, condition, true); + + (, uint256 maxClaimable, , uint256 quantityLimitPerWallet, , , address currency, ) = base.claimCondition( + targetTokenId + ); + + assertEq(maxClaimable, 100); + assertEq(quantityLimitPerWallet, 5); + assertEq(currency, 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE); + } + + function test_state_setClaimConditions_resetEligibility() public { + condition.startTimestamp = block.timestamp; + condition.maxClaimableSupply = 100; + condition.supplyClaimed = 0; + condition.quantityLimitPerWallet = 5; + condition.merkleRoot = bytes32(0); + condition.pricePerToken = 0; + condition.currency = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + + vm.prank(admin); + base.setClaimConditions(targetTokenId, condition, false); + + vm.prank(nftHolder, nftHolder); + base.claim(nftHolder, targetTokenId, 1, condition.currency, condition.pricePerToken, allowlistProof, ""); + + (, , uint256 supplyClaimedBefore, , , , , ) = base.claimCondition(targetTokenId); + assertEq(supplyClaimedBefore, 1); + + vm.prank(admin); + base.setClaimConditions(targetTokenId, condition, false); + + (, , uint256 supplyClaimedAfter, , , , , ) = base.claimCondition(targetTokenId); + assertEq(supplyClaimedBefore, supplyClaimedAfter); + } + + function test_revert_setClaimConditions_unauthorizedCaller() public { + condition.startTimestamp = block.timestamp; + condition.maxClaimableSupply = 100; + condition.supplyClaimed = 0; + condition.quantityLimitPerWallet = 5; + condition.merkleRoot = bytes32(0); + condition.pricePerToken = 0; + condition.currency = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + + (, uint256 maxClaimableSupplyBefore, , , , , , ) = base.claimCondition(targetTokenId); + + assertEq(maxClaimableSupplyBefore, 0); + + vm.prank(nftHolder); + vm.expectRevert("Not authorized"); + base.setClaimConditions(targetTokenId, condition, true); + } + + function test_revert_setClaimConditions_supplyClaimedAlready() public { + condition.startTimestamp = block.timestamp; + condition.maxClaimableSupply = 100; + condition.supplyClaimed = 0; + condition.quantityLimitPerWallet = 100; + condition.merkleRoot = bytes32(0); + condition.pricePerToken = 0; + condition.currency = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + + vm.prank(admin); + base.setClaimConditions(targetTokenId, condition, false); + + vm.prank(nftHolder, nftHolder); + base.claim( + nftHolder, + targetTokenId, + condition.quantityLimitPerWallet, + condition.currency, + condition.pricePerToken, + allowlistProof, + "" + ); + + condition.maxClaimableSupply = 50; + + vm.prank(admin); + vm.expectRevert("max supply claimed"); + base.setClaimConditions(targetTokenId, condition, false); + } + + function test_state_claim() public { + condition.startTimestamp = block.timestamp; + condition.maxClaimableSupply = 100; + condition.supplyClaimed = 0; + condition.quantityLimitPerWallet = 5; + condition.merkleRoot = bytes32(0); + condition.pricePerToken = 0; + condition.currency = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + + (, uint256 maxClaimableSupplyBefore, , , , , , ) = base.claimCondition(targetTokenId); + + assertEq(maxClaimableSupplyBefore, 0); + + vm.prank(admin); + base.setClaimConditions(targetTokenId, condition, true); + + uint256 quantityToClaim = 5; + vm.prank(nftHolder, nftHolder); + base.claim( + nftHolder, + targetTokenId, + quantityToClaim, + condition.currency, + condition.pricePerToken, + allowlistProof, + "" + ); + + (, , uint256 supplyClaimed, , , , , ) = base.claimCondition(targetTokenId); + assertEq(supplyClaimed, quantityToClaim); + + assertEq(base.balanceOf(nftHolder, targetTokenId), quantityToClaim); + assertEq(base.totalSupply(targetTokenId), quantityToClaim); + } + + function test_state_claim_withPrice() public { + condition.startTimestamp = block.timestamp; + condition.maxClaimableSupply = 100; + condition.supplyClaimed = 0; + condition.quantityLimitPerWallet = 5; + condition.merkleRoot = bytes32(0); + condition.pricePerToken = 0.01 ether; + condition.currency = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + + (, uint256 maxClaimableSupplyBefore, , , , , , ) = base.claimCondition(targetTokenId); + + assertEq(maxClaimableSupplyBefore, 0); + + vm.prank(admin); + base.setClaimConditions(targetTokenId, condition, true); + + uint256 quantityToClaim = 5; + uint256 totalPrice = quantityToClaim * condition.pricePerToken; + + uint256 saleRecipientBalBefore = saleRecipient.balance; + + vm.prank(nftHolder, nftHolder); + base.claim{ value: totalPrice }( + nftHolder, + targetTokenId, + quantityToClaim, + condition.currency, + condition.pricePerToken, + allowlistProof, + "" + ); + + assertEq(saleRecipient.balance, saleRecipientBalBefore + totalPrice); + } + + function test_state_claim_withAllowlist() public { + string[] memory inputs = new string[](5); + + inputs[0] = "node"; + inputs[1] = "src/test/scripts/generateRoot.ts"; + inputs[2] = "1"; + inputs[3] = "0"; + inputs[4] = "0x0000000000000000000000000000000000000000"; + + bytes memory result = vm.ffi(inputs); + bytes32 root = abi.decode(result, (bytes32)); + + inputs[1] = "src/test/scripts/getProof.ts"; + result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + address claimer = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); + + condition.startTimestamp = block.timestamp; + condition.maxClaimableSupply = 100; + condition.supplyClaimed = 0; + condition.quantityLimitPerWallet = 5; + condition.merkleRoot = root; + condition.pricePerToken = 0; + condition.currency = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + + (, uint256 maxClaimableSupplyBefore, , , , , , ) = base.claimCondition(targetTokenId); + + assertEq(maxClaimableSupplyBefore, 0); + + vm.prank(admin); + base.setClaimConditions(targetTokenId, condition, true); + + allowlistProof.proof = proofs; + allowlistProof.quantityLimitPerWallet = 1; + allowlistProof.pricePerToken = 0; + allowlistProof.currency = address(0); + + uint256 quantityToClaim = allowlistProof.quantityLimitPerWallet; + + vm.prank(claimer, claimer); + base.claim( + nftHolder, + targetTokenId, + quantityToClaim, + condition.currency, + condition.pricePerToken, + allowlistProof, + "" + ); + + (, , uint256 supplyClaimed, , , , , ) = base.claimCondition(targetTokenId); + assertEq(supplyClaimed, quantityToClaim); + + assertEq(base.balanceOf(nftHolder, targetTokenId), quantityToClaim); + assertEq(base.totalSupply(targetTokenId), quantityToClaim); + } + + function test_revert_claim_invalidQtyProof() public { + string[] memory inputs = new string[](5); + + inputs[0] = "node"; + inputs[1] = "src/test/scripts/generateRoot.ts"; + inputs[2] = "1"; + inputs[3] = "0"; + inputs[4] = "0x0000000000000000000000000000000000000000"; + + bytes memory result = vm.ffi(inputs); + bytes32 root = abi.decode(result, (bytes32)); + + inputs[1] = "src/test/scripts/getProof.ts"; + result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + address claimer = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); + + condition.startTimestamp = block.timestamp; + condition.maxClaimableSupply = 100; + condition.supplyClaimed = 0; + condition.quantityLimitPerWallet = 5; + condition.merkleRoot = root; + condition.pricePerToken = 0; + condition.currency = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + + (, uint256 maxClaimableSupplyBefore, , , , , , ) = base.claimCondition(targetTokenId); + + assertEq(maxClaimableSupplyBefore, 0); + + vm.prank(admin); + base.setClaimConditions(targetTokenId, condition, true); + + allowlistProof.proof = proofs; + allowlistProof.quantityLimitPerWallet = 1; + allowlistProof.pricePerToken = 0; + allowlistProof.currency = address(0); + + uint256 quantityToClaim = allowlistProof.quantityLimitPerWallet + 1; + + bytes memory errorQty = "!Qty"; + + vm.prank(claimer, claimer); + vm.expectRevert(errorQty); + base.claim( + nftHolder, + targetTokenId, + quantityToClaim, + condition.currency, + condition.pricePerToken, + allowlistProof, + "" + ); + } + + function test_revert_claim_invalidPrice() public { + condition.startTimestamp = block.timestamp; + condition.maxClaimableSupply = 100; + condition.supplyClaimed = 0; + condition.quantityLimitPerWallet = 5; + condition.merkleRoot = bytes32(0); + condition.pricePerToken = 0.01 ether; + condition.currency = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + + (, uint256 maxClaimableSupplyBefore, , , , , , ) = base.claimCondition(targetTokenId); + + assertEq(maxClaimableSupplyBefore, 0); + + vm.prank(admin); + base.setClaimConditions(targetTokenId, condition, true); + + uint256 quantityToClaim = 5; + uint256 totalPrice = quantityToClaim * condition.pricePerToken; + + vm.prank(nftHolder, nftHolder); + vm.expectRevert("!PriceOrCurrency"); + base.claim{ value: totalPrice - 1 }( + nftHolder, + targetTokenId, + quantityToClaim, + condition.currency, + 0, + allowlistProof, + "" + ); + } + + function test_revert_claim_insufficientPrice() public { + condition.startTimestamp = block.timestamp; + condition.maxClaimableSupply = 100; + condition.supplyClaimed = 0; + condition.quantityLimitPerWallet = 5; + condition.merkleRoot = bytes32(0); + condition.pricePerToken = 0.01 ether; + condition.currency = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + + (, uint256 maxClaimableSupplyBefore, , , , , , ) = base.claimCondition(targetTokenId); + + assertEq(maxClaimableSupplyBefore, 0); + + vm.prank(admin); + base.setClaimConditions(targetTokenId, condition, true); + + uint256 quantityToClaim = 5; + uint256 totalPrice = quantityToClaim * condition.pricePerToken; + + vm.prank(nftHolder, nftHolder); + vm.expectRevert("Invalid msg value"); + base.claim{ value: totalPrice - 1 }( + nftHolder, + targetTokenId, + quantityToClaim, + condition.currency, + condition.pricePerToken, + allowlistProof, + "" + ); + } + + function test_revert_claim_invalidCurrency() public { + condition.startTimestamp = block.timestamp; + condition.maxClaimableSupply = 100; + condition.supplyClaimed = 0; + condition.quantityLimitPerWallet = 5; + condition.merkleRoot = bytes32(0); + condition.pricePerToken = 0.01 ether; + condition.currency = address(0x123); + + (, uint256 maxClaimableSupplyBefore, , , , , , ) = base.claimCondition(targetTokenId); + + assertEq(maxClaimableSupplyBefore, 0); + + vm.prank(admin); + base.setClaimConditions(targetTokenId, condition, true); + + uint256 quantityToClaim = 5; + uint256 totalPrice = quantityToClaim * condition.pricePerToken; + + vm.prank(nftHolder, nftHolder); + vm.expectRevert("!PriceOrCurrency"); + base.claim{ value: totalPrice }( + nftHolder, + targetTokenId, + quantityToClaim, + 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE, + condition.pricePerToken, + allowlistProof, + "" + ); + } + + function test_revert_claim_invalidQuantity() public { + condition.startTimestamp = block.timestamp; + condition.maxClaimableSupply = 100; + condition.supplyClaimed = 0; + condition.quantityLimitPerWallet = 5; + condition.merkleRoot = bytes32(0); + condition.pricePerToken = 0.01 ether; + condition.currency = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + + (, uint256 maxClaimableSupplyBefore, , , , , , ) = base.claimCondition(targetTokenId); + + assertEq(maxClaimableSupplyBefore, 0); + + vm.prank(admin); + base.setClaimConditions(targetTokenId, condition, true); + + uint256 quantityToClaim = condition.quantityLimitPerWallet + 1; + uint256 totalPrice = quantityToClaim * condition.pricePerToken; + + bytes memory errorQty = "!Qty"; + + vm.prank(nftHolder, nftHolder); + vm.expectRevert(errorQty); + base.claim{ value: totalPrice }( + nftHolder, + targetTokenId, + quantityToClaim, + condition.currency, + condition.pricePerToken, + allowlistProof, + "" + ); + } + + function test_revert_claim_exceedsMaxSupply() public { + condition.startTimestamp = block.timestamp; + condition.maxClaimableSupply = 100; + condition.supplyClaimed = 0; + condition.quantityLimitPerWallet = 101; + condition.merkleRoot = bytes32(0); + condition.pricePerToken = 0.01 ether; + condition.currency = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + + (, uint256 maxClaimableSupplyBefore, , , , , , ) = base.claimCondition(targetTokenId); + + assertEq(maxClaimableSupplyBefore, 0); + + vm.prank(admin); + base.setClaimConditions(targetTokenId, condition, true); + + uint256 quantityToClaim = condition.quantityLimitPerWallet; + uint256 totalPrice = quantityToClaim * condition.pricePerToken; + + vm.prank(nftHolder, nftHolder); + vm.expectRevert("!MaxSupply"); + base.claim{ value: totalPrice }( + nftHolder, + targetTokenId, + quantityToClaim, + condition.currency, + condition.pricePerToken, + allowlistProof, + "" + ); + } +} diff --git a/src/test/sdk/base/ERC1155LazyMint.t.sol b/src/test/sdk/base/ERC1155LazyMint.t.sol new file mode 100644 index 000000000..3d83c1b94 --- /dev/null +++ b/src/test/sdk/base/ERC1155LazyMint.t.sol @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import { ERC1155LazyMint } from "contracts/base/ERC1155LazyMint.sol"; +import { Strings } from "contracts/lib/Strings.sol"; + +contract ERC1155LazyMintTest is DSTest, Test { + using Strings for uint256; + + // Target contract + ERC1155LazyMint internal base; + + // Signers + address internal admin; + address internal nftHolder; + + // Lazy mitning args + uint256 internal lazymintAmount = 10; + string internal baseURI = "ipfs://"; + + function setUp() public { + admin = address(0x123); + nftHolder = address(0x456); + + vm.prank(admin); + base = new ERC1155LazyMint(admin, "name", "symbol", admin, 0); + + // Lazy mint tokens + vm.prank(admin); + base.lazyMint(lazymintAmount, baseURI, ""); + + assertEq(base.nextTokenIdToMint(), lazymintAmount); + } + + function test_state_claim() public { + uint256 tokenId = 0; + uint256 amount = 100; + + vm.prank(nftHolder); + base.claim(nftHolder, tokenId, amount); + + assertEq(base.balanceOf(nftHolder, tokenId), amount); + assertEq(base.totalSupply(tokenId), amount); + assertEq(base.uri(tokenId), string(abi.encodePacked(baseURI, tokenId.toString()))); + } + + function test_revert_mintTo_invalidId() public { + uint256 tokenId = base.nextTokenIdToMint(); + uint256 amount = 100; + + vm.prank(nftHolder); + vm.expectRevert("invalid id"); + base.claim(nftHolder, tokenId, amount); + } +} diff --git a/src/test/sdk/base/ERC1155SignatureMint.t.sol b/src/test/sdk/base/ERC1155SignatureMint.t.sol new file mode 100644 index 000000000..77433ba98 --- /dev/null +++ b/src/test/sdk/base/ERC1155SignatureMint.t.sol @@ -0,0 +1,240 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import { ERC1155SignatureMint } from "contracts/base/ERC1155SignatureMint.sol"; +import { Strings } from "contracts/lib/Strings.sol"; + +contract ERC1155SignatureMintTest is DSTest, Test { + using Strings for uint256; + + // Target contract + ERC1155SignatureMint internal base; + + // Signers + uint256 internal adminPkey; + uint256 internal nftHolderPkey; + + address internal admin; + address internal nftHolder; + address internal saleRecipient; + + bytes32 internal typehashMintRequest; + bytes32 internal nameHash; + bytes32 internal versionHash; + bytes32 internal typehashEip712; + bytes32 internal domainSeparator; + + ERC1155SignatureMint.MintRequest req; + + function signMintRequest( + ERC1155SignatureMint.MintRequest memory _request, + uint256 privateKey + ) internal view returns (bytes memory) { + bytes memory encodedRequest = bytes.concat( + abi.encode( + typehashMintRequest, + _request.to, + _request.royaltyRecipient, + _request.royaltyBps, + _request.primarySaleRecipient, + _request.tokenId, + keccak256(bytes(_request.uri)) + ), + abi.encode( + _request.quantity, + _request.pricePerToken, + _request.currency, + _request.validityStartTimestamp, + _request.validityEndTimestamp, + _request.uid + ) + ); + bytes32 structHash = keccak256(encodedRequest); + bytes32 typedDataHash = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, typedDataHash); + bytes memory sig = abi.encodePacked(r, s, v); + + return sig; + } + + function setUp() public { + adminPkey = 123; + nftHolderPkey = 456; + + admin = vm.addr(adminPkey); + nftHolder = vm.addr(nftHolderPkey); + saleRecipient = address(0x8910); + + vm.deal(nftHolder, 100 ether); + + vm.prank(admin); + base = new ERC1155SignatureMint(admin, "name", "symbol", admin, 0, saleRecipient); + + typehashMintRequest = keccak256( + "MintRequest(address to,address royaltyRecipient,uint256 royaltyBps,address primarySaleRecipient,uint256 tokenId,string uri,uint256 quantity,uint256 pricePerToken,address currency,uint128 validityStartTimestamp,uint128 validityEndTimestamp,bytes32 uid)" + ); + nameHash = keccak256(bytes("SignatureMintERC1155")); + versionHash = keccak256(bytes("1")); + typehashEip712 = keccak256( + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" + ); + domainSeparator = keccak256(abi.encode(typehashEip712, nameHash, versionHash, block.chainid, address(base))); + } + + function test_state_mintWithSignature_newNFTs() public { + req.to = nftHolder; + req.royaltyRecipient = admin; + req.royaltyBps = 0; + req.primarySaleRecipient = saleRecipient; + req.tokenId = type(uint256).max; + req.uri = "ipfs://"; + req.quantity = 100; + req.pricePerToken = 0; + req.currency = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + req.validityStartTimestamp = 0; + req.validityEndTimestamp = type(uint128).max; + req.uid = keccak256("uid"); + + bytes memory signature = signMintRequest(req, adminPkey); + + uint256 tokenId = base.nextTokenIdToMint(); + assertEq(base.totalSupply(tokenId), 0); + + vm.prank(nftHolder); + base.mintWithSignature(req, signature); + + assertEq(base.balanceOf(nftHolder, tokenId), req.quantity); + assertEq(base.totalSupply(tokenId), req.quantity); + assertEq(base.uri(tokenId), req.uri); + } + + function test_state_mintWithSignature_existingNFTs() public { + req.to = nftHolder; + req.royaltyRecipient = admin; + req.royaltyBps = 0; + req.primarySaleRecipient = saleRecipient; + req.tokenId = type(uint256).max; + req.uri = "ipfs://"; + req.quantity = 100; + req.pricePerToken = 0; + req.currency = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + req.validityStartTimestamp = 0; + req.validityEndTimestamp = type(uint128).max; + req.uid = keccak256("uid"); + + bytes memory signature = signMintRequest(req, adminPkey); + + uint256 tokenId = base.nextTokenIdToMint(); + assertEq(base.totalSupply(tokenId), 0); + + vm.prank(nftHolder); + base.mintWithSignature(req, signature); + + assertEq(base.balanceOf(nftHolder, tokenId), req.quantity); + assertEq(base.totalSupply(tokenId), req.quantity); + + req.tokenId = tokenId; + string memory originalURI = req.uri; + req.uri = "wrongURI://"; + req.uid = keccak256("new uid"); + + bytes memory signature2 = signMintRequest(req, adminPkey); + vm.prank(nftHolder); + base.mintWithSignature(req, signature2); + + assertEq(base.balanceOf(nftHolder, tokenId), req.quantity * 2); + assertEq(base.totalSupply(tokenId), req.quantity * 2); + assertEq(base.uri(tokenId), originalURI); + } + + function test_state_mintWithSignature_withPrice() public { + req.to = nftHolder; + req.royaltyRecipient = admin; + req.royaltyBps = 0; + req.primarySaleRecipient = saleRecipient; + req.tokenId = type(uint256).max; + req.uri = "ipfs://"; + req.quantity = 100; + req.pricePerToken = 0.01 ether; + req.currency = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + req.validityStartTimestamp = 0; + req.validityEndTimestamp = type(uint128).max; + req.uid = keccak256("uid"); + + uint256 saleRecipientBalBefore = saleRecipient.balance; + uint256 totalPrice = req.pricePerToken * req.quantity; + + bytes memory signature = signMintRequest(req, adminPkey); + vm.prank(nftHolder); + base.mintWithSignature{ value: totalPrice }(req, signature); + + assertEq(saleRecipient.balance, saleRecipientBalBefore + totalPrice); + } + + function test_revert_mintWithSignature_withPrice_incorrectPrice() public { + req.to = nftHolder; + req.royaltyRecipient = admin; + req.royaltyBps = 0; + req.primarySaleRecipient = saleRecipient; + req.tokenId = type(uint256).max; + req.uri = "ipfs://"; + req.quantity = 100; + req.pricePerToken = 0.01 ether; + req.currency = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + req.validityStartTimestamp = 0; + req.validityEndTimestamp = type(uint128).max; + req.uid = keccak256("uid"); + + uint256 totalPrice = req.pricePerToken * req.quantity; + bytes memory signature = signMintRequest(req, adminPkey); + vm.prank(nftHolder); + vm.expectRevert("Invalid msg value"); + base.mintWithSignature{ value: totalPrice - 1 }(req, signature); + } + + function test_revert_mintWithSignature_mintingZeroTokens() public { + req.to = nftHolder; + req.royaltyRecipient = admin; + req.royaltyBps = 0; + req.primarySaleRecipient = saleRecipient; + req.tokenId = type(uint256).max; + req.uri = "ipfs://"; + req.quantity = 0; + req.pricePerToken = 0; + req.currency = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + req.validityStartTimestamp = 0; + req.validityEndTimestamp = type(uint128).max; + req.uid = keccak256("uid"); + + bytes memory signature = signMintRequest(req, adminPkey); + vm.prank(nftHolder); + vm.expectRevert("Minting zero tokens."); + base.mintWithSignature(req, signature); + } + + function test_revert_mintWithSignature_invalidId() public { + uint256 nextId = base.nextTokenIdToMint(); + + req.to = nftHolder; + req.royaltyRecipient = admin; + req.royaltyBps = 0; + req.primarySaleRecipient = saleRecipient; + req.tokenId = nextId; + req.uri = "ipfs://"; + req.quantity = 100; + req.pricePerToken = 0; + req.currency = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + req.validityStartTimestamp = 0; + req.validityEndTimestamp = type(uint128).max; + req.uid = keccak256("uid"); + + bytes memory signature = signMintRequest(req, adminPkey); + vm.prank(nftHolder); + vm.expectRevert("invalid id"); + base.mintWithSignature(req, signature); + } +} diff --git a/src/test/sdk/base/ERC20Base.t.sol b/src/test/sdk/base/ERC20Base.t.sol new file mode 100644 index 000000000..ec8131cfd --- /dev/null +++ b/src/test/sdk/base/ERC20Base.t.sol @@ -0,0 +1,248 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import "./BaseUtilTest.sol"; +import { ERC20Base } from "contracts/base/ERC20Base.sol"; + +contract BaseERC20BaseTest is BaseUtilTest { + ERC20Base internal base; + using Strings for uint256; + + bytes32 internal permitTypeHash; + bytes32 internal permitNameHash; + bytes32 internal permitVersionHash; + bytes32 internal typehashEip712; + bytes32 internal domainSeparator; + + uint256 public recipientPrivateKey = 5678; + address public recipient; + + function setUp() public override { + super.setUp(); + vm.prank(deployer); + base = new ERC20Base(deployer, NAME, SYMBOL); + + recipient = vm.addr(recipientPrivateKey); + + typehashEip712 = keccak256( + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" + ); + + // permit related inputs + permitTypeHash = keccak256( + "Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)" + ); + permitNameHash = keccak256(bytes(NAME)); + permitVersionHash = keccak256("1"); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `mint` + //////////////////////////////////////////////////////////////*/ + + function test_state_mint() public { + uint256 amount = 5 ether; + + uint256 currentTotalSupply = base.totalSupply(); + uint256 currentBalanceOfRecipient = base.balanceOf(recipient); + + vm.prank(deployer); + base.mintTo(recipient, amount); + + assertEq(base.totalSupply(), currentTotalSupply + amount); + assertEq(base.balanceOf(recipient), currentBalanceOfRecipient + amount); + } + + function test_revert_mint_NotAuthorized() public { + uint256 amount = 5 ether; + + vm.expectRevert("Not authorized to mint."); + vm.prank(address(0x1)); + base.mintTo(recipient, amount); + } + + function test_revert_mint_MintingZeroTokens() public { + uint256 amount = 0; + + vm.expectRevert("Minting zero tokens."); + vm.prank(deployer); + base.mintTo(recipient, amount); + } + + function test_revert_mint_MintToZeroAddress() public { + uint256 amount = 1; + + vm.expectRevert("ERC20: mint to the zero address"); + vm.prank(deployer); + base.mintTo(address(0), amount); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `burn` + //////////////////////////////////////////////////////////////*/ + + function test_state_burn() public { + uint256 amount = 5 ether; + + uint256 currentTotalSupply = base.totalSupply(); + uint256 currentBalanceOfRecipient = base.balanceOf(recipient); + + vm.prank(deployer); + base.mintTo(recipient, amount); + + assertEq(base.totalSupply(), currentTotalSupply + amount); + assertEq(base.balanceOf(recipient), currentBalanceOfRecipient + amount); + + // burn minted tokens + currentTotalSupply = base.totalSupply(); + currentBalanceOfRecipient = base.balanceOf(recipient); + vm.prank(recipient); + base.burn(amount); + + assertEq(base.totalSupply(), currentTotalSupply - amount); + assertEq(base.balanceOf(recipient), currentBalanceOfRecipient - amount); + } + + function test_revert_burn_NotEnoughBalance() public { + uint256 amount = 5 ether; + + vm.prank(deployer); + base.mintTo(recipient, amount); + + vm.expectRevert("not enough balance"); + vm.prank(recipient); + base.burn(amount + 1); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `permit` + //////////////////////////////////////////////////////////////*/ + + function test_state_permit() public { + uint256 amount = 5 ether; + + // mint amount to recipient + vm.prank(deployer); + base.mintTo(recipient, amount); + + // generate permit signature + address _owner = recipient; + address _spender = address(0x789); + uint256 _value = 1 ether; + uint256 _deadline = 1000; + + uint256 _nonce = base.nonces(_owner); + + domainSeparator = keccak256( + abi.encode(typehashEip712, permitNameHash, permitVersionHash, block.chainid, address(base)) + ); + bytes32 structHash = keccak256(abi.encode(permitTypeHash, _owner, _spender, _value, _nonce, _deadline)); + bytes32 typedDataHash = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(recipientPrivateKey, typedDataHash); + + // call permit to approve _value to _spender + base.permit(_owner, _spender, _value, _deadline, v, r, s); + + // check allowance + uint256 _allowance = base.allowance(_owner, _spender); + + assertEq(_allowance, _value); + assertEq(base.nonces(_owner), _nonce + 1); + } + + function test_revert_permit_IncorrectKey() public { + uint256 amount = 5 ether; + uint256 wrongPrivateKey = 2345; + + // mint amount to recipient + vm.prank(deployer); + base.mintTo(recipient, amount); + + // generate permit signature + address _owner = recipient; + address _spender = address(0x789); + uint256 _value = 1 ether; + uint256 _deadline = 1000; + + uint256 _nonce = base.nonces(_owner); + + domainSeparator = keccak256( + abi.encode(typehashEip712, permitNameHash, permitVersionHash, block.chainid, address(base)) + ); + bytes32 structHash = keccak256(abi.encode(permitTypeHash, _owner, _spender, _value, _nonce, _deadline)); + bytes32 typedDataHash = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(wrongPrivateKey, typedDataHash); // sign with wrong key + + // call permit to approve _value to _spender + vm.expectRevert("ERC20Permit: invalid signature"); + base.permit(_owner, _spender, _value, _deadline, v, r, s); + } + + function test_revert_permit_UsedNonce() public { + uint256 amount = 5 ether; + + // mint amount to recipient + vm.prank(deployer); + base.mintTo(recipient, amount); + + // generate permit signature + address _owner = recipient; + address _spender = address(0x789); + uint256 _value = 1 ether; + uint256 _deadline = 1000; + + uint256 _nonce = base.nonces(_owner); + + domainSeparator = keccak256( + abi.encode(typehashEip712, permitNameHash, permitVersionHash, block.chainid, address(base)) + ); + bytes32 structHash = keccak256(abi.encode(permitTypeHash, _owner, _spender, _value, _nonce, _deadline)); + bytes32 typedDataHash = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(recipientPrivateKey, typedDataHash); + + // call permit to approve _value to _spender + base.permit(_owner, _spender, _value, _deadline, v, r, s); + + // sign again with same nonce + (v, r, s) = vm.sign(recipientPrivateKey, typedDataHash); + + vm.expectRevert("ERC20Permit: invalid signature"); + base.permit(_owner, _spender, _value, _deadline, v, r, s); + } + + function test_revert_permit_ExpiredDeadline() public { + uint256 amount = 5 ether; + // uint256 wrongPrivateKey = 2345; + + // mint amount to recipient + vm.prank(deployer); + base.mintTo(recipient, amount); + + // generate permit signature + address _owner = recipient; + address _spender = address(0x789); + uint256 _value = 1 ether; + uint256 _deadline = 1000; + + uint256 _nonce = base.nonces(_owner); + + domainSeparator = keccak256( + abi.encode(typehashEip712, permitNameHash, permitVersionHash, block.chainid, address(base)) + ); + bytes32 structHash = keccak256(abi.encode(permitTypeHash, _owner, _spender, _value, _nonce, _deadline)); + bytes32 typedDataHash = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(recipientPrivateKey, typedDataHash); + + // call permit to approve _value to _spender + vm.warp(_deadline + 1); + vm.expectRevert("ERC20Permit: expired deadline"); + base.permit(_owner, _spender, _value, _deadline, v, r, s); + } +} diff --git a/src/test/sdk/base/ERC20Drop.t.sol b/src/test/sdk/base/ERC20Drop.t.sol new file mode 100644 index 000000000..cb4457298 --- /dev/null +++ b/src/test/sdk/base/ERC20Drop.t.sol @@ -0,0 +1,300 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import "./BaseUtilTest.sol"; +import { ERC20Drop } from "contracts/base/ERC20Drop.sol"; + +contract BaseERC20DropTest is BaseUtilTest { + ERC20Drop internal base; + using Strings for uint256; + + bytes32 internal typehashEip712; + bytes32 internal domainSeparator; + + // permit + bytes32 internal permitTypeHash; + bytes32 internal permitNameHash; + bytes32 internal permitVersionHash; + + uint256 public recipientPrivateKey = 5678; + address public recipient; + + function setUp() public override { + super.setUp(); + vm.prank(signer); + base = new ERC20Drop(signer, NAME, SYMBOL, saleRecipient); + + recipient = vm.addr(recipientPrivateKey); + erc20.mint(recipient, 1_000_000 ether); + vm.deal(recipient, 1_000_000 ether); + + typehashEip712 = keccak256( + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" + ); + + // permit related inputs + permitTypeHash = keccak256( + "Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)" + ); + permitNameHash = keccak256(bytes(NAME)); + permitVersionHash = keccak256("1"); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `claim` + //////////////////////////////////////////////////////////////*/ + + function test_state_claim_ZeroPrice() public { + vm.warp(1); + + address claimer = address(0x345); + uint256 _quantity = 10; + + uint256 currentTotalSupply = base.totalSupply(); + uint256 currentBalanceOfRecipient = base.balanceOf(recipient); + + bytes32[] memory proofs = new bytes32[](0); + + ERC20Drop.AllowlistProof memory alp; + alp.proof = proofs; + + ERC20Drop.ClaimCondition[] memory conditions = new ERC20Drop.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 100; + conditions[0].quantityLimitPerWallet = 100; + + vm.prank(signer); + base.setClaimConditions(conditions[0], false); + + vm.prank(claimer, claimer); + base.claim(recipient, _quantity, address(0), 0, alp, ""); + + assertEq(base.totalSupply(), currentTotalSupply + _quantity); + assertEq(base.balanceOf(recipient), currentBalanceOfRecipient + _quantity); + } + + function test_state_claim_NonZeroPrice_ERC20() public { + vm.warp(1); + + address claimer = address(0x345); + uint256 _quantity = 10 ether; + + uint256 currentTotalSupply = base.totalSupply(); + uint256 currentBalanceOfRecipient = base.balanceOf(recipient); + + bytes32[] memory proofs = new bytes32[](0); + + ERC20Drop.AllowlistProof memory alp; + alp.proof = proofs; + + ERC20Drop.ClaimCondition[] memory conditions = new ERC20Drop.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 100 ether; + conditions[0].quantityLimitPerWallet = 100 ether; + + // set price and currency + conditions[0].pricePerToken = 1 ether; + conditions[0].currency = address(erc20); + + uint256 totalPrice = (conditions[0].pricePerToken * _quantity) / 1 ether; + + vm.prank(signer); + base.setClaimConditions(conditions[0], false); + + // mint erc20 to claimer, and approve to base + erc20.mint(claimer, 1000 ether); + vm.prank(claimer); + erc20.approve(address(base), totalPrice); + + vm.prank(claimer, claimer); + base.claim(recipient, _quantity, address(erc20), 1 ether, alp, ""); + + assertEq(base.totalSupply(), currentTotalSupply + _quantity); + assertEq(base.balanceOf(recipient), currentBalanceOfRecipient + _quantity); + } + + function test_state_claim_NonZeroPrice_NativeToken() public { + vm.warp(1); + + address claimer = address(0x345); + uint256 _quantity = 10 ether; + + uint256 currentTotalSupply = base.totalSupply(); + uint256 currentBalanceOfRecipient = base.balanceOf(recipient); + + bytes32[] memory proofs = new bytes32[](0); + + ERC20Drop.AllowlistProof memory alp; + alp.proof = proofs; + + ERC20Drop.ClaimCondition[] memory conditions = new ERC20Drop.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 100 ether; + conditions[0].quantityLimitPerWallet = 100 ether; + + // set price and currency + conditions[0].pricePerToken = 1 ether; + conditions[0].currency = address(NATIVE_TOKEN); + + uint256 totalPrice = (conditions[0].pricePerToken * _quantity) / 1 ether; + + vm.prank(signer); + base.setClaimConditions(conditions[0], false); + + // deal NATIVE_TOKEN to claimer + vm.deal(claimer, 1_000 ether); + + vm.prank(claimer, claimer); + base.claim{ value: totalPrice }(recipient, _quantity, address(NATIVE_TOKEN), 1 ether, alp, ""); + + assertEq(base.totalSupply(), currentTotalSupply + _quantity); + assertEq(base.balanceOf(recipient), currentBalanceOfRecipient + _quantity); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `burn` + //////////////////////////////////////////////////////////////*/ + + function test_state_burn() public { + address claimer = address(0x345); + uint256 _quantity = 10 ether; + + uint256 currentTotalSupply = base.totalSupply(); + uint256 currentBalanceOfRecipient = base.balanceOf(recipient); + + bytes32[] memory proofs = new bytes32[](0); + + ERC20Drop.AllowlistProof memory alp; + alp.proof = proofs; + + ERC20Drop.ClaimCondition[] memory conditions = new ERC20Drop.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 100 ether; + conditions[0].quantityLimitPerWallet = 100 ether; + + vm.prank(signer); + base.setClaimConditions(conditions[0], false); + + vm.prank(claimer, claimer); + base.claim(recipient, _quantity, address(0), 0, alp, ""); + + // burn minted tokens + currentTotalSupply = base.totalSupply(); + currentBalanceOfRecipient = base.balanceOf(recipient); + vm.prank(recipient); + base.burn(_quantity); + + assertEq(base.totalSupply(), currentTotalSupply - _quantity); + assertEq(base.balanceOf(recipient), currentBalanceOfRecipient - _quantity); + } + + function test_revert_burn_NotEnoughBalance() public { + vm.expectRevert("not enough balance"); + vm.prank(recipient); + base.burn(1); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `permit` + //////////////////////////////////////////////////////////////*/ + + function test_state_permit() public { + // generate permit signature + address _owner = recipient; + address _spender = address(0x789); + uint256 _value = 1 ether; + uint256 _deadline = 1000; + + uint256 _nonce = base.nonces(_owner); + + domainSeparator = keccak256( + abi.encode(typehashEip712, permitNameHash, permitVersionHash, block.chainid, address(base)) + ); + bytes32 structHash = keccak256(abi.encode(permitTypeHash, _owner, _spender, _value, _nonce, _deadline)); + bytes32 typedDataHash = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(recipientPrivateKey, typedDataHash); + + // call permit to approve _value to _spender + base.permit(_owner, _spender, _value, _deadline, v, r, s); + + // check allowance + uint256 _allowance = base.allowance(_owner, _spender); + + assertEq(_allowance, _value); + assertEq(base.nonces(_owner), _nonce + 1); + } + + function test_revert_permit_IncorrectKey() public { + uint256 wrongPrivateKey = 2345; + + // generate permit signature + address _owner = recipient; + address _spender = address(0x789); + uint256 _value = 1 ether; + uint256 _deadline = 1000; + + uint256 _nonce = base.nonces(_owner); + + domainSeparator = keccak256( + abi.encode(typehashEip712, permitNameHash, permitVersionHash, block.chainid, address(base)) + ); + bytes32 structHash = keccak256(abi.encode(permitTypeHash, _owner, _spender, _value, _nonce, _deadline)); + bytes32 typedDataHash = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(wrongPrivateKey, typedDataHash); // sign with wrong key + + // call permit to approve _value to _spender + vm.expectRevert("ERC20Permit: invalid signature"); + base.permit(_owner, _spender, _value, _deadline, v, r, s); + } + + function test_revert_permit_UsedNonce() public { + // generate permit signature + address _owner = recipient; + address _spender = address(0x789); + uint256 _value = 1 ether; + uint256 _deadline = 1000; + + uint256 _nonce = base.nonces(_owner); + + domainSeparator = keccak256( + abi.encode(typehashEip712, permitNameHash, permitVersionHash, block.chainid, address(base)) + ); + bytes32 structHash = keccak256(abi.encode(permitTypeHash, _owner, _spender, _value, _nonce, _deadline)); + bytes32 typedDataHash = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(recipientPrivateKey, typedDataHash); + + // call permit to approve _value to _spender + base.permit(_owner, _spender, _value, _deadline, v, r, s); + + // sign again with same nonce + (v, r, s) = vm.sign(recipientPrivateKey, typedDataHash); + + vm.expectRevert("ERC20Permit: invalid signature"); + base.permit(_owner, _spender, _value, _deadline, v, r, s); + } + + function test_revert_permit_ExpiredDeadline() public { + // generate permit signature + address _owner = recipient; + address _spender = address(0x789); + uint256 _value = 1 ether; + uint256 _deadline = 1000; + + uint256 _nonce = base.nonces(_owner); + + domainSeparator = keccak256( + abi.encode(typehashEip712, permitNameHash, permitVersionHash, block.chainid, address(base)) + ); + bytes32 structHash = keccak256(abi.encode(permitTypeHash, _owner, _spender, _value, _nonce, _deadline)); + bytes32 typedDataHash = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(recipientPrivateKey, typedDataHash); + + // call permit to approve _value to _spender + vm.warp(_deadline + 1); + vm.expectRevert("ERC20Permit: expired deadline"); + base.permit(_owner, _spender, _value, _deadline, v, r, s); + } +} diff --git a/src/test/sdk/base/ERC20DropVote.t.sol b/src/test/sdk/base/ERC20DropVote.t.sol new file mode 100644 index 000000000..fd32af1de --- /dev/null +++ b/src/test/sdk/base/ERC20DropVote.t.sol @@ -0,0 +1,377 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import "./BaseUtilTest.sol"; +import { ERC20DropVote } from "contracts/base/ERC20DropVote.sol"; + +contract BaseERC20DropVoteTest is BaseUtilTest { + ERC20DropVote internal base; + using Strings for uint256; + + bytes32 internal typehashEip712; + bytes32 internal domainSeparator; + + // permit and vote + bytes32 internal permitTypeHash; + bytes32 internal delegationTypeHash; + bytes32 internal permitNameHash; + bytes32 internal permitVersionHash; + + uint256 public recipientPrivateKey = 5678; + address public recipient; + + function setUp() public override { + super.setUp(); + vm.prank(signer); + base = new ERC20DropVote(signer, NAME, SYMBOL, saleRecipient); + + recipient = vm.addr(recipientPrivateKey); + erc20.mint(recipient, 1_000_000 ether); + vm.deal(recipient, 1_000_000 ether); + + typehashEip712 = keccak256( + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" + ); + + // permit related inputs + permitTypeHash = keccak256( + "Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)" + ); + permitNameHash = keccak256(bytes(NAME)); + permitVersionHash = keccak256("1"); + + // vote-delegation related inputs + delegationTypeHash = keccak256("Delegation(address delegatee,uint256 nonce,uint256 expiry)"); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `claim` + //////////////////////////////////////////////////////////////*/ + + function test_state_claim_ZeroPrice() public { + vm.warp(1); + + address claimer = address(0x345); + uint256 _quantity = 10; + + uint256 currentTotalSupply = base.totalSupply(); + uint256 currentBalanceOfRecipient = base.balanceOf(recipient); + + bytes32[] memory proofs = new bytes32[](0); + + ERC20DropVote.AllowlistProof memory alp; + alp.proof = proofs; + + ERC20DropVote.ClaimCondition[] memory conditions = new ERC20DropVote.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 100; + conditions[0].quantityLimitPerWallet = 100; + + vm.prank(signer); + base.setClaimConditions(conditions[0], false); + + vm.prank(claimer, claimer); + base.claim(recipient, _quantity, address(0), 0, alp, ""); + + assertEq(base.totalSupply(), currentTotalSupply + _quantity); + assertEq(base.balanceOf(recipient), currentBalanceOfRecipient + _quantity); + } + + function test_state_claim_NonZeroPrice_ERC20() public { + vm.warp(1); + + address claimer = address(0x345); + uint256 _quantity = 10 ether; + + uint256 currentTotalSupply = base.totalSupply(); + uint256 currentBalanceOfRecipient = base.balanceOf(recipient); + + bytes32[] memory proofs = new bytes32[](0); + + ERC20DropVote.AllowlistProof memory alp; + alp.proof = proofs; + + ERC20DropVote.ClaimCondition[] memory conditions = new ERC20DropVote.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 100 ether; + conditions[0].quantityLimitPerWallet = 100 ether; + + // set price and currency + conditions[0].pricePerToken = 1 ether; + conditions[0].currency = address(erc20); + + // uint256 totalPrice = (conditions[0].pricePerToken * _quantity) / 1 ether; + + vm.prank(signer); + base.setClaimConditions(conditions[0], false); + + // mint erc20 to claimer, and approve to base + erc20.mint(claimer, 1000 ether); + vm.prank(claimer); + erc20.approve(address(base), 1_000 ether); + + vm.prank(claimer, claimer); + base.claim(recipient, _quantity, address(erc20), 1 ether, alp, ""); + + assertEq(base.totalSupply(), currentTotalSupply + _quantity); + assertEq(base.balanceOf(recipient), currentBalanceOfRecipient + _quantity); + } + + function test_state_claim_NonZeroPrice_NativeToken() public { + vm.warp(1); + + address claimer = address(0x345); + uint256 _quantity = 10 ether; + + uint256 currentTotalSupply = base.totalSupply(); + uint256 currentBalanceOfRecipient = base.balanceOf(recipient); + + bytes32[] memory proofs = new bytes32[](0); + + ERC20DropVote.AllowlistProof memory alp; + alp.proof = proofs; + + ERC20DropVote.ClaimCondition[] memory conditions = new ERC20DropVote.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 100 ether; + conditions[0].quantityLimitPerWallet = 100 ether; + + // set price and currency + conditions[0].pricePerToken = 1 ether; + conditions[0].currency = address(NATIVE_TOKEN); + + uint256 totalPrice = (conditions[0].pricePerToken * _quantity) / 1 ether; + + vm.prank(signer); + base.setClaimConditions(conditions[0], false); + + // deal NATIVE_TOKEN to claimer + vm.deal(claimer, 1_000 ether); + + vm.prank(claimer, claimer); + base.claim{ value: totalPrice }(recipient, _quantity, address(NATIVE_TOKEN), 1 ether, alp, ""); + + assertEq(base.totalSupply(), currentTotalSupply + _quantity); + assertEq(base.balanceOf(recipient), currentBalanceOfRecipient + _quantity); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `burn` + //////////////////////////////////////////////////////////////*/ + + function test_state_burn() public { + address claimer = address(0x345); + uint256 _quantity = 10 ether; + + uint256 currentTotalSupply = base.totalSupply(); + uint256 currentBalanceOfRecipient = base.balanceOf(recipient); + + bytes32[] memory proofs = new bytes32[](0); + + ERC20DropVote.AllowlistProof memory alp; + alp.proof = proofs; + + ERC20DropVote.ClaimCondition[] memory conditions = new ERC20DropVote.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 100 ether; + conditions[0].quantityLimitPerWallet = 100 ether; + + vm.prank(signer); + base.setClaimConditions(conditions[0], false); + + vm.prank(claimer, claimer); + base.claim(recipient, _quantity, address(0), 0, alp, ""); + + // burn minted tokens + currentTotalSupply = base.totalSupply(); + currentBalanceOfRecipient = base.balanceOf(recipient); + vm.prank(recipient); + base.burn(_quantity); + + assertEq(base.totalSupply(), currentTotalSupply - _quantity); + assertEq(base.balanceOf(recipient), currentBalanceOfRecipient - _quantity); + } + + function test_revert_burn_NotEnoughBalance() public { + vm.expectRevert("not enough balance"); + vm.prank(recipient); + base.burn(1); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `permit` + //////////////////////////////////////////////////////////////*/ + + function test_state_permit() public { + // generate permit signature + address _owner = recipient; + address _spender = address(0x789); + uint256 _value = 1 ether; + uint256 _deadline = 1000; + + uint256 _nonce = base.nonces(_owner); + + domainSeparator = keccak256( + abi.encode(typehashEip712, permitNameHash, permitVersionHash, block.chainid, address(base)) + ); + bytes32 structHash = keccak256(abi.encode(permitTypeHash, _owner, _spender, _value, _nonce, _deadline)); + bytes32 typedDataHash = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(recipientPrivateKey, typedDataHash); + + // call permit to approve _value to _spender + base.permit(_owner, _spender, _value, _deadline, v, r, s); + + // check allowance + uint256 _allowance = base.allowance(_owner, _spender); + + assertEq(_allowance, _value); + assertEq(base.nonces(_owner), _nonce + 1); + } + + function test_revert_permit_IncorrectKey() public { + uint256 wrongPrivateKey = 2345; + + // generate permit signature + address _owner = recipient; + address _spender = address(0x789); + uint256 _value = 1 ether; + uint256 _deadline = 1000; + + uint256 _nonce = base.nonces(_owner); + + domainSeparator = keccak256( + abi.encode(typehashEip712, permitNameHash, permitVersionHash, block.chainid, address(base)) + ); + bytes32 structHash = keccak256(abi.encode(permitTypeHash, _owner, _spender, _value, _nonce, _deadline)); + bytes32 typedDataHash = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(wrongPrivateKey, typedDataHash); // sign with wrong key + + // call permit to approve _value to _spender + vm.expectRevert("ERC20Permit: invalid signature"); + base.permit(_owner, _spender, _value, _deadline, v, r, s); + } + + function test_revert_permit_UsedNonce() public { + // generate permit signature + address _owner = recipient; + address _spender = address(0x789); + uint256 _value = 1 ether; + uint256 _deadline = 1000; + + uint256 _nonce = base.nonces(_owner); + + domainSeparator = keccak256( + abi.encode(typehashEip712, permitNameHash, permitVersionHash, block.chainid, address(base)) + ); + bytes32 structHash = keccak256(abi.encode(permitTypeHash, _owner, _spender, _value, _nonce, _deadline)); + bytes32 typedDataHash = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(recipientPrivateKey, typedDataHash); + + // call permit to approve _value to _spender + base.permit(_owner, _spender, _value, _deadline, v, r, s); + + // sign again with same nonce + (v, r, s) = vm.sign(recipientPrivateKey, typedDataHash); + + vm.expectRevert("ERC20Permit: invalid signature"); + base.permit(_owner, _spender, _value, _deadline, v, r, s); + } + + function test_revert_permit_ExpiredDeadline() public { + // generate permit signature + address _owner = recipient; + address _spender = address(0x789); + uint256 _value = 1 ether; + uint256 _deadline = 1000; + + uint256 _nonce = base.nonces(_owner); + + domainSeparator = keccak256( + abi.encode(typehashEip712, permitNameHash, permitVersionHash, block.chainid, address(base)) + ); + bytes32 structHash = keccak256(abi.encode(permitTypeHash, _owner, _spender, _value, _nonce, _deadline)); + bytes32 typedDataHash = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(recipientPrivateKey, typedDataHash); + + // call permit to approve _value to _spender + vm.warp(_deadline + 1); + vm.expectRevert("ERC20Permit: expired deadline"); + base.permit(_owner, _spender, _value, _deadline, v, r, s); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `delegateBySig` + //////////////////////////////////////////////////////////////*/ + + function test_state_delegateBySig() public { + // generate delegation signature + address _delegatee = address(0x789); + uint256 _nonce = base.nonces(recipient); + uint256 _expiry = 1000; + + // vote extends permit, so name-hash and version-hash are same for both + domainSeparator = keccak256( + abi.encode(typehashEip712, permitNameHash, permitVersionHash, block.chainid, address(base)) + ); + bytes32 structHash = keccak256(abi.encode(delegationTypeHash, _delegatee, _nonce, _expiry)); + bytes32 typedDataHash = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(recipientPrivateKey, typedDataHash); + + // call delegationBySig to delegate votes from recipient address to delegatee address + base.delegateBySig(_delegatee, _nonce, _expiry, v, r, s); + + assertEq(base.delegates(recipient), _delegatee); + } + + function test_revert_delegateBySig_InvalidNonce() public { + // generate delegation signature + address _delegatee = address(0x789); + uint256 _nonce = base.nonces(recipient); + uint256 _expiry = 1000; + + // vote extends permit, so name-hash and version-hash are same for both + domainSeparator = keccak256( + abi.encode(typehashEip712, permitNameHash, permitVersionHash, block.chainid, address(base)) + ); + bytes32 structHash = keccak256( + abi.encode( + delegationTypeHash, + _delegatee, + _nonce + 1, // invalid nonce + _expiry + ) + ); + bytes32 typedDataHash = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(recipientPrivateKey, typedDataHash); + + // call delegationBySig to delegate votes from recipient address to delegatee address + vm.expectRevert("ERC20Votes: invalid nonce"); + base.delegateBySig(_delegatee, _nonce + 1, _expiry, v, r, s); + } + + function test_revert_delegateBySig_SignatureExpired() public { + // generate delegation signature + address _delegatee = address(0x789); + uint256 _nonce = base.nonces(recipient); + uint256 _expiry = 1000; + + // vote extends permit, so name-hash and version-hash are same for both + domainSeparator = keccak256( + abi.encode(typehashEip712, permitNameHash, permitVersionHash, block.chainid, address(base)) + ); + bytes32 structHash = keccak256(abi.encode(delegationTypeHash, _delegatee, _nonce, _expiry)); + bytes32 typedDataHash = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(recipientPrivateKey, typedDataHash); + + // call delegationBySig to delegate votes from recipient address to delegatee address + vm.warp(_expiry + 1); + vm.expectRevert("ERC20Votes: signature expired"); + base.delegateBySig(_delegatee, _nonce, _expiry, v, r, s); + } +} diff --git a/src/test/sdk/base/ERC20SignatureMint.t.sol b/src/test/sdk/base/ERC20SignatureMint.t.sol new file mode 100644 index 000000000..9c4560868 --- /dev/null +++ b/src/test/sdk/base/ERC20SignatureMint.t.sol @@ -0,0 +1,172 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import "./BaseUtilTest.sol"; +import { ERC20SignatureMint } from "contracts/base/ERC20SignatureMint.sol"; + +contract BaseERC20SignatureMintTest is BaseUtilTest { + ERC20SignatureMint internal base; + using Strings for uint256; + + bytes32 internal typehashMintRequest; + bytes32 internal nameHash; + bytes32 internal versionHash; + bytes32 internal typehashEip712; + bytes32 internal domainSeparator; + + ERC20SignatureMint.MintRequest _mintrequest; + bytes _signature; + + address recipient; + + function setUp() public override { + super.setUp(); + vm.prank(signer); + base = new ERC20SignatureMint(signer, NAME, SYMBOL, saleRecipient); + + recipient = address(0x123); + erc20.mint(recipient, 1_000_000 ether); + vm.deal(recipient, 1_000_000 ether); + + typehashMintRequest = keccak256( + "MintRequest(address to,address primarySaleRecipient,uint256 quantity,uint256 price,address currency,uint128 validityStartTimestamp,uint128 validityEndTimestamp,bytes32 uid)" + ); + nameHash = keccak256(bytes("SignatureMintERC20")); + versionHash = keccak256(bytes("1")); + typehashEip712 = keccak256( + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" + ); + domainSeparator = keccak256(abi.encode(typehashEip712, nameHash, versionHash, block.chainid, address(base))); + + _mintrequest.to = recipient; + _mintrequest.primarySaleRecipient = saleRecipient; + _mintrequest.quantity = 100 ether; + _mintrequest.price = 0; + _mintrequest.currency = address(0); + _mintrequest.validityStartTimestamp = 1000; + _mintrequest.validityEndTimestamp = 2000; + _mintrequest.uid = bytes32(0); + + _signature = signMintRequest(_mintrequest, privateKey); + } + + function signMintRequest( + ERC20SignatureMint.MintRequest memory _request, + uint256 _privateKey + ) internal view returns (bytes memory) { + bytes memory encodedRequest = abi.encode( + typehashMintRequest, + _request.to, + _request.primarySaleRecipient, + _request.quantity, + _request.price, + _request.currency, + _request.validityStartTimestamp, + _request.validityEndTimestamp, + _request.uid + ); + bytes32 structHash = keccak256(encodedRequest); + bytes32 typedDataHash = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(_privateKey, typedDataHash); + bytes memory sig = abi.encodePacked(r, s, v); + + return sig; + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `mintWithSignature` + //////////////////////////////////////////////////////////////*/ + + function test_state_mintWithSignature_ZeroPrice() public { + vm.warp(1000); + + uint256 currentTotalSupply = base.totalSupply(); + uint256 currentBalanceOfRecipient = base.balanceOf(recipient); + + address recoveredSigner = base.mintWithSignature(_mintrequest, _signature); + + assertEq(base.totalSupply(), currentTotalSupply + _mintrequest.quantity); + assertEq(base.balanceOf(recipient), currentBalanceOfRecipient + _mintrequest.quantity); + assertEq(signer, recoveredSigner); + } + + function test_state_mintWithSignature_NonZeroPrice_ERC20() public { + vm.warp(1000); + + _mintrequest.price = 1; + _mintrequest.currency = address(erc20); + _signature = signMintRequest(_mintrequest, privateKey); + + vm.prank(recipient); + erc20.approve(address(base), _mintrequest.price); + + uint256 currentTotalSupply = base.totalSupply(); + uint256 currentBalanceOfRecipient = base.balanceOf(recipient); + uint256 erc20BalanceOfRecipient = erc20.balanceOf(recipient); + uint256 erc20BalanceOfSeller = erc20.balanceOf(saleRecipient); + + uint256 totalPrice = _mintrequest.price; + + vm.prank(recipient); + base.mintWithSignature(_mintrequest, _signature); + + // check token balances + assertEq(base.totalSupply(), currentTotalSupply + _mintrequest.quantity); + assertEq(base.balanceOf(recipient), currentBalanceOfRecipient + _mintrequest.quantity); + + // check erc20 currency balances + assertEq(erc20.balanceOf(recipient), erc20BalanceOfRecipient - totalPrice); + assertEq(erc20.balanceOf(saleRecipient), erc20BalanceOfSeller + totalPrice); + } + + function test_state_mintWithSignature_NonZeroPrice_NativeToken() public { + vm.warp(1000); + + _mintrequest.price = 1 ether; + _mintrequest.currency = address(NATIVE_TOKEN); + _signature = signMintRequest(_mintrequest, privateKey); + + uint256 currentTotalSupply = base.totalSupply(); + uint256 currentBalanceOfRecipient = base.balanceOf(recipient); + uint256 etherBalanceOfRecipient = recipient.balance; + uint256 etherBalanceOfSeller = saleRecipient.balance; + + uint256 totalPrice = _mintrequest.price; + + vm.prank(recipient); + base.mintWithSignature{ value: totalPrice }(_mintrequest, _signature); + + assertEq(base.totalSupply(), currentTotalSupply + _mintrequest.quantity); + assertEq(base.balanceOf(recipient), currentBalanceOfRecipient + _mintrequest.quantity); + + // check native-token balances + assertEq(recipient.balance, etherBalanceOfRecipient - totalPrice); + assertEq(saleRecipient.balance, etherBalanceOfSeller + totalPrice); + } + + function test_revert_mintWithSignature_MustSendTotalPrice() public { + vm.warp(1000); + + _mintrequest.price = 1; + _mintrequest.currency = address(NATIVE_TOKEN); + _signature = signMintRequest(_mintrequest, privateKey); + + vm.prank(recipient); + vm.expectRevert("Must send total price."); + base.mintWithSignature{ value: 0 }(_mintrequest, _signature); + } + + function test_revert_mintWithSignature_MintingZeroTokens() public { + vm.warp(1000); + + _mintrequest.quantity = 0; + _signature = signMintRequest(_mintrequest, privateKey); + + vm.expectRevert("Minting zero tokens."); + base.mintWithSignature(_mintrequest, _signature); + } +} diff --git a/src/test/sdk/base/ERC20SignatureMintVote.t.sol b/src/test/sdk/base/ERC20SignatureMintVote.t.sol new file mode 100644 index 000000000..f705aba8e --- /dev/null +++ b/src/test/sdk/base/ERC20SignatureMintVote.t.sol @@ -0,0 +1,172 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import "./BaseUtilTest.sol"; +import { ERC20SignatureMintVote } from "contracts/base/ERC20SignatureMintVote.sol"; + +contract BaseERC20SignatureMintVoteTest is BaseUtilTest { + ERC20SignatureMintVote internal base; + using Strings for uint256; + + bytes32 internal typehashMintRequest; + bytes32 internal nameHash; + bytes32 internal versionHash; + bytes32 internal typehashEip712; + bytes32 internal domainSeparator; + + ERC20SignatureMintVote.MintRequest _mintrequest; + bytes _signature; + + address recipient; + + function setUp() public override { + super.setUp(); + vm.prank(signer); + base = new ERC20SignatureMintVote(signer, NAME, SYMBOL, saleRecipient); + + recipient = address(0x123); + erc20.mint(recipient, 1_000 ether); + vm.deal(recipient, 1_000 ether); + + typehashMintRequest = keccak256( + "MintRequest(address to,address primarySaleRecipient,uint256 quantity,uint256 price,address currency,uint128 validityStartTimestamp,uint128 validityEndTimestamp,bytes32 uid)" + ); + nameHash = keccak256(bytes("SignatureMintERC20")); + versionHash = keccak256(bytes("1")); + typehashEip712 = keccak256( + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" + ); + domainSeparator = keccak256(abi.encode(typehashEip712, nameHash, versionHash, block.chainid, address(base))); + + _mintrequest.to = recipient; + _mintrequest.primarySaleRecipient = saleRecipient; + _mintrequest.quantity = 100 ether; + _mintrequest.price = 0; + _mintrequest.currency = address(0); + _mintrequest.validityStartTimestamp = 1000; + _mintrequest.validityEndTimestamp = 2000; + _mintrequest.uid = bytes32(0); + + _signature = signMintRequest(_mintrequest, privateKey); + } + + function signMintRequest( + ERC20SignatureMintVote.MintRequest memory _request, + uint256 _privateKey + ) internal view returns (bytes memory) { + bytes memory encodedRequest = abi.encode( + typehashMintRequest, + _request.to, + _request.primarySaleRecipient, + _request.quantity, + _request.price, + _request.currency, + _request.validityStartTimestamp, + _request.validityEndTimestamp, + _request.uid + ); + bytes32 structHash = keccak256(encodedRequest); + bytes32 typedDataHash = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(_privateKey, typedDataHash); + bytes memory sig = abi.encodePacked(r, s, v); + + return sig; + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `mintWithSignature` + //////////////////////////////////////////////////////////////*/ + + function test_state_mintWithSignature_ZeroPrice() public { + vm.warp(1000); + + uint256 currentTotalSupply = base.totalSupply(); + uint256 currentBalanceOfRecipient = base.balanceOf(recipient); + + address recoveredSigner = base.mintWithSignature(_mintrequest, _signature); + + assertEq(base.totalSupply(), currentTotalSupply + _mintrequest.quantity); + assertEq(base.balanceOf(recipient), currentBalanceOfRecipient + _mintrequest.quantity); + assertEq(signer, recoveredSigner); + } + + function test_state_mintWithSignature_NonZeroPrice_ERC20() public { + vm.warp(1000); + + _mintrequest.price = 1; + _mintrequest.currency = address(erc20); + _signature = signMintRequest(_mintrequest, privateKey); + + vm.prank(recipient); + erc20.approve(address(base), _mintrequest.price); + + uint256 currentTotalSupply = base.totalSupply(); + uint256 currentBalanceOfRecipient = base.balanceOf(recipient); + uint256 erc20BalanceOfRecipient = erc20.balanceOf(recipient); + uint256 erc20BalanceOfSeller = erc20.balanceOf(saleRecipient); + + uint256 totalPrice = _mintrequest.price; + + vm.prank(recipient); + base.mintWithSignature(_mintrequest, _signature); + + // check token balances + assertEq(base.totalSupply(), currentTotalSupply + _mintrequest.quantity); + assertEq(base.balanceOf(recipient), currentBalanceOfRecipient + _mintrequest.quantity); + + // check erc20 currency balances + assertEq(erc20.balanceOf(recipient), erc20BalanceOfRecipient - totalPrice); + assertEq(erc20.balanceOf(saleRecipient), erc20BalanceOfSeller + totalPrice); + } + + function test_state_mintWithSignature_NonZeroPrice_NativeToken() public { + vm.warp(1000); + + _mintrequest.price = 1; + _mintrequest.currency = address(NATIVE_TOKEN); + _signature = signMintRequest(_mintrequest, privateKey); + + uint256 currentTotalSupply = base.totalSupply(); + uint256 currentBalanceOfRecipient = base.balanceOf(recipient); + uint256 etherBalanceOfRecipient = recipient.balance; + uint256 etherBalanceOfSeller = saleRecipient.balance; + + uint256 totalPrice = _mintrequest.price; + + vm.prank(recipient); + base.mintWithSignature{ value: totalPrice }(_mintrequest, _signature); + + assertEq(base.totalSupply(), currentTotalSupply + _mintrequest.quantity); + assertEq(base.balanceOf(recipient), currentBalanceOfRecipient + _mintrequest.quantity); + + // check native-token balances + assertEq(recipient.balance, etherBalanceOfRecipient - totalPrice); + assertEq(saleRecipient.balance, etherBalanceOfSeller + totalPrice); + } + + function test_revert_mintWithSignature_MustSendTotalPrice() public { + vm.warp(1000); + + _mintrequest.price = 1; + _mintrequest.currency = address(NATIVE_TOKEN); + _signature = signMintRequest(_mintrequest, privateKey); + + vm.prank(recipient); + vm.expectRevert("Must send total price."); + base.mintWithSignature{ value: 0 }(_mintrequest, _signature); + } + + function test_revert_mintWithSignature_MintingZeroTokens() public { + vm.warp(1000); + + _mintrequest.quantity = 0; + _signature = signMintRequest(_mintrequest, privateKey); + + vm.expectRevert("Minting zero tokens."); + base.mintWithSignature(_mintrequest, _signature); + } +} diff --git a/src/test/sdk/base/ERC20Vote.t.sol b/src/test/sdk/base/ERC20Vote.t.sol new file mode 100644 index 000000000..ca57c1c76 --- /dev/null +++ b/src/test/sdk/base/ERC20Vote.t.sol @@ -0,0 +1,325 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import "./BaseUtilTest.sol"; +import { ERC20Vote } from "contracts/base/ERC20Vote.sol"; + +contract BaseERC20VoteTest is BaseUtilTest { + ERC20Vote internal base; + using Strings for uint256; + + bytes32 internal permitTypeHash; + bytes32 internal delegationTypeHash; + bytes32 internal permitNameHash; + bytes32 internal permitVersionHash; + bytes32 internal typehashEip712; + bytes32 internal domainSeparator; + + uint256 public recipientPrivateKey = 5678; + address public recipient; + + function setUp() public override { + super.setUp(); + vm.prank(deployer); + base = new ERC20Vote(deployer, NAME, SYMBOL); + + recipient = vm.addr(recipientPrivateKey); + + typehashEip712 = keccak256( + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" + ); + + // permit related inputs + permitTypeHash = keccak256( + "Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)" + ); + permitNameHash = keccak256(bytes(NAME)); + permitVersionHash = keccak256("1"); + + // vote-delegation related inputs + delegationTypeHash = keccak256("Delegation(address delegatee,uint256 nonce,uint256 expiry)"); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `mint` + //////////////////////////////////////////////////////////////*/ + + function test_state_mint() public { + uint256 amount = 5 ether; + + uint256 currentTotalSupply = base.totalSupply(); + uint256 currentBalanceOfRecipient = base.balanceOf(recipient); + + vm.prank(deployer); + base.mintTo(recipient, amount); + + assertEq(base.totalSupply(), currentTotalSupply + amount); + assertEq(base.balanceOf(recipient), currentBalanceOfRecipient + amount); + } + + function test_revert_mint_NotAuthorized() public { + uint256 amount = 5 ether; + + vm.expectRevert("Not authorized to mint."); + vm.prank(address(0x1)); + base.mintTo(recipient, amount); + } + + function test_revert_mint_MintingZeroTokens() public { + uint256 amount = 0; + + vm.expectRevert("Minting zero tokens."); + vm.prank(deployer); + base.mintTo(recipient, amount); + } + + function test_revert_mint_MintToZeroAddress() public { + uint256 amount = 1; + + vm.expectRevert("ERC20: mint to the zero address"); + vm.prank(deployer); + base.mintTo(address(0), amount); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `burn` + //////////////////////////////////////////////////////////////*/ + + function test_state_burn() public { + uint256 amount = 5 ether; + + uint256 currentTotalSupply = base.totalSupply(); + uint256 currentBalanceOfRecipient = base.balanceOf(recipient); + + vm.prank(deployer); + base.mintTo(recipient, amount); + + assertEq(base.totalSupply(), currentTotalSupply + amount); + assertEq(base.balanceOf(recipient), currentBalanceOfRecipient + amount); + + // burn minted tokens + currentTotalSupply = base.totalSupply(); + currentBalanceOfRecipient = base.balanceOf(recipient); + vm.prank(recipient); + base.burn(amount); + + assertEq(base.totalSupply(), currentTotalSupply - amount); + assertEq(base.balanceOf(recipient), currentBalanceOfRecipient - amount); + } + + function test_revert_burn_NotEnoughBalance() public { + uint256 amount = 5 ether; + + vm.prank(deployer); + base.mintTo(recipient, amount); + + vm.expectRevert("not enough balance"); + vm.prank(recipient); + base.burn(amount + 1); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `permit` + //////////////////////////////////////////////////////////////*/ + + function test_state_permit() public { + uint256 amount = 5 ether; + + // mint amount to recipient + vm.prank(deployer); + base.mintTo(recipient, amount); + + // generate permit signature + address _owner = recipient; + address _spender = address(0x789); + uint256 _value = 1 ether; + uint256 _deadline = 1000; + + uint256 _nonce = base.nonces(_owner); + + domainSeparator = keccak256( + abi.encode(typehashEip712, permitNameHash, permitVersionHash, block.chainid, address(base)) + ); + bytes32 structHash = keccak256(abi.encode(permitTypeHash, _owner, _spender, _value, _nonce, _deadline)); + bytes32 typedDataHash = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(recipientPrivateKey, typedDataHash); + + // call permit to approve _value to _spender + base.permit(_owner, _spender, _value, _deadline, v, r, s); + + // check allowance + uint256 _allowance = base.allowance(_owner, _spender); + + assertEq(_allowance, _value); + assertEq(base.nonces(_owner), _nonce + 1); + } + + function test_revert_permit_IncorrectKey() public { + uint256 amount = 5 ether; + uint256 wrongPrivateKey = 2345; + + // mint amount to recipient + vm.prank(deployer); + base.mintTo(recipient, amount); + + // generate permit signature + address _owner = recipient; + address _spender = address(0x789); + uint256 _value = 1 ether; + uint256 _deadline = 1000; + + uint256 _nonce = base.nonces(_owner); + + domainSeparator = keccak256( + abi.encode(typehashEip712, permitNameHash, permitVersionHash, block.chainid, address(base)) + ); + bytes32 structHash = keccak256(abi.encode(permitTypeHash, _owner, _spender, _value, _nonce, _deadline)); + bytes32 typedDataHash = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(wrongPrivateKey, typedDataHash); // sign with wrong key + + // call permit to approve _value to _spender + vm.expectRevert("ERC20Permit: invalid signature"); + base.permit(_owner, _spender, _value, _deadline, v, r, s); + } + + function test_revert_permit_UsedNonce() public { + uint256 amount = 5 ether; + + // mint amount to recipient + vm.prank(deployer); + base.mintTo(recipient, amount); + + // generate permit signature + address _owner = recipient; + address _spender = address(0x789); + uint256 _value = 1 ether; + uint256 _deadline = 1000; + + uint256 _nonce = base.nonces(_owner); + + domainSeparator = keccak256( + abi.encode(typehashEip712, permitNameHash, permitVersionHash, block.chainid, address(base)) + ); + bytes32 structHash = keccak256(abi.encode(permitTypeHash, _owner, _spender, _value, _nonce, _deadline)); + bytes32 typedDataHash = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(recipientPrivateKey, typedDataHash); + + // call permit to approve _value to _spender + base.permit(_owner, _spender, _value, _deadline, v, r, s); + + // sign again with same nonce + (v, r, s) = vm.sign(recipientPrivateKey, typedDataHash); + + vm.expectRevert("ERC20Permit: invalid signature"); + base.permit(_owner, _spender, _value, _deadline, v, r, s); + } + + function test_revert_permit_ExpiredDeadline() public { + uint256 amount = 5 ether; + uint256 wrongPrivateKey = 2345; + + // mint amount to recipient + vm.prank(deployer); + base.mintTo(recipient, amount); + + // generate permit signature + address _owner = recipient; + address _spender = address(0x789); + uint256 _value = 1 ether; + uint256 _deadline = 1000; + + uint256 _nonce = base.nonces(_owner); + + domainSeparator = keccak256( + abi.encode(typehashEip712, permitNameHash, permitVersionHash, block.chainid, address(base)) + ); + bytes32 structHash = keccak256(abi.encode(permitTypeHash, _owner, _spender, _value, _nonce, _deadline)); + bytes32 typedDataHash = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(wrongPrivateKey, typedDataHash); // sign with wrong key + + // call permit to approve _value to _spender + vm.warp(_deadline + 1); + vm.expectRevert("ERC20Permit: expired deadline"); + base.permit(_owner, _spender, _value, _deadline, v, r, s); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `delegateBySig` + //////////////////////////////////////////////////////////////*/ + + function test_state_delegateBySig() public { + // generate delegation signature + address _delegatee = address(0x789); + uint256 _nonce = base.nonces(recipient); + uint256 _expiry = 1000; + + // vote extends permit, so name-hash and version-hash are same for both + domainSeparator = keccak256( + abi.encode(typehashEip712, permitNameHash, permitVersionHash, block.chainid, address(base)) + ); + bytes32 structHash = keccak256(abi.encode(delegationTypeHash, _delegatee, _nonce, _expiry)); + bytes32 typedDataHash = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(recipientPrivateKey, typedDataHash); + + // call delegationBySig to delegate votes from recipient address to delegatee address + base.delegateBySig(_delegatee, _nonce, _expiry, v, r, s); + + assertEq(base.delegates(recipient), _delegatee); + } + + function test_revert_delegateBySig_InvalidNonce() public { + // generate delegation signature + address _delegatee = address(0x789); + uint256 _nonce = base.nonces(recipient); + uint256 _expiry = 1000; + + // vote extends permit, so name-hash and version-hash are same for both + domainSeparator = keccak256( + abi.encode(typehashEip712, permitNameHash, permitVersionHash, block.chainid, address(base)) + ); + bytes32 structHash = keccak256( + abi.encode( + delegationTypeHash, + _delegatee, + _nonce + 1, // invalid nonce + _expiry + ) + ); + bytes32 typedDataHash = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(recipientPrivateKey, typedDataHash); + + // call delegationBySig to delegate votes from recipient address to delegatee address + vm.expectRevert("ERC20Votes: invalid nonce"); + base.delegateBySig(_delegatee, _nonce + 1, _expiry, v, r, s); + } + + function test_revert_delegateBySig_SignatureExpired() public { + // generate delegation signature + address _delegatee = address(0x789); + uint256 _nonce = base.nonces(recipient); + uint256 _expiry = 1000; + + // vote extends permit, so name-hash and version-hash are same for both + domainSeparator = keccak256( + abi.encode(typehashEip712, permitNameHash, permitVersionHash, block.chainid, address(base)) + ); + bytes32 structHash = keccak256(abi.encode(delegationTypeHash, _delegatee, _nonce, _expiry)); + bytes32 typedDataHash = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(recipientPrivateKey, typedDataHash); + + // call delegationBySig to delegate votes from recipient address to delegatee address + vm.warp(_expiry + 1); + vm.expectRevert("ERC20Votes: signature expired"); + base.delegateBySig(_delegatee, _nonce, _expiry, v, r, s); + } +} diff --git a/src/test/sdk/base/ERC721Base.t.sol b/src/test/sdk/base/ERC721Base.t.sol new file mode 100644 index 000000000..7eaf4b2d2 --- /dev/null +++ b/src/test/sdk/base/ERC721Base.t.sol @@ -0,0 +1,192 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import "./BaseUtilTest.sol"; +import { ERC721Base } from "contracts/base/ERC721Base.sol"; + +contract BaseERC721BaseTest is BaseUtilTest { + ERC721Base internal base; + using Strings for uint256; + + function setUp() public override { + vm.prank(deployer); + base = new ERC721Base(deployer, NAME, SYMBOL, royaltyRecipient, royaltyBps); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `mintTo` + //////////////////////////////////////////////////////////////*/ + + function test_state_mintTo() public { + address recipient = address(0x123); + string memory _tokenURI = "tokenURI"; + + uint256 nextTokenId = base.nextTokenIdToMint(); + uint256 currentTotalSupply = base.totalSupply(); + uint256 currentBalanceOfRecipient = base.balanceOf(recipient); + + vm.prank(deployer); + base.mintTo(recipient, _tokenURI); + + assertEq(base.nextTokenIdToMint(), nextTokenId + 1); + assertEq(base.tokenURI(nextTokenId), _tokenURI); + assertEq(base.totalSupply(), currentTotalSupply + 1); + assertEq(base.balanceOf(recipient), currentBalanceOfRecipient + 1); + assertEq(base.ownerOf(nextTokenId), recipient); + } + + function test_revert_mintTo_NotAuthorized() public { + address recipient = address(0x123); + string memory _tokenURI = "tokenURI"; + + vm.expectRevert("Not authorized to mint."); + vm.prank(address(0x1)); + base.mintTo(recipient, _tokenURI); + } + + function test_revert_mintTo_MintToZeroAddress() public { + string memory _tokenURI = "tokenURI"; + + vm.expectRevert(bytes4(abi.encodeWithSignature("MintToZeroAddress()"))); + vm.prank(deployer); + base.mintTo(address(0), _tokenURI); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `batchMintTo` + //////////////////////////////////////////////////////////////*/ + + function test_state_batchMintTo() public { + address recipient = address(0x123); + uint256 _quantity = 100; + string memory _baseURI = "baseURI/"; + + uint256 nextTokenId = base.nextTokenIdToMint(); + uint256 currentTotalSupply = base.totalSupply(); + uint256 currentBalanceOfRecipient = base.balanceOf(recipient); + + vm.prank(deployer); + base.batchMintTo(recipient, _quantity, _baseURI, ""); + + assertEq(base.nextTokenIdToMint(), nextTokenId + _quantity); + assertEq(base.totalSupply(), currentTotalSupply + _quantity); + assertEq(base.balanceOf(recipient), currentBalanceOfRecipient + _quantity); + for (uint256 i = nextTokenId; i < _quantity; i += 1) { + assertEq(base.tokenURI(i), string(abi.encodePacked(_baseURI, i.toString()))); + assertEq(base.ownerOf(i), recipient); + } + } + + function test_revert_batchMintTo_NotAuthorized() public { + address recipient = address(0x123); + uint256 _quantity = 100; + string memory _baseURI = "baseURI/"; + + vm.expectRevert("Not authorized to mint."); + vm.prank(address(0x1)); + base.batchMintTo(recipient, _quantity, _baseURI, ""); + } + + function test_revert_batchMintTo_MintToZeroAddress() public { + uint256 _quantity = 100; + string memory _baseURI = "baseURI/"; + + vm.expectRevert(bytes4(abi.encodeWithSignature("MintToZeroAddress()"))); + vm.prank(deployer); + base.batchMintTo(address(0), _quantity, _baseURI, ""); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `burn` + //////////////////////////////////////////////////////////////*/ + + function test_state_burn_Owner() public { + address recipient = address(0x123); + string memory _tokenURI = "tokenURI"; + + uint256 nextTokenId = base.nextTokenIdToMint(); + uint256 currentTotalSupply = base.totalSupply(); + uint256 currentBalanceOfRecipient = base.balanceOf(recipient); + + vm.prank(deployer); + base.mintTo(recipient, _tokenURI); + + vm.prank(recipient); + base.burn(nextTokenId); + assertEq(base.nextTokenIdToMint(), nextTokenId + 1); + assertEq(base.tokenURI(nextTokenId), _tokenURI); + assertEq(base.totalSupply(), currentTotalSupply); + assertEq(base.balanceOf(recipient), currentBalanceOfRecipient); + + vm.expectRevert(bytes4(abi.encodeWithSignature("OwnerQueryForNonexistentToken()"))); + assertEq(base.ownerOf(nextTokenId), address(0)); + } + + function test_state_burn_Approved() public { + address recipient = address(0x123); + string memory _tokenURI = "tokenURI"; + + address operator = address(0x789); + + uint256 nextTokenId = base.nextTokenIdToMint(); + uint256 currentTotalSupply = base.totalSupply(); + uint256 currentBalanceOfRecipient = base.balanceOf(recipient); + + vm.prank(deployer); + base.mintTo(recipient, _tokenURI); + + vm.prank(recipient); + base.setApprovalForAll(operator, true); + + vm.prank(operator); + base.burn(nextTokenId); + assertEq(base.nextTokenIdToMint(), nextTokenId + 1); + assertEq(base.tokenURI(nextTokenId), _tokenURI); + assertEq(base.totalSupply(), currentTotalSupply); + assertEq(base.balanceOf(recipient), currentBalanceOfRecipient); + + vm.expectRevert(bytes4(abi.encodeWithSignature("OwnerQueryForNonexistentToken()"))); + assertEq(base.ownerOf(nextTokenId), address(0)); + } + + function test_revert_burn_NotOwnerNorApproved() public { + address recipient = address(0x123); + string memory _tokenURI = "tokenURI"; + + uint256 nextTokenId = base.nextTokenIdToMint(); + + vm.prank(deployer); + base.mintTo(recipient, _tokenURI); + + vm.prank(address(0x789)); + vm.expectRevert(bytes4(abi.encodeWithSignature("TransferCallerNotOwnerNorApproved()"))); + base.burn(nextTokenId); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `isApprovedOrOwner` + //////////////////////////////////////////////////////////////*/ + + function test_isApprovedOrOwner() public { + address recipient = address(0x123); + string memory _tokenURI = "tokenURI"; + + address operator = address(0x789); + + uint256 nextTokenId = base.nextTokenIdToMint(); + + vm.prank(deployer); + base.mintTo(recipient, _tokenURI); + + assertFalse(base.isApprovedOrOwner(operator, nextTokenId)); + assertEq(base.isApprovedOrOwner(recipient, nextTokenId), true); + + vm.prank(recipient); + base.approve(operator, nextTokenId); + + assertEq(base.isApprovedOrOwner(operator, nextTokenId), true); + } +} diff --git a/src/test/sdk/base/ERC721DelayedReveal.t.sol b/src/test/sdk/base/ERC721DelayedReveal.t.sol new file mode 100644 index 000000000..dce4da3c2 --- /dev/null +++ b/src/test/sdk/base/ERC721DelayedReveal.t.sol @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import "./BaseUtilTest.sol"; +import { ERC721DelayedReveal, BatchMintMetadata } from "contracts/base/ERC721DelayedReveal.sol"; + +contract BaseERC721DelayedRevealTest is BaseUtilTest { + ERC721DelayedReveal internal base; + using Strings for uint256; + + function setUp() public override { + vm.prank(deployer); + base = new ERC721DelayedReveal(deployer, NAME, SYMBOL, royaltyRecipient, royaltyBps); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `lazyMint` + //////////////////////////////////////////////////////////////*/ + + function test_state_lazyMint_noEncryptedURI() public { + uint256 _amount = 100; + string memory _baseURIForTokens = "baseURI/"; + bytes memory _encryptedBaseURI = ""; + + uint256 nextTokenId = base.nextTokenIdToMint(); + + vm.startPrank(deployer); + uint256 batchId = base.lazyMint(_amount, _baseURIForTokens, _encryptedBaseURI); + + assertEq(nextTokenId + _amount, base.nextTokenIdToMint()); + assertEq(nextTokenId + _amount, batchId); + + for (uint256 i = 0; i < _amount; i += 1) { + string memory _tokenURI = base.tokenURI(i); + assertEq(_tokenURI, string(abi.encodePacked(_baseURIForTokens, i.toString()))); + } + + vm.stopPrank(); + } + + function test_state_lazyMint_withEncryptedURI() public { + uint256 _amount = 100; + string memory _baseURIForTokens = "baseURI/"; + string memory secretURI = "secretURI/"; + bytes memory key = "key"; + bytes memory _encryptedBaseURI = base.encryptDecrypt(bytes(secretURI), key); + bytes32 provenanceHash = keccak256(abi.encodePacked(secretURI, key, block.chainid)); + + uint256 nextTokenId = base.nextTokenIdToMint(); + + vm.startPrank(deployer); + uint256 batchId = base.lazyMint(_amount, _baseURIForTokens, abi.encode(_encryptedBaseURI, provenanceHash)); + + assertEq(nextTokenId + _amount, base.nextTokenIdToMint()); + assertEq(nextTokenId + _amount, batchId); + + for (uint256 i = 0; i < _amount; i += 1) { + string memory _tokenURI = base.tokenURI(i); + assertEq(_tokenURI, string(abi.encodePacked(_baseURIForTokens, "0"))); + } + + vm.stopPrank(); + } + + function test_revert_lazyMint_URIForNonExistentId() public { + uint256 _amount = 100; + string memory _baseURIForTokens = "baseURI/"; + + bytes memory key = "key"; + string memory secretURI = "secretURI/"; + bytes memory _encryptedBaseURI = base.encryptDecrypt(bytes(secretURI), key); + bytes32 provenanceHash = keccak256(abi.encodePacked(secretURI, key, block.chainid)); + + vm.startPrank(deployer); + base.lazyMint(_amount, _baseURIForTokens, abi.encode(_encryptedBaseURI, provenanceHash)); + + vm.expectRevert(abi.encodeWithSelector(BatchMintMetadata.BatchMintInvalidTokenId.selector, 100)); + base.tokenURI(100); + + vm.stopPrank(); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `reveal` + //////////////////////////////////////////////////////////////*/ + + function test_state_reveal() public { + uint256 _amount = 100; + string memory _tempURIForTokens = "tempURI/"; + string memory _baseURIForTokens = "baseURI/"; + bytes memory key = "key"; + bytes memory _encryptedBaseURI = base.encryptDecrypt(bytes(_baseURIForTokens), key); + bytes32 provenanceHash = keccak256(abi.encodePacked(_baseURIForTokens, key, block.chainid)); + + vm.startPrank(deployer); + base.lazyMint(_amount, _tempURIForTokens, abi.encode(_encryptedBaseURI, provenanceHash)); + + for (uint256 i = 0; i < _amount; i += 1) { + string memory _tokenURI = base.tokenURI(i); + assertEq(_tokenURI, string(abi.encodePacked(_tempURIForTokens, "0"))); + } + + base.reveal(0, "key"); + + for (uint256 i = 0; i < _amount; i += 1) { + string memory _tokenURI = base.tokenURI(i); + assertEq(_tokenURI, string(abi.encodePacked(_baseURIForTokens, i.toString()))); + } + + vm.stopPrank(); + } + + function test_revert_reveal_NotAuthorized() public { + uint256 _amount = 100; + string memory _tempURIForTokens = "tempURI/"; + string memory _baseURIForTokens = "baseURI/"; + bytes memory key = "key"; + bytes memory _encryptedBaseURI = base.encryptDecrypt(bytes(_baseURIForTokens), key); + bytes32 provenanceHash = keccak256(abi.encodePacked(_baseURIForTokens, key, block.chainid)); + + vm.prank(deployer); + base.lazyMint(_amount, _tempURIForTokens, abi.encode(_encryptedBaseURI, provenanceHash)); + + for (uint256 i = 0; i < _amount; i += 1) { + string memory _tokenURI = base.tokenURI(i); + assertEq(_tokenURI, string(abi.encodePacked(_tempURIForTokens, "0"))); + } + + vm.prank(address(0x345)); + vm.expectRevert("Not authorized"); + base.reveal(0, "key"); + } +} diff --git a/src/test/sdk/base/ERC721Drop.t.sol b/src/test/sdk/base/ERC721Drop.t.sol new file mode 100644 index 000000000..663485705 --- /dev/null +++ b/src/test/sdk/base/ERC721Drop.t.sol @@ -0,0 +1,189 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import "./BaseUtilTest.sol"; +import { ERC721Drop } from "contracts/base/ERC721Drop.sol"; + +contract BaseERC721DropTest is BaseUtilTest { + ERC721Drop internal base; + using Strings for uint256; + + address recipient; + + function setUp() public override { + super.setUp(); + + recipient = address(0x123); + + vm.prank(signer); + base = new ERC721Drop(signer, NAME, SYMBOL, royaltyRecipient, royaltyBps, saleRecipient); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `claim` + //////////////////////////////////////////////////////////////*/ + + function test_state_claim_ZeroPrice() public { + vm.warp(1); + + address receiver = address(0x123); + address claimer = address(0x345); + string memory _baseURI = "baseURI/"; + uint256 _quantity = 10; + + uint256 currentTotalSupply = base.totalSupply(); + uint256 currentBalanceOfRecipient = base.balanceOf(receiver); + + bytes32[] memory proofs = new bytes32[](0); + + ERC721Drop.AllowlistProof memory alp; + alp.proof = proofs; + + ERC721Drop.ClaimCondition[] memory conditions = new ERC721Drop.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 100; + conditions[0].quantityLimitPerWallet = 100; + + vm.prank(signer); + base.lazyMint(100, _baseURI, ""); + + vm.prank(signer); + base.setClaimConditions(conditions[0], false); + + vm.prank(claimer, claimer); + base.claim(receiver, _quantity, address(0), 0, alp, ""); + + assertEq(base.totalSupply(), currentTotalSupply + _quantity); + assertEq(base.balanceOf(receiver), currentBalanceOfRecipient + _quantity); + + for (uint256 i = 0; i < _quantity; i += 1) { + string memory _tokenURI = base.tokenURI(i); + assertEq(_tokenURI, string(abi.encodePacked(_baseURI, i.toString()))); + assertEq(base.ownerOf(i), receiver); + } + } + + function test_state_claim_NonZeroPrice_ERC20() public { + vm.warp(1); + + address receiver = address(0x123); + address claimer = address(0x345); + string memory _baseURI = "baseURI/"; + uint256 _quantity = 10; + + uint256 currentTotalSupply = base.totalSupply(); + uint256 currentBalanceOfRecipient = base.balanceOf(receiver); + + bytes32[] memory proofs = new bytes32[](0); + + ERC721Drop.AllowlistProof memory alp; + alp.proof = proofs; + + ERC721Drop.ClaimCondition[] memory conditions = new ERC721Drop.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 100; + conditions[0].quantityLimitPerWallet = 100; + + // set price and currency + conditions[0].pricePerToken = 1; + conditions[0].currency = address(erc20); + + vm.prank(signer); + base.lazyMint(100, _baseURI, ""); + + vm.prank(signer); + base.setClaimConditions(conditions[0], false); + + // mint erc20 to claimer, and approve to base + erc20.mint(claimer, 1_000); + vm.prank(claimer); + erc20.approve(address(base), 10); + + vm.prank(claimer, claimer); + base.claim(receiver, _quantity, address(erc20), 1, alp, ""); + + assertEq(base.totalSupply(), currentTotalSupply + _quantity); + assertEq(base.balanceOf(receiver), currentBalanceOfRecipient + _quantity); + + for (uint256 i = 0; i < _quantity; i += 1) { + string memory _tokenURI = base.tokenURI(i); + assertEq(_tokenURI, string(abi.encodePacked(_baseURI, i.toString()))); + assertEq(base.ownerOf(i), receiver); + } + } + + function test_state_claim_NonZeroPrice_NativeToken() public { + vm.warp(1); + + address receiver = address(0x123); + address claimer = address(0x345); + string memory _baseURI = "baseURI/"; + uint256 _quantity = 10; + + uint256 currentTotalSupply = base.totalSupply(); + uint256 currentBalanceOfRecipient = base.balanceOf(receiver); + + bytes32[] memory proofs = new bytes32[](0); + + ERC721Drop.AllowlistProof memory alp; + alp.proof = proofs; + + ERC721Drop.ClaimCondition[] memory conditions = new ERC721Drop.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 100; + conditions[0].quantityLimitPerWallet = 100; + + // set price and currency + conditions[0].pricePerToken = 1; + conditions[0].currency = address(NATIVE_TOKEN); + + vm.prank(signer); + base.lazyMint(100, _baseURI, ""); + + vm.prank(signer); + base.setClaimConditions(conditions[0], false); + + // deal NATIVE_TOKEN to claimer + vm.deal(claimer, 1_000); + + vm.prank(claimer, claimer); + base.claim{ value: 10 }(receiver, _quantity, address(NATIVE_TOKEN), 1, alp, ""); + + assertEq(base.totalSupply(), currentTotalSupply + _quantity); + assertEq(base.balanceOf(receiver), currentBalanceOfRecipient + _quantity); + + for (uint256 i = 0; i < _quantity; i += 1) { + string memory _tokenURI = base.tokenURI(i); + assertEq(_tokenURI, string(abi.encodePacked(_baseURI, i.toString()))); + assertEq(base.ownerOf(i), receiver); + } + } + + function test_revert_claim_NotEnoughMintedTokens() public { + vm.warp(1); + + address receiver = address(0x123); + address claimer = address(0x345); + string memory _baseURI = "baseURI/"; + uint256 _quantity = 10; + + bytes32[] memory proofs = new bytes32[](0); + + ERC721Drop.AllowlistProof memory alp; + alp.proof = proofs; + + ERC721Drop.ClaimCondition[] memory conditions = new ERC721Drop.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 100; + conditions[0].quantityLimitPerWallet = 100; + + vm.prank(signer); + base.lazyMint(100, _baseURI, ""); + + vm.prank(signer); + base.setClaimConditions(conditions[0], false); + + vm.expectRevert("Not enough minted tokens"); + vm.prank(claimer, claimer); + base.claim(receiver, _quantity + 1000, address(0), 0, alp, ""); + } +} diff --git a/src/test/sdk/base/ERC721LazyMint.t.sol b/src/test/sdk/base/ERC721LazyMint.t.sol new file mode 100644 index 000000000..241d82116 --- /dev/null +++ b/src/test/sdk/base/ERC721LazyMint.t.sol @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import "./BaseUtilTest.sol"; +import { ERC721LazyMint } from "contracts/base/ERC721LazyMint.sol"; + +contract BaseERC721LazyMintTest is BaseUtilTest { + ERC721LazyMint internal base; + using Strings for uint256; + + uint256 _amount; + string _baseURIForTokens; + bytes _encryptedBaseURI; + + function setUp() public override { + vm.prank(deployer); + base = new ERC721LazyMint(deployer, NAME, SYMBOL, royaltyRecipient, royaltyBps); + + _amount = 10; + _baseURIForTokens = "baseURI/"; + _encryptedBaseURI = ""; + + vm.prank(deployer); + base.lazyMint(_amount, _baseURIForTokens, _encryptedBaseURI); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `claim` + //////////////////////////////////////////////////////////////*/ + + function test_state_claim() public { + address recipient = address(0x123); + uint256 quantity = 5; + + uint256 currentTotalSupply = base.totalSupply(); + uint256 currentBalanceOfRecipient = base.balanceOf(recipient); + + vm.startPrank(recipient); + + base.claim(recipient, quantity); + + assertEq(base.totalSupply(), currentTotalSupply + quantity); + assertEq(base.balanceOf(recipient), currentBalanceOfRecipient + quantity); + + for (uint256 i = 0; i < quantity; i += 1) { + string memory _tokenURI = base.tokenURI(i); + assertEq(_tokenURI, string(abi.encodePacked(_baseURIForTokens, i.toString()))); + assertEq(base.ownerOf(i), recipient); + } + + vm.stopPrank(); + } + + function test_revert_claim_NotEnoughTokens() public { + address recipient = address(0x123); + + vm.startPrank(recipient); + + vm.expectRevert("Not enough lazy minted tokens."); + base.claim(recipient, _amount + 1); + + vm.stopPrank(); + } +} diff --git a/src/test/sdk/base/ERC721Multiwrap.t.sol b/src/test/sdk/base/ERC721Multiwrap.t.sol new file mode 100644 index 000000000..30e718d23 --- /dev/null +++ b/src/test/sdk/base/ERC721Multiwrap.t.sol @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import "./BaseUtilTest.sol"; +import { ERC721Multiwrap } from "contracts/base/ERC721Multiwrap.sol"; +import { CurrencyTransferLib } from "contracts/lib/CurrencyTransferLib.sol"; +import { ITokenBundle } from "contracts/extension/interface/ITokenBundle.sol"; + +contract BaseERC721MultiwrapTest is BaseUtilTest { + ERC721Multiwrap internal base; + using Strings for uint256; + + Wallet internal tokenOwner; + string internal uriForWrappedToken; + ITokenBundle.Token[] internal wrappedContent; + + function setUp() public override { + super.setUp(); + + vm.prank(deployer); + base = new ERC721Multiwrap( + deployer, + NAME, + SYMBOL, + royaltyRecipient, + royaltyBps, + CurrencyTransferLib.NATIVE_TOKEN + ); + + tokenOwner = getWallet(); + uriForWrappedToken = "ipfs://baseURI/"; + + wrappedContent.push( + ITokenBundle.Token({ + assetContract: address(erc20), + tokenType: ITokenBundle.TokenType.ERC20, + tokenId: 0, + totalAmount: 10 ether + }) + ); + wrappedContent.push( + ITokenBundle.Token({ + assetContract: address(erc721), + tokenType: ITokenBundle.TokenType.ERC721, + tokenId: 0, + totalAmount: 1 + }) + ); + wrappedContent.push( + ITokenBundle.Token({ + assetContract: address(erc1155), + tokenType: ITokenBundle.TokenType.ERC1155, + tokenId: 0, + totalAmount: 100 + }) + ); + + erc20.mint(address(tokenOwner), 10 ether); + erc721.mint(address(tokenOwner), 1); + erc1155.mint(address(tokenOwner), 0, 100); + + tokenOwner.setAllowanceERC20(address(erc20), address(base), type(uint256).max); + tokenOwner.setApprovalForAllERC721(address(erc721), address(base), true); + tokenOwner.setApprovalForAllERC1155(address(erc1155), address(base), true); + + vm.prank(deployer); + base.grantRole(keccak256("MINTER_ROLE"), address(tokenOwner)); + } + + function getWrappedContents(uint256 _tokenId) public view returns (ITokenBundle.Token[] memory contents) { + uint256 total = base.getTokenCountOfBundle(_tokenId); + contents = new ITokenBundle.Token[](total); + + for (uint256 i = 0; i < total; i += 1) { + contents[i] = base.getTokenOfBundle(_tokenId, i); + } + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `wrap` + //////////////////////////////////////////////////////////////*/ + + function test_state_wrap() public { + uint256 expectedIdForWrappedToken = base.nextTokenIdToMint(); + address recipient = address(0x123); + + vm.prank(address(tokenOwner)); + base.wrap(wrappedContent, uriForWrappedToken, recipient); + + assertEq(expectedIdForWrappedToken + 1, base.nextTokenIdToMint()); + + ITokenBundle.Token[] memory contentsOfWrappedToken = getWrappedContents(expectedIdForWrappedToken); + assertEq(contentsOfWrappedToken.length, wrappedContent.length); + for (uint256 i = 0; i < contentsOfWrappedToken.length; i += 1) { + assertEq(contentsOfWrappedToken[i].assetContract, wrappedContent[i].assetContract); + assertEq(uint256(contentsOfWrappedToken[i].tokenType), uint256(wrappedContent[i].tokenType)); + assertEq(contentsOfWrappedToken[i].tokenId, wrappedContent[i].tokenId); + assertEq(contentsOfWrappedToken[i].totalAmount, wrappedContent[i].totalAmount); + } + + assertEq(uriForWrappedToken, base.tokenURI(expectedIdForWrappedToken)); + } +} diff --git a/src/test/sdk/base/ERC721SignatureMint.t.sol b/src/test/sdk/base/ERC721SignatureMint.t.sol new file mode 100644 index 000000000..0dad51e65 --- /dev/null +++ b/src/test/sdk/base/ERC721SignatureMint.t.sol @@ -0,0 +1,174 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import "./BaseUtilTest.sol"; +import { ERC721SignatureMint } from "contracts/base/ERC721SignatureMint.sol"; + +contract BaseERC721SignatureMintTest is BaseUtilTest { + ERC721SignatureMint internal base; + using Strings for uint256; + + bytes32 internal typehashMintRequest; + bytes32 internal nameHash; + bytes32 internal versionHash; + bytes32 internal typehashEip712; + bytes32 internal domainSeparator; + + ERC721SignatureMint.MintRequest _mintrequest; + bytes _signature; + + address recipient; + + function setUp() public override { + super.setUp(); + vm.prank(signer); + base = new ERC721SignatureMint(signer, NAME, SYMBOL, royaltyRecipient, royaltyBps, saleRecipient); + + recipient = address(0x123); + erc20.mint(recipient, 1_000); + vm.deal(recipient, 1_000); + + typehashMintRequest = keccak256( + "MintRequest(address to,address royaltyRecipient,uint256 royaltyBps,address primarySaleRecipient,string uri,uint256 quantity,uint256 pricePerToken,address currency,uint128 validityStartTimestamp,uint128 validityEndTimestamp,bytes32 uid)" + ); + nameHash = keccak256(bytes("SignatureMintERC721")); + versionHash = keccak256(bytes("1")); + typehashEip712 = keccak256( + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" + ); + domainSeparator = keccak256(abi.encode(typehashEip712, nameHash, versionHash, block.chainid, address(base))); + + _mintrequest.to = recipient; + _mintrequest.royaltyRecipient = royaltyRecipient; + _mintrequest.royaltyBps = royaltyBps; + _mintrequest.primarySaleRecipient = saleRecipient; + _mintrequest.uri = "ipfs://"; + _mintrequest.quantity = 1; + _mintrequest.pricePerToken = 0; + _mintrequest.currency = address(0); + _mintrequest.validityStartTimestamp = 1000; + _mintrequest.validityEndTimestamp = 2000; + _mintrequest.uid = bytes32(0); + + _signature = signMintRequest(_mintrequest, privateKey); + } + + function signMintRequest( + ERC721SignatureMint.MintRequest memory _request, + uint256 _privateKey + ) internal view returns (bytes memory) { + bytes memory encodedRequest = abi.encode( + typehashMintRequest, + _request.to, + _request.royaltyRecipient, + _request.royaltyBps, + _request.primarySaleRecipient, + keccak256(bytes(_request.uri)), + _request.quantity, + _request.pricePerToken, + _request.currency, + _request.validityStartTimestamp, + _request.validityEndTimestamp, + _request.uid + ); + bytes32 structHash = keccak256(encodedRequest); + bytes32 typedDataHash = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(_privateKey, typedDataHash); + bytes memory sig = abi.encodePacked(r, s, v); + + return sig; + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `mintWithSignature` + //////////////////////////////////////////////////////////////*/ + + function test_state_mintWithSignature_ZeroPrice() public { + vm.warp(1000); + + uint256 nextTokenId = base.nextTokenIdToMint(); + uint256 currentTotalSupply = base.totalSupply(); + uint256 currentBalanceOfRecipient = base.balanceOf(recipient); + + base.mintWithSignature(_mintrequest, _signature); + + assertEq(base.nextTokenIdToMint(), nextTokenId + _mintrequest.quantity); + assertEq(base.tokenURI(nextTokenId), string(abi.encodePacked(_mintrequest.uri))); + assertEq(base.totalSupply(), currentTotalSupply + _mintrequest.quantity); + assertEq(base.balanceOf(recipient), currentBalanceOfRecipient + _mintrequest.quantity); + assertEq(base.ownerOf(nextTokenId), recipient); + } + + function test_state_mintWithSignature_NonZeroPrice_ERC20() public { + vm.warp(1000); + + _mintrequest.pricePerToken = 1; + _mintrequest.currency = address(erc20); + _signature = signMintRequest(_mintrequest, privateKey); + + vm.prank(recipient); + erc20.approve(address(base), 1); + + uint256 nextTokenId = base.nextTokenIdToMint(); + uint256 currentTotalSupply = base.totalSupply(); + uint256 currentBalanceOfRecipient = base.balanceOf(recipient); + + vm.prank(recipient); + base.mintWithSignature(_mintrequest, _signature); + + assertEq(base.nextTokenIdToMint(), nextTokenId + _mintrequest.quantity); + assertEq(base.tokenURI(nextTokenId), string(abi.encodePacked(_mintrequest.uri))); + assertEq(base.totalSupply(), currentTotalSupply + _mintrequest.quantity); + assertEq(base.balanceOf(recipient), currentBalanceOfRecipient + _mintrequest.quantity); + assertEq(base.ownerOf(nextTokenId), recipient); + } + + function test_state_mintWithSignature_NonZeroPrice_NativeToken() public { + vm.warp(1000); + + _mintrequest.pricePerToken = 1; + _mintrequest.currency = address(NATIVE_TOKEN); + _signature = signMintRequest(_mintrequest, privateKey); + + uint256 nextTokenId = base.nextTokenIdToMint(); + uint256 currentTotalSupply = base.totalSupply(); + uint256 currentBalanceOfRecipient = base.balanceOf(recipient); + + vm.deal(recipient, 1); + + vm.prank(recipient); + base.mintWithSignature{ value: 1 }(_mintrequest, _signature); + + assertEq(base.nextTokenIdToMint(), nextTokenId + _mintrequest.quantity); + assertEq(base.tokenURI(nextTokenId), string(abi.encodePacked(_mintrequest.uri))); + assertEq(base.totalSupply(), currentTotalSupply + _mintrequest.quantity); + assertEq(base.balanceOf(recipient), currentBalanceOfRecipient + _mintrequest.quantity); + assertEq(base.ownerOf(nextTokenId), recipient); + } + + function test_revert_mintWithSignature_MustSendTotalPrice() public { + vm.warp(1000); + + _mintrequest.pricePerToken = 1; + _mintrequest.currency = address(NATIVE_TOKEN); + _signature = signMintRequest(_mintrequest, privateKey); + + vm.prank(recipient); + vm.expectRevert("Invalid msg value"); + base.mintWithSignature{ value: 0 }(_mintrequest, _signature); + } + + function test_revert_mintWithSignature_QuantityNotOne() public { + vm.warp(1000); + + _mintrequest.quantity = 0; + _signature = signMintRequest(_mintrequest, privateKey); + + vm.expectRevert("quantiy must be 1"); + base.mintWithSignature(_mintrequest, _signature); + } +} diff --git a/src/test/sdk/extension/BatchMintMetadata.t.sol b/src/test/sdk/extension/BatchMintMetadata.t.sol new file mode 100644 index 000000000..31da1aa4d --- /dev/null +++ b/src/test/sdk/extension/BatchMintMetadata.t.sol @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import { BatchMintMetadata } from "contracts/extension/BatchMintMetadata.sol"; + +contract MyBatchMintMetadata is BatchMintMetadata { + function setBaseURI(uint256 _batchId, string memory _baseURI) external { + _setBaseURI(_batchId, _baseURI); + } + + function batchMintMetadata( + uint256 _startId, + uint256 _amountToMint, + string memory _baseURIForTokens + ) external returns (uint256 nextTokenIdToMint, uint256 batchId) { + (nextTokenIdToMint, batchId) = _batchMintMetadata(_startId, _amountToMint, _baseURIForTokens); + } + + function viewBaseURI(uint256 _tokenId) external view returns (string memory) { + return _getBaseURI(_tokenId); + } + + function freezeBaseURI(uint256 _batchId) external { + _freezeBaseURI(_batchId); + } +} + +contract ExtensionBatchMintMetadata is DSTest, Test { + MyBatchMintMetadata internal ext; + + function setUp() public { + ext = new MyBatchMintMetadata(); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `batchMintMetadata` + //////////////////////////////////////////////////////////////*/ + + function test_state_batchMintMetadata() public { + (uint256 nextTokenIdToMint, uint256 batchId) = ext.batchMintMetadata(0, 100, ""); + assertEq(nextTokenIdToMint, 100); + assertEq(batchId, 100); + + (nextTokenIdToMint, batchId) = ext.batchMintMetadata(100, 100, ""); + assertEq(nextTokenIdToMint, 200); + assertEq(batchId, 200); + + assertEq(2, ext.getBaseURICount()); + + assertEq(100, ext.getBatchIdAtIndex(0)); + assertEq(200, ext.getBatchIdAtIndex(1)); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `setBaseURI` + //////////////////////////////////////////////////////////////*/ + + function test_state_setBaseURI() public { + string memory baseUriOne = "one"; + string memory baseUriTwo = "two"; + + (, uint256 batchId) = ext.batchMintMetadata(0, 100, baseUriOne); + + assertEq(baseUriOne, ext.viewBaseURI(10)); + + ext.setBaseURI(batchId, baseUriTwo); + assertEq(baseUriTwo, ext.viewBaseURI(10)); + } + + function test_setBaseURI_revert_frozen() public { + string memory baseUriOne = "one"; + (, uint256 batchId) = ext.batchMintMetadata(0, 100, baseUriOne); + + ext.freezeBaseURI(batchId); + + vm.expectRevert(abi.encodeWithSelector(BatchMintMetadata.BatchMintMetadataFrozen.selector, batchId)); + string memory baseUri = "one"; + ext.setBaseURI(batchId, baseUri); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `freezeBaseURI` + //////////////////////////////////////////////////////////////*/ + + function test_state_freezeBaseURI() public { + string memory baseUriOne = "one"; + (, uint256 batchId) = ext.batchMintMetadata(0, 100, baseUriOne); + + ext.freezeBaseURI(batchId); + assertEq(ext.batchFrozen(batchId), true); + } + + function test_freezeBaseURI_revert_invalidBatch() public { + vm.expectRevert(abi.encodeWithSelector(BatchMintMetadata.BatchMintInvalidBatchId.selector, 100)); + ext.freezeBaseURI(100); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `viewBaseURI` + //////////////////////////////////////////////////////////////*/ + + function test_viewBaseURI_revert_invalidTokenId() public { + vm.expectRevert(abi.encodeWithSelector(BatchMintMetadata.BatchMintInvalidTokenId.selector, 100)); + ext.viewBaseURI(100); + } +} diff --git a/src/test/sdk/extension/ContractMetadata.t.sol b/src/test/sdk/extension/ContractMetadata.t.sol new file mode 100644 index 000000000..ad6e7060d --- /dev/null +++ b/src/test/sdk/extension/ContractMetadata.t.sol @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import { ContractMetadata } from "contracts/extension/ContractMetadata.sol"; + +contract MyContractMetadata is ContractMetadata { + bool condition; + + function setCondition(bool _condition) external { + condition = _condition; + } + + function _canSetContractURI() internal view override returns (bool) { + return condition; + } +} + +contract ExtensionContractMetadataTest is DSTest, Test { + MyContractMetadata internal ext; + event ContractURIUpdated(string prevURI, string newURI); + + function setUp() public { + ext = new MyContractMetadata(); + } + + function test_state_setContractURI() public { + ext.setCondition(true); + + string memory uri = "uri_string"; + ext.setContractURI(uri); + + string memory contractURI = ext.contractURI(); + + assertEq(contractURI, uri); + } + + function test_revert_setContractURI() public { + vm.expectRevert(abi.encodeWithSelector(ContractMetadata.ContractMetadataUnauthorized.selector)); + ext.setContractURI(""); + } + + function test_event_setContractURI() public { + ext.setCondition(true); + string memory uri = "uri_string"; + + vm.expectEmit(true, true, true, true); + emit ContractURIUpdated("", uri); + + ext.setContractURI(uri); + } +} diff --git a/src/test/sdk/extension/DelayedReveal.t.sol b/src/test/sdk/extension/DelayedReveal.t.sol new file mode 100644 index 000000000..12576b227 --- /dev/null +++ b/src/test/sdk/extension/DelayedReveal.t.sol @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import { DelayedReveal } from "contracts/extension/DelayedReveal.sol"; + +contract MyDelayedReveal is DelayedReveal { + function setEncryptedData(uint256 _batchId, bytes memory _encryptedData) external { + _setEncryptedData(_batchId, _encryptedData); + } + + function reveal(uint256 identifier, bytes calldata key) external returns (string memory revealedURI) {} +} + +contract ExtensionDelayedReveal is DSTest, Test { + MyDelayedReveal internal ext; + + function setUp() public { + ext = new MyDelayedReveal(); + } + + function test_state_setEncryptedData() public { + string memory uriToEncrypt = "uri_string"; + bytes memory key = "key"; + + bytes memory encryptedUri = ext.encryptDecrypt(bytes(uriToEncrypt), key); + bytes32 provenanceHash = keccak256(abi.encodePacked(uriToEncrypt, key, block.chainid)); + + bytes memory data = abi.encode(encryptedUri, provenanceHash); + + ext.setEncryptedData(0, data); + + assertEq(true, ext.isEncryptedBatch(0)); + } + + function test_state_getRevealURI() public { + string memory uriToEncrypt = "uri_string"; + bytes memory key = "key"; + + bytes memory encryptedUri = ext.encryptDecrypt(bytes(uriToEncrypt), key); + bytes32 provenanceHash = keccak256(abi.encodePacked(uriToEncrypt, key, block.chainid)); + + bytes memory data = abi.encode(encryptedUri, provenanceHash); + + ext.setEncryptedData(0, data); + + string memory revealedURI = ext.getRevealURI(0, key); + + assertEq(uriToEncrypt, revealedURI); + } + + function test_revert_getRevealURI_IncorrectKey() public { + string memory uriToEncrypt = "uri_string"; + bytes memory key = "key"; + bytes memory incorrectKey = "incorrect key"; + + bytes memory encryptedUri = ext.encryptDecrypt(bytes(uriToEncrypt), key); + bytes32 provenanceHash = keccak256(abi.encodePacked(uriToEncrypt, key, block.chainid)); + string memory incorrectURI = string(ext.encryptDecrypt(encryptedUri, incorrectKey)); + + bytes memory data = abi.encode(encryptedUri, provenanceHash); + + ext.setEncryptedData(0, data); + + vm.expectRevert( + abi.encodeWithSelector( + DelayedReveal.DelayedRevealIncorrectResultHash.selector, + provenanceHash, + keccak256(abi.encodePacked(incorrectURI, incorrectKey, block.chainid)) + ) + ); + ext.getRevealURI(0, incorrectKey); + } + + function test_revert_getRevealURI_NothingToReveal() public { + string memory uriToEncrypt = "uri_string"; + bytes memory key = "key"; + + bytes memory encryptedUri = ext.encryptDecrypt(bytes(uriToEncrypt), key); + bytes32 provenanceHash = keccak256(abi.encodePacked(uriToEncrypt, key, block.chainid)); + + bytes memory data = abi.encode(encryptedUri, provenanceHash); + + ext.setEncryptedData(0, data); + assertEq(true, ext.isEncryptedBatch(0)); + + ext.setEncryptedData(0, ""); + assertFalse(ext.isEncryptedBatch(0)); + + vm.expectRevert(abi.encodeWithSelector(DelayedReveal.DelayedRevealNothingToReveal.selector)); + ext.getRevealURI(0, key); + } +} diff --git a/src/test/sdk/extension/DropSinglePhase.t.sol b/src/test/sdk/extension/DropSinglePhase.t.sol new file mode 100644 index 000000000..51d684eb6 --- /dev/null +++ b/src/test/sdk/extension/DropSinglePhase.t.sol @@ -0,0 +1,243 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; +import { Strings } from "contracts/lib/Strings.sol"; + +import { DropSinglePhase } from "contracts/extension/DropSinglePhase.sol"; + +contract MyDropSinglePhase is DropSinglePhase { + bool condition; + + function setCondition(bool _condition) external { + condition = _condition; + } + + function _canSetClaimConditions() internal view override returns (bool) { + return condition; + } + + function _collectPriceOnClaim( + address _primarySaleRecipient, + uint256 _quantityToClaim, + address _currency, + uint256 _pricePerToken + ) internal override {} + + function _transferTokensOnClaim( + address _to, + uint256 _quantityBeingClaimed + ) internal override returns (uint256 startTokenId) {} +} + +contract ExtensionDropSinglePhase is DSTest, Test { + using Strings for uint256; + MyDropSinglePhase internal ext; + + event TokensClaimed( + address indexed claimer, + address indexed receiver, + uint256 indexed startTokenId, + uint256 quantityClaimed + ); + event ClaimConditionUpdated(MyDropSinglePhase.ClaimCondition condition, bool resetEligibility); + + function setUp() public { + ext = new MyDropSinglePhase(); + } + + /*/////////////////////////////////////////////////////////////// + Claim Tests + //////////////////////////////////////////////////////////////*/ + + /** + * note: Testing revert condition; exceed max claimable supply. + */ + function test_revert_claimCondition_exceedMaxClaimableSupply() public { + ext.setCondition(true); + vm.warp(1); + + address receiver = address(0x123); + address claimer1 = address(0x345); + address claimer2 = address(0x567); + bytes32[] memory proofs = new bytes32[](0); + + MyDropSinglePhase.AllowlistProof memory alp; + alp.proof = proofs; + + MyDropSinglePhase.ClaimCondition[] memory conditions = new MyDropSinglePhase.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 100; + conditions[0].quantityLimitPerWallet = 100; + + ext.setClaimConditions(conditions[0], false); + + vm.prank(claimer1, claimer1); + ext.claim(receiver, 100, address(0), 0, alp, ""); + + vm.expectRevert( + abi.encodeWithSelector( + DropSinglePhase.DropClaimExceedMaxSupply.selector, + conditions[0].maxClaimableSupply, + 1 + conditions[0].maxClaimableSupply + ) + ); + vm.prank(claimer2, claimer2); + ext.claim(receiver, 1, address(0), 0, alp, ""); + } + + /** + * note: Testing quantity limit restriction when no allowlist present. + */ + function test_fuzz_claim_noAllowlist(uint256 x) public { + ext.setCondition(true); + vm.assume(x != 0); + vm.warp(1); + + address receiver = address(0x123); + address claimer = address(0x345); + bytes32[] memory proofs = new bytes32[](0); + + MyDropSinglePhase.AllowlistProof memory alp; + alp.proof = proofs; + alp.quantityLimitPerWallet = x; + + MyDropSinglePhase.ClaimCondition[] memory conditions = new MyDropSinglePhase.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 500; + conditions[0].quantityLimitPerWallet = 100; + + ext.setClaimConditions(conditions[0], false); + + vm.prank(claimer, claimer); + vm.expectRevert( + abi.encodeWithSelector( + DropSinglePhase.DropClaimExceedLimit.selector, + conditions[0].quantityLimitPerWallet, + 101 + ) + ); + ext.claim(receiver, 101, address(0), 0, alp, ""); + + ext.setClaimConditions(conditions[0], true); + + vm.prank(claimer, claimer); + vm.expectRevert( + abi.encodeWithSelector( + DropSinglePhase.DropClaimExceedLimit.selector, + conditions[0].quantityLimitPerWallet, + 101 + ) + ); + ext.claim(receiver, 101, address(0), 0, alp, ""); + } + + function test_fuzz_claim_merkleProof(uint256 x) public { + ext.setCondition(true); + vm.assume(x > 10 && x < 500); + string[] memory inputs = new string[](5); + + inputs[0] = "node"; + inputs[1] = "src/test/scripts/generateRoot.ts"; + inputs[2] = Strings.toString(x); + inputs[3] = "0"; + inputs[4] = "0x0000000000000000000000000000000000000000"; + + bytes memory result = vm.ffi(inputs); + // revert(); + bytes32 root = abi.decode(result, (bytes32)); + + inputs[1] = "src/test/scripts/getProof.ts"; + result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + MyDropSinglePhase.AllowlistProof memory alp; + alp.proof = proofs; + alp.quantityLimitPerWallet = x; + alp.pricePerToken = 0; + alp.currency = address(0); + + vm.warp(1); + + address receiver = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); + + // bytes32[] memory proofs = new bytes32[](0); + + MyDropSinglePhase.ClaimCondition[] memory conditions = new MyDropSinglePhase.ClaimCondition[](1); + conditions[0].maxClaimableSupply = x; + conditions[0].quantityLimitPerWallet = 1; + conditions[0].merkleRoot = root; + + ext.setClaimConditions(conditions[0], false); + + // vm.prank(getActor(5), getActor(5)); + vm.prank(receiver, receiver); + ext.claim(receiver, x - 5, address(0), 0, alp, ""); + assertEq(ext.getSupplyClaimedByWallet(receiver), x - 5); + + vm.prank(receiver, receiver); + vm.expectRevert( + abi.encodeWithSelector(DropSinglePhase.DropClaimExceedLimit.selector, alp.quantityLimitPerWallet, x + 1) + ); + ext.claim(receiver, 6, address(0), 0, alp, ""); + + vm.prank(receiver, receiver); + ext.claim(receiver, 5, address(0), 0, alp, ""); + assertEq(ext.getSupplyClaimedByWallet(receiver), x); + + vm.prank(receiver, receiver); + vm.expectRevert( + abi.encodeWithSelector(DropSinglePhase.DropClaimExceedLimit.selector, alp.quantityLimitPerWallet, x + 5) + ); + ext.claim(receiver, 5, address(0), 0, alp, ""); + } + + /** + * note: Testing event emission on setClaimConditions. + */ + function test_event_setClaimConditions() public { + ext.setCondition(true); + vm.warp(1); + + bytes32[] memory proofs = new bytes32[](0); + + MyDropSinglePhase.AllowlistProof memory alp; + alp.proof = proofs; + + MyDropSinglePhase.ClaimCondition[] memory conditions = new MyDropSinglePhase.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 100; + conditions[0].quantityLimitPerWallet = 100; + + vm.expectEmit(true, true, true, true); + emit ClaimConditionUpdated(conditions[0], false); + + ext.setClaimConditions(conditions[0], false); + } + + /** + * note: Testing event emission on claim. + */ + function test_event_claim() public { + ext.setCondition(true); + vm.warp(1); + + address receiver = address(0x123); + address claimer = address(0x345); + bytes32[] memory proofs = new bytes32[](0); + + MyDropSinglePhase.AllowlistProof memory alp; + alp.proof = proofs; + + MyDropSinglePhase.ClaimCondition[] memory conditions = new MyDropSinglePhase.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 100; + conditions[0].quantityLimitPerWallet = 100; + + ext.setClaimConditions(conditions[0], false); + + vm.startPrank(claimer, claimer); + + vm.expectEmit(true, true, true, true); + emit TokensClaimed(claimer, receiver, 0, 1); + + ext.claim(receiver, 1, address(0), 0, alp, ""); + } +} diff --git a/src/test/sdk/extension/DropSinglePhase1155.t.sol b/src/test/sdk/extension/DropSinglePhase1155.t.sol new file mode 100644 index 000000000..dc6e02689 --- /dev/null +++ b/src/test/sdk/extension/DropSinglePhase1155.t.sol @@ -0,0 +1,230 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import { DropSinglePhase1155 } from "contracts/extension/DropSinglePhase1155.sol"; + +contract MyDropSinglePhase1155 is DropSinglePhase1155 { + bool condition; + + function setCondition(bool _condition) external { + condition = _condition; + } + + function _canSetClaimConditions() internal view override returns (bool) { + return condition; + } + + function _collectPriceOnClaim( + address _primarySaleRecipient, + uint256 _quantityToClaim, + address _currency, + uint256 _pricePerToken + ) internal override {} + + function _transferTokensOnClaim(address _to, uint256 _tokenId, uint256 _quantityBeingClaimed) internal override {} +} + +contract ExtensionDropSinglePhase1155 is DSTest, Test { + MyDropSinglePhase1155 internal ext; + + event TokensClaimed( + address indexed claimer, + address indexed receiver, + uint256 indexed tokenId, + uint256 quantityClaimed + ); + event ClaimConditionUpdated( + uint256 indexed tokenId, + MyDropSinglePhase1155.ClaimCondition condition, + bool resetEligibility + ); + + function setUp() public { + ext = new MyDropSinglePhase1155(); + } + + /*/////////////////////////////////////////////////////////////// + Claim Tests + //////////////////////////////////////////////////////////////*/ + + /** + * note: Testing revert condition; exceed max claimable supply. + */ + function test_revert_claimCondition_exceedMaxClaimableSupply() public { + ext.setCondition(true); + vm.warp(1); + + address receiver = address(0x123); + address claimer1 = address(0x345); + address claimer2 = address(0x567); + bytes32[] memory proofs = new bytes32[](0); + uint256 _tokenId = 0; + + MyDropSinglePhase1155.AllowlistProof memory alp; + alp.proof = proofs; + + MyDropSinglePhase1155.ClaimCondition[] memory conditions = new MyDropSinglePhase1155.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 100; + conditions[0].quantityLimitPerWallet = 100; + + ext.setClaimConditions(_tokenId, conditions[0], false); + + vm.prank(claimer1, claimer1); + ext.claim(receiver, _tokenId, 100, address(0), 0, alp, ""); + + vm.expectRevert("!MaxSupply"); + vm.prank(claimer2, claimer2); + ext.claim(receiver, _tokenId, 1, address(0), 0, alp, ""); + } + + /** + * note: Testing quantity limit restriction when no allowlist present. + */ + function test_fuzz_claim_noAllowlist(uint256 x) public { + ext.setCondition(true); + vm.assume(x != 0); + vm.warp(1); + + address receiver = address(0x123); + address claimer = address(0x345); + bytes32[] memory proofs = new bytes32[](0); + uint256 _tokenId = 0; + + MyDropSinglePhase1155.AllowlistProof memory alp; + alp.proof = proofs; + alp.quantityLimitPerWallet = x; + + MyDropSinglePhase1155.ClaimCondition[] memory conditions = new MyDropSinglePhase1155.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 500; + conditions[0].quantityLimitPerWallet = 100; + + ext.setClaimConditions(_tokenId, conditions[0], false); + + bytes memory errorQty = "!Qty"; + + vm.prank(claimer, claimer); + vm.expectRevert(errorQty); + ext.claim(receiver, _tokenId, 101, address(0), 0, alp, ""); + + ext.setClaimConditions(_tokenId, conditions[0], true); + + vm.prank(claimer, claimer); + vm.expectRevert(errorQty); + ext.claim(receiver, _tokenId, 101, address(0), 0, alp, ""); + } + + /** + * note: Testing event emission on setClaimConditions. + */ + function test_event_setClaimConditions() public { + ext.setCondition(true); + vm.warp(1); + + bytes32[] memory proofs = new bytes32[](0); + uint256 _tokenId = 0; + + MyDropSinglePhase1155.AllowlistProof memory alp; + alp.proof = proofs; + + MyDropSinglePhase1155.ClaimCondition[] memory conditions = new MyDropSinglePhase1155.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 100; + conditions[0].quantityLimitPerWallet = 100; + + vm.expectEmit(true, true, true, true); + emit ClaimConditionUpdated(_tokenId, conditions[0], false); + + ext.setClaimConditions(_tokenId, conditions[0], false); + } + + /** + * note: Testing event emission on claim. + */ + function test_event_claim() public { + ext.setCondition(true); + vm.warp(1); + + address receiver = address(0x123); + address claimer = address(0x345); + bytes32[] memory proofs = new bytes32[](0); + uint256 _tokenId = 0; + + MyDropSinglePhase1155.AllowlistProof memory alp; + alp.proof = proofs; + + MyDropSinglePhase1155.ClaimCondition[] memory conditions = new MyDropSinglePhase1155.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 100; + conditions[0].quantityLimitPerWallet = 100; + + ext.setClaimConditions(_tokenId, conditions[0], false); + + vm.startPrank(claimer, claimer); + + vm.expectEmit(true, true, true, true); + emit TokensClaimed(claimer, receiver, _tokenId, 1); + + ext.claim(receiver, _tokenId, 1, address(0), 0, alp, ""); + } + + function test_claimCondition_resetEligibility_quantityLimitPerWallet() public { + ext.setCondition(true); + vm.warp(1); + + address receiver = address(0x123); + bytes32[] memory proofs = new bytes32[](0); + + MyDropSinglePhase1155.AllowlistProof memory alp; + alp.proof = proofs; + + MyDropSinglePhase1155.ClaimCondition[] memory conditions = new MyDropSinglePhase1155.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 100; + conditions[0].quantityLimitPerWallet = 100; + + ext.setClaimConditions(0, conditions[0], false); + + vm.prank(receiver, receiver); + ext.claim(receiver, 0, 10, address(0), 0, alp, ""); + assertEq(ext.getSupplyClaimedByWallet(0, receiver), 10); + + vm.roll(100); + ext.setClaimConditions(0, conditions[0], true); + assertEq(ext.getSupplyClaimedByWallet(0, receiver), 0); + + vm.prank(receiver, receiver); + ext.claim(receiver, 0, 10, address(0), 0, alp, ""); + assertEq(ext.getSupplyClaimedByWallet(0, receiver), 10); + } + + /** + * note: Testing state; unique condition Id for every token. + */ + function test_state_claimCondition_uniqueConditionId() public { + ext.setCondition(true); + vm.warp(1); + + address receiver = address(0x123); + address claimer1 = address(0x345); + bytes32[] memory proofs = new bytes32[](0); + uint256 _tokenId = 0; + + MyDropSinglePhase1155.AllowlistProof memory alp; + alp.proof = proofs; + + MyDropSinglePhase1155.ClaimCondition[] memory conditions = new MyDropSinglePhase1155.ClaimCondition[](1); + conditions[0].maxClaimableSupply = 100; + conditions[0].quantityLimitPerWallet = 100; + + ext.setClaimConditions(_tokenId, conditions[0], false); + + vm.prank(claimer1, claimer1); + ext.claim(receiver, _tokenId, 100, address(0), 0, alp, ""); + + assertEq(ext.getSupplyClaimedByWallet(_tokenId, claimer1), 100); + + // supply claimed for other tokenIds should be 0 + assertEq(ext.getSupplyClaimedByWallet(1, claimer1), 0); + assertEq(ext.getSupplyClaimedByWallet(2, claimer1), 0); + } +} diff --git a/src/test/sdk/extension/ExtensionUtilTest.sol b/src/test/sdk/extension/ExtensionUtilTest.sol new file mode 100644 index 000000000..694ab6946 --- /dev/null +++ b/src/test/sdk/extension/ExtensionUtilTest.sol @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; +import "../../utils/Wallet.sol"; +import "../../mocks/WETH9.sol"; +import "../../mocks/MockERC20.sol"; +import "../../mocks/MockERC721.sol"; +import "../../mocks/MockERC1155.sol"; +import { MockERC721NonBurnable } from "../../mocks/MockERC721NonBurnable.sol"; +import { MockERC1155NonBurnable } from "../../mocks/MockERC1155NonBurnable.sol"; +import "contracts/infra/forwarder/Forwarder.sol"; + +abstract contract ExtensionUtilTest is DSTest, Test { + string public constant NAME = "NAME"; + string public constant SYMBOL = "SYMBOL"; + string public constant CONTRACT_URI = "CONTRACT_URI"; + address public constant NATIVE_TOKEN = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + + MockERC20 public erc20; + MockERC721 public erc721; + MockERC1155 public erc1155; + MockERC721NonBurnable public erc721NonBurnable; + MockERC1155NonBurnable public erc1155NonBurnable; + WETH9 public weth; + + address public forwarder; + + address public deployer = address(0x20000); + address public saleRecipient = address(0x30000); + address public royaltyRecipient = address(0x30001); + address public platformFeeRecipient = address(0x30002); + uint128 public royaltyBps = 500; // 5% + uint128 public platformFeeBps = 500; // 5% + uint256 public constant MAX_BPS = 10_000; // 100% + + uint256 public privateKey = 1234; + address public signer; + + mapping(bytes32 => address) public contracts; + + function setUp() public virtual { + signer = vm.addr(privateKey); + + erc20 = new MockERC20(); + erc721 = new MockERC721(); + erc1155 = new MockERC1155(); + erc721NonBurnable = new MockERC721NonBurnable(); + erc1155NonBurnable = new MockERC1155NonBurnable(); + weth = new WETH9(); + forwarder = address(new Forwarder()); + } + + function getActor(uint160 _index) public pure returns (address) { + return address(uint160(0x50000 + _index)); + } + + function getWallet() public returns (Wallet wallet) { + wallet = new Wallet(); + } + + function assertIsOwnerERC721(address _token, address _owner, uint256[] memory _tokenIds) internal { + for (uint256 i = 0; i < _tokenIds.length; i += 1) { + bool isOwnerOfToken = MockERC721(_token).ownerOf(_tokenIds[i]) == _owner; + assertTrue(isOwnerOfToken); + } + } + + function assertIsNotOwnerERC721(address _token, address _owner, uint256[] memory _tokenIds) internal { + for (uint256 i = 0; i < _tokenIds.length; i += 1) { + bool isOwnerOfToken = MockERC721(_token).ownerOf(_tokenIds[i]) == _owner; + assertTrue(!isOwnerOfToken); + } + } + + function assertBalERC1155Eq( + address _token, + address _owner, + uint256[] memory _tokenIds, + uint256[] memory _amounts + ) internal { + require(_tokenIds.length == _amounts.length, "unequal lengths"); + + for (uint256 i = 0; i < _tokenIds.length; i += 1) { + assertEq(MockERC1155(_token).balanceOf(_owner, _tokenIds[i]), _amounts[i]); + } + } + + function assertBalERC1155Gte( + address _token, + address _owner, + uint256[] memory _tokenIds, + uint256[] memory _amounts + ) internal { + require(_tokenIds.length == _amounts.length, "unequal lengths"); + + for (uint256 i = 0; i < _tokenIds.length; i += 1) { + assertTrue(MockERC1155(_token).balanceOf(_owner, _tokenIds[i]) >= _amounts[i]); + } + } + + function assertBalERC20Eq(address _token, address _owner, uint256 _amount) internal { + assertEq(MockERC20(_token).balanceOf(_owner), _amount); + } + + function assertBalERC20Gte(address _token, address _owner, uint256 _amount) internal { + assertTrue(MockERC20(_token).balanceOf(_owner) >= _amount); + } + + function forwarders() public view returns (address[] memory) { + address[] memory _forwarders = new address[](1); + _forwarders[0] = forwarder; + return _forwarders; + } +} diff --git a/src/test/sdk/extension/LazyMint.t.sol b/src/test/sdk/extension/LazyMint.t.sol new file mode 100644 index 000000000..ae152d1e8 --- /dev/null +++ b/src/test/sdk/extension/LazyMint.t.sol @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import { LazyMint } from "contracts/extension/LazyMint.sol"; + +contract MyLazyMint is LazyMint { + bool condition; + + function setCondition(bool _condition) external { + condition = _condition; + } + + function _canLazyMint() internal view override returns (bool) { + return condition; + } +} + +contract ExtensionLazyMint is DSTest, Test { + MyLazyMint internal ext; + event TokensLazyMinted(uint256 indexed startTokenId, uint256 endTokenId, string baseURI, bytes encryptedBaseURI); + + function setUp() public { + ext = new MyLazyMint(); + } + + function test_state_lazyMint() public { + ext.setCondition(true); + + string memory uri = "uri_string"; + uint256 batchId = ext.lazyMint(100, uri, ""); + + assertEq(batchId, 100); + assertEq(1, ext.getBaseURICount()); + + batchId = ext.lazyMint(200, uri, ""); + + assertEq(batchId, 300); + assertEq(2, ext.getBaseURICount()); + } + + function test_state_lazyMint_NotAuthorized() public { + vm.expectRevert(abi.encodeWithSelector(LazyMint.LazyMintUnauthorized.selector)); + ext.lazyMint(100, "", ""); + } + + function test_state_lazyMint_ZeroAmount() public { + ext.setCondition(true); + vm.expectRevert(abi.encodeWithSelector(LazyMint.LazyMintInvalidAmount.selector)); + ext.lazyMint(0, "", ""); + } + + function test_event_lazyMint() public { + ext.setCondition(true); + + vm.expectEmit(true, true, true, true); + emit TokensLazyMinted(0, 99, "", ""); + ext.lazyMint(100, "", ""); + } +} diff --git a/src/test/sdk/extension/NFTMetadata.t.sol b/src/test/sdk/extension/NFTMetadata.t.sol new file mode 100644 index 000000000..819921433 --- /dev/null +++ b/src/test/sdk/extension/NFTMetadata.t.sol @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import { NFTMetadata } from "contracts/extension/NFTMetadata.sol"; + +contract NFTMetadataHarness is NFTMetadata { + address private authorized; + + constructor() { + authorized = msg.sender; + } + + function _canSetMetadata() internal view override returns (bool) { + if (msg.sender == authorized) return true; + return false; + } + + function _canFreezeMetadata() internal view override returns (bool) { + if (msg.sender == authorized) return true; + return false; + } + + function getTokenURI(uint256 _tokenId) external view returns (string memory) { + return _getTokenURI(_tokenId); + } + + function URIStatus() external view returns (bool) { + return uriFrozen; + } + + function supportsInterface(bytes4 interfaceId) external view override returns (bool) {} +} + +contract ExtensionNFTMetadata is DSTest, Test { + NFTMetadataHarness internal ext; + + function setUp() public { + ext = new NFTMetadataHarness(); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `setTokenURI` + //////////////////////////////////////////////////////////////*/ + + function test_setTokenURI_state() public { + string memory uri = "test"; + ext.setTokenURI(0, uri); + assertEq(ext.getTokenURI(0), uri); + + string memory uri2 = "test2"; + ext.setTokenURI(0, uri2); + assertEq(ext.getTokenURI(0), uri2); + } + + function test_setTokenURI_revert_notAuthorized() public { + vm.startPrank(address(0x1)); + string memory uri = "test"; + vm.expectRevert(abi.encodeWithSelector(NFTMetadata.NFTMetadataUnauthorized.selector)); + ext.setTokenURI(1, uri); + } + + function test_setTokenURI_revert_emptyMetadata() public { + string memory uri = ""; + vm.expectRevert(abi.encodeWithSelector(NFTMetadata.NFTMetadataInvalidUrl.selector)); + ext.setTokenURI(1, uri); + } + + function test_setTokenURI_revert_frozen() public { + ext.freezeMetadata(); + string memory uri = "test"; + vm.expectRevert(abi.encodeWithSelector(NFTMetadata.NFTMetadataFrozen.selector, 2)); + ext.setTokenURI(2, uri); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `freezeMetadata` + //////////////////////////////////////////////////////////////*/ + + function test_freezeMetadata_state() public { + ext.freezeMetadata(); + assertEq(ext.URIStatus(), true); + } + + function test_freezeMetadata_revert_notAuthorized() public { + vm.startPrank(address(0x1)); + vm.expectRevert(abi.encodeWithSelector(NFTMetadata.NFTMetadataUnauthorized.selector)); + ext.freezeMetadata(); + } +} diff --git a/src/test/sdk/extension/Ownable.t.sol b/src/test/sdk/extension/Ownable.t.sol new file mode 100644 index 000000000..ca8edbe64 --- /dev/null +++ b/src/test/sdk/extension/Ownable.t.sol @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import { Ownable } from "contracts/extension/Ownable.sol"; + +contract MyOwnable is Ownable { + bool condition; + + function setCondition(bool _condition) external { + condition = _condition; + } + + function _canSetOwner() internal view override returns (bool) { + return condition; + } +} + +contract ExtensionOwnableTest is DSTest, Test { + MyOwnable internal ext; + event OwnerUpdated(address indexed prevOwner, address indexed newOwner); + + function setUp() public { + ext = new MyOwnable(); + } + + function test_state_setOwner() public { + ext.setCondition(true); + + address owner = address(0x123); + ext.setOwner(owner); + + address currentOwner = ext.owner(); + assertEq(currentOwner, owner); + } + + function test_revert_setOwner() public { + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorized.selector)); + ext.setOwner(address(0x1234)); + } + + function test_event_setOwner() public { + ext.setCondition(true); + + address owner = address(0x123); + + vm.expectEmit(true, true, true, true); + emit OwnerUpdated(address(0), owner); + + ext.setOwner(owner); + } +} diff --git a/src/test/sdk/extension/Permissions.t.sol b/src/test/sdk/extension/Permissions.t.sol new file mode 100644 index 000000000..0c8bfdfbd --- /dev/null +++ b/src/test/sdk/extension/Permissions.t.sol @@ -0,0 +1,225 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import { Permissions, Strings } from "contracts/extension/Permissions.sol"; + +contract MyPermissions is Permissions { + constructor() { + _setupRole(DEFAULT_ADMIN_ROLE, msg.sender); + } + + function setRoleAdmin(bytes32 role, bytes32 adminRole) external { + _setRoleAdmin(role, adminRole); + } + + function checkModifier() external view onlyRole(DEFAULT_ADMIN_ROLE) {} +} + +contract ExtensionPermissions is DSTest, Test { + MyPermissions internal ext; + event RoleAdminChanged(bytes32 indexed role, bytes32 indexed previousAdminRole, bytes32 indexed newAdminRole); + event RoleGranted(bytes32 indexed role, address indexed account, address indexed sender); + event RoleRevoked(bytes32 indexed role, address indexed account, address indexed sender); + + address defaultAdmin; + + function setUp() public { + defaultAdmin = address(0x123); + + vm.prank(defaultAdmin); + ext = new MyPermissions(); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `setRoleAdmin` + //////////////////////////////////////////////////////////////*/ + + function test_state_setRoleAdmin() public { + bytes32 role1 = "ROLE_1"; + bytes32 role2 = "ROLE_2"; + + bytes32 adminRole1 = "ADMIN_ROLE_1"; + bytes32 currentDefaultAdmin = ext.DEFAULT_ADMIN_ROLE(); + + ext.setRoleAdmin(role1, adminRole1); + + assertEq(adminRole1, ext.getRoleAdmin(role1)); + assertEq(currentDefaultAdmin, ext.getRoleAdmin(role2)); + } + + function test_event_roleAdminChanged() public { + bytes32 role1 = keccak256("ROLE_1"); + bytes32 adminRole1 = keccak256("ADMIN_ROLE_1"); + + bytes32 previousAdmin = ext.getRoleAdmin(role1); + + vm.expectEmit(true, true, true, true); + emit RoleAdminChanged(role1, previousAdmin, adminRole1); + ext.setRoleAdmin(role1, adminRole1); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `grantRole` + //////////////////////////////////////////////////////////////*/ + + function test_state_grantRole() public { + bytes32 role1 = "ROLE_1"; + bytes32 role2 = "ROLE_2"; + + bytes32 adminRole1 = "ADMIN_ROLE_1"; + address adminOne = address(0x1); + + ext.setRoleAdmin(role1, adminRole1); + + vm.prank(defaultAdmin); + ext.grantRole(adminRole1, adminOne); + + vm.prank(adminOne); + ext.grantRole(role1, address(0x567)); + + vm.prank(defaultAdmin); + ext.grantRole(role2, address(0x567)); + + assertTrue(ext.hasRole(role1, address(0x567))); + assertTrue(ext.hasRole(role2, address(0x567))); + } + + function test_revert_grantRole_missingRole() public { + address caller = address(0x345); + + vm.startPrank(caller); + vm.expectRevert( + abi.encodeWithSelector( + Permissions.PermissionsUnauthorizedAccount.selector, + caller, + ext.DEFAULT_ADMIN_ROLE() + ) + ); + ext.grantRole(keccak256("role"), address(0x1)); + } + + function test_revert_grantRole_grantToHolder() public { + vm.startPrank(defaultAdmin); + ext.grantRole(keccak256("role"), address(0x1)); + + vm.expectRevert( + abi.encodeWithSelector(Permissions.PermissionsAlreadyGranted.selector, address(0x1), keccak256("role")) + ); + ext.grantRole(keccak256("role"), address(0x1)); + } + + function test_event_grantRole() public { + vm.startPrank(defaultAdmin); + + vm.expectEmit(true, true, true, true); + emit RoleGranted(keccak256("role"), address(0x1), defaultAdmin); + ext.grantRole(keccak256("role"), address(0x1)); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `revokeRole` + //////////////////////////////////////////////////////////////*/ + + function test_state_revokeRole() public { + vm.startPrank(defaultAdmin); + + ext.grantRole(keccak256("role"), address(0x567)); + assertTrue(ext.hasRole(keccak256("role"), address(0x567))); + + ext.revokeRole(keccak256("role"), address(0x567)); + assertFalse(ext.hasRole(keccak256("role"), address(0x567))); + } + + function test_revert_revokeRole_missingRole() public { + vm.prank(defaultAdmin); + ext.grantRole(keccak256("role"), address(0x567)); + assertTrue(ext.hasRole(keccak256("role"), address(0x567))); + + vm.startPrank(address(0x345)); + vm.expectRevert( + abi.encodeWithSelector( + Permissions.PermissionsUnauthorizedAccount.selector, + address(0x345), + ext.DEFAULT_ADMIN_ROLE() + ) + ); + ext.revokeRole(keccak256("role"), address(0x567)); + vm.stopPrank(); + + vm.startPrank(defaultAdmin); + vm.expectRevert( + abi.encodeWithSelector( + Permissions.PermissionsUnauthorizedAccount.selector, + address(0x789), + keccak256("role") + ) + ); + ext.revokeRole(keccak256("role"), address(0x789)); + vm.stopPrank(); + } + + function test_event_revokeRole() public { + vm.startPrank(defaultAdmin); + + ext.grantRole(keccak256("role"), address(0x1)); + + vm.expectEmit(true, true, true, true); + emit RoleRevoked(keccak256("role"), address(0x1), defaultAdmin); + ext.revokeRole(keccak256("role"), address(0x1)); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `renounceRole` + //////////////////////////////////////////////////////////////*/ + + function test_state_renounceRole() public { + vm.prank(defaultAdmin); + ext.grantRole(keccak256("role"), address(0x567)); + assertTrue(ext.hasRole(keccak256("role"), address(0x567))); + + vm.prank(address(0x567)); + ext.renounceRole(keccak256("role"), address(0x567)); + + assertFalse(ext.hasRole(keccak256("role"), address(0x567))); + } + + function test_revert_renounceRole_missingRole() public { + vm.startPrank(defaultAdmin); + vm.expectRevert( + abi.encodeWithSelector(Permissions.PermissionsUnauthorizedAccount.selector, defaultAdmin, keccak256("role")) + ); + ext.renounceRole(keccak256("role"), defaultAdmin); + vm.stopPrank(); + } + + function test_revert_renounceRole_renounceForOthers() public { + vm.startPrank(defaultAdmin); + ext.grantRole(keccak256("role"), address(0x567)); + assertTrue(ext.hasRole(keccak256("role"), address(0x567))); + + vm.expectRevert( + abi.encodeWithSelector(Permissions.PermissionsInvalidPermission.selector, defaultAdmin, address(0x567)) + ); + ext.renounceRole(keccak256("role"), address(0x567)); + vm.stopPrank(); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `onlyRole` modifier + //////////////////////////////////////////////////////////////*/ + + function test_modifier_onlyRole() public { + vm.startPrank(address(0x345)); + vm.expectRevert( + abi.encodeWithSelector( + Permissions.PermissionsUnauthorizedAccount.selector, + address(0x345), + ext.DEFAULT_ADMIN_ROLE() + ) + ); + ext.checkModifier(); + } +} diff --git a/src/test/sdk/extension/PermissionsEnumerable.t.sol b/src/test/sdk/extension/PermissionsEnumerable.t.sol new file mode 100644 index 000000000..5a20187d4 --- /dev/null +++ b/src/test/sdk/extension/PermissionsEnumerable.t.sol @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import { PermissionsEnumerable, Strings } from "contracts/extension/PermissionsEnumerable.sol"; + +contract MyPermissionsEnumerable is PermissionsEnumerable { + constructor() { + _setupRole(DEFAULT_ADMIN_ROLE, msg.sender); + } +} + +contract ExtensionPermissionsEnumerable is DSTest, Test { + MyPermissionsEnumerable internal ext; + + address defaultAdmin; + + function setUp() public { + defaultAdmin = address(0x123); + + vm.prank(defaultAdmin); + ext = new MyPermissionsEnumerable(); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `grantRole` + //////////////////////////////////////////////////////////////*/ + + function test_state_grantRole() public { + bytes32 role1 = keccak256("ROLE_1"); + + address[] memory members = new address[](3); + + members[0] = address(0); + members[1] = address(1); + members[2] = address(2); + + vm.startPrank(defaultAdmin); + + ext.grantRole(role1, members[0]); + assertEq(1, ext.getRoleMemberCount(role1)); + + ext.grantRole(role1, members[1]); + assertEq(2, ext.getRoleMemberCount(role1)); + + ext.grantRole(role1, members[2]); + assertEq(3, ext.getRoleMemberCount(role1)); + + for (uint256 i = 0; i < members.length; i++) { + assertEq(members[i], ext.getRoleMember(role1, i)); + } + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `revokeRole` + //////////////////////////////////////////////////////////////*/ + + function test_state_revokeRole() public { + bytes32 role1 = keccak256("ROLE_1"); + + address[] memory members = new address[](3); + + members[0] = address(0); + members[1] = address(1); + members[2] = address(2); + + vm.startPrank(defaultAdmin); + + ext.grantRole(role1, members[0]); + assertEq(1, ext.getRoleMemberCount(role1)); + + ext.grantRole(role1, members[1]); + assertEq(2, ext.getRoleMemberCount(role1)); + + ext.grantRole(role1, members[2]); + assertEq(3, ext.getRoleMemberCount(role1)); + + for (uint256 i = 0; i < members.length; i++) { + assertEq(members[i], ext.getRoleMember(role1, i)); + } + + // revoke roles, and check updated list of members + ext.revokeRole(role1, members[1]); + assertEq(2, ext.getRoleMemberCount(role1)); + assertEq(members[2], ext.getRoleMember(role1, 1)); + + ext.revokeRole(role1, members[0]); + assertEq(1, ext.getRoleMemberCount(role1)); + assertEq(members[2], ext.getRoleMember(role1, 0)); + + // re-grant roles, and check updated list of members + ext.grantRole(role1, members[0]); + assertEq(2, ext.getRoleMemberCount(role1)); + assertEq(members[2], ext.getRoleMember(role1, 0)); + assertEq(members[0], ext.getRoleMember(role1, 1)); + + ext.grantRole(role1, members[1]); + assertEq(3, ext.getRoleMemberCount(role1)); + assertEq(members[2], ext.getRoleMember(role1, 0)); + assertEq(members[0], ext.getRoleMember(role1, 1)); + assertEq(members[1], ext.getRoleMember(role1, 2)); + } +} diff --git a/src/test/sdk/extension/PlatformFee.t.sol b/src/test/sdk/extension/PlatformFee.t.sol new file mode 100644 index 000000000..9b5fd0128 --- /dev/null +++ b/src/test/sdk/extension/PlatformFee.t.sol @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import { PlatformFee } from "contracts/extension/PlatformFee.sol"; + +contract MyPlatformFee is PlatformFee { + bool condition; + + function setCondition(bool _condition) external { + condition = _condition; + } + + function _canSetPlatformFeeInfo() internal view override returns (bool) { + return condition; + } +} + +contract ExtensionPlatformFee is DSTest, Test { + MyPlatformFee internal ext; + event PlatformFeeInfoUpdated(address indexed platformFeeRecipient, uint256 platformFeeBps); + + function setUp() public { + ext = new MyPlatformFee(); + } + + function test_state_setPlatformFeeInfo() public { + ext.setCondition(true); + + address _platformFeeRecipient = address(0x123); + uint256 _platformFeeBps = 1000; + ext.setPlatformFeeInfo(_platformFeeRecipient, _platformFeeBps); + + (address recipient, uint16 bps) = ext.getPlatformFeeInfo(); + assertEq(_platformFeeRecipient, recipient); + assertEq(_platformFeeBps, bps); + } + + function test_revert_setPlatformFeeInfo_ExceedsMaxBps() public { + ext.setCondition(true); + + address _platformFeeRecipient = address(0x123); + uint256 _platformFeeBps = 10001; + + vm.expectRevert( + abi.encodeWithSelector(PlatformFee.PlatformFeeExceededMaxFeeBps.selector, 10_000, _platformFeeBps) + ); + ext.setPlatformFeeInfo(_platformFeeRecipient, _platformFeeBps); + } + + function test_revert_setPlatformFeeInfo_NotAuthorized() public { + vm.expectRevert(abi.encodeWithSelector(PlatformFee.PlatformFeeUnauthorized.selector)); + ext.setPlatformFeeInfo(address(1), 1000); + } + + function test_event_platformFeeInfo() public { + ext.setCondition(true); + + address _platformFeeRecipient = address(0x123); + uint256 _platformFeeBps = 1000; + + vm.expectEmit(true, true, true, true); + emit PlatformFeeInfoUpdated(_platformFeeRecipient, _platformFeeBps); + + ext.setPlatformFeeInfo(_platformFeeRecipient, _platformFeeBps); + } +} diff --git a/src/test/sdk/extension/PrimarySale.t.sol b/src/test/sdk/extension/PrimarySale.t.sol new file mode 100644 index 000000000..acb30f37e --- /dev/null +++ b/src/test/sdk/extension/PrimarySale.t.sol @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import { PrimarySale } from "contracts/extension/PrimarySale.sol"; + +contract MyPrimarySale is PrimarySale { + bool condition; + + function setCondition(bool _condition) external { + condition = _condition; + } + + function _canSetPrimarySaleRecipient() internal view override returns (bool) { + return condition; + } +} + +contract ExtensionPrimarySale is DSTest, Test { + MyPrimarySale internal ext; + event PrimarySaleRecipientUpdated(address indexed recipient); + + function setUp() public { + ext = new MyPrimarySale(); + } + + function test_state_setPrimarySaleRecipient() public { + ext.setCondition(true); + + address _primarySaleRecipient = address(0x123); + ext.setPrimarySaleRecipient(_primarySaleRecipient); + + address recipient = ext.primarySaleRecipient(); + assertEq(recipient, _primarySaleRecipient); + } + + function test_revert_setPrimarySaleRecipient_NotAuthorized() public { + address _primarySaleRecipient = address(0x123); + + vm.expectRevert(abi.encodeWithSelector(PrimarySale.PrimarySaleUnauthorized.selector)); + ext.setPrimarySaleRecipient(_primarySaleRecipient); + } + + function test_event_setPrimarySaleRecipient() public { + ext.setCondition(true); + + address _primarySaleRecipient = address(0x123); + + vm.expectEmit(true, true, true, true); + emit PrimarySaleRecipientUpdated(_primarySaleRecipient); + + ext.setPrimarySaleRecipient(_primarySaleRecipient); + } +} diff --git a/src/test/sdk/extension/Royalty.t.sol b/src/test/sdk/extension/Royalty.t.sol new file mode 100644 index 000000000..1c3a888a7 --- /dev/null +++ b/src/test/sdk/extension/Royalty.t.sol @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import { Royalty } from "contracts/extension/Royalty.sol"; + +contract MyRoyalty is Royalty { + bool condition; + + function setCondition(bool _condition) external { + condition = _condition; + } + + function _canSetRoyaltyInfo() internal view override returns (bool) { + return condition; + } + + function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) { + return interfaceId == 0x01ffc9a7; + } +} + +contract ExtensionRoyaltyTest is DSTest, Test { + MyRoyalty internal ext; + event DefaultRoyalty(address indexed newRoyaltyRecipient, uint256 newRoyaltyBps); + event RoyaltyForToken(uint256 indexed tokenId, address indexed royaltyRecipient, uint256 royaltyBps); + + function setUp() public { + ext = new MyRoyalty(); + } + + function test_state_setDefaultRoyaltyInfo() public { + ext.setCondition(true); + + address _royaltyRecipient = address(0x123); + uint256 _royaltyBps = 1000; + ext.setDefaultRoyaltyInfo(_royaltyRecipient, _royaltyBps); + + (address royaltyRecipient, uint256 royaltyBps) = ext.getDefaultRoyaltyInfo(); + assertEq(royaltyRecipient, _royaltyRecipient); + assertEq(royaltyBps, _royaltyBps); + + (address receiver, uint256 royaltyAmount) = ext.royaltyInfo(0, 100); + assertEq(receiver, _royaltyRecipient); + assertEq(royaltyAmount, (100 * 1000) / 10_000); + } + + function test_revert_setDefaultRoyaltyInfo_ExceedsMaxBps() public { + ext.setCondition(true); + + address _royaltyRecipient = address(0x123); + uint256 _royaltyBps = 10001; + + vm.expectRevert(abi.encodeWithSelector(Royalty.RoyaltyExceededMaxFeeBps.selector, 10_000, _royaltyBps)); + ext.setDefaultRoyaltyInfo(_royaltyRecipient, _royaltyBps); + } + + function test_state_setRoyaltyInfoForToken() public { + ext.setCondition(true); + + uint256 _tokenId = 1; + address _recipient = address(0x123); + uint256 _bps = 1000; + ext.setRoyaltyInfoForToken(_tokenId, _recipient, _bps); + + (address receiver, uint256 royaltyAmount) = ext.royaltyInfo(_tokenId, 100); + assertEq(receiver, _recipient); + assertEq(royaltyAmount, (100 * 1000) / 10_000); + } + + function test_revert_setRoyaltyInfo_NotAuthorized() public { + vm.expectRevert(abi.encodeWithSelector(Royalty.RoyaltyUnauthorized.selector)); + ext.setRoyaltyInfoForToken(0, address(1), 1000); + } + + function test_revert_setRoyaltyInfoForToken_ExceedsMaxBps() public { + ext.setCondition(true); + + uint256 _tokenId = 1; + address _recipient = address(0x123); + uint256 _bps = 10001; + + vm.expectRevert(abi.encodeWithSelector(Royalty.RoyaltyExceededMaxFeeBps.selector, 10_000, _bps)); + ext.setRoyaltyInfoForToken(_tokenId, _recipient, _bps); + } + + function test_event_defaultRoyalty() public { + ext.setCondition(true); + + address _royaltyRecipient = address(0x123); + uint256 _royaltyBps = 1000; + + vm.expectEmit(true, true, true, true); + emit DefaultRoyalty(_royaltyRecipient, _royaltyBps); + + ext.setDefaultRoyaltyInfo(_royaltyRecipient, _royaltyBps); + } + + function test_event_royaltyForToken() public { + ext.setCondition(true); + + uint256 _tokenId = 1; + address _recipient = address(0x123); + uint256 _bps = 1000; + + vm.expectEmit(true, true, true, true); + emit RoyaltyForToken(_tokenId, _recipient, _bps); + + ext.setRoyaltyInfoForToken(_tokenId, _recipient, _bps); + } +} diff --git a/src/test/sdk/extension/SignatureMintERC1155.t.sol b/src/test/sdk/extension/SignatureMintERC1155.t.sol new file mode 100644 index 000000000..9de73c188 --- /dev/null +++ b/src/test/sdk/extension/SignatureMintERC1155.t.sol @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import { SignatureMintERC1155 } from "contracts/extension/SignatureMintERC1155.sol"; + +contract MySigMint1155 is SignatureMintERC1155 { + bool condition; + + function setCondition(bool _condition) external { + condition = _condition; + } + + function _canSignMintRequest(address) internal view override returns (bool) { + return condition; + } + + function mintWithSignature( + MintRequest calldata req, + bytes calldata signature + ) external payable returns (address signer) { + if (!_canSignMintRequest(msg.sender)) { + revert("not authorized"); + } + + signer = _processRequest(req, signature); + } +} + +contract ExtensionSignatureMintERC1155 is DSTest, Test { + MySigMint1155 internal ext; + + uint256 public privateKey = 1234; + address public signer; + + bytes32 internal typehashMintRequest; + bytes32 internal nameHash; + bytes32 internal versionHash; + bytes32 internal typehashEip712; + bytes32 internal domainSeparator; + + MySigMint1155.MintRequest _mintrequest; + bytes _signature; + + function setUp() public { + ext = new MySigMint1155(); + + signer = vm.addr(privateKey); + + typehashMintRequest = keccak256( + "MintRequest(address to,address royaltyRecipient,uint256 royaltyBps,address primarySaleRecipient,uint256 tokenId,string uri,uint256 quantity,uint256 pricePerToken,address currency,uint128 validityStartTimestamp,uint128 validityEndTimestamp,bytes32 uid)" + ); + nameHash = keccak256(bytes("SignatureMintERC1155")); + versionHash = keccak256(bytes("1")); + typehashEip712 = keccak256( + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" + ); + domainSeparator = keccak256(abi.encode(typehashEip712, nameHash, versionHash, block.chainid, address(ext))); + + _mintrequest.to = address(1); + _mintrequest.royaltyRecipient = address(2); + _mintrequest.royaltyBps = 0; + _mintrequest.primarySaleRecipient = address(2); + _mintrequest.tokenId = 0; + _mintrequest.uri = "ipfs://"; + _mintrequest.quantity = 1; + _mintrequest.pricePerToken = 1; + _mintrequest.currency = address(0x111); + _mintrequest.validityStartTimestamp = 1000; + _mintrequest.validityEndTimestamp = 2000; + _mintrequest.uid = bytes32(0); + + _signature = signMintRequest(_mintrequest, privateKey); + } + + function signMintRequest( + MySigMint1155.MintRequest memory _request, + uint256 _privateKey + ) internal view returns (bytes memory) { + bytes memory encodedRequest = bytes.concat( + abi.encode( + typehashMintRequest, + _request.to, + _request.royaltyRecipient, + _request.royaltyBps, + _request.primarySaleRecipient, + _request.tokenId, + keccak256(bytes(_request.uri)) + ), + abi.encode( + _request.quantity, + _request.pricePerToken, + _request.currency, + _request.validityStartTimestamp, + _request.validityEndTimestamp, + _request.uid + ) + ); + bytes32 structHash = keccak256(encodedRequest); + bytes32 typedDataHash = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(_privateKey, typedDataHash); + bytes memory sig = abi.encodePacked(r, s, v); + + return sig; + } + + function test_state_mintWithSignature() public { + vm.warp(1000); + ext.setCondition(true); + vm.prank(signer); + address recoveredSigner = ext.mintWithSignature(_mintrequest, _signature); + + assertEq(signer, recoveredSigner); + } + + function test_revert_mintWithSignature_NotAuthorized() public { + vm.expectRevert("not authorized"); + ext.mintWithSignature(_mintrequest, _signature); + } + + function test_revert_mintWithSignature_InvalidReq() public { + vm.warp(1000); + ext.setCondition(true); + + vm.prank(signer); + ext.mintWithSignature(_mintrequest, _signature); + + vm.expectRevert("Invalid request"); + ext.mintWithSignature(_mintrequest, _signature); + } + + function test_revert_mintWithSignature_RequestExpired() public { + vm.warp(3000); + ext.setCondition(true); + + vm.prank(signer); + vm.expectRevert("Request expired"); + ext.mintWithSignature(_mintrequest, _signature); + } +} diff --git a/src/test/sdk/extension/SignatureMintERC20.t.sol b/src/test/sdk/extension/SignatureMintERC20.t.sol new file mode 100644 index 000000000..452c28a29 --- /dev/null +++ b/src/test/sdk/extension/SignatureMintERC20.t.sol @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import { SignatureMintERC20 } from "contracts/extension/SignatureMintERC20.sol"; + +contract MySigMint20 is SignatureMintERC20 { + bool condition; + + function setCondition(bool _condition) external { + condition = _condition; + } + + function _canSignMintRequest(address) internal view override returns (bool) { + return condition; + } + + function mintWithSignature( + MintRequest calldata req, + bytes calldata signature + ) external payable returns (address signer) { + if (!_canSignMintRequest(msg.sender)) { + revert("not authorized"); + } + + signer = _processRequest(req, signature); + } +} + +contract ExtensionSignatureMintERC20 is DSTest, Test { + MySigMint20 internal ext; + + uint256 public privateKey = 1234; + address public signer; + + bytes32 internal typehashMintRequest; + bytes32 internal nameHash; + bytes32 internal versionHash; + bytes32 internal typehashEip712; + bytes32 internal domainSeparator; + + MySigMint20.MintRequest _mintrequest; + bytes _signature; + + function setUp() public { + ext = new MySigMint20(); + + signer = vm.addr(privateKey); + + typehashMintRequest = keccak256( + "MintRequest(address to,address primarySaleRecipient,uint256 quantity,uint256 price,address currency,uint128 validityStartTimestamp,uint128 validityEndTimestamp,bytes32 uid)" + ); + nameHash = keccak256(bytes("SignatureMintERC20")); + versionHash = keccak256(bytes("1")); + typehashEip712 = keccak256( + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" + ); + domainSeparator = keccak256(abi.encode(typehashEip712, nameHash, versionHash, block.chainid, address(ext))); + + _mintrequest.to = address(1); + _mintrequest.primarySaleRecipient = address(2); + _mintrequest.quantity = 1; + _mintrequest.price = 1; + _mintrequest.currency = address(0x111); + _mintrequest.validityStartTimestamp = 1000; + _mintrequest.validityEndTimestamp = 2000; + _mintrequest.uid = bytes32(0); + + _signature = signMintRequest(_mintrequest, privateKey); + } + + function signMintRequest( + MySigMint20.MintRequest memory _request, + uint256 _privateKey + ) internal view returns (bytes memory) { + bytes memory encodedRequest = abi.encode( + typehashMintRequest, + _request.to, + _request.primarySaleRecipient, + _request.quantity, + _request.price, + _request.currency, + _request.validityStartTimestamp, + _request.validityEndTimestamp, + _request.uid + ); + bytes32 structHash = keccak256(encodedRequest); + bytes32 typedDataHash = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(_privateKey, typedDataHash); + bytes memory sig = abi.encodePacked(r, s, v); + + return sig; + } + + function test_state_mintWithSignature() public { + vm.warp(1000); + ext.setCondition(true); + vm.prank(signer); + address recoveredSigner = ext.mintWithSignature(_mintrequest, _signature); + + assertEq(signer, recoveredSigner); + } + + function test_revert_mintWithSignature_NotAuthorized() public { + vm.expectRevert("not authorized"); + ext.mintWithSignature(_mintrequest, _signature); + } + + function test_revert_mintWithSignature_InvalidReq() public { + vm.warp(1000); + ext.setCondition(true); + + vm.prank(signer); + ext.mintWithSignature(_mintrequest, _signature); + + vm.expectRevert("Invalid request"); + ext.mintWithSignature(_mintrequest, _signature); + } + + function test_revert_mintWithSignature_RequestExpired() public { + vm.warp(3000); + ext.setCondition(true); + + vm.prank(signer); + vm.expectRevert("Request expired"); + ext.mintWithSignature(_mintrequest, _signature); + } +} diff --git a/src/test/sdk/extension/SignatureMintERC721.t.sol b/src/test/sdk/extension/SignatureMintERC721.t.sol new file mode 100644 index 000000000..85c0e7db1 --- /dev/null +++ b/src/test/sdk/extension/SignatureMintERC721.t.sol @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import { SignatureMintERC721 } from "contracts/extension/SignatureMintERC721.sol"; + +contract MySigMint721 is SignatureMintERC721 { + bool condition; + + function setCondition(bool _condition) external { + condition = _condition; + } + + function _canSignMintRequest(address) internal view override returns (bool) { + return condition; + } + + function mintWithSignature( + MintRequest calldata req, + bytes calldata signature + ) external payable returns (address signer) { + if (!_canSignMintRequest(msg.sender)) { + revert("not authorized"); + } + + signer = _processRequest(req, signature); + } +} + +contract ExtensionSignatureMintERC721 is DSTest, Test { + MySigMint721 internal ext; + + uint256 public privateKey = 1234; + address public signer; + + bytes32 internal typehashMintRequest; + bytes32 internal nameHash; + bytes32 internal versionHash; + bytes32 internal typehashEip712; + bytes32 internal domainSeparator; + + MySigMint721.MintRequest _mintrequest; + bytes _signature; + + function setUp() public { + ext = new MySigMint721(); + + signer = vm.addr(privateKey); + + typehashMintRequest = keccak256( + "MintRequest(address to,address royaltyRecipient,uint256 royaltyBps,address primarySaleRecipient,string uri,uint256 quantity,uint256 pricePerToken,address currency,uint128 validityStartTimestamp,uint128 validityEndTimestamp,bytes32 uid)" + ); + nameHash = keccak256(bytes("SignatureMintERC721")); + versionHash = keccak256(bytes("1")); + typehashEip712 = keccak256( + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" + ); + domainSeparator = keccak256(abi.encode(typehashEip712, nameHash, versionHash, block.chainid, address(ext))); + + _mintrequest.to = address(1); + _mintrequest.royaltyRecipient = address(2); + _mintrequest.royaltyBps = 0; + _mintrequest.primarySaleRecipient = address(2); + _mintrequest.uri = "ipfs://"; + _mintrequest.quantity = 1; + _mintrequest.pricePerToken = 1; + _mintrequest.currency = address(0x111); + _mintrequest.validityStartTimestamp = 1000; + _mintrequest.validityEndTimestamp = 2000; + _mintrequest.uid = bytes32(0); + + _signature = signMintRequest(_mintrequest, privateKey); + } + + function signMintRequest( + MySigMint721.MintRequest memory _request, + uint256 _privateKey + ) internal view returns (bytes memory) { + bytes memory encodedRequest = abi.encode( + typehashMintRequest, + _request.to, + _request.royaltyRecipient, + _request.royaltyBps, + _request.primarySaleRecipient, + keccak256(bytes(_request.uri)), + _request.quantity, + _request.pricePerToken, + _request.currency, + _request.validityStartTimestamp, + _request.validityEndTimestamp, + _request.uid + ); + bytes32 structHash = keccak256(encodedRequest); + bytes32 typedDataHash = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(_privateKey, typedDataHash); + bytes memory sig = abi.encodePacked(r, s, v); + + return sig; + } + + function test_state_mintWithSignature() public { + vm.warp(1000); + ext.setCondition(true); + vm.prank(signer); + address recoveredSigner = ext.mintWithSignature(_mintrequest, _signature); + + assertEq(signer, recoveredSigner); + } + + function test_revert_mintWithSignature_NotAuthorized() public { + vm.expectRevert("not authorized"); + ext.mintWithSignature(_mintrequest, _signature); + } + + function test_revert_mintWithSignature_InvalidReq() public { + vm.warp(1000); + ext.setCondition(true); + + vm.prank(signer); + ext.mintWithSignature(_mintrequest, _signature); + + vm.expectRevert(abi.encodeWithSelector(SignatureMintERC721.SignatureMintInvalidSigner.selector)); + ext.mintWithSignature(_mintrequest, _signature); + } + + function test_revert_mintWithSignature_RequestExpired() public { + vm.warp(3000); + ext.setCondition(true); + + vm.prank(signer); + vm.expectRevert( + abi.encodeWithSelector( + SignatureMintERC721.SignatureMintInvalidTime.selector, + _mintrequest.validityStartTimestamp, + _mintrequest.validityEndTimestamp, + block.timestamp + ) + ); + ext.mintWithSignature(_mintrequest, _signature); + } +} diff --git a/src/test/sdk/extension/StakingExtension.t.sol b/src/test/sdk/extension/StakingExtension.t.sol new file mode 100644 index 000000000..2805ba210 --- /dev/null +++ b/src/test/sdk/extension/StakingExtension.t.sol @@ -0,0 +1,499 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import { Staking721 } from "contracts/extension/Staking721.sol"; +import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import "contracts/eip/interface/IERC721.sol"; +import "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol"; + +import { MockERC721 } from "../../mocks/MockERC721.sol"; + +contract MyStakingContract is ERC20, Staking721, IERC721Receiver { + bool condition; + + constructor( + string memory _name, + string memory _symbol, + address _nftCollection, + uint256 _timeUnit, + uint256 _rewardsPerUnitTime + ) ERC20(_name, _symbol) Staking721(_nftCollection) { + condition = true; + _setStakingCondition(_timeUnit, _rewardsPerUnitTime); + } + + /// @notice View total rewards available in the staking contract. + function getRewardTokenBalance() external view override returns (uint256) {} + + /*/////////////////////////////////////////////////////////////// + ERC 165 / 721 logic + //////////////////////////////////////////////////////////////*/ + + function onERC721Received(address, address, uint256, bytes calldata) external view override returns (bytes4) { + require(isStaking == 2, "Direct transfer"); + return this.onERC721Received.selector; + } + + function supportsInterface(bytes4 interfaceId) public view virtual returns (bool) { + return interfaceId == type(IERC721Receiver).interfaceId; + } + + function setCondition(bool _condition) external { + condition = _condition; + } + + function _canSetStakeConditions() internal view override returns (bool) { + return condition; + } + + function _mintRewards(address _staker, uint256 _rewards) internal override { + _mint(_staker, _rewards); + } +} + +contract StakingExtensionTest is DSTest, Test { + MyStakingContract internal ext; + MockERC721 public erc721; + + uint256 timeUnit; + uint256 rewardsPerUnitTime; + + address deployer; + address stakerOne; + address stakerTwo; + + function setUp() public { + erc721 = new MockERC721(); + timeUnit = 1 hours; + rewardsPerUnitTime = 100; + + deployer = address(0x123); + stakerOne = address(0x345); + stakerTwo = address(0x567); + + erc721.mint(stakerOne, 5); // mint token id 0 to 4 + erc721.mint(stakerTwo, 5); // mint token id 5 to 9 + + vm.prank(deployer); + ext = new MyStakingContract("Test Staking Contract", "TSC", address(erc721), timeUnit, rewardsPerUnitTime); + + // set approvals + vm.prank(stakerOne); + erc721.setApprovalForAll(address(ext), true); + + vm.prank(stakerTwo); + erc721.setApprovalForAll(address(ext), true); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: Stake + //////////////////////////////////////////////////////////////*/ + + function test_state_stake() public { + //================ first staker ====================== + vm.warp(1); + uint256[] memory _tokenIdsOne = new uint256[](3); + _tokenIdsOne[0] = 0; + _tokenIdsOne[1] = 1; + _tokenIdsOne[2] = 2; + + // stake 3 tokens + vm.prank(stakerOne); + ext.stake(_tokenIdsOne); + uint256 timeOfLastUpdate_one = block.timestamp; + + // check balances/ownership of staked tokens + for (uint256 i = 0; i < _tokenIdsOne.length; i++) { + assertEq(erc721.ownerOf(_tokenIdsOne[i]), address(ext)); + assertEq(ext.stakerAddress(_tokenIdsOne[i]), stakerOne); + } + assertEq(erc721.balanceOf(stakerOne), 2); + assertEq(erc721.balanceOf(address(ext)), _tokenIdsOne.length); + + // check available rewards right after staking + (uint256[] memory _amountStaked, uint256 _availableRewards) = ext.getStakeInfo(stakerOne); + + assertEq(_amountStaked.length, _tokenIdsOne.length); + assertEq(_availableRewards, 0); + + //=================== warp timestamp to calculate rewards + vm.roll(100); + vm.warp(1000); + + // check available rewards after warp + (, _availableRewards) = ext.getStakeInfo(stakerOne); + + assertEq( + _availableRewards, + ((((block.timestamp - timeOfLastUpdate_one) * _tokenIdsOne.length) * rewardsPerUnitTime) / timeUnit) + ); + + //================ second staker ====================== + vm.roll(200); + vm.warp(2000); + uint256[] memory _tokenIdsTwo = new uint256[](2); + _tokenIdsTwo[0] = 5; + _tokenIdsTwo[1] = 6; + + // stake 2 tokens + vm.prank(stakerTwo); + ext.stake(_tokenIdsTwo); + uint256 timeOfLastUpdate_two = block.timestamp; + + // check balances/ownership of staked tokens + for (uint256 i = 0; i < _tokenIdsTwo.length; i++) { + assertEq(erc721.ownerOf(_tokenIdsTwo[i]), address(ext)); + assertEq(ext.stakerAddress(_tokenIdsTwo[i]), stakerTwo); + } + assertEq(erc721.balanceOf(stakerTwo), 3); + assertEq(erc721.balanceOf(address(ext)), _tokenIdsTwo.length + _tokenIdsOne.length); + + // check available rewards right after staking + (_amountStaked, _availableRewards) = ext.getStakeInfo(stakerTwo); + + assertEq(_amountStaked.length, _tokenIdsTwo.length); + assertEq(_availableRewards, 0); + + //=================== warp timestamp to calculate rewards + vm.roll(300); + vm.warp(3000); + + // check available rewards for stakerOne + (, _availableRewards) = ext.getStakeInfo(stakerOne); + + assertEq( + _availableRewards, + ((((block.timestamp - timeOfLastUpdate_one) * _tokenIdsOne.length) * rewardsPerUnitTime) / timeUnit) + ); + + // check available rewards for stakerTwo + (, _availableRewards) = ext.getStakeInfo(stakerTwo); + + assertEq( + _availableRewards, + ((((block.timestamp - timeOfLastUpdate_two) * _tokenIdsTwo.length) * rewardsPerUnitTime) / timeUnit) + ); + } + + function test_revert_stake_stakingZeroTokens() public { + // stake 0 tokens + uint256[] memory _tokenIds; + + vm.prank(stakerOne); + vm.expectRevert("Staking 0 tokens"); + ext.stake(_tokenIds); + } + + function test_revert_stake_notStaker() public { + // stake unowned tokens + uint256[] memory _tokenIds = new uint256[](1); + _tokenIds[0] = 6; + + vm.prank(stakerOne); + vm.expectRevert("ERC721: transfer from incorrect owner"); + ext.stake(_tokenIds); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: claimRewards + //////////////////////////////////////////////////////////////*/ + + function test_state_claimRewards() public { + //================ first staker ====================== + vm.warp(1); + uint256[] memory _tokenIdsOne = new uint256[](3); + _tokenIdsOne[0] = 0; + _tokenIdsOne[1] = 1; + _tokenIdsOne[2] = 2; + + // stake 3 tokens + vm.prank(stakerOne); + ext.stake(_tokenIdsOne); + uint256 timeOfLastUpdate_one = block.timestamp; + + //=================== warp timestamp to claim rewards + vm.roll(100); + vm.warp(1000); + + vm.prank(stakerOne); + ext.claimRewards(); + + // check reward balances + assertEq( + ext.balanceOf(stakerOne), + ((((block.timestamp - timeOfLastUpdate_one) * _tokenIdsOne.length) * rewardsPerUnitTime) / timeUnit) + ); + + // check available rewards after claiming + (uint256[] memory _amountStaked, uint256 _availableRewards) = ext.getStakeInfo(stakerOne); + + assertEq(_amountStaked.length, _tokenIdsOne.length); + assertEq(_availableRewards, 0); + } + + function test_revert_claimRewards_noRewards() public { + vm.warp(1); + uint256[] memory _tokenIdsOne = new uint256[](3); + _tokenIdsOne[0] = 0; + _tokenIdsOne[1] = 1; + _tokenIdsOne[2] = 2; + + // stake 3 tokens + vm.prank(stakerOne); + ext.stake(_tokenIdsOne); + + //=================== try to claim rewards in same block + + vm.prank(stakerOne); + vm.expectRevert("No rewards"); + ext.claimRewards(); + + //======= withdraw tokens and claim rewards + vm.roll(100); + vm.warp(1000); + + vm.prank(stakerOne); + ext.withdraw(_tokenIdsOne); + vm.prank(stakerOne); + ext.claimRewards(); + + //===== try to claim rewards again + vm.roll(200); + vm.warp(2000); + vm.prank(stakerOne); + vm.expectRevert("No rewards"); + ext.claimRewards(); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: stake conditions + //////////////////////////////////////////////////////////////*/ + + function test_state_setRewardsPerUnitTime() public { + // check current value + assertEq(rewardsPerUnitTime, ext.getRewardsPerUnitTime()); + + // set new value and check + uint256 newRewardsPerUnitTime = 50; + ext.setRewardsPerUnitTime(newRewardsPerUnitTime); + assertEq(newRewardsPerUnitTime, ext.getRewardsPerUnitTime()); + + //================ stake tokens + vm.warp(1); + uint256[] memory _tokenIdsOne = new uint256[](3); + _tokenIdsOne[0] = 0; + _tokenIdsOne[1] = 1; + _tokenIdsOne[2] = 2; + + // stake 3 tokens + vm.prank(stakerOne); + ext.stake(_tokenIdsOne); + uint256 timeOfLastUpdate = block.timestamp; + + //=================== warp timestamp and again set rewardsPerUnitTime + vm.roll(100); + vm.warp(1000); + + ext.setRewardsPerUnitTime(200); + assertEq(200, ext.getRewardsPerUnitTime()); + uint256 newTimeOfLastUpdate = block.timestamp; + + // check available rewards -- should use previous value for rewardsPerUnitTime for calculation + (, uint256 _availableRewards) = ext.getStakeInfo(stakerOne); + + assertEq( + _availableRewards, + ((((block.timestamp - timeOfLastUpdate) * _tokenIdsOne.length) * newRewardsPerUnitTime) / timeUnit) + ); + + //====== check rewards after some time + vm.roll(300); + vm.warp(3000); + + (, uint256 _newRewards) = ext.getStakeInfo(stakerOne); + + assertEq( + _newRewards, + _availableRewards + ((((block.timestamp - newTimeOfLastUpdate) * _tokenIdsOne.length) * 200) / timeUnit) + ); + } + + function test_revert_setRewardsPerUnitTime_notAuthorized() public { + ext.setCondition(false); + + vm.expectRevert("Not authorized"); + ext.setRewardsPerUnitTime(1); + } + + function test_state_setTimeUnit() public { + // check current value + assertEq(timeUnit, ext.getTimeUnit()); + + // set new value and check + uint256 newTimeUnit = 1 minutes; + ext.setTimeUnit(newTimeUnit); + assertEq(newTimeUnit, ext.getTimeUnit()); + + //================ stake tokens + vm.warp(1); + uint256[] memory _tokenIdsOne = new uint256[](3); + _tokenIdsOne[0] = 0; + _tokenIdsOne[1] = 1; + _tokenIdsOne[2] = 2; + + // stake 3 tokens + vm.prank(stakerOne); + ext.stake(_tokenIdsOne); + uint256 timeOfLastUpdate = block.timestamp; + + //=================== warp timestamp and again set rewardsPerUnitTime + vm.roll(100); + vm.warp(1000); + + ext.setTimeUnit(1 seconds); + assertEq(1 seconds, ext.getTimeUnit()); + uint256 newTimeOfLastUpdate = block.timestamp; + + // check available rewards -- should use previous value for rewardsPerUnitTime for calculation + (, uint256 _availableRewards) = ext.getStakeInfo(stakerOne); + + assertEq( + _availableRewards, + ((((block.timestamp - timeOfLastUpdate) * _tokenIdsOne.length) * rewardsPerUnitTime) / newTimeUnit) + ); + + //====== check rewards after some time + vm.roll(300); + vm.warp(3000); + + (, uint256 _newRewards) = ext.getStakeInfo(stakerOne); + + assertEq( + _newRewards, + _availableRewards + + ((((block.timestamp - newTimeOfLastUpdate) * _tokenIdsOne.length) * rewardsPerUnitTime) / (1 seconds)) + ); + } + + function test_revert_setTimeUnit_notAuthorized() public { + ext.setCondition(false); + + vm.expectRevert("Not authorized"); + ext.setTimeUnit(1); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: withdraw + //////////////////////////////////////////////////////////////*/ + + function test_state_withdraw() public { + //================ first staker ====================== + vm.warp(1); + uint256[] memory _tokenIdsOne = new uint256[](3); + _tokenIdsOne[0] = 0; + _tokenIdsOne[1] = 1; + _tokenIdsOne[2] = 2; + + // stake 3 tokens + vm.prank(stakerOne); + ext.stake(_tokenIdsOne); + uint256 timeOfLastUpdate = block.timestamp; + + // check balances/ownership of staked tokens + for (uint256 i = 0; i < _tokenIdsOne.length; i++) { + assertEq(erc721.ownerOf(_tokenIdsOne[i]), address(ext)); + assertEq(ext.stakerAddress(_tokenIdsOne[i]), stakerOne); + } + assertEq(erc721.balanceOf(stakerOne), 2); + assertEq(erc721.balanceOf(address(ext)), _tokenIdsOne.length); + + // check available rewards right after staking + (uint256[] memory _amountStaked, uint256 _availableRewards) = ext.getStakeInfo(stakerOne); + + assertEq(_amountStaked.length, _tokenIdsOne.length); + assertEq(_availableRewards, 0); + + //========== warp timestamp before withdraw + vm.roll(100); + vm.warp(1000); + + uint256[] memory _tokensToWithdraw = new uint256[](2); + _tokensToWithdraw[0] = 2; + _tokensToWithdraw[1] = 0; + + vm.prank(stakerOne); + ext.withdraw(_tokensToWithdraw); + + // check balances/ownership after withdraw + for (uint256 i = 0; i < _tokensToWithdraw.length; i++) { + assertEq(erc721.ownerOf(_tokensToWithdraw[i]), stakerOne); + assertEq(ext.stakerAddress(_tokensToWithdraw[i]), address(0)); + } + assertEq(erc721.balanceOf(stakerOne), 4); + assertEq(erc721.balanceOf(address(ext)), 1); + + // check available rewards after withdraw + (, _availableRewards) = ext.getStakeInfo(stakerOne); + assertEq(_availableRewards, ((((block.timestamp - timeOfLastUpdate) * 3) * rewardsPerUnitTime) / timeUnit)); + + uint256 timeOfLastUpdateLatest = block.timestamp; + + // check available rewards some time after withdraw + vm.roll(200); + vm.warp(2000); + + (, _availableRewards) = ext.getStakeInfo(stakerOne); + + assertEq( + _availableRewards, + (((((timeOfLastUpdateLatest - timeOfLastUpdate) * 3)) * rewardsPerUnitTime) / timeUnit) + + (((((block.timestamp - timeOfLastUpdateLatest) * 1)) * rewardsPerUnitTime) / timeUnit) + ); + } + + function test_revert_withdraw_withdrawingZeroTokens() public { + uint256[] memory _tokensToWithdraw; + + vm.expectRevert("Withdrawing 0 tokens"); + ext.withdraw(_tokensToWithdraw); + } + + function test_revert_withdraw_notStaker() public { + // stake tokens + uint256[] memory _tokenIds = new uint256[](2); + _tokenIds[0] = 0; + _tokenIds[1] = 1; + + vm.prank(stakerOne); + ext.stake(_tokenIds); + + // trying to withdraw zero tokens + uint256[] memory _tokensToWithdraw = new uint256[](1); + _tokensToWithdraw[0] = 2; + + vm.prank(stakerOne); + vm.expectRevert("Not staker"); + ext.withdraw(_tokensToWithdraw); + } + + function test_revert_withdraw_withdrawingMoreThanStaked() public { + // stake tokens + uint256[] memory _tokenIds = new uint256[](1); + _tokenIds[0] = 0; + + vm.prank(stakerOne); + ext.stake(_tokenIds); + + // trying to withdraw tokens not staked by caller + uint256[] memory _tokensToWithdraw = new uint256[](2); + _tokensToWithdraw[0] = 0; + _tokensToWithdraw[1] = 1; + + vm.prank(stakerOne); + vm.expectRevert("Withdrawing more than staked"); + ext.withdraw(_tokensToWithdraw); + } +} diff --git a/src/test/sdk/extension/TokenBundle.t.sol b/src/test/sdk/extension/TokenBundle.t.sol new file mode 100644 index 000000000..a357f1b35 --- /dev/null +++ b/src/test/sdk/extension/TokenBundle.t.sol @@ -0,0 +1,322 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import "../../mocks/WETH9.sol"; +import "../../mocks/MockERC20.sol"; +import "../../mocks/MockERC721.sol"; +import "../../mocks/MockERC1155.sol"; + +import { TokenBundle, ITokenBundle } from "contracts/extension/TokenBundle.sol"; + +contract MyTokenBundle is TokenBundle { + function createBundle(Token[] calldata _tokensToBind, uint256 _bundleId) external { + _createBundle(_tokensToBind, _bundleId); + } + + function updateBundle(Token[] calldata _tokensToBind, uint256 _bundleId) external { + _updateBundle(_tokensToBind, _bundleId); + } + + function addTokenInBundle(Token memory _tokenToBind, uint256 _bundleId) external { + _addTokenInBundle(_tokenToBind, _bundleId); + } + + function updateTokenInBundle(Token memory _tokenToBind, uint256 _bundleId, uint256 _index) external { + _updateTokenInBundle(_tokenToBind, _bundleId, _index); + } + + function setUriOfBundle(string calldata _uri, uint256 _bundleId) external { + _setUriOfBundle(_uri, _bundleId); + } + + function deleteBundle(uint256 _bundleId) external { + _deleteBundle(_bundleId); + } +} + +contract ExtensionTokenBundle is DSTest, Test { + MyTokenBundle internal ext; + + MockERC20 public erc20; + MockERC721 public erc721; + MockERC1155 public erc1155; + WETH9 public weth; + + ITokenBundle.Token[] internal bundleContent; + + function setUp() public { + ext = new MyTokenBundle(); + + erc20 = new MockERC20(); + erc721 = new MockERC721(); + erc1155 = new MockERC1155(); + weth = new WETH9(); + + bundleContent.push( + ITokenBundle.Token({ + assetContract: address(erc20), + tokenType: ITokenBundle.TokenType.ERC20, + tokenId: 0, + totalAmount: 10 ether + }) + ); + bundleContent.push( + ITokenBundle.Token({ + assetContract: address(erc721), + tokenType: ITokenBundle.TokenType.ERC721, + tokenId: 0, + totalAmount: 1 + }) + ); + bundleContent.push( + ITokenBundle.Token({ + assetContract: address(erc1155), + tokenType: ITokenBundle.TokenType.ERC1155, + tokenId: 0, + totalAmount: 100 + }) + ); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `createBundle` + //////////////////////////////////////////////////////////////*/ + + function test_state_createBundle() public { + ext.createBundle(bundleContent, 0); + + uint256 tokenCountOfBundle = ext.getTokenCountOfBundle(0); + assertEq(bundleContent.length, tokenCountOfBundle); + + for (uint256 i = 0; i < tokenCountOfBundle; i += 1) { + ITokenBundle.Token memory tokenOfBundle = ext.getTokenOfBundle(0, i); + assertEq(bundleContent[i].assetContract, tokenOfBundle.assetContract); + assertEq(uint256(bundleContent[i].tokenType), uint256(tokenOfBundle.tokenType)); + assertEq(bundleContent[i].tokenId, tokenOfBundle.tokenId); + assertEq(bundleContent[i].totalAmount, tokenOfBundle.totalAmount); + } + } + + function test_revert_createBundle_emptyBundle() public { + ITokenBundle.Token[] memory emptyBundle; + + vm.expectRevert("!Tokens"); + ext.createBundle(emptyBundle, 0); + } + + function test_revert_createBundle_existingBundleId() public { + ext.createBundle(bundleContent, 0); + + vm.expectRevert("id exists"); + ext.createBundle(bundleContent, 0); + } + + function test_revert_createBundle_tokenTypeMismatch() public { + bundleContent.push( + ITokenBundle.Token({ + assetContract: address(erc20), + tokenType: ITokenBundle.TokenType.ERC721, + tokenId: 0, + totalAmount: 0 + }) + ); + + vm.expectRevert("!TokenType"); + ext.createBundle(bundleContent, 0); + + bundleContent.pop(); + bundleContent.push( + ITokenBundle.Token({ + assetContract: address(erc20), + tokenType: ITokenBundle.TokenType.ERC1155, + tokenId: 0, + totalAmount: 0 + }) + ); + + vm.expectRevert("!TokenType"); + ext.createBundle(bundleContent, 0); + + bundleContent.pop(); + bundleContent.push( + ITokenBundle.Token({ + assetContract: address(erc721), + tokenType: ITokenBundle.TokenType.ERC20, + tokenId: 0, + totalAmount: 0 + }) + ); + + vm.expectRevert("!TokenType"); + ext.createBundle(bundleContent, 0); + + bundleContent.pop(); + bundleContent.push( + ITokenBundle.Token({ + assetContract: address(erc721), + tokenType: ITokenBundle.TokenType.ERC1155, + tokenId: 0, + totalAmount: 0 + }) + ); + + vm.expectRevert("!TokenType"); + ext.createBundle(bundleContent, 0); + + bundleContent.pop(); + bundleContent.push( + ITokenBundle.Token({ + assetContract: address(erc1155), + tokenType: ITokenBundle.TokenType.ERC20, + tokenId: 0, + totalAmount: 0 + }) + ); + + vm.expectRevert("!TokenType"); + ext.createBundle(bundleContent, 0); + + bundleContent.pop(); + bundleContent.push( + ITokenBundle.Token({ + assetContract: address(erc1155), + tokenType: ITokenBundle.TokenType.ERC721, + tokenId: 0, + totalAmount: 0 + }) + ); + + vm.expectRevert("!TokenType"); + ext.createBundle(bundleContent, 0); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `updateBundle` + //////////////////////////////////////////////////////////////*/ + + function test_state_updateBundle() public { + ext.createBundle(bundleContent, 0); + + bundleContent.push( + ITokenBundle.Token({ + assetContract: address(erc1155), + tokenType: ITokenBundle.TokenType.ERC1155, + tokenId: 1, + totalAmount: 200 + }) + ); + + ext.updateBundle(bundleContent, 0); + + uint256 tokenCountOfBundle = ext.getTokenCountOfBundle(0); + assertEq(bundleContent.length, tokenCountOfBundle); + + for (uint256 i = 0; i < tokenCountOfBundle; i += 1) { + ITokenBundle.Token memory tokenOfBundle = ext.getTokenOfBundle(0, i); + assertEq(bundleContent[i].assetContract, tokenOfBundle.assetContract); + assertEq(uint256(bundleContent[i].tokenType), uint256(tokenOfBundle.tokenType)); + assertEq(bundleContent[i].tokenId, tokenOfBundle.tokenId); + assertEq(bundleContent[i].totalAmount, tokenOfBundle.totalAmount); + } + + bundleContent.pop(); + bundleContent.pop(); + ext.updateBundle(bundleContent, 0); + + tokenCountOfBundle = ext.getTokenCountOfBundle(0); + assertEq(bundleContent.length, tokenCountOfBundle); + + for (uint256 i = 0; i < tokenCountOfBundle; i += 1) { + ITokenBundle.Token memory tokenOfBundle = ext.getTokenOfBundle(0, i); + assertEq(bundleContent[i].assetContract, tokenOfBundle.assetContract); + assertEq(uint256(bundleContent[i].tokenType), uint256(tokenOfBundle.tokenType)); + assertEq(bundleContent[i].tokenId, tokenOfBundle.tokenId); + assertEq(bundleContent[i].totalAmount, tokenOfBundle.totalAmount); + } + } + + function test_revert_updateBundle_emptyBundle() public { + ext.createBundle(bundleContent, 0); + + ITokenBundle.Token[] memory emptyBundle; + vm.expectRevert("!Tokens"); + ext.updateBundle(emptyBundle, 0); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `addTokenInBundle` + //////////////////////////////////////////////////////////////*/ + + function test_state_addTokenInBundle() public { + ext.createBundle(bundleContent, 0); + + ITokenBundle.Token memory newToken = ITokenBundle.Token({ + assetContract: address(erc1155), + tokenType: ITokenBundle.TokenType.ERC1155, + tokenId: 1, + totalAmount: 200 + }); + + ext.addTokenInBundle(newToken, 0); + + uint256 tokenCountOfBundle = ext.getTokenCountOfBundle(0); + assertEq(bundleContent.length + 1, tokenCountOfBundle); + + for (uint256 i = 0; i < tokenCountOfBundle - 1; i += 1) { + ITokenBundle.Token memory tokenOfBundle_ = ext.getTokenOfBundle(0, i); + assertEq(bundleContent[i].assetContract, tokenOfBundle_.assetContract); + assertEq(uint256(bundleContent[i].tokenType), uint256(tokenOfBundle_.tokenType)); + assertEq(bundleContent[i].tokenId, tokenOfBundle_.tokenId); + assertEq(bundleContent[i].totalAmount, tokenOfBundle_.totalAmount); + } + + ITokenBundle.Token memory tokenOfBundle = ext.getTokenOfBundle(0, tokenCountOfBundle - 1); + assertEq(newToken.assetContract, tokenOfBundle.assetContract); + assertEq(uint256(newToken.tokenType), uint256(tokenOfBundle.tokenType)); + assertEq(newToken.tokenId, tokenOfBundle.tokenId); + assertEq(newToken.totalAmount, tokenOfBundle.totalAmount); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `updateTokenInBundle` + //////////////////////////////////////////////////////////////*/ + + function test_state_updateTokenInBundle() public { + ext.createBundle(bundleContent, 0); + + ITokenBundle.Token memory newToken = ITokenBundle.Token({ + assetContract: address(erc1155), + tokenType: ITokenBundle.TokenType.ERC1155, + tokenId: 1, + totalAmount: 200 + }); + + ext.updateTokenInBundle(newToken, 0, 1); + + uint256 tokenCountOfBundle = ext.getTokenCountOfBundle(0); + assertEq(bundleContent.length, tokenCountOfBundle); + + ITokenBundle.Token memory tokenOfBundle = ext.getTokenOfBundle(0, 1); + assertEq(newToken.assetContract, tokenOfBundle.assetContract); + assertEq(uint256(newToken.tokenType), uint256(tokenOfBundle.tokenType)); + assertEq(newToken.tokenId, tokenOfBundle.tokenId); + assertEq(newToken.totalAmount, tokenOfBundle.totalAmount); + } + + function test_revert_updateTokenInBundle_indexDNE() public { + ext.createBundle(bundleContent, 0); + + ITokenBundle.Token memory newToken = ITokenBundle.Token({ + assetContract: address(erc1155), + tokenType: ITokenBundle.TokenType.ERC1155, + tokenId: 1, + totalAmount: 200 + }); + + vm.expectRevert("index DNE"); + ext.updateTokenInBundle(newToken, 0, 3); + } +} diff --git a/src/test/sdk/extension/TokenStore.t.sol b/src/test/sdk/extension/TokenStore.t.sol new file mode 100644 index 000000000..c60531719 --- /dev/null +++ b/src/test/sdk/extension/TokenStore.t.sol @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import "../../mocks/WETH9.sol"; +import "../../mocks/MockERC20.sol"; +import "../../mocks/MockERC721.sol"; +import "../../mocks/MockERC1155.sol"; +import "../../utils/Wallet.sol"; + +import { TokenStore, TokenBundle, ITokenBundle, CurrencyTransferLib } from "contracts/extension/TokenStore.sol"; + +contract MyTokenStore is TokenStore { + constructor(address _nativeTokenWrapper) TokenStore(_nativeTokenWrapper) {} + + receive() external payable {} + + function storeTokens( + address _tokenOwner, + Token[] calldata _tokens, + string calldata _uriForTokens, + uint256 _idForTokens + ) external { + _storeTokens(_tokenOwner, _tokens, _uriForTokens, _idForTokens); + } + + function releaseTokens(address _recipient, uint256 _idForContent) external { + _releaseTokens(_recipient, _idForContent); + } +} + +contract ExtensionTokenStore is DSTest, Test { + MyTokenStore internal ext; + + MockERC20 public erc20; + MockERC721 public erc721; + MockERC1155 public erc1155; + WETH9 public weth; + + ITokenBundle.Token[] internal bundleContent; + + Wallet internal tokenOwner; + + function setUp() public { + ext = new MyTokenStore(CurrencyTransferLib.NATIVE_TOKEN); + + erc20 = new MockERC20(); + erc721 = new MockERC721(); + erc1155 = new MockERC1155(); + weth = new WETH9(); + + tokenOwner = new Wallet(); + + bundleContent.push( + ITokenBundle.Token({ + assetContract: address(erc20), + tokenType: ITokenBundle.TokenType.ERC20, + tokenId: 0, + totalAmount: 10 ether + }) + ); + bundleContent.push( + ITokenBundle.Token({ + assetContract: address(erc721), + tokenType: ITokenBundle.TokenType.ERC721, + tokenId: 0, + totalAmount: 1 + }) + ); + bundleContent.push( + ITokenBundle.Token({ + assetContract: address(erc1155), + tokenType: ITokenBundle.TokenType.ERC1155, + tokenId: 0, + totalAmount: 100 + }) + ); + + erc20.mint(address(tokenOwner), 10 ether); + erc721.mint(address(tokenOwner), 1); + erc1155.mint(address(tokenOwner), 0, 100); + + tokenOwner.setAllowanceERC20(address(erc20), address(ext), type(uint256).max); + tokenOwner.setApprovalForAllERC721(address(erc721), address(ext), true); + tokenOwner.setApprovalForAllERC1155(address(erc1155), address(ext), true); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `storeTokens` + //////////////////////////////////////////////////////////////*/ + + function test_balances_storeTokens() public { + assertEq(erc20.balanceOf(address(tokenOwner)), 10 ether); + assertEq(erc20.balanceOf(address(ext)), 0); + + assertEq(erc721.ownerOf(0), address(tokenOwner)); + + assertEq(erc1155.balanceOf(address(tokenOwner), 0), 100); + assertEq(erc1155.balanceOf(address(ext), 0), 0); + + vm.prank(address(tokenOwner)); + ext.storeTokens(address(tokenOwner), bundleContent, "", 0); + + assertEq(erc20.balanceOf(address(tokenOwner)), 0); + assertEq(erc20.balanceOf(address(ext)), 10 ether); + + assertEq(erc721.ownerOf(0), address(ext)); + + assertEq(erc1155.balanceOf(address(tokenOwner), 0), 0); + assertEq(erc1155.balanceOf(address(ext), 0), 100); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `releaseTokens` + //////////////////////////////////////////////////////////////*/ + + function test_balances_releaseTokens() public { + vm.prank(address(tokenOwner)); + ext.storeTokens(address(tokenOwner), bundleContent, "", 0); + + assertEq(erc20.balanceOf(address(tokenOwner)), 0); + assertEq(erc20.balanceOf(address(ext)), 10 ether); + + assertEq(erc721.ownerOf(0), address(ext)); + + assertEq(erc1155.balanceOf(address(tokenOwner), 0), 0); + assertEq(erc1155.balanceOf(address(ext), 0), 100); + + ext.releaseTokens(address(0x345), 0); + + assertEq(erc20.balanceOf(address(0x345)), 10 ether); + assertEq(erc20.balanceOf(address(ext)), 0); + + assertEq(erc721.ownerOf(0), address(0x345)); + + assertEq(erc1155.balanceOf(address(0x345), 0), 100); + assertEq(erc1155.balanceOf(address(ext), 0), 0); + } +} diff --git a/src/test/sdk/extension/batch-mint-metadata/batch-mint-metadata/_batchMintMetadata.t.sol b/src/test/sdk/extension/batch-mint-metadata/batch-mint-metadata/_batchMintMetadata.t.sol new file mode 100644 index 000000000..63dd48e7f --- /dev/null +++ b/src/test/sdk/extension/batch-mint-metadata/batch-mint-metadata/_batchMintMetadata.t.sol @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import { BatchMintMetadata } from "contracts/extension/BatchMintMetadata.sol"; +import "../../ExtensionUtilTest.sol"; + +contract MyBatchMintMetadata is BatchMintMetadata { + function batchMintMetadata( + uint256 _startId, + uint256 _amountToMint, + string memory _baseURIForTokens + ) external returns (uint256 nextTokenIdToMint, uint256 batchId) { + (nextTokenIdToMint, batchId) = _batchMintMetadata(_startId, _amountToMint, _baseURIForTokens); + } + + function getBaseURI(uint256 _tokenId) public view returns (string memory) { + return _getBaseURI(_tokenId); + } +} + +contract BatchMintMetadata_BatchMintMetadata is ExtensionUtilTest { + MyBatchMintMetadata internal ext; + uint256 internal startId; + uint256 internal amountToMint; + string internal baseURI; + + function setUp() public override { + super.setUp(); + + ext = new MyBatchMintMetadata(); + startId = 0; + amountToMint = 100; + baseURI = "ipfs://baseURI"; + } + + function test_batchMintMetadata() public { + uint256 prevBaseURICount = ext.getBaseURICount(); + uint256 batchId = startId + amountToMint; + + ext.batchMintMetadata(startId, amountToMint, baseURI); + uint256 newBaseURICount = ext.getBaseURICount(); + assertEq(ext.getBaseURI(amountToMint - 1), baseURI); + assertEq(newBaseURICount, prevBaseURICount + 1); + assertEq(ext.getBatchIdAtIndex(newBaseURICount - 1), batchId); + + vm.expectRevert(abi.encodeWithSelector(BatchMintMetadata.BatchMintInvalidBatchId.selector, newBaseURICount)); + ext.getBatchIdAtIndex(newBaseURICount); + } +} diff --git a/src/test/sdk/extension/batch-mint-metadata/batch-mint-metadata/_batchMintMetadata.tree b/src/test/sdk/extension/batch-mint-metadata/batch-mint-metadata/_batchMintMetadata.tree new file mode 100644 index 000000000..572dd5203 --- /dev/null +++ b/src/test/sdk/extension/batch-mint-metadata/batch-mint-metadata/_batchMintMetadata.tree @@ -0,0 +1,7 @@ +_batchMintMetadata( + uint256 _startId, + uint256 _amountToMint, + string memory _baseURIForTokens +) +├── it should store batch id equal to the sum of `_startId` and `_amountToMint` in batchIds array ✅ +├── it should map the new batch id to `_baseURIForTokens` ✅ \ No newline at end of file diff --git a/src/test/sdk/extension/batch-mint-metadata/freeze-base-uri/_freezeBaseURI.t.sol b/src/test/sdk/extension/batch-mint-metadata/freeze-base-uri/_freezeBaseURI.t.sol new file mode 100644 index 000000000..147820874 --- /dev/null +++ b/src/test/sdk/extension/batch-mint-metadata/freeze-base-uri/_freezeBaseURI.t.sol @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import { BatchMintMetadata } from "contracts/extension/BatchMintMetadata.sol"; +import "../../ExtensionUtilTest.sol"; + +contract MyBatchMintMetadata is BatchMintMetadata { + function batchMintMetadata( + uint256 _startId, + uint256 _amountToMint, + string memory _baseURIForTokens + ) external returns (uint256 nextTokenIdToMint, uint256 batchId) { + (nextTokenIdToMint, batchId) = _batchMintMetadata(_startId, _amountToMint, _baseURIForTokens); + } + + function freezeBaseURI(uint256 _batchId) external { + _freezeBaseURI(_batchId); + } +} + +contract BatchMintMetadata_FreezeBaseURI is ExtensionUtilTest { + MyBatchMintMetadata internal ext; + uint256 internal startId; + uint256[] internal batchIds; + uint256 internal indexToFreeze; + + event MetadataFrozen(); + + function setUp() public override { + super.setUp(); + + ext = new MyBatchMintMetadata(); + + startId = 0; + // mint 5 batches + for (uint256 i = 0; i < 5; i++) { + uint256 amount = (i + 1) * 10; + uint256 batchId = startId + amount; + batchIds.push(batchId); + + (startId, ) = ext.batchMintMetadata(startId, amount, "ipfs://"); + assertEq(ext.batchFrozen(batchId), false); + } + + indexToFreeze = 3; + } + + function test_freezeBaseURI_invalidBatch() public { + vm.expectRevert( + abi.encodeWithSelector(BatchMintMetadata.BatchMintInvalidBatchId.selector, batchIds[indexToFreeze] * 10) + ); + ext.freezeBaseURI(batchIds[indexToFreeze] * 10); // non-existent batchId + } + + modifier whenBatchIdValid() { + _; + } + + function test_freezeBaseURI() public whenBatchIdValid { + ext.freezeBaseURI(batchIds[indexToFreeze]); + + assertEq(ext.batchFrozen(batchIds[indexToFreeze]), true); + } + + function test_freezeBaseURI_event() public whenBatchIdValid { + vm.expectEmit(false, false, false, false); + emit MetadataFrozen(); + ext.freezeBaseURI(batchIds[indexToFreeze]); + } +} diff --git a/src/test/sdk/extension/batch-mint-metadata/freeze-base-uri/_freezeBaseURI.tree b/src/test/sdk/extension/batch-mint-metadata/freeze-base-uri/_freezeBaseURI.tree new file mode 100644 index 000000000..4dd87edef --- /dev/null +++ b/src/test/sdk/extension/batch-mint-metadata/freeze-base-uri/_freezeBaseURI.tree @@ -0,0 +1,6 @@ +_freezeBaseURI(uint256 _batchId) +├── when there is no baseURI for given `_batchId` + │ └── it should revert ✅ + └── when there is a baseURI present for given `_batchId` + └── it should freeze the `batchId` by setting `frozen[_batchId]` to `true` ✅ + └── it should emit MetadataFrozen event ✅ \ No newline at end of file diff --git a/src/test/sdk/extension/batch-mint-metadata/get-base-uri/_getBaseURI.t.sol b/src/test/sdk/extension/batch-mint-metadata/get-base-uri/_getBaseURI.t.sol new file mode 100644 index 000000000..afa7aa6de --- /dev/null +++ b/src/test/sdk/extension/batch-mint-metadata/get-base-uri/_getBaseURI.t.sol @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import { BatchMintMetadata } from "contracts/extension/BatchMintMetadata.sol"; +import "../../ExtensionUtilTest.sol"; + +contract MyBatchMintMetadata is BatchMintMetadata { + function batchMintMetadata( + uint256 _startId, + uint256 _amountToMint, + string memory _baseURIForTokens + ) external returns (uint256 nextTokenIdToMint, uint256 batchId) { + (nextTokenIdToMint, batchId) = _batchMintMetadata(_startId, _amountToMint, _baseURIForTokens); + } + + function getBaseURI(uint256 _tokenId) external view returns (string memory) { + return _getBaseURI(_tokenId); + } +} + +contract BatchMintMetadata_GetBaseURI is ExtensionUtilTest { + MyBatchMintMetadata internal ext; + uint256 internal startId; + uint256[] internal batchIds; + + function setUp() public override { + super.setUp(); + + ext = new MyBatchMintMetadata(); + + startId = 0; + // mint 5 batches + for (uint256 i = 0; i < 5; i++) { + uint256 amount = (i + 1) * 10; + uint256 batchId = startId + amount; + batchIds.push(batchId); + + string memory baseURI = Strings.toString(batchId); + (startId, ) = ext.batchMintMetadata(startId, amount, baseURI); + } + } + + function test_getBaseURI_invalidTokenId() public { + uint256 tokenId = batchIds[4]; // tokenId greater than the last batchId + + vm.expectRevert(abi.encodeWithSelector(BatchMintMetadata.BatchMintInvalidTokenId.selector, tokenId)); + ext.getBaseURI(tokenId); + } + + modifier whenValidTokenId() { + _; + } + + function test_getBaseURI() public whenValidTokenId { + for (uint256 i = 0; i < 5; i++) { + uint256 start = i == 0 ? 0 : batchIds[i - 1]; + for (uint256 j = start; j < batchIds[i]; j++) { + string memory _baseURI = ext.getBaseURI(j); + + assertEq(_baseURI, Strings.toString(batchIds[i])); + } + } + } +} diff --git a/src/test/sdk/extension/batch-mint-metadata/get-base-uri/_getBaseURI.tree b/src/test/sdk/extension/batch-mint-metadata/get-base-uri/_getBaseURI.tree new file mode 100644 index 000000000..c4ee674bf --- /dev/null +++ b/src/test/sdk/extension/batch-mint-metadata/get-base-uri/_getBaseURI.tree @@ -0,0 +1,6 @@ +_getBaseURI(uint256 _tokenId) +├── when `_tokenId` doesn't belong to any batch, i.e. greater than the last batchId + │ └── it should revert ✅ + └── when `_tokenId` belongs to some batch, i.e. less than that batchId + └── it should return correct baseURI for the `_tokenId` ✅ +(note: all batches are assumed to be contiguous, i.e. start id of one batch is the end id of the previous batch) \ No newline at end of file diff --git a/src/test/sdk/extension/batch-mint-metadata/get-batch-id/_getBatchId.t.sol b/src/test/sdk/extension/batch-mint-metadata/get-batch-id/_getBatchId.t.sol new file mode 100644 index 000000000..a4f16687e --- /dev/null +++ b/src/test/sdk/extension/batch-mint-metadata/get-batch-id/_getBatchId.t.sol @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import { BatchMintMetadata } from "contracts/extension/BatchMintMetadata.sol"; +import "../../ExtensionUtilTest.sol"; + +contract MyBatchMintMetadata is BatchMintMetadata { + function batchMintMetadata( + uint256 _startId, + uint256 _amountToMint, + string memory _baseURIForTokens + ) external returns (uint256 nextTokenIdToMint, uint256 batchId) { + (nextTokenIdToMint, batchId) = _batchMintMetadata(_startId, _amountToMint, _baseURIForTokens); + } + + function getBatchId(uint256 _tokenId) external view returns (uint256 batchId, uint256 index) { + return _getBatchId(_tokenId); + } +} + +contract BatchMintMetadata_GetBatchId is ExtensionUtilTest { + MyBatchMintMetadata internal ext; + uint256 internal startId; + uint256[] internal batchIds; + + function setUp() public override { + super.setUp(); + + ext = new MyBatchMintMetadata(); + + startId = 0; + // mint 5 batches + for (uint256 i = 0; i < 5; i++) { + uint256 amount = (i + 1) * 10; + batchIds.push(startId + amount); + (startId, ) = ext.batchMintMetadata(startId, amount, "ipfs://"); + } + } + + function test_getBatchId_invalidTokenId() public { + uint256 tokenId = batchIds[4]; // tokenId greater than the last batchId + + vm.expectRevert(abi.encodeWithSelector(BatchMintMetadata.BatchMintInvalidTokenId.selector, tokenId)); + ext.getBatchId(tokenId); + } + + modifier whenValidTokenId() { + _; + } + + function test_getBatchId() public whenValidTokenId { + for (uint256 i = 0; i < 5; i++) { + uint256 start = i == 0 ? 0 : batchIds[i - 1]; + for (uint256 j = start; j < batchIds[i]; j++) { + (uint256 batchId, uint256 index) = ext.getBatchId(j); + + assertEq(batchId, batchIds[i]); + assertEq(index, i); + } + } + } +} diff --git a/src/test/sdk/extension/batch-mint-metadata/get-batch-id/_getBatchId.tree b/src/test/sdk/extension/batch-mint-metadata/get-batch-id/_getBatchId.tree new file mode 100644 index 000000000..2e6dd366e --- /dev/null +++ b/src/test/sdk/extension/batch-mint-metadata/get-batch-id/_getBatchId.tree @@ -0,0 +1,6 @@ +_getBatchId(uint256 _tokenId) +├── when `_tokenId` doesn't belong to any batch, i.e. greater than the last batchId + │ └── it should revert ✅ + └── when `_tokenId` belongs to some batch, i.e. less than that batchId + └── it should return correct batchId and batch index for the `_tokenId` ✅ +(note: all batches are assumed to be contiguous, i.e. start id of one batch is the end id of the previous batch) \ No newline at end of file diff --git a/src/test/sdk/extension/batch-mint-metadata/get-batch-start-id/_getBatchStartId.t.sol b/src/test/sdk/extension/batch-mint-metadata/get-batch-start-id/_getBatchStartId.t.sol new file mode 100644 index 000000000..18d1fc954 --- /dev/null +++ b/src/test/sdk/extension/batch-mint-metadata/get-batch-start-id/_getBatchStartId.t.sol @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import { BatchMintMetadata } from "contracts/extension/BatchMintMetadata.sol"; +import "../../ExtensionUtilTest.sol"; + +contract MyBatchMintMetadata is BatchMintMetadata { + function batchMintMetadata( + uint256 _startId, + uint256 _amountToMint, + string memory _baseURIForTokens + ) external returns (uint256 nextTokenIdToMint, uint256 batchId) { + (nextTokenIdToMint, batchId) = _batchMintMetadata(_startId, _amountToMint, _baseURIForTokens); + } + + function getBatchStartId(uint256 _batchId) external view returns (uint256) { + return _getBatchStartId(_batchId); + } +} + +contract BatchMintMetadata_GetBatchStartId is ExtensionUtilTest { + MyBatchMintMetadata internal ext; + uint256 internal startId; + uint256[] internal batchIds; + + function setUp() public override { + super.setUp(); + + ext = new MyBatchMintMetadata(); + + startId = 0; + // mint 5 batches + for (uint256 i = 0; i < 5; i++) { + uint256 amount = (i + 1) * 10; + batchIds.push(startId + amount); + (startId, ) = ext.batchMintMetadata(startId, amount, "ipfs://"); + } + } + + function test_getBatchStartId_invalidBatchId() public { + uint256 batchId = batchIds[4] + 1; // non-existent batchId + + vm.expectRevert(abi.encodeWithSelector(BatchMintMetadata.BatchMintInvalidBatchId.selector, batchId)); + ext.getBatchStartId(batchId); + } + + modifier whenValidBatchId() { + _; + } + + function test_getBatchStartId() public whenValidBatchId { + for (uint256 i = 0; i < 5; i++) { + uint256 start = i == 0 ? 0 : batchIds[i - 1]; + uint256 _batchStartId = ext.getBatchStartId(batchIds[i]); + + assertEq(start, _batchStartId); + } + } +} diff --git a/src/test/sdk/extension/batch-mint-metadata/get-batch-start-id/_getBatchStartId.tree b/src/test/sdk/extension/batch-mint-metadata/get-batch-start-id/_getBatchStartId.tree new file mode 100644 index 000000000..7e303ab46 --- /dev/null +++ b/src/test/sdk/extension/batch-mint-metadata/get-batch-start-id/_getBatchStartId.tree @@ -0,0 +1,6 @@ +_getBatchStartId(uint256 _batchID) +├── when `_batchID` doesn't exist + │ └── it should revert ✅ + └── when `_batchID` exists + └── it should return the starting tokenId for that batch ✅ +(note: all batches are assumed to be contiguous, i.e. start id of one batch is the end id of the previous batch) \ No newline at end of file diff --git a/src/test/sdk/extension/batch-mint-metadata/set-base-uri/_setBaseURI.t.sol b/src/test/sdk/extension/batch-mint-metadata/set-base-uri/_setBaseURI.t.sol new file mode 100644 index 000000000..1ec55e8a8 --- /dev/null +++ b/src/test/sdk/extension/batch-mint-metadata/set-base-uri/_setBaseURI.t.sol @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import { BatchMintMetadata } from "contracts/extension/BatchMintMetadata.sol"; +import "../../ExtensionUtilTest.sol"; + +contract MyBatchMintMetadata is BatchMintMetadata { + function batchMintMetadata( + uint256 _startId, + uint256 _amountToMint, + string memory _baseURIForTokens + ) external returns (uint256 nextTokenIdToMint, uint256 batchId) { + (nextTokenIdToMint, batchId) = _batchMintMetadata(_startId, _amountToMint, _baseURIForTokens); + } + + function setBaseURI(uint256 _batchId, string memory _baseURI) external { + _setBaseURI(_batchId, _baseURI); + } + + function freezeBaseURI(uint256 _batchId, bool _freeze) public { + batchFrozen[_batchId] = _freeze; + } + + function getBaseURI(uint256 _tokenId) external view returns (string memory) { + return _getBaseURI(_tokenId); + } +} + +contract BatchMintMetadata_SetBaseURI is ExtensionUtilTest { + MyBatchMintMetadata internal ext; + string internal newBaseURI; + uint256 internal startId; + uint256[] internal batchIds; + uint256 internal indexToUpdate; + + event BatchMetadataUpdate(uint256 _fromTokenId, uint256 _toTokenId); + + function setUp() public override { + super.setUp(); + + ext = new MyBatchMintMetadata(); + + startId = 0; + // mint 5 batches + for (uint256 i = 0; i < 5; i++) { + uint256 amount = (i + 1) * 10; + uint256 batchId = startId + amount; + batchIds.push(batchId); + + (startId, ) = ext.batchMintMetadata(startId, amount, "ipfs://"); + ext.freezeBaseURI(batchId, true); + } + + indexToUpdate = 3; + newBaseURI = "ipfs://baseURI"; + } + + function test_setBaseURI_frozenBatchId() public { + vm.expectRevert( + abi.encodeWithSelector(BatchMintMetadata.BatchMintMetadataFrozen.selector, batchIds[indexToUpdate]) + ); + ext.setBaseURI(batchIds[indexToUpdate], newBaseURI); + } + + modifier whenBatchIdNotFrozen() { + ext.freezeBaseURI(batchIds[indexToUpdate], false); + _; + } + + function test_setBaseURI() public whenBatchIdNotFrozen { + ext.setBaseURI(batchIds[indexToUpdate], newBaseURI); + + string memory _baseURI = ext.getBaseURI(batchIds[indexToUpdate] - 1); + + assertEq(_baseURI, newBaseURI); + } + + function test_setBaseURI_event() public whenBatchIdNotFrozen { + vm.expectEmit(false, false, false, true); + emit BatchMetadataUpdate(batchIds[indexToUpdate - 1], batchIds[indexToUpdate]); + ext.setBaseURI(batchIds[indexToUpdate], newBaseURI); + } +} diff --git a/src/test/sdk/extension/batch-mint-metadata/set-base-uri/_setBaseURI.tree b/src/test/sdk/extension/batch-mint-metadata/set-base-uri/_setBaseURI.tree new file mode 100644 index 000000000..3df76f653 --- /dev/null +++ b/src/test/sdk/extension/batch-mint-metadata/set-base-uri/_setBaseURI.tree @@ -0,0 +1,6 @@ +_setBaseURI(uint256 _batchId, string memory _baseURI) +├── when the `_batchId` is frozen + │ └── it should revert ✅ + └── when the `_batchId` is not frozen + └── it should map the `_batchId` to `_baseURI` param ✅ + └── it should emit BatchMetadataUpdate event ✅ \ No newline at end of file diff --git a/src/test/sdk/extension/burn-to-claim/burn-tokens-on-origin/_burnTokensOnOrigin.t.sol b/src/test/sdk/extension/burn-to-claim/burn-tokens-on-origin/_burnTokensOnOrigin.t.sol new file mode 100644 index 000000000..e01939b3b --- /dev/null +++ b/src/test/sdk/extension/burn-to-claim/burn-tokens-on-origin/_burnTokensOnOrigin.t.sol @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import { BurnToClaim, IBurnToClaim } from "contracts/extension/BurnToClaim.sol"; +import "../../ExtensionUtilTest.sol"; + +contract MyBurnToClaim is BurnToClaim { + function burnTokensOnOrigin(address _tokenOwner, uint256 _tokenId, uint256 _quantity) public { + _burnTokensOnOrigin(_tokenOwner, _tokenId, _quantity); + } + + function _canSetBurnToClaim() internal view override returns (bool) { + return true; + } +} + +contract BurnToClaim_BurnTokensOnOrigin is ExtensionUtilTest { + MyBurnToClaim internal ext; + Wallet internal tokenOwner; + uint256 internal tokenId; + uint256 internal quantity; + + function setUp() public override { + super.setUp(); + + ext = new MyBurnToClaim(); + + tokenOwner = getWallet(); + erc721.mint(address(tokenOwner), 10); + erc1155.mint(address(tokenOwner), 1, 10); + + erc721NonBurnable.mint(address(tokenOwner), 10); + erc1155NonBurnable.mint(address(tokenOwner), 1, 10); + + tokenOwner.setApprovalForAllERC721(address(erc721), address(ext), true); + tokenOwner.setApprovalForAllERC1155(address(erc1155), address(ext), true); + } + + // ================== + // ======= Test branch: token type is ERC721 + // ================== + + modifier whenNotBurnableERC721() { + ext.setBurnToClaimInfo( + IBurnToClaim.BurnToClaimInfo({ + originContractAddress: address(erc721NonBurnable), + tokenType: IBurnToClaim.TokenType.ERC721, + tokenId: 0, + mintPriceForNewToken: 0, + currency: address(erc20) + }) + ); + _; + } + + function test_burnTokensOnOrigin_ERC721_nonBurnable() public whenNotBurnableERC721 { + vm.expectRevert(); + ext.burnTokensOnOrigin(address(tokenOwner), tokenId, quantity); + } + + modifier whenBurnableERC721() { + ext.setBurnToClaimInfo( + IBurnToClaim.BurnToClaimInfo({ + originContractAddress: address(erc721), + tokenType: IBurnToClaim.TokenType.ERC721, + tokenId: 0, + mintPriceForNewToken: 0, + currency: address(erc20) + }) + ); + _; + } + + function test_burnTokensOnOrigin_ERC721() public whenBurnableERC721 { + ext.burnTokensOnOrigin(address(tokenOwner), tokenId, quantity); + + assertEq(erc721.balanceOf(address(tokenOwner)), 9); + + vm.expectRevert(); + erc721.ownerOf(tokenId); // token doesn't exist after burning + } + + // ================== + // ======= Test branch: token type is ERC71155 + // ================== + + modifier whenNotBurnableERC1155() { + ext.setBurnToClaimInfo( + IBurnToClaim.BurnToClaimInfo({ + originContractAddress: address(erc1155NonBurnable), + tokenType: IBurnToClaim.TokenType.ERC1155, + tokenId: 1, + mintPriceForNewToken: 0, + currency: address(erc20) + }) + ); + _; + } + + function test_burnTokensOnOrigin_ERC1155_nonBurnable() public whenNotBurnableERC1155 { + vm.expectRevert(); + ext.burnTokensOnOrigin(address(tokenOwner), tokenId, quantity); + } + + modifier whenBurnableERC1155() { + ext.setBurnToClaimInfo( + IBurnToClaim.BurnToClaimInfo({ + originContractAddress: address(erc1155), + tokenType: IBurnToClaim.TokenType.ERC1155, + tokenId: 1, + mintPriceForNewToken: 0, + currency: address(erc20) + }) + ); + _; + } + + function test_burnTokensOnOrigin_ERC1155() public whenBurnableERC1155 { + ext.burnTokensOnOrigin(address(tokenOwner), tokenId, quantity); + + assertEq(erc1155.balanceOf(address(tokenOwner), tokenId), 0); + } +} diff --git a/src/test/sdk/extension/burn-to-claim/burn-tokens-on-origin/_burnTokensOnOrigin.tree b/src/test/sdk/extension/burn-to-claim/burn-tokens-on-origin/_burnTokensOnOrigin.tree new file mode 100644 index 000000000..a2a3911ac --- /dev/null +++ b/src/test/sdk/extension/burn-to-claim/burn-tokens-on-origin/_burnTokensOnOrigin.tree @@ -0,0 +1,15 @@ +_burnTokensOnOrigin( + address _tokenOwner, + uint256 _tokenId, + uint256 _quantity +) +├── when burn-to-claim info has token type ERC721 + ├── when the origin ERC721 contract is not burnable + │ └── it should revert ✅ + └── when the origin ERC721 contract is burnable + └── it should successfully burn the token with given tokenId for the token owner ✅ +├── when burn-to-claim info has token type ERC1155 + ├── when the origin ERC1155 contract is not burnable + │ └── it should revert ✅ + └── when the origin ERC1155 contract is burnable + └── it should successfully burn tokens with given tokenId and quantity for the token owner ✅ \ No newline at end of file diff --git a/src/test/sdk/extension/burn-to-claim/set-burn-to-claim-info/setBurnToClaimInfo.t.sol b/src/test/sdk/extension/burn-to-claim/set-burn-to-claim-info/setBurnToClaimInfo.t.sol new file mode 100644 index 000000000..b4e721145 --- /dev/null +++ b/src/test/sdk/extension/burn-to-claim/set-burn-to-claim-info/setBurnToClaimInfo.t.sol @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import { BurnToClaim, IBurnToClaim } from "contracts/extension/BurnToClaim.sol"; +import "../../ExtensionUtilTest.sol"; + +contract MyBurnToClaim is BurnToClaim { + bool condition; + address admin; + + constructor(address _admin) { + admin = _admin; + } + + function _canSetBurnToClaim() internal view override returns (bool) { + return msg.sender == admin; + } +} + +contract BurnToClaim_SetBurnToClaimInfo is ExtensionUtilTest { + MyBurnToClaim internal ext; + address internal admin; + address internal caller; + IBurnToClaim.BurnToClaimInfo internal info; + + function setUp() public override { + super.setUp(); + + admin = getActor(0); + caller = getActor(1); + + ext = new MyBurnToClaim(address(admin)); + info = IBurnToClaim.BurnToClaimInfo({ + originContractAddress: address(0), + tokenType: IBurnToClaim.TokenType.ERC721, + tokenId: 0, + mintPriceForNewToken: 0, + currency: address(0) + }); + } + + function test_setBurnToClaimInfo_callerNotAuthorized() public { + vm.prank(address(caller)); + vm.expectRevert("Not authorized."); + ext.setBurnToClaimInfo(info); + } + + modifier whenCallerAuthorized() { + caller = admin; + _; + } + + function test_setBurnToClaimInfo_invalidOriginContract_addressZero() public whenCallerAuthorized { + vm.prank(address(caller)); + vm.expectRevert("Origin contract not set."); + ext.setBurnToClaimInfo(info); + } + + modifier whenValidOriginContract() { + info.originContractAddress = address(erc721); + _; + } + + function test_setBurnToClaimInfo_invalidCurrency_addressZero() public whenCallerAuthorized whenValidOriginContract { + vm.prank(address(caller)); + vm.expectRevert("Currency not set."); + ext.setBurnToClaimInfo(info); + } + + modifier whenValidCurrency() { + info.currency = address(erc20); + _; + } + + function test_setBurnToClaimInfo() public whenCallerAuthorized whenValidOriginContract whenValidCurrency { + vm.prank(address(caller)); + ext.setBurnToClaimInfo(info); + + IBurnToClaim.BurnToClaimInfo memory _info = ext.getBurnToClaimInfo(); + + assertEq(_info.originContractAddress, info.originContractAddress); + assertEq(_info.currency, info.currency); + } +} diff --git a/src/test/sdk/extension/burn-to-claim/set-burn-to-claim-info/setBurnToClaimInfo.tree b/src/test/sdk/extension/burn-to-claim/set-burn-to-claim-info/setBurnToClaimInfo.tree new file mode 100644 index 000000000..d6e347f5e --- /dev/null +++ b/src/test/sdk/extension/burn-to-claim/set-burn-to-claim-info/setBurnToClaimInfo.tree @@ -0,0 +1,11 @@ +setBurnToClaimInfo(BurnToClaimInfo calldata _burnToClaimInfo) +├── when caller not authorized + │ └── it should revert ✅ + └── when caller is authorized + ├── when input originContractAddress is address(0) + │ └── it should revert ✅ + └── when input originContractAddress is not address(0) + ├── when input currency is address(0) + │ └── it should revert ✅ + └── when input currency is not address(0) + └── it should save incoming struct values into burnToClaimInfo state ✅ \ No newline at end of file diff --git a/src/test/sdk/extension/burn-to-claim/verify-burn-to-claim/verifyBurnToClaim.t.sol b/src/test/sdk/extension/burn-to-claim/verify-burn-to-claim/verifyBurnToClaim.t.sol new file mode 100644 index 000000000..b985d473d --- /dev/null +++ b/src/test/sdk/extension/burn-to-claim/verify-burn-to-claim/verifyBurnToClaim.t.sol @@ -0,0 +1,146 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import { BurnToClaim, IBurnToClaim } from "contracts/extension/BurnToClaim.sol"; +import "../../ExtensionUtilTest.sol"; + +contract MyBurnToClaim is BurnToClaim { + bool condition; + + function setCondition(bool _condition) external { + condition = _condition; + } + + function _canSetBurnToClaim() internal view override returns (bool) { + return condition; + } +} + +contract BurnToClaim_VerifyBurnToClaim is ExtensionUtilTest { + MyBurnToClaim internal ext; + address internal tokenOwner; + uint256 internal tokenId; + uint256 internal quantity; + + function setUp() public override { + super.setUp(); + + ext = new MyBurnToClaim(); + ext.setCondition(true); + + tokenOwner = getActor(1); + erc721.mint(address(tokenOwner), 10); + erc1155.mint(address(tokenOwner), 1, 10); + } + + function test_verifyBurnToClaim_infoNotSet() public { + vm.expectRevert(); + ext.verifyBurnToClaim(tokenOwner, tokenId, 1); + } + + // ================== + // ======= Test branch: token type is ERC721 + // ================== + + modifier whenBurnToClaimInfoSetERC721() { + ext.setBurnToClaimInfo( + IBurnToClaim.BurnToClaimInfo({ + originContractAddress: address(erc721), + tokenType: IBurnToClaim.TokenType.ERC721, + tokenId: 0, + mintPriceForNewToken: 0, + currency: address(erc20) + }) + ); + IBurnToClaim.BurnToClaimInfo memory info = ext.getBurnToClaimInfo(); + _; + } + + function test_verifyBurnToClaim_ERC721_quantity_not_1() public whenBurnToClaimInfoSetERC721 { + quantity = 10; + vm.expectRevert("Invalid amount"); + ext.verifyBurnToClaim(tokenOwner, tokenId, quantity); + } + + modifier whenQuantityParamisOne() { + quantity = 1; + _; + } + + function test_verifyBurnToClaim_ERC721_notOwnerOfToken() + public + whenBurnToClaimInfoSetERC721 + whenQuantityParamisOne + { + vm.expectRevert("!Owner"); + ext.verifyBurnToClaim(address(0x123), tokenId, quantity); // random address as owner + } + + modifier whenCorrectOwner() { + _; + } + + function test_verifyBurnToClaim_ERC721() + public + whenBurnToClaimInfoSetERC721 + whenQuantityParamisOne + whenCorrectOwner + { + ext.verifyBurnToClaim(tokenOwner, tokenId, quantity); + } + + // ================== + // ======= Test branch: token type is ERC1155 + // ================== + + modifier whenBurnToClaimInfoSetERC1155() { + ext.setBurnToClaimInfo( + IBurnToClaim.BurnToClaimInfo({ + originContractAddress: address(erc1155), + tokenType: IBurnToClaim.TokenType.ERC1155, + tokenId: 1, + mintPriceForNewToken: 0, + currency: address(erc20) + }) + ); + IBurnToClaim.BurnToClaimInfo memory info = ext.getBurnToClaimInfo(); + _; + } + + function test_verifyBurnToClaim_ERC1155_invalidTokenId() public whenBurnToClaimInfoSetERC1155 { + vm.expectRevert("Invalid token Id"); + ext.verifyBurnToClaim(tokenOwner, tokenId, quantity); // the tokenId here is 0, but eligible one is set as 1 above + } + + modifier whenCorrectTokenId() { + tokenId = 1; + _; + } + + function test_verifyBurnToClaim_ERC1155_balanceLessThanQuantity() + public + whenBurnToClaimInfoSetERC1155 + whenCorrectTokenId + { + quantity = 100; + vm.expectRevert("!Balance"); + ext.verifyBurnToClaim(tokenOwner, tokenId, quantity); // available balance is 10 + } + + modifier whenSufficientBalance() { + quantity = 10; + _; + } + + function test_verifyBurnToClaim_ERC1155() + public + whenBurnToClaimInfoSetERC1155 + whenCorrectTokenId + whenSufficientBalance + { + ext.verifyBurnToClaim(tokenOwner, tokenId, quantity); + } +} diff --git a/src/test/sdk/extension/burn-to-claim/verify-burn-to-claim/verifyBurnToClaim.tree b/src/test/sdk/extension/burn-to-claim/verify-burn-to-claim/verifyBurnToClaim.tree new file mode 100644 index 000000000..ffc4dba9d --- /dev/null +++ b/src/test/sdk/extension/burn-to-claim/verify-burn-to-claim/verifyBurnToClaim.tree @@ -0,0 +1,23 @@ +verifyBurnToClaim( + address tokenOwner, + uint256 tokenId, + uint256 quantity +) +├── when burn-to-claim info is not set + │ └── it should revert ✅ + └── when burn-to-claim info is set, with token type ERC721 + │ ├── when quantity param is not 1 + │ │ └── it should revert ✅ + │ └── when quantity param is 1 + │ ├── when token owner param is not the actual token owner + │ │ └── it should revert ✅ + │ └── when token owner param is the correct token owner + │ │ └── execution completes -- exit function ✅ + └── when burn-to-claim info is set, with token type ERC1155 + ├── when tokenId param doesn't match eligible tokenId + │ └── it should revert ✅ + └── when tokenId param matches eligible tokenId + ├── when token owner has balance less than quantity param + │ └── it should revert ✅ + └── when token owner has balance greater than or equal to quantity param + └── execution completes -- exit function ✅ \ No newline at end of file diff --git a/src/test/sdk/extension/contract-metadata/set-contract-uri/setContractURI.t.sol b/src/test/sdk/extension/contract-metadata/set-contract-uri/setContractURI.t.sol new file mode 100644 index 000000000..3395c0850 --- /dev/null +++ b/src/test/sdk/extension/contract-metadata/set-contract-uri/setContractURI.t.sol @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import { ContractMetadata, IContractMetadata } from "contracts/extension/ContractMetadata.sol"; +import "../../ExtensionUtilTest.sol"; + +contract MyContractMetadata is ContractMetadata { + address admin; + + constructor(address _admin) { + admin = _admin; + } + + function _canSetContractURI() internal view override returns (bool) { + return msg.sender == admin; + } +} + +contract ContractMetadata_SetContractURI is ExtensionUtilTest { + MyContractMetadata internal ext; + address internal admin; + address internal caller; + string internal uri; + + event ContractURIUpdated(string prevURI, string newURI); + + function setUp() public override { + super.setUp(); + + admin = getActor(0); + caller = getActor(1); + uri = "ipfs://newUri"; + + ext = new MyContractMetadata(address(admin)); + } + + function test_setContractURI_callerNotAuthorized() public { + vm.prank(address(caller)); + vm.expectRevert(abi.encodeWithSelector(ContractMetadata.ContractMetadataUnauthorized.selector)); + ext.setContractURI(uri); + } + + modifier whenCallerAuthorized() { + caller = admin; + _; + } + + function test_setContractURI() public whenCallerAuthorized { + vm.prank(address(caller)); + ext.setContractURI(uri); + + string memory _updatedUri = ext.contractURI(); + assertEq(_updatedUri, uri); + } + + function test_setContractURI_event() public whenCallerAuthorized { + vm.prank(address(caller)); + vm.expectEmit(false, false, false, true); + emit ContractURIUpdated("", uri); + ext.setContractURI(uri); + } +} diff --git a/src/test/sdk/extension/contract-metadata/set-contract-uri/setContractURI.tree b/src/test/sdk/extension/contract-metadata/set-contract-uri/setContractURI.tree new file mode 100644 index 000000000..e626d76e4 --- /dev/null +++ b/src/test/sdk/extension/contract-metadata/set-contract-uri/setContractURI.tree @@ -0,0 +1,6 @@ +setContractURI(string memory uri) +├── when caller not authorized + │ └── it should revert ✅ + └── when caller is authorized + └── it should update contract URI to the new URI value ✅ + └── it should emit ContractURIUpdated event ✅ \ No newline at end of file diff --git a/src/test/sdk/extension/delayed-reveal/get-reveal-uri/getRevealURI.t.sol b/src/test/sdk/extension/delayed-reveal/get-reveal-uri/getRevealURI.t.sol new file mode 100644 index 000000000..0e97a5eb4 --- /dev/null +++ b/src/test/sdk/extension/delayed-reveal/get-reveal-uri/getRevealURI.t.sol @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import { DelayedReveal, IDelayedReveal } from "contracts/extension/DelayedReveal.sol"; +import "../../ExtensionUtilTest.sol"; + +contract MyDelayedReveal is DelayedReveal { + function setEncryptedData(uint256 _batchId, bytes memory _encryptedData) external { + _setEncryptedData(_batchId, _encryptedData); + } + + function reveal(uint256 identifier, bytes calldata key) external returns (string memory revealedURI) {} +} + +contract DelayedReveal_GetRevealURI is ExtensionUtilTest { + MyDelayedReveal internal ext; + string internal originalURI; + bytes internal encryptionKey; + bytes internal encryptedURI; + bytes internal encryptedData; + uint256 internal batchId; + bytes32 internal provenanceHash; + + function setUp() public override { + super.setUp(); + + ext = new MyDelayedReveal(); + originalURI = "ipfs://original"; + encryptionKey = "key123"; + batchId = 1; + + provenanceHash = keccak256(abi.encodePacked(originalURI, encryptionKey, block.chainid)); + encryptedURI = ext.encryptDecrypt(bytes(originalURI), encryptionKey); + encryptedData = abi.encode(encryptedURI, provenanceHash); + } + + function test_getRevealURI_encryptedDataNotSet() public { + vm.expectRevert(abi.encodeWithSelector(DelayedReveal.DelayedRevealNothingToReveal.selector)); + ext.getRevealURI(batchId, encryptionKey); + } + + modifier whenEncryptedDataIsSet() { + ext.setEncryptedData(batchId, encryptedData); + _; + } + + function test_getRevealURI_incorrectKey() public whenEncryptedDataIsSet { + bytes memory incorrectKey = "incorrect key"; + string memory incorrectURI = string(ext.encryptDecrypt(encryptedURI, incorrectKey)); + + vm.expectRevert( + abi.encodeWithSelector( + DelayedReveal.DelayedRevealIncorrectResultHash.selector, + provenanceHash, + keccak256(abi.encodePacked(incorrectURI, incorrectKey, block.chainid)) + ) + ); + ext.getRevealURI(batchId, incorrectKey); + } + + modifier whenCorrectKey() { + _; + } + + function test_getRevealURI() public whenEncryptedDataIsSet whenCorrectKey { + string memory revealedURI = ext.getRevealURI(batchId, encryptionKey); + + assertEq(originalURI, revealedURI); + } +} diff --git a/src/test/sdk/extension/delayed-reveal/get-reveal-uri/getRevealURI.tree b/src/test/sdk/extension/delayed-reveal/get-reveal-uri/getRevealURI.tree new file mode 100644 index 000000000..acb580468 --- /dev/null +++ b/src/test/sdk/extension/delayed-reveal/get-reveal-uri/getRevealURI.tree @@ -0,0 +1,8 @@ +getRevealURI(uint256 _batchId, bytes calldata _key) +├── when there is no encrypted data set for the given batch id + │ └── it should revert ✅ + └── when there is an associated encrypted data present for the given batch id + ├── when the encryption key provided is incorrect + │ └── it should revert ✅ + └── when the encryption key provided is correct + └── it should correctly decrypt and return the original URI ✅ \ No newline at end of file diff --git a/src/test/sdk/extension/delayed-reveal/set-encrypted-data/_setEncryptedData.t.sol b/src/test/sdk/extension/delayed-reveal/set-encrypted-data/_setEncryptedData.t.sol new file mode 100644 index 000000000..096e33568 --- /dev/null +++ b/src/test/sdk/extension/delayed-reveal/set-encrypted-data/_setEncryptedData.t.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import { DelayedReveal, IDelayedReveal } from "contracts/extension/DelayedReveal.sol"; +import "../../ExtensionUtilTest.sol"; + +contract MyDelayedReveal is DelayedReveal { + function setEncryptedData(uint256 _batchId, bytes memory _encryptedData) external { + _setEncryptedData(_batchId, _encryptedData); + } + + function reveal(uint256 identifier, bytes calldata key) external returns (string memory revealedURI) {} +} + +contract DelayedReveal_SetEncryptedData is ExtensionUtilTest { + MyDelayedReveal internal ext; + uint256 internal batchId; + bytes internal data; + + function setUp() public override { + super.setUp(); + + ext = new MyDelayedReveal(); + batchId = 1; + data = "test"; + } + + function test_setEncryptedData() public { + ext.setEncryptedData(batchId, data); + + assertEq(true, ext.isEncryptedBatch(batchId)); + assertEq(ext.encryptedData(batchId), data); + } +} diff --git a/src/test/sdk/extension/delayed-reveal/set-encrypted-data/_setEncryptedData.tree b/src/test/sdk/extension/delayed-reveal/set-encrypted-data/_setEncryptedData.tree new file mode 100644 index 000000000..68f99a2c8 --- /dev/null +++ b/src/test/sdk/extension/delayed-reveal/set-encrypted-data/_setEncryptedData.tree @@ -0,0 +1,3 @@ +_setEncryptedData(uint256 _batchId, bytes memory _encryptedData) +├── it should store input bytes data for the given batch id param ✅ +├── isEncryptedBatch should return true for this batch id ✅ \ No newline at end of file diff --git a/src/test/sdk/extension/drop/claim/claim.t.sol b/src/test/sdk/extension/drop/claim/claim.t.sol new file mode 100644 index 000000000..f1dbab680 --- /dev/null +++ b/src/test/sdk/extension/drop/claim/claim.t.sol @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import { Drop, IDrop, IClaimConditionMultiPhase, IClaimCondition } from "contracts/extension/Drop.sol"; +import "../../ExtensionUtilTest.sol"; + +contract MyDrop is Drop { + function _collectPriceOnClaim( + address _primarySaleRecipient, + uint256 _quantityToClaim, + address _currency, + uint256 _pricePerToken + ) internal override {} + + function _transferTokensOnClaim( + address _to, + uint256 _quantityBeingClaimed + ) internal override returns (uint256 startTokenId) {} + + function _canSetClaimConditions() internal view override returns (bool) { + return true; + } + + function verifyClaim( + uint256 _conditionId, + address _claimer, + uint256 _quantity, + address _currency, + uint256 _pricePerToken, + AllowlistProof calldata _allowlistProof + ) public view override returns (bool isOverride) {} +} + +contract Drop_Claim is ExtensionUtilTest { + MyDrop internal ext; + + address internal _claimer; + uint256 internal _quantity; + address internal _currency; + uint256 internal _pricePerToken; + IDrop.AllowlistProof internal _allowlistProof; + + IClaimCondition.ClaimCondition[] internal claimConditions; + + function setUp() public override { + super.setUp(); + + ext = new MyDrop(); + _claimer = getActor(1); + _quantity = 10; + } + + function _setConditionsState() public { + // values here are not important (except timestamp), since we won't be verifying claim params + + claimConditions.push( + IClaimCondition.ClaimCondition({ + startTimestamp: 0, + maxClaimableSupply: 100, + supplyClaimed: 0, + quantityLimitPerWallet: 1, + merkleRoot: bytes32(0), + pricePerToken: 10, + currency: address(erc20), + metadata: "" + }) + ); + ext.setClaimConditions(claimConditions, false); + } + + function test_claim_noConditionsSet() public { + vm.expectRevert(abi.encodeWithSelector(Drop.DropNoActiveCondition.selector)); + ext.claim(_claimer, _quantity, _currency, _pricePerToken, _allowlistProof, ""); + } + + modifier whenConditionsAreSet() { + _setConditionsState(); + _; + } + + function test_claim() public whenConditionsAreSet { + // claim + vm.prank(_claimer); + ext.claim(_claimer, _quantity, _currency, _pricePerToken, _allowlistProof, ""); + + uint256 supplyClaimedByWallet_1 = ext.getSupplyClaimedByWallet(0, _claimer); + uint256 supplyClaimed_1 = (ext.getClaimConditionById(0)).supplyClaimed; + + // claim again + vm.prank(_claimer); + ext.claim(_claimer, _quantity, _currency, _pricePerToken, _allowlistProof, ""); + + uint256 supplyClaimedByWallet_2 = ext.getSupplyClaimedByWallet(0, _claimer); + uint256 supplyClaimed_2 = (ext.getClaimConditionById(0)).supplyClaimed; + + // check state + assertEq(supplyClaimedByWallet_1, _quantity); + assertEq(supplyClaimedByWallet_2, supplyClaimedByWallet_1 + _quantity); + + assertEq(supplyClaimed_1, _quantity); + assertEq(supplyClaimed_2, supplyClaimed_1 + _quantity); + } +} diff --git a/src/test/sdk/extension/drop/claim/claim.tree b/src/test/sdk/extension/drop/claim/claim.tree new file mode 100644 index 000000000..4ca1d3187 --- /dev/null +++ b/src/test/sdk/extension/drop/claim/claim.tree @@ -0,0 +1,15 @@ +claim( + address _receiver, + uint256 _quantity, + address _currency, + uint256 _pricePerToken, + AllowlistProof calldata _allowlistProof, + bytes memory _data +) +├── when no active condition + │ └── it should revert ✅ + └── when there's an active condition + └── it should increase the supplyClaimed for that condition by quantity param input ✅ + └── it should increase the supplyClaimedByWallet for that condition and msg.sender by quantity param input ✅ + +(Note: verifyClaim function has been tested separately, and hence not being tested here) \ No newline at end of file diff --git a/src/test/sdk/extension/drop/get-active-claim-condition-id/getActiveClaimConditionId.t.sol b/src/test/sdk/extension/drop/get-active-claim-condition-id/getActiveClaimConditionId.t.sol new file mode 100644 index 000000000..833e4f4fd --- /dev/null +++ b/src/test/sdk/extension/drop/get-active-claim-condition-id/getActiveClaimConditionId.t.sol @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import { Drop, IDrop, IClaimConditionMultiPhase, IClaimCondition } from "contracts/extension/upgradeable/Drop.sol"; +import "../../ExtensionUtilTest.sol"; + +contract MyDrop is Drop { + function _collectPriceOnClaim( + address _primarySaleRecipient, + uint256 _quantityToClaim, + address _currency, + uint256 _pricePerToken + ) internal override {} + + function _transferTokensOnClaim( + address _to, + uint256 _quantityBeingClaimed + ) internal override returns (uint256 startTokenId) {} + + function _canSetClaimConditions() internal view override returns (bool) { + return true; + } +} + +contract Drop_GetActiveClaimConditionId is ExtensionUtilTest { + MyDrop internal ext; + + IClaimCondition.ClaimCondition[] internal claimConditions; + + function setUp() public override { + super.setUp(); + + ext = new MyDrop(); + } + + function _setConditionsState() public { + claimConditions.push( + IClaimCondition.ClaimCondition({ + startTimestamp: 100, + maxClaimableSupply: 100, + supplyClaimed: 0, + quantityLimitPerWallet: 1, + merkleRoot: bytes32(0), + pricePerToken: 10, + currency: address(erc20), + metadata: "" + }) + ); + + claimConditions.push( + IClaimCondition.ClaimCondition({ + startTimestamp: 200, + maxClaimableSupply: 100, + supplyClaimed: 0, + quantityLimitPerWallet: 1, + merkleRoot: bytes32(0), + pricePerToken: 10, + currency: address(erc20), + metadata: "" + }) + ); + + claimConditions.push( + IClaimCondition.ClaimCondition({ + startTimestamp: 300, + maxClaimableSupply: 100, + supplyClaimed: 0, + quantityLimitPerWallet: 1, + merkleRoot: bytes32(0), + pricePerToken: 10, + currency: address(erc20), + metadata: "" + }) + ); + + ext.setClaimConditions(claimConditions, false); + } + + function test_getActiveClaimConditionId_noConditionsSet() public { + vm.expectRevert("!CONDITION."); + ext.getActiveClaimConditionId(); + } + + modifier whenConditionsAreSet() { + _setConditionsState(); + _; + } + + function test_getActiveClaimConditionId_noActiveCondition() public whenConditionsAreSet { + vm.expectRevert("!CONDITION."); + ext.getActiveClaimConditionId(); + } + + modifier whenActiveConditions() { + _; + } + + function test_getActiveClaimConditionId_activeConditions() public whenConditionsAreSet whenActiveConditions { + vm.warp(claimConditions[0].startTimestamp); + + uint256 id = ext.getActiveClaimConditionId(); + assertEq(id, 0); + + vm.warp(claimConditions[1].startTimestamp); + + id = ext.getActiveClaimConditionId(); + assertEq(id, 1); + + vm.warp(claimConditions[2].startTimestamp); + + id = ext.getActiveClaimConditionId(); + assertEq(id, 2); + } +} diff --git a/src/test/sdk/extension/drop/get-active-claim-condition-id/getActiveClaimConditionId.tree b/src/test/sdk/extension/drop/get-active-claim-condition-id/getActiveClaimConditionId.tree new file mode 100644 index 000000000..8b8a94d99 --- /dev/null +++ b/src/test/sdk/extension/drop/get-active-claim-condition-id/getActiveClaimConditionId.tree @@ -0,0 +1,8 @@ +getActiveClaimConditionId() +├── when no conditions are set + │ └── it should revert ✅ + └── when condition(s) are set + ├── when no active condition, i.e. start timestamps of all conditions greater than block timestamp + │ └── it should revert ✅ + └── when conditions active, i.e. start timestamps at least one condition is less than or equal to the block timestamp + └── it should return the latest active claim condition id (i.e. with highest start timestamp among those active) ✅ \ No newline at end of file diff --git a/src/test/sdk/extension/drop/set-claim-conditions/setClaimConditions.t.sol b/src/test/sdk/extension/drop/set-claim-conditions/setClaimConditions.t.sol new file mode 100644 index 000000000..476155352 --- /dev/null +++ b/src/test/sdk/extension/drop/set-claim-conditions/setClaimConditions.t.sol @@ -0,0 +1,379 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import { Drop, IDrop, IClaimConditionMultiPhase, IClaimCondition } from "contracts/extension/Drop.sol"; +import "../../ExtensionUtilTest.sol"; + +contract MyDrop is Drop { + address admin; + + constructor(address _admin) { + admin = _admin; + } + + function _collectPriceOnClaim( + address _primarySaleRecipient, + uint256 _quantityToClaim, + address _currency, + uint256 _pricePerToken + ) internal override {} + + function _transferTokensOnClaim( + address _to, + uint256 _quantityBeingClaimed + ) internal override returns (uint256 startTokenId) {} + + function _canSetClaimConditions() internal view override returns (bool) { + return msg.sender == admin; + } + + /** + * note: the functions below are dummy functions for test purposes, + * to directly access and set/reset state without going through the actual functions and checks + */ + + function setCondition(ClaimCondition calldata condition, uint256 _conditionId) public { + claimCondition.conditions[_conditionId] = condition; + } + + function setSupplyClaimedForCondition(uint256 _conditionId, uint256 _supplyClaimed) public { + claimCondition.conditions[_conditionId].supplyClaimed = _supplyClaimed; + } +} + +contract Drop_SetClaimConditions is ExtensionUtilTest { + MyDrop internal ext; + address internal admin; + + IClaimCondition.ClaimCondition[] internal newClaimConditions; + IClaimCondition.ClaimCondition[] internal oldClaimConditions; + + event ClaimConditionsUpdated(IClaimCondition.ClaimCondition[] claimConditions, bool resetEligibility); + + function setUp() public override { + super.setUp(); + + admin = getActor(0); + ext = new MyDrop(admin); + + _setOldConditionsState(); + + newClaimConditions.push( + IClaimCondition.ClaimCondition({ + startTimestamp: 100, + maxClaimableSupply: 100, + supplyClaimed: 0, + quantityLimitPerWallet: 1, + merkleRoot: bytes32(0), + pricePerToken: 10, + currency: address(erc20), + metadata: "" + }) + ); + + newClaimConditions.push( + IClaimCondition.ClaimCondition({ + startTimestamp: 200, + maxClaimableSupply: 100, + supplyClaimed: 0, + quantityLimitPerWallet: 1, + merkleRoot: bytes32(0), + pricePerToken: 10, + currency: address(erc20), + metadata: "" + }) + ); + } + + function _setOldConditionsState() public { + oldClaimConditions.push( + IClaimCondition.ClaimCondition({ + startTimestamp: 10, + maxClaimableSupply: 100, + supplyClaimed: 0, + quantityLimitPerWallet: 1, + merkleRoot: bytes32(0), + pricePerToken: 10, + currency: address(erc20), + metadata: "" + }) + ); + + oldClaimConditions.push( + IClaimCondition.ClaimCondition({ + startTimestamp: 20, + maxClaimableSupply: 100, + supplyClaimed: 0, + quantityLimitPerWallet: 1, + merkleRoot: bytes32(0), + pricePerToken: 10, + currency: address(erc20), + metadata: "" + }) + ); + + oldClaimConditions.push( + IClaimCondition.ClaimCondition({ + startTimestamp: 30, + maxClaimableSupply: 100, + supplyClaimed: 0, + quantityLimitPerWallet: 1, + merkleRoot: bytes32(0), + pricePerToken: 10, + currency: address(erc20), + metadata: "" + }) + ); + + vm.prank(admin); + ext.setClaimConditions(oldClaimConditions, false); + (, uint256 count) = ext.claimCondition(); + assertEq(count, oldClaimConditions.length); + + ext.setSupplyClaimedForCondition(0, 5); + ext.setSupplyClaimedForCondition(0, 20); + ext.setSupplyClaimedForCondition(0, 100); + } + + function test_setClaimConditions_notAuthorized() public { + vm.expectRevert(abi.encodeWithSelector(Drop.DropUnauthorized.selector)); + ext.setClaimConditions(newClaimConditions, false); + + vm.expectRevert(abi.encodeWithSelector(Drop.DropUnauthorized.selector)); + ext.setClaimConditions(newClaimConditions, true); + } + + modifier whenCallerAuthorized() { + vm.startPrank(admin); + _; + vm.stopPrank(); + } + + function test_setClaimConditions_incorrectStartTimestamps() public whenCallerAuthorized { + // reverse the order of timestamps + newClaimConditions[0].startTimestamp = newClaimConditions[1].startTimestamp + 100; + + vm.expectRevert(bytes("ST")); + ext.setClaimConditions(newClaimConditions, false); + + vm.expectRevert(bytes("ST")); + ext.setClaimConditions(newClaimConditions, true); + } + + modifier whenCorrectTimestamps() { + _; + } + + // ================== + // ======= Test branch: claim eligibility reset + // ================== + + function test_setClaimConditions_resetEligibility_startIndex() public whenCallerAuthorized whenCorrectTimestamps { + (, uint256 oldCount) = ext.claimCondition(); + + ext.setClaimConditions(newClaimConditions, true); + + (uint256 newStartIndex, ) = ext.claimCondition(); + assertEq(newStartIndex, oldCount); + } + + function test_setClaimConditions_resetEligibility_conditionCount() + public + whenCallerAuthorized + whenCorrectTimestamps + { + (, uint256 oldCount) = ext.claimCondition(); + assertEq(oldCount, oldClaimConditions.length); + + ext.setClaimConditions(newClaimConditions, true); + + (uint256 newStartIndex, uint256 newCount) = ext.claimCondition(); + assertEq(newCount, newClaimConditions.length); + } + + function test_setClaimConditions_resetEligibility_conditionState() + public + whenCallerAuthorized + whenCorrectTimestamps + { + ext.setClaimConditions(newClaimConditions, true); + + (uint256 newStartIndex, uint256 newCount) = ext.claimCondition(); + + for (uint256 i = 0; i < newCount; i++) { + IClaimCondition.ClaimCondition memory _claimCondition = ext.getClaimConditionById(i + newStartIndex); + + assertEq(_claimCondition.startTimestamp, newClaimConditions[i].startTimestamp); + assertEq(_claimCondition.maxClaimableSupply, newClaimConditions[i].maxClaimableSupply); + assertEq(_claimCondition.supplyClaimed, 0); + } + } + + function test_setClaimConditions_resetEligibility_oldConditionsDeleted() + public + whenCallerAuthorized + whenCorrectTimestamps + { + (uint256 oldStartIndex, uint256 oldCount) = ext.claimCondition(); + assertEq(oldCount, oldClaimConditions.length); + + ext.setClaimConditions(newClaimConditions, true); + + for (uint256 i = 0; i < oldCount; i++) { + IClaimCondition.ClaimCondition memory _claimCondition = ext.getClaimConditionById(i + oldStartIndex); + + assertEq(_claimCondition.startTimestamp, 0); + assertEq(_claimCondition.maxClaimableSupply, 0); + assertEq(_claimCondition.supplyClaimed, 0); + assertEq(_claimCondition.quantityLimitPerWallet, 0); + assertEq(_claimCondition.merkleRoot, bytes32(0)); + assertEq(_claimCondition.pricePerToken, 0); + assertEq(_claimCondition.currency, address(0)); + assertEq(_claimCondition.metadata, ""); + } + } + + function test_setClaimConditions_resetEligibility_event() public whenCallerAuthorized whenCorrectTimestamps { + // TODO: fix/review event data check by setting last param true + vm.expectEmit(false, false, false, false); + emit ClaimConditionsUpdated(newClaimConditions, true); + ext.setClaimConditions(newClaimConditions, true); + } + + // ================== + // ======= Test branch: claim eligibility not reset + // ================== + + function test_setClaimConditions_noReset_maxClaimableLessThanClaimed() + public + whenCallerAuthorized + whenCorrectTimestamps + { + IClaimCondition.ClaimCondition memory _oldCondition = ext.getClaimConditionById(0); + + // set new maxClaimableSupply less than supplyClaimed of the old condition + newClaimConditions[0].maxClaimableSupply = _oldCondition.supplyClaimed - 1; + + vm.expectRevert(abi.encodeWithSelector(Drop.DropExceedMaxSupply.selector)); + ext.setClaimConditions(newClaimConditions, false); + } + + modifier whenMaxClaimableNotLessThanClaimed() { + _; + } + + function test_setClaimConditions_noReset_startIndex() + public + whenCallerAuthorized + whenCorrectTimestamps + whenMaxClaimableNotLessThanClaimed + { + (uint256 oldStartIndex, ) = ext.claimCondition(); + + ext.setClaimConditions(newClaimConditions, false); + + (uint256 newStartIndex, ) = ext.claimCondition(); + assertEq(newStartIndex, oldStartIndex); + } + + function test_setClaimConditions_noReset_conditionCount() + public + whenCallerAuthorized + whenCorrectTimestamps + whenMaxClaimableNotLessThanClaimed + { + (, uint256 oldCount) = ext.claimCondition(); + assertEq(oldCount, oldClaimConditions.length); + + ext.setClaimConditions(newClaimConditions, false); + + (uint256 newStartIndex, uint256 newCount) = ext.claimCondition(); + assertEq(newCount, newClaimConditions.length); + } + + function test_setClaimConditions_noReset_conditionState() + public + whenCallerAuthorized + whenCorrectTimestamps + whenMaxClaimableNotLessThanClaimed + { + (, uint256 oldCount) = ext.claimCondition(); + + // setting array size as this way to avoid out-of-bound error in the second loop + uint256 length = newClaimConditions.length > oldCount ? newClaimConditions.length : oldCount; + IClaimCondition.ClaimCondition[] memory _oldConditions = new IClaimCondition.ClaimCondition[](length); + + for (uint256 i = 0; i < oldCount; i++) { + _oldConditions[i] = ext.getClaimConditionById(i); + } + + ext.setClaimConditions(newClaimConditions, false); + + (uint256 newStartIndex, uint256 newCount) = ext.claimCondition(); + + for (uint256 i = 0; i < newCount; i++) { + IClaimCondition.ClaimCondition memory _claimCondition = ext.getClaimConditionById(i + newStartIndex); + + assertEq(_claimCondition.startTimestamp, newClaimConditions[i].startTimestamp); + assertEq(_claimCondition.maxClaimableSupply, newClaimConditions[i].maxClaimableSupply); + assertEq(_claimCondition.supplyClaimed, _oldConditions[i].supplyClaimed); + } + } + + function test_setClaimConditions_resetEligibility_oldConditionsDeletedOrReplaced() + public + whenCallerAuthorized + whenCorrectTimestamps + whenMaxClaimableNotLessThanClaimed + { + (uint256 oldStartIndex, uint256 oldCount) = ext.claimCondition(); + assertEq(oldCount, oldClaimConditions.length); + + ext.setClaimConditions(newClaimConditions, false); + (, uint256 newCount) = ext.claimCondition(); + + for (uint256 i = 0; i < oldCount; i++) { + IClaimCondition.ClaimCondition memory _claimCondition = ext.getClaimConditionById(i + oldStartIndex); + + if (i >= newCount) { + // case where deleted + + assertEq(_claimCondition.startTimestamp, 0); + assertEq(_claimCondition.maxClaimableSupply, 0); + assertEq(_claimCondition.supplyClaimed, 0); + assertEq(_claimCondition.quantityLimitPerWallet, 0); + assertEq(_claimCondition.merkleRoot, bytes32(0)); + assertEq(_claimCondition.pricePerToken, 0); + assertEq(_claimCondition.currency, address(0)); + assertEq(_claimCondition.metadata, ""); + } else { + // case where replaced + + // supply claimed should be same as old condition, hence not checked below + + assertEq(_claimCondition.startTimestamp, newClaimConditions[i].startTimestamp); + assertEq(_claimCondition.maxClaimableSupply, newClaimConditions[i].maxClaimableSupply); + assertEq(_claimCondition.quantityLimitPerWallet, newClaimConditions[i].quantityLimitPerWallet); + assertEq(_claimCondition.merkleRoot, newClaimConditions[i].merkleRoot); + assertEq(_claimCondition.pricePerToken, newClaimConditions[i].pricePerToken); + assertEq(_claimCondition.currency, newClaimConditions[i].currency); + assertEq(_claimCondition.metadata, newClaimConditions[i].metadata); + } + } + } + + function test_setClaimConditions_noReset_event() + public + whenCallerAuthorized + whenCorrectTimestamps + whenMaxClaimableNotLessThanClaimed + { + // TODO: fix/review event data check by setting last param true + vm.expectEmit(false, false, false, false); + emit ClaimConditionsUpdated(newClaimConditions, false); + ext.setClaimConditions(newClaimConditions, false); + } +} diff --git a/src/test/sdk/extension/drop/set-claim-conditions/setClaimConditions.tree b/src/test/sdk/extension/drop/set-claim-conditions/setClaimConditions.tree new file mode 100644 index 000000000..dbf6297d3 --- /dev/null +++ b/src/test/sdk/extension/drop/set-claim-conditions/setClaimConditions.tree @@ -0,0 +1,24 @@ +setClaimConditions(ClaimCondition[] calldata _conditions, bool _resetClaimEligibility) +├── when caller not authorized + │ └── it should revert ✅ + └── when caller is authorized + ├── when start timestamps of new conditions aren't in ascending order + │ └── it should revert ✅ + └── when start timestamps of new conditions are in ascending order + ├── when claim eligibility is reset + │ └── it should set new conditions start index as the count of old conditions ✅ + │ └── it should set claim condition count equal to the count of new conditions ✅ + │ └── it should correctly save all new conditions at right index ✅ + │ └── it should set supply claimed for each condition equal to 0 ✅ + │ └── it should delete all old conditions (i.e. all conditions with index less than new start index) ✅ + │ └── it should emit ClaimConditionsUpdated event ✅ + └── when claim eligibility is not reset + ├── when maxClaimableSupply of a new condition is less than supplyClaimed of the old condition (at that index) + │ └── it should revert ✅ + └── when maxClaimableSupply of a new condition is greater than or equal to supplyClaimed of the old condition (at that index) + └── it should set new conditions start index same as old start index ✅ + └── it should set claim condition count equal to the count of new conditions ✅ + └── it should correctly save all new conditions at right index ✅ + └── it should set supply claimed for each condition equal to what it was in old condition (at that index) ✅ + └── it should delete all old conditions with index exceeding new count, in case new count is less than previous count ✅ + └── it should emit ClaimConditionsUpdated event ✅ \ No newline at end of file diff --git a/src/test/sdk/extension/drop/verify-claim/verifyClaim.t.sol b/src/test/sdk/extension/drop/verify-claim/verifyClaim.t.sol new file mode 100644 index 000000000..0057f6220 --- /dev/null +++ b/src/test/sdk/extension/drop/verify-claim/verifyClaim.t.sol @@ -0,0 +1,481 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import { Drop, IDrop, IClaimConditionMultiPhase, IClaimCondition } from "contracts/extension/Drop.sol"; +import "../../ExtensionUtilTest.sol"; + +contract MyDrop is Drop { + function _collectPriceOnClaim( + address _primarySaleRecipient, + uint256 _quantityToClaim, + address _currency, + uint256 _pricePerToken + ) internal override {} + + function _transferTokensOnClaim( + address _to, + uint256 _quantityBeingClaimed + ) internal override returns (uint256 startTokenId) {} + + function _canSetClaimConditions() internal view override returns (bool) { + return true; + } + + /** + * note: the functions below are dummy functions for test purposes, + * to directly access and set/reset state without going through the actual functions and checks + */ + + function setCondition(ClaimCondition calldata condition, uint256 _conditionId) public { + claimCondition.conditions[_conditionId] = condition; + } + + function setSupplyClaimedByWallet(uint256 _conditionId, address _wallet, uint256 _supplyClaimed) public { + claimCondition.supplyClaimedByWallet[_conditionId][_wallet] = _supplyClaimed; + } +} + +contract Drop_VerifyClaim is ExtensionUtilTest { + MyDrop internal ext; + + uint256 internal _conditionId; + address internal _claimer; + address internal _allowlistClaimer; + uint256 internal _quantity; + address internal _currency; + uint256 internal _pricePerToken; + IDrop.AllowlistProof internal _allowlistProof; + IDrop.AllowlistProof internal _allowlistProofEmpty; // will leave uninitialized + + IClaimCondition.ClaimCondition internal claimCondition; + IClaimCondition.ClaimCondition internal claimConditionWithAllowlist; + + function setUp() public override { + super.setUp(); + + ext = new MyDrop(); + + _claimer = getActor(1); + _allowlistClaimer = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); + + // claim condition without allowlist + claimCondition = IClaimCondition.ClaimCondition({ + startTimestamp: 1000, + maxClaimableSupply: 100, + supplyClaimed: 0, + quantityLimitPerWallet: 1, + merkleRoot: bytes32(0), + pricePerToken: 10, + currency: address(erc20), + metadata: "" + }); + + // claim condition with allowlist -- set defaults for now + claimConditionWithAllowlist = claimCondition; + (_allowlistProof, claimConditionWithAllowlist.merkleRoot) = _setAllowlistAndProofs( + 0, // default + type(uint256).max, // default + address(0) // default + ); + } + + function _setAllowlistAndProofs( + uint256 _quantity, + uint256 _price, + address _currency + ) internal returns (IDrop.AllowlistProof memory, bytes32) { + string[] memory inputs = new string[](5); + + inputs[0] = "node"; + inputs[1] = "src/test/scripts/generateRoot.ts"; + inputs[2] = Strings.toString(_quantity); + inputs[3] = Strings.toString(_price); + inputs[4] = Strings.toHexString(uint160(_currency)); + + bytes memory result = vm.ffi(inputs); + // revert(); + bytes32 root = abi.decode(result, (bytes32)); + + inputs[1] = "src/test/scripts/getProof.ts"; + result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + IDrop.AllowlistProof memory alp; + alp.proof = proofs; + alp.quantityLimitPerWallet = _quantity; + alp.pricePerToken = _price; + alp.currency = address(_currency); + + return (alp, root); + } + + // ================== + // ======= Test branch: when no allowlist + // ================== + + function test_verifyClaim_noAllowlist_invalidCurrency() public { + ext.setCondition(claimCondition, _conditionId); + + vm.expectRevert( + abi.encodeWithSelector( + Drop.DropClaimInvalidTokenPrice.selector, + _currency, + _pricePerToken, + claimCondition.currency, + claimCondition.pricePerToken + ) + ); + ext.verifyClaim(_conditionId, _claimer, _quantity, _currency, _pricePerToken, _allowlistProofEmpty); + } + + modifier whenValidCurrency_open() { + _currency = claimCondition.currency; + _; + } + + function test_verifyClaim_noAllowlist_invalidPrice() public whenValidCurrency_open { + ext.setCondition(claimCondition, _conditionId); + + vm.expectRevert( + abi.encodeWithSelector( + Drop.DropClaimInvalidTokenPrice.selector, + _currency, + _pricePerToken, + claimCondition.currency, + claimCondition.pricePerToken + ) + ); + ext.verifyClaim(_conditionId, _claimer, _quantity, _currency, _pricePerToken, _allowlistProofEmpty); + } + + modifier whenValidPrice_open() { + _pricePerToken = claimCondition.pricePerToken; + _; + } + + function test_verifyClaim_noAllowlist_zeroQuantity() public whenValidCurrency_open whenValidPrice_open { + ext.setCondition(claimCondition, _conditionId); + + _quantity = 0; + vm.expectRevert( + abi.encodeWithSelector(Drop.DropClaimExceedLimit.selector, claimCondition.quantityLimitPerWallet, 0) + ); + ext.verifyClaim(_conditionId, _claimer, _quantity, _currency, _pricePerToken, _allowlistProofEmpty); + } + + modifier whenNonZeroQuantity() { + _quantity = claimCondition.quantityLimitPerWallet + 1234; + _; + } + + function test_verifyClaim_noAllowlist_nonZeroInvalidQuantity() + public + whenValidCurrency_open + whenValidPrice_open + whenNonZeroQuantity + { + ext.setCondition(claimCondition, _conditionId); + ext.setSupplyClaimedByWallet(_conditionId, _claimer, claimCondition.quantityLimitPerWallet); + + vm.expectRevert( + abi.encodeWithSelector( + Drop.DropClaimExceedLimit.selector, + claimCondition.quantityLimitPerWallet, + _quantity + claimCondition.quantityLimitPerWallet + ) + ); + ext.verifyClaim(_conditionId, _claimer, _quantity, _currency, _pricePerToken, _allowlistProofEmpty); + } + + modifier whenValidQuantity_open() { + _quantity = 1; + _; + } + + function test_verifyClaim_noAllowlist_quantityMoreThanMaxClaimableSupply() + public + whenValidCurrency_open + whenValidPrice_open + whenNonZeroQuantity + whenValidQuantity_open + { + claimCondition.supplyClaimed = claimCondition.maxClaimableSupply; + ext.setCondition(claimCondition, _conditionId); + + vm.expectRevert( + abi.encodeWithSelector( + Drop.DropClaimExceedMaxSupply.selector, + claimCondition.maxClaimableSupply, + _quantity + claimCondition.maxClaimableSupply + ) + ); + ext.verifyClaim(_conditionId, _claimer, _quantity, _currency, _pricePerToken, _allowlistProofEmpty); + } + + modifier whenQuantityWithinMaxLimit() { + _; + } + + function test_verifyClaim_noAllowlist_beforeStartTimestamp() + public + whenValidCurrency_open + whenValidPrice_open + whenNonZeroQuantity + whenValidQuantity_open + whenQuantityWithinMaxLimit + { + ext.setCondition(claimCondition, _conditionId); + + vm.expectRevert( + abi.encodeWithSelector(Drop.DropClaimNotStarted.selector, claimCondition.startTimestamp, block.timestamp) + ); + ext.verifyClaim(_conditionId, _claimer, _quantity, _currency, _pricePerToken, _allowlistProofEmpty); + } + + modifier whenValidTimestamp() { + vm.warp(claimCondition.startTimestamp); + _; + } + + function test_verifyClaim_noAllowlist() + public + whenValidCurrency_open + whenValidPrice_open + whenNonZeroQuantity + whenValidQuantity_open + whenQuantityWithinMaxLimit + whenValidTimestamp + { + ext.setCondition(claimCondition, _conditionId); + + ext.verifyClaim(_conditionId, _claimer, _quantity, _currency, _pricePerToken, _allowlistProofEmpty); + } + + // ================== + // ======= Test branch: allowlist but incorrect proof -- open limits should apply + // ================== + + function test_verifyClaim_incorrectProof_invalidCurrency() public { + ext.setCondition(claimConditionWithAllowlist, _conditionId); + + vm.expectRevert( + abi.encodeWithSelector( + Drop.DropClaimInvalidTokenPrice.selector, + _currency, + _pricePerToken, + claimCondition.currency, + claimCondition.pricePerToken + ) + ); + ext.verifyClaim(_conditionId, _claimer, _quantity, _currency, _pricePerToken, _allowlistProofEmpty); + } + + function test_verifyClaim_incorrectProof_invalidPrice() public whenValidCurrency_open { + ext.setCondition(claimConditionWithAllowlist, _conditionId); + + vm.expectRevert( + abi.encodeWithSelector( + Drop.DropClaimInvalidTokenPrice.selector, + _currency, + _pricePerToken, + claimCondition.currency, + claimCondition.pricePerToken + ) + ); + ext.verifyClaim(_conditionId, _claimer, _quantity, _currency, _pricePerToken, _allowlistProofEmpty); + } + + function test_verifyClaim_incorrectProof_zeroQuantity() public whenValidCurrency_open whenValidPrice_open { + ext.setCondition(claimConditionWithAllowlist, _conditionId); + + _quantity = 0; + vm.expectRevert( + abi.encodeWithSelector(Drop.DropClaimExceedLimit.selector, claimCondition.quantityLimitPerWallet, _quantity) + ); + ext.verifyClaim(_conditionId, _claimer, _quantity, _currency, _pricePerToken, _allowlistProofEmpty); + } + + function test_verifyClaim_incorrectProof_nonZeroInvalidQuantity() + public + whenValidCurrency_open + whenValidPrice_open + whenNonZeroQuantity + { + ext.setCondition(claimConditionWithAllowlist, _conditionId); + ext.setSupplyClaimedByWallet(_conditionId, _claimer, claimCondition.quantityLimitPerWallet); + + vm.expectRevert( + abi.encodeWithSelector( + Drop.DropClaimExceedLimit.selector, + claimCondition.quantityLimitPerWallet, + claimCondition.quantityLimitPerWallet + _quantity + ) + ); + ext.verifyClaim(_conditionId, _claimer, _quantity, _currency, _pricePerToken, _allowlistProofEmpty); + } + + function test_verifyClaim_incorrectProof() + public + whenValidCurrency_open + whenValidPrice_open + whenNonZeroQuantity + whenValidQuantity_open + whenQuantityWithinMaxLimit + whenValidTimestamp + { + ext.setCondition(claimConditionWithAllowlist, _conditionId); + + ext.verifyClaim(_conditionId, _claimer, _quantity, _currency, _pricePerToken, _allowlistProofEmpty); + } + + // ================== + // ======= Test branch: allowlist with correct proof + // ================== + + function test_verifyClaim_allowlist_defaultPriceAndCurrency_invalidCurrencyParam() public { + ext.setCondition(claimConditionWithAllowlist, _conditionId); + + vm.expectRevert( + abi.encodeWithSelector( + Drop.DropClaimInvalidTokenPrice.selector, + _currency, + _pricePerToken, + claimCondition.currency, + claimCondition.pricePerToken + ) + ); + ext.verifyClaim(_conditionId, _allowlistClaimer, _quantity, _currency, _pricePerToken, _allowlistProof); + } + + function test_verifyClaim_allowlist_defaultPriceNonDefaultCurrenct_invalidCurrencyParam() public { + (_allowlistProof, claimConditionWithAllowlist.merkleRoot) = _setAllowlistAndProofs( + 0, // default + type(uint256).max, // default + address(weth) + ); + ext.setCondition(claimConditionWithAllowlist, _conditionId); + + vm.expectRevert( + abi.encodeWithSelector( + Drop.DropClaimInvalidTokenPrice.selector, + _currency, + _pricePerToken, + claimCondition.currency, + claimCondition.pricePerToken + ) + ); + ext.verifyClaim(_conditionId, _allowlistClaimer, _quantity, _currency, _pricePerToken, _allowlistProof); + } + + function test_verifyClaim_allowlist_nonDefaultPriceAndCurrency_invalidCurrencyParam() public { + (_allowlistProof, claimConditionWithAllowlist.merkleRoot) = _setAllowlistAndProofs( + 0, // default + 2, + address(weth) + ); + ext.setCondition(claimConditionWithAllowlist, _conditionId); + + vm.expectRevert( + abi.encodeWithSelector( + Drop.DropClaimInvalidTokenPrice.selector, + _currency, + _pricePerToken, + address(weth), + 2 + ) + ); + ext.verifyClaim(_conditionId, _allowlistClaimer, _quantity, _currency, _pricePerToken, _allowlistProof); + } + + function test_verifyClaim_allowlist_defaultQuantity_invalidQuantityParam() public { + (_allowlistProof, claimConditionWithAllowlist.merkleRoot) = _setAllowlistAndProofs( + 0, // default + 2, + address(weth) + ); + ext.setCondition(claimConditionWithAllowlist, _conditionId); + ext.setSupplyClaimedByWallet( + _conditionId, + _allowlistClaimer, + claimConditionWithAllowlist.quantityLimitPerWallet + ); + + _currency = address(weth); + _pricePerToken = 2; + _quantity = 1; + vm.expectRevert( + abi.encodeWithSelector( + Drop.DropClaimExceedLimit.selector, + claimCondition.quantityLimitPerWallet, + claimCondition.quantityLimitPerWallet + _quantity + ) + ); + ext.verifyClaim(_conditionId, _allowlistClaimer, _quantity, _currency, _pricePerToken, _allowlistProof); + } + + function test_verifyClaim_allowlist_nonDefaultQuantity_invalidQuantityParam() public { + (_allowlistProof, claimConditionWithAllowlist.merkleRoot) = _setAllowlistAndProofs(5, 2, address(weth)); + ext.setCondition(claimConditionWithAllowlist, _conditionId); + ext.setSupplyClaimedByWallet(_conditionId, _allowlistClaimer, 5); + + _currency = address(weth); + _pricePerToken = 2; + _quantity = 1; + vm.expectRevert(abi.encodeWithSelector(Drop.DropClaimExceedLimit.selector, 5, 5 + _quantity)); + ext.verifyClaim(_conditionId, _allowlistClaimer, _quantity, _currency, _pricePerToken, _allowlistProof); + } + + function test_verifyClaim_allowlist_defaultPrice_invalidPriceParam() public { + (_allowlistProof, claimConditionWithAllowlist.merkleRoot) = _setAllowlistAndProofs( + 5, + type(uint256).max, // default + address(weth) + ); + ext.setCondition(claimConditionWithAllowlist, _conditionId); + + _currency = address(weth); + _quantity = 1; + vm.expectRevert( + abi.encodeWithSelector( + Drop.DropClaimInvalidTokenPrice.selector, + _currency, + _pricePerToken, + claimCondition.currency, + claimCondition.pricePerToken + ) + ); + ext.verifyClaim(_conditionId, _allowlistClaimer, _quantity, _currency, _pricePerToken, _allowlistProof); + } + + function test_verifyClaim_allowlist_nonDefaultPrice_invalidPriceParam() public { + (_allowlistProof, claimConditionWithAllowlist.merkleRoot) = _setAllowlistAndProofs(5, 1, address(weth)); + ext.setCondition(claimConditionWithAllowlist, _conditionId); + + _currency = address(weth); + _quantity = 1; + _pricePerToken = 2; + vm.expectRevert( + abi.encodeWithSelector( + Drop.DropClaimInvalidTokenPrice.selector, + _currency, + _pricePerToken, + address(weth), + 1 + ) + ); + ext.verifyClaim(_conditionId, _allowlistClaimer, _quantity, _currency, _pricePerToken, _allowlistProof); + } + + function test_verifyClaim_allowlist() public whenQuantityWithinMaxLimit whenValidTimestamp { + (_allowlistProof, claimConditionWithAllowlist.merkleRoot) = _setAllowlistAndProofs(5, 1, address(weth)); + ext.setCondition(claimConditionWithAllowlist, _conditionId); + + _currency = address(weth); + _quantity = 1; + _pricePerToken = 1; + ext.verifyClaim(_conditionId, _allowlistClaimer, _quantity, _currency, _pricePerToken, _allowlistProof); + } +} diff --git a/src/test/sdk/extension/drop/verify-claim/verifyClaim.tree b/src/test/sdk/extension/drop/verify-claim/verifyClaim.tree new file mode 100644 index 000000000..64553ab90 --- /dev/null +++ b/src/test/sdk/extension/drop/verify-claim/verifyClaim.tree @@ -0,0 +1,67 @@ +verifyClaim( + uint256 conditionId, + address claimer, + uint256 quantity, + address currency, + uint256 pricePerToken, + AllowlistProof calldata allowlistProof +) +├── when no allowlist + └── when currency param not equal to open claim currency + │ └── it should revert ✅ + └── when currency param is equal to open claim currency + └── when pricePerToken param not equal to open claim price + │ └── it should revert ✅ + └── when pricePerToken param is equal to open claim price + └── when quantity param is 0 + │ └── it should revert ✅ + └── when quantity param is not 0 + └── when quantity param plus supply claimed is more than open claim limit + │ └── it should revert ✅ + └── when quantity param plus supply claimed is within open claim limit + └── when quantity param plus claimed supply is more than max claimable supply + │ └── it should revert ✅ + └── when quantity param plus claimed supply is within max claimable supply limit + └── when block timestamp is less than start timestamp of claim phase + │ └── it should revert ✅ + └── when block timestamp is greater than or equal to start timestamp of claim phase + └── execution completes -- exit function ✅ + +├── when allowlist but incorrect merkle proof + └── when currency param not equal to open claim currency + │ └── it should revert ✅ + └── when currency param is equal to open claim currency + └── when pricePerToken param not equal to open claim price + │ └── it should revert ✅ + └── when pricePerToken param is equal to open claim price + └── when quantity param is 0 + │ └── it should revert ✅ + └── when quantity param is not 0 + └── when quantity param plus supply claimed is more than open claim limit + │ └── it should revert ✅ + +├── when allowlist and correct merkle proof + └── when allowlist price is default max uint256 and allowlist currency is default address(0) + │ └── when currency param not equal to open claim currency + │ └── it should revert ✅ + └── when allowlist price is default max uint256 and allowlist currency is not default + │ └── when currency param not equal to open claim currency + │ └── it should revert ✅ + └── when allowlist price is not default and allowlist currency is default address(0) + │ └── when currency param not equal to open claim currency + │ └── it should revert ✅ + └── when allowlist price is not default and allowlist currency is not default + │ └── when currency param not equal to allowlist claim currency + │ └── it should revert ✅ + └── when allowlist quantity is default 0 + │ └── when nonzero quantity param plus supply claimed is more than open claim limit + │ └── it should revert ✅ + └── when allowlist quantity is not default + │ └── when nonzero quantity param plus supply claimed is more than allowlist claim limit + │ └── it should revert ✅ + └── when allowlist price is default max uint256 + │ └── when pricePerToken param not equal to open claim price + │ └── it should revert ✅ + └── when allowlist price is not default + │ └── when pricePerToken param not equal to allowlist claim price + │ └── it should revert ✅ \ No newline at end of file diff --git a/src/test/sdk/extension/lazy-mint/lazy-mint/lazyMint.t.sol b/src/test/sdk/extension/lazy-mint/lazy-mint/lazyMint.t.sol new file mode 100644 index 000000000..ae6774d9e --- /dev/null +++ b/src/test/sdk/extension/lazy-mint/lazy-mint/lazyMint.t.sol @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import { LazyMint, BatchMintMetadata } from "contracts/extension/LazyMint.sol"; +import "../../ExtensionUtilTest.sol"; + +contract MyLazyMint is LazyMint { + address admin; + + constructor(address _admin) { + admin = _admin; + } + + function _canLazyMint() internal view override returns (bool) { + return msg.sender == admin; + } + + function getBaseURI(uint256 _tokenId) external view returns (string memory) { + return _getBaseURI(_tokenId); + } + + function getBatchStartId(uint256 _batchID) public view returns (uint256) { + return _getBatchStartId(_batchID); + } + + function nextTokenIdToMint() public view returns (uint256) { + return nextTokenIdToLazyMint; + } +} + +contract LazyMint_LazyMint is ExtensionUtilTest { + MyLazyMint internal ext; + uint256 internal startId; + uint256 internal amount; + uint256[] internal batchIds; + address internal admin; + address internal caller; + + event TokensLazyMinted(uint256 indexed startTokenId, uint256 endTokenId, string baseURI, bytes encryptedBaseURI); + + function setUp() public override { + super.setUp(); + + admin = getActor(0); + caller = getActor(1); + + ext = new MyLazyMint(address(admin)); + + startId = 0; + // mint 5 batches + vm.startPrank(admin); + for (uint256 i = 0; i < 5; i++) { + uint256 _amount = (i + 1) * 10; + uint256 batchId = startId + _amount; + batchIds.push(batchId); + + string memory baseURI = Strings.toString(batchId); + startId = ext.lazyMint(_amount, baseURI, ""); + } + vm.stopPrank(); + } + + function test_lazyMint_callerNotAuthorized() public { + vm.prank(address(caller)); + vm.expectRevert(abi.encodeWithSelector(LazyMint.LazyMintUnauthorized.selector)); + ext.lazyMint(amount, "", ""); + } + + modifier whenCallerAuthorized() { + caller = admin; + _; + } + + function test_lazyMint_zeroAmount() public whenCallerAuthorized { + vm.prank(address(caller)); + vm.expectRevert(abi.encodeWithSelector(LazyMint.LazyMintInvalidAmount.selector)); + ext.lazyMint(amount, "", ""); + } + + modifier whenAmountNotZero() { + amount = 50; + _; + } + + function test_lazyMint() public whenCallerAuthorized whenAmountNotZero { + // check previous state + uint256 _nextTokenIdToLazyMintOld = ext.nextTokenIdToMint(); + assertEq(_nextTokenIdToLazyMintOld, batchIds[4]); + + string memory baseURI = "ipfs://baseURI"; + + // lazy mint next batch + vm.prank(address(caller)); + uint256 _batchId = ext.lazyMint(amount, baseURI, ""); + + // check new state + uint256 _batchStartId = ext.getBatchStartId(_batchId); + assertEq(_nextTokenIdToLazyMintOld, _batchStartId); + assertEq(_batchId, _nextTokenIdToLazyMintOld + amount); + for (uint256 i = _batchStartId; i < _batchId; i++) { + assertEq(ext.getBaseURI(i), baseURI); + } + assertEq(ext.nextTokenIdToMint(), _nextTokenIdToLazyMintOld + amount); + } + + function test_lazyMint_event() public whenCallerAuthorized whenAmountNotZero { + string memory baseURI = "ipfs://baseURI"; + uint256 _nextTokenIdToLazyMintOld = ext.nextTokenIdToMint(); + + // lazy mint next batch + vm.prank(address(caller)); + vm.expectEmit(); + emit TokensLazyMinted(_nextTokenIdToLazyMintOld, _nextTokenIdToLazyMintOld + amount - 1, baseURI, ""); + ext.lazyMint(amount, baseURI, ""); + } +} diff --git a/src/test/sdk/extension/lazy-mint/lazy-mint/lazyMint.tree b/src/test/sdk/extension/lazy-mint/lazy-mint/lazyMint.tree new file mode 100644 index 000000000..72ac4ddb3 --- /dev/null +++ b/src/test/sdk/extension/lazy-mint/lazy-mint/lazyMint.tree @@ -0,0 +1,17 @@ +lazyMint( + uint256 _amount, + string calldata _baseURIForTokens, + bytes calldata _data +) +├── when caller not authorized + │ └── it should revert ✅ + └── when caller is authorized + └── when amount to lazy mint is 0 + │ └── it should revert ✅ + └── when amount to lazy mint is not 0 + └── it should save the batch of tokens starting at `nextTokenIdToLazyMint` ✅ + └── it should store batch id equal to the sum of `nextTokenIdToLazyMint` and `_amount` ✅ + └── it should map the new batch id to `_baseURIForTokens` ✅ + └── it should increase `nextTokenIdToLazyMint` by `_amount` ✅ + └── it should return the new `batchId` ✅ + └── it should emit TokensLazyMinted event ✅ \ No newline at end of file diff --git a/src/test/sdk/extension/ownable/set-owner/setOwner.t.sol b/src/test/sdk/extension/ownable/set-owner/setOwner.t.sol new file mode 100644 index 000000000..7f6d136d6 --- /dev/null +++ b/src/test/sdk/extension/ownable/set-owner/setOwner.t.sol @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import { Ownable, IOwnable } from "contracts/extension/Ownable.sol"; +import "../../ExtensionUtilTest.sol"; + +contract MyOwnable is Ownable { + address admin; + + constructor(address _admin) { + admin = _admin; + } + + function _canSetOwner() internal view override returns (bool) { + return msg.sender == admin; + } +} + +contract Ownable_SetOwner is ExtensionUtilTest { + MyOwnable internal ext; + address internal admin; + address internal caller; + address internal oldOwner; + address internal newOwner; + + event OwnerUpdated(address indexed prevOwner, address indexed newOwner); + + function setUp() public override { + super.setUp(); + + admin = getActor(0); + caller = getActor(1); + + oldOwner = getActor(2); + newOwner = getActor(3); + + ext = new MyOwnable(address(admin)); + + vm.prank(address(admin)); + ext.setOwner(oldOwner); + + assertEq(oldOwner, ext.owner()); + } + + function test_setOwner_callerNotAuthorized() public { + vm.prank(address(caller)); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorized.selector)); + ext.setOwner(newOwner); + } + + modifier whenCallerAuthorized() { + caller = admin; + _; + } + + function test_setOwner() public whenCallerAuthorized { + vm.prank(address(caller)); + ext.setOwner(newOwner); + + assertEq(newOwner, ext.owner()); + } + + function test_setOwner_event() public whenCallerAuthorized { + vm.prank(address(caller)); + vm.expectEmit(true, true, false, false); + emit OwnerUpdated(oldOwner, newOwner); + ext.setOwner(newOwner); + } +} diff --git a/src/test/sdk/extension/ownable/set-owner/setOwner.tree b/src/test/sdk/extension/ownable/set-owner/setOwner.tree new file mode 100644 index 000000000..9db2c0a70 --- /dev/null +++ b/src/test/sdk/extension/ownable/set-owner/setOwner.tree @@ -0,0 +1,6 @@ +setContractURI(string memory uri) +├── when caller not authorized + │ └── it should revert ✅ + └── when caller is authorized + └── it should update owner by replacing old owner with the new owner input ✅ + └── it should emit OwnerUpdated event ✅ \ No newline at end of file diff --git a/src/test/sdk/extension/royalty/set-default-royalty-info/setDefaultRoyaltyInfo.t.sol b/src/test/sdk/extension/royalty/set-default-royalty-info/setDefaultRoyaltyInfo.t.sol new file mode 100644 index 000000000..792f1c52e --- /dev/null +++ b/src/test/sdk/extension/royalty/set-default-royalty-info/setDefaultRoyaltyInfo.t.sol @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import { Royalty, IRoyalty } from "contracts/extension/Royalty.sol"; +import "../../ExtensionUtilTest.sol"; + +contract MyRoyalty is Royalty { + address admin; + + constructor(address _admin) { + admin = _admin; + } + + function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) {} + + function _canSetRoyaltyInfo() internal view override returns (bool) { + return msg.sender == admin; + } +} + +contract Royalty_SetDefaultRoyaltyInfo is ExtensionUtilTest { + MyRoyalty internal ext; + address internal admin; + address internal caller; + address internal defaultRoyaltyRecipient; + uint256 internal defaultRoyaltyBps; + + event DefaultRoyalty(address indexed newRoyaltyRecipient, uint256 newRoyaltyBps); + + function setUp() public override { + super.setUp(); + + admin = getActor(0); + caller = getActor(1); + defaultRoyaltyRecipient = getActor(2); + + ext = new MyRoyalty(address(admin)); + } + + function test_setDefaultRoyaltyInfo_callerNotAuthorized() public { + vm.prank(address(caller)); + vm.expectRevert(abi.encodeWithSelector(Royalty.RoyaltyUnauthorized.selector)); + ext.setDefaultRoyaltyInfo(defaultRoyaltyRecipient, defaultRoyaltyBps); + } + + modifier whenCallerAuthorized() { + caller = admin; + _; + } + + function test_setDefaultRoyaltyInfo_exceedMaxBps() public whenCallerAuthorized { + defaultRoyaltyBps = 10_001; + vm.prank(address(caller)); + vm.expectRevert(abi.encodeWithSelector(Royalty.RoyaltyExceededMaxFeeBps.selector, 10_000, defaultRoyaltyBps)); + ext.setDefaultRoyaltyInfo(defaultRoyaltyRecipient, defaultRoyaltyBps); + } + + modifier whenNotExceedMaxBps() { + defaultRoyaltyBps = 500; + _; + } + + function test_setDefaultRoyaltyInfo() public whenCallerAuthorized whenNotExceedMaxBps { + vm.prank(address(caller)); + ext.setDefaultRoyaltyInfo(defaultRoyaltyRecipient, defaultRoyaltyBps); + + // get default royalty info + (address _recipient, uint16 _royaltyBps) = ext.getDefaultRoyaltyInfo(); + assertEq(_recipient, defaultRoyaltyRecipient); + assertEq(_royaltyBps, uint16(defaultRoyaltyBps)); + + // get royalty info for token + uint256 tokenId = 0; + (_recipient, _royaltyBps) = ext.getRoyaltyInfoForToken(tokenId); + assertEq(_recipient, defaultRoyaltyRecipient); + assertEq(_royaltyBps, uint16(defaultRoyaltyBps)); + + // royaltyInfo - ERC2981 + uint256 salePrice = 1000; + (address _royaltyRecipient, uint256 _royaltyAmount) = ext.royaltyInfo(tokenId, salePrice); + assertEq(_royaltyRecipient, defaultRoyaltyRecipient); + assertEq(_royaltyAmount, (salePrice * defaultRoyaltyBps) / 10_000); + } + + function test_setDefaultRoyaltyInfo_event() public whenCallerAuthorized whenNotExceedMaxBps { + vm.prank(address(caller)); + vm.expectEmit(true, false, false, true); + emit DefaultRoyalty(defaultRoyaltyRecipient, defaultRoyaltyBps); + ext.setDefaultRoyaltyInfo(defaultRoyaltyRecipient, defaultRoyaltyBps); + } +} diff --git a/src/test/sdk/extension/royalty/set-default-royalty-info/setDefaultRoyaltyInfo.tree b/src/test/sdk/extension/royalty/set-default-royalty-info/setDefaultRoyaltyInfo.tree new file mode 100644 index 000000000..78a4312de --- /dev/null +++ b/src/test/sdk/extension/royalty/set-default-royalty-info/setDefaultRoyaltyInfo.tree @@ -0,0 +1,11 @@ +setDefaultRoyaltyInfo(address _royaltyRecipient, uint256 _royaltyBps) +├── when caller not authorized + │ └── it should revert ✅ + └── when caller is authorized + ├── when royalty bps input is greater than MAX_BPS + │ └── it should revert ✅ + └── when royalty bps input is less than or equal to MAX_BPS + └── it should update default royalty recipient ✅ + └── it should update default royalty bps ✅ + └── it should calculate royalty amount for a token-id based on default royalty info ✅ + └── it should emit DefaultRoyalty event ✅ \ No newline at end of file diff --git a/src/test/sdk/extension/royalty/set-royalty-info-for-token/setRoyaltyInfoForToken.t.sol b/src/test/sdk/extension/royalty/set-royalty-info-for-token/setRoyaltyInfoForToken.t.sol new file mode 100644 index 000000000..997697a5e --- /dev/null +++ b/src/test/sdk/extension/royalty/set-royalty-info-for-token/setRoyaltyInfoForToken.t.sol @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import { Royalty, IRoyalty } from "contracts/extension/Royalty.sol"; +import "../../ExtensionUtilTest.sol"; + +contract MyRoyalty is Royalty { + address admin; + + constructor(address _admin) { + admin = _admin; + } + + function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) {} + + function _canSetRoyaltyInfo() internal view override returns (bool) { + return msg.sender == admin; + } +} + +contract Royalty_SetRoyaltyInfoForToken is ExtensionUtilTest { + MyRoyalty internal ext; + address internal admin; + address internal caller; + address internal defaultRoyaltyRecipient; + uint256 internal defaultRoyaltyBps; + + address internal royaltyRecipientForToken; + uint256 internal royaltyBpsForToken; + uint256 internal tokenId; + + event RoyaltyForToken(uint256 indexed tokenId, address indexed royaltyRecipient, uint256 royaltyBps); + + function setUp() public override { + super.setUp(); + + admin = getActor(0); + caller = getActor(1); + defaultRoyaltyRecipient = getActor(2); + royaltyRecipientForToken = getActor(3); + defaultRoyaltyBps = 500; + tokenId = 1; + + ext = new MyRoyalty(address(admin)); + + vm.prank(address(admin)); + ext.setDefaultRoyaltyInfo(defaultRoyaltyRecipient, defaultRoyaltyBps); + } + + function test_setRoyaltyInfoForToken_callerNotAuthorized() public { + vm.prank(address(caller)); + vm.expectRevert(abi.encodeWithSelector(Royalty.RoyaltyUnauthorized.selector)); + ext.setRoyaltyInfoForToken(tokenId, royaltyRecipientForToken, royaltyBpsForToken); + } + + modifier whenCallerAuthorized() { + caller = admin; + _; + } + + function test_setRoyaltyInfoForToken_exceedMaxBps() public whenCallerAuthorized { + royaltyBpsForToken = 10_001; + vm.prank(address(caller)); + vm.expectRevert(abi.encodeWithSelector(Royalty.RoyaltyExceededMaxFeeBps.selector, 10_000, royaltyBpsForToken)); + ext.setRoyaltyInfoForToken(tokenId, royaltyRecipientForToken, royaltyBpsForToken); + } + + modifier whenNotExceedMaxBps() { + royaltyBpsForToken = 1000; + _; + } + + function test_setRoyaltyInfoForToken() public whenCallerAuthorized whenNotExceedMaxBps { + vm.prank(address(caller)); + ext.setRoyaltyInfoForToken(tokenId, royaltyRecipientForToken, royaltyBpsForToken); + + // get default royalty info + (address _defaultRecipient, uint16 _defaultRoyaltyBps) = ext.getDefaultRoyaltyInfo(); + assertEq(_defaultRecipient, defaultRoyaltyRecipient); + assertEq(_defaultRoyaltyBps, uint16(defaultRoyaltyBps)); + + // get royalty info for token + (address _royaltyRecipientForToken, uint16 _royaltyBpsForToken) = ext.getRoyaltyInfoForToken(tokenId); + assertEq(_royaltyRecipientForToken, royaltyRecipientForToken); + assertEq(_royaltyBpsForToken, uint16(royaltyBpsForToken)); + + // royaltyInfo - ERC2981: calculate for default + uint256 salePrice = 1000; + (address _royaltyRecipient, uint256 _royaltyAmount) = ext.royaltyInfo(0, salePrice); + assertEq(_royaltyRecipient, defaultRoyaltyRecipient); + assertEq(_royaltyAmount, (salePrice * defaultRoyaltyBps) / 10_000); + + // royaltyInfo - ERC2981: calculate for specific tokenId we set the royalty info for + (_royaltyRecipient, _royaltyAmount) = ext.royaltyInfo(tokenId, salePrice); + assertEq(_royaltyRecipient, royaltyRecipientForToken); + assertEq(_royaltyAmount, (salePrice * royaltyBpsForToken) / 10_000); + } + + function test_setRoyaltyInfoForToken_event() public whenCallerAuthorized whenNotExceedMaxBps { + vm.prank(address(caller)); + vm.expectEmit(true, true, false, true); + emit RoyaltyForToken(tokenId, royaltyRecipientForToken, royaltyBpsForToken); + ext.setRoyaltyInfoForToken(tokenId, royaltyRecipientForToken, royaltyBpsForToken); + } +} diff --git a/src/test/sdk/extension/royalty/set-royalty-info-for-token/setRoyaltyInfoForToken.tree b/src/test/sdk/extension/royalty/set-royalty-info-for-token/setRoyaltyInfoForToken.tree new file mode 100644 index 000000000..e28295634 --- /dev/null +++ b/src/test/sdk/extension/royalty/set-royalty-info-for-token/setRoyaltyInfoForToken.tree @@ -0,0 +1,15 @@ +function setRoyaltyInfoForToken( + uint256 _tokenId, + address _recipient, + uint256 _bps +) +├── when caller not authorized + │ └── it should revert ✅ + └── when caller is authorized + ├── when royalty bps input is greater than MAX_BPS + │ └── it should revert ✅ + └── when royalty bps input is less than or equal to MAX_BPS + └── it should update default royalty recipient ✅ + └── it should update default royalty bps ✅ + └── it should calculate royalty amount for a token-id based on default royalty info ✅ + └── it should emit DefaultRoyalty event ✅ \ No newline at end of file diff --git a/src/test/sdk/extension/upgradeable/batch-mint-metadata/batch-mint-metadata/_batchMintMetadata.t.sol b/src/test/sdk/extension/upgradeable/batch-mint-metadata/batch-mint-metadata/_batchMintMetadata.t.sol new file mode 100644 index 000000000..6ca105a6f --- /dev/null +++ b/src/test/sdk/extension/upgradeable/batch-mint-metadata/batch-mint-metadata/_batchMintMetadata.t.sol @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import { BatchMintMetadata } from "contracts/extension/upgradeable/BatchMintMetadata.sol"; +import "../../../ExtensionUtilTest.sol"; + +contract MyBatchMintMetadataUpg is BatchMintMetadata { + function batchMintMetadata( + uint256 _startId, + uint256 _amountToMint, + string memory _baseURIForTokens + ) external returns (uint256 nextTokenIdToMint, uint256 batchId) { + (nextTokenIdToMint, batchId) = _batchMintMetadata(_startId, _amountToMint, _baseURIForTokens); + } + + function getBaseURI(uint256 _batchId) external view returns (string memory) { + return _batchMintMetadataStorage().baseURI[_batchId]; + } +} + +contract UpgradeableBatchMintMetadata_BatchMintMetadata is ExtensionUtilTest { + MyBatchMintMetadataUpg internal ext; + uint256 internal startId; + uint256 internal amountToMint; + string internal baseURI; + + function setUp() public override { + super.setUp(); + + ext = new MyBatchMintMetadataUpg(); + startId = 20; + amountToMint = 100; + baseURI = "ipfs://baseURI"; + } + + function test_batchMintMetadata() public { + uint256 prevBaseURICount = ext.getBaseURICount(); + uint256 batchId = startId + amountToMint; + + ext.batchMintMetadata(startId, amountToMint, baseURI); + uint256 newBaseURICount = ext.getBaseURICount(); + assertEq(ext.getBaseURI(batchId), baseURI); + assertEq(newBaseURICount, prevBaseURICount + 1); + assertEq(ext.getBatchIdAtIndex(newBaseURICount - 1), batchId); + + vm.expectRevert("Invalid index"); + ext.getBatchIdAtIndex(newBaseURICount); + } +} diff --git a/src/test/sdk/extension/upgradeable/batch-mint-metadata/batch-mint-metadata/_batchMintMetadata.tree b/src/test/sdk/extension/upgradeable/batch-mint-metadata/batch-mint-metadata/_batchMintMetadata.tree new file mode 100644 index 000000000..572dd5203 --- /dev/null +++ b/src/test/sdk/extension/upgradeable/batch-mint-metadata/batch-mint-metadata/_batchMintMetadata.tree @@ -0,0 +1,7 @@ +_batchMintMetadata( + uint256 _startId, + uint256 _amountToMint, + string memory _baseURIForTokens +) +├── it should store batch id equal to the sum of `_startId` and `_amountToMint` in batchIds array ✅ +├── it should map the new batch id to `_baseURIForTokens` ✅ \ No newline at end of file diff --git a/src/test/sdk/extension/upgradeable/batch-mint-metadata/freeze-base-uri/_freezeBaseURI.t.sol b/src/test/sdk/extension/upgradeable/batch-mint-metadata/freeze-base-uri/_freezeBaseURI.t.sol new file mode 100644 index 000000000..9bc777bf4 --- /dev/null +++ b/src/test/sdk/extension/upgradeable/batch-mint-metadata/freeze-base-uri/_freezeBaseURI.t.sol @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import { BatchMintMetadata } from "contracts/extension/upgradeable/BatchMintMetadata.sol"; +import "../../../ExtensionUtilTest.sol"; + +contract MyBatchMintMetadataUpg is BatchMintMetadata { + function batchMintMetadata( + uint256 _startId, + uint256 _amountToMint, + string memory _baseURIForTokens + ) external returns (uint256 nextTokenIdToMint, uint256 batchId) { + (nextTokenIdToMint, batchId) = _batchMintMetadata(_startId, _amountToMint, _baseURIForTokens); + } + + function freezeBaseURI(uint256 _batchId) external { + _freezeBaseURI(_batchId); + } + + function batchFrozen(uint256 _batchId) external view returns (bool) { + return _batchMintMetadataStorage().batchFrozen[_batchId]; + } +} + +contract UpgradeableBatchMintMetadata_FreezeBaseURI is ExtensionUtilTest { + MyBatchMintMetadataUpg internal ext; + uint256 internal startId; + uint256[] internal batchIds; + uint256 internal indexToFreeze; + + event MetadataFrozen(); + + function setUp() public override { + super.setUp(); + + ext = new MyBatchMintMetadataUpg(); + + startId = 0; + // mint 5 batches + for (uint256 i = 0; i < 5; i++) { + uint256 amount = (i + 1) * 10; + uint256 batchId = startId + amount; + batchIds.push(batchId); + + (startId, ) = ext.batchMintMetadata(startId, amount, "ipfs://"); + assertEq(ext.batchFrozen(batchId), false); + } + + indexToFreeze = 3; + } + + function test_freezeBaseURI_invalidBatch() public { + vm.expectRevert("Invalid batch"); + ext.freezeBaseURI(batchIds[indexToFreeze] * 10); // non-existent batchId + } + + modifier whenBatchIdValid() { + _; + } + + function test_freezeBaseURI() public whenBatchIdValid { + ext.freezeBaseURI(batchIds[indexToFreeze]); + + assertEq(ext.batchFrozen(batchIds[indexToFreeze]), true); + } + + function test_freezeBaseURI_event() public whenBatchIdValid { + vm.expectEmit(false, false, false, false); + emit MetadataFrozen(); + ext.freezeBaseURI(batchIds[indexToFreeze]); + } +} diff --git a/src/test/sdk/extension/upgradeable/batch-mint-metadata/freeze-base-uri/_freezeBaseURI.tree b/src/test/sdk/extension/upgradeable/batch-mint-metadata/freeze-base-uri/_freezeBaseURI.tree new file mode 100644 index 000000000..4dd87edef --- /dev/null +++ b/src/test/sdk/extension/upgradeable/batch-mint-metadata/freeze-base-uri/_freezeBaseURI.tree @@ -0,0 +1,6 @@ +_freezeBaseURI(uint256 _batchId) +├── when there is no baseURI for given `_batchId` + │ └── it should revert ✅ + └── when there is a baseURI present for given `_batchId` + └── it should freeze the `batchId` by setting `frozen[_batchId]` to `true` ✅ + └── it should emit MetadataFrozen event ✅ \ No newline at end of file diff --git a/src/test/sdk/extension/upgradeable/batch-mint-metadata/get-base-uri/_getBaseURI.t.sol b/src/test/sdk/extension/upgradeable/batch-mint-metadata/get-base-uri/_getBaseURI.t.sol new file mode 100644 index 000000000..153915408 --- /dev/null +++ b/src/test/sdk/extension/upgradeable/batch-mint-metadata/get-base-uri/_getBaseURI.t.sol @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import { BatchMintMetadata } from "contracts/extension/upgradeable/BatchMintMetadata.sol"; +import "../../../ExtensionUtilTest.sol"; + +contract MyBatchMintMetadataUpg is BatchMintMetadata { + function batchMintMetadata( + uint256 _startId, + uint256 _amountToMint, + string memory _baseURIForTokens + ) external returns (uint256 nextTokenIdToMint, uint256 batchId) { + (nextTokenIdToMint, batchId) = _batchMintMetadata(_startId, _amountToMint, _baseURIForTokens); + } + + function getBaseURI(uint256 _tokenId) external view returns (string memory) { + return _getBaseURI(_tokenId); + } +} + +contract UpgradeableBatchMintMetadata_GetBaseURI is ExtensionUtilTest { + MyBatchMintMetadataUpg internal ext; + uint256 internal startId; + uint256[] internal batchIds; + + function setUp() public override { + super.setUp(); + + ext = new MyBatchMintMetadataUpg(); + + startId = 0; + // mint 5 batches + for (uint256 i = 0; i < 5; i++) { + uint256 amount = (i + 1) * 10; + uint256 batchId = startId + amount; + batchIds.push(batchId); + + string memory baseURI = Strings.toString(batchId); + (startId, ) = ext.batchMintMetadata(startId, amount, baseURI); + } + } + + function test_getBaseURI_invalidTokenId() public { + uint256 tokenId = batchIds[4]; // tokenId greater than the last batchId + + vm.expectRevert("Invalid tokenId"); + ext.getBaseURI(tokenId); + } + + modifier whenValidTokenId() { + _; + } + + function test_getBaseURI() public whenValidTokenId { + for (uint256 i = 0; i < 5; i++) { + uint256 start = i == 0 ? 0 : batchIds[i - 1]; + for (uint256 j = start; j < batchIds[i]; j++) { + string memory _baseURI = ext.getBaseURI(j); + + assertEq(_baseURI, Strings.toString(batchIds[i])); + } + } + } +} diff --git a/src/test/sdk/extension/upgradeable/batch-mint-metadata/get-base-uri/_getBaseURI.tree b/src/test/sdk/extension/upgradeable/batch-mint-metadata/get-base-uri/_getBaseURI.tree new file mode 100644 index 000000000..c4ee674bf --- /dev/null +++ b/src/test/sdk/extension/upgradeable/batch-mint-metadata/get-base-uri/_getBaseURI.tree @@ -0,0 +1,6 @@ +_getBaseURI(uint256 _tokenId) +├── when `_tokenId` doesn't belong to any batch, i.e. greater than the last batchId + │ └── it should revert ✅ + └── when `_tokenId` belongs to some batch, i.e. less than that batchId + └── it should return correct baseURI for the `_tokenId` ✅ +(note: all batches are assumed to be contiguous, i.e. start id of one batch is the end id of the previous batch) \ No newline at end of file diff --git a/src/test/sdk/extension/upgradeable/batch-mint-metadata/get-batch-id/_getBatchId.t.sol b/src/test/sdk/extension/upgradeable/batch-mint-metadata/get-batch-id/_getBatchId.t.sol new file mode 100644 index 000000000..9c8ec1136 --- /dev/null +++ b/src/test/sdk/extension/upgradeable/batch-mint-metadata/get-batch-id/_getBatchId.t.sol @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import { BatchMintMetadata } from "contracts/extension/upgradeable/BatchMintMetadata.sol"; +import "../../../ExtensionUtilTest.sol"; + +contract MyBatchMintMetadataUpg is BatchMintMetadata { + function batchMintMetadata( + uint256 _startId, + uint256 _amountToMint, + string memory _baseURIForTokens + ) external returns (uint256 nextTokenIdToMint, uint256 batchId) { + (nextTokenIdToMint, batchId) = _batchMintMetadata(_startId, _amountToMint, _baseURIForTokens); + } + + function getBatchId(uint256 _tokenId) external view returns (uint256 batchId, uint256 index) { + return _getBatchId(_tokenId); + } +} + +contract UpgradeableBatchMintMetadata_GetBatchId is ExtensionUtilTest { + MyBatchMintMetadataUpg internal ext; + uint256 internal startId; + uint256[] internal batchIds; + + function setUp() public override { + super.setUp(); + + ext = new MyBatchMintMetadataUpg(); + + startId = 0; + // mint 5 batches + for (uint256 i = 0; i < 5; i++) { + uint256 amount = (i + 1) * 10; + batchIds.push(startId + amount); + (startId, ) = ext.batchMintMetadata(startId, amount, "ipfs://"); + } + } + + function test_getBatchId_invalidTokenId() public { + uint256 tokenId = batchIds[4]; // tokenId greater than the last batchId + + vm.expectRevert("Invalid tokenId"); + ext.getBatchId(tokenId); + } + + modifier whenValidTokenId() { + _; + } + + function test_getBatchId() public whenValidTokenId { + for (uint256 i = 0; i < 5; i++) { + uint256 start = i == 0 ? 0 : batchIds[i - 1]; + for (uint256 j = start; j < batchIds[i]; j++) { + (uint256 batchId, uint256 index) = ext.getBatchId(j); + + assertEq(batchId, batchIds[i]); + assertEq(index, i); + } + } + } +} diff --git a/src/test/sdk/extension/upgradeable/batch-mint-metadata/get-batch-id/_getBatchId.tree b/src/test/sdk/extension/upgradeable/batch-mint-metadata/get-batch-id/_getBatchId.tree new file mode 100644 index 000000000..2e6dd366e --- /dev/null +++ b/src/test/sdk/extension/upgradeable/batch-mint-metadata/get-batch-id/_getBatchId.tree @@ -0,0 +1,6 @@ +_getBatchId(uint256 _tokenId) +├── when `_tokenId` doesn't belong to any batch, i.e. greater than the last batchId + │ └── it should revert ✅ + └── when `_tokenId` belongs to some batch, i.e. less than that batchId + └── it should return correct batchId and batch index for the `_tokenId` ✅ +(note: all batches are assumed to be contiguous, i.e. start id of one batch is the end id of the previous batch) \ No newline at end of file diff --git a/src/test/sdk/extension/upgradeable/batch-mint-metadata/get-batch-start-id/_getBatchStartId.t.sol b/src/test/sdk/extension/upgradeable/batch-mint-metadata/get-batch-start-id/_getBatchStartId.t.sol new file mode 100644 index 000000000..6a6182a3b --- /dev/null +++ b/src/test/sdk/extension/upgradeable/batch-mint-metadata/get-batch-start-id/_getBatchStartId.t.sol @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import { BatchMintMetadata } from "contracts/extension/upgradeable/BatchMintMetadata.sol"; +import "../../../ExtensionUtilTest.sol"; + +contract MyBatchMintMetadataUpg is BatchMintMetadata { + function batchMintMetadata( + uint256 _startId, + uint256 _amountToMint, + string memory _baseURIForTokens + ) external returns (uint256 nextTokenIdToMint, uint256 batchId) { + (nextTokenIdToMint, batchId) = _batchMintMetadata(_startId, _amountToMint, _baseURIForTokens); + } + + function getBatchStartId(uint256 _batchId) external view returns (uint256) { + return _getBatchStartId(_batchId); + } +} + +contract UpgradeableBatchMintMetadata_GetBatchStartId is ExtensionUtilTest { + MyBatchMintMetadataUpg internal ext; + uint256 internal startId; + uint256[] internal batchIds; + + function setUp() public override { + super.setUp(); + + ext = new MyBatchMintMetadataUpg(); + + startId = 0; + // mint 5 batches + for (uint256 i = 0; i < 5; i++) { + uint256 amount = (i + 1) * 10; + batchIds.push(startId + amount); + (startId, ) = ext.batchMintMetadata(startId, amount, "ipfs://"); + } + } + + function test_getBatchStartId_invalidBatchId() public { + uint256 batchId = batchIds[4] + 1; // non-existent batchId + + vm.expectRevert("Invalid batchId"); + ext.getBatchStartId(batchId); + } + + modifier whenValidBatchId() { + _; + } + + function test_getBatchStartId() public whenValidBatchId { + for (uint256 i = 0; i < 5; i++) { + uint256 start = i == 0 ? 0 : batchIds[i - 1]; + uint256 _batchStartId = ext.getBatchStartId(batchIds[i]); + + assertEq(start, _batchStartId); + } + } +} diff --git a/src/test/sdk/extension/upgradeable/batch-mint-metadata/get-batch-start-id/_getBatchStartId.tree b/src/test/sdk/extension/upgradeable/batch-mint-metadata/get-batch-start-id/_getBatchStartId.tree new file mode 100644 index 000000000..7e303ab46 --- /dev/null +++ b/src/test/sdk/extension/upgradeable/batch-mint-metadata/get-batch-start-id/_getBatchStartId.tree @@ -0,0 +1,6 @@ +_getBatchStartId(uint256 _batchID) +├── when `_batchID` doesn't exist + │ └── it should revert ✅ + └── when `_batchID` exists + └── it should return the starting tokenId for that batch ✅ +(note: all batches are assumed to be contiguous, i.e. start id of one batch is the end id of the previous batch) \ No newline at end of file diff --git a/src/test/sdk/extension/upgradeable/batch-mint-metadata/set-base-uri/_setBaseURI.t.sol b/src/test/sdk/extension/upgradeable/batch-mint-metadata/set-base-uri/_setBaseURI.t.sol new file mode 100644 index 000000000..28a351d76 --- /dev/null +++ b/src/test/sdk/extension/upgradeable/batch-mint-metadata/set-base-uri/_setBaseURI.t.sol @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import { BatchMintMetadata } from "contracts/extension/upgradeable/BatchMintMetadata.sol"; +import "../../../ExtensionUtilTest.sol"; + +contract MyBatchMintMetadataUpg is BatchMintMetadata { + function batchMintMetadata( + uint256 _startId, + uint256 _amountToMint, + string memory _baseURIForTokens + ) external returns (uint256 nextTokenIdToMint, uint256 batchId) { + (nextTokenIdToMint, batchId) = _batchMintMetadata(_startId, _amountToMint, _baseURIForTokens); + } + + function setBaseURI(uint256 _batchId, string memory _baseURI) external { + _setBaseURI(_batchId, _baseURI); + } + + function freezeBaseURI(uint256 _batchId, bool _freeze) external { + _batchMintMetadataStorage().batchFrozen[_batchId] = _freeze; + } + + function getBaseURI(uint256 _batchId) external view returns (string memory) { + return _batchMintMetadataStorage().baseURI[_batchId]; + } +} + +contract UpgradeableBatchMintMetadata_SetBaseURI is ExtensionUtilTest { + MyBatchMintMetadataUpg internal ext; + string internal newBaseURI; + uint256 internal startId; + uint256[] internal batchIds; + uint256 internal indexToUpdate; + + event BatchMetadataUpdate(uint256 _fromTokenId, uint256 _toTokenId); + + function setUp() public override { + super.setUp(); + + ext = new MyBatchMintMetadataUpg(); + + startId = 0; + // mint 5 batches + for (uint256 i = 0; i < 5; i++) { + uint256 amount = (i + 1) * 10; + uint256 batchId = startId + amount; + batchIds.push(batchId); + + (startId, ) = ext.batchMintMetadata(startId, amount, "ipfs://"); + ext.freezeBaseURI(batchId, true); + } + + indexToUpdate = 3; + newBaseURI = "ipfs://baseURI"; + } + + function test_setBaseURI_frozenBatchId() public { + vm.expectRevert("Batch frozen"); + ext.setBaseURI(batchIds[indexToUpdate], newBaseURI); + } + + modifier whenBatchIdNotFrozen() { + ext.freezeBaseURI(batchIds[indexToUpdate], false); + _; + } + + function test_setBaseURI() public whenBatchIdNotFrozen { + ext.setBaseURI(batchIds[indexToUpdate], newBaseURI); + + string memory _baseURI = ext.getBaseURI(batchIds[indexToUpdate]); + + assertEq(_baseURI, newBaseURI); + } + + function test_setBaseURI_event() public whenBatchIdNotFrozen { + vm.expectEmit(false, false, false, true); + emit BatchMetadataUpdate(batchIds[indexToUpdate - 1], batchIds[indexToUpdate]); + ext.setBaseURI(batchIds[indexToUpdate], newBaseURI); + } +} diff --git a/src/test/sdk/extension/upgradeable/batch-mint-metadata/set-base-uri/_setBaseURI.tree b/src/test/sdk/extension/upgradeable/batch-mint-metadata/set-base-uri/_setBaseURI.tree new file mode 100644 index 000000000..3df76f653 --- /dev/null +++ b/src/test/sdk/extension/upgradeable/batch-mint-metadata/set-base-uri/_setBaseURI.tree @@ -0,0 +1,6 @@ +_setBaseURI(uint256 _batchId, string memory _baseURI) +├── when the `_batchId` is frozen + │ └── it should revert ✅ + └── when the `_batchId` is not frozen + └── it should map the `_batchId` to `_baseURI` param ✅ + └── it should emit BatchMetadataUpdate event ✅ \ No newline at end of file diff --git a/src/test/sdk/extension/upgradeable/burn-to-claim/burn-tokens-on-origin/_burnTokensOnOrigin.t.sol b/src/test/sdk/extension/upgradeable/burn-to-claim/burn-tokens-on-origin/_burnTokensOnOrigin.t.sol new file mode 100644 index 000000000..bc530cd78 --- /dev/null +++ b/src/test/sdk/extension/upgradeable/burn-to-claim/burn-tokens-on-origin/_burnTokensOnOrigin.t.sol @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import { BurnToClaim, IBurnToClaim } from "contracts/extension/upgradeable/BurnToClaim.sol"; +import "../../../ExtensionUtilTest.sol"; + +contract MyBurnToClaimUpg is BurnToClaim { + function burnTokensOnOrigin(address _tokenOwner, uint256 _tokenId, uint256 _quantity) public { + _burnTokensOnOrigin(_tokenOwner, _tokenId, _quantity); + } + + function _canSetBurnToClaim() internal view override returns (bool) { + return true; + } +} + +contract UpgradeableBurnToClaim_BurnTokensOnOrigin is ExtensionUtilTest { + MyBurnToClaimUpg internal ext; + Wallet internal tokenOwner; + uint256 internal tokenId; + uint256 internal quantity; + + function setUp() public override { + super.setUp(); + + ext = new MyBurnToClaimUpg(); + + tokenOwner = getWallet(); + erc721.mint(address(tokenOwner), 10); + erc1155.mint(address(tokenOwner), 1, 10); + + erc721NonBurnable.mint(address(tokenOwner), 10); + erc1155NonBurnable.mint(address(tokenOwner), 1, 10); + + tokenOwner.setApprovalForAllERC721(address(erc721), address(ext), true); + tokenOwner.setApprovalForAllERC1155(address(erc1155), address(ext), true); + } + + // ================== + // ======= Test branch: token type is ERC721 + // ================== + + modifier whenNotBurnableERC721() { + ext.setBurnToClaimInfo( + IBurnToClaim.BurnToClaimInfo({ + originContractAddress: address(erc721NonBurnable), + tokenType: IBurnToClaim.TokenType.ERC721, + tokenId: 0, + mintPriceForNewToken: 0, + currency: address(erc20) + }) + ); + _; + } + + function test_burnTokensOnOrigin_ERC721_nonBurnable() public whenNotBurnableERC721 { + vm.expectRevert(); + ext.burnTokensOnOrigin(address(tokenOwner), tokenId, quantity); + } + + modifier whenBurnableERC721() { + ext.setBurnToClaimInfo( + IBurnToClaim.BurnToClaimInfo({ + originContractAddress: address(erc721), + tokenType: IBurnToClaim.TokenType.ERC721, + tokenId: 0, + mintPriceForNewToken: 0, + currency: address(erc20) + }) + ); + _; + } + + function test_burnTokensOnOrigin_ERC721() public whenBurnableERC721 { + ext.burnTokensOnOrigin(address(tokenOwner), tokenId, quantity); + + assertEq(erc721.balanceOf(address(tokenOwner)), 9); + + vm.expectRevert(); + erc721.ownerOf(tokenId); // token doesn't exist after burning + } + + // ================== + // ======= Test branch: token type is ERC71155 + // ================== + + modifier whenNotBurnableERC1155() { + ext.setBurnToClaimInfo( + IBurnToClaim.BurnToClaimInfo({ + originContractAddress: address(erc1155NonBurnable), + tokenType: IBurnToClaim.TokenType.ERC1155, + tokenId: 1, + mintPriceForNewToken: 0, + currency: address(erc20) + }) + ); + _; + } + + function test_burnTokensOnOrigin_ERC1155_nonBurnable() public whenNotBurnableERC1155 { + vm.expectRevert(); + ext.burnTokensOnOrigin(address(tokenOwner), tokenId, quantity); + } + + modifier whenBurnableERC1155() { + ext.setBurnToClaimInfo( + IBurnToClaim.BurnToClaimInfo({ + originContractAddress: address(erc1155), + tokenType: IBurnToClaim.TokenType.ERC1155, + tokenId: 1, + mintPriceForNewToken: 0, + currency: address(erc20) + }) + ); + _; + } + + function test_burnTokensOnOrigin_ERC1155() public whenBurnableERC1155 { + ext.burnTokensOnOrigin(address(tokenOwner), tokenId, quantity); + + assertEq(erc1155.balanceOf(address(tokenOwner), tokenId), 0); + } +} diff --git a/src/test/sdk/extension/upgradeable/burn-to-claim/burn-tokens-on-origin/_burnTokensOnOrigin.tree b/src/test/sdk/extension/upgradeable/burn-to-claim/burn-tokens-on-origin/_burnTokensOnOrigin.tree new file mode 100644 index 000000000..a2a3911ac --- /dev/null +++ b/src/test/sdk/extension/upgradeable/burn-to-claim/burn-tokens-on-origin/_burnTokensOnOrigin.tree @@ -0,0 +1,15 @@ +_burnTokensOnOrigin( + address _tokenOwner, + uint256 _tokenId, + uint256 _quantity +) +├── when burn-to-claim info has token type ERC721 + ├── when the origin ERC721 contract is not burnable + │ └── it should revert ✅ + └── when the origin ERC721 contract is burnable + └── it should successfully burn the token with given tokenId for the token owner ✅ +├── when burn-to-claim info has token type ERC1155 + ├── when the origin ERC1155 contract is not burnable + │ └── it should revert ✅ + └── when the origin ERC1155 contract is burnable + └── it should successfully burn tokens with given tokenId and quantity for the token owner ✅ \ No newline at end of file diff --git a/src/test/sdk/extension/upgradeable/burn-to-claim/set-burn-to-claim-info/setBurnToClaimInfo.t.sol b/src/test/sdk/extension/upgradeable/burn-to-claim/set-burn-to-claim-info/setBurnToClaimInfo.t.sol new file mode 100644 index 000000000..cb3cf0133 --- /dev/null +++ b/src/test/sdk/extension/upgradeable/burn-to-claim/set-burn-to-claim-info/setBurnToClaimInfo.t.sol @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import { BurnToClaim, IBurnToClaim } from "contracts/extension/upgradeable/BurnToClaim.sol"; +import "../../../ExtensionUtilTest.sol"; + +contract MyBurnToClaimUpg is BurnToClaim { + bool condition; + address admin; + + constructor(address _admin) { + admin = _admin; + } + + function _canSetBurnToClaim() internal view override returns (bool) { + return msg.sender == admin; + } +} + +contract UpgradeableBurnToClaim_SetBurnToClaimInfo is ExtensionUtilTest { + MyBurnToClaimUpg internal ext; + address internal admin; + address internal caller; + IBurnToClaim.BurnToClaimInfo internal info; + + function setUp() public override { + super.setUp(); + + admin = getActor(0); + caller = getActor(1); + + ext = new MyBurnToClaimUpg(address(admin)); + info = IBurnToClaim.BurnToClaimInfo({ + originContractAddress: address(0), + tokenType: IBurnToClaim.TokenType.ERC721, + tokenId: 0, + mintPriceForNewToken: 0, + currency: address(0) + }); + } + + function test_setBurnToClaimInfo_callerNotAuthorized() public { + vm.prank(address(caller)); + vm.expectRevert("Not authorized."); + ext.setBurnToClaimInfo(info); + } + + modifier whenCallerAuthorized() { + caller = admin; + _; + } + + function test_setBurnToClaimInfo_invalidOriginContract_addressZero() public whenCallerAuthorized { + vm.prank(address(caller)); + vm.expectRevert("Origin contract not set."); + ext.setBurnToClaimInfo(info); + } + + modifier whenValidOriginContract() { + info.originContractAddress = address(erc721); + _; + } + + function test_setBurnToClaimInfo_invalidCurrency_addressZero() public whenCallerAuthorized whenValidOriginContract { + vm.prank(address(caller)); + vm.expectRevert("Currency not set."); + ext.setBurnToClaimInfo(info); + } + + modifier whenValidCurrency() { + info.currency = address(erc20); + _; + } + + function test_setBurnToClaimInfo() public whenCallerAuthorized whenValidOriginContract whenValidCurrency { + vm.prank(address(caller)); + ext.setBurnToClaimInfo(info); + + IBurnToClaim.BurnToClaimInfo memory _info = ext.getBurnToClaimInfo(); + + assertEq(_info.originContractAddress, info.originContractAddress); + assertEq(_info.currency, info.currency); + } +} diff --git a/src/test/sdk/extension/upgradeable/burn-to-claim/set-burn-to-claim-info/setBurnToClaimInfo.tree b/src/test/sdk/extension/upgradeable/burn-to-claim/set-burn-to-claim-info/setBurnToClaimInfo.tree new file mode 100644 index 000000000..d6e347f5e --- /dev/null +++ b/src/test/sdk/extension/upgradeable/burn-to-claim/set-burn-to-claim-info/setBurnToClaimInfo.tree @@ -0,0 +1,11 @@ +setBurnToClaimInfo(BurnToClaimInfo calldata _burnToClaimInfo) +├── when caller not authorized + │ └── it should revert ✅ + └── when caller is authorized + ├── when input originContractAddress is address(0) + │ └── it should revert ✅ + └── when input originContractAddress is not address(0) + ├── when input currency is address(0) + │ └── it should revert ✅ + └── when input currency is not address(0) + └── it should save incoming struct values into burnToClaimInfo state ✅ \ No newline at end of file diff --git a/src/test/sdk/extension/upgradeable/burn-to-claim/verify-burn-to-claim/verifyBurnToClaim.t.sol b/src/test/sdk/extension/upgradeable/burn-to-claim/verify-burn-to-claim/verifyBurnToClaim.t.sol new file mode 100644 index 000000000..030ea0bb2 --- /dev/null +++ b/src/test/sdk/extension/upgradeable/burn-to-claim/verify-burn-to-claim/verifyBurnToClaim.t.sol @@ -0,0 +1,146 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import { BurnToClaim, IBurnToClaim } from "contracts/extension/upgradeable/BurnToClaim.sol"; +import "../../../ExtensionUtilTest.sol"; + +contract MyBurnToClaimUpg is BurnToClaim { + bool condition; + + function setCondition(bool _condition) external { + condition = _condition; + } + + function _canSetBurnToClaim() internal view override returns (bool) { + return condition; + } +} + +contract UpgradeableBurnToClaim_VerifyBurnToClaim is ExtensionUtilTest { + MyBurnToClaimUpg internal ext; + address internal tokenOwner; + uint256 internal tokenId; + uint256 internal quantity; + + function setUp() public override { + super.setUp(); + + ext = new MyBurnToClaimUpg(); + ext.setCondition(true); + + tokenOwner = getActor(1); + erc721.mint(address(tokenOwner), 10); + erc1155.mint(address(tokenOwner), 1, 10); + } + + function test_verifyBurnToClaim_infoNotSet() public { + vm.expectRevert(); + ext.verifyBurnToClaim(tokenOwner, tokenId, 1); + } + + // ================== + // ======= Test branch: token type is ERC721 + // ================== + + modifier whenBurnToClaimInfoSetERC721() { + ext.setBurnToClaimInfo( + IBurnToClaim.BurnToClaimInfo({ + originContractAddress: address(erc721), + tokenType: IBurnToClaim.TokenType.ERC721, + tokenId: 0, + mintPriceForNewToken: 0, + currency: address(erc20) + }) + ); + IBurnToClaim.BurnToClaimInfo memory info = ext.getBurnToClaimInfo(); + _; + } + + function test_verifyBurnToClaim_ERC721_quantity_not_1() public whenBurnToClaimInfoSetERC721 { + quantity = 10; + vm.expectRevert("Invalid amount"); + ext.verifyBurnToClaim(tokenOwner, tokenId, quantity); + } + + modifier whenQuantityParamisOne() { + quantity = 1; + _; + } + + function test_verifyBurnToClaim_ERC721_notOwnerOfToken() + public + whenBurnToClaimInfoSetERC721 + whenQuantityParamisOne + { + vm.expectRevert("!Owner"); + ext.verifyBurnToClaim(address(0x123), tokenId, quantity); // random address as owner + } + + modifier whenCorrectOwner() { + _; + } + + function test_verifyBurnToClaim_ERC721() + public + whenBurnToClaimInfoSetERC721 + whenQuantityParamisOne + whenCorrectOwner + { + ext.verifyBurnToClaim(tokenOwner, tokenId, quantity); + } + + // ================== + // ======= Test branch: token type is ERC1155 + // ================== + + modifier whenBurnToClaimInfoSetERC1155() { + ext.setBurnToClaimInfo( + IBurnToClaim.BurnToClaimInfo({ + originContractAddress: address(erc1155), + tokenType: IBurnToClaim.TokenType.ERC1155, + tokenId: 1, + mintPriceForNewToken: 0, + currency: address(erc20) + }) + ); + IBurnToClaim.BurnToClaimInfo memory info = ext.getBurnToClaimInfo(); + _; + } + + function test_verifyBurnToClaim_ERC1155_invalidTokenId() public whenBurnToClaimInfoSetERC1155 { + vm.expectRevert("Invalid token Id"); + ext.verifyBurnToClaim(tokenOwner, tokenId, quantity); // the tokenId here is 0, but eligible one is set as 1 above + } + + modifier whenCorrectTokenId() { + tokenId = 1; + _; + } + + function test_verifyBurnToClaim_ERC1155_balanceLessThanQuantity() + public + whenBurnToClaimInfoSetERC1155 + whenCorrectTokenId + { + quantity = 100; + vm.expectRevert("!Balance"); + ext.verifyBurnToClaim(tokenOwner, tokenId, quantity); // available balance is 10 + } + + modifier whenSufficientBalance() { + quantity = 10; + _; + } + + function test_verifyBurnToClaim_ERC1155() + public + whenBurnToClaimInfoSetERC1155 + whenCorrectTokenId + whenSufficientBalance + { + ext.verifyBurnToClaim(tokenOwner, tokenId, quantity); + } +} diff --git a/src/test/sdk/extension/upgradeable/burn-to-claim/verify-burn-to-claim/verifyBurnToClaim.tree b/src/test/sdk/extension/upgradeable/burn-to-claim/verify-burn-to-claim/verifyBurnToClaim.tree new file mode 100644 index 000000000..ffc4dba9d --- /dev/null +++ b/src/test/sdk/extension/upgradeable/burn-to-claim/verify-burn-to-claim/verifyBurnToClaim.tree @@ -0,0 +1,23 @@ +verifyBurnToClaim( + address tokenOwner, + uint256 tokenId, + uint256 quantity +) +├── when burn-to-claim info is not set + │ └── it should revert ✅ + └── when burn-to-claim info is set, with token type ERC721 + │ ├── when quantity param is not 1 + │ │ └── it should revert ✅ + │ └── when quantity param is 1 + │ ├── when token owner param is not the actual token owner + │ │ └── it should revert ✅ + │ └── when token owner param is the correct token owner + │ │ └── execution completes -- exit function ✅ + └── when burn-to-claim info is set, with token type ERC1155 + ├── when tokenId param doesn't match eligible tokenId + │ └── it should revert ✅ + └── when tokenId param matches eligible tokenId + ├── when token owner has balance less than quantity param + │ └── it should revert ✅ + └── when token owner has balance greater than or equal to quantity param + └── execution completes -- exit function ✅ \ No newline at end of file diff --git a/src/test/sdk/extension/upgradeable/contract-metadata/set-contract-uri/setContractURI.t.sol b/src/test/sdk/extension/upgradeable/contract-metadata/set-contract-uri/setContractURI.t.sol new file mode 100644 index 000000000..0be6c9030 --- /dev/null +++ b/src/test/sdk/extension/upgradeable/contract-metadata/set-contract-uri/setContractURI.t.sol @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import { ContractMetadata, IContractMetadata } from "contracts/extension/upgradeable/ContractMetadata.sol"; +import "../../../ExtensionUtilTest.sol"; + +contract MyContractMetadataUpg is ContractMetadata { + address admin; + + constructor(address _admin) { + admin = _admin; + } + + function _canSetContractURI() internal view override returns (bool) { + return msg.sender == admin; + } +} + +contract UpgradeableContractMetadata_SetContractURI is ExtensionUtilTest { + MyContractMetadataUpg internal ext; + address internal admin; + address internal caller; + string internal uri; + + event ContractURIUpdated(string prevURI, string newURI); + + function setUp() public override { + super.setUp(); + + admin = getActor(0); + caller = getActor(1); + uri = "ipfs://newUri"; + + ext = new MyContractMetadataUpg(address(admin)); + } + + function test_setContractURI_callerNotAuthorized() public { + vm.prank(address(caller)); + vm.expectRevert("Not authorized"); + ext.setContractURI(uri); + } + + modifier whenCallerAuthorized() { + caller = admin; + _; + } + + function test_setContractURI() public whenCallerAuthorized { + vm.prank(address(caller)); + ext.setContractURI(uri); + + string memory _updatedUri = ext.contractURI(); + assertEq(_updatedUri, uri); + } + + function test_setContractURI_event() public whenCallerAuthorized { + vm.prank(address(caller)); + vm.expectEmit(false, false, false, true); + emit ContractURIUpdated("", uri); + ext.setContractURI(uri); + } +} diff --git a/src/test/sdk/extension/upgradeable/contract-metadata/set-contract-uri/setContractURI.tree b/src/test/sdk/extension/upgradeable/contract-metadata/set-contract-uri/setContractURI.tree new file mode 100644 index 000000000..e626d76e4 --- /dev/null +++ b/src/test/sdk/extension/upgradeable/contract-metadata/set-contract-uri/setContractURI.tree @@ -0,0 +1,6 @@ +setContractURI(string memory uri) +├── when caller not authorized + │ └── it should revert ✅ + └── when caller is authorized + └── it should update contract URI to the new URI value ✅ + └── it should emit ContractURIUpdated event ✅ \ No newline at end of file diff --git a/src/test/sdk/extension/upgradeable/delayed-reveal/get-reveal-uri/getRevealURI.t.sol b/src/test/sdk/extension/upgradeable/delayed-reveal/get-reveal-uri/getRevealURI.t.sol new file mode 100644 index 000000000..62a10b844 --- /dev/null +++ b/src/test/sdk/extension/upgradeable/delayed-reveal/get-reveal-uri/getRevealURI.t.sol @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import { DelayedReveal, IDelayedReveal } from "contracts/extension/upgradeable/DelayedReveal.sol"; +import "../../../ExtensionUtilTest.sol"; + +contract MyDelayedRevealUpg is DelayedReveal { + function setEncryptedData(uint256 _batchId, bytes memory _encryptedData) external { + _setEncryptedData(_batchId, _encryptedData); + } + + function reveal(uint256 identifier, bytes calldata key) external returns (string memory revealedURI) {} +} + +contract UpgradeableDelayedReveal_GetRevealURI is ExtensionUtilTest { + MyDelayedRevealUpg internal ext; + string internal originalURI; + bytes internal encryptionKey; + bytes internal encryptedURI; + bytes internal encryptedData; + uint256 internal batchId; + bytes32 internal provenanceHash; + + function setUp() public override { + super.setUp(); + + ext = new MyDelayedRevealUpg(); + originalURI = "ipfs://original"; + encryptionKey = "key123"; + batchId = 1; + + provenanceHash = keccak256(abi.encodePacked(originalURI, encryptionKey, block.chainid)); + encryptedURI = ext.encryptDecrypt(bytes(originalURI), encryptionKey); + encryptedData = abi.encode(encryptedURI, provenanceHash); + } + + function test_getRevealURI_encryptedDataNotSet() public { + vm.expectRevert("Nothing to reveal"); + ext.getRevealURI(batchId, encryptionKey); + } + + modifier whenEncryptedDataIsSet() { + ext.setEncryptedData(batchId, encryptedData); + _; + } + + function test_getRevealURI_incorrectKey() public whenEncryptedDataIsSet { + bytes memory incorrectKey = "incorrect key"; + + vm.expectRevert("Incorrect key"); + ext.getRevealURI(batchId, incorrectKey); + } + + modifier whenCorrectKey() { + _; + } + + function test_getRevealURI() public whenEncryptedDataIsSet whenCorrectKey { + string memory revealedURI = ext.getRevealURI(batchId, encryptionKey); + + assertEq(originalURI, revealedURI); + } +} diff --git a/src/test/sdk/extension/upgradeable/delayed-reveal/get-reveal-uri/getRevealURI.tree b/src/test/sdk/extension/upgradeable/delayed-reveal/get-reveal-uri/getRevealURI.tree new file mode 100644 index 000000000..acb580468 --- /dev/null +++ b/src/test/sdk/extension/upgradeable/delayed-reveal/get-reveal-uri/getRevealURI.tree @@ -0,0 +1,8 @@ +getRevealURI(uint256 _batchId, bytes calldata _key) +├── when there is no encrypted data set for the given batch id + │ └── it should revert ✅ + └── when there is an associated encrypted data present for the given batch id + ├── when the encryption key provided is incorrect + │ └── it should revert ✅ + └── when the encryption key provided is correct + └── it should correctly decrypt and return the original URI ✅ \ No newline at end of file diff --git a/src/test/sdk/extension/upgradeable/delayed-reveal/set-encrypted-data/_setEncryptedData.t.sol b/src/test/sdk/extension/upgradeable/delayed-reveal/set-encrypted-data/_setEncryptedData.t.sol new file mode 100644 index 000000000..a499bad5e --- /dev/null +++ b/src/test/sdk/extension/upgradeable/delayed-reveal/set-encrypted-data/_setEncryptedData.t.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import { DelayedReveal, IDelayedReveal } from "contracts/extension/upgradeable/DelayedReveal.sol"; +import "../../../ExtensionUtilTest.sol"; + +contract MyDelayedRevealUpg is DelayedReveal { + function setEncryptedData(uint256 _batchId, bytes memory _encryptedData) external { + _setEncryptedData(_batchId, _encryptedData); + } + + function reveal(uint256 identifier, bytes calldata key) external returns (string memory revealedURI) {} +} + +contract UpgradeableDelayedReveal_SetEncryptedData is ExtensionUtilTest { + MyDelayedRevealUpg internal ext; + uint256 internal batchId; + bytes internal data; + + function setUp() public override { + super.setUp(); + + ext = new MyDelayedRevealUpg(); + batchId = 1; + data = "test"; + } + + function test_setEncryptedData() public { + ext.setEncryptedData(batchId, data); + + assertEq(true, ext.isEncryptedBatch(batchId)); + assertEq(ext.encryptedData(batchId), data); + } +} diff --git a/src/test/sdk/extension/upgradeable/delayed-reveal/set-encrypted-data/_setEncryptedData.tree b/src/test/sdk/extension/upgradeable/delayed-reveal/set-encrypted-data/_setEncryptedData.tree new file mode 100644 index 000000000..68f99a2c8 --- /dev/null +++ b/src/test/sdk/extension/upgradeable/delayed-reveal/set-encrypted-data/_setEncryptedData.tree @@ -0,0 +1,3 @@ +_setEncryptedData(uint256 _batchId, bytes memory _encryptedData) +├── it should store input bytes data for the given batch id param ✅ +├── isEncryptedBatch should return true for this batch id ✅ \ No newline at end of file diff --git a/src/test/sdk/extension/upgradeable/drop/claim/claim.t.sol b/src/test/sdk/extension/upgradeable/drop/claim/claim.t.sol new file mode 100644 index 000000000..ca65c8830 --- /dev/null +++ b/src/test/sdk/extension/upgradeable/drop/claim/claim.t.sol @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import { Drop, IDrop, IClaimConditionMultiPhase, IClaimCondition } from "contracts/extension/upgradeable/Drop.sol"; +import "../../../ExtensionUtilTest.sol"; + +contract MyDropUpg is Drop { + function _collectPriceOnClaim( + address _primarySaleRecipient, + uint256 _quantityToClaim, + address _currency, + uint256 _pricePerToken + ) internal override {} + + function _transferTokensOnClaim( + address _to, + uint256 _quantityBeingClaimed + ) internal override returns (uint256 startTokenId) {} + + function _canSetClaimConditions() internal view override returns (bool) { + return true; + } + + function verifyClaim( + uint256 _conditionId, + address _claimer, + uint256 _quantity, + address _currency, + uint256 _pricePerToken, + AllowlistProof calldata _allowlistProof + ) public view override returns (bool isOverride) {} +} + +contract UpgradeableDrop_Claim is ExtensionUtilTest { + MyDropUpg internal ext; + + address internal _claimer; + uint256 internal _quantity; + address internal _currency; + uint256 internal _pricePerToken; + IDrop.AllowlistProof internal _allowlistProof; + + IClaimCondition.ClaimCondition[] internal claimConditions; + + function setUp() public override { + super.setUp(); + + ext = new MyDropUpg(); + _claimer = getActor(1); + _quantity = 10; + } + + function _setConditionsState() public { + // values here are not important (except timestamp), since we won't be verifying claim params + + claimConditions.push( + IClaimCondition.ClaimCondition({ + startTimestamp: 0, + maxClaimableSupply: 100, + supplyClaimed: 0, + quantityLimitPerWallet: 1, + merkleRoot: bytes32(0), + pricePerToken: 10, + currency: address(erc20), + metadata: "" + }) + ); + ext.setClaimConditions(claimConditions, false); + } + + function test_claim_noConditionsSet() public { + vm.expectRevert("!CONDITION."); + ext.claim(_claimer, _quantity, _currency, _pricePerToken, _allowlistProof, ""); + } + + modifier whenConditionsAreSet() { + _setConditionsState(); + _; + } + + function test_claim() public whenConditionsAreSet { + // claim + vm.prank(_claimer); + ext.claim(_claimer, _quantity, _currency, _pricePerToken, _allowlistProof, ""); + + uint256 supplyClaimedByWallet_1 = ext.getSupplyClaimedByWallet(0, _claimer); + uint256 supplyClaimed_1 = (ext.getClaimConditionById(0)).supplyClaimed; + + // claim again + vm.prank(_claimer); + ext.claim(_claimer, _quantity, _currency, _pricePerToken, _allowlistProof, ""); + + uint256 supplyClaimedByWallet_2 = ext.getSupplyClaimedByWallet(0, _claimer); + uint256 supplyClaimed_2 = (ext.getClaimConditionById(0)).supplyClaimed; + + // check state + assertEq(supplyClaimedByWallet_1, _quantity); + assertEq(supplyClaimedByWallet_2, supplyClaimedByWallet_1 + _quantity); + + assertEq(supplyClaimed_1, _quantity); + assertEq(supplyClaimed_2, supplyClaimed_1 + _quantity); + } +} diff --git a/src/test/sdk/extension/upgradeable/drop/claim/claim.tree b/src/test/sdk/extension/upgradeable/drop/claim/claim.tree new file mode 100644 index 000000000..4ca1d3187 --- /dev/null +++ b/src/test/sdk/extension/upgradeable/drop/claim/claim.tree @@ -0,0 +1,15 @@ +claim( + address _receiver, + uint256 _quantity, + address _currency, + uint256 _pricePerToken, + AllowlistProof calldata _allowlistProof, + bytes memory _data +) +├── when no active condition + │ └── it should revert ✅ + └── when there's an active condition + └── it should increase the supplyClaimed for that condition by quantity param input ✅ + └── it should increase the supplyClaimedByWallet for that condition and msg.sender by quantity param input ✅ + +(Note: verifyClaim function has been tested separately, and hence not being tested here) \ No newline at end of file diff --git a/src/test/sdk/extension/upgradeable/drop/get-active-claim-condition-id/getActiveClaimConditionId.t.sol b/src/test/sdk/extension/upgradeable/drop/get-active-claim-condition-id/getActiveClaimConditionId.t.sol new file mode 100644 index 000000000..3fdd2dce2 --- /dev/null +++ b/src/test/sdk/extension/upgradeable/drop/get-active-claim-condition-id/getActiveClaimConditionId.t.sol @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import { Drop, IDrop, IClaimConditionMultiPhase, IClaimCondition } from "contracts/extension/upgradeable/Drop.sol"; +import "../../../ExtensionUtilTest.sol"; + +contract MyDropUpg is Drop { + function _collectPriceOnClaim( + address _primarySaleRecipient, + uint256 _quantityToClaim, + address _currency, + uint256 _pricePerToken + ) internal override {} + + function _transferTokensOnClaim( + address _to, + uint256 _quantityBeingClaimed + ) internal override returns (uint256 startTokenId) {} + + function _canSetClaimConditions() internal view override returns (bool) { + return true; + } +} + +contract UpgradeableDrop_GetActiveClaimConditionId is ExtensionUtilTest { + MyDropUpg internal ext; + + IClaimCondition.ClaimCondition[] internal claimConditions; + + function setUp() public override { + super.setUp(); + + ext = new MyDropUpg(); + } + + function _setConditionsState() public { + claimConditions.push( + IClaimCondition.ClaimCondition({ + startTimestamp: 100, + maxClaimableSupply: 100, + supplyClaimed: 0, + quantityLimitPerWallet: 1, + merkleRoot: bytes32(0), + pricePerToken: 10, + currency: address(erc20), + metadata: "" + }) + ); + + claimConditions.push( + IClaimCondition.ClaimCondition({ + startTimestamp: 200, + maxClaimableSupply: 100, + supplyClaimed: 0, + quantityLimitPerWallet: 1, + merkleRoot: bytes32(0), + pricePerToken: 10, + currency: address(erc20), + metadata: "" + }) + ); + + claimConditions.push( + IClaimCondition.ClaimCondition({ + startTimestamp: 300, + maxClaimableSupply: 100, + supplyClaimed: 0, + quantityLimitPerWallet: 1, + merkleRoot: bytes32(0), + pricePerToken: 10, + currency: address(erc20), + metadata: "" + }) + ); + + ext.setClaimConditions(claimConditions, false); + } + + function test_getActiveClaimConditionId_noConditionsSet() public { + vm.expectRevert("!CONDITION."); + ext.getActiveClaimConditionId(); + } + + modifier whenConditionsAreSet() { + _setConditionsState(); + _; + } + + function test_getActiveClaimConditionId_noActiveCondition() public whenConditionsAreSet { + vm.expectRevert("!CONDITION."); + ext.getActiveClaimConditionId(); + } + + modifier whenActiveConditions() { + _; + } + + function test_getActiveClaimConditionId_activeConditions() public whenConditionsAreSet whenActiveConditions { + vm.warp(claimConditions[0].startTimestamp); + + uint256 id = ext.getActiveClaimConditionId(); + assertEq(id, 0); + + vm.warp(claimConditions[1].startTimestamp); + + id = ext.getActiveClaimConditionId(); + assertEq(id, 1); + + vm.warp(claimConditions[2].startTimestamp); + + id = ext.getActiveClaimConditionId(); + assertEq(id, 2); + } +} diff --git a/src/test/sdk/extension/upgradeable/drop/get-active-claim-condition-id/getActiveClaimConditionId.tree b/src/test/sdk/extension/upgradeable/drop/get-active-claim-condition-id/getActiveClaimConditionId.tree new file mode 100644 index 000000000..8b8a94d99 --- /dev/null +++ b/src/test/sdk/extension/upgradeable/drop/get-active-claim-condition-id/getActiveClaimConditionId.tree @@ -0,0 +1,8 @@ +getActiveClaimConditionId() +├── when no conditions are set + │ └── it should revert ✅ + └── when condition(s) are set + ├── when no active condition, i.e. start timestamps of all conditions greater than block timestamp + │ └── it should revert ✅ + └── when conditions active, i.e. start timestamps at least one condition is less than or equal to the block timestamp + └── it should return the latest active claim condition id (i.e. with highest start timestamp among those active) ✅ \ No newline at end of file diff --git a/src/test/sdk/extension/upgradeable/drop/set-claim-conditions/setClaimConditions.t.sol b/src/test/sdk/extension/upgradeable/drop/set-claim-conditions/setClaimConditions.t.sol new file mode 100644 index 000000000..cd96e3970 --- /dev/null +++ b/src/test/sdk/extension/upgradeable/drop/set-claim-conditions/setClaimConditions.t.sol @@ -0,0 +1,379 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import { Drop, IDrop, IClaimConditionMultiPhase, IClaimCondition } from "contracts/extension/upgradeable/Drop.sol"; +import "../../../ExtensionUtilTest.sol"; + +contract MyDropUpg is Drop { + address admin; + + constructor(address _admin) { + admin = _admin; + } + + function _collectPriceOnClaim( + address _primarySaleRecipient, + uint256 _quantityToClaim, + address _currency, + uint256 _pricePerToken + ) internal override {} + + function _transferTokensOnClaim( + address _to, + uint256 _quantityBeingClaimed + ) internal override returns (uint256 startTokenId) {} + + function _canSetClaimConditions() internal view override returns (bool) { + return msg.sender == admin; + } + + /** + * note: the functions below are dummy functions for test purposes, + * to directly access and set/reset state without going through the actual functions and checks + */ + + function setCondition(ClaimCondition calldata condition, uint256 _conditionId) public { + _dropStorage().claimCondition.conditions[_conditionId] = condition; + } + + function setSupplyClaimedForCondition(uint256 _conditionId, uint256 _supplyClaimed) public { + _dropStorage().claimCondition.conditions[_conditionId].supplyClaimed = _supplyClaimed; + } +} + +contract UpgradeableDrop_SetClaimConditions is ExtensionUtilTest { + MyDropUpg internal ext; + address internal admin; + + IClaimCondition.ClaimCondition[] internal newClaimConditions; + IClaimCondition.ClaimCondition[] internal oldClaimConditions; + + event ClaimConditionsUpdated(IClaimCondition.ClaimCondition[] claimConditions, bool resetEligibility); + + function setUp() public override { + super.setUp(); + + admin = getActor(0); + ext = new MyDropUpg(admin); + + _setOldConditionsState(); + + newClaimConditions.push( + IClaimCondition.ClaimCondition({ + startTimestamp: 100, + maxClaimableSupply: 100, + supplyClaimed: 0, + quantityLimitPerWallet: 1, + merkleRoot: bytes32(0), + pricePerToken: 10, + currency: address(erc20), + metadata: "" + }) + ); + + newClaimConditions.push( + IClaimCondition.ClaimCondition({ + startTimestamp: 200, + maxClaimableSupply: 100, + supplyClaimed: 0, + quantityLimitPerWallet: 1, + merkleRoot: bytes32(0), + pricePerToken: 10, + currency: address(erc20), + metadata: "" + }) + ); + } + + function _setOldConditionsState() public { + oldClaimConditions.push( + IClaimCondition.ClaimCondition({ + startTimestamp: 10, + maxClaimableSupply: 100, + supplyClaimed: 0, + quantityLimitPerWallet: 1, + merkleRoot: bytes32(0), + pricePerToken: 10, + currency: address(erc20), + metadata: "" + }) + ); + + oldClaimConditions.push( + IClaimCondition.ClaimCondition({ + startTimestamp: 20, + maxClaimableSupply: 100, + supplyClaimed: 0, + quantityLimitPerWallet: 1, + merkleRoot: bytes32(0), + pricePerToken: 10, + currency: address(erc20), + metadata: "" + }) + ); + + oldClaimConditions.push( + IClaimCondition.ClaimCondition({ + startTimestamp: 30, + maxClaimableSupply: 100, + supplyClaimed: 0, + quantityLimitPerWallet: 1, + merkleRoot: bytes32(0), + pricePerToken: 10, + currency: address(erc20), + metadata: "" + }) + ); + + vm.prank(admin); + ext.setClaimConditions(oldClaimConditions, false); + (, uint256 count) = ext.claimCondition(); + assertEq(count, oldClaimConditions.length); + + ext.setSupplyClaimedForCondition(0, 5); + ext.setSupplyClaimedForCondition(0, 20); + ext.setSupplyClaimedForCondition(0, 100); + } + + function test_setClaimConditions_notAuthorized() public { + vm.expectRevert("Not authorized"); + ext.setClaimConditions(newClaimConditions, false); + + vm.expectRevert("Not authorized"); + ext.setClaimConditions(newClaimConditions, true); + } + + modifier whenCallerAuthorized() { + vm.startPrank(admin); + _; + vm.stopPrank(); + } + + function test_setClaimConditions_incorrectStartTimestamps() public whenCallerAuthorized { + // reverse the order of timestamps + newClaimConditions[0].startTimestamp = newClaimConditions[1].startTimestamp + 100; + + vm.expectRevert(bytes("ST")); + ext.setClaimConditions(newClaimConditions, false); + + vm.expectRevert(bytes("ST")); + ext.setClaimConditions(newClaimConditions, true); + } + + modifier whenCorrectTimestamps() { + _; + } + + // ================== + // ======= Test branch: claim eligibility reset + // ================== + + function test_setClaimConditions_resetEligibility_startIndex() public whenCallerAuthorized whenCorrectTimestamps { + (, uint256 oldCount) = ext.claimCondition(); + + ext.setClaimConditions(newClaimConditions, true); + + (uint256 newStartIndex, ) = ext.claimCondition(); + assertEq(newStartIndex, oldCount); + } + + function test_setClaimConditions_resetEligibility_conditionCount() + public + whenCallerAuthorized + whenCorrectTimestamps + { + (, uint256 oldCount) = ext.claimCondition(); + assertEq(oldCount, oldClaimConditions.length); + + ext.setClaimConditions(newClaimConditions, true); + + (uint256 newStartIndex, uint256 newCount) = ext.claimCondition(); + assertEq(newCount, newClaimConditions.length); + } + + function test_setClaimConditions_resetEligibility_conditionState() + public + whenCallerAuthorized + whenCorrectTimestamps + { + ext.setClaimConditions(newClaimConditions, true); + + (uint256 newStartIndex, uint256 newCount) = ext.claimCondition(); + + for (uint256 i = 0; i < newCount; i++) { + IClaimCondition.ClaimCondition memory _claimCondition = ext.getClaimConditionById(i + newStartIndex); + + assertEq(_claimCondition.startTimestamp, newClaimConditions[i].startTimestamp); + assertEq(_claimCondition.maxClaimableSupply, newClaimConditions[i].maxClaimableSupply); + assertEq(_claimCondition.supplyClaimed, 0); + } + } + + function test_setClaimConditions_resetEligibility_oldConditionsDeleted() + public + whenCallerAuthorized + whenCorrectTimestamps + { + (uint256 oldStartIndex, uint256 oldCount) = ext.claimCondition(); + assertEq(oldCount, oldClaimConditions.length); + + ext.setClaimConditions(newClaimConditions, true); + + for (uint256 i = 0; i < oldCount; i++) { + IClaimCondition.ClaimCondition memory _claimCondition = ext.getClaimConditionById(i + oldStartIndex); + + assertEq(_claimCondition.startTimestamp, 0); + assertEq(_claimCondition.maxClaimableSupply, 0); + assertEq(_claimCondition.supplyClaimed, 0); + assertEq(_claimCondition.quantityLimitPerWallet, 0); + assertEq(_claimCondition.merkleRoot, bytes32(0)); + assertEq(_claimCondition.pricePerToken, 0); + assertEq(_claimCondition.currency, address(0)); + assertEq(_claimCondition.metadata, ""); + } + } + + function test_setClaimConditions_resetEligibility_event() public whenCallerAuthorized whenCorrectTimestamps { + // TODO: fix/review event data check by setting last param true + vm.expectEmit(false, false, false, false); + emit ClaimConditionsUpdated(newClaimConditions, true); + ext.setClaimConditions(newClaimConditions, true); + } + + // ================== + // ======= Test branch: claim eligibility not reset + // ================== + + function test_setClaimConditions_noReset_maxClaimableLessThanClaimed() + public + whenCallerAuthorized + whenCorrectTimestamps + { + IClaimCondition.ClaimCondition memory _oldCondition = ext.getClaimConditionById(0); + + // set new maxClaimableSupply less than supplyClaimed of the old condition + newClaimConditions[0].maxClaimableSupply = _oldCondition.supplyClaimed - 1; + + vm.expectRevert("max supply claimed"); + ext.setClaimConditions(newClaimConditions, false); + } + + modifier whenMaxClaimableNotLessThanClaimed() { + _; + } + + function test_setClaimConditions_noReset_startIndex() + public + whenCallerAuthorized + whenCorrectTimestamps + whenMaxClaimableNotLessThanClaimed + { + (uint256 oldStartIndex, ) = ext.claimCondition(); + + ext.setClaimConditions(newClaimConditions, false); + + (uint256 newStartIndex, ) = ext.claimCondition(); + assertEq(newStartIndex, oldStartIndex); + } + + function test_setClaimConditions_noReset_conditionCount() + public + whenCallerAuthorized + whenCorrectTimestamps + whenMaxClaimableNotLessThanClaimed + { + (, uint256 oldCount) = ext.claimCondition(); + assertEq(oldCount, oldClaimConditions.length); + + ext.setClaimConditions(newClaimConditions, false); + + (uint256 newStartIndex, uint256 newCount) = ext.claimCondition(); + assertEq(newCount, newClaimConditions.length); + } + + function test_setClaimConditions_noReset_conditionState() + public + whenCallerAuthorized + whenCorrectTimestamps + whenMaxClaimableNotLessThanClaimed + { + (, uint256 oldCount) = ext.claimCondition(); + + // setting array size as this way to avoid out-of-bound error in the second loop + uint256 length = newClaimConditions.length > oldCount ? newClaimConditions.length : oldCount; + IClaimCondition.ClaimCondition[] memory _oldConditions = new IClaimCondition.ClaimCondition[](length); + + for (uint256 i = 0; i < oldCount; i++) { + _oldConditions[i] = ext.getClaimConditionById(i); + } + + ext.setClaimConditions(newClaimConditions, false); + + (uint256 newStartIndex, uint256 newCount) = ext.claimCondition(); + + for (uint256 i = 0; i < newCount; i++) { + IClaimCondition.ClaimCondition memory _claimCondition = ext.getClaimConditionById(i + newStartIndex); + + assertEq(_claimCondition.startTimestamp, newClaimConditions[i].startTimestamp); + assertEq(_claimCondition.maxClaimableSupply, newClaimConditions[i].maxClaimableSupply); + assertEq(_claimCondition.supplyClaimed, _oldConditions[i].supplyClaimed); + } + } + + function test_setClaimConditions_resetEligibility_oldConditionsDeletedOrReplaced() + public + whenCallerAuthorized + whenCorrectTimestamps + whenMaxClaimableNotLessThanClaimed + { + (uint256 oldStartIndex, uint256 oldCount) = ext.claimCondition(); + assertEq(oldCount, oldClaimConditions.length); + + ext.setClaimConditions(newClaimConditions, false); + (, uint256 newCount) = ext.claimCondition(); + + for (uint256 i = 0; i < oldCount; i++) { + IClaimCondition.ClaimCondition memory _claimCondition = ext.getClaimConditionById(i + oldStartIndex); + + if (i >= newCount) { + // case where deleted + + assertEq(_claimCondition.startTimestamp, 0); + assertEq(_claimCondition.maxClaimableSupply, 0); + assertEq(_claimCondition.supplyClaimed, 0); + assertEq(_claimCondition.quantityLimitPerWallet, 0); + assertEq(_claimCondition.merkleRoot, bytes32(0)); + assertEq(_claimCondition.pricePerToken, 0); + assertEq(_claimCondition.currency, address(0)); + assertEq(_claimCondition.metadata, ""); + } else { + // case where replaced + + // supply claimed should be same as old condition, hence not checked below + + assertEq(_claimCondition.startTimestamp, newClaimConditions[i].startTimestamp); + assertEq(_claimCondition.maxClaimableSupply, newClaimConditions[i].maxClaimableSupply); + assertEq(_claimCondition.quantityLimitPerWallet, newClaimConditions[i].quantityLimitPerWallet); + assertEq(_claimCondition.merkleRoot, newClaimConditions[i].merkleRoot); + assertEq(_claimCondition.pricePerToken, newClaimConditions[i].pricePerToken); + assertEq(_claimCondition.currency, newClaimConditions[i].currency); + assertEq(_claimCondition.metadata, newClaimConditions[i].metadata); + } + } + } + + function test_setClaimConditions_noReset_event() + public + whenCallerAuthorized + whenCorrectTimestamps + whenMaxClaimableNotLessThanClaimed + { + // TODO: fix/review event data check by setting last param true + vm.expectEmit(false, false, false, false); + emit ClaimConditionsUpdated(newClaimConditions, false); + ext.setClaimConditions(newClaimConditions, false); + } +} diff --git a/src/test/sdk/extension/upgradeable/drop/set-claim-conditions/setClaimConditions.tree b/src/test/sdk/extension/upgradeable/drop/set-claim-conditions/setClaimConditions.tree new file mode 100644 index 000000000..aecd6b06f --- /dev/null +++ b/src/test/sdk/extension/upgradeable/drop/set-claim-conditions/setClaimConditions.tree @@ -0,0 +1,24 @@ +setClaimConditions(ClaimCondition[] calldata _conditions, bool _resetClaimEligibility) +├── when caller not authorized + │ └── it should revert ✅ + └── when caller is authorized + ├── when start timestamps of new conditions aren't in ascending order + │ └── it should revert ✅ + └── when start timestamps of new conditions are in ascending order + ├── when claim eligibility is reset + │ └── it should set new conditions start index as the count of old conditions ✅ + │ └── it should set claim condition count equal to the count of new conditions ✅ + │ └── it should correctly save all new conditions at right index ✅ + │ └── it should set supply claimed for each condition equal to 0 ✅ + │ └── it should delete all old conditions (i.e. all conditions with index less than new start index) ✅ + │ └── it should emit ClaimConditionsUpdated event ✅ + └── when claim eligibility is not reset + ├── when maxClaimableSupply of a new condition is less than supplyClaimed of the old condition (at that index) + │ └── it should revert ✅ + └── when maxClaimableSupply of a new condition is greater than or equal to supplyClaimed of the old condition (at that index) + └── it should set new conditions start index same as old start index ✅ + └── it should set claim condition count equal to the count of new conditions ✅ + └── it should correctly save all new conditions at right index ✅ + └── it should set supply claimed for each condition equal to what it was in old condition (at that index) ✅ + └── it should delete all old conditions with index exceeding new count, in case new count is less than previous count ✅ + └── it should emit ClaimConditionsUpdated event ✅ \ No newline at end of file diff --git a/src/test/sdk/extension/upgradeable/drop/verify-claim/verifyClaim.t.sol b/src/test/sdk/extension/upgradeable/drop/verify-claim/verifyClaim.t.sol new file mode 100644 index 000000000..3cd3372a1 --- /dev/null +++ b/src/test/sdk/extension/upgradeable/drop/verify-claim/verifyClaim.t.sol @@ -0,0 +1,379 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import { Drop, IDrop, IClaimConditionMultiPhase, IClaimCondition } from "contracts/extension/upgradeable/Drop.sol"; +import "../../../ExtensionUtilTest.sol"; + +contract MyDropUpg is Drop { + function _collectPriceOnClaim( + address _primarySaleRecipient, + uint256 _quantityToClaim, + address _currency, + uint256 _pricePerToken + ) internal override {} + + function _transferTokensOnClaim( + address _to, + uint256 _quantityBeingClaimed + ) internal override returns (uint256 startTokenId) {} + + function _canSetClaimConditions() internal view override returns (bool) { + return true; + } + + /** + * note: the functions below are dummy functions for test purposes, + * to directly access and set/reset state without going through the actual functions and checks + */ + + function setCondition(ClaimCondition calldata condition, uint256 _conditionId) public { + _dropStorage().claimCondition.conditions[_conditionId] = condition; + } + + function setSupplyClaimedByWallet(uint256 _conditionId, address _wallet, uint256 _supplyClaimed) public { + _dropStorage().claimCondition.supplyClaimedByWallet[_conditionId][_wallet] = _supplyClaimed; + } +} + +contract UpgradeableDrop_VerifyClaim is ExtensionUtilTest { + MyDropUpg internal ext; + + uint256 internal _conditionId; + address internal _claimer; + address internal _allowlistClaimer; + uint256 internal _quantity; + address internal _currency; + uint256 internal _pricePerToken; + IDrop.AllowlistProof internal _allowlistProof; + IDrop.AllowlistProof internal _allowlistProofEmpty; // will leave uninitialized + + IClaimCondition.ClaimCondition internal claimCondition; + IClaimCondition.ClaimCondition internal claimConditionWithAllowlist; + + function setUp() public override { + super.setUp(); + + ext = new MyDropUpg(); + + _claimer = getActor(1); + _allowlistClaimer = address(0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd); + + // claim condition without allowlist + claimCondition = IClaimCondition.ClaimCondition({ + startTimestamp: 1000, + maxClaimableSupply: 100, + supplyClaimed: 0, + quantityLimitPerWallet: 1, + merkleRoot: bytes32(0), + pricePerToken: 10, + currency: address(erc20), + metadata: "" + }); + + // claim condition with allowlist -- set defaults for now + claimConditionWithAllowlist = claimCondition; + (_allowlistProof, claimConditionWithAllowlist.merkleRoot) = _setAllowlistAndProofs( + 0, // default + type(uint256).max, // default + address(0) // default + ); + } + + function _setAllowlistAndProofs( + uint256 _quantity, + uint256 _price, + address _currency + ) internal returns (IDrop.AllowlistProof memory, bytes32) { + string[] memory inputs = new string[](5); + + inputs[0] = "node"; + inputs[1] = "src/test/scripts/generateRoot.ts"; + inputs[2] = Strings.toString(_quantity); + inputs[3] = Strings.toString(_price); + inputs[4] = Strings.toHexString(uint160(_currency)); + + bytes memory result = vm.ffi(inputs); + // revert(); + bytes32 root = abi.decode(result, (bytes32)); + + inputs[1] = "src/test/scripts/getProof.ts"; + result = vm.ffi(inputs); + bytes32[] memory proofs = abi.decode(result, (bytes32[])); + + IDrop.AllowlistProof memory alp; + alp.proof = proofs; + alp.quantityLimitPerWallet = _quantity; + alp.pricePerToken = _price; + alp.currency = address(_currency); + + return (alp, root); + } + + // ================== + // ======= Test branch: when no allowlist + // ================== + + function test_verifyClaim_noAllowlist_invalidCurrency() public { + ext.setCondition(claimCondition, _conditionId); + + vm.expectRevert("!PriceOrCurrency"); + ext.verifyClaim(_conditionId, _claimer, _quantity, _currency, _pricePerToken, _allowlistProofEmpty); + } + + modifier whenValidCurrency_open() { + _currency = claimCondition.currency; + _; + } + + function test_verifyClaim_noAllowlist_invalidPrice() public whenValidCurrency_open { + ext.setCondition(claimCondition, _conditionId); + + vm.expectRevert("!PriceOrCurrency"); + ext.verifyClaim(_conditionId, _claimer, _quantity, _currency, _pricePerToken, _allowlistProofEmpty); + } + + modifier whenValidPrice_open() { + _pricePerToken = claimCondition.pricePerToken; + _; + } + + function test_verifyClaim_noAllowlist_zeroQuantity() public whenValidCurrency_open whenValidPrice_open { + ext.setCondition(claimCondition, _conditionId); + + _quantity = 0; + vm.expectRevert(bytes("!Qty")); + ext.verifyClaim(_conditionId, _claimer, _quantity, _currency, _pricePerToken, _allowlistProofEmpty); + } + + modifier whenNonZeroQuantity() { + _quantity = claimCondition.quantityLimitPerWallet + 1234; + _; + } + + function test_verifyClaim_noAllowlist_nonZeroInvalidQuantity() + public + whenValidCurrency_open + whenValidPrice_open + whenNonZeroQuantity + { + ext.setCondition(claimCondition, _conditionId); + ext.setSupplyClaimedByWallet(_conditionId, _claimer, claimCondition.quantityLimitPerWallet); + + vm.expectRevert(bytes("!Qty")); + ext.verifyClaim(_conditionId, _claimer, _quantity, _currency, _pricePerToken, _allowlistProofEmpty); + } + + modifier whenValidQuantity_open() { + _quantity = 1; + _; + } + + function test_verifyClaim_noAllowlist_quantityMoreThanMaxClaimableSupply() + public + whenValidCurrency_open + whenValidPrice_open + whenNonZeroQuantity + whenValidQuantity_open + { + claimCondition.supplyClaimed = claimCondition.maxClaimableSupply; + ext.setCondition(claimCondition, _conditionId); + + vm.expectRevert("!MaxSupply"); + ext.verifyClaim(_conditionId, _claimer, _quantity, _currency, _pricePerToken, _allowlistProofEmpty); + } + + modifier whenQuantityWithinMaxLimit() { + _; + } + + function test_verifyClaim_noAllowlist_beforeStartTimestamp() + public + whenValidCurrency_open + whenValidPrice_open + whenNonZeroQuantity + whenValidQuantity_open + whenQuantityWithinMaxLimit + { + ext.setCondition(claimCondition, _conditionId); + + vm.expectRevert("cant claim yet"); + ext.verifyClaim(_conditionId, _claimer, _quantity, _currency, _pricePerToken, _allowlistProofEmpty); + } + + modifier whenValidTimestamp() { + vm.warp(claimCondition.startTimestamp); + _; + } + + function test_verifyClaim_noAllowlist() + public + whenValidCurrency_open + whenValidPrice_open + whenNonZeroQuantity + whenValidQuantity_open + whenQuantityWithinMaxLimit + whenValidTimestamp + { + ext.setCondition(claimCondition, _conditionId); + + ext.verifyClaim(_conditionId, _claimer, _quantity, _currency, _pricePerToken, _allowlistProofEmpty); + } + + // ================== + // ======= Test branch: allowlist but incorrect proof -- open limits should apply + // ================== + + function test_verifyClaim_incorrectProof_invalidCurrency() public { + ext.setCondition(claimConditionWithAllowlist, _conditionId); + + vm.expectRevert("!PriceOrCurrency"); + ext.verifyClaim(_conditionId, _claimer, _quantity, _currency, _pricePerToken, _allowlistProofEmpty); + } + + function test_verifyClaim_incorrectProof_invalidPrice() public whenValidCurrency_open { + ext.setCondition(claimConditionWithAllowlist, _conditionId); + + vm.expectRevert("!PriceOrCurrency"); + ext.verifyClaim(_conditionId, _claimer, _quantity, _currency, _pricePerToken, _allowlistProofEmpty); + } + + function test_verifyClaim_incorrectProof_zeroQuantity() public whenValidCurrency_open whenValidPrice_open { + ext.setCondition(claimConditionWithAllowlist, _conditionId); + + _quantity = 0; + vm.expectRevert(bytes("!Qty")); + ext.verifyClaim(_conditionId, _claimer, _quantity, _currency, _pricePerToken, _allowlistProofEmpty); + } + + function test_verifyClaim_incorrectProof_nonZeroInvalidQuantity() + public + whenValidCurrency_open + whenValidPrice_open + whenNonZeroQuantity + { + ext.setCondition(claimConditionWithAllowlist, _conditionId); + ext.setSupplyClaimedByWallet(_conditionId, _claimer, claimCondition.quantityLimitPerWallet); + + vm.expectRevert(bytes("!Qty")); + ext.verifyClaim(_conditionId, _claimer, _quantity, _currency, _pricePerToken, _allowlistProofEmpty); + } + + function test_verifyClaim_incorrectProof() + public + whenValidCurrency_open + whenValidPrice_open + whenNonZeroQuantity + whenValidQuantity_open + whenQuantityWithinMaxLimit + whenValidTimestamp + { + ext.setCondition(claimConditionWithAllowlist, _conditionId); + + ext.verifyClaim(_conditionId, _claimer, _quantity, _currency, _pricePerToken, _allowlistProofEmpty); + } + + // ================== + // ======= Test branch: allowlist with correct proof + // ================== + + function test_verifyClaim_allowlist_defaultPriceAndCurrency_invalidCurrencyParam() public { + ext.setCondition(claimConditionWithAllowlist, _conditionId); + + vm.expectRevert("!PriceOrCurrency"); + ext.verifyClaim(_conditionId, _allowlistClaimer, _quantity, _currency, _pricePerToken, _allowlistProof); + } + + function test_verifyClaim_allowlist_defaultPriceNonDefaultCurrenct_invalidCurrencyParam() public { + (_allowlistProof, claimConditionWithAllowlist.merkleRoot) = _setAllowlistAndProofs( + 0, // default + type(uint256).max, // default + address(weth) + ); + ext.setCondition(claimConditionWithAllowlist, _conditionId); + + vm.expectRevert("!PriceOrCurrency"); + ext.verifyClaim(_conditionId, _allowlistClaimer, _quantity, _currency, _pricePerToken, _allowlistProof); + } + + function test_verifyClaim_allowlist_nonDefaultPriceAndCurrency_invalidCurrencyParam() public { + (_allowlistProof, claimConditionWithAllowlist.merkleRoot) = _setAllowlistAndProofs( + 0, // default + 2, + address(weth) + ); + ext.setCondition(claimConditionWithAllowlist, _conditionId); + + vm.expectRevert("!PriceOrCurrency"); + ext.verifyClaim(_conditionId, _allowlistClaimer, _quantity, _currency, _pricePerToken, _allowlistProof); + } + + function test_verifyClaim_allowlist_defaultQuantity_invalidQuantityParam() public { + (_allowlistProof, claimConditionWithAllowlist.merkleRoot) = _setAllowlistAndProofs( + 0, // default + 2, + address(weth) + ); + ext.setCondition(claimConditionWithAllowlist, _conditionId); + ext.setSupplyClaimedByWallet( + _conditionId, + _allowlistClaimer, + claimConditionWithAllowlist.quantityLimitPerWallet + ); + + _currency = address(weth); + _pricePerToken = 2; + _quantity = 1; + vm.expectRevert(bytes("!Qty")); + ext.verifyClaim(_conditionId, _allowlistClaimer, _quantity, _currency, _pricePerToken, _allowlistProof); + } + + function test_verifyClaim_allowlist_nonDefaultQuantity_invalidQuantityParam() public { + (_allowlistProof, claimConditionWithAllowlist.merkleRoot) = _setAllowlistAndProofs(5, 2, address(weth)); + ext.setCondition(claimConditionWithAllowlist, _conditionId); + ext.setSupplyClaimedByWallet(_conditionId, _allowlistClaimer, 5); + + _currency = address(weth); + _pricePerToken = 2; + _quantity = 1; + vm.expectRevert(bytes("!Qty")); + ext.verifyClaim(_conditionId, _allowlistClaimer, _quantity, _currency, _pricePerToken, _allowlistProof); + } + + function test_verifyClaim_allowlist_defaultPrice_invalidPriceParam() public { + (_allowlistProof, claimConditionWithAllowlist.merkleRoot) = _setAllowlistAndProofs( + 5, + type(uint256).max, // default + address(weth) + ); + ext.setCondition(claimConditionWithAllowlist, _conditionId); + + _currency = address(weth); + _quantity = 1; + vm.expectRevert(bytes("!PriceOrCurrency")); + ext.verifyClaim(_conditionId, _allowlistClaimer, _quantity, _currency, _pricePerToken, _allowlistProof); + } + + function test_verifyClaim_allowlist_nonDefaultPrice_invalidPriceParam() public { + (_allowlistProof, claimConditionWithAllowlist.merkleRoot) = _setAllowlistAndProofs(5, 1, address(weth)); + ext.setCondition(claimConditionWithAllowlist, _conditionId); + + _currency = address(weth); + _quantity = 1; + _pricePerToken = 2; + vm.expectRevert(bytes("!PriceOrCurrency")); + ext.verifyClaim(_conditionId, _allowlistClaimer, _quantity, _currency, _pricePerToken, _allowlistProof); + } + + function test_verifyClaim_allowlist() public whenQuantityWithinMaxLimit whenValidTimestamp { + (_allowlistProof, claimConditionWithAllowlist.merkleRoot) = _setAllowlistAndProofs(5, 1, address(weth)); + ext.setCondition(claimConditionWithAllowlist, _conditionId); + + _currency = address(weth); + _quantity = 1; + _pricePerToken = 1; + ext.verifyClaim(_conditionId, _allowlistClaimer, _quantity, _currency, _pricePerToken, _allowlistProof); + } +} diff --git a/src/test/sdk/extension/upgradeable/drop/verify-claim/verifyClaim.tree b/src/test/sdk/extension/upgradeable/drop/verify-claim/verifyClaim.tree new file mode 100644 index 000000000..ef84f4ef2 --- /dev/null +++ b/src/test/sdk/extension/upgradeable/drop/verify-claim/verifyClaim.tree @@ -0,0 +1,67 @@ +verifyClaim( + uint256 conditionId, + address claimer, + uint256 quantity, + address currency, + uint256 pricePerToken, + AllowlistProof calldata allowlistProof +) +├── when no allowlist + └── when currency param not equal to open claim currency + │ └── it should revert ✅ + └── when currency param is equal to open claim currency + └── when pricePerToken param not equal to open claim price + │ └── it should revert ✅ + └── when pricePerToken param is equal to open claim price + └── when quantity param is 0 + │ └── it should revert ✅ + └── when quantity param is not 0 + └── when quantity param plus supply claimed is more than open claim limit + │ └── it should revert ✅ + └── when quantity param plus supply claimed is within open claim limit + └── when quantity param plus claimed supply is more than max claimable supply + │ └── it should revert ✅ + └── when quantity param plus claimed supply is within max claimable supply limit + └── when block timestamp is less than start timestamp of claim phase + │ └── it should revert ✅ + └── when block timestamp is greater than or equal to start timestamp of claim phase + └── execution completes -- exit function ✅ + +├── when allowlist but incorrect merkle proof + └── when currency param not equal to open claim currency + │ └── it should revert ✅ + └── when currency param is equal to open claim currency + └── when pricePerToken param not equal to open claim price + │ └── it should revert ✅ + └── when pricePerToken param is equal to open claim price + └── when quantity param is 0 + │ └── it should revert ✅ + └── when quantity param is not 0 + └── when quantity param plus supply claimed is more than open claim limit + │ └── it should revert ✅ + +├── when allowlist and correct merkle proof + └── when allowlist price is default max uint256 and allowlist currency is default address(0) + │ └── when currency param not equal to open claim currency + │ └── it should revert ✅ + └── when allowlist price is default max uint256 and allowlist currency is not default + │ └── when currency param not equal to open claim currency + │ └── it should revert ✅ + └── when allowlist price is not default and allowlist currency is default address(0) + │ └── when currency param not equal to open claim currency + │ └── it should revert ✅ + └── when allowlist price is not default and allowlist currency is not default + │ └── when currency param not equal to allowlist claim currency + │ └── it should revert ✅ + └── when allowlist quantity is default 0 + │ └── when nonzero quantity param plus supply claimed is more than open claim limit + │ └── it should revert ✅ + └── when allowlist quantity is not default + │ └── when nonzero quantity param plus supply claimed is more than allowlist claim limit + │ └── it should revert ✅ + └── when allowlist price is default max uint256 + │ └── when pricePerToken param not equal to open claim price + │ └── it should revert ✅ + └── when allowlist price is not default + │ └── when pricePerToken param not equal to allowlist claim price + │ └── it should revert ✅ \ No newline at end of file diff --git a/src/test/sdk/extension/upgradeable/lazy-mint/lazy-mint/lazyMint.t.sol b/src/test/sdk/extension/upgradeable/lazy-mint/lazy-mint/lazyMint.t.sol new file mode 100644 index 000000000..97fec025d --- /dev/null +++ b/src/test/sdk/extension/upgradeable/lazy-mint/lazy-mint/lazyMint.t.sol @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import { LazyMint, BatchMintMetadata } from "contracts/extension/upgradeable/LazyMint.sol"; +import "../../../ExtensionUtilTest.sol"; + +contract MyLazyMintUpg is LazyMint { + address admin; + + constructor(address _admin) { + admin = _admin; + } + + function _canLazyMint() internal view override returns (bool) { + return msg.sender == admin; + } + + function getBaseURI(uint256 _tokenId) external view returns (string memory) { + return _getBaseURI(_tokenId); + } + + function getBatchStartId(uint256 _batchID) public view returns (uint256) { + return _getBatchStartId(_batchID); + } + + function nextTokenIdToMint() public view returns (uint256) { + return nextTokenIdToLazyMint(); + } +} + +contract UpgradeableLazyMint_LazyMint is ExtensionUtilTest { + MyLazyMintUpg internal ext; + uint256 internal startId; + uint256 internal amount; + uint256[] internal batchIds; + address internal admin; + address internal caller; + + event TokensLazyMinted(uint256 indexed startTokenId, uint256 endTokenId, string baseURI, bytes encryptedBaseURI); + + function setUp() public override { + super.setUp(); + + admin = getActor(0); + caller = getActor(1); + + ext = new MyLazyMintUpg(address(admin)); + + startId = 0; + // mint 5 batches + vm.startPrank(admin); + for (uint256 i = 0; i < 5; i++) { + uint256 _amount = (i + 1) * 10; + uint256 batchId = startId + _amount; + batchIds.push(batchId); + + string memory baseURI = Strings.toString(batchId); + startId = ext.lazyMint(_amount, baseURI, ""); + } + vm.stopPrank(); + } + + function test_lazyMint_callerNotAuthorized() public { + vm.prank(address(caller)); + vm.expectRevert("Not authorized"); + ext.lazyMint(amount, "", ""); + } + + modifier whenCallerAuthorized() { + caller = admin; + _; + } + + function test_lazyMint_zeroAmount() public whenCallerAuthorized { + vm.prank(address(caller)); + vm.expectRevert("0 amt"); + ext.lazyMint(amount, "", ""); + } + + modifier whenAmountNotZero() { + amount = 50; + _; + } + + function test_lazyMint() public whenCallerAuthorized whenAmountNotZero { + // check previous state + uint256 _nextTokenIdToLazyMintOld = ext.nextTokenIdToMint(); + assertEq(_nextTokenIdToLazyMintOld, batchIds[4]); + + string memory baseURI = "ipfs://baseURI"; + + // lazy mint next batch + vm.prank(address(caller)); + uint256 _batchId = ext.lazyMint(amount, baseURI, ""); + + // check new state + uint256 _batchStartId = ext.getBatchStartId(_batchId); + assertEq(_nextTokenIdToLazyMintOld, _batchStartId); + assertEq(_batchId, _nextTokenIdToLazyMintOld + amount); + for (uint256 i = _batchStartId; i < _batchId; i++) { + assertEq(ext.getBaseURI(i), baseURI); + } + assertEq(ext.nextTokenIdToMint(), _nextTokenIdToLazyMintOld + amount); + } + + function test_lazyMint_event() public whenCallerAuthorized whenAmountNotZero { + string memory baseURI = "ipfs://baseURI"; + uint256 _nextTokenIdToLazyMintOld = ext.nextTokenIdToMint(); + + // lazy mint next batch + vm.prank(address(caller)); + vm.expectEmit(); + emit TokensLazyMinted(_nextTokenIdToLazyMintOld, _nextTokenIdToLazyMintOld + amount - 1, baseURI, ""); + ext.lazyMint(amount, baseURI, ""); + } +} diff --git a/src/test/sdk/extension/upgradeable/lazy-mint/lazy-mint/lazyMint.tree b/src/test/sdk/extension/upgradeable/lazy-mint/lazy-mint/lazyMint.tree new file mode 100644 index 000000000..daf177146 --- /dev/null +++ b/src/test/sdk/extension/upgradeable/lazy-mint/lazy-mint/lazyMint.tree @@ -0,0 +1,17 @@ +lazyMint( + uint256 _amount, + string calldata _baseURIForTokens, + bytes calldata _data +) +├── when caller not authorized + │ └── it should revert ✅ + └── when caller is authorized + └── when amount to lazy mint is 0 + │ └── it should revert ✅ + └── when amount to lazy mint is not 0 + └── it should save the batch of tokens starting at `nextTokenIdToLazyMint` ✅ + └── it should store batch id equal to the sum of `nextTokenIdToLazyMint` and `_amount` ✅ + └── it should map the new batch id to `_baseURIForTokens` ✅ + └── it should increase `nextTokenIdToLazyMint` by `_amount` ✅ + └── it should return the new `batchId` ✅ + └── it should emit TokensLazyMinted event ✅ \ No newline at end of file diff --git a/src/test/sdk/extension/upgradeable/ownable/set-owner/setOwner.t.sol b/src/test/sdk/extension/upgradeable/ownable/set-owner/setOwner.t.sol new file mode 100644 index 000000000..6c938158f --- /dev/null +++ b/src/test/sdk/extension/upgradeable/ownable/set-owner/setOwner.t.sol @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import { Ownable, IOwnable } from "contracts/extension/upgradeable/Ownable.sol"; +import "../../../ExtensionUtilTest.sol"; + +contract MyOwnableUpg is Ownable { + address admin; + + constructor(address _admin) { + admin = _admin; + } + + function _canSetOwner() internal view override returns (bool) { + return msg.sender == admin; + } +} + +contract UpgradeableOwnable_SetOwner is ExtensionUtilTest { + MyOwnableUpg internal ext; + address internal admin; + address internal caller; + address internal oldOwner; + address internal newOwner; + + event OwnerUpdated(address indexed prevOwner, address indexed newOwner); + + function setUp() public override { + super.setUp(); + + admin = getActor(0); + caller = getActor(1); + + oldOwner = getActor(2); + newOwner = getActor(3); + + ext = new MyOwnableUpg(address(admin)); + + vm.prank(address(admin)); + ext.setOwner(oldOwner); + + assertEq(oldOwner, ext.owner()); + } + + function test_setOwner_callerNotAuthorized() public { + vm.prank(address(caller)); + vm.expectRevert("Not authorized"); + ext.setOwner(newOwner); + } + + modifier whenCallerAuthorized() { + caller = admin; + _; + } + + function test_setOwner() public whenCallerAuthorized { + vm.prank(address(caller)); + ext.setOwner(newOwner); + + assertEq(newOwner, ext.owner()); + } + + function test_setOwner_event() public whenCallerAuthorized { + vm.prank(address(caller)); + vm.expectEmit(true, true, false, false); + emit OwnerUpdated(oldOwner, newOwner); + ext.setOwner(newOwner); + } +} diff --git a/src/test/sdk/extension/upgradeable/ownable/set-owner/setOwner.tree b/src/test/sdk/extension/upgradeable/ownable/set-owner/setOwner.tree new file mode 100644 index 000000000..9db2c0a70 --- /dev/null +++ b/src/test/sdk/extension/upgradeable/ownable/set-owner/setOwner.tree @@ -0,0 +1,6 @@ +setContractURI(string memory uri) +├── when caller not authorized + │ └── it should revert ✅ + └── when caller is authorized + └── it should update owner by replacing old owner with the new owner input ✅ + └── it should emit OwnerUpdated event ✅ \ No newline at end of file diff --git a/src/test/sdk/extension/upgradeable/royalty/set-default-royalty-info/setDefaultRoyaltyInfo.t.sol b/src/test/sdk/extension/upgradeable/royalty/set-default-royalty-info/setDefaultRoyaltyInfo.t.sol new file mode 100644 index 000000000..e541be5ad --- /dev/null +++ b/src/test/sdk/extension/upgradeable/royalty/set-default-royalty-info/setDefaultRoyaltyInfo.t.sol @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import { Royalty, IRoyalty } from "contracts/extension/upgradeable/Royalty.sol"; +import "../../../ExtensionUtilTest.sol"; + +contract MyRoyaltyUpg is Royalty { + address admin; + + constructor(address _admin) { + admin = _admin; + } + + function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) {} + + function _canSetRoyaltyInfo() internal view override returns (bool) { + return msg.sender == admin; + } +} + +contract UpgradeableRoyalty_SetDefaultRoyaltyInfo is ExtensionUtilTest { + MyRoyaltyUpg internal ext; + address internal admin; + address internal caller; + address internal defaultRoyaltyRecipient; + uint256 internal defaultRoyaltyBps; + + event DefaultRoyalty(address indexed newRoyaltyRecipient, uint256 newRoyaltyBps); + + function setUp() public override { + super.setUp(); + + admin = getActor(0); + caller = getActor(1); + defaultRoyaltyRecipient = getActor(2); + + ext = new MyRoyaltyUpg(address(admin)); + } + + function test_setDefaultRoyaltyInfo_callerNotAuthorized() public { + vm.prank(address(caller)); + vm.expectRevert("Not authorized"); + ext.setDefaultRoyaltyInfo(defaultRoyaltyRecipient, defaultRoyaltyBps); + } + + modifier whenCallerAuthorized() { + caller = admin; + _; + } + + function test_setDefaultRoyaltyInfo_exceedMaxBps() public whenCallerAuthorized { + defaultRoyaltyBps = 10_001; + vm.prank(address(caller)); + vm.expectRevert("Exceeds max bps"); + ext.setDefaultRoyaltyInfo(defaultRoyaltyRecipient, defaultRoyaltyBps); + } + + modifier whenNotExceedMaxBps() { + defaultRoyaltyBps = 500; + _; + } + + function test_setDefaultRoyaltyInfo() public whenCallerAuthorized whenNotExceedMaxBps { + vm.prank(address(caller)); + ext.setDefaultRoyaltyInfo(defaultRoyaltyRecipient, defaultRoyaltyBps); + + // get default royalty info + (address _recipient, uint16 _royaltyBps) = ext.getDefaultRoyaltyInfo(); + assertEq(_recipient, defaultRoyaltyRecipient); + assertEq(_royaltyBps, uint16(defaultRoyaltyBps)); + + // get royalty info for token + uint256 tokenId = 0; + (_recipient, _royaltyBps) = ext.getRoyaltyInfoForToken(tokenId); + assertEq(_recipient, defaultRoyaltyRecipient); + assertEq(_royaltyBps, uint16(defaultRoyaltyBps)); + + // royaltyInfo - ERC2981 + uint256 salePrice = 1000; + (address _royaltyRecipient, uint256 _royaltyAmount) = ext.royaltyInfo(tokenId, salePrice); + assertEq(_royaltyRecipient, defaultRoyaltyRecipient); + assertEq(_royaltyAmount, (salePrice * defaultRoyaltyBps) / 10_000); + } + + function test_setDefaultRoyaltyInfo_event() public whenCallerAuthorized whenNotExceedMaxBps { + vm.prank(address(caller)); + vm.expectEmit(true, false, false, true); + emit DefaultRoyalty(defaultRoyaltyRecipient, defaultRoyaltyBps); + ext.setDefaultRoyaltyInfo(defaultRoyaltyRecipient, defaultRoyaltyBps); + } +} diff --git a/src/test/sdk/extension/upgradeable/royalty/set-default-royalty-info/setDefaultRoyaltyInfo.tree b/src/test/sdk/extension/upgradeable/royalty/set-default-royalty-info/setDefaultRoyaltyInfo.tree new file mode 100644 index 000000000..78a4312de --- /dev/null +++ b/src/test/sdk/extension/upgradeable/royalty/set-default-royalty-info/setDefaultRoyaltyInfo.tree @@ -0,0 +1,11 @@ +setDefaultRoyaltyInfo(address _royaltyRecipient, uint256 _royaltyBps) +├── when caller not authorized + │ └── it should revert ✅ + └── when caller is authorized + ├── when royalty bps input is greater than MAX_BPS + │ └── it should revert ✅ + └── when royalty bps input is less than or equal to MAX_BPS + └── it should update default royalty recipient ✅ + └── it should update default royalty bps ✅ + └── it should calculate royalty amount for a token-id based on default royalty info ✅ + └── it should emit DefaultRoyalty event ✅ \ No newline at end of file diff --git a/src/test/sdk/extension/upgradeable/royalty/set-royalty-info-for-token/setRoyaltyInfoForToken.t.sol b/src/test/sdk/extension/upgradeable/royalty/set-royalty-info-for-token/setRoyaltyInfoForToken.t.sol new file mode 100644 index 000000000..d28a142ee --- /dev/null +++ b/src/test/sdk/extension/upgradeable/royalty/set-royalty-info-for-token/setRoyaltyInfoForToken.t.sol @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; + +import { Royalty, IRoyalty } from "contracts/extension/upgradeable/Royalty.sol"; +import "../../../ExtensionUtilTest.sol"; + +contract MyRoyaltyUpg is Royalty { + address admin; + + constructor(address _admin) { + admin = _admin; + } + + function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) {} + + function _canSetRoyaltyInfo() internal view override returns (bool) { + return msg.sender == admin; + } +} + +contract UpgradeableRoyalty_SetRoyaltyInfoForToken is ExtensionUtilTest { + MyRoyaltyUpg internal ext; + address internal admin; + address internal caller; + address internal defaultRoyaltyRecipient; + uint256 internal defaultRoyaltyBps; + + address internal royaltyRecipientForToken; + uint256 internal royaltyBpsForToken; + uint256 internal tokenId; + + event RoyaltyForToken(uint256 indexed tokenId, address indexed royaltyRecipient, uint256 royaltyBps); + + function setUp() public override { + super.setUp(); + + admin = getActor(0); + caller = getActor(1); + defaultRoyaltyRecipient = getActor(2); + royaltyRecipientForToken = getActor(3); + defaultRoyaltyBps = 500; + tokenId = 1; + + ext = new MyRoyaltyUpg(address(admin)); + + vm.prank(address(admin)); + ext.setDefaultRoyaltyInfo(defaultRoyaltyRecipient, defaultRoyaltyBps); + } + + function test_setRoyaltyInfoForToken_callerNotAuthorized() public { + vm.prank(address(caller)); + vm.expectRevert("Not authorized"); + ext.setRoyaltyInfoForToken(tokenId, royaltyRecipientForToken, royaltyBpsForToken); + } + + modifier whenCallerAuthorized() { + caller = admin; + _; + } + + function test_setRoyaltyInfoForToken_exceedMaxBps() public whenCallerAuthorized { + royaltyBpsForToken = 10_001; + vm.prank(address(caller)); + vm.expectRevert("Exceeds max bps"); + ext.setRoyaltyInfoForToken(tokenId, royaltyRecipientForToken, royaltyBpsForToken); + } + + modifier whenNotExceedMaxBps() { + royaltyBpsForToken = 1000; + _; + } + + function test_setRoyaltyInfoForToken() public whenCallerAuthorized whenNotExceedMaxBps { + vm.prank(address(caller)); + ext.setRoyaltyInfoForToken(tokenId, royaltyRecipientForToken, royaltyBpsForToken); + + // get default royalty info + (address _defaultRecipient, uint16 _defaultRoyaltyBps) = ext.getDefaultRoyaltyInfo(); + assertEq(_defaultRecipient, defaultRoyaltyRecipient); + assertEq(_defaultRoyaltyBps, uint16(defaultRoyaltyBps)); + + // get royalty info for token + (address _royaltyRecipientForToken, uint16 _royaltyBpsForToken) = ext.getRoyaltyInfoForToken(tokenId); + assertEq(_royaltyRecipientForToken, royaltyRecipientForToken); + assertEq(_royaltyBpsForToken, uint16(royaltyBpsForToken)); + + // royaltyInfo - ERC2981: calculate for default + uint256 salePrice = 1000; + (address _royaltyRecipient, uint256 _royaltyAmount) = ext.royaltyInfo(0, salePrice); + assertEq(_royaltyRecipient, defaultRoyaltyRecipient); + assertEq(_royaltyAmount, (salePrice * defaultRoyaltyBps) / 10_000); + + // royaltyInfo - ERC2981: calculate for specific tokenId we set the royalty info for + (_royaltyRecipient, _royaltyAmount) = ext.royaltyInfo(tokenId, salePrice); + assertEq(_royaltyRecipient, royaltyRecipientForToken); + assertEq(_royaltyAmount, (salePrice * royaltyBpsForToken) / 10_000); + } + + function test_setRoyaltyInfoForToken_event() public whenCallerAuthorized whenNotExceedMaxBps { + vm.prank(address(caller)); + vm.expectEmit(true, true, false, true); + emit RoyaltyForToken(tokenId, royaltyRecipientForToken, royaltyBpsForToken); + ext.setRoyaltyInfoForToken(tokenId, royaltyRecipientForToken, royaltyBpsForToken); + } +} diff --git a/src/test/sdk/extension/upgradeable/royalty/set-royalty-info-for-token/setRoyaltyInfoForToken.tree b/src/test/sdk/extension/upgradeable/royalty/set-royalty-info-for-token/setRoyaltyInfoForToken.tree new file mode 100644 index 000000000..e28295634 --- /dev/null +++ b/src/test/sdk/extension/upgradeable/royalty/set-royalty-info-for-token/setRoyaltyInfoForToken.tree @@ -0,0 +1,15 @@ +function setRoyaltyInfoForToken( + uint256 _tokenId, + address _recipient, + uint256 _bps +) +├── when caller not authorized + │ └── it should revert ✅ + └── when caller is authorized + ├── when royalty bps input is greater than MAX_BPS + │ └── it should revert ✅ + └── when royalty bps input is less than or equal to MAX_BPS + └── it should update default royalty recipient ✅ + └── it should update default royalty bps ✅ + └── it should calculate royalty amount for a token-id based on default royalty info ✅ + └── it should emit DefaultRoyalty event ✅ \ No newline at end of file diff --git a/src/test/smart-wallet/Account.t.sol b/src/test/smart-wallet/Account.t.sol new file mode 100644 index 000000000..799ade4da --- /dev/null +++ b/src/test/smart-wallet/Account.t.sol @@ -0,0 +1,797 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +// Test utils +import "../utils/BaseTest.sol"; + +// Account Abstraction setup for smart wallets. +import { EntryPoint, IEntryPoint } from "contracts/prebuilts/account/utils/Entrypoint.sol"; +import { UserOperation } from "contracts/prebuilts/account/utils/UserOperation.sol"; + +// Target +import { IAccountPermissions } from "contracts/extension/interface/IAccountPermissions.sol"; +import { AccountFactory } from "contracts/prebuilts/account/non-upgradeable/AccountFactory.sol"; +import { Account as SimpleAccount } from "contracts/prebuilts/account/non-upgradeable/Account.sol"; +import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; + +/// @dev This is a dummy contract to test contract interactions with Account. +contract Number { + uint256 public num; + + function setNum(uint256 _num) public { + num = _num; + } + + function doubleNum() public { + num *= 2; + } + + function incrementNum() public { + num += 1; + } +} + +contract SimpleAccountTest is BaseTest { + // Target contracts + EntryPoint private entrypoint; + AccountFactory private accountFactory; + + // Mocks + Number internal numberContract; + + // Test params + uint256 private accountAdminPKey = 100; + address private accountAdmin; + + uint256 private accountSignerPKey = 200; + address private accountSigner; + + uint256 private nonSignerPKey = 300; + address private nonSigner; + + // UserOp terminology: `sender` is the smart wallet. + address private sender = 0x0df2C3523703d165Aa7fA1a552f3F0B56275DfC6; + address payable private beneficiary = payable(address(0x45654)); + + bytes32 private uidCache = bytes32("random uid"); + + event AccountCreated(address indexed account, address indexed accountAdmin); + + function _prepareSignature( + IAccountPermissions.SignerPermissionRequest memory _req + ) internal view returns (bytes32 typedDataHash) { + bytes32 typehashSignerPermissionRequest = keccak256( + "SignerPermissionRequest(address signer,uint8 isAdmin,address[] approvedTargets,uint256 nativeTokenLimitPerTransaction,uint128 permissionStartTimestamp,uint128 permissionEndTimestamp,uint128 reqValidityStartTimestamp,uint128 reqValidityEndTimestamp,bytes32 uid)" + ); + bytes32 nameHash = keccak256(bytes("Account")); + bytes32 versionHash = keccak256(bytes("1")); + bytes32 typehashEip712 = keccak256( + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" + ); + bytes32 domainSeparator = keccak256(abi.encode(typehashEip712, nameHash, versionHash, block.chainid, sender)); + + bytes memory encodedRequestStart = abi.encode( + typehashSignerPermissionRequest, + _req.signer, + _req.isAdmin, + keccak256(abi.encodePacked(_req.approvedTargets)), + _req.nativeTokenLimitPerTransaction + ); + + bytes memory encodedRequestEnd = abi.encode( + _req.permissionStartTimestamp, + _req.permissionEndTimestamp, + _req.reqValidityStartTimestamp, + _req.reqValidityEndTimestamp, + _req.uid + ); + + bytes32 structHash = keccak256(bytes.concat(encodedRequestStart, encodedRequestEnd)); + typedDataHash = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); + } + + function _signSignerPermissionRequest( + IAccountPermissions.SignerPermissionRequest memory _req + ) internal view returns (bytes memory signature) { + bytes32 typedDataHash = _prepareSignature(_req); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(accountAdminPKey, typedDataHash); + signature = abi.encodePacked(r, s, v); + } + + function _setupUserOp( + uint256 _signerPKey, + bytes memory _initCode, + bytes memory _callDataForEntrypoint + ) internal returns (UserOperation[] memory ops) { + uint256 nonce = entrypoint.getNonce(sender, 0); + + // Get user op fields + UserOperation memory op = UserOperation({ + sender: sender, + nonce: nonce, + initCode: _initCode, + callData: _callDataForEntrypoint, + callGasLimit: 500_000, + verificationGasLimit: 500_000, + preVerificationGas: 500_000, + maxFeePerGas: 0, + maxPriorityFeePerGas: 0, + paymasterAndData: bytes(""), + signature: bytes("") + }); + + // Sign UserOp + bytes32 opHash = EntryPoint(entrypoint).getUserOpHash(op); + bytes32 msgHash = ECDSA.toEthSignedMessageHash(opHash); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(_signerPKey, msgHash); + bytes memory userOpSignature = abi.encodePacked(r, s, v); + + address recoveredSigner = ECDSA.recover(msgHash, v, r, s); + address expectedSigner = vm.addr(_signerPKey); + assertEq(recoveredSigner, expectedSigner); + + op.signature = userOpSignature; + + // Store UserOp + ops = new UserOperation[](1); + ops[0] = op; + } + + function _setupUserOpWithSender( + bytes memory _initCode, + bytes memory _callDataForEntrypoint, + address _sender + ) internal returns (UserOperation[] memory ops) { + uint256 nonce = entrypoint.getNonce(_sender, 0); + + // Get user op fields + UserOperation memory op = UserOperation({ + sender: _sender, + nonce: nonce, + initCode: _initCode, + callData: _callDataForEntrypoint, + callGasLimit: 500_000, + verificationGasLimit: 500_000, + preVerificationGas: 500_000, + maxFeePerGas: 0, + maxPriorityFeePerGas: 0, + paymasterAndData: bytes(""), + signature: bytes("") + }); + + // Sign UserOp + bytes32 opHash = EntryPoint(entrypoint).getUserOpHash(op); + bytes32 msgHash = ECDSA.toEthSignedMessageHash(opHash); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(accountAdminPKey, msgHash); + bytes memory userOpSignature = abi.encodePacked(r, s, v); + + address recoveredSigner = ECDSA.recover(msgHash, v, r, s); + address expectedSigner = vm.addr(accountAdminPKey); + assertEq(recoveredSigner, expectedSigner); + + op.signature = userOpSignature; + + // Store UserOp + ops = new UserOperation[](1); + ops[0] = op; + } + + function _setupUserOpExecuteWithSender( + bytes memory _initCode, + address _target, + uint256 _value, + bytes memory _callData, + address _sender + ) internal returns (UserOperation[] memory) { + bytes memory callDataForEntrypoint = abi.encodeWithSignature( + "execute(address,uint256,bytes)", + _target, + _value, + _callData + ); + + return _setupUserOpWithSender(_initCode, callDataForEntrypoint, _sender); + } + + function _setupUserOpExecute( + uint256 _signerPKey, + bytes memory _initCode, + address _target, + uint256 _value, + bytes memory _callData + ) internal returns (UserOperation[] memory) { + bytes memory callDataForEntrypoint = abi.encodeWithSignature( + "execute(address,uint256,bytes)", + _target, + _value, + _callData + ); + + return _setupUserOp(_signerPKey, _initCode, callDataForEntrypoint); + } + + function _setupUserOpExecuteBatch( + uint256 _signerPKey, + bytes memory _initCode, + address[] memory _target, + uint256[] memory _value, + bytes[] memory _callData + ) internal returns (UserOperation[] memory) { + bytes memory callDataForEntrypoint = abi.encodeWithSignature( + "executeBatch(address[],uint256[],bytes[])", + _target, + _value, + _callData + ); + + return _setupUserOp(_signerPKey, _initCode, callDataForEntrypoint); + } + + /// @dev Returns the salt used when deploying an Account. + function _generateSalt(address _admin, bytes memory _data) internal view virtual returns (bytes32) { + return keccak256(abi.encode(_admin, _data)); + } + + function setUp() public override { + super.setUp(); + + // Setup signers. + accountAdmin = vm.addr(accountAdminPKey); + vm.deal(accountAdmin, 100 ether); + + accountSigner = vm.addr(accountSignerPKey); + nonSigner = vm.addr(nonSignerPKey); + + // Setup contracts + entrypoint = new EntryPoint(); + // deploy account factory + accountFactory = new AccountFactory(deployer, IEntryPoint(payable(address(entrypoint)))); + // deploy dummy contract + numberContract = new Number(); + } + + /*/////////////////////////////////////////////////////////////// + Test: creating an account + //////////////////////////////////////////////////////////////*/ + + /// @dev Create an account by directly calling the factory. + function test_state_createAccount_viaFactory() public { + vm.expectEmit(true, true, false, true); + emit AccountCreated(sender, accountAdmin); + accountFactory.createAccount(accountAdmin, bytes("")); + + address[] memory allAccounts = accountFactory.getAllAccounts(); + assertEq(allAccounts.length, 1); + assertEq(allAccounts[0], sender); + } + + /// @dev Create an account via Entrypoint. + function test_state_createAccount_viaEntrypoint() public { + bytes memory initCallData = abi.encodeWithSignature("createAccount(address,bytes)", accountAdmin, bytes("")); + bytes memory initCode = abi.encodePacked(abi.encodePacked(address(accountFactory)), initCallData); + + UserOperation[] memory userOpCreateAccount = _setupUserOpExecute( + accountAdminPKey, + initCode, + address(0), + 0, + bytes("") + ); + + vm.expectEmit(true, true, false, true); + emit AccountCreated(sender, accountAdmin); + EntryPoint(entrypoint).handleOps(userOpCreateAccount, beneficiary); + + address[] memory allAccounts = accountFactory.getAllAccounts(); + assertEq(allAccounts.length, 1); + assertEq(allAccounts[0], sender); + } + + /// @dev Try registering with factory with a contract not deployed by factory. + function test_revert_onRegister_nonFactoryChildContract() public { + vm.prank(address(0x12345)); + vm.expectRevert("AccountFactory: not an account."); + accountFactory.onRegister(_generateSalt(accountAdmin, "")); + } + + /// @dev Create more than one accounts with the same admin. + function test_state_createAccount_viaEntrypoint_multipleAccountSameAdmin() public { + uint256 start = 0; + uint256 end = 0; + + assertEq(accountFactory.totalAccounts(), 0); + + vm.expectRevert("BaseAccountFactory: invalid indices"); + address[] memory accs = accountFactory.getAccounts(start, end); + + uint256 amount = 100; + + for (uint256 i = 0; i < amount; i += 1) { + bytes memory initCallData = abi.encodeWithSignature( + "createAccount(address,bytes)", + accountAdmin, + bytes(abi.encode(i)) + ); + bytes memory initCode = abi.encodePacked(abi.encodePacked(address(accountFactory)), initCallData); + + address expectedSenderAddress = Clones.predictDeterministicAddress( + accountFactory.accountImplementation(), + _generateSalt(accountAdmin, bytes(abi.encode(i))), + address(accountFactory) + ); + + UserOperation[] memory userOpCreateAccount = _setupUserOpExecuteWithSender( + initCode, + address(0), + 0, + bytes(abi.encode(i)), + expectedSenderAddress + ); + + vm.expectEmit(true, true, false, true); + emit AccountCreated(expectedSenderAddress, accountAdmin); + EntryPoint(entrypoint).handleOps(userOpCreateAccount, beneficiary); + } + + address[] memory allAccounts = accountFactory.getAllAccounts(); + assertEq(allAccounts.length, amount); + assertEq(accountFactory.totalAccounts(), amount); + + for (uint256 i = 0; i < amount; i += 1) { + assertEq( + allAccounts[i], + Clones.predictDeterministicAddress( + accountFactory.accountImplementation(), + _generateSalt(accountAdmin, bytes(abi.encode(i))), + address(accountFactory) + ) + ); + } + + start = 25; + end = 75; + + address[] memory accountsPaginatedOne = accountFactory.getAccounts(start, end); + + for (uint256 i = 0; i < (end - start); i += 1) { + assertEq( + accountsPaginatedOne[i], + Clones.predictDeterministicAddress( + accountFactory.accountImplementation(), + _generateSalt(accountAdmin, bytes(abi.encode(start + i))), + address(accountFactory) + ) + ); + } + + start = 0; + end = amount; + + address[] memory accountsPaginatedTwo = accountFactory.getAccounts(start, end); + + for (uint256 i = 0; i < (end - start); i += 1) { + assertEq( + accountsPaginatedTwo[i], + Clones.predictDeterministicAddress( + accountFactory.accountImplementation(), + _generateSalt(accountAdmin, bytes(abi.encode(start + i))), + address(accountFactory) + ) + ); + } + + start = 75; + end = 25; + + vm.expectRevert("BaseAccountFactory: invalid indices"); + accs = accountFactory.getAccounts(start, end); + + start = 25; + end = amount + 1; + + vm.expectRevert("BaseAccountFactory: invalid indices"); + accs = accountFactory.getAccounts(start, end); + } + + /*/////////////////////////////////////////////////////////////// + Test: performing a contract call + //////////////////////////////////////////////////////////////*/ + + function _setup_executeTransaction() internal { + bytes memory initCallData = abi.encodeWithSignature("createAccount(address,bytes)", accountAdmin, bytes("")); + bytes memory initCode = abi.encodePacked(abi.encodePacked(address(accountFactory)), initCallData); + + UserOperation[] memory userOpCreateAccount = _setupUserOpExecute( + accountAdminPKey, + initCode, + address(0), + 0, + bytes("") + ); + + EntryPoint(entrypoint).handleOps(userOpCreateAccount, beneficiary); + } + + /// @dev Perform a state changing transaction directly via account. + function test_state_executeTransaction() public { + _setup_executeTransaction(); + + address account = accountFactory.getAddress(accountAdmin, bytes("")); + + assertEq(numberContract.num(), 0); + + vm.prank(accountAdmin); + SimpleAccount(payable(account)).execute( + address(numberContract), + 0, + abi.encodeWithSignature("setNum(uint256)", 42) + ); + + assertEq(numberContract.num(), 42); + } + + /// @dev Perform many state changing transactions in a batch directly via account. + function test_state_executeBatchTransaction() public { + _setup_executeTransaction(); + + address account = accountFactory.getAddress(accountAdmin, bytes("")); + + assertEq(numberContract.num(), 0); + + uint256 count = 3; + address[] memory targets = new address[](count); + uint256[] memory values = new uint256[](count); + bytes[] memory callData = new bytes[](count); + + for (uint256 i = 0; i < count; i += 1) { + targets[i] = address(numberContract); + values[i] = 0; + callData[i] = abi.encodeWithSignature("incrementNum()", i); + } + + vm.prank(accountAdmin); + SimpleAccount(payable(account)).executeBatch(targets, values, callData); + + assertEq(numberContract.num(), count); + } + + /// @dev Perform a state changing transaction via Entrypoint. + function test_state_executeTransaction_viaEntrypoint() public { + _setup_executeTransaction(); + + assertEq(numberContract.num(), 0); + + UserOperation[] memory userOp = _setupUserOpExecute( + accountAdminPKey, + bytes(""), + address(numberContract), + 0, + abi.encodeWithSignature("setNum(uint256)", 42) + ); + + EntryPoint(entrypoint).handleOps(userOp, beneficiary); + + assertEq(numberContract.num(), 42); + } + + /// @dev Perform many state changing transactions in a batch via Entrypoint. + function test_state_executeBatchTransaction_viaEntrypoint() public { + _setup_executeTransaction(); + + assertEq(numberContract.num(), 0); + + uint256 count = 3; + address[] memory targets = new address[](count); + uint256[] memory values = new uint256[](count); + bytes[] memory callData = new bytes[](count); + + for (uint256 i = 0; i < count; i += 1) { + targets[i] = address(numberContract); + values[i] = 0; + callData[i] = abi.encodeWithSignature("incrementNum()", i); + } + + UserOperation[] memory userOp = _setupUserOpExecuteBatch( + accountAdminPKey, + bytes(""), + targets, + values, + callData + ); + + EntryPoint(entrypoint).handleOps(userOp, beneficiary); + + assertEq(numberContract.num(), count); + } + + /// @dev Perform many state changing transactions in a batch via Entrypoint. + function test_state_executeBatchTransaction_viaAccountSigner() public { + _setup_executeTransaction(); + + assertEq(numberContract.num(), 0); + + uint256 count = 3; + address[] memory targets = new address[](count); + uint256[] memory values = new uint256[](count); + bytes[] memory callData = new bytes[](count); + + for (uint256 i = 0; i < count; i += 1) { + targets[i] = address(numberContract); + values[i] = 0; + callData[i] = abi.encodeWithSignature("incrementNum()", i); + } + + address account = accountFactory.getAddress(accountAdmin, bytes("")); + + address[] memory approvedTargets = new address[](1); + approvedTargets[0] = address(numberContract); + + IAccountPermissions.SignerPermissionRequest memory permissionsReq = IAccountPermissions.SignerPermissionRequest( + accountSigner, + 0, + approvedTargets, + 1 ether, + 0, + type(uint128).max, + 0, + type(uint128).max, + uidCache + ); + + vm.prank(accountAdmin); + bytes memory sig = _signSignerPermissionRequest(permissionsReq); + SimpleAccount(payable(account)).setPermissionsForSigner(permissionsReq, sig); + + UserOperation[] memory userOp = _setupUserOpExecuteBatch( + accountSignerPKey, + bytes(""), + targets, + values, + callData + ); + + EntryPoint(entrypoint).handleOps(userOp, beneficiary); + + assertEq(numberContract.num(), count); + } + + /// @dev Perform a state changing transaction via Entrypoint and a SIGNER_ROLE holder. + function test_state_executeTransaction_viaAccountSigner() public { + _setup_executeTransaction(); + + address account = accountFactory.getAddress(accountAdmin, bytes("")); + + address[] memory approvedTargets = new address[](1); + approvedTargets[0] = address(numberContract); + + IAccountPermissions.SignerPermissionRequest memory permissionsReq = IAccountPermissions.SignerPermissionRequest( + accountSigner, + 0, + approvedTargets, + 1 ether, + 0, + type(uint128).max, + 0, + type(uint128).max, + uidCache + ); + + vm.prank(accountAdmin); + bytes memory sig = _signSignerPermissionRequest(permissionsReq); + SimpleAccount(payable(account)).setPermissionsForSigner(permissionsReq, sig); + + assertEq(numberContract.num(), 0); + + UserOperation[] memory userOp = _setupUserOpExecute( + accountSignerPKey, + bytes(""), + address(numberContract), + 0, + abi.encodeWithSignature("setNum(uint256)", 42) + ); + + EntryPoint(entrypoint).handleOps(userOp, beneficiary); + + assertEq(numberContract.num(), 42); + } + + /// @dev Revert: perform a state changing transaction via Entrypoint without appropriate permissions. + function test_revert_executeTransaction_nonSigner_viaEntrypoint() public { + _setup_executeTransaction(); + + assertEq(numberContract.num(), 0); + + UserOperation[] memory userOp = _setupUserOpExecute( + accountSignerPKey, + bytes(""), + address(numberContract), + 0, + abi.encodeWithSignature("setNum(uint256)", 42) + ); + + vm.expectRevert(); + EntryPoint(entrypoint).handleOps(userOp, beneficiary); + } + + /// @dev Revert: non-admin performs a state changing transaction directly via account contract. + function test_revert_executeTransaction_nonSigner_viaDirectCall() public { + _setup_executeTransaction(); + + address account = accountFactory.getAddress(accountAdmin, bytes("")); + address[] memory approvedTargets = new address[](1); + approvedTargets[0] = address(numberContract); + IAccountPermissions.SignerPermissionRequest memory permissionsReq = IAccountPermissions.SignerPermissionRequest( + accountSigner, + 0, + approvedTargets, + 1 ether, + 0, + type(uint128).max, + 0, + type(uint128).max, + uidCache + ); + + vm.prank(accountAdmin); + bytes memory sig = _signSignerPermissionRequest(permissionsReq); + SimpleAccount(payable(account)).setPermissionsForSigner(permissionsReq, sig); + + assertEq(numberContract.num(), 0); + + vm.prank(accountSigner); + vm.expectRevert("Account: not admin or EntryPoint."); + SimpleAccount(payable(account)).execute( + address(numberContract), + 0, + abi.encodeWithSignature("setNum(uint256)", 42) + ); + } + + /*/////////////////////////////////////////////////////////////// + Test: receiving and sending native tokens + //////////////////////////////////////////////////////////////*/ + + /// @dev Send native tokens to an account. + function test_state_accountReceivesNativeTokens() public { + _setup_executeTransaction(); + + address account = accountFactory.getAddress(accountAdmin, bytes("")); + + assertEq(address(account).balance, 0); + + vm.prank(accountAdmin); + // solhint-disable-next-line avoid-low-level-calls + (bool success, bytes memory data) = payable(account).call{ value: 1000 }(""); + + // Silence warning: Return value of low-level calls not used. + (success, data) = (success, data); + + assertEq(address(account).balance, 1000); + } + + /// @dev Transfer native tokens out of an account. + function test_state_transferOutsNativeTokens() public { + _setup_executeTransaction(); + + uint256 value = 1000; + + address account = accountFactory.getAddress(accountAdmin, bytes("")); + vm.prank(accountAdmin); + // solhint-disable-next-line avoid-low-level-calls + (bool success, bytes memory data) = payable(account).call{ value: value }(""); + assertEq(address(account).balance, value); + + // Silence warning: Return value of low-level calls not used. + (success, data) = (success, data); + + address recipient = address(0x3456); + + UserOperation[] memory userOp = _setupUserOpExecute(accountAdminPKey, bytes(""), recipient, value, bytes("")); + + EntryPoint(entrypoint).handleOps(userOp, beneficiary); + assertEq(address(account).balance, 0); + assertEq(recipient.balance, value); + } + + /// @dev Add and remove a deposit for the account from the Entrypoint. + + function test_state_addAndWithdrawDeposit() public { + _setup_executeTransaction(); + + address account = accountFactory.getAddress(accountAdmin, bytes("")); + + assertEq(EntryPoint(entrypoint).balanceOf(account), 0); + + vm.prank(accountAdmin); + SimpleAccount(payable(account)).addDeposit{ value: 1000 }(); + assertEq(EntryPoint(entrypoint).balanceOf(account), 1000); + + vm.prank(accountAdmin); + SimpleAccount(payable(account)).withdrawDepositTo(payable(accountSigner), 500); + assertEq(EntryPoint(entrypoint).balanceOf(account), 500); + } + + /*/////////////////////////////////////////////////////////////// + Test: receiving ERC-721 and ERC-1155 NFTs + //////////////////////////////////////////////////////////////*/ + + /// @dev Send an ERC-721 NFT to an account. + function test_state_receiveERC721NFT() public { + _setup_executeTransaction(); + address account = accountFactory.getAddress(accountAdmin, bytes("")); + + assertEq(erc721.balanceOf(account), 0); + + erc721.mint(account, 1); + + assertEq(erc721.balanceOf(account), 1); + } + + /// @dev Send an ERC-1155 NFT to an account. + function test_state_receiveERC1155NFT() public { + _setup_executeTransaction(); + address account = accountFactory.getAddress(accountAdmin, bytes("")); + + assertEq(erc1155.balanceOf(account, 0), 0); + + erc1155.mint(account, 0, 1); + + assertEq(erc1155.balanceOf(account, 0), 1); + } + + /*/////////////////////////////////////////////////////////////// + Test: setting contract metadata + //////////////////////////////////////////////////////////////*/ + + /// @dev Set contract metadata via admin or entrypoint. + function test_state_contractMetadata() public { + _setup_executeTransaction(); + address account = accountFactory.getAddress(accountAdmin, bytes("")); + + vm.prank(accountAdmin); + SimpleAccount(payable(account)).setContractURI("https://example.com"); + assertEq(SimpleAccount(payable(account)).contractURI(), "https://example.com"); + + UserOperation[] memory userOp = _setupUserOpExecute( + accountAdminPKey, + bytes(""), + address(account), + 0, + abi.encodeWithSignature("setContractURI(string)", "https://thirdweb.com") + ); + + EntryPoint(entrypoint).handleOps(userOp, beneficiary); + assertEq(SimpleAccount(payable(account)).contractURI(), "https://thirdweb.com"); + + address[] memory approvedTargets = new address[](0); + + IAccountPermissions.SignerPermissionRequest memory permissionsReq = IAccountPermissions.SignerPermissionRequest( + accountSigner, + 0, + approvedTargets, + 1 ether, + 0, + type(uint128).max, + 0, + type(uint128).max, + uidCache + ); + + vm.prank(accountAdmin); + bytes memory sig = _signSignerPermissionRequest(permissionsReq); + SimpleAccount(payable(account)).setPermissionsForSigner(permissionsReq, sig); + + UserOperation[] memory userOpViaSigner = _setupUserOpExecute( + accountSignerPKey, + bytes(""), + address(account), + 0, + abi.encodeWithSignature("setContractURI(string)", "https://thirdweb.com") + ); + + vm.expectRevert(); + EntryPoint(entrypoint).handleOps(userOpViaSigner, beneficiary); + } +} diff --git a/src/test/smart-wallet/AccountVulnPOC.t.sol b/src/test/smart-wallet/AccountVulnPOC.t.sol new file mode 100644 index 000000000..b9087f70d --- /dev/null +++ b/src/test/smart-wallet/AccountVulnPOC.t.sol @@ -0,0 +1,315 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +// Test utils +import "../utils/BaseTest.sol"; +// Account Abstraction setup for smart wallets. +import { EntryPoint, IEntryPoint } from "contracts/prebuilts/account/utils/Entrypoint.sol"; +import { UserOperation } from "contracts/prebuilts/account/utils/UserOperation.sol"; +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +// Target +import { IAccountPermissions } from "contracts/extension/interface/IAccountPermissions.sol"; +import { AccountFactory, Account as SimpleAccount } from "contracts/prebuilts/account/non-upgradeable/AccountFactory.sol"; +import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; + +library GPv2EIP1271 { + bytes4 internal constant MAGICVALUE = 0x1626ba7e; +} + +interface EIP1271Verifier { + function isValidSignature(bytes32 _hash, bytes memory _signature) external view returns (bytes4 magicValue); +} + +/// @dev This is a dummy contract to test contract interactions with Account. +contract Number { + uint256 public num; + + function setNum(uint256 _num) public { + num = _num; + } + + function doubleNum() public { + num *= 2; + } + + function incrementNum() public { + num += 1; + } + + function setNumBySignature(address owner, uint256 newNum, bytes calldata signature) public { + if (owner.code.length == 0) { + // Signature verification by ECDSA + } else { + // Signature verification by EIP1271 + bytes32 digest = keccak256(abi.encode(newNum)); + require( + EIP1271Verifier(owner).isValidSignature(digest, signature) == GPv2EIP1271.MAGICVALUE, + "GPv2: invalid eip1271 signature" + ); + num = newNum; + } + } +} + +contract SimpleAccountVulnPOCTest is BaseTest { + // Target contracts + EntryPoint private entrypoint; + AccountFactory private accountFactory; + + // Mocks + Number internal numberContract; + + // Test params + uint256 private accountAdminPKey = 100; + address private accountAdmin; + + uint256 private accountSignerPKey = 200; + address private accountSigner; + + uint256 private nonSignerPKey = 300; + address private nonSigner; + + // UserOp terminology: `sender` is the smart wallet. + address private sender = 0x0df2C3523703d165Aa7fA1a552f3F0B56275DfC6; + address payable private beneficiary = payable(address(0x45654)); + + bytes32 private uidCache = bytes32("random uid"); + + event AccountCreated(address indexed account, address indexed accountAdmin); + + function _prepareSignature( + IAccountPermissions.SignerPermissionRequest memory _req + ) internal view returns (bytes32 typedDataHash) { + bytes32 typehashSignerPermissionRequest = keccak256( + "SignerPermissionRequest(address signer,uint8 isAdmin,address[] approvedTargets,uint256 nativeTokenLimitPerTransaction,uint128 permissionStartTimestamp,uint128 permissionEndTimestamp,uint128 reqValidityStartTimestamp,uint128 reqValidityEndTimestamp,bytes32 uid)" + ); + bytes32 nameHash = keccak256(bytes("Account")); + bytes32 versionHash = keccak256(bytes("1")); + bytes32 typehashEip712 = keccak256( + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" + ); + bytes32 domainSeparator = keccak256(abi.encode(typehashEip712, nameHash, versionHash, block.chainid, sender)); + + bytes memory encodedRequestStart = abi.encode( + typehashSignerPermissionRequest, + _req.signer, + _req.isAdmin, + keccak256(abi.encodePacked(_req.approvedTargets)), + _req.nativeTokenLimitPerTransaction + ); + + bytes memory encodedRequestEnd = abi.encode( + _req.permissionStartTimestamp, + _req.permissionEndTimestamp, + _req.reqValidityStartTimestamp, + _req.reqValidityEndTimestamp, + _req.uid + ); + + bytes32 structHash = keccak256(bytes.concat(encodedRequestStart, encodedRequestEnd)); + typedDataHash = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); + } + + function _signSignerPermissionRequest( + IAccountPermissions.SignerPermissionRequest memory _req + ) internal view returns (bytes memory signature) { + bytes32 typedDataHash = _prepareSignature(_req); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(accountAdminPKey, typedDataHash); + signature = abi.encodePacked(r, s, v); + } + + function _setupUserOp( + uint256 _signerPKey, + bytes memory _initCode, + bytes memory _callDataForEntrypoint + ) internal returns (UserOperation[] memory ops) { + uint256 nonce = entrypoint.getNonce(sender, 0); + + // Get user op fields + UserOperation memory op = UserOperation({ + sender: sender, + nonce: nonce, + initCode: _initCode, + callData: _callDataForEntrypoint, + callGasLimit: 500_000, + verificationGasLimit: 500_000, + preVerificationGas: 500_000, + maxFeePerGas: 0, + maxPriorityFeePerGas: 0, + paymasterAndData: bytes(""), + signature: bytes("") + }); + + // Sign UserOp + bytes32 opHash = EntryPoint(entrypoint).getUserOpHash(op); + bytes32 msgHash = ECDSA.toEthSignedMessageHash(opHash); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(_signerPKey, msgHash); + bytes memory userOpSignature = abi.encodePacked(r, s, v); + + address recoveredSigner = ECDSA.recover(msgHash, v, r, s); + address expectedSigner = vm.addr(_signerPKey); + assertEq(recoveredSigner, expectedSigner); + + op.signature = userOpSignature; + + // Store UserOp + ops = new UserOperation[](1); + ops[0] = op; + } + + function _setupUserOpExecute( + uint256 _signerPKey, + bytes memory _initCode, + address _target, + uint256 _value, + bytes memory _callData + ) internal returns (UserOperation[] memory) { + bytes memory callDataForEntrypoint = abi.encodeWithSignature( + "execute(address,uint256,bytes)", + _target, + _value, + _callData + ); + + return _setupUserOp(_signerPKey, _initCode, callDataForEntrypoint); + } + + function _setupUserOpExecuteBatch( + uint256 _signerPKey, + bytes memory _initCode, + address[] memory _target, + uint256[] memory _value, + bytes[] memory _callData + ) internal returns (UserOperation[] memory) { + bytes memory callDataForEntrypoint = abi.encodeWithSignature( + "executeBatch(address[],uint256[],bytes[])", + _target, + _value, + _callData + ); + + return _setupUserOp(_signerPKey, _initCode, callDataForEntrypoint); + } + + function setUp() public override { + super.setUp(); + + // Setup signers. + accountAdmin = vm.addr(accountAdminPKey); + vm.deal(accountAdmin, 100 ether); + + accountSigner = vm.addr(accountSignerPKey); + nonSigner = vm.addr(nonSignerPKey); + + // Setup contracts + entrypoint = new EntryPoint(); + // deploy account factory + accountFactory = new AccountFactory(deployer, IEntryPoint(payable(address(entrypoint)))); + // deploy dummy contract + numberContract = new Number(); + } + + /*////////////////////////////////////////////////////////// + Test: performing a contract call + //////////////////////////////////////////////////////////////*/ + + function _setup_executeTransaction() internal { + bytes memory initCallData = abi.encodeWithSignature("createAccount(address,bytes)", accountAdmin, bytes("")); + bytes memory initCode = abi.encodePacked(abi.encodePacked(address(accountFactory)), initCallData); + + UserOperation[] memory userOpCreateAccount = _setupUserOpExecute( + accountAdminPKey, + initCode, + address(0), + 0, + bytes("") + ); + + EntryPoint(entrypoint).handleOps(userOpCreateAccount, beneficiary); + } + + function test_POC() public { + _setup_executeTransaction(); + + /*////////////////////////////////////////////////////////// + Setup + //////////////////////////////////////////////////////////////*/ + address account = accountFactory.getAddress(accountAdmin, bytes("")); + + address[] memory approvedTargets = new address[](1); + approvedTargets[0] = address(0x123); // allowing accountSigner permissions for some random contract, consider it as 0 address here + + IAccountPermissions.SignerPermissionRequest memory permissionsReq = IAccountPermissions.SignerPermissionRequest( + accountSigner, + 0, + approvedTargets, + 1 ether, + 0, + type(uint128).max, + 0, + type(uint128).max, + uidCache + ); + + vm.prank(accountAdmin); + bytes memory sig = _signSignerPermissionRequest(permissionsReq); + IAccountPermissions(payable(account)).setPermissionsForSigner(permissionsReq, sig); + + // As expected, Account Signer is not be able to call setNum on numberContract since it doesnt have numberContract as approved target + assertEq(numberContract.num(), 0); + + vm.prank(accountSigner); + UserOperation[] memory userOp = _setupUserOpExecute( + accountSignerPKey, + bytes(""), + address(numberContract), + 0, + abi.encodeWithSignature("setNum(uint256)", 42) + ); + + vm.expectRevert(); + EntryPoint(entrypoint).handleOps(userOp, beneficiary); + + /*////////////////////////////////////////////////////////// + Attack + //////////////////////////////////////////////////////////////*/ + + // However they can bypass this by using signature verification on number contract instead + vm.prank(accountSigner); + bytes32 digest = keccak256(abi.encode(42)); + bytes32 toSign = SimpleAccount(payable(account)).getMessageHash(digest); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(accountSignerPKey, toSign); + bytes memory signature = abi.encodePacked(r, s, v); + + vm.expectRevert("Account: caller not approved target."); + numberContract.setNumBySignature(account, 42, signature); + assertEq(numberContract.num(), 0); + + // Signer can perform transaction if target is approved. + address[] memory newApprovedTargets = new address[](2); + newApprovedTargets[0] = address(0x123); // allowing accountSigner permissions for some random contract, consider it as 0 address here + newApprovedTargets[1] = address(numberContract); + + IAccountPermissions.SignerPermissionRequest memory updatedPermissionsReq = IAccountPermissions + .SignerPermissionRequest( + accountSigner, + 0, + newApprovedTargets, + 1 ether, + 0, + type(uint128).max, + 0, + type(uint128).max, + bytes32("another UID") + ); + + vm.prank(accountAdmin); + bytes memory sig2 = _signSignerPermissionRequest(updatedPermissionsReq); + IAccountPermissions(payable(account)).setPermissionsForSigner(updatedPermissionsReq, sig2); + + numberContract.setNumBySignature(account, 42, signature); + assertEq(numberContract.num(), 42); + } +} diff --git a/src/test/smart-wallet/DynamicAccount.t.sol b/src/test/smart-wallet/DynamicAccount.t.sol new file mode 100644 index 000000000..5892ac43a --- /dev/null +++ b/src/test/smart-wallet/DynamicAccount.t.sol @@ -0,0 +1,851 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +// Test utils +import "../utils/BaseTest.sol"; +import "@thirdweb-dev/dynamic-contracts/src/interface/IExtension.sol"; +import { IAccountPermissions } from "contracts/extension/interface/IAccountPermissions.sol"; +import { AccountPermissions } from "contracts/extension/upgradeable/AccountPermissions.sol"; +import { AccountExtension } from "contracts/prebuilts/account/utils/AccountExtension.sol"; + +// Account Abstraction setup for smart wallets. +import { EntryPoint, IEntryPoint } from "contracts/prebuilts/account/utils/Entrypoint.sol"; +import { UserOperation } from "contracts/prebuilts/account/utils/UserOperation.sol"; + +// Target +import { Account as SimpleAccount } from "contracts/prebuilts/account/non-upgradeable/Account.sol"; +import { DynamicAccountFactory, DynamicAccount } from "contracts/prebuilts/account/dynamic/DynamicAccountFactory.sol"; +import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; + +/// @dev This is a dummy contract to test contract interactions with Account. +contract Number { + uint256 public num; + + function setNum(uint256 _num) public { + num = _num; + } + + function doubleNum() public { + num *= 2; + } + + function incrementNum() public { + num += 1; + } +} + +contract NFTRejector { + function onERC721Received(address, address, uint256, bytes memory) public virtual returns (bytes4) { + revert("NFTs not accepted"); + } +} + +contract DynamicAccountTest is BaseTest { + // Target contracts + EntryPoint private constant entrypoint = EntryPoint(payable(0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789)); + DynamicAccountFactory private accountFactory; + + // Mocks + Number internal numberContract; + + // Test params + uint256 private accountAdminPKey = 100; + address private accountAdmin; + + uint256 private accountSignerPKey = 200; + address private accountSigner; + + uint256 private nonSignerPKey = 300; + address private nonSigner; + + bytes internal data = bytes(""); + + // UserOp terminology: `sender` is the smart wallet. + address private sender = 0x78b942FBC9126b4Ed8384Bb9dd1420Ea865be91a; + address payable private beneficiary = payable(address(0x45654)); + + bytes32 private uidCache = bytes32("random uid"); + + event AccountCreated(address indexed account, address indexed accountAdmin); + + function _prepareSignature( + IAccountPermissions.SignerPermissionRequest memory _req + ) internal view returns (bytes32 typedDataHash) { + bytes32 typehashSignerPermissionRequest = keccak256( + "SignerPermissionRequest(address signer,uint8 isAdmin,address[] approvedTargets,uint256 nativeTokenLimitPerTransaction,uint128 permissionStartTimestamp,uint128 permissionEndTimestamp,uint128 reqValidityStartTimestamp,uint128 reqValidityEndTimestamp,bytes32 uid)" + ); + bytes32 nameHash = keccak256(bytes("Account")); + bytes32 versionHash = keccak256(bytes("1")); + bytes32 typehashEip712 = keccak256( + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" + ); + bytes32 domainSeparator = keccak256(abi.encode(typehashEip712, nameHash, versionHash, block.chainid, sender)); + + bytes memory encodedRequestStart = abi.encode( + typehashSignerPermissionRequest, + _req.signer, + _req.isAdmin, + keccak256(abi.encodePacked(_req.approvedTargets)), + _req.nativeTokenLimitPerTransaction + ); + + bytes memory encodedRequestEnd = abi.encode( + _req.permissionStartTimestamp, + _req.permissionEndTimestamp, + _req.reqValidityStartTimestamp, + _req.reqValidityEndTimestamp, + _req.uid + ); + + bytes32 structHash = keccak256(bytes.concat(encodedRequestStart, encodedRequestEnd)); + typedDataHash = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); + } + + function _signSignerPermissionRequest( + IAccountPermissions.SignerPermissionRequest memory _req + ) internal view returns (bytes memory signature) { + bytes32 typedDataHash = _prepareSignature(_req); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(accountAdminPKey, typedDataHash); + signature = abi.encodePacked(r, s, v); + } + + function _setupUserOp( + uint256 _signerPKey, + bytes memory _initCode, + bytes memory _callDataForEntrypoint + ) internal returns (UserOperation[] memory ops) { + uint256 nonce = entrypoint.getNonce(sender, 0); + + // Get user op fields + UserOperation memory op = UserOperation({ + sender: sender, + nonce: nonce, + initCode: _initCode, + callData: _callDataForEntrypoint, + callGasLimit: 5_000_000, + verificationGasLimit: 5_000_000, + preVerificationGas: 5_000_000, + maxFeePerGas: 0, + maxPriorityFeePerGas: 0, + paymasterAndData: bytes(""), + signature: bytes("") + }); + + // Sign UserOp + bytes32 opHash = EntryPoint(entrypoint).getUserOpHash(op); + bytes32 msgHash = ECDSA.toEthSignedMessageHash(opHash); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(_signerPKey, msgHash); + bytes memory userOpSignature = abi.encodePacked(r, s, v); + + address recoveredSigner = ECDSA.recover(msgHash, v, r, s); + address expectedSigner = vm.addr(_signerPKey); + assertEq(recoveredSigner, expectedSigner); + + op.signature = userOpSignature; + + // Store UserOp + ops = new UserOperation[](1); + ops[0] = op; + } + + function _setupUserOpWithSender( + bytes memory _initCode, + bytes memory _callDataForEntrypoint, + address _sender + ) internal returns (UserOperation[] memory ops) { + uint256 nonce = entrypoint.getNonce(_sender, 0); + + // Get user op fields + UserOperation memory op = UserOperation({ + sender: _sender, + nonce: nonce, + initCode: _initCode, + callData: _callDataForEntrypoint, + callGasLimit: 5_000_000, + verificationGasLimit: 5_000_000, + preVerificationGas: 5_000_000, + maxFeePerGas: 0, + maxPriorityFeePerGas: 0, + paymasterAndData: bytes(""), + signature: bytes("") + }); + + // Sign UserOp + bytes32 opHash = EntryPoint(entrypoint).getUserOpHash(op); + bytes32 msgHash = ECDSA.toEthSignedMessageHash(opHash); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(accountAdminPKey, msgHash); + bytes memory userOpSignature = abi.encodePacked(r, s, v); + + address recoveredSigner = ECDSA.recover(msgHash, v, r, s); + address expectedSigner = vm.addr(accountAdminPKey); + assertEq(recoveredSigner, expectedSigner); + + op.signature = userOpSignature; + + // Store UserOp + ops = new UserOperation[](1); + ops[0] = op; + } + + function _setupUserOpExecuteWithSender( + bytes memory _initCode, + address _target, + uint256 _value, + bytes memory _callData, + address _sender + ) internal returns (UserOperation[] memory) { + bytes memory callDataForEntrypoint = abi.encodeWithSignature( + "execute(address,uint256,bytes)", + _target, + _value, + _callData + ); + + return _setupUserOpWithSender(_initCode, callDataForEntrypoint, _sender); + } + + function _setupUserOpExecute( + uint256 _signerPKey, + bytes memory _initCode, + address _target, + uint256 _value, + bytes memory _callData + ) internal returns (UserOperation[] memory) { + bytes memory callDataForEntrypoint = abi.encodeWithSignature( + "execute(address,uint256,bytes)", + _target, + _value, + _callData + ); + + return _setupUserOp(_signerPKey, _initCode, callDataForEntrypoint); + } + + function _setupUserOpExecuteBatch( + uint256 _signerPKey, + bytes memory _initCode, + address[] memory _target, + uint256[] memory _value, + bytes[] memory _callData + ) internal returns (UserOperation[] memory) { + bytes memory callDataForEntrypoint = abi.encodeWithSignature( + "executeBatch(address[],uint256[],bytes[])", + _target, + _value, + _callData + ); + + return _setupUserOp(_signerPKey, _initCode, callDataForEntrypoint); + } + + /// @dev Returns the salt used when deploying an Account. + function _generateSalt(address _admin, bytes memory _data) internal view virtual returns (bytes32) { + return keccak256(abi.encode(_admin, _data)); + } + + function setUp() public override { + super.setUp(); + + // Setup signers. + accountAdmin = vm.addr(accountAdminPKey); + vm.deal(accountAdmin, 100 ether); + + accountSigner = vm.addr(accountSignerPKey); + nonSigner = vm.addr(nonSignerPKey); + + // Setup contracts + address _deployedEntrypoint = address(new EntryPoint()); + vm.etch(address(entrypoint), bytes(_deployedEntrypoint.code)); + + // Setting up default extension. + IExtension.Extension memory defaultExtension; + + defaultExtension.metadata = IExtension.ExtensionMetadata({ + name: "AccountExtension", + metadataURI: "ipfs://AccountExtension", + implementation: address(new AccountExtension()) + }); + + defaultExtension.functions = new IExtension.ExtensionFunction[](9); + + defaultExtension.functions[0] = IExtension.ExtensionFunction( + AccountExtension.supportsInterface.selector, + "supportsInterface(bytes4)" + ); + defaultExtension.functions[1] = IExtension.ExtensionFunction( + AccountExtension.execute.selector, + "execute(address,uint256,bytes)" + ); + defaultExtension.functions[2] = IExtension.ExtensionFunction( + AccountExtension.executeBatch.selector, + "executeBatch(address[],uint256[],bytes[])" + ); + defaultExtension.functions[3] = IExtension.ExtensionFunction( + ERC721Holder.onERC721Received.selector, + "onERC721Received(address,address,uint256,bytes)" + ); + defaultExtension.functions[4] = IExtension.ExtensionFunction( + ERC1155Holder.onERC1155Received.selector, + "onERC1155Received(address,address,uint256,uint256,bytes)" + ); + defaultExtension.functions[5] = IExtension.ExtensionFunction( + bytes4(0), // Selector for `receive()` function. + "receive()" + ); + defaultExtension.functions[6] = IExtension.ExtensionFunction( + AccountExtension.isValidSignature.selector, + "isValidSignature(bytes32,bytes)" + ); + defaultExtension.functions[7] = IExtension.ExtensionFunction( + AccountExtension.addDeposit.selector, + "addDeposit()" + ); + defaultExtension.functions[8] = IExtension.ExtensionFunction( + AccountExtension.withdrawDepositTo.selector, + "withdrawDepositTo(address,uint256)" + ); + + IExtension.Extension[] memory extensions = new IExtension.Extension[](1); + extensions[0] = defaultExtension; + + // deploy account factory + accountFactory = new DynamicAccountFactory(deployer, extensions); + // deploy dummy contract + numberContract = new Number(); + } + + /*/////////////////////////////////////////////////////////////// + Test: creating an account + //////////////////////////////////////////////////////////////*/ + + /// @dev benchmark test for deployment gas cost + function test_deploy_dynamicAccount() public { + // Setting up default extension. + IExtension.Extension memory defaultExtension; + + defaultExtension.metadata = IExtension.ExtensionMetadata({ + name: "AccountExtension", + metadataURI: "ipfs://AccountExtension", + implementation: address(new AccountExtension()) + }); + + defaultExtension.functions = new IExtension.ExtensionFunction[](7); + + defaultExtension.functions[0] = IExtension.ExtensionFunction( + AccountExtension.supportsInterface.selector, + "supportsInterface(bytes4)" + ); + defaultExtension.functions[1] = IExtension.ExtensionFunction( + AccountExtension.execute.selector, + "execute(address,uint256,bytes)" + ); + defaultExtension.functions[2] = IExtension.ExtensionFunction( + AccountExtension.executeBatch.selector, + "executeBatch(address[],uint256[],bytes[])" + ); + defaultExtension.functions[3] = IExtension.ExtensionFunction( + ERC721Holder.onERC721Received.selector, + "onERC721Received(address,address,uint256,bytes)" + ); + defaultExtension.functions[4] = IExtension.ExtensionFunction( + ERC1155Holder.onERC1155Received.selector, + "onERC1155Received(address,address,uint256,uint256,bytes)" + ); + defaultExtension.functions[5] = IExtension.ExtensionFunction( + bytes4(0), // Selector for `receive()` function. + "receive()" + ); + defaultExtension.functions[6] = IExtension.ExtensionFunction( + AccountExtension.isValidSignature.selector, + "isValidSignature(bytes32,bytes)" + ); + + IExtension.Extension[] memory extensions = new IExtension.Extension[](1); + extensions[0] = defaultExtension; + + // deploy account factory + DynamicAccountFactory factory = new DynamicAccountFactory(deployer, extensions); + } + + /// @dev Create an account by directly calling the factory. + function test_state_createAccount_viaFactory() public { + vm.expectEmit(true, true, false, true); + emit AccountCreated(sender, accountAdmin); + accountFactory.createAccount(accountAdmin, data); + + address[] memory allAccounts = accountFactory.getAllAccounts(); + assertEq(allAccounts.length, 1); + assertEq(allAccounts[0], sender); + } + + /// @dev Create an account via Entrypoint. + function test_state_createAccount_viaEntrypoint() public { + bytes memory initCallData = abi.encodeWithSignature("createAccount(address,bytes)", accountAdmin, data); + bytes memory initCode = abi.encodePacked(abi.encodePacked(address(accountFactory)), initCallData); + + UserOperation[] memory userOpCreateAccount = _setupUserOpExecute( + accountAdminPKey, + initCode, + address(0), + 0, + bytes("") + ); + + vm.expectEmit(true, true, false, true); + emit AccountCreated(sender, accountAdmin); + EntryPoint(entrypoint).handleOps(userOpCreateAccount, beneficiary); + + address[] memory allAccounts = accountFactory.getAllAccounts(); + assertEq(allAccounts.length, 1); + assertEq(allAccounts[0], sender); + } + + /// @dev Try registering with factory with a contract not deployed by factory. + function test_revert_onRegister_nonFactoryChildContract() public { + vm.prank(address(0x12345)); + vm.expectRevert("AccountFactory: not an account."); + accountFactory.onRegister(_generateSalt(accountAdmin, "")); + } + + /// @dev Create more than one accounts with the same admin. + function test_state_createAccount_viaEntrypoint_multipleAccountSameAdmin() public { + uint256 amount = 1; + + for (uint256 i = 0; i < amount; i += 1) { + bytes memory initCallData = abi.encodeWithSignature( + "createAccount(address,bytes)", + accountAdmin, + bytes(abi.encode(i)) + ); + bytes memory initCode = abi.encodePacked(abi.encodePacked(address(accountFactory)), initCallData); + + address expectedSenderAddress = Clones.predictDeterministicAddress( + accountFactory.accountImplementation(), + _generateSalt(accountAdmin, bytes(abi.encode(i))), + address(accountFactory) + ); + + UserOperation[] memory userOpCreateAccount = _setupUserOpExecuteWithSender( + initCode, + address(0), + 0, + bytes(abi.encode(i)), + expectedSenderAddress + ); + + vm.expectEmit(true, true, false, true); + emit AccountCreated(expectedSenderAddress, accountAdmin); + EntryPoint(entrypoint).handleOps(userOpCreateAccount, beneficiary); + } + + address[] memory allAccounts = accountFactory.getAllAccounts(); + assertEq(allAccounts.length, amount); + + for (uint256 i = 0; i < amount; i += 1) { + assertEq( + allAccounts[i], + Clones.predictDeterministicAddress( + accountFactory.accountImplementation(), + _generateSalt(accountAdmin, bytes(abi.encode(i))), + address(accountFactory) + ) + ); + } + } + + /*/////////////////////////////////////////////////////////////// + Test: performing a contract call + //////////////////////////////////////////////////////////////*/ + + function _setup_executeTransaction() internal { + bytes memory initCallData = abi.encodeWithSignature("createAccount(address,bytes)", accountAdmin, data); + bytes memory initCode = abi.encodePacked(abi.encodePacked(address(accountFactory)), initCallData); + + UserOperation[] memory userOpCreateAccount = _setupUserOpExecute( + accountAdminPKey, + initCode, + address(0), + 0, + bytes("") + ); + + EntryPoint(entrypoint).handleOps(userOpCreateAccount, beneficiary); + } + + /// @dev Perform a state changing transaction directly via account. + function test_state_executeTransaction() public { + _setup_executeTransaction(); + + address account = accountFactory.getAddress(accountAdmin, bytes("")); + + assertEq(numberContract.num(), 0); + + vm.prank(accountAdmin); + SimpleAccount(payable(account)).execute( + address(numberContract), + 0, + abi.encodeWithSignature("setNum(uint256)", 42) + ); + + assertEq(numberContract.num(), 42); + } + + /// @dev Perform many state changing transactions in a batch directly via account. + function test_state_executeBatchTransaction() public { + _setup_executeTransaction(); + + address account = accountFactory.getAddress(accountAdmin, bytes("")); + + assertEq(numberContract.num(), 0); + + uint256 count = 3; + address[] memory targets = new address[](count); + uint256[] memory values = new uint256[](count); + bytes[] memory callData = new bytes[](count); + + for (uint256 i = 0; i < count; i += 1) { + targets[i] = address(numberContract); + values[i] = 0; + callData[i] = abi.encodeWithSignature("incrementNum()", i); + } + + vm.prank(accountAdmin); + SimpleAccount(payable(account)).executeBatch(targets, values, callData); + + assertEq(numberContract.num(), count); + } + + /// @dev Perform a state changing transaction via Entrypoint. + function test_state_executeTransaction_viaEntrypoint() public { + _setup_executeTransaction(); + + assertEq(numberContract.num(), 0); + + UserOperation[] memory userOp = _setupUserOpExecute( + accountAdminPKey, + bytes(""), + address(numberContract), + 0, + abi.encodeWithSignature("setNum(uint256)", 42) + ); + + EntryPoint(entrypoint).handleOps(userOp, beneficiary); + + assertEq(numberContract.num(), 42); + } + + /// @dev Perform many state changing transactions in a batch via Entrypoint. + function test_state_executeBatchTransaction_viaEntrypoint() public { + _setup_executeTransaction(); + + assertEq(numberContract.num(), 0); + + uint256 count = 3; + address[] memory targets = new address[](count); + uint256[] memory values = new uint256[](count); + bytes[] memory callData = new bytes[](count); + + for (uint256 i = 0; i < count; i += 1) { + targets[i] = address(numberContract); + values[i] = 0; + callData[i] = abi.encodeWithSignature("incrementNum()", i); + } + + UserOperation[] memory userOp = _setupUserOpExecuteBatch( + accountAdminPKey, + bytes(""), + targets, + values, + callData + ); + + EntryPoint(entrypoint).handleOps(userOp, beneficiary); + + assertEq(numberContract.num(), count); + } + + /// @dev Perform many state changing transactions in a batch via Entrypoint. + function test_state_executeBatchTransaction_viaAccountSigner() public { + _setup_executeTransaction(); + + assertEq(numberContract.num(), 0); + + uint256 count = 3; + address[] memory targets = new address[](count); + uint256[] memory values = new uint256[](count); + bytes[] memory callData = new bytes[](count); + + for (uint256 i = 0; i < count; i += 1) { + targets[i] = address(numberContract); + values[i] = 0; + callData[i] = abi.encodeWithSignature("incrementNum()", i); + } + + address account = accountFactory.getAddress(accountAdmin, bytes("")); + + address[] memory approvedTargets = new address[](1); + approvedTargets[0] = address(numberContract); + + IAccountPermissions.SignerPermissionRequest memory permissionsReq = IAccountPermissions.SignerPermissionRequest( + accountSigner, + 0, + approvedTargets, + 1 ether, + 0, + type(uint128).max, + 0, + type(uint128).max, + uidCache + ); + + vm.prank(accountAdmin); + bytes memory sig = _signSignerPermissionRequest(permissionsReq); + SimpleAccount(payable(account)).setPermissionsForSigner(permissionsReq, sig); + + UserOperation[] memory userOp = _setupUserOpExecuteBatch( + accountSignerPKey, + bytes(""), + targets, + values, + callData + ); + + EntryPoint(entrypoint).handleOps(userOp, beneficiary); + + assertEq(numberContract.num(), count); + } + + /// @dev Perform a state changing transaction via Entrypoint and a SIGNER_ROLE holder. + function test_state_executeTransaction_viaAccountSigner() public { + _setup_executeTransaction(); + + address account = accountFactory.getAddress(accountAdmin, bytes("")); + + address[] memory approvedTargets = new address[](1); + approvedTargets[0] = address(numberContract); + + IAccountPermissions.SignerPermissionRequest memory permissionsReq = IAccountPermissions.SignerPermissionRequest( + accountSigner, + 0, + approvedTargets, + 1 ether, + 0, + type(uint128).max, + 0, + type(uint128).max, + uidCache + ); + + vm.prank(accountAdmin); + bytes memory sig = _signSignerPermissionRequest(permissionsReq); + SimpleAccount(payable(account)).setPermissionsForSigner(permissionsReq, sig); + + assertEq(numberContract.num(), 0); + + UserOperation[] memory userOp = _setupUserOpExecute( + accountSignerPKey, + bytes(""), + address(numberContract), + 0, + abi.encodeWithSignature("setNum(uint256)", 42) + ); + + EntryPoint(entrypoint).handleOps(userOp, beneficiary); + + assertEq(numberContract.num(), 42); + } + + /// @dev Revert: perform a state changing transaction via Entrypoint without appropriate permissions. + function test_revert_executeTransaction_nonSigner_viaEntrypoint() public { + _setup_executeTransaction(); + + assertEq(numberContract.num(), 0); + + UserOperation[] memory userOp = _setupUserOpExecute( + accountSignerPKey, + bytes(""), + address(numberContract), + 0, + abi.encodeWithSignature("setNum(uint256)", 42) + ); + + vm.expectRevert(); + EntryPoint(entrypoint).handleOps(userOp, beneficiary); + } + + /// @dev Revert: non-admin performs a state changing transaction directly via account contract. + function test_revert_executeTransaction_nonSigner_viaDirectCall() public { + _setup_executeTransaction(); + + address account = accountFactory.getAddress(accountAdmin, bytes("")); + + address[] memory approvedTargets = new address[](1); + approvedTargets[0] = address(numberContract); + + IAccountPermissions.SignerPermissionRequest memory permissionsReq = IAccountPermissions.SignerPermissionRequest( + accountSigner, + 0, + approvedTargets, + 1 ether, + 0, + type(uint128).max, + 0, + type(uint128).max, + uidCache + ); + + vm.prank(accountAdmin); + bytes memory sig = _signSignerPermissionRequest(permissionsReq); + SimpleAccount(payable(account)).setPermissionsForSigner(permissionsReq, sig); + assertEq(numberContract.num(), 0); + + vm.prank(accountSigner); + vm.expectRevert("Account: not admin or EntryPoint."); + SimpleAccount(payable(account)).execute( + address(numberContract), + 0, + abi.encodeWithSignature("setNum(uint256)", 42) + ); + } + + /*/////////////////////////////////////////////////////////////// + Test: receiving and sending native tokens + //////////////////////////////////////////////////////////////*/ + + /// @dev Send native tokens to an account. + function test_state_accountReceivesNativeTokens() public { + _setup_executeTransaction(); + + address account = accountFactory.getAddress(accountAdmin, bytes("")); + + assertEq(address(account).balance, 0); + + vm.prank(accountAdmin); + // solhint-disable-next-line avoid-low-level-calls + (bool success, bytes memory ret) = payable(account).call{ value: 1000 }(""); + + // Silence warning: Return value of low-level calls not used. + (success, ret) = (success, ret); + + assertEq(address(account).balance, 1000); + } + + /// @dev Transfer native tokens out of an account. + function test_state_transferOutsNativeTokens() public { + _setup_executeTransaction(); + + uint256 value = 1000; + + address account = accountFactory.getAddress(accountAdmin, bytes("")); + vm.prank(accountAdmin); + // solhint-disable-next-line avoid-low-level-calls + (bool success, bytes memory ret) = payable(account).call{ value: value }(""); + assertEq(address(account).balance, value); + + // Silence warning: Return value of low-level calls not used. + (success, ret) = (success, ret); + + address recipient = address(0x3456); + + UserOperation[] memory userOp = _setupUserOpExecute(accountAdminPKey, bytes(""), recipient, value, bytes("")); + + EntryPoint(entrypoint).handleOps(userOp, beneficiary); + assertEq(address(account).balance, 0); + assertEq(recipient.balance, value); + } + + /// @dev Add and remove a deposit for the account from the Entrypoint. + + function test_state_addAndWithdrawDeposit() public { + _setup_executeTransaction(); + + address account = accountFactory.getAddress(accountAdmin, bytes("")); + + assertEq(EntryPoint(entrypoint).balanceOf(account), 0); + + vm.prank(accountAdmin); + SimpleAccount(payable(account)).addDeposit{ value: 1000 }(); + assertEq(EntryPoint(entrypoint).balanceOf(account), 1000); + + vm.prank(accountAdmin); + SimpleAccount(payable(account)).withdrawDepositTo(payable(accountSigner), 500); + assertEq(EntryPoint(entrypoint).balanceOf(account), 500); + } + + /*/////////////////////////////////////////////////////////////// + Test: receiving ERC-721 and ERC-1155 NFTs + //////////////////////////////////////////////////////////////*/ + + /// @dev Send an ERC-721 NFT to an account. + function test_state_receiveERC721NFT() public { + _setup_executeTransaction(); + address account = accountFactory.getAddress(accountAdmin, bytes("")); + + assertEq(erc721.balanceOf(account), 0); + + erc721.mint(account, 1); + + assertEq(erc721.balanceOf(account), 1); + } + + /// @dev Send an ERC-1155 NFT to an account. + function test_state_receiveERC1155NFT() public { + _setup_executeTransaction(); + address account = accountFactory.getAddress(accountAdmin, bytes("")); + + assertEq(erc1155.balanceOf(account, 0), 0); + + erc1155.mint(account, 0, 1); + + assertEq(erc1155.balanceOf(account, 0), 1); + } + + /*/////////////////////////////////////////////////////////////// + Test: change an extension on the account + //////////////////////////////////////////////////////////////*/ + + /// @dev Make the account reject ERC-721 NFTs instead of accepting them. + function test_scenario_changeExtensionForFunction() public { + _setup_executeTransaction(); + address account = accountFactory.getAddress(accountAdmin, bytes("")); + + // The account can initially receive NFTs. + assertEq(erc721.balanceOf(account), 0); + erc721.mint(account, 1); + assertEq(erc721.balanceOf(account), 1); + + // Make the account reject ERC-721 NFTs going forward. + IExtension.Extension memory extension; + + extension.metadata = IExtension.ExtensionMetadata({ + name: "NFTRejector", + metadataURI: "ipfs://NFTRejector", + implementation: address(new NFTRejector()) + }); + + extension.functions = new IExtension.ExtensionFunction[](1); + + extension.functions[0] = IExtension.ExtensionFunction( + NFTRejector.onERC721Received.selector, + "onERC721Received(address,address,uint256,bytes)" + ); + + vm.prank(accountAdmin); + DynamicAccount(payable(account)).disableFunctionInExtension( + "AccountExtension", + NFTRejector.onERC721Received.selector + ); + + vm.prank(accountAdmin); + DynamicAccount(payable(account)).addExtension(extension); + + // Transfer NFTs to the account + erc721.mint(accountSigner, 1); + assertEq(erc721.ownerOf(1), accountSigner); + vm.prank(accountSigner); + vm.expectRevert("NFTs not accepted"); + erc721.safeTransferFrom(accountSigner, account, 1); + } +} diff --git a/src/test/smart-wallet/ManagedAccount.t.sol b/src/test/smart-wallet/ManagedAccount.t.sol new file mode 100644 index 000000000..643f8e1b8 --- /dev/null +++ b/src/test/smart-wallet/ManagedAccount.t.sol @@ -0,0 +1,860 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +// Test utils +import "../utils/BaseTest.sol"; +import "@thirdweb-dev/dynamic-contracts/src/interface/IExtension.sol"; +import { IAccountPermissions } from "contracts/extension/interface/IAccountPermissions.sol"; +import { AccountPermissions } from "contracts/extension/upgradeable/AccountPermissions.sol"; +import { AccountExtension } from "contracts/prebuilts/account/utils/AccountExtension.sol"; + +// Account Abstraction setup for smart wallets. +import { EntryPoint, IEntryPoint } from "contracts/prebuilts/account/utils/Entrypoint.sol"; +import { UserOperation } from "contracts/prebuilts/account/utils/UserOperation.sol"; + +// Target +import { Account as SimpleAccount } from "contracts/prebuilts/account/non-upgradeable/Account.sol"; +import { ManagedAccountFactory, ManagedAccount } from "contracts/prebuilts/account/managed/ManagedAccountFactory.sol"; +import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; + +/// @dev This is a dummy contract to test contract interactions with Account. +contract Number { + uint256 public num; + + function setNum(uint256 _num) public { + num = _num; + } + + function doubleNum() public { + num *= 2; + } + + function incrementNum() public { + num += 1; + } +} + +contract NFTRejector { + function onERC721Received(address, address, uint256, bytes memory) public virtual returns (bytes4) { + revert("NFTs not accepted"); + } +} + +contract ManagedAccountTest is BaseTest { + // Target contracts + EntryPoint private entrypoint; + ManagedAccountFactory private accountFactory; + + // Mocks + Number internal numberContract; + + // Test params + address private factoryDeployer = address(0x9876); + bytes internal data = bytes(""); + + uint256 private accountAdminPKey = 100; + address private accountAdmin; + + uint256 private accountSignerPKey = 200; + address private accountSigner; + + uint256 private nonSignerPKey = 300; + address private nonSigner; + + // UserOp terminology: `sender` is the smart wallet. + address private sender = 0xbEA1Fa134A1727187A8f2e7E714B660f3a95478D; + address payable private beneficiary = payable(address(0x45654)); + + bytes32 private uidCache = bytes32("random uid"); + + event AccountCreated(address indexed account, address indexed accountAdmin); + + function _prepareSignature( + IAccountPermissions.SignerPermissionRequest memory _req + ) internal view returns (bytes32 typedDataHash) { + bytes32 typehashSignerPermissionRequest = keccak256( + "SignerPermissionRequest(address signer,uint8 isAdmin,address[] approvedTargets,uint256 nativeTokenLimitPerTransaction,uint128 permissionStartTimestamp,uint128 permissionEndTimestamp,uint128 reqValidityStartTimestamp,uint128 reqValidityEndTimestamp,bytes32 uid)" + ); + bytes32 nameHash = keccak256(bytes("Account")); + bytes32 versionHash = keccak256(bytes("1")); + bytes32 typehashEip712 = keccak256( + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" + ); + bytes32 domainSeparator = keccak256(abi.encode(typehashEip712, nameHash, versionHash, block.chainid, sender)); + + bytes memory encodedRequestStart = abi.encode( + typehashSignerPermissionRequest, + _req.signer, + _req.isAdmin, + keccak256(abi.encodePacked(_req.approvedTargets)), + _req.nativeTokenLimitPerTransaction + ); + + bytes memory encodedRequestEnd = abi.encode( + _req.permissionStartTimestamp, + _req.permissionEndTimestamp, + _req.reqValidityStartTimestamp, + _req.reqValidityEndTimestamp, + _req.uid + ); + + bytes32 structHash = keccak256(bytes.concat(encodedRequestStart, encodedRequestEnd)); + typedDataHash = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); + } + + function _signSignerPermissionRequest( + IAccountPermissions.SignerPermissionRequest memory _req + ) internal view returns (bytes memory signature) { + bytes32 typedDataHash = _prepareSignature(_req); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(accountAdminPKey, typedDataHash); + signature = abi.encodePacked(r, s, v); + } + + function _setupUserOp( + uint256 _signerPKey, + bytes memory _initCode, + bytes memory _callDataForEntrypoint + ) internal returns (UserOperation[] memory ops) { + uint256 nonce = entrypoint.getNonce(sender, 0); + + // Get user op fields + UserOperation memory op = UserOperation({ + sender: sender, + nonce: nonce, + initCode: _initCode, + callData: _callDataForEntrypoint, + callGasLimit: 500_000, + verificationGasLimit: 500_000, + preVerificationGas: 500_000, + maxFeePerGas: 0, + maxPriorityFeePerGas: 0, + paymasterAndData: bytes(""), + signature: bytes("") + }); + + // Sign UserOp + bytes32 opHash = EntryPoint(entrypoint).getUserOpHash(op); + bytes32 msgHash = ECDSA.toEthSignedMessageHash(opHash); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(_signerPKey, msgHash); + bytes memory userOpSignature = abi.encodePacked(r, s, v); + + address recoveredSigner = ECDSA.recover(msgHash, v, r, s); + address expectedSigner = vm.addr(_signerPKey); + assertEq(recoveredSigner, expectedSigner); + + op.signature = userOpSignature; + + // Store UserOp + ops = new UserOperation[](1); + ops[0] = op; + } + + function _setupUserOpWithSender( + bytes memory _initCode, + bytes memory _callDataForEntrypoint, + address _sender + ) internal returns (UserOperation[] memory ops) { + uint256 nonce = entrypoint.getNonce(_sender, 0); + + // Get user op fields + UserOperation memory op = UserOperation({ + sender: _sender, + nonce: nonce, + initCode: _initCode, + callData: _callDataForEntrypoint, + callGasLimit: 5_000_000, + verificationGasLimit: 5_000_000, + preVerificationGas: 5_000_000, + maxFeePerGas: 0, + maxPriorityFeePerGas: 0, + paymasterAndData: bytes(""), + signature: bytes("") + }); + + // Sign UserOp + bytes32 opHash = EntryPoint(entrypoint).getUserOpHash(op); + bytes32 msgHash = ECDSA.toEthSignedMessageHash(opHash); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(accountAdminPKey, msgHash); + bytes memory userOpSignature = abi.encodePacked(r, s, v); + + address recoveredSigner = ECDSA.recover(msgHash, v, r, s); + address expectedSigner = vm.addr(accountAdminPKey); + assertEq(recoveredSigner, expectedSigner); + + op.signature = userOpSignature; + + // Store UserOp + ops = new UserOperation[](1); + ops[0] = op; + } + + function _setupUserOpExecuteWithSender( + bytes memory _initCode, + address _target, + uint256 _value, + bytes memory _callData, + address _sender + ) internal returns (UserOperation[] memory) { + bytes memory callDataForEntrypoint = abi.encodeWithSignature( + "execute(address,uint256,bytes)", + _target, + _value, + _callData + ); + + return _setupUserOpWithSender(_initCode, callDataForEntrypoint, _sender); + } + + function _setupUserOpExecute( + uint256 _signerPKey, + bytes memory _initCode, + address _target, + uint256 _value, + bytes memory _callData + ) internal returns (UserOperation[] memory) { + bytes memory callDataForEntrypoint = abi.encodeWithSignature( + "execute(address,uint256,bytes)", + _target, + _value, + _callData + ); + + return _setupUserOp(_signerPKey, _initCode, callDataForEntrypoint); + } + + function _setupUserOpExecuteBatch( + uint256 _signerPKey, + bytes memory _initCode, + address[] memory _target, + uint256[] memory _value, + bytes[] memory _callData + ) internal returns (UserOperation[] memory) { + bytes memory callDataForEntrypoint = abi.encodeWithSignature( + "executeBatch(address[],uint256[],bytes[])", + _target, + _value, + _callData + ); + + return _setupUserOp(_signerPKey, _initCode, callDataForEntrypoint); + } + + /// @dev Returns the salt used when deploying an Account. + function _generateSalt(address _admin, bytes memory _data) internal view virtual returns (bytes32) { + return keccak256(abi.encode(_admin, _data)); + } + + function setUp() public override { + super.setUp(); + + // Setup signers. + accountAdmin = vm.addr(accountAdminPKey); + vm.deal(accountAdmin, 100 ether); + + accountSigner = vm.addr(accountSignerPKey); + nonSigner = vm.addr(nonSignerPKey); + + // Setup contracts + entrypoint = new EntryPoint(); + + // Setting up default extension. + IExtension.Extension memory defaultExtension; + + defaultExtension.metadata = IExtension.ExtensionMetadata({ + name: "AccountExtension", + metadataURI: "ipfs://AccountExtension", + implementation: address(new AccountExtension()) + }); + + defaultExtension.functions = new IExtension.ExtensionFunction[](9); + + defaultExtension.functions[0] = IExtension.ExtensionFunction( + AccountExtension.supportsInterface.selector, + "supportsInterface(bytes4)" + ); + defaultExtension.functions[1] = IExtension.ExtensionFunction( + AccountExtension.execute.selector, + "execute(address,uint256,bytes)" + ); + defaultExtension.functions[2] = IExtension.ExtensionFunction( + AccountExtension.executeBatch.selector, + "executeBatch(address[],uint256[],bytes[])" + ); + defaultExtension.functions[3] = IExtension.ExtensionFunction( + ERC721Holder.onERC721Received.selector, + "onERC721Received(address,address,uint256,bytes)" + ); + defaultExtension.functions[4] = IExtension.ExtensionFunction( + ERC1155Holder.onERC1155Received.selector, + "onERC1155Received(address,address,uint256,uint256,bytes)" + ); + defaultExtension.functions[5] = IExtension.ExtensionFunction( + bytes4(0), // Selector for `receive()` function. + "receive()" + ); + defaultExtension.functions[6] = IExtension.ExtensionFunction( + AccountExtension.isValidSignature.selector, + "isValidSignature(bytes32,bytes)" + ); + defaultExtension.functions[7] = IExtension.ExtensionFunction( + AccountExtension.addDeposit.selector, + "addDeposit()" + ); + defaultExtension.functions[8] = IExtension.ExtensionFunction( + AccountExtension.withdrawDepositTo.selector, + "withdrawDepositTo(address,uint256)" + ); + + IExtension.Extension[] memory extensions = new IExtension.Extension[](1); + extensions[0] = defaultExtension; + + // deploy account factory + vm.prank(factoryDeployer); + accountFactory = new ManagedAccountFactory( + factoryDeployer, + IEntryPoint(payable(address(entrypoint))), + extensions + ); + // deploy dummy contract + numberContract = new Number(); + } + + /// @dev benchmark test for deployment gas cost + function test_deploy_managedAccount() public { + // Setting up default extension. + IExtension.Extension memory defaultExtension; + + defaultExtension.metadata = IExtension.ExtensionMetadata({ + name: "AccountExtension", + metadataURI: "ipfs://AccountExtension", + implementation: address(new AccountExtension()) + }); + + defaultExtension.functions = new IExtension.ExtensionFunction[](7); + + defaultExtension.functions[0] = IExtension.ExtensionFunction( + AccountExtension.supportsInterface.selector, + "supportsInterface(bytes4)" + ); + defaultExtension.functions[1] = IExtension.ExtensionFunction( + AccountExtension.execute.selector, + "execute(address,uint256,bytes)" + ); + defaultExtension.functions[2] = IExtension.ExtensionFunction( + AccountExtension.executeBatch.selector, + "executeBatch(address[],uint256[],bytes[])" + ); + defaultExtension.functions[3] = IExtension.ExtensionFunction( + ERC721Holder.onERC721Received.selector, + "onERC721Received(address,address,uint256,bytes)" + ); + defaultExtension.functions[4] = IExtension.ExtensionFunction( + ERC1155Holder.onERC1155Received.selector, + "onERC1155Received(address,address,uint256,uint256,bytes)" + ); + defaultExtension.functions[5] = IExtension.ExtensionFunction( + bytes4(0), // Selector for `receive()` function. + "receive()" + ); + defaultExtension.functions[6] = IExtension.ExtensionFunction( + AccountExtension.isValidSignature.selector, + "isValidSignature(bytes32,bytes)" + ); + + IExtension.Extension[] memory extensions = new IExtension.Extension[](1); + extensions[0] = defaultExtension; + + // deploy account factory + vm.prank(factoryDeployer); + ManagedAccountFactory factory = new ManagedAccountFactory( + factoryDeployer, + IEntryPoint(payable(address(entrypoint))), + extensions + ); + assertTrue(address(factory) != address(0), "factory address should not be zero"); + } + + /*/////////////////////////////////////////////////////////////// + Test: creating an account + //////////////////////////////////////////////////////////////*/ + + /// @dev Create an account by directly calling the factory. + function test_state_createAccount_viaFactory() public { + vm.expectEmit(true, true, false, true); + emit AccountCreated(sender, accountAdmin); + accountFactory.createAccount(accountAdmin, data); + + address[] memory allAccounts = accountFactory.getAllAccounts(); + assertEq(allAccounts.length, 1); + assertEq(allAccounts[0], sender); + } + + /// @dev Create an account via Entrypoint. + function test_state_createAccount_viaEntrypoint() public { + bytes memory initCallData = abi.encodeWithSignature("createAccount(address,bytes)", accountAdmin, data); + bytes memory initCode = abi.encodePacked(abi.encodePacked(address(accountFactory)), initCallData); + + UserOperation[] memory userOpCreateAccount = _setupUserOpExecute( + accountAdminPKey, + initCode, + address(0), + 0, + bytes("") + ); + + vm.expectEmit(true, true, false, true); + emit AccountCreated(sender, accountAdmin); + EntryPoint(entrypoint).handleOps(userOpCreateAccount, beneficiary); + + address[] memory allAccounts = accountFactory.getAllAccounts(); + assertEq(allAccounts.length, 1); + assertEq(allAccounts[0], sender); + } + + /// @dev Try registering with factory with a contract not deployed by factory. + function test_revert_onRegister_nonFactoryChildContract() public { + vm.prank(address(0x12345)); + vm.expectRevert("AccountFactory: not an account."); + accountFactory.onRegister(_generateSalt(accountAdmin, "")); + } + + /// @dev Create more than one accounts with the same admin. + function test_state_createAccount_viaEntrypoint_multipleAccountSameAdmin() public { + uint256 amount = 1; + + for (uint256 i = 0; i < amount; i += 1) { + bytes memory initCallData = abi.encodeWithSignature( + "createAccount(address,bytes)", + accountAdmin, + bytes(abi.encode(i)) + ); + bytes memory initCode = abi.encodePacked(abi.encodePacked(address(accountFactory)), initCallData); + + address expectedSenderAddress = Clones.predictDeterministicAddress( + accountFactory.accountImplementation(), + _generateSalt(accountAdmin, bytes(abi.encode(i))), + address(accountFactory) + ); + + UserOperation[] memory userOpCreateAccount = _setupUserOpExecuteWithSender( + initCode, + address(0), + 0, + bytes(abi.encode(i)), + expectedSenderAddress + ); + + vm.expectEmit(true, true, false, true); + emit AccountCreated(expectedSenderAddress, accountAdmin); + EntryPoint(entrypoint).handleOps(userOpCreateAccount, beneficiary); + } + + address[] memory allAccounts = accountFactory.getAllAccounts(); + assertEq(allAccounts.length, amount); + + for (uint256 i = 0; i < amount; i += 1) { + assertEq( + allAccounts[i], + Clones.predictDeterministicAddress( + accountFactory.accountImplementation(), + _generateSalt(accountAdmin, bytes(abi.encode(i))), + address(accountFactory) + ) + ); + } + } + + /*/////////////////////////////////////////////////////////////// + Test: performing a contract call + //////////////////////////////////////////////////////////////*/ + + function _setup_executeTransaction() internal { + bytes memory initCallData = abi.encodeWithSignature("createAccount(address,bytes)", accountAdmin, data); + bytes memory initCode = abi.encodePacked(abi.encodePacked(address(accountFactory)), initCallData); + + UserOperation[] memory userOpCreateAccount = _setupUserOpExecute( + accountAdminPKey, + initCode, + address(0), + 0, + bytes("") + ); + + EntryPoint(entrypoint).handleOps(userOpCreateAccount, beneficiary); + } + + /// @dev Perform a state changing transaction directly via account. + function test_state_executeTransaction() public { + _setup_executeTransaction(); + + address account = accountFactory.getAddress(accountAdmin, bytes("")); + + assertEq(numberContract.num(), 0); + + vm.prank(accountAdmin); + SimpleAccount(payable(account)).execute( + address(numberContract), + 0, + abi.encodeWithSignature("setNum(uint256)", 42) + ); + + assertEq(numberContract.num(), 42); + } + + /// @dev Perform many state changing transactions in a batch directly via account. + function test_state_executeBatchTransaction() public { + _setup_executeTransaction(); + + address account = accountFactory.getAddress(accountAdmin, bytes("")); + + assertEq(numberContract.num(), 0); + + uint256 count = 3; + address[] memory targets = new address[](count); + uint256[] memory values = new uint256[](count); + bytes[] memory callData = new bytes[](count); + + for (uint256 i = 0; i < count; i += 1) { + targets[i] = address(numberContract); + values[i] = 0; + callData[i] = abi.encodeWithSignature("incrementNum()", i); + } + + vm.prank(accountAdmin); + SimpleAccount(payable(account)).executeBatch(targets, values, callData); + + assertEq(numberContract.num(), count); + } + + /// @dev Perform a state changing transaction via Entrypoint. + function test_state_executeTransaction_viaEntrypoint() public { + _setup_executeTransaction(); + + assertEq(numberContract.num(), 0); + + UserOperation[] memory userOp = _setupUserOpExecute( + accountAdminPKey, + bytes(""), + address(numberContract), + 0, + abi.encodeWithSignature("setNum(uint256)", 42) + ); + + EntryPoint(entrypoint).handleOps(userOp, beneficiary); + + assertEq(numberContract.num(), 42); + } + + /// @dev Perform many state changing transactions in a batch via Entrypoint. + function test_state_executeBatchTransaction_viaEntrypoint() public { + _setup_executeTransaction(); + + assertEq(numberContract.num(), 0); + + uint256 count = 3; + address[] memory targets = new address[](count); + uint256[] memory values = new uint256[](count); + bytes[] memory callData = new bytes[](count); + + for (uint256 i = 0; i < count; i += 1) { + targets[i] = address(numberContract); + values[i] = 0; + callData[i] = abi.encodeWithSignature("incrementNum()", i); + } + + UserOperation[] memory userOp = _setupUserOpExecuteBatch( + accountAdminPKey, + bytes(""), + targets, + values, + callData + ); + + EntryPoint(entrypoint).handleOps(userOp, beneficiary); + + assertEq(numberContract.num(), count); + } + + /// @dev Perform a state changing transaction via Entrypoint and a SIGNER_ROLE holder. + function test_state_executeTransaction_viaAccountSigner() public { + _setup_executeTransaction(); + + address account = accountFactory.getAddress(accountAdmin, bytes("")); + + address[] memory approvedTargets = new address[](1); + approvedTargets[0] = address(numberContract); + + IAccountPermissions.SignerPermissionRequest memory permissionsReq = IAccountPermissions.SignerPermissionRequest( + accountSigner, + 0, + approvedTargets, + 1 ether, + 0, + type(uint128).max, + 0, + type(uint128).max, + uidCache + ); + + vm.prank(accountAdmin); + bytes memory sig = _signSignerPermissionRequest(permissionsReq); + SimpleAccount(payable(account)).setPermissionsForSigner(permissionsReq, sig); + + assertEq(numberContract.num(), 0); + + UserOperation[] memory userOp = _setupUserOpExecute( + accountSignerPKey, + bytes(""), + address(numberContract), + 0, + abi.encodeWithSignature("setNum(uint256)", 42) + ); + + EntryPoint(entrypoint).handleOps(userOp, beneficiary); + + assertEq(numberContract.num(), 42); + } + + /// @dev Revert: perform a state changing transaction via Entrypoint without appropriate permissions. + function test_revert_executeTransaction_nonSigner_viaEntrypoint() public { + _setup_executeTransaction(); + + assertEq(numberContract.num(), 0); + + UserOperation[] memory userOp = _setupUserOpExecute( + accountSignerPKey, + bytes(""), + address(numberContract), + 0, + abi.encodeWithSignature("setNum(uint256)", 42) + ); + + vm.expectRevert(); + EntryPoint(entrypoint).handleOps(userOp, beneficiary); + } + + /// @dev Perform many state changing transactions in a batch via Entrypoint. + function test_state_executeBatchTransaction_viaAccountSigner() public { + _setup_executeTransaction(); + + assertEq(numberContract.num(), 0); + + uint256 count = 3; + address[] memory targets = new address[](count); + uint256[] memory values = new uint256[](count); + bytes[] memory callData = new bytes[](count); + + for (uint256 i = 0; i < count; i += 1) { + targets[i] = address(numberContract); + values[i] = 0; + callData[i] = abi.encodeWithSignature("incrementNum()", i); + } + + address account = accountFactory.getAddress(accountAdmin, bytes("")); + + address[] memory approvedTargets = new address[](1); + approvedTargets[0] = address(numberContract); + + IAccountPermissions.SignerPermissionRequest memory permissionsReq = IAccountPermissions.SignerPermissionRequest( + accountSigner, + 0, + approvedTargets, + 1 ether, + 0, + type(uint128).max, + 0, + type(uint128).max, + uidCache + ); + + vm.prank(accountAdmin); + bytes memory sig = _signSignerPermissionRequest(permissionsReq); + SimpleAccount(payable(account)).setPermissionsForSigner(permissionsReq, sig); + + UserOperation[] memory userOp = _setupUserOpExecuteBatch( + accountSignerPKey, + bytes(""), + targets, + values, + callData + ); + + EntryPoint(entrypoint).handleOps(userOp, beneficiary); + + assertEq(numberContract.num(), count); + } + + /// @dev Revert: non-admin performs a state changing transaction directly via account contract. + function test_revert_executeTransaction_nonSigner_viaDirectCall() public { + _setup_executeTransaction(); + + address account = accountFactory.getAddress(accountAdmin, bytes("")); + + address[] memory approvedTargets = new address[](1); + approvedTargets[0] = address(numberContract); + + IAccountPermissions.SignerPermissionRequest memory permissionsReq = IAccountPermissions.SignerPermissionRequest( + accountSigner, + 0, + approvedTargets, + 1 ether, + 0, + type(uint128).max, + 0, + type(uint128).max, + uidCache + ); + + vm.prank(accountAdmin); + bytes memory sig = _signSignerPermissionRequest(permissionsReq); + SimpleAccount(payable(account)).setPermissionsForSigner(permissionsReq, sig); + + assertEq(numberContract.num(), 0); + + vm.prank(accountSigner); + vm.expectRevert("Account: not admin or EntryPoint."); + SimpleAccount(payable(account)).execute( + address(numberContract), + 0, + abi.encodeWithSignature("setNum(uint256)", 42) + ); + } + + /*/////////////////////////////////////////////////////////////// + Test: receiving and sending native tokens + //////////////////////////////////////////////////////////////*/ + + /// @dev Send native tokens to an account. + function test_state_accountReceivesNativeTokens() public { + _setup_executeTransaction(); + + address account = accountFactory.getAddress(accountAdmin, bytes("")); + + assertEq(address(account).balance, 0); + + vm.prank(accountAdmin); + // solhint-disable-next-line avoid-low-level-calls + (bool success, bytes memory ret) = payable(account).call{ value: 1000 }(""); + + assertEq(address(account).balance, 1000); + + // Silence warning: Return value of low-level calls not used. + (success, ret) = (success, ret); + } + + /// @dev Transfer native tokens out of an account. + function test_state_transferOutsNativeTokens() public { + _setup_executeTransaction(); + + uint256 value = 1000; + + address account = accountFactory.getAddress(accountAdmin, bytes("")); + vm.prank(accountAdmin); + // solhint-disable-next-line avoid-low-level-calls + (bool success, bytes memory ret) = payable(account).call{ value: value }(""); + assertEq(address(account).balance, value); + + // Silence warning: Return value of low-level calls not used. + (success, ret) = (success, ret); + + address recipient = address(0x3456); + + UserOperation[] memory userOp = _setupUserOpExecute(accountAdminPKey, bytes(""), recipient, value, bytes("")); + + EntryPoint(entrypoint).handleOps(userOp, beneficiary); + assertEq(address(account).balance, 0); + assertEq(recipient.balance, value); + } + + /// @dev Add and remove a deposit for the account from the Entrypoint. + + function test_state_addAndWithdrawDeposit() public { + _setup_executeTransaction(); + + address account = accountFactory.getAddress(accountAdmin, bytes("")); + + assertEq(EntryPoint(entrypoint).balanceOf(account), 0); + + vm.prank(accountAdmin); + SimpleAccount(payable(account)).addDeposit{ value: 1000 }(); + assertEq(EntryPoint(entrypoint).balanceOf(account), 1000); + + vm.prank(accountAdmin); + SimpleAccount(payable(account)).withdrawDepositTo(payable(accountSigner), 500); + assertEq(EntryPoint(entrypoint).balanceOf(account), 500); + } + + /*/////////////////////////////////////////////////////////////// + Test: receiving ERC-721 and ERC-1155 NFTs + //////////////////////////////////////////////////////////////*/ + + /// @dev Send an ERC-721 NFT to an account. + function test_state_receiveERC721NFT() public { + _setup_executeTransaction(); + address account = accountFactory.getAddress(accountAdmin, bytes("")); + + assertEq(erc721.balanceOf(account), 0); + + erc721.mint(account, 1); + + assertEq(erc721.balanceOf(account), 1); + } + + /// @dev Send an ERC-1155 NFT to an account. + function test_state_receiveERC1155NFT() public { + _setup_executeTransaction(); + address account = accountFactory.getAddress(accountAdmin, bytes("")); + + assertEq(erc1155.balanceOf(account, 0), 0); + + erc1155.mint(account, 0, 1); + + assertEq(erc1155.balanceOf(account, 0), 1); + } + + /*/////////////////////////////////////////////////////////////// + Test: change an extension on the account + //////////////////////////////////////////////////////////////*/ + + /// @dev Make the account reject ERC-721 NFTs instead of accepting them. + function test_scenario_changeExtensionForFunction() public { + _setup_executeTransaction(); + address account = accountFactory.getAddress(accountAdmin, bytes("")); + + // The account can initially receive NFTs. + assertEq(erc721.balanceOf(account), 0); + erc721.mint(account, 1); + assertEq(erc721.balanceOf(account), 1); + + // Make the account reject ERC-721 NFTs going forward. + IExtension.Extension memory extension; + + extension.metadata = IExtension.ExtensionMetadata({ + name: "NFTRejector", + metadataURI: "ipfs://NFTRejector", + implementation: address(new NFTRejector()) + }); + + extension.functions = new IExtension.ExtensionFunction[](1); + + extension.functions[0] = IExtension.ExtensionFunction( + NFTRejector.onERC721Received.selector, + "onERC721Received(address,address,uint256,bytes)" + ); + + vm.prank(factoryDeployer); + accountFactory.disableFunctionInExtension("AccountExtension", NFTRejector.onERC721Received.selector); + + vm.prank(factoryDeployer); + accountFactory.addExtension(extension); + + // Transfer NFTs to the account + erc721.mint(accountSigner, 1); + assertEq(erc721.ownerOf(1), accountSigner); + vm.prank(accountSigner); + vm.expectRevert("NFTs not accepted"); + erc721.safeTransferFrom(accountSigner, account, 1); + } +} diff --git a/src/test/smart-wallet/account-core/isValidSigner.t.sol b/src/test/smart-wallet/account-core/isValidSigner.t.sol new file mode 100644 index 000000000..49ddd39c3 --- /dev/null +++ b/src/test/smart-wallet/account-core/isValidSigner.t.sol @@ -0,0 +1,563 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +// Test utils +import { BaseTest } from "../../utils/BaseTest.sol"; +import "contracts/external-deps/openzeppelin/proxy/Clones.sol"; +import "@thirdweb-dev/dynamic-contracts/src/interface/IExtension.sol"; +import { IAccountPermissions } from "contracts/extension/interface/IAccountPermissions.sol"; +import { AccountPermissions, EnumerableSet, ECDSA } from "contracts/extension/upgradeable/AccountPermissions.sol"; + +// Account Abstraction setup for smart wallets. +import { EntryPoint, IEntryPoint } from "contracts/prebuilts/account/utils/Entrypoint.sol"; +import { UserOperation } from "contracts/prebuilts/account/utils/UserOperation.sol"; + +// Target +import { DynamicAccountFactory, DynamicAccount, BaseAccountFactory } from "contracts/prebuilts/account/dynamic/DynamicAccountFactory.sol"; + +/// @dev This is a dummy contract to test contract interactions with Account. +contract Number { + uint256 public num; + + function setNum(uint256 _num) public { + num = _num; + } + + function doubleNum() public { + num *= 2; + } + + function incrementNum() public { + num += 1; + } +} + +contract MyDynamicAccount is DynamicAccount { + using EnumerableSet for EnumerableSet.AddressSet; + + constructor( + IEntryPoint _entrypoint, + Extension[] memory _defaultExtensions + ) DynamicAccount(_entrypoint, _defaultExtensions) {} + + function setPermissionsForSigner( + address _signer, + uint256 _nativeTokenLimit, + uint256 _startTimestamp, + uint256 _endTimestamp + ) public { + _accountPermissionsStorage().signerPermissions[_signer] = SignerPermissionsStatic( + _nativeTokenLimit, + uint128(_startTimestamp), + uint128(_endTimestamp) + ); + } + + function setApprovedTargetsForSigner(address _signer, address[] memory _approvedTargets) public { + uint256 len = _approvedTargets.length; + for (uint256 i = 0; i < len; i += 1) { + _accountPermissionsStorage().approvedTargets[_signer].add(_approvedTargets[i]); + } + } + + function _setAdmin(address _account, bool _isAdmin) internal virtual override { + _accountPermissionsStorage().isAdmin[_account] = _isAdmin; + } + + function _isAuthorizedCallToUpgrade() internal view virtual override returns (bool) {} +} + +contract AccountCoreTest_isValidSigner is BaseTest { + // Target contracts + EntryPoint private entrypoint; + DynamicAccountFactory private accountFactory; + MyDynamicAccount private account; + + // Mocks + Number internal numberContract; + + // Test params + uint256 private accountAdminPKey = 100; + address private accountAdmin; + + uint256 private accountSignerPKey = 200; + address private accountSigner; + + uint256 private nonSignerPKey = 300; + address private nonSigner; + + address private opSigner; + uint256 private startTimestamp; + uint256 private endTimestamp; + uint256 private nativeTokenLimit; + UserOperation private op; + + bytes internal data = bytes(""); + + function _setupUserOp( + uint256 _signerPKey, + bytes memory _initCode, + bytes memory _callDataForEntrypoint + ) internal returns (UserOperation memory) { + uint256 nonce = entrypoint.getNonce(address(account), 0); + + // Get user op fields + UserOperation memory op = UserOperation({ + sender: address(account), + nonce: nonce, + initCode: _initCode, + callData: _callDataForEntrypoint, + callGasLimit: 5_000_000, + verificationGasLimit: 5_000_000, + preVerificationGas: 5_000_000, + maxFeePerGas: 0, + maxPriorityFeePerGas: 0, + paymasterAndData: bytes(""), + signature: bytes("") + }); + + // Sign UserOp + bytes32 opHash = EntryPoint(entrypoint).getUserOpHash(op); + bytes32 msgHash = ECDSA.toEthSignedMessageHash(opHash); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(_signerPKey, msgHash); + bytes memory userOpSignature = abi.encodePacked(r, s, v); + + address recoveredSigner = ECDSA.recover(msgHash, v, r, s); + address expectedSigner = vm.addr(_signerPKey); + assertEq(recoveredSigner, expectedSigner); + + op.signature = userOpSignature; + + return op; + } + + function _setupUserOpExecute( + uint256 _signerPKey, + bytes memory _initCode, + address _target, + uint256 _value, + bytes memory _callData + ) internal returns (UserOperation memory) { + bytes memory callDataForEntrypoint = abi.encodeWithSignature( + "execute(address,uint256,bytes)", + _target, + _value, + _callData + ); + + return _setupUserOp(_signerPKey, _initCode, callDataForEntrypoint); + } + + function _setupUserOpExecuteBatch( + uint256 _signerPKey, + bytes memory _initCode, + address[] memory _targets, + uint256[] memory _values, + bytes[] memory _callData + ) internal returns (UserOperation memory) { + bytes memory callDataForEntrypoint = abi.encodeWithSignature( + "executeBatch(address[],uint256[],bytes[])", + _targets, + _values, + _callData + ); + + return _setupUserOp(_signerPKey, _initCode, callDataForEntrypoint); + } + + function _setupUserOpInvalidFunction( + uint256 _signerPKey, + bytes memory _initCode + ) internal returns (UserOperation memory) { + bytes memory callDataForEntrypoint = abi.encodeWithSignature("invalidFunction()"); + + return _setupUserOp(_signerPKey, _initCode, callDataForEntrypoint); + } + + function setUp() public override { + super.setUp(); + + // Setup signers. + accountAdmin = vm.addr(accountAdminPKey); + vm.deal(accountAdmin, 100 ether); + + accountSigner = vm.addr(accountSignerPKey); + nonSigner = vm.addr(nonSignerPKey); + + // Setup contracts + entrypoint = new EntryPoint(); + + IExtension.Extension[] memory extensions; + + // deploy account factory + accountFactory = new DynamicAccountFactory(deployer, extensions); + // deploy dummy contract + numberContract = new Number(); + + address accountImpl = address(new MyDynamicAccount(IEntryPoint(payable(address(entrypoint))), extensions)); + address _account = Clones.cloneDeterministic(accountImpl, "salt"); + account = MyDynamicAccount(payable(_account)); + account.initialize(accountAdmin, ""); + } + + function test_isValidSigner_whenSignerIsAdmin() public { + opSigner = accountAdmin; + UserOperation memory _op; // empty op since it's not relevant for this check + bool isValid = DynamicAccount(payable(account)).isValidSigner(opSigner, _op); + + assertTrue(isValid); + } + + modifier whenNotAdmin() { + opSigner = accountSigner; + _; + } + + function test_isValidSigner_invalidTimestamps() public whenNotAdmin { + UserOperation memory _op; // empty op since it's not relevant for this check + startTimestamp = 100; + endTimestamp = 200; + account.setPermissionsForSigner(opSigner, nativeTokenLimit, startTimestamp, endTimestamp); + + vm.warp(201); // block timestamp greater than end timestamp + bool isValid = account.isValidSigner(opSigner, _op); + + assertFalse(isValid); + + vm.warp(200); // block timestamp equal to end timestamp + isValid = account.isValidSigner(opSigner, _op); + + assertFalse(isValid); + + vm.warp(99); // block timestamp less than start timestamp + isValid = account.isValidSigner(opSigner, _op); + + assertFalse(isValid); + } + + modifier whenValidTimestamps() { + startTimestamp = 100; + endTimestamp = 200; + vm.warp(150); // block timestamp within start and end timestamps + _; + } + + function test_isValidSigner_noApprovedTargets() public whenNotAdmin whenValidTimestamps { + UserOperation memory _op; // empty op since it's not relevant for this check + address[] memory _approvedTargets; + account.setPermissionsForSigner(opSigner, nativeTokenLimit, startTimestamp, endTimestamp); + account.setApprovedTargetsForSigner(opSigner, _approvedTargets); + + bool isValid = account.isValidSigner(opSigner, _op); + + assertFalse(isValid); + } + + // ================== + // ======= Test branch: wildcard + // ================== + + function test_isValidSigner_wildcardExecute_breachNativeTokenLimit() public whenNotAdmin whenValidTimestamps { + // set wildcard + address[] memory _approvedTargets = new address[](1); + _approvedTargets[0] = address(0); + account.setApprovedTargetsForSigner(opSigner, _approvedTargets); + + // user op execute + op = _setupUserOpExecute(accountSignerPKey, bytes(""), address(0x123), 10, bytes("")); + + account.setPermissionsForSigner(opSigner, nativeTokenLimit, startTimestamp, endTimestamp); + + bool isValid = account.isValidSigner(opSigner, op); + + assertFalse(isValid); + } + + function test_isValidSigner_wildcardExecuteBatch_breachNativeTokenLimit() public whenNotAdmin whenValidTimestamps { + // set wildcard + address[] memory _approvedTargets = new address[](1); + _approvedTargets[0] = address(0); + account.setApprovedTargetsForSigner(opSigner, _approvedTargets); + + // user op execute + uint256 count = 3; + address[] memory targets = new address[](count); + uint256[] memory values = new uint256[](count); + bytes[] memory callData = new bytes[](count); + + for (uint256 i = 0; i < count; i += 1) { + targets[i] = address(numberContract); + values[i] = 10; + callData[i] = abi.encodeWithSignature("incrementNum()", i); + } + + op = _setupUserOpExecuteBatch(accountSignerPKey, bytes(""), targets, values, callData); + + account.setPermissionsForSigner(opSigner, nativeTokenLimit, startTimestamp, endTimestamp); + + bool isValid = account.isValidSigner(opSigner, op); + + assertFalse(isValid); + } + + modifier whenWithinNativeTokenLimit() { + nativeTokenLimit = 1000; + _; + } + + function test_isValidSigner_wildcardExecute() public whenNotAdmin whenValidTimestamps whenWithinNativeTokenLimit { + // set wildcard + address[] memory _approvedTargets = new address[](1); + _approvedTargets[0] = address(0); + account.setApprovedTargetsForSigner(opSigner, _approvedTargets); + + // user op execute + op = _setupUserOpExecute(accountSignerPKey, bytes(""), address(0x123), 10, bytes("")); + + account.setPermissionsForSigner(opSigner, nativeTokenLimit, startTimestamp, endTimestamp); + + bool isValid = account.isValidSigner(opSigner, op); + + assertTrue(isValid); + } + + function test_isValidSigner_wildcardExecuteBatch() + public + whenNotAdmin + whenValidTimestamps + whenWithinNativeTokenLimit + { + // set wildcard + address[] memory _approvedTargets = new address[](1); + _approvedTargets[0] = address(0); + account.setApprovedTargetsForSigner(opSigner, _approvedTargets); + + // user op execute + uint256 count = 3; + address[] memory targets = new address[](count); + uint256[] memory values = new uint256[](count); + bytes[] memory callData = new bytes[](count); + + for (uint256 i = 0; i < count; i += 1) { + targets[i] = address(numberContract); + values[i] = 10; + callData[i] = abi.encodeWithSignature("incrementNum()", i); + } + + op = _setupUserOpExecuteBatch(accountSignerPKey, bytes(""), targets, values, callData); + + account.setPermissionsForSigner(opSigner, nativeTokenLimit, startTimestamp, endTimestamp); + + bool isValid = account.isValidSigner(opSigner, op); + + assertTrue(isValid); + } + + function test_isValidSigner_wildcardInvalidFunction() + public + whenNotAdmin + whenValidTimestamps + whenWithinNativeTokenLimit + { + // set wildcard + address[] memory _approvedTargets = new address[](1); + _approvedTargets[0] = address(0); + account.setApprovedTargetsForSigner(opSigner, _approvedTargets); + + // user op execute + op = _setupUserOpInvalidFunction(accountSignerPKey, bytes("")); + + account.setPermissionsForSigner(opSigner, nativeTokenLimit, startTimestamp, endTimestamp); + + bool isValid = account.isValidSigner(opSigner, op); + + assertFalse(isValid); + } + + // ================== + // ======= Test branch: not wildcard + // ================== + + function test_isValidSigner_execute_callingWrongTarget() + public + whenNotAdmin + whenValidTimestamps + whenWithinNativeTokenLimit + { + // set wildcard + address[] memory _approvedTargets = new address[](1); + _approvedTargets[0] = address(numberContract); + account.setApprovedTargetsForSigner(opSigner, _approvedTargets); + + // user op execute + address wrongTarget = address(0x123); + op = _setupUserOpExecute(accountSignerPKey, bytes(""), wrongTarget, 10, bytes("")); + + account.setPermissionsForSigner(opSigner, nativeTokenLimit, startTimestamp, endTimestamp); + + bool isValid = account.isValidSigner(opSigner, op); + + assertFalse(isValid); + } + + function test_isValidSigner_executeBatch_callingWrongTarget() + public + whenNotAdmin + whenValidTimestamps + whenWithinNativeTokenLimit + { + // set wildcard + address[] memory _approvedTargets = new address[](1); + _approvedTargets[0] = address(numberContract); + account.setApprovedTargetsForSigner(opSigner, _approvedTargets); + + // user op execute + uint256 count = 3; + address[] memory targets = new address[](count); + uint256[] memory values = new uint256[](count); + bytes[] memory callData = new bytes[](count); + address wrongTarget = address(0x123); + for (uint256 i = 0; i < count; i += 1) { + targets[i] = wrongTarget; + values[i] = 10; + callData[i] = abi.encodeWithSignature("incrementNum()", i); + } + + op = _setupUserOpExecuteBatch(accountSignerPKey, bytes(""), targets, values, callData); + + account.setPermissionsForSigner(opSigner, nativeTokenLimit, startTimestamp, endTimestamp); + + bool isValid = account.isValidSigner(opSigner, op); + + assertFalse(isValid); + } + + modifier whenCorrectTarget() { + _; + } + + function test_isValidSigner_execute_breachNativeTokenLimit() + public + whenNotAdmin + whenValidTimestamps + whenCorrectTarget + { + // set wildcard + address[] memory _approvedTargets = new address[](1); + _approvedTargets[0] = address(numberContract); + account.setApprovedTargetsForSigner(opSigner, _approvedTargets); + + // user op execute + op = _setupUserOpExecute(accountSignerPKey, bytes(""), address(numberContract), 10, bytes("")); + + account.setPermissionsForSigner(opSigner, nativeTokenLimit, startTimestamp, endTimestamp); + + bool isValid = account.isValidSigner(opSigner, op); + + assertFalse(isValid); + } + + function test_isValidSigner_executeBatch_breachNativeTokenLimit() + public + whenNotAdmin + whenValidTimestamps + whenCorrectTarget + { + // set wildcard + address[] memory _approvedTargets = new address[](1); + _approvedTargets[0] = address(numberContract); + account.setApprovedTargetsForSigner(opSigner, _approvedTargets); + + // user op execute + uint256 count = 3; + address[] memory targets = new address[](count); + uint256[] memory values = new uint256[](count); + bytes[] memory callData = new bytes[](count); + + for (uint256 i = 0; i < count; i += 1) { + targets[i] = address(numberContract); + values[i] = 10; + callData[i] = abi.encodeWithSignature("incrementNum()", i); + } + + op = _setupUserOpExecuteBatch(accountSignerPKey, bytes(""), targets, values, callData); + + account.setPermissionsForSigner(opSigner, nativeTokenLimit, startTimestamp, endTimestamp); + + bool isValid = account.isValidSigner(opSigner, op); + + assertFalse(isValid); + } + + function test_isValidSigner_execute() + public + whenNotAdmin + whenValidTimestamps + whenWithinNativeTokenLimit + whenCorrectTarget + { + // set wildcard + address[] memory _approvedTargets = new address[](1); + _approvedTargets[0] = address(numberContract); + account.setApprovedTargetsForSigner(opSigner, _approvedTargets); + + // user op execute + op = _setupUserOpExecute(accountSignerPKey, bytes(""), address(numberContract), 10, bytes("")); + + account.setPermissionsForSigner(opSigner, nativeTokenLimit, startTimestamp, endTimestamp); + + bool isValid = account.isValidSigner(opSigner, op); + + assertTrue(isValid); + } + + function test_isValidSigner_executeBatch() + public + whenNotAdmin + whenValidTimestamps + whenWithinNativeTokenLimit + whenCorrectTarget + { + // set wildcard + address[] memory _approvedTargets = new address[](1); + _approvedTargets[0] = address(numberContract); + account.setApprovedTargetsForSigner(opSigner, _approvedTargets); + + // user op execute + uint256 count = 3; + address[] memory targets = new address[](count); + uint256[] memory values = new uint256[](count); + bytes[] memory callData = new bytes[](count); + + for (uint256 i = 0; i < count; i += 1) { + targets[i] = address(numberContract); + values[i] = 10; + callData[i] = abi.encodeWithSignature("incrementNum()", i); + } + + op = _setupUserOpExecuteBatch(accountSignerPKey, bytes(""), targets, values, callData); + + account.setPermissionsForSigner(opSigner, nativeTokenLimit, startTimestamp, endTimestamp); + + bool isValid = account.isValidSigner(opSigner, op); + + assertTrue(isValid); + } + + function test_isValidSigner_invalidFunction() public whenNotAdmin whenValidTimestamps whenWithinNativeTokenLimit { + // set wildcard + address[] memory _approvedTargets = new address[](1); + _approvedTargets[0] = address(numberContract); + account.setApprovedTargetsForSigner(opSigner, _approvedTargets); + + // user op execute + op = _setupUserOpInvalidFunction(accountSignerPKey, bytes("")); + + account.setPermissionsForSigner(opSigner, nativeTokenLimit, startTimestamp, endTimestamp); + + bool isValid = account.isValidSigner(opSigner, op); + + assertFalse(isValid); + } +} diff --git a/src/test/smart-wallet/account-core/isValidSigner.tree b/src/test/smart-wallet/account-core/isValidSigner.tree new file mode 100644 index 000000000..cc4497260 --- /dev/null +++ b/src/test/smart-wallet/account-core/isValidSigner.tree @@ -0,0 +1,46 @@ +isValidSigner(address _signer, UserOperation calldata _userOp) +├── when `_signer` is admin +│ └── it should return true +├── when `_signer` is not admin + └── when timestamp is invalid + │ └── it should return false + └── when timestamp is valid + └── when no approved targets + │ └── it should return false + │ + │ // Case - Wildcard + └── when approved targets length is equal to 1 and contains address(0) + │ └── when calling `execute` function + │ │ └── when the decoded `value` is more than nativeTokenLimitPerTransaction + │ │ │ └── it should return false + │ │ └── when the decoded `value` is within nativeTokenLimitPerTransaction + │ │ └── it should return true + │ └── when calling `batchExecute` function + │ │ └── when any item in the decoded `values` array is more than nativeTokenLimitPerTransaction + │ │ │ └── it should return false + │ │ └── when all items in the decoded `values` array are within nativeTokenLimitPerTransaction + │ │ └── it should return true + │ └── when calling an invalid function + │ └── it should return false + │ + │ // Case - No Wildcard + └── when approved targets length is greater than 1, or doesn't contain address(0) + └── when calling `execute` function + │ └── when approvedTargets doesn't contain the decoded `target` + │ │ └── it should return false + │ └── when approvedTargets contains the decoded `target` + │ └── when the decoded `value` is more than nativeTokenLimitPerTransaction + │ │ └── it should return false + │ └── when the decoded `value` is within nativeTokenLimitPerTransaction + │ └── it should return true + └── when calling `batchExecute` function + │ └── when approvedTargets doesn't contain one or more items in the decoded `targets` array + │ │ └── it should return false + │ └── when approvedTargets contains all items in the decdoded `targets` array + │ └── when any item in the decoded `values` array is more than nativeTokenLimitPerTransaction + │ │ └── it should return false + │ └── when all items in the decoded `values` array are within nativeTokenLimitPerTransaction + │ └── it should return true + └── when calling an invalid function + └── it should return false + \ No newline at end of file diff --git a/src/test/smart-wallet/account-permissions/setPermissionsForSigner.t.sol b/src/test/smart-wallet/account-permissions/setPermissionsForSigner.t.sol new file mode 100644 index 000000000..f2eb8bb28 --- /dev/null +++ b/src/test/smart-wallet/account-permissions/setPermissionsForSigner.t.sol @@ -0,0 +1,630 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +// Test utils +import "../../utils/BaseTest.sol"; +import "@thirdweb-dev/dynamic-contracts/src/interface/IExtension.sol"; +import { IAccountPermissions } from "contracts/extension/interface/IAccountPermissions.sol"; +import { AccountPermissions } from "contracts/extension/upgradeable/AccountPermissions.sol"; +import { AccountExtension } from "contracts/prebuilts/account/utils/AccountExtension.sol"; + +// Account Abstraction setup for smart wallets. +import { EntryPoint, IEntryPoint } from "contracts/prebuilts/account/utils/Entrypoint.sol"; +import { UserOperation } from "contracts/prebuilts/account/utils/UserOperation.sol"; + +// Target +import { Account as SimpleAccount } from "contracts/prebuilts/account/non-upgradeable/Account.sol"; +import { DynamicAccountFactory, DynamicAccount } from "contracts/prebuilts/account/dynamic/DynamicAccountFactory.sol"; +import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; + +/// @dev This is a dummy contract to test contract interactions with Account. +contract Number { + uint256 public num; + + function setNum(uint256 _num) public { + num = _num; + } + + function doubleNum() public { + num *= 2; + } + + function incrementNum() public { + num += 1; + } +} + +contract NFTRejector { + function onERC721Received(address, address, uint256, bytes memory) public virtual returns (bytes4) { + revert("NFTs not accepted"); + } +} + +contract AccountPermissionsTest_setPermissionsForSigner is BaseTest { + event AdminUpdated(address indexed signer, bool isAdmin); + + event SignerPermissionsUpdated( + address indexed authorizingSigner, + address indexed targetSigner, + IAccountPermissions.SignerPermissionRequest permissions + ); + + // Target contracts + EntryPoint private constant entrypoint = EntryPoint(payable(0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789)); + DynamicAccountFactory private accountFactory; + + // Mocks + Number internal numberContract; + + // Test params + uint256 private accountAdminPKey = 100; + address private accountAdmin; + + uint256 private accountSignerPKey = 200; + address private accountSigner; + + uint256 private nonSignerPKey = 300; + address private nonSigner; + + bytes internal data = bytes(""); + + // UserOp terminology: `sender` is the smart wallet. + address private sender = 0x78b942FBC9126b4Ed8384Bb9dd1420Ea865be91a; + address payable private beneficiary = payable(address(0x45654)); + + bytes32 private uidCache = bytes32("random uid"); + + event AccountCreated(address indexed account, address indexed accountAdmin); + + function _prepareSignature( + IAccountPermissions.SignerPermissionRequest memory _req + ) internal view returns (bytes32 typedDataHash) { + bytes32 typehashSignerPermissionRequest = keccak256( + "SignerPermissionRequest(address signer,uint8 isAdmin,address[] approvedTargets,uint256 nativeTokenLimitPerTransaction,uint128 permissionStartTimestamp,uint128 permissionEndTimestamp,uint128 reqValidityStartTimestamp,uint128 reqValidityEndTimestamp,bytes32 uid)" + ); + bytes32 nameHash = keccak256(bytes("Account")); + bytes32 versionHash = keccak256(bytes("1")); + bytes32 typehashEip712 = keccak256( + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" + ); + bytes32 domainSeparator = keccak256(abi.encode(typehashEip712, nameHash, versionHash, block.chainid, sender)); + + bytes memory encodedRequestStart = abi.encode( + typehashSignerPermissionRequest, + _req.signer, + _req.isAdmin, + keccak256(abi.encodePacked(_req.approvedTargets)), + _req.nativeTokenLimitPerTransaction + ); + + bytes memory encodedRequestEnd = abi.encode( + _req.permissionStartTimestamp, + _req.permissionEndTimestamp, + _req.reqValidityStartTimestamp, + _req.reqValidityEndTimestamp, + _req.uid + ); + + bytes32 structHash = keccak256(bytes.concat(encodedRequestStart, encodedRequestEnd)); + typedDataHash = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); + } + + function _signSignerPermissionRequest( + IAccountPermissions.SignerPermissionRequest memory _req + ) internal view returns (bytes memory signature) { + bytes32 typedDataHash = _prepareSignature(_req); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(accountAdminPKey, typedDataHash); + signature = abi.encodePacked(r, s, v); + } + + function _signSignerPermissionRequestInvalid( + IAccountPermissions.SignerPermissionRequest memory _req + ) internal view returns (bytes memory signature) { + bytes32 typedDataHash = _prepareSignature(_req); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(0x111, typedDataHash); + signature = abi.encodePacked(r, s, v); + } + + function _setupUserOp( + uint256 _signerPKey, + bytes memory _initCode, + bytes memory _callDataForEntrypoint + ) internal returns (UserOperation[] memory ops) { + uint256 nonce = entrypoint.getNonce(sender, 0); + + // Get user op fields + UserOperation memory op = UserOperation({ + sender: sender, + nonce: nonce, + initCode: _initCode, + callData: _callDataForEntrypoint, + callGasLimit: 5_000_000, + verificationGasLimit: 5_000_000, + preVerificationGas: 5_000_000, + maxFeePerGas: 0, + maxPriorityFeePerGas: 0, + paymasterAndData: bytes(""), + signature: bytes("") + }); + + // Sign UserOp + bytes32 opHash = EntryPoint(entrypoint).getUserOpHash(op); + bytes32 msgHash = ECDSA.toEthSignedMessageHash(opHash); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(_signerPKey, msgHash); + bytes memory userOpSignature = abi.encodePacked(r, s, v); + + address recoveredSigner = ECDSA.recover(msgHash, v, r, s); + address expectedSigner = vm.addr(_signerPKey); + assertEq(recoveredSigner, expectedSigner); + + op.signature = userOpSignature; + + // Store UserOp + ops = new UserOperation[](1); + ops[0] = op; + } + + function _setupUserOpExecute( + uint256 _signerPKey, + bytes memory _initCode, + address _target, + uint256 _value, + bytes memory _callData + ) internal returns (UserOperation[] memory) { + bytes memory callDataForEntrypoint = abi.encodeWithSignature( + "execute(address,uint256,bytes)", + _target, + _value, + _callData + ); + + return _setupUserOp(_signerPKey, _initCode, callDataForEntrypoint); + } + + function _setupUserOpExecuteBatch( + uint256 _signerPKey, + bytes memory _initCode, + address[] memory _target, + uint256[] memory _value, + bytes[] memory _callData + ) internal returns (UserOperation[] memory) { + bytes memory callDataForEntrypoint = abi.encodeWithSignature( + "executeBatch(address[],uint256[],bytes[])", + _target, + _value, + _callData + ); + + return _setupUserOp(_signerPKey, _initCode, callDataForEntrypoint); + } + + function setUp() public override { + super.setUp(); + + // Setup signers. + accountAdmin = vm.addr(accountAdminPKey); + vm.deal(accountAdmin, 100 ether); + + accountSigner = vm.addr(accountSignerPKey); + nonSigner = vm.addr(nonSignerPKey); + + // Setup contracts + address _deployedEntrypoint = address(new EntryPoint()); + vm.etch(address(entrypoint), bytes(_deployedEntrypoint.code)); + + // Setting up default extension. + IExtension.Extension memory defaultExtension; + + defaultExtension.metadata = IExtension.ExtensionMetadata({ + name: "AccountExtension", + metadataURI: "ipfs://AccountExtension", + implementation: address(new AccountExtension()) + }); + + defaultExtension.functions = new IExtension.ExtensionFunction[](7); + + defaultExtension.functions[0] = IExtension.ExtensionFunction( + AccountExtension.supportsInterface.selector, + "supportsInterface(bytes4)" + ); + defaultExtension.functions[1] = IExtension.ExtensionFunction( + AccountExtension.execute.selector, + "execute(address,uint256,bytes)" + ); + defaultExtension.functions[2] = IExtension.ExtensionFunction( + AccountExtension.executeBatch.selector, + "executeBatch(address[],uint256[],bytes[])" + ); + defaultExtension.functions[3] = IExtension.ExtensionFunction( + ERC721Holder.onERC721Received.selector, + "onERC721Received(address,address,uint256,bytes)" + ); + defaultExtension.functions[4] = IExtension.ExtensionFunction( + ERC1155Holder.onERC1155Received.selector, + "onERC1155Received(address,address,uint256,uint256,bytes)" + ); + defaultExtension.functions[5] = IExtension.ExtensionFunction( + bytes4(0), // Selector for `receive()` function. + "receive()" + ); + defaultExtension.functions[6] = IExtension.ExtensionFunction( + AccountExtension.isValidSignature.selector, + "isValidSignature(bytes32,bytes)" + ); + + IExtension.Extension[] memory extensions = new IExtension.Extension[](1); + extensions[0] = defaultExtension; + + // deploy account factory + accountFactory = new DynamicAccountFactory(deployer, extensions); + // deploy dummy contract + numberContract = new Number(); + } + + function _setup_executeTransaction() internal { + bytes memory initCallData = abi.encodeWithSignature("createAccount(address,bytes)", accountAdmin, data); + bytes memory initCode = abi.encodePacked(abi.encodePacked(address(accountFactory)), initCallData); + + UserOperation[] memory userOpCreateAccount = _setupUserOpExecute( + accountAdminPKey, + initCode, + address(0), + 0, + bytes("") + ); + + EntryPoint(entrypoint).handleOps(userOpCreateAccount, beneficiary); + } + + function test_state_targetAdminNotAdmin() public { + _setup_executeTransaction(); + + address account = accountFactory.getAddress(accountAdmin, bytes("")); + address[] memory approvedTargets = new address[](0); + + bool adminStatusBefore = SimpleAccount(payable(account)).isAdmin(accountSigner); + + IAccountPermissions.SignerPermissionRequest memory permissionsReq = IAccountPermissions.SignerPermissionRequest( + accountSigner, + 1, + approvedTargets, + 0, + 0, + type(uint128).max, + 0, + type(uint128).max, + uidCache + ); + + vm.prank(accountAdmin); + bytes memory sig = _signSignerPermissionRequest(permissionsReq); + SimpleAccount(payable(account)).setPermissionsForSigner(permissionsReq, sig); + + bool adminStatusAfter = SimpleAccount(payable(account)).isAdmin(accountSigner); + + assertEq(adminStatusBefore, false); + assertEq(adminStatusAfter, true); + } + + function test_state_targetAdminIsAdmin() public { + _setup_executeTransaction(); + + address account = accountFactory.getAddress(accountAdmin, bytes("")); + address[] memory approvedTargets = new address[](0); + + { + IAccountPermissions.SignerPermissionRequest memory request = IAccountPermissions.SignerPermissionRequest( + accountSigner, + 1, + approvedTargets, + 1, + 0, + type(uint128).max, + 0, + type(uint128).max, + uidCache + ); + + bytes memory sig2 = _signSignerPermissionRequest(request); + SimpleAccount(payable(account)).setPermissionsForSigner(request, sig2); + + address[] memory adminsBefore = SimpleAccount(payable(account)).getAllAdmins(); + assertEq(adminsBefore[1], accountSigner); + } + + bool adminStatusBefore = SimpleAccount(payable(account)).isAdmin(accountAdmin); + + uidCache = bytes32("new uid"); + + IAccountPermissions.SignerPermissionRequest memory req = IAccountPermissions.SignerPermissionRequest( + accountSigner, + 2, + approvedTargets, + 1, + 0, + type(uint128).max, + 0, + type(uint128).max, + uidCache + ); + + bytes memory sig3 = _signSignerPermissionRequest(req); + SimpleAccount(payable(account)).setPermissionsForSigner(req, sig3); + + bool adminStatusAfter = SimpleAccount(payable(account)).isAdmin(accountSigner); + address[] memory adminsAfter = SimpleAccount(payable(account)).getAllAdmins(); + + assertEq(adminStatusBefore, true); + assertEq(adminStatusAfter, false); + assertEq(adminsAfter.length, 1); + } + + function test_revert_attemptReplayUID() public { + _setup_executeTransaction(); + + address account = accountFactory.getAddress(accountAdmin, bytes("")); + address[] memory approvedTargets = new address[](0); + + IAccountPermissions.SignerPermissionRequest memory permissionsReq = IAccountPermissions.SignerPermissionRequest( + accountSigner, + 1, + approvedTargets, + 1, + 0, + type(uint128).max, + 0, + type(uint128).max, + uidCache + ); + + bytes memory sig = _signSignerPermissionRequest(permissionsReq); + SimpleAccount(payable(account)).setPermissionsForSigner(permissionsReq, sig); + + // Attempt replay UID + + IAccountPermissions.SignerPermissionRequest memory permissionsReqTwo = IAccountPermissions + .SignerPermissionRequest( + accountSigner, + 1, + approvedTargets, + 0, + 0, + type(uint128).max, + 0, + type(uint128).max, + uidCache + ); + + sig = _signSignerPermissionRequest(permissionsReqTwo); + vm.expectRevert(); + SimpleAccount(payable(account)).setPermissionsForSigner(permissionsReqTwo, sig); + } + + function test_event_addAdmin_AdminUpdated() public { + _setup_executeTransaction(); + + address account = accountFactory.getAddress(accountAdmin, bytes("")); + address[] memory approvedTargets = new address[](0); + + IAccountPermissions.SignerPermissionRequest memory permissionsReq = IAccountPermissions.SignerPermissionRequest( + accountSigner, + 1, + approvedTargets, + 1, + 0, + type(uint128).max, + 0, + type(uint128).max, + uidCache + ); + + bytes memory sig = _signSignerPermissionRequest(permissionsReq); + + vm.expectEmit(true, false, false, true); + emit AdminUpdated(accountSigner, true); + SimpleAccount(payable(account)).setPermissionsForSigner(permissionsReq, sig); + } + + function test_event_removeAdmin_AdminUpdated() public { + _setup_executeTransaction(); + + address account = accountFactory.getAddress(accountAdmin, bytes("")); + address[] memory approvedTargets = new address[](0); + + IAccountPermissions.SignerPermissionRequest memory permissionsReq = IAccountPermissions.SignerPermissionRequest( + accountSigner, + 2, + approvedTargets, + 1, + 0, + type(uint128).max, + 0, + type(uint128).max, + uidCache + ); + + bytes memory sig = _signSignerPermissionRequest(permissionsReq); + + vm.expectEmit(true, false, false, true); + emit AdminUpdated(accountSigner, false); + SimpleAccount(payable(account)).setPermissionsForSigner(permissionsReq, sig); + } + + function test_revert_timeBeforeStart() public { + _setup_executeTransaction(); + + address account = accountFactory.getAddress(accountAdmin, bytes("")); + address[] memory approvedTargets = new address[](0); + + IAccountPermissions.SignerPermissionRequest memory permissionsReq = IAccountPermissions.SignerPermissionRequest( + accountSigner, + 1, + approvedTargets, + 0, + 0, + type(uint128).max, + uint128(block.timestamp + 1000), + type(uint128).max, + uidCache + ); + + vm.prank(accountAdmin); + bytes memory sig = _signSignerPermissionRequest(permissionsReq); + vm.expectRevert("!period"); + SimpleAccount(payable(account)).setPermissionsForSigner(permissionsReq, sig); + } + + function test_revert_timeAfterExpiry() public { + _setup_executeTransaction(); + + address account = accountFactory.getAddress(accountAdmin, bytes("")); + address[] memory approvedTargets = new address[](0); + + IAccountPermissions.SignerPermissionRequest memory permissionsReq = IAccountPermissions.SignerPermissionRequest( + accountSigner, + 1, + approvedTargets, + 0, + 0, + type(uint128).max, + 0, + uint128(block.timestamp - 1), + uidCache + ); + + vm.prank(accountAdmin); + bytes memory sig = _signSignerPermissionRequest(permissionsReq); + vm.expectRevert("!period"); + SimpleAccount(payable(account)).setPermissionsForSigner(permissionsReq, sig); + } + + function test_revert_SignerNotAdmin() public { + _setup_executeTransaction(); + + address account = accountFactory.getAddress(accountAdmin, bytes("")); + address[] memory approvedTargets = new address[](0); + + IAccountPermissions.SignerPermissionRequest memory permissionsReq = IAccountPermissions.SignerPermissionRequest( + accountSigner, + 0, + approvedTargets, + 0, + 0, + type(uint128).max, + 0, + type(uint128).max, + uidCache + ); + + vm.prank(accountAdmin); + bytes memory sig = _signSignerPermissionRequestInvalid(permissionsReq); + vm.expectRevert(bytes("!sig")); + SimpleAccount(payable(account)).setPermissionsForSigner(permissionsReq, sig); + } + + function test_revert_SignerAlreadyAdmin() public { + _setup_executeTransaction(); + + address account = accountFactory.getAddress(accountAdmin, bytes("")); + address[] memory approvedTargets = new address[](0); + + { + //set admin status + IAccountPermissions.SignerPermissionRequest memory req = IAccountPermissions.SignerPermissionRequest( + accountSigner, + 1, + approvedTargets, + 0, + 0, + type(uint128).max, + 0, + type(uint128).max, + uidCache + ); + + vm.prank(accountAdmin); + bytes memory sig2 = _signSignerPermissionRequest(req); + SimpleAccount(payable(account)).setPermissionsForSigner(req, sig2); + } + + //test set signerPerms as admin + + uidCache = bytes32("new uid"); + + IAccountPermissions.SignerPermissionRequest memory permissionsReq = IAccountPermissions.SignerPermissionRequest( + accountSigner, + 0, + approvedTargets, + 0, + 0, + type(uint128).max, + 0, + type(uint128).max, + uidCache + ); + + vm.prank(accountAdmin); + bytes memory sig3 = _signSignerPermissionRequest(permissionsReq); + vm.expectRevert("admin"); + SimpleAccount(payable(account)).setPermissionsForSigner(permissionsReq, sig3); + } + + function test_state_setPermissionsForSigner() public { + _setup_executeTransaction(); + + address account = accountFactory.getAddress(accountAdmin, bytes("")); + address[] memory approvedTargets = new address[](1); + approvedTargets[0] = address(numberContract); + + IAccountPermissions.SignerPermissionRequest memory permissionsReq = IAccountPermissions.SignerPermissionRequest( + accountSigner, + 0, + approvedTargets, + 1, + 0, + type(uint128).max, + 0, + type(uint128).max, + uidCache + ); + + vm.prank(accountAdmin); + bytes memory sig = _signSignerPermissionRequest(permissionsReq); + SimpleAccount(payable(account)).setPermissionsForSigner(permissionsReq, sig); + + IAccountPermissions.SignerPermissions[] memory allSigners = SimpleAccount(payable(account)).getAllSigners(); + assertEq(allSigners[0].signer, accountSigner); + assertEq(allSigners[0].approvedTargets[0], address(numberContract)); + assertEq(allSigners[0].nativeTokenLimitPerTransaction, 1); + assertEq(allSigners[0].startTimestamp, 0); + assertEq(allSigners[0].endTimestamp, type(uint128).max); + } + + function test_event_addSigner() public { + _setup_executeTransaction(); + + address account = accountFactory.getAddress(accountAdmin, bytes("")); + address[] memory approvedTargets = new address[](1); + approvedTargets[0] = address(numberContract); + + IAccountPermissions.SignerPermissionRequest memory permissionsReq = IAccountPermissions.SignerPermissionRequest( + accountSigner, + 0, + approvedTargets, + 1, + 0, + type(uint128).max, + 0, + type(uint128).max, + uidCache + ); + + vm.prank(accountAdmin); + bytes memory sig = _signSignerPermissionRequest(permissionsReq); + + vm.expectEmit(true, true, false, true); + emit SignerPermissionsUpdated(accountAdmin, accountSigner, permissionsReq); + SimpleAccount(payable(account)).setPermissionsForSigner(permissionsReq, sig); + } +} diff --git a/src/test/smart-wallet/account-permissions/setPermissionsForSigner.tree b/src/test/smart-wallet/account-permissions/setPermissionsForSigner.tree new file mode 100644 index 000000000..fbe90351d --- /dev/null +++ b/src/test/smart-wallet/account-permissions/setPermissionsForSigner.tree @@ -0,0 +1,33 @@ +function setPermissionsForSigner(SignerPermissionRequest calldata _req, bytes calldata _signature) +├── when reqValidityStartTimestamp is greater than block.timestamp +│ └── it should revert ✅ +├── when reqValidityEndTimestamp is less than block.timestamp +│ └── it should revert ✅ +├── when uid is executed +│ └── it should revert ✅ +├── when the recovered signer is not an admin +│ └── it should revert ✅ +└── when the reqValidityStartTimestamp is less than block.timestamp + └── when reqValidityEndTimestamp is greater than block.timestamp + └── when recovered signer is an admin ✅ + └── when req.uid has not been marked as executed + └── when _req.isAdmin is greater than zero + ├── it should mark req.uid as executed ✅ + ├── when _req.isAdmin is one + │ ├── it should set isAdmin[(targetAdmin)] as true ✅ + │ ├── it should add targetAdmin to allAdmins ✅ + │ └── it should emit AdminUpdated with the parameters targetAdmin, true ✅ + ├── when _req.isAdmin is greater than one + │ ├── it should set isAdmin[(targetAdmin)] as false ✅ + │ ├── it should remove targetAdmin from allAdmins ✅ + │ └── it should emit the event AdminUpdated with the parameters targetAdmin, false ✅ + └── when _req.isAdmin is equal to zero + ├── when targetSigner is an admin + │ └── it should revert ✅ + └── when targetSigner is not an admin + ├── it should mark req.uid as executed ✅ + ├── it should add targetSigner to allSigners ✅ + ├── it should set signerPermissions[(targetSigner)] as a SignerPermissionsStatic(nativeTokenLimitPerTransaction, permissionStartTimestamp, permissionEndTimestamp) ✅ + ├── it should remove current approved targets for targetSigner ✅ + ├── it should add the new approved targets for targetSigner ✅ + └── it should emit the event SignerPermissionsUpdated with the parameters signer, targetSigner, SignerPermissionRequest ✅ \ No newline at end of file diff --git a/src/test/smart-wallet/utils/AABenchmarkArtifacts.sol b/src/test/smart-wallet/utils/AABenchmarkArtifacts.sol new file mode 100644 index 000000000..69107bb0c --- /dev/null +++ b/src/test/smart-wallet/utils/AABenchmarkArtifacts.sol @@ -0,0 +1,14 @@ + +pragma solidity ^0.8.0; +interface ThirdwebAccountFactory { + function createAccount(address _admin, bytes calldata _data) external returns (address); + function getAddress(address _adminSigner, bytes calldata _data) external view returns (address); +} +interface ThirdwebAccount { + function execute(address _target, uint256 _value, bytes calldata _calldata) external; +} +address constant THIRDWEB_ACCOUNT_FACTORY_ADDRESS = 0x2e234DAe75C793f67A35089C9d99245E1C58470b; +address constant THIRDWEB_ACCOUNT_IMPL_ADDRESS = 0xffD4505B3452Dc22f8473616d50503bA9E1710Ac; +bytes constant THIRDWEB_ACCOUNT_FACTORY_BYTECODE = hex"608060405234801561001057600080fd5b50600436106101285760003560e01c806308e93d0a1461012d5780630b61e12b1461014b5780630e6254fd1461016057806311464fbe14610173578063248a9ca3146101b25780632f2ff15d146101d357806336568abe146101e657806358451f97146101f957806383a03f8c146102015780638878ed33146102145780639010d07c1461022757806391d148541461023a5780639387a3801461025d578063938e3d7b14610270578063a217fddf14610283578063a32fa5b31461028b578063a65d69d41461029e578063ac9650d8146102c5578063c3c5a547146102e5578063ca15c873146102f8578063d547741f1461030b578063d8fd8f441461031e578063e68a7c3b14610331578063e8a3d48514610344575b600080fd5b610135610359565b6040516101429190611945565b60405180910390f35b61015e6101593660046119ae565b61036a565b005b61013561016e3660046119d8565b61040b565b61019a7f000000000000000000000000ffd4505b3452dc22f8473616d50503ba9e1710ac81565b6040516001600160a01b039091168152602001610142565b6101c56101c03660046119f3565b610435565b604051908152602001610142565b61015e6101e1366004611a0c565b610453565b61015e6101f4366004611a0c565b6104fd565b6101c561055c565b61015e61020f3660046119f3565b610568565b61019a610222366004611a38565b6105b6565b61019a610235366004611aba565b610630565b61024d610248366004611a0c565b61073e565b6040519015158152602001610142565b61015e61026b3660046119ae565b610772565b61015e61027e366004611af2565b610809565b6101c5600081565b61024d610299366004611a0c565b61085a565b61019a7f0000000000000000000000005ff137d4b0fdcd49dca30c7cf57e578a026d278981565b6102d86102d3366004611ba2565b6108bd565b6040516101429190611c66565b61024d6102f33660046119d8565b610a19565b6101c56103063660046119f3565b610a25565b61015e610319366004611a0c565b610ac2565b61019a61032c366004611a38565b610acd565b61013561033f366004611aba565b610c18565b61034c610d49565b6040516101429190611cca565b60606103656000610de1565b905090565b336103758183610dee565b61039a5760405162461bcd60e51b815260040161039190611cdd565b60405180910390fd5b6001600160a01b03831660009081526002602052604081206103bc9083610e32565b9050801561040557836001600160a01b0316826001600160a01b03167f12146497b3b826918ec47f0cac7272a09ed06b30c16c030e99ec48ff5dd60b4760405160405180910390a35b50505050565b6001600160a01b038116600090815260026020526040902060609061042f90610de1565b92915050565b600061043f610e47565b600092835260010160205250604090205490565b61047761045e610e47565b6000848152600191909101602052604090205433610e6b565b61047f610e47565b6000838152602091825260408082206001600160a01b0385168352909252205460ff16156104ef5760405162461bcd60e51b815260206004820152601d60248201527f43616e206f6e6c79206772616e7420746f206e6f6e20686f6c646572730000006044820152606401610391565b6104f98282610ef0565b5050565b336001600160a01b038216146105525760405162461bcd60e51b815260206004820152601a60248201527921b0b71037b7363c903932b737bab731b2903337b91039b2b63360311b6044820152606401610391565b6104f98282610f04565b60006103656000610f18565b336105738183610dee565b61058f5760405162461bcd60e51b815260040161039190611cdd565b61059a600082610e32565b6104f95760405162461bcd60e51b815260040161039190611d14565b6000806105f98585858080601f016020809104026020016040519081016040528093929190818152602001838380828437600092019190915250610f2292505050565b90506106257f000000000000000000000000ffd4505b3452dc22f8473616d50503ba9e1710ac82610f55565b9150505b9392505050565b60008061063b610fb5565b600085815260209190915260408120549150805b82811015610735576000610661610fb5565b60008881526020918252604080822085835260010190925220546001600160a01b0316146106d9578482036106c757610698610fb5565b600087815260209182526040808220938252600190930190915220546001600160a01b0316925061042f915050565b6106d2600183611d74565b9150610723565b6106e486600061073e565b801561071057506106f3610fb5565b600087815260209182526040808220828052600201909252205481145b1561072357610720600183611d74565b91505b61072e600182611d74565b905061064f565b50505092915050565b6000610748610e47565b6000938452602090815260408085206001600160a01b039490941685529290525090205460ff1690565b3361077d8183610dee565b6107995760405162461bcd60e51b815260040161039190611cdd565b6001600160a01b03831660009081526002602052604081206107bb9083610fbf565b9050801561040557836001600160a01b0316826001600160a01b03167f98d1ebbe00ae92a5de96a0f49742a8afa89f42363592bc2e7cfaaed68b45e7a660405160405180910390a350505050565b610811610fd4565b61084e5760405162461bcd60e51b815260206004820152600e60248201526d139bdd08185d5d1a1bdc9a5e995960921b6044820152606401610391565b61085781610fe0565b50565b6000610864610e47565b600084815260209182526040808220828052909252205460ff166108b45761088a610e47565b6000848152602091825260408082206001600160a01b0386168352909252205460ff16905061042f565b50600192915050565b6060816001600160401b038111156108d7576108d7611adc565b60405190808252806020026020018201604052801561090a57816020015b60608152602001906001900390816108f55790505b509050336000805b848110156107355781156109915761096f3087878481811061093657610936611d87565b90506020028101906109489190611d9d565b8660405160200161095b93929190611dea565b6040516020818303038152906040526110c7565b84828151811061098157610981611d87565b6020026020010181905250610a11565b6109f3308787848181106109a7576109a7611d87565b90506020028101906109b99190611d9d565b8080601f0160208091040260200160405190810160405280939291908181526020018383808284376000920191909152506110c792505050565b848281518110610a0557610a05611d87565b60200260200101819052505b600101610912565b600061042f81836110ec565b600080610a30610fb5565b6000848152602091909152604081205491505b81811015610a9d576000610a55610fb5565b60008681526020918252604080822085835260010190925220546001600160a01b031614610a8b57610a88600184611d74565b92505b610a96600182611d74565b9050610a43565b50610aa983600061073e565b15610abc57610ab9600183611d74565b91505b50919050565b61055261045e610e47565b6000807f000000000000000000000000ffd4505b3452dc22f8473616d50503ba9e1710ac90506000610b358686868080601f016020809104026020016040519081016040528093929190818152602001838380828437600092019190915250610f2292505050565b90506000610b438383610f55565b90506001600160a01b0381163b15610b5f579250610629915050565b610b69838361110e565b9050336001600160a01b037f0000000000000000000000005ff137d4b0fdcd49dca30c7cf57e578a026d27891614610bc257610ba6600082610e32565b610bc25760405162461bcd60e51b815260040161039190611d14565b610bce818888886111a5565b866001600160a01b0316816001600160a01b03167fac631f3001b55ea1509cf3d7e74898f85392a61a76e8149181ae1259622dabc860405160405180910390a39695505050505050565b60608183108015610c325750610c2e6000610f18565b8211155b610c8a5760405162461bcd60e51b815260206004820152602360248201527f426173654163636f756e74466163746f72793a20696e76616c696420696e646960448201526263657360e81b6064820152608401610391565b6000610c968484611e0b565b9050610ca28484611e0b565b6001600160401b03811115610cb957610cb9611adc565b604051908082528060200260200182016040528015610ce2578160200160208202803683370190505b50915060005b81811015610d4157610d05610cfd8683611d74565b60009061120d565b838281518110610d1757610d17611d87565b6001600160a01b0390921660209283029190910190910152610d3a600182611d74565b9050610ce8565b505092915050565b6060610d53611219565b8054610d5e90611e1e565b80601f0160208091040260200160405190810160405280929190818152602001828054610d8a90611e1e565b8015610dd75780601f10610dac57610100808354040283529160200191610dd7565b820191906000526020600020905b815481529060010190602001808311610dba57829003601f168201915b5050505050905090565b606060006106298361123d565b600080610e1b7f000000000000000000000000ffd4505b3452dc22f8473616d50503ba9e1710ac84610f55565b6001600160a01b0385811691161491505092915050565b6000610629836001600160a01b038416611299565b7f0a7b0f5c59907924802379ebe98cdc23e2ee7820f63d30126e10b3752010e50090565b610e73610e47565b6000838152602091825260408082206001600160a01b0385168352909252205460ff166104f957610eae816001600160a01b031660146112e8565b610eb98360206112e8565b604051602001610eca929190611e52565b60408051601f198184030181529082905262461bcd60e51b825261039191600401611cca565b610efa8282611483565b6104f982826114ec565b610f0e82826115ab565b6104f98282611614565b600061042f825490565b60008282604051602001610f37929190611ebf565b60405160208183030381529060405280519060200120905092915050565b6040513060388201526f5af43d82803e903d91602b57fd5bf3ff602482015260148101839052733d602d80600a3d3981f3363d3d373d3d3d363d738152605881018290526037600c82012060788201526055604390910120600090610629565b60006103656116a3565b6000610629836001600160a01b038416611705565b6000610365813361073e565b6000610fea611219565b8054610ff590611e1e565b80601f016020809104026020016040519081016040528092919081815260200182805461102190611e1e565b801561106e5780601f106110435761010080835404028352916020019161106e565b820191906000526020600020905b81548152906001019060200180831161105157829003601f168201915b505050505090508161107e611219565b906110899082611f34565b507fc9c7c3fe08b88b4df9d4d47ef47d2c43d55c025a0ba88ca442580ed9e7348a1681836040516110bb929190611ff3565b60405180910390a15050565b606061062983836040518060600160405280602781526020016120b9602791396117f8565b6001600160a01b03811660009081526001830160205260408120541515610629565b6000763d602d80600a3d3981f3363d3d373d3d3d363d730000008360601b60e81c176000526e5af43d82803e903d91602b57fd5bf38360781b1760205281603760096000f590506001600160a01b03811661042f5760405162461bcd60e51b8152602060048201526017602482015276115490cc4c4d8dce8818dc99585d194c8819985a5b1959604a1b6044820152606401610391565b60405163347d5e2560e21b81526001600160a01b0385169063d1f57894906111d590869086908690600401612018565b600060405180830381600087803b1580156111ef57600080fd5b505af1158015611203573d6000803e3d6000fd5b5050505050505050565b60006106298383611870565b7f4bc804ba64359c0e35e5ed5d90ee596ecaa49a3a930ddcb1470ea0dd625da90090565b60608160000180548060200260200160405190810160405280929190818152602001828054801561128d57602002820191906000526020600020905b815481526020019060010190808311611279575b50505050509050919050565b60008181526001830160205260408120546112e05750815460018181018455600084815260208082209093018490558454848252828601909352604090209190915561042f565b50600061042f565b606060006112f7836002612058565b611302906002611d74565b6001600160401b0381111561131957611319611adc565b6040519080825280601f01601f191660200182016040528015611343576020820181803683370190505b509050600360fc1b8160008151811061135e5761135e611d87565b60200101906001600160f81b031916908160001a905350600f60fb1b8160018151811061138d5761138d611d87565b60200101906001600160f81b031916908160001a90535060006113b1846002612058565b6113bc906001611d74565b90505b6001811115611434576f181899199a1a9b1b9c1cb0b131b232b360811b85600f16601081106113f0576113f0611d87565b1a60f81b82828151811061140657611406611d87565b60200101906001600160f81b031916908160001a90535060049490941c9361142d8161206f565b90506113bf565b5083156106295760405162461bcd60e51b815260206004820181905260248201527f537472696e67733a20686578206c656e67746820696e73756666696369656e746044820152606401610391565b600161148d610e47565b6000848152602091825260408082206001600160a01b0386168084529352808220805460ff1916941515949094179093559151339285917f2f8788117e7eff1d82e926ec794901d17c78024a50270940304540a733656f0d9190a45050565b60006114f6610fb5565b6000848152602091909152604090205490506001611512610fb5565b6000858152602091909152604081208054909190611531908490611d74565b90915550829050611540610fb5565b6000858152602091825260408082208583526001019092522080546001600160a01b0319166001600160a01b039290921691909117905580611580610fb5565b6000948552602090815260408086206001600160a01b03909516865260029094019052919092205550565b6115b58282610e6b565b6115bd610e47565b6000838152602091825260408082206001600160a01b0385168084529352808220805460ff191690555133929185917ff6391f5c32d9c69d2a47ea670b442974b53935d1edc7fd64eb21e047a839171b9190a45050565b600061161e610fb5565b6000848152602091825260408082206001600160a01b03861683526002019092522054905061164b610fb5565b6000848152602091825260408082208483526001019092522080546001600160a01b031916905561167a610fb5565b6000938452602090815260408085206001600160a01b0390941685526002909301905250812055565b60008060ff196116d460017f0c4ba382c0009cf238e4c1ca1a52f51c61e6248a70bdfb34e5ed49d5578a5c0c611e0b565b6040516020016116e691815260200190565b60408051601f1981840301815291905280516020909101201692915050565b600081815260018301602052604081205480156117ee576000611729600183611e0b565b855490915060009061173d90600190611e0b565b90508181146117a257600086600001828154811061175d5761175d611d87565b906000526020600020015490508087600001848154811061178057611780611d87565b6000918252602080832090910192909255918252600188019052604090208390555b85548690806117b3576117b3612086565b60019003818190600052602060002001600090559055856001016000868152602001908152602001600020600090556001935050505061042f565b600091505061042f565b6060600080856001600160a01b031685604051611815919061209c565b600060405180830381855af49150503d8060008114611850576040519150601f19603f3d011682016040523d82523d6000602084013e611855565b606091505b50915091506118668683838761189a565b9695505050505050565b600082600001828154811061188757611887611d87565b9060005260206000200154905092915050565b60608315611909578251600003611902576001600160a01b0385163b6119025760405162461bcd60e51b815260206004820152601d60248201527f416464726573733a2063616c6c20746f206e6f6e2d636f6e74726163740000006044820152606401610391565b5081611913565b611913838361191b565b949350505050565b81511561192b5781518083602001fd5b8060405162461bcd60e51b81526004016103919190611cca565b6020808252825182820181905260009190848201906040850190845b818110156119865783516001600160a01b031683529284019291840191600101611961565b50909695505050505050565b80356001600160a01b03811681146119a957600080fd5b919050565b600080604083850312156119c157600080fd5b6119ca83611992565b946020939093013593505050565b6000602082840312156119ea57600080fd5b61062982611992565b600060208284031215611a0557600080fd5b5035919050565b60008060408385031215611a1f57600080fd5b82359150611a2f60208401611992565b90509250929050565b600080600060408486031215611a4d57600080fd5b611a5684611992565b925060208401356001600160401b0380821115611a7257600080fd5b818601915086601f830112611a8657600080fd5b813581811115611a9557600080fd5b876020828501011115611aa757600080fd5b6020830194508093505050509250925092565b60008060408385031215611acd57600080fd5b50508035926020909101359150565b634e487b7160e01b600052604160045260246000fd5b600060208284031215611b0457600080fd5b81356001600160401b0380821115611b1b57600080fd5b818401915084601f830112611b2f57600080fd5b813581811115611b4157611b41611adc565b604051601f8201601f19908116603f01168101908382118183101715611b6957611b69611adc565b81604052828152876020848701011115611b8257600080fd5b826020860160208301376000928101602001929092525095945050505050565b60008060208385031215611bb557600080fd5b82356001600160401b0380821115611bcc57600080fd5b818501915085601f830112611be057600080fd5b813581811115611bef57600080fd5b8660208260051b8501011115611c0457600080fd5b60209290920196919550909350505050565b60005b83811015611c31578181015183820152602001611c19565b50506000910152565b60008151808452611c52816020860160208601611c16565b601f01601f19169290920160200192915050565b600060208083016020845280855180835260408601915060408160051b87010192506020870160005b82811015611cbd57603f19888603018452611cab858351611c3a565b94509285019290850190600101611c8f565b5092979650505050505050565b6020815260006106296020830184611c3a565b6020808252601f908201527f4163636f756e74466163746f72793a206e6f7420616e206163636f756e742e00604082015260600190565b6020808252602a908201527f4163636f756e74466163746f72793a206163636f756e7420616c7265616479206040820152691c9959da5cdd195c995960b21b606082015260800190565b634e487b7160e01b600052601160045260246000fd5b8082018082111561042f5761042f611d5e565b634e487b7160e01b600052603260045260246000fd5b6000808335601e19843603018112611db457600080fd5b8301803591506001600160401b03821115611dce57600080fd5b602001915036819003821315611de357600080fd5b9250929050565b8284823760609190911b6001600160601b0319169101908152601401919050565b8181038181111561042f5761042f611d5e565b600181811c90821680611e3257607f821691505b602082108103610abc57634e487b7160e01b600052602260045260246000fd5b7402832b936b4b9b9b4b7b7399d1030b1b1b7bab73a1605d1b815260008351611e82816015850160208801611c16565b7001034b99036b4b9b9b4b733903937b6329607d1b6015918401918201528351611eb3816026840160208801611c16565b01602601949350505050565b6001600160a01b038316815260406020820181905260009061191390830184611c3a565b601f821115611f2f576000816000526020600020601f850160051c81016020861015611f0c5750805b601f850160051c820191505b81811015611f2b57828155600101611f18565b5050505b505050565b81516001600160401b03811115611f4d57611f4d611adc565b611f6181611f5b8454611e1e565b84611ee3565b602080601f831160018114611f965760008415611f7e5750858301515b600019600386901b1c1916600185901b178555611f2b565b600085815260208120601f198616915b82811015611fc557888601518255948401946001909101908401611fa6565b5085821015611fe35787850151600019600388901b60f8161c191681555b5050505050600190811b01905550565b6040815260006120066040830185611c3a565b82810360208401526106258185611c3a565b6001600160a01b03841681526040602082018190528101829052818360608301376000818301606090810191909152601f909201601f1916010192915050565b808202811582820484141761042f5761042f611d5e565b60008161207e5761207e611d5e565b506000190190565b634e487b7160e01b600052603160045260246000fd5b600082516120ae818460208701611c16565b919091019291505056fe416464726573733a206c6f772d6c6576656c2064656c65676174652063616c6c206661696c6564a26469706673582212208fee46949383576f28224ce9e6b6a4b07519741c4de38b0c75218e600dce91e564736f6c63430008170033"; +bytes constant THIRDWEB_ACCOUNT_IMPL_BYTECODE = hex"60806040526004361061014b5760003560e01c806301ffc9a714610157578063150b7a021461018c5780631626ba7e146101c55780631dd756c5146101e557806324d7806c14610205578063399b77da146102255780633a871cdd1461025357806347e1da2a146102735780634a58db19146102955780634d44560d1461029d5780635892e236146102bd5780637dff5a79146102dd5780638b52d723146102fd578063938e3d7b1461031f578063a9082d841461033f578063ac9650d81461037e578063b0d691fe146103ab578063b61d27f6146103cd578063b76464d5146103ed578063bc197c811461040d578063c45a015514610439578063d087d2881461046d578063d1f5789414610482578063d42f2f35146104a2578063e8a3d485146104b7578063e9523c97146104d9578063f15d424e146104fb578063f23a6e611461052857600080fd5b3661015257005b600080fd5b34801561016357600080fd5b50610177610172366004612d97565b610554565b60405190151581526020015b60405180910390f35b34801561019857600080fd5b506101ac6101a7366004612ea3565b61059a565b6040516001600160e01b03199091168152602001610183565b3480156101d157600080fd5b506101ac6101e0366004612f0e565b6105ab565b3480156101f157600080fd5b50610177610200366004612f6d565b6106ca565b34801561021157600080fd5b50610177610220366004612fb2565b61098e565b34801561023157600080fd5b50610245610240366004612fcf565b6109bd565b604051908152602001610183565b34801561025f57600080fd5b5061024561026e366004612fe8565b610a88565b34801561027f57600080fd5b5061029361028e366004613079565b610aae565b005b610293610c15565b3480156102a957600080fd5b506102936102b8366004613112565b610c7d565b3480156102c957600080fd5b506102936102d836600461317f565b610cf0565b3480156102e957600080fd5b506101776102f8366004612fb2565b6110ad565b34801561030957600080fd5b50610312611166565b6040516101839190613292565b34801561032b57600080fd5b5061029361033a3660046132f6565b6113ad565b34801561034b57600080fd5b5061035f61035a36600461317f565b6113fe565b6040805192151583526001600160a01b03909116602083015201610183565b34801561038a57600080fd5b5061039e61039936600461333e565b611455565b60405161018391906133cf565b3480156103b757600080fd5b506103c06115ba565b6040516101839190613426565b3480156103d957600080fd5b506102936103e836600461343a565b611603565b3480156103f957600080fd5b50610293610408366004612fb2565b611693565b34801561041957600080fd5b506101ac610428366004613527565b63bc197c8160e01b95945050505050565b34801561044557600080fd5b506103c07f0000000000000000000000002e234dae75c793f67a35089c9d99245e1c58470b81565b34801561047957600080fd5b506102456116c5565b34801561048e57600080fd5b5061029361049d3660046135d4565b611745565b3480156104ae57600080fd5b506103126118fd565b3480156104c357600080fd5b506104cc611a6e565b604051610183919061361b565b3480156104e557600080fd5b506104ee611b06565b604051610183919061362e565b34801561050757600080fd5b5061051b610516366004612fb2565b611b18565b604051610183919061367b565b34801561053457600080fd5b506101ac61054336600461368e565b63f23a6e6160e01b95945050505050565b60006001600160e01b03198216630271189760e51b148061058557506001600160e01b03198216630a85bd0160e11b145b80610594575061059482611bf0565b92915050565b630a85bd0160e11b5b949350505050565b6000806105b7846109bd565b905060006105c58285611c25565b90506105d08161098e565b156105e75750630b135d3f60e11b91506105949050565b3360006105f2611c49565b6001600160a01b038416600090815260069190910160205260409020905061061a8183611c6d565b8061064a575061062981611c8f565b600114801561064a5750600061063f8282611c99565b6001600160a01b0316145b6106a75760405162461bcd60e51b8152602060048201526024808201527f4163636f756e743a2063616c6c6572206e6f7420617070726f7665642074617260448201526333b2ba1760e11b60648201526084015b60405180910390fd5b6106b0836110ad565b156106c057630b135d3f60e11b94505b5050505092915050565b60006106d4611c49565b6001600160a01b0384166000908152600491909101602052604090205460ff161561070157506001610594565b600061070b611c49565b6001600160a01b0385166000908152600591909101602090815260408083208151606081018352815481526001909101546001600160801b0380821694830194909452600160801b9004909216908201529150610766611c49565b6006016000866001600160a01b03166001600160a01b0316815260200190815260200160002090504282602001516001600160801b031611806107b6575081604001516001600160801b03164210155b806107c757506107c581611c8f565b155b156107d757600092505050610594565b60006107ee6107e960608701876136f6565b611ca5565b905060006107fb83611c8f565b600114801561081c575060006108118482611c99565b6001600160a01b0316145b90506324f16c0560e11b6001600160e01b03198316016108935760008061084e61084960608a018a6136f6565b611cdf565b9150915082610874576108618583611c6d565b6108745760009650505050505050610594565b855181111561088c5760009650505050505050610594565b5050610981565b635c0f12eb60e11b6001600160e01b0319831601610974576000806108c36108be60608a018a6136f6565b611d44565b5091509150826109235760005b8251811015610921576109058382815181106108ee576108ee61373c565b602002602001015187611c6d90919063ffffffff16565b610919576000975050505050505050610594565b6001016108d0565b505b60005b825181101561096c578181815181106109415761094161373c565b602002602001015187600001511015610964576000975050505050505050610594565b600101610926565b505050610981565b6000945050505050610594565b5060019695505050505050565b6000610998611c49565b6001600160a01b03909216600090815260049290920160205250604090205460ff1690565b600080826040516020016109d391815260200190565b60405160208183030381529060405280519060200120905060007f82cac545155fcbf147f2a9013809613677ac7d65498556e6d19ce43bcbf6c28482604051602001610a29929190918252602082015260400190565b604051602081830303815290604052805190602001209050610a49611d91565b60405161190160f01b60208201526022810191909152604281018290526062016040516020818303038152906040528051906020012092505050919050565b6000610a92611eb8565b610a9c8484611f21565b9050610aa782612066565b9392505050565b610ab66115ba565b6001600160a01b0316336001600160a01b03161480610ad95750610ad93361098e565b610af55760405162461bcd60e51b815260040161069e90613752565b610afd6120b3565b8481148015610b0b57508483145b610b575760405162461bcd60e51b815260206004820152601d60248201527f4163636f756e743a2077726f6e67206172726179206c656e677468732e000000604482015260640161069e565b60005b85811015610c0c57610c03878783818110610b7757610b7761373c565b9050602002016020810190610b8c9190612fb2565b868684818110610b9e57610b9e61373c565b90506020020135858585818110610bb757610bb761373c565b9050602002810190610bc991906136f6565b8080601f01602080910402602001604051908101604052809392919081815260200183838082843760009201919091525061219992505050565b50600101610b5a565b50505050505050565b610c1d6115ba565b6001600160a01b031663b760faf934306040518363ffffffff1660e01b8152600401610c499190613426565b6000604051808303818588803b158015610c6257600080fd5b505af1158015610c76573d6000803e3d6000fd5b5050505050565b610c8561220a565b610c8d6115ba565b6001600160a01b031663205c287883836040518363ffffffff1660e01b8152600401610cba929190613793565b600060405180830381600087803b158015610cd457600080fd5b505af1158015610ce8573d6000803e3d6000fd5b505050505050565b6000610cff6020850185612fb2565b905042610d1260e0860160c087016137c3565b6001600160801b031611158015610d415750610d35610100850160e086016137c3565b6001600160801b031642105b610d775760405162461bcd60e51b8152602060048201526007602482015266085c195c9a5bd960ca1b604482015260640161069e565b600080610d858686866113fe565b9150915081610dbf5760405162461bcd60e51b815260040161069e906020808252600490820152632173696760e01b604082015260600190565b6001610dc9611c49565b610100880135600090815260079190910160209081526040808320805460ff1916941515949094179093559091610e05919089019089016137ef565b60ff161115610e32576000610e2060408801602089016137ef565b60ff166001149050610c0c8482612248565b610e3b8361098e565b15610e705760405162461bcd60e51b815260206004820152600560248201526430b236b4b760d91b604482015260640161069e565b610e8583610e7c611c49565b6002019061231d565b50604051806060016040528087606001358152602001876080016020810190610eae91906137c3565b6001600160801b03168152602001610ecc60c0890160a08a016137c3565b6001600160801b03169052610edf611c49565b6001600160a01b03851660009081526005919091016020908152604080832084518155918401519301516001600160801b03908116600160801b02931692909217600190920191909155610f55610f34611c49565b6001600160a01b038616600090815260069190910160205260409020612332565b805190915060005b81811015610fbf57610fac838281518110610f7a57610f7a61373c565b6020026020010151610f8a611c49565b6001600160a01b0389166000908152600691909101602052604090209061233f565b50610fb8600182613820565b9050610f5d565b50610fcd6040890189613833565b9050905060005b8181101561104e5761103b610fec60408b018b613833565b83818110610ffc57610ffc61373c565b90506020020160208101906110119190612fb2565b611019611c49565b6001600160a01b0389166000908152600691909101602052604090209061231d565b50611047600182613820565b9050610fd4565b5061105888612354565b846001600160a01b0316836001600160a01b03167ff21d10c26e35863a8df291aca54181ee8c4a3bc8e00246c3f7a5a14b69d826a78a60405161109b919061390d565b60405180910390a35050505050505050565b6000806110b8611c49565b6001600160a01b038416600090815260059190910160209081526040918290208251606081018452815481526001909101546001600160801b03808216938301849052600160801b90910416928101929092529091504210801590611129575080604001516001600160801b031642105b8015610aa75750600061115e61113d611c49565b6001600160a01b038616600090815260069190910160205260409020611c8f565b119392505050565b6060600061117d611175611c49565b600201612332565b80519091506000805b8281101561120e576111b08482815181106111a3576111a361373c565b60200260200101516110ad565b156111c757816111bf816139f8565b9250506111fc565b60008482815181106111db576111db61373c565b60200260200101906001600160a01b031690816001600160a01b0316815250505b611207600182613820565b9050611186565b50806001600160401b0381111561122757611227612de6565b60405190808252806020026020018201604052801561126057816020015b61124d612d4d565b8152602001906001900390816112455790505b5093506000805b838110156113a55760006001600160a01b031685828151811061128c5761128c61373c565b60200260200101516001600160a01b0316146113935760008582815181106112b6576112b661373c565b6020026020010151905060006112ca611c49565b6001600160a01b038316600081815260059290920160209081526040928390208351606081018552815481526001909101546001600160801b0380821683850152600160801b9091041681850152835160a081019094529183529092508101611334610f34611c49565b81526020018260000151815260200182602001516001600160801b0316815260200182604001516001600160801b0316815250888580611373906139f8565b9650815181106113855761138561373c565b602002602001018190525050505b61139e600182613820565b9050611267565b505050505090565b6113b56123e9565b6113f25760405162461bcd60e51b815260206004820152600e60248201526d139bdd08185d5d1a1bdc9a5e995960921b604482015260640161069e565b6113fb81612401565b50565b60008061141461140d866124e8565b858561262c565b905061141e611c49565b6101008601356000908152600791909101602052604090205460ff1615801561144b575061144b8161098e565b9150935093915050565b6060816001600160401b0381111561146f5761146f612de6565b6040519080825280602002602001820160405280156114a257816020015b606081526020019060019003908161148d5790505b509050336000805b848110156115b157811561152957611507308787848181106114ce576114ce61373c565b90506020028101906114e091906136f6565b866040516020016114f393929190613a11565b60405160208183030381529060405261267e565b8482815181106115195761151961373c565b60200260200101819052506115a9565b61158b3087878481811061153f5761153f61373c565b905060200281019061155191906136f6565b8080601f01602080910402602001604051908101604052809392919081815260200183838082843760009201919091525061267e92505050565b84828151811061159d5761159d61373c565b60200260200101819052505b6001016114aa565b50505092915050565b6000806115c56126a3565b546001600160a01b0316905080156115dc57919050565b7f0000000000000000000000005ff137d4b0fdcd49dca30c7cf57e578a026d278991505090565b61160b6115ba565b6001600160a01b0316336001600160a01b0316148061162e575061162e3361098e565b61164a5760405162461bcd60e51b815260040161069e90613752565b6116526120b3565b610c76848484848080601f01602080910402602001604051908101604052809392919081815260200183838082843760009201919091525061219992505050565b61169b61220a565b806116a46126a3565b80546001600160a01b0319166001600160a01b039290921691909117905550565b60006116cf6115ba565b604051631aab3f0d60e11b8152306004820152600060248201526001600160a01b0391909116906335567e1a90604401602060405180830381865afa15801561171c573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906117409190613a32565b905090565b600061174f6126c7565b5460ff169050600061175f6126c7565b54610100900460ff169050801580801561177c575060018360ff16105b8061179b575061178b306126eb565b15801561179b57508260ff166001145b6117fe5760405162461bcd60e51b815260206004820152602e60248201527f496e697469616c697a61626c653a20636f6e747261637420697320616c72656160448201526d191e481a5b9a5d1a585b1a5e995960921b606482015260840161069e565b60016118086126c7565b805460ff191660ff92909216919091179055801561184157600161182a6126c7565b80549115156101000261ff00199092169190911790555b6118818686868080601f0160208091040260200160405190810160405280939291908181526020018383808284376000920191909152506126fa92505050565b6118896126a3565b6001018190555061189b866001612248565b8015610ce85760006118ab6126c7565b80549115156101000261ff0019909216919091179055604051600181527f7f26b83ff96e1f2b6a682f133852f6798a09c465da95921460cefb38474024989060200160405180910390a1505050505050565b6060600061190c611175611c49565b8051909150806001600160401b0381111561192957611929612de6565b60405190808252806020026020018201604052801561196257816020015b61194f612d4d565b8152602001906001900390816119475790505b50925060005b81811015611a685760008382815181106119845761198461373c565b602002602001015190506000611998611c49565b6001600160a01b038316600081815260059290920160209081526040928390208351606081018552815481526001909101546001600160801b0380821683850152600160801b9091041681850152835160a081019094529183529092508101611a02610f34611c49565b81526020018260000151815260200182602001516001600160801b0316815260200182604001516001600160801b0316815250868481518110611a4757611a4761373c565b60200260200101819052505050600181611a619190613820565b9050611968565b50505090565b6060611a7861272d565b8054611a8390613a4b565b80601f0160208091040260200160405190810160405280929190818152602001828054611aaf90613a4b565b8015611afc5780601f10611ad157610100808354040283529160200191611afc565b820191906000526020600020905b815481529060010190602001808311611adf57829003601f168201915b5050505050905090565b6060611740611b13611c49565b612332565b611b20612d4d565b6000611b2a611c49565b6001600160a01b038416600081815260059290920160209081526040928390208351606081018552815481526001909101546001600160801b0380821683850152600160801b9091041681850152835160a081019094529183529092508101611bb5611b94611c49565b6001600160a01b038716600090815260069190910160205260409020612332565b81526020018260000151815260200182602001516001600160801b0316815260200182604001516001600160801b0316815250915050919050565b60006001600160e01b03198216630271189760e51b148061059457506301ffc9a760e01b6001600160e01b0319831614610594565b6000806000611c348585612751565b91509150611c4181612796565b509392505050565b7f3181e78fc1b109bc611fd2406150bf06e33faa75f71cba12c3e1fd670f2def0090565b6001600160a01b03811660009081526001830160205260408120541515610aa7565b6000610594825490565b6000610aa783836128db565b60006004821015611cc85760405162461bcd60e51b815260040161069e90613a7f565b611cd6600460008486613a9e565b610aa791613ac8565b6000806044831015611d035760405162461bcd60e51b815260040161069e90613a7f565b611d11602460048587613a9e565b810190611d1e9190612fb2565b9150611d2e604460248587613a9e565b810190611d3b9190612fcf565b90509250929050565b606080806064841015611d695760405162461bcd60e51b815260040161069e90613a7f565b611d768460048188613a9e565b810190611d839190613b77565b919790965090945092505050565b6000306001600160a01b037f000000000000000000000000ffd4505b3452dc22f8473616d50503ba9e1710ac16148015611dea57507f0000000000000000000000000000000000000000000000000000000000007a6946145b15611e1457507fbcdadf6444930a967ffda04923d78c49b3dd65df3ed39abb04a1e3eb1190553790565b50604080517f8b73c3c69bb8fe3d512ecc4cf759cc79239f7b179b0ffacaa9a75d522b39400f6020808301919091527ff0729608244859f656d32ae4cbc6b0367695d68d8e941a28f5e2d33c6d5182dd828401527fc89efdaa54c0f20c7adf612882df0950f5a951637e0307cdcb4c672f298b8bc660608301524660808301523060a0808401919091528351808403909101815260c0909201909252805191012090565b611ec06115ba565b6001600160a01b0316336001600160a01b031614611f1f5760405162461bcd60e51b815260206004820152601c60248201527b1858d8dbdd5b9d0e881b9bdd08199c9bdb48115b9d1c9e541bda5b9d60221b604482015260640161069e565b565b7b0ca2ba3432b932bab69029b4b3b732b21026b2b9b9b0b3b29d05199960211b6000908152601c829052603c81206000611f9f611f626101408701876136f6565b8080601f0160208091040260200160405190810160405280939291908181526020018383808284376000920191909152508693925050611c259050565b9050611fab81866106ca565b611fba57600192505050610594565b6000611fc4611c49565b6001600160a01b03929092166000908152600590920160209081526040808420815160608082018452825482526001909201546001600160801b0380821683870152600160801b8204908116928501929092528351928301845295825265ffffffffffff8087169483019490945292831691015260d09290921b6001600160d01b03191660a09290921b65ffffffffffff60a01b169190911795945050505050565b80156113fb57604051600090339060001990849084818181858888f193505050503d8060008114610c76576040519150601f19603f3d011682016040523d82523d6000602084013e610c76565b60405163c3c5a54760e01b81527f0000000000000000000000002e234dae75c793f67a35089c9d99245e1c58470b906001600160a01b0382169063c3c5a54790612101903090600401613426565b602060405180830381865afa15801561211e573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906121429190613c5c565b6113fb57806001600160a01b03166383a03f8c61215d6126a3565b600101546040518263ffffffff1660e01b815260040161217f91815260200190565b600060405180830381600087803b158015610c6257600080fd5b60606000846001600160a01b031684846040516121b69190613c7e565b60006040518083038185875af1925050503d80600081146121f3576040519150601f19603f3d011682016040523d82523d6000602084013e6121f8565b606091505b509250905080611c4157815160208301fd5b6122133361098e565b611f1f5760405162461bcd60e51b815260206004820152600660248201526510b0b236b4b760d11b604482015260640161069e565b6122528282612905565b6001600160a01b037f0000000000000000000000002e234dae75c793f67a35089c9d99245e1c58470b163b156123195780156122e1577f0000000000000000000000002e234dae75c793f67a35089c9d99245e1c58470b6001600160a01b0316630b61e12b836122c06126a3565b600101546040518363ffffffff1660e01b8152600401610cba929190613793565b7f0000000000000000000000002e234dae75c793f67a35089c9d99245e1c58470b6001600160a01b0316639387a380836122c06126a3565b5050565b6000610aa7836001600160a01b0384166129b4565b60606000610aa783612a03565b6000610aa7836001600160a01b038416612a5f565b6001600160a01b037f0000000000000000000000002e234dae75c793f67a35089c9d99245e1c58470b163b156113fb576001600160a01b037f0000000000000000000000002e234dae75c793f67a35089c9d99245e1c58470b16630b61e12b6123c06020840184612fb2565b6123c86126a3565b600101546040518363ffffffff1660e01b815260040161217f929190613793565b60006123f43361098e565b8061174057505030331490565b600061240b61272d565b805461241690613a4b565b80601f016020809104026020016040519081016040528092919081815260200182805461244290613a4b565b801561248f5780601f106124645761010080835404028352916020019161248f565b820191906000526020600020905b81548152906001019060200180831161247257829003601f168201915b505050505090508161249f61272d565b906124aa9082613ce7565b507fc9c7c3fe08b88b4df9d4d47ef47d2c43d55c025a0ba88ca442580ed9e7348a1681836040516124dc929190613da6565b60405180910390a15050565b60607f3fd4a1a1a267c84185e3b7eecd57c68783c0581d538b9d6e5f23e4670497c1e96125186020840184612fb2565b61252860408501602086016137ef565b6125356040860186613833565b604051602001612546929190613dd4565b60408051601f198184030181529190528051602090910120606086013561257360a08801608089016137c3565b61258360c0890160a08a016137c3565b61259360e08a0160c08b016137c3565b6125a46101008b0160e08c016137c3565b60408051602081019a909a526001600160a01b039098169789019790975260ff9095166060880152608087019390935260a08601919091526001600160801b0390811660c086015290811660e0850152908116610100848101919091529116610120830152830135610140820152610160016040516020818303038152906040529050919050565b60006105a383838080601f016020809104026020016040519081016040528093929190818152602001838380828437600092019190915250508751602089012061267892509050612b52565b90611c25565b6060610aa78383604051806060016040528060278152602001613e7a60279139612b7f565b7f036f52c1827dab135f7fd44ca0bddde297e2f659c710e0ec53e975f22b54830090565b7f322cf19c484104d3b1a9c2982ebae869ede3fa5f6c4703ca41b9a48c76ee030090565b6001600160a01b03163b151590565b6000828260405160200161270f929190613e16565b60405160208183030381529060405280519060200120905092915050565b7f4bc804ba64359c0e35e5ed5d90ee596ecaa49a3a930ddcb1470ea0dd625da90090565b60008082516041036127875760208301516040840151606085015160001a61277b87828585612bf7565b9450945050505061278f565b506000905060025b9250929050565b60008160048111156127aa576127aa613e3a565b036127b25750565b60018160048111156127c6576127c6613e3a565b0361280e5760405162461bcd60e51b815260206004820152601860248201527745434453413a20696e76616c6964207369676e617475726560401b604482015260640161069e565b600281600481111561282257612822613e3a565b0361286f5760405162461bcd60e51b815260206004820152601f60248201527f45434453413a20696e76616c6964207369676e6174757265206c656e67746800604482015260640161069e565b600381600481111561288357612883613e3a565b036113fb5760405162461bcd60e51b815260206004820152602260248201527f45434453413a20696e76616c6964207369676e6174757265202773272076616c604482015261756560f01b606482015260840161069e565b60008260000182815481106128f2576128f261373c565b9060005260206000200154905092915050565b8061290e611c49565b6001600160a01b038416600090815260049190910160205260409020805460ff19169115159190911790558015612957576129518261294b611c49565b9061231d565b5061296b565b61296982612963611c49565b9061233f565b505b816001600160a01b03167f235bc17e7930760029e9f4d860a2a8089976de5b381cf8380fc11c1d88a11133826040516129a8911515815260200190565b60405180910390a25050565b60008181526001830160205260408120546129fb57508154600181810184556000848152602080822090930184905584548482528286019093526040902091909155610594565b506000610594565b606081600001805480602002602001604051908101604052809291908181526020018280548015612a5357602002820191906000526020600020905b815481526020019060010190808311612a3f575b50505050509050919050565b60008181526001830160205260408120548015612b48576000612a83600183613e50565b8554909150600090612a9790600190613e50565b9050818114612afc576000866000018281548110612ab757612ab761373c565b9060005260206000200154905080876000018481548110612ada57612ada61373c565b6000918252602080832090910192909255918252600188019052604090208390555b8554869080612b0d57612b0d613e63565b600190038181906000526020600020016000905590558560010160008681526020019081526020016000206000905560019350505050610594565b6000915050610594565b6000610594612b5f611d91565b8360405161190160f01b8152600281019290925260228201526042902090565b6060600080856001600160a01b031685604051612b9c9190613c7e565b600060405180830381855af49150503d8060008114612bd7576040519150601f19603f3d011682016040523d82523d6000602084013e612bdc565b606091505b5091509150612bed86838387612cb1565b9695505050505050565b6000806fa2a8918ca85bafe22016d0b997e4df60600160ff1b03831115612c245750600090506003612ca8565b6040805160008082526020820180845289905260ff881692820192909252606081018690526080810185905260019060a0016020604051602081039080840390855afa158015612c78573d6000803e3d6000fd5b5050604051601f1901519150506001600160a01b038116612ca157600060019250925050612ca8565b9150600090505b94509492505050565b60608315612d1e578251600003612d1757612ccb856126eb565b612d175760405162461bcd60e51b815260206004820152601d60248201527f416464726573733a2063616c6c20746f206e6f6e2d636f6e7472616374000000604482015260640161069e565b50816105a3565b6105a38383815115612d335781518083602001fd5b8060405162461bcd60e51b815260040161069e919061361b565b6040518060a0016040528060006001600160a01b03168152602001606081526020016000815260200160006001600160801b0316815260200160006001600160801b031681525090565b600060208284031215612da957600080fd5b81356001600160e01b031981168114610aa757600080fd5b6001600160a01b03811681146113fb57600080fd5b8035612de181612dc1565b919050565b634e487b7160e01b600052604160045260246000fd5b604051601f8201601f191681016001600160401b0381118282101715612e2457612e24612de6565b604052919050565b60006001600160401b03831115612e4557612e45612de6565b612e58601f8401601f1916602001612dfc565b9050828152838383011115612e6c57600080fd5b828260208301376000602084830101529392505050565b600082601f830112612e9457600080fd5b610aa783833560208501612e2c565b60008060008060808587031215612eb957600080fd5b8435612ec481612dc1565b93506020850135612ed481612dc1565b92506040850135915060608501356001600160401b03811115612ef657600080fd5b612f0287828801612e83565b91505092959194509250565b60008060408385031215612f2157600080fd5b8235915060208301356001600160401b03811115612f3e57600080fd5b612f4a85828601612e83565b9150509250929050565b60006101608284031215612f6757600080fd5b50919050565b60008060408385031215612f8057600080fd5b8235612f8b81612dc1565b915060208301356001600160401b03811115612fa657600080fd5b612f4a85828601612f54565b600060208284031215612fc457600080fd5b8135610aa781612dc1565b600060208284031215612fe157600080fd5b5035919050565b600080600060608486031215612ffd57600080fd5b83356001600160401b0381111561301357600080fd5b61301f86828701612f54565b9660208601359650604090950135949350505050565b60008083601f84011261304757600080fd5b5081356001600160401b0381111561305e57600080fd5b6020830191508360208260051b850101111561278f57600080fd5b6000806000806000806060878903121561309257600080fd5b86356001600160401b03808211156130a957600080fd5b6130b58a838b01613035565b909850965060208901359150808211156130ce57600080fd5b6130da8a838b01613035565b909650945060408901359150808211156130f357600080fd5b5061310089828a01613035565b979a9699509497509295939492505050565b6000806040838503121561312557600080fd5b823561313081612dc1565b946020939093013593505050565b60008083601f84011261315057600080fd5b5081356001600160401b0381111561316757600080fd5b60208301915083602082850101111561278f57600080fd5b60008060006040848603121561319457600080fd5b83356001600160401b03808211156131ab57600080fd5b9085019061012082880312156131c057600080fd5b909350602085013590808211156131d657600080fd5b506131e38682870161313e565b9497909650939450505050565b6001600160801b03169052565b80516001600160a01b03908116835260208083015160a082860181905281519086018190526000939183019290849060c08801905b8083101561325457855185168252948301946001929092019190830190613232565b50604087015160408901526060870151945061327360608901866131f0565b6080870151945061328760808901866131f0565b979650505050505050565b600060208083016020845280855180835260408601915060408160051b87010192506020870160005b828110156132e957603f198886030184526132d78583516131fd565b945092850192908501906001016132bb565b5092979650505050505050565b60006020828403121561330857600080fd5b81356001600160401b0381111561331e57600080fd5b8201601f8101841361332f57600080fd5b6105a384823560208401612e2c565b6000806020838503121561335157600080fd5b82356001600160401b0381111561336757600080fd5b61337385828601613035565b90969095509350505050565b60005b8381101561339a578181015183820152602001613382565b50506000910152565b600081518084526133bb81602086016020860161337f565b601f01601f19169290920160200192915050565b600060208083016020845280855180835260408601915060408160051b87010192506020870160005b828110156132e957603f198886030184526134148583516133a3565b945092850192908501906001016133f8565b6001600160a01b0391909116815260200190565b6000806000806060858703121561345057600080fd5b843561345b81612dc1565b93506020850135925060408501356001600160401b0381111561347d57600080fd5b6134898782880161313e565b95989497509550505050565b60006001600160401b038211156134ae576134ae612de6565b5060051b60200190565b600082601f8301126134c957600080fd5b813560206134de6134d983613495565b612dfc565b8083825260208201915060208460051b87010193508684111561350057600080fd5b602086015b8481101561351c5780358352918301918301613505565b509695505050505050565b600080600080600060a0868803121561353f57600080fd5b853561354a81612dc1565b9450602086013561355a81612dc1565b935060408601356001600160401b038082111561357657600080fd5b61358289838a016134b8565b9450606088013591508082111561359857600080fd5b6135a489838a016134b8565b935060808801359150808211156135ba57600080fd5b506135c788828901612e83565b9150509295509295909350565b6000806000604084860312156135e957600080fd5b83356135f481612dc1565b925060208401356001600160401b0381111561360f57600080fd5b6131e38682870161313e565b602081526000610aa760208301846133a3565b6020808252825182820181905260009190848201906040850190845b8181101561366f5783516001600160a01b03168352928401929184019160010161364a565b50909695505050505050565b602081526000610aa760208301846131fd565b600080600080600060a086880312156136a657600080fd5b85356136b181612dc1565b945060208601356136c181612dc1565b9350604086013592506060860135915060808601356001600160401b038111156136ea57600080fd5b6135c788828901612e83565b6000808335601e1984360301811261370d57600080fd5b8301803591506001600160401b0382111561372757600080fd5b60200191503681900382131561278f57600080fd5b634e487b7160e01b600052603260045260246000fd5b60208082526021908201527f4163636f756e743a206e6f742061646d696e206f7220456e747279506f696e746040820152601760f91b606082015260800190565b6001600160a01b03929092168252602082015260400190565b80356001600160801b0381168114612de157600080fd5b6000602082840312156137d557600080fd5b610aa7826137ac565b803560ff81168114612de157600080fd5b60006020828403121561380157600080fd5b610aa7826137de565b634e487b7160e01b600052601160045260246000fd5b808201808211156105945761059461380a565b6000808335601e1984360301811261384a57600080fd5b8301803591506001600160401b0382111561386457600080fd5b6020019150600581901b360382131561278f57600080fd5b6000808335601e1984360301811261389357600080fd5b83016020810192503590506001600160401b038111156138b257600080fd5b8060051b360382131561278f57600080fd5b8183526000602080850194508260005b858110156139025781356138e781612dc1565b6001600160a01b0316875295820195908201906001016138d4565b509495945050505050565b6020815261392e6020820161392184612dd6565b6001600160a01b03169052565b600061393c602084016137de565b60ff8116604084015250613953604084018461387c565b61012080606086015261396b610140860183856138c4565b925060608601356080860152613983608087016137ac565b915061399260a08601836131f0565b61399e60a087016137ac565b91506139ad60c08601836131f0565b6139b960c087016137ac565b91506139c860e08601836131f0565b6139d460e087016137ac565b91506101006139e5818701846131f0565b9590950135939094019290925250919050565b600060018201613a0a57613a0a61380a565b5060010190565b8284823760609190911b6001600160601b0319169101908152601401919050565b600060208284031215613a4457600080fd5b5051919050565b600181811c90821680613a5f57607f821691505b602082108103612f6757634e487b7160e01b600052602260045260246000fd5b602080825260059082015264214461746160d81b604082015260600190565b60008085851115613aae57600080fd5b83861115613abb57600080fd5b5050820193919092039150565b6001600160e01b03198135818116916004851015613af05780818660040360031b1b83161692505b505092915050565b600082601f830112613b0957600080fd5b81356020613b196134d983613495565b82815260059290921b84018101918181019086841115613b3857600080fd5b8286015b8481101561351c5780356001600160401b03811115613b5b5760008081fd5b613b698986838b0101612e83565b845250918301918301613b3c565b600080600060608486031215613b8c57600080fd5b83356001600160401b0380821115613ba357600080fd5b818601915086601f830112613bb757600080fd5b81356020613bc76134d983613495565b82815260059290921b8401810191818101908a841115613be657600080fd5b948201945b83861015613c0d578535613bfe81612dc1565b82529482019490820190613beb565b97505087013592505080821115613c2357600080fd5b613c2f878388016134b8565b93506040860135915080821115613c4557600080fd5b50613c5286828701613af8565b9150509250925092565b600060208284031215613c6e57600080fd5b81518015158114610aa757600080fd5b60008251613c9081846020870161337f565b9190910192915050565b601f821115613ce2576000816000526020600020601f850160051c81016020861015613cc35750805b601f850160051c820191505b81811015610ce857828155600101613ccf565b505050565b81516001600160401b03811115613d0057613d00612de6565b613d1481613d0e8454613a4b565b84613c9a565b602080601f831160018114613d495760008415613d315750858301515b600019600386901b1c1916600185901b178555610ce8565b600085815260208120601f198616915b82811015613d7857888601518255948401946001909101908401613d59565b5085821015613d965787850151600019600388901b60f8161c191681555b5050505050600190811b01905550565b604081526000613db960408301856133a3565b8281036020840152613dcb81856133a3565b95945050505050565b60008184825b85811015613e0b578135613ded81612dc1565b6001600160a01b031683526020928301929190910190600101613dda565b509095945050505050565b6001600160a01b03831681526040602082018190526000906105a3908301846133a3565b634e487b7160e01b600052602160045260246000fd5b818103818111156105945761059461380a565b634e487b7160e01b600052603160045260246000fdfe416464726573733a206c6f772d6c6576656c2064656c65676174652063616c6c206661696c6564a2646970667358221220247c9feadcfb4aa67bba286fdc86b80cc167fce1383f2afbc218bf965fb6bc3264736f6c63430008170033"; + diff --git a/src/test/smart-wallet/utils/AABenchmarkPrepare.sol b/src/test/smart-wallet/utils/AABenchmarkPrepare.sol new file mode 100644 index 000000000..146be270c --- /dev/null +++ b/src/test/smart-wallet/utils/AABenchmarkPrepare.sol @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +// Test utils +import { BaseTest } from "../../utils/BaseTest.sol"; + +// Account Abstraction setup for smart wallets. +import { IEntryPoint } from "contracts/prebuilts/account/utils/Entrypoint.sol"; +import { Strings } from "contracts/lib/Strings.sol"; +import { AccountFactory } from "contracts/prebuilts/account/non-upgradeable/AccountFactory.sol"; +import "forge-std/Test.sol"; + +contract AABenchmarkPrepare is BaseTest { + AccountFactory private accountFactory; + + function setUp() public override { + super.setUp(); + accountFactory = new AccountFactory( + deployer, + IEntryPoint(payable(address(0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789))) + ); + } + + function test_prepareBenchmarkFile() public { + address accountFactoryAddress = address(accountFactory); + bytes memory accountFactoryBytecode = accountFactoryAddress.code; + + address accountImplAddress = accountFactory.accountImplementation(); + bytes memory accountImplBytecode = accountImplAddress.code; + + string memory accountFactoryAddressString = string.concat( + "address constant THIRDWEB_ACCOUNT_FACTORY_ADDRESS = ", + Strings.toHexStringChecksummed(accountFactoryAddress), + ";" + ); + string memory accountFactoryBytecodeString = string.concat( + 'bytes constant THIRDWEB_ACCOUNT_FACTORY_BYTECODE = hex"', + Strings.toHexStringNoPrefix(accountFactoryBytecode), + '"', + ";" + ); + + string memory accountImplAddressString = string.concat( + "address constant THIRDWEB_ACCOUNT_IMPL_ADDRESS = ", + Strings.toHexStringChecksummed(accountImplAddress), + ";" + ); + string memory accountImplBytecodeString = string.concat( + 'bytes constant THIRDWEB_ACCOUNT_IMPL_BYTECODE = hex"', + Strings.toHexStringNoPrefix(accountImplBytecode), + '"', + ";" + ); + + string memory path = "src/test/smart-wallet/utils/AABenchmarkArtifacts.sol"; + + vm.removeFile(path); + + vm.writeLine(path, ""); + vm.writeLine(path, "pragma solidity ^0.8.0;"); + vm.writeLine(path, "interface ThirdwebAccountFactory {"); + vm.writeLine( + path, + " function createAccount(address _admin, bytes calldata _data) external returns (address);" + ); + vm.writeLine( + path, + " function getAddress(address _adminSigner, bytes calldata _data) external view returns (address);" + ); + vm.writeLine(path, "}"); + + vm.writeLine(path, "interface ThirdwebAccount {"); + vm.writeLine(path, " function execute(address _target, uint256 _value, bytes calldata _calldata) external;"); + vm.writeLine(path, "}"); + vm.writeLine(path, accountFactoryAddressString); + vm.writeLine(path, accountImplAddressString); + vm.writeLine(path, accountFactoryBytecodeString); + vm.writeLine(path, accountImplBytecodeString); + + vm.writeLine(path, ""); + } +} diff --git a/src/test/smart-wallet/utils/AABenchmarkTest.t.sol b/src/test/smart-wallet/utils/AABenchmarkTest.t.sol new file mode 100644 index 000000000..0ca4622b1 --- /dev/null +++ b/src/test/smart-wallet/utils/AABenchmarkTest.t.sol @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "./AATestBase.sol"; +import { ThirdwebAccountFactory, ThirdwebAccount, THIRDWEB_ACCOUNT_FACTORY_ADDRESS, THIRDWEB_ACCOUNT_IMPL_ADDRESS, THIRDWEB_ACCOUNT_FACTORY_BYTECODE, THIRDWEB_ACCOUNT_IMPL_BYTECODE } from "./AABenchmarkArtifacts.sol"; + +contract ProfileThirdwebAccount is AAGasProfileBase { + ThirdwebAccountFactory factory; + + function setUp() external { + initializeTest("thirdwebAccount"); + factory = ThirdwebAccountFactory(THIRDWEB_ACCOUNT_FACTORY_ADDRESS); + vm.etch(address(factory), THIRDWEB_ACCOUNT_FACTORY_BYTECODE); + vm.etch(THIRDWEB_ACCOUNT_IMPL_ADDRESS, THIRDWEB_ACCOUNT_IMPL_BYTECODE); + setAccount(); + } + + function fillData(address _to, uint256 _value, bytes memory _data) internal view override returns (bytes memory) { + return abi.encodeWithSelector(ThirdwebAccount.execute.selector, _to, _value, _data); + } + + function getSignature(UserOperation memory _op) internal view override returns (bytes memory) { + return signUserOpHash(key, _op); + } + + function createAccount(address _owner) internal override { + // if (address(account).code.length == 0) { + factory.createAccount(_owner, ""); + // } + } + + function getAccountAddr(address _owner) internal view override returns (IAccount) { + return IAccount(factory.getAddress(_owner, "")); + } + + function getInitCode(address _owner) internal view override returns (bytes memory) { + return abi.encodePacked(address(factory), abi.encodeWithSelector(factory.createAccount.selector, _owner, "")); + } + + function getDummySig(UserOperation memory _op) internal pure override returns (bytes memory) { + return + hex"fffffffffffffffffffffffffffffff0000000000000000000000000000000007aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1c"; + } +} diff --git a/src/test/smart-wallet/utils/AATestArtifacts.sol b/src/test/smart-wallet/utils/AATestArtifacts.sol new file mode 100644 index 000000000..66ecea693 --- /dev/null +++ b/src/test/smart-wallet/utils/AATestArtifacts.sol @@ -0,0 +1,9 @@ +pragma solidity ^0.8.0; + +bytes constant ENTRYPOINT_0_6_BYTECODE = hex"60806040526004361015610023575b361561001957600080fd5b610021615531565b005b60003560e01c80630396cb60146101b35780630bd28e3b146101aa5780631b2e01b8146101a15780631d732756146101985780631fad948c1461018f578063205c28781461018657806335567e1a1461017d5780634b1d7cf5146101745780635287ce121461016b57806370a08231146101625780638f41ec5a14610159578063957122ab146101505780639b249f6914610147578063a61935311461013e578063b760faf914610135578063bb9fe6bf1461012c578063c23a5cea14610123578063d6383f941461011a578063ee219423146101115763fc7e286d0361000e5761010c611bcd565b61000e565b5061010c6119b5565b5061010c61184d565b5061010c6116b4565b5061010c611536565b5061010c6114f7565b5061010c6114d6565b5061010c611337565b5061010c611164565b5061010c611129565b5061010c6110a4565b5061010c610f54565b5061010c610bf8565b5061010c610b33565b5061010c610994565b5061010c6108ba565b5061010c6106e7565b5061010c610467565b5061010c610385565b5060207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126103595760043563ffffffff8116808203610359576103547fa5ae833d0bb1dcd632d98a8b70973e8516812898e19bf27b70071ebc8dc52c01916102716102413373ffffffffffffffffffffffffffffffffffffffff166000526000602052604060002090565b9161024d811515615697565b61026a610261600185015463ffffffff1690565b63ffffffff1690565b11156156fc565b54926103366dffffffffffffffffffffffffffff946102f461029834888460781c166121d5565b966102a4881515615761565b6102b0818911156157c6565b6102d4816102bc6105ec565b941684906dffffffffffffffffffffffffffff169052565b6001602084015287166dffffffffffffffffffffffffffff166040830152565b63ffffffff83166060820152600060808201526103313373ffffffffffffffffffffffffffffffffffffffff166000526000602052604060002090565b61582b565b6040805194855263ffffffff90911660208501523393918291820190565b0390a2005b600080fd5b6024359077ffffffffffffffffffffffffffffffffffffffffffffffff8216820361035957565b50346103595760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126103595760043577ffffffffffffffffffffffffffffffffffffffffffffffff81168103610359576104149033600052600160205260406000209077ffffffffffffffffffffffffffffffffffffffffffffffff16600052602052604060002090565b61041e8154612491565b9055005b73ffffffffffffffffffffffffffffffffffffffff81160361035957565b6024359061044d82610422565b565b60c4359061044d82610422565b359061044d82610422565b50346103595760407ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126103595760206104fc6004356104a881610422565b73ffffffffffffffffffffffffffffffffffffffff6104c561035e565b91166000526001835260406000209077ffffffffffffffffffffffffffffffffffffffffffffffff16600052602052604060002090565b54604051908152f35b507f4e487b7100000000000000000000000000000000000000000000000000000000600052604160045260246000fd5b60a0810190811067ffffffffffffffff82111761055157604052565b610559610505565b604052565b610100810190811067ffffffffffffffff82111761055157604052565b67ffffffffffffffff811161055157604052565b6060810190811067ffffffffffffffff82111761055157604052565b90601f7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0910116810190811067ffffffffffffffff82111761055157604052565b6040519061044d82610535565b6040519060c0820182811067ffffffffffffffff82111761055157604052565b604051906040820182811067ffffffffffffffff82111761055157604052565b7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0601f60209267ffffffffffffffff8111610675575b01160190565b61067d610505565b61066f565b92919261068e82610639565b9161069c60405193846105ab565b829481845281830111610359578281602093846000960137010152565b9181601f840112156103595782359167ffffffffffffffff8311610359576020838186019501011161035957565b5034610359576101c07ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126103595767ffffffffffffffff60043581811161035957366023820112156103595761074a903690602481600401359101610682565b907fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffdc36016101808112610359576101006040519161078783610535565b12610359576040516107988161055e565b6107a0610440565b815260443560208201526064356040820152608435606082015260a43560808201526107ca61044f565b60a082015260e43560c08201526101043560e082015281526101243560208201526101443560408201526101643560608201526101843560808201526101a4359182116103595761083e9261082661082e9336906004016106b9565b9290916128b1565b6040519081529081906020820190565b0390f35b9060407ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc8301126103595760043567ffffffffffffffff9283821161035957806023830112156103595781600401359384116103595760248460051b830101116103595760240191906024356108b781610422565b90565b5034610359576108c936610842565b6108d4929192611e3a565b6108dd83611d2d565b60005b84811061095d57506000927fbb47ee3e183a558b1a2ff0874b079f3fc5478b7454eacf2bfc5af2ff5878f9728480a183915b85831061092d576109238585611ed7565b6100216001600255565b909193600190610953610941878987611dec565b61094b8886611dca565b51908861233f565b0194019190610912565b8061098b610984610972600194869896611dca565b5161097e848a88611dec565b84613448565b9083612f30565b019290926108e0565b50346103595760407ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc360112610359576004356109d081610422565b6024359060009133835282602052604083206dffffffffffffffffffffffffffff81541692838311610ad557848373ffffffffffffffffffffffffffffffffffffffff829593610a788496610a3f610a2c8798610ad29c6121c0565b6dffffffffffffffffffffffffffff1690565b6dffffffffffffffffffffffffffff167fffffffffffffffffffffffffffffffffffff0000000000000000000000000000825416179055565b6040805173ffffffffffffffffffffffffffffffffffffffff831681526020810185905233917fd1c19fbcd4551a5edfb66d43d2e337c04837afda3482b42bdf569a8fccdae5fb91a2165af1610acc611ea7565b50615ba2565b80f35b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601960248201527f576974686472617720616d6f756e7420746f6f206c61726765000000000000006044820152fd5b50346103595760407ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc360112610359576020600435610b7181610422565b73ffffffffffffffffffffffffffffffffffffffff610b8e61035e565b911660005260018252610bc98160406000209077ffffffffffffffffffffffffffffffffffffffffffffffff16600052602052604060002090565b547fffffffffffffffffffffffffffffffffffffffffffffffff00000000000000006040519260401b16178152f35b503461035957610c0736610842565b610c0f611e3a565b6000805b838210610df657610c249150611d2d565b7fbb47ee3e183a558b1a2ff0874b079f3fc5478b7454eacf2bfc5af2ff5878f972600080a16000805b848110610d5c57505060008093815b818110610c9357610923868660007f575ff3acadd5ab348fe1855e217e0f3678f8d767d7494c9f9fefbee2e17cca4d8180a2611ed7565b610cf7610ca182848a6124cb565b610ccc610cb3610cb36020840161256d565b73ffffffffffffffffffffffffffffffffffffffff1690565b7f575ff3acadd5ab348fe1855e217e0f3678f8d767d7494c9f9fefbee2e17cca4d600080a280612519565b906000915b808310610d1457505050610d0f90612491565b610c5c565b90919497610d4f610d49610d5592610d438c8b610d3c82610d368e8b8d611dec565b92611dca565b519161233f565b906121d5565b99612491565b95612491565b9190610cfc565b610d678186886124cb565b6020610d7f610d768380612519565b9290930161256d565b9173ffffffffffffffffffffffffffffffffffffffff60009316905b828410610db45750505050610daf90612491565b610c4d565b90919294610d4f81610de985610de2610dd0610dee968d611dca565b51610ddc8c8b8a611dec565b85613448565b908b613148565b612491565b929190610d9b565b610e018285876124cb565b90610e0c8280612519565b92610e1c610cb36020830161256d565b9173ffffffffffffffffffffffffffffffffffffffff8316610e416001821415612577565b610e62575b505050610e5c91610e56916121d5565b91612491565b90610c13565b909592610e7b6040999693999895989788810190611fc8565b92908a3b156103595789938b918a5193849283927fe3563a4f00000000000000000000000000000000000000000000000000000000845260049e8f850193610ec294612711565b03815a93600094fa9081610f3b575b50610f255786517f86a9f75000000000000000000000000000000000000000000000000000000000815273ffffffffffffffffffffffffffffffffffffffff8a16818a0190815281906020010390fd5b0390fd5b9497509295509093509181610e56610e5c610e46565b80610f48610f4e9261057b565b8061111e565b38610ed1565b50346103595760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126103595761083e73ffffffffffffffffffffffffffffffffffffffff600435610fa881610422565b608060409283928351610fba81610535565b60009381858093528260208201528287820152826060820152015216815280602052209061104965ffffffffffff6001835194610ff686610535565b80546dffffffffffffffffffffffffffff8082168852607082901c60ff161515602089015260789190911c1685870152015463ffffffff8116606086015260201c16608084019065ffffffffffff169052565b5191829182919091608065ffffffffffff8160a08401956dffffffffffffffffffffffffffff808251168652602082015115156020870152604082015116604086015263ffffffff6060820151166060860152015116910152565b50346103595760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126103595773ffffffffffffffffffffffffffffffffffffffff6004356110f581610422565b16600052600060205260206dffffffffffffffffffffffffffff60406000205416604051908152f35b600091031261035957565b50346103595760007ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261035957602060405160018152f35b50346103595760607ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261035957600467ffffffffffffffff8135818111610359576111b590369084016106b9565b9050602435916111c483610422565b604435908111610359576111db90369085016106b9565b92909115908161132d575b506112c6576014821015611236575b610f21836040519182917f08c379a0000000000000000000000000000000000000000000000000000000008352820160409060208152600060208201520190565b6112466112529261124c92612b88565b90612b96565b60601c90565b3b1561125f5738806111f5565b610f21906040519182917f08c379a0000000000000000000000000000000000000000000000000000000008352820160609060208152601b60208201527f41413330207061796d6173746572206e6f74206465706c6f796564000000000060408201520190565b610f21836040519182917f08c379a0000000000000000000000000000000000000000000000000000000008352820160609060208152601960208201527f41413230206163636f756e74206e6f74206465706c6f7965640000000000000060408201520190565b90503b15386111e6565b50346103595760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126103595760043567ffffffffffffffff81116103595761138960249136906004016106b9565b906113bf6040519283927f570e1a3600000000000000000000000000000000000000000000000000000000845260048401612d2c565b0360208273ffffffffffffffffffffffffffffffffffffffff92816000857f0000000000000000000000007fc98430eaedbb6070b35b39d798725049088348165af1918215611471575b600092611441575b50604051917f6ca7b806000000000000000000000000000000000000000000000000000000008352166004820152fd5b61146391925060203d811161146a575b61145b81836105ab565b810190612d17565b9038611411565b503d611451565b611479612183565b611409565b90816101609103126103595790565b60207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc820112610359576004359067ffffffffffffffff8211610359576108b79160040161147e565b50346103595760206114ef6114ea3661148d565b612a0c565b604051908152f35b5060207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126103595761002160043561153181610422565b61562b565b5034610359576000807ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126116b1573381528060205260408120600181019063ffffffff825416908115611653576115f06115b5611618936115a76115a2855460ff9060701c1690565b61598f565b65ffffffffffff42166159f4565b84547fffffffffffffffffffffffffffffffffffffffffffff000000000000ffffffff16602082901b69ffffffffffff000000001617909455565b7fffffffffffffffffffffffffffffffffff00ffffffffffffffffffffffffffff8154169055565b60405165ffffffffffff91909116815233907ffa9b3c14cc825c412c9ed81b3ba365a5b459439403f18829e572ed53a4180f0a90602090a280f35b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152600a60248201527f6e6f74207374616b6564000000000000000000000000000000000000000000006044820152fd5b80fd5b50346103595760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc360112610359576004356116f081610422565b610ad273ffffffffffffffffffffffffffffffffffffffff6117323373ffffffffffffffffffffffffffffffffffffffff166000526000602052604060002090565b926117ea611755610a2c86546dffffffffffffffffffffffffffff9060781c1690565b94611761861515615a0e565b6117c26001820161179a65ffffffffffff611786835465ffffffffffff9060201c1690565b16611792811515615a73565b421015615ad8565b80547fffffffffffffffffffffffffffffffffffffffffffff00000000000000000000169055565b7fffffff0000000000000000000000000000ffffffffffffffffffffffffffffff8154169055565b6040805173ffffffffffffffffffffffffffffffffffffffff831681526020810186905233917fb7c918e0e249f999e965cafeb6c664271b3f4317d296461500e71da39f0cbda391a2600080809581948294165af1611847611ea7565b50615b3d565b50346103595760607ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126103595767ffffffffffffffff6004358181116103595761189e90369060040161147e565b602435916118ab83610422565b604435908111610359576118c6610f219136906004016106b9565b6118ce611caa565b6118d785612e2b565b6118ea6118e48287613240565b906153ba565b946118fa826000924384526121e2565b96438252819360609573ffffffffffffffffffffffffffffffffffffffff8316611981575b50505050608001519361194e6040611940602084015165ffffffffffff1690565b92015165ffffffffffff1690565b906040519687967f8b7ac980000000000000000000000000000000000000000000000000000000008852600488016127e1565b8395508394965061199b60409492939451809481936127d3565b03925af19060806119aa611ea7565b92919038808061191f565b5034610359576119c43661148d565b6119cc611caa565b6119d582612e2b565b6119df8183613240565b825160a00151919391611a0c9073ffffffffffffffffffffffffffffffffffffffff166154dc565b6154dc565b90611a30611a07855173ffffffffffffffffffffffffffffffffffffffff90511690565b94611a39612b50565b50611a68611a4c60409586810190611fc8565b90600060148310611bc55750611246611a079261124c92612b88565b91611a72916153ba565b805173ffffffffffffffffffffffffffffffffffffffff169073ffffffffffffffffffffffffffffffffffffffff821660018114916080880151978781015191886020820151611ac79065ffffffffffff1690565b91015165ffffffffffff16916060015192611ae06105f9565b9a8b5260208b0152841515898b015265ffffffffffff1660608a015265ffffffffffff16608089015260a088015215159081611bbc575b50611b515750610f2192519485947fe0cff05f00000000000000000000000000000000000000000000000000000000865260048601612cbd565b9190610f2193611b60846154dc565b611b87611b6b610619565b73ffffffffffffffffffffffffffffffffffffffff9096168652565b6020850152519586957ffaecb4e400000000000000000000000000000000000000000000000000000000875260048701612c2b565b90501538611b17565b9150506154dc565b50346103595760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126103595773ffffffffffffffffffffffffffffffffffffffff600435611c1e81610422565b16600052600060205260a0604060002065ffffffffffff60018254920154604051926dffffffffffffffffffffffffffff90818116855260ff8160701c161515602086015260781c16604084015263ffffffff8116606084015260201c166080820152f35b60209067ffffffffffffffff8111611c9d575b60051b0190565b611ca5610505565b611c96565b60405190611cb782610535565b604051608083610100830167ffffffffffffffff811184821017611d20575b60405260009283815283602082015283604082015283606082015283838201528360a08201528360c08201528360e082015281528260208201528260408201528260608201520152565b611d28610505565b611cd6565b90611d3782611c83565b611d4460405191826105ab565b8281527fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0611d728294611c83565b019060005b828110611d8357505050565b602090611d8e611caa565b82828501015201611d77565b507f4e487b7100000000000000000000000000000000000000000000000000000000600052603260045260246000fd5b6020918151811015611ddf575b60051b010190565b611de7611d9a565b611dd7565b9190811015611e2d575b60051b810135907ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffea181360301821215610359570190565b611e35611d9a565b611df6565b6002805414611e495760028055565b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601f60248201527f5265656e7472616e637947756172643a207265656e7472616e742063616c6c006044820152fd5b3d15611ed2573d90611eb882610639565b91611ec660405193846105ab565b82523d6000602084013e565b606090565b73ffffffffffffffffffffffffffffffffffffffff168015611f6a57600080809381935af1611f04611ea7565b5015611f0c57565b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601f60248201527f41413931206661696c65642073656e6420746f2062656e6566696369617279006044820152fd5b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601860248201527f4141393020696e76616c69642062656e656669636961727900000000000000006044820152fd5b9035907fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe181360301821215610359570180359067ffffffffffffffff82116103595760200191813603831361035957565b90816020910312610359575190565b601f82602094937fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0938186528686013760008582860101520116010190565b60005b83811061207a5750506000910152565b818101518382015260200161206a565b907fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0601f6020936120c681518092818752878088019101612067565b0116010190565b906120e76080916108b796946101c0808652850191612028565b9360e0815173ffffffffffffffffffffffffffffffffffffffff80825116602087015260208201516040870152604082015160608701526060820151858701528482015160a087015260a08201511660c086015260c081015182860152015161010084015260208101516101208401526040810151610140840152606081015161016084015201516101808201526101a081840391015261208a565b506040513d6000823e3d90fd5b507f4e487b7100000000000000000000000000000000000000000000000000000000600052601160045260246000fd5b919082039182116121cd57565b61044d612190565b919082018092116121cd57565b905a918160206121fb6060830151936060810190611fc8565b906122348560405195869485947f1d732756000000000000000000000000000000000000000000000000000000008652600486016120cd565b03816000305af16000918161230f575b50612308575060206000803e7fdeaddead000000000000000000000000000000000000000000000000000000006000511461229b5761229561228a6108b7945a906121c0565b6080840151906121d5565b91614afc565b6040517f220266b600000000000000000000000000000000000000000000000000000000815280610f21600482016080906000815260406020820152600f60408201527f41413935206f7574206f6620676173000000000000000000000000000000000060608201520190565b9250505090565b61233191925060203d8111612338575b61232981836105ab565b810190612019565b9038612244565b503d61231f565b909291925a9380602061235b6060830151946060810190611fc8565b906123948660405195869485947f1d732756000000000000000000000000000000000000000000000000000000008652600486016120cd565b03816000305af160009181612471575b5061246a575060206000803e7fdeaddead00000000000000000000000000000000000000000000000000000000600051146123fc576123f66123eb6108b795965a906121c0565b6080830151906121d5565b92614ddf565b610f21836040519182917f220266b600000000000000000000000000000000000000000000000000000000835260048301608091815260406020820152600f60408201527f41413935206f7574206f6620676173000000000000000000000000000000000060608201520190565b9450505050565b61248a91925060203d81116123385761232981836105ab565b90386123a4565b6001907fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff81146124bf570190565b6124c7612190565b0190565b919081101561250c575b60051b810135907fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffa181360301821215610359570190565b612514611d9a565b6124d5565b9035907fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe181360301821215610359570180359067ffffffffffffffff821161035957602001918160051b3603831361035957565b356108b781610422565b1561257e57565b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601760248201527f4141393620696e76616c69642061676772656761746f720000000000000000006044820152fd5b90357fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe18236030181121561035957016020813591019167ffffffffffffffff821161035957813603831361035957565b6108b7916126578161263d8461045c565b73ffffffffffffffffffffffffffffffffffffffff169052565b602082013560208201526126f26126a361268861267760408601866125dc565b610160806040880152860191612028565b61269560608601866125dc565b908583036060870152612028565b6080840135608084015260a084013560a084015260c084013560c084015260e084013560e084015261010080850135908401526101206126e5818601866125dc565b9185840390860152612028565b9161270361014091828101906125dc565b929091818503910152612028565b949391929083604087016040885252606086019360608160051b8801019482600090815b848310612754575050505050508460206108b795968503910152612028565b9091929394977fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffa08b820301855288357ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffea1843603018112156127cf57600191846127bd920161262c565b98602090810196950193019190612735565b8280fd5b908092918237016000815290565b9290936108b796959260c0958552602085015265ffffffffffff8092166040850152166060830152151560808201528160a0820152019061208a565b1561282457565b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601760248201527f4141393220696e7465726e616c2063616c6c206f6e6c790000000000000000006044820152fd5b9060406108b79260008152816020820152019061208a565b6040906108b793928152816020820152019061208a565b909291925a936128c230331461281d565b8151946040860151955a6113886060830151890101116129e2576108b7966000958051612909575b50505090612903915a9003608084015101943691610682565b91615047565b612938916129349161292f855173ffffffffffffffffffffffffffffffffffffffff1690565b615c12565b1590565b612944575b80806128ea565b61290392919450612953615c24565b908151612967575b5050600193909161293d565b7f1c4fada7374c0a9ee8841fc38afe82932dc0f8e69012e927f061a8bae611a20173ffffffffffffffffffffffffffffffffffffffff6020870151926129d860206129c6835173ffffffffffffffffffffffffffffffffffffffff1690565b9201519560405193849316968361289a565b0390a3388061295b565b7fdeaddead0000000000000000000000000000000000000000000000000000000060005260206000fd5b612a22612a1c6040830183611fc8565b90615c07565b90612a33612a1c6060830183611fc8565b90612ae9612a48612a1c610120840184611fc8565b60405194859360208501956101008201359260e08301359260c08101359260a08201359260808301359273ffffffffffffffffffffffffffffffffffffffff60208201359135168c9693909a9998959261012098959273ffffffffffffffffffffffffffffffffffffffff6101408a019d168952602089015260408801526060870152608086015260a085015260c084015260e08301526101008201520152565b0391612b1b7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0938481018352826105ab565b51902060408051602081019283523091810191909152466060820152608092830181529091612b4a90826105ab565b51902090565b604051906040820182811067ffffffffffffffff821117612b7b575b60405260006020838281520152565b612b83610505565b612b6c565b906014116103595790601490565b7fffffffffffffffffffffffffffffffffffffffff0000000000000000000000009035818116939260148110612bcb57505050565b60140360031b82901b16169150565b9060c060a06108b793805184526020810151602085015260408101511515604085015265ffffffffffff80606083015116606086015260808201511660808501520151918160a0820152019061208a565b9294612c8c61044d95612c7a610100959998612c68612c54602097610140808c528b0190612bda565b9b878a019060208091805184520151910152565b80516060890152602001516080880152565b805160a08701526020015160c0860152565b73ffffffffffffffffffffffffffffffffffffffff81511660e0850152015191019060208091805184520151910152565b612d0661044d94612cf4612cdf60a0959998969960e0865260e0860190612bda565b98602085019060208091805184520151910152565b80516060840152602001516080830152565b019060208091805184520151910152565b9081602091031261035957516108b781610422565b9160206108b7938181520191612028565b90612d6c73ffffffffffffffffffffffffffffffffffffffff916108b797959694606085526060850191612028565b941660208201526040818503910152612028565b60009060033d11612d8d57565b905060046000803e60005160e01c90565b600060443d106108b7576040517ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc91823d016004833e815167ffffffffffffffff918282113d602484011117612e1a57818401948551938411612e22573d85010160208487010111612e1a57506108b7929101602001906105ab565b949350505050565b50949350505050565b612e386040820182611fc8565b612e50612e448461256d565b93610120810190611fc8565b9290303b1561035957600093612e949160405196879586957f957122ab00000000000000000000000000000000000000000000000000000000875260048701612d3d565b0381305afa9081612f1d575b5061044d576001612eaf612d80565b6308c379a014612ec8575b612ec057565b61044d612183565b612ed0612d9e565b80612edc575b50612eba565b80516000925015612ed657610f21906040519182917f220266b600000000000000000000000000000000000000000000000000000000835260048301612882565b80610f48612f2a9261057b565b38612ea0565b9190612f3b9061317f565b73ffffffffffffffffffffffffffffffffffffffff929183166130da5761306c57612f659061317f565b9116612ffe57612f725750565b604080517f220266b600000000000000000000000000000000000000000000000000000000815260048101929092526024820152602160448201527f41413332207061796d61737465722065787069726564206f72206e6f7420647560648201527f6500000000000000000000000000000000000000000000000000000000000000608482015260a490fd5b610f21826040519182917f220266b600000000000000000000000000000000000000000000000000000000835260048301608091815260406020820152601460408201527f41413334207369676e6174757265206572726f7200000000000000000000000060608201520190565b610f21836040519182917f220266b600000000000000000000000000000000000000000000000000000000835260048301608091815260406020820152601760408201527f414132322065787069726564206f72206e6f742064756500000000000000000060608201520190565b610f21846040519182917f220266b600000000000000000000000000000000000000000000000000000000835260048301608091815260406020820152601460408201527f41413234207369676e6174757265206572726f7200000000000000000000000060608201520190565b9291906131549061317f565b909273ffffffffffffffffffffffffffffffffffffffff808095169116036130da5761306c57612f65905b80156131d25761318e9061535f565b73ffffffffffffffffffffffffffffffffffffffff65ffffffffffff8060408401511642119081156131c2575b5091511691565b90506020830151164210386131bb565b50600090600090565b156131e257565b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601860248201527f41413934206761732076616c756573206f766572666c6f7700000000000000006044820152fd5b916000915a9381519061325382826136b3565b61325c81612a0c565b602084015261329a6effffffffffffffffffffffffffffff60808401516060850151176040850151176101008401359060e0850135171711156131db565b6132a382613775565b6132ae818584613836565b97906132df6129346132d4875173ffffffffffffffffffffffffffffffffffffffff1690565b60208801519061546c565b6133db576132ec43600052565b73ffffffffffffffffffffffffffffffffffffffff61332460a0606097015173ffffffffffffffffffffffffffffffffffffffff1690565b166133c1575b505a810360a0840135106133545760809360c092604087015260608601525a900391013501910152565b6040517f220266b600000000000000000000000000000000000000000000000000000000815280610f21600482016080906000815260406020820152601e60408201527f41413430206f76657220766572696669636174696f6e4761734c696d6974000060608201520190565b909350816133d2929750858461455c565b9590923861332a565b6040517f220266b600000000000000000000000000000000000000000000000000000000815280610f21600482016080906000815260406020820152601a60408201527f4141323520696e76616c6964206163636f756e74206e6f6e636500000000000060608201520190565b9290916000925a825161345b81846136b3565b61346483612a0c565b60208501526134a26effffffffffffffffffffffffffffff60808301516060840151176040840151176101008601359060e0870135171711156131db565b6134ab81613775565b6134b78186868b613ba2565b98906134e86129346134dd865173ffffffffffffffffffffffffffffffffffffffff1690565b60208701519061546c565b6135e0576134f543600052565b73ffffffffffffffffffffffffffffffffffffffff61352d60a0606096015173ffffffffffffffffffffffffffffffffffffffff1690565b166135c5575b505a840360a08601351061355f5750604085015260608401526080919060c0905a900391013501910152565b604080517f220266b600000000000000000000000000000000000000000000000000000000815260048101929092526024820152601e60448201527f41413430206f76657220766572696669636174696f6e4761734c696d697400006064820152608490fd5b909250816135d79298508686856147ef565b96909138613533565b610f21826040519182917f220266b600000000000000000000000000000000000000000000000000000000835260048301608091815260406020820152601a60408201527f4141323520696e76616c6964206163636f756e74206e6f6e636500000000000060608201520190565b1561365557565b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601d60248201527f4141393320696e76616c6964207061796d6173746572416e64446174610000006044820152fd5b613725906136dd6136c38261256d565b73ffffffffffffffffffffffffffffffffffffffff168452565b602081013560208401526080810135604084015260a0810135606084015260c0810135608084015260e081013560c084015261010081013560e0840152610120810190611fc8565b90811561376a5761374f61124c6112468460a09461374a601461044d9998101561364e565b612b88565b73ffffffffffffffffffffffffffffffffffffffff16910152565b505060a06000910152565b60a081015173ffffffffffffffffffffffffffffffffffffffff16156137b75760c060035b60ff60408401519116606084015102016080830151019101510290565b60c0600161379a565b6137d86040929594939560608352606083019061262c565b9460208201520152565b9061044d602f60405180947f414132332072657665727465643a20000000000000000000000000000000000060208301526138268151809260208686019101612067565b810103600f8101855201836105ab565b916000926000925a936139046020835193613865855173ffffffffffffffffffffffffffffffffffffffff1690565b9561387d6138766040830183611fc8565b9084613e0d565b60a086015173ffffffffffffffffffffffffffffffffffffffff16906138a243600052565b85809373ffffffffffffffffffffffffffffffffffffffff809416159889613b3a575b60600151908601516040517f3a871cdd0000000000000000000000000000000000000000000000000000000081529788968795869390600485016137c0565b03938a1690f1829181613b1a575b50613b115750600190613923612d80565b6308c379a014613abd575b50613a50575b613941575b50505a900391565b61396b9073ffffffffffffffffffffffffffffffffffffffff166000526000602052604060002090565b613986610a2c82546dffffffffffffffffffffffffffff1690565b8083116139e3576139dc926dffffffffffffffffffffffffffff9103166dffffffffffffffffffffffffffff167fffffffffffffffffffffffffffffffffffff0000000000000000000000000000825416179055565b3880613939565b6040517f220266b600000000000000000000000000000000000000000000000000000000815280610f21600482016080906000815260406020820152601760408201527f41413231206469646e2774207061792070726566756e6400000000000000000060608201520190565b6040517f220266b600000000000000000000000000000000000000000000000000000000815280610f21600482016080906000815260406020820152601660408201527f4141323320726576657274656420286f72204f4f47290000000000000000000060608201520190565b613ac5612d9e565b9081613ad1575061392e565b610f2191613adf91506137e2565b6040519182917f220266b600000000000000000000000000000000000000000000000000000000835260048301612882565b95506139349050565b613b3391925060203d81116123385761232981836105ab565b9038613912565b9450613b80610a2c613b6c8c73ffffffffffffffffffffffffffffffffffffffff166000526000602052604060002090565b546dffffffffffffffffffffffffffff1690565b8b811115613b975750856060835b969150506138c5565b606087918d03613b8e565b90926000936000935a94613beb6020835193613bd2855173ffffffffffffffffffffffffffffffffffffffff1690565b9561387d613be36040830183611fc8565b90848c61412b565b03938a1690f1829181613ded575b50613de45750600190613c0a612d80565b6308c379a014613d8e575b50613d20575b613c29575b5050505a900391565b613c539073ffffffffffffffffffffffffffffffffffffffff166000526000602052604060002090565b91613c6f610a2c84546dffffffffffffffffffffffffffff1690565b90818311613cba575082547fffffffffffffffffffffffffffffffffffff0000000000000000000000000000169190036dffffffffffffffffffffffffffff16179055388080613c20565b604080517f220266b600000000000000000000000000000000000000000000000000000000815260048101929092526024820152601760448201527f41413231206469646e2774207061792070726566756e640000000000000000006064820152608490fd5b610f21846040519182917f220266b600000000000000000000000000000000000000000000000000000000835260048301608091815260406020820152601660408201527f4141323320726576657274656420286f72204f4f47290000000000000000000060608201520190565b613d96612d9e565b9081613da25750613c15565b8691613dae91506137e2565b90610f216040519283927f220266b60000000000000000000000000000000000000000000000000000000084526004840161289a565b9650613c1b9050565b613e0691925060203d81116123385761232981836105ab565b9038613bf9565b909180613e1957505050565b81515173ffffffffffffffffffffffffffffffffffffffff1692833b6140be57606083510151604051907f570e1a3600000000000000000000000000000000000000000000000000000000825260208280613e78878760048401612d2c565b0381600073ffffffffffffffffffffffffffffffffffffffff95867f0000000000000000000000007fc98430eaedbb6070b35b39d7987250490883481690f19182156140b1575b600092614091575b508082169586156140245716809503613fb7573b15613f4a5761124c6112467fd51a9c61267aa6196961883ecf5ff2da6619c37dac0fa92122513fb32c032d2d93613f1193612b88565b602083810151935160a001516040805173ffffffffffffffffffffffffffffffffffffffff9485168152939091169183019190915290a3565b6040517f220266b600000000000000000000000000000000000000000000000000000000815280610f21600482016080906000815260406020820152602060408201527f4141313520696e6974436f6465206d757374206372656174652073656e64657260608201520190565b6040517f220266b600000000000000000000000000000000000000000000000000000000815280610f21600482016080906000815260406020820152602060408201527f4141313420696e6974436f6465206d7573742072657475726e2073656e64657260608201520190565b6040517f220266b600000000000000000000000000000000000000000000000000000000815280610f21600482016080906000815260406020820152601b60408201527f4141313320696e6974436f6465206661696c6564206f72204f4f47000000000060608201520190565b6140aa91925060203d811161146a5761145b81836105ab565b9038613ec7565b6140b9612183565b613ebf565b6040517f220266b600000000000000000000000000000000000000000000000000000000815280610f21600482016080906000815260406020820152601f60408201527f414131302073656e64657220616c726561647920636f6e73747275637465640060608201520190565b9290918161413a575b50505050565b82515173ffffffffffffffffffffffffffffffffffffffff1693843b6143e257606084510151604051907f570e1a3600000000000000000000000000000000000000000000000000000000825260208280614199888860048401612d2c565b0381600073ffffffffffffffffffffffffffffffffffffffff95867f0000000000000000000000007fc98430eaedbb6070b35b39d7987250490883481690f19182156143d5575b6000926143b5575b5080821696871561434757168096036142d9573b15614273575061124c6112467fd51a9c61267aa6196961883ecf5ff2da6619c37dac0fa92122513fb32c032d2d9361423393612b88565b602083810151935160a001516040805173ffffffffffffffffffffffffffffffffffffffff9485168152939091169183019190915290a338808080614134565b604080517f220266b600000000000000000000000000000000000000000000000000000000815260048101929092526024820152602060448201527f4141313520696e6974436f6465206d757374206372656174652073656e6465726064820152608490fd5b610f21826040519182917f220266b600000000000000000000000000000000000000000000000000000000835260048301608091815260406020820152602060408201527f4141313420696e6974436f6465206d7573742072657475726e2073656e64657260608201520190565b610f21846040519182917f220266b600000000000000000000000000000000000000000000000000000000835260048301608091815260406020820152601b60408201527f4141313320696e6974436f6465206661696c6564206f72204f4f47000000000060608201520190565b6143ce91925060203d811161146a5761145b81836105ab565b90386141e8565b6143dd612183565b6141e0565b604080517f220266b600000000000000000000000000000000000000000000000000000000815260048101929092526024820152601f60448201527f414131302073656e64657220616c726561647920636f6e7374727563746564006064820152608490fd5b1561444f57565b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601f60248201527f4141343120746f6f206c6974746c6520766572696669636174696f6e476173006044820152fd5b919060408382031261035957825167ffffffffffffffff81116103595783019080601f83011215610359578151916144e483610639565b916144f260405193846105ab565b838352602084830101116103595760209261451291848085019101612067565b92015190565b9061044d602f60405180947f414133332072657665727465643a20000000000000000000000000000000000060208301526138268151809260208686019101612067565b93919260609460009460009380519261459b60a08a86015195614580888811614448565b015173ffffffffffffffffffffffffffffffffffffffff1690565b916145c68373ffffffffffffffffffffffffffffffffffffffff166000526000602052604060002090565b946145e2610a2c87546dffffffffffffffffffffffffffff1690565b968588106147825773ffffffffffffffffffffffffffffffffffffffff60208a98946146588a966dffffffffffffffffffffffffffff8b6146919e03166dffffffffffffffffffffffffffff167fffffffffffffffffffffffffffffffffffff0000000000000000000000000000825416179055565b015194604051998a98899788937ff465c77e000000000000000000000000000000000000000000000000000000008552600485016137c0565b0395169103f190818391849361475c575b506147555750506001906146b4612d80565b6308c379a014614733575b506146c657565b6040517f220266b600000000000000000000000000000000000000000000000000000000815280610f21600482016080906000815260406020820152601660408201527f4141333320726576657274656420286f72204f4f47290000000000000000000060608201520190565b61473b612d9e565b908161474757506146bf565b610f2191613adf9150614518565b9450925050565b90925061477b91503d8085833e61477381836105ab565b8101906144ad565b91386146a2565b6040517f220266b600000000000000000000000000000000000000000000000000000000815280610f21600482016080906000815260406020820152601e60408201527f41413331207061796d6173746572206465706f73697420746f6f206c6f77000060608201520190565b91949293909360609560009560009382519061481660a08b84015193614580848611614448565b936148418573ffffffffffffffffffffffffffffffffffffffff166000526000602052604060002090565b61485c610a2c82546dffffffffffffffffffffffffffff1690565b8781106149b7579273ffffffffffffffffffffffffffffffffffffffff60208a989693946146588a966dffffffffffffffffffffffffffff8d6148d69e9c9a03166dffffffffffffffffffffffffffff167fffffffffffffffffffffffffffffffffffff0000000000000000000000000000825416179055565b0395169103f1908183918493614999575b506149915750506001906148f9612d80565b6308c379a014614972575b5061490c5750565b604080517f220266b600000000000000000000000000000000000000000000000000000000815260048101929092526024820152601660448201527f4141333320726576657274656420286f72204f4f4729000000000000000000006064820152608490fd5b61497a612d9e565b90816149865750614904565b613dae925050614518565b955093505050565b9092506149b091503d8085833e61477381836105ab565b91386148e7565b610f218a6040519182917f220266b600000000000000000000000000000000000000000000000000000000835260048301608091815260406020820152601e60408201527f41413331207061796d6173746572206465706f73697420746f6f206c6f77000060608201520190565b60031115614a2f57565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052602160045260246000fd5b929190614a7c6040916002865260606020870152606086019061208a565b930152565b939291906003811015614a2f57604091614a7c91865260606020870152606086019061208a565b9061044d603660405180947f4141353020706f73744f702072657665727465643a20000000000000000000006020830152614aec8151809260208686019101612067565b81010360168101855201836105ab565b929190925a93600091805191614b1183615318565b9260a0810195614b35875173ffffffffffffffffffffffffffffffffffffffff1690565b73ffffffffffffffffffffffffffffffffffffffff93908481169081614ca457505050614b76825173ffffffffffffffffffffffffffffffffffffffff1690565b985b5a90030193840297604084019089825110614c37577f49628fd1471006c1482da88028e9ce4dbb080b815c9b0344d39e5a8e6ec1419f94614bc26020928c614c329551039061553a565b015194896020614c04614be9865173ffffffffffffffffffffffffffffffffffffffff1690565b9a5173ffffffffffffffffffffffffffffffffffffffff1690565b9401519785604051968796169a16988590949392606092608083019683521515602083015260408201520152565b0390a4565b6040517f220266b600000000000000000000000000000000000000000000000000000000815280610f21600482016080906000815260406020820152602060408201527f414135312070726566756e642062656c6f772061637475616c476173436f737460608201520190565b9a918051614cb4575b5050614b78565b6060850151600099509091803b15614ddb579189918983614d07956040518097819682957fa9a234090000000000000000000000000000000000000000000000000000000084528c029060048401614a5e565b0393f19081614dc8575b50614dc3576001614d20612d80565b6308c379a014614da4575b614d37575b3880614cad565b6040517f220266b600000000000000000000000000000000000000000000000000000000815280610f21600482016080906000815260406020820152601260408201527f4141353020706f73744f7020726576657274000000000000000000000000000060608201520190565b614dac612d9e565b80614db75750614d2b565b613adf610f2191614aa8565b614d30565b80610f48614dd59261057b565b38614d11565b8980fd5b9392915a90600092805190614df382615318565b9360a0830196614e17885173ffffffffffffffffffffffffffffffffffffffff1690565b73ffffffffffffffffffffffffffffffffffffffff95908681169081614f0d57505050614e58845173ffffffffffffffffffffffffffffffffffffffff1690565b915b5a9003019485029860408301908a825110614ea757507f49628fd1471006c1482da88028e9ce4dbb080b815c9b0344d39e5a8e6ec1419f949392614bc2614c32938c60209451039061553a565b604080517f220266b600000000000000000000000000000000000000000000000000000000815260048101929092526024820152602060448201527f414135312070726566756e642062656c6f772061637475616c476173436f73746064820152608490fd5b93918051614f1d575b5050614e5a565b606087015160009a509091803b1561504357918a918a83614f70956040518097819682957fa9a234090000000000000000000000000000000000000000000000000000000084528c029060048401614a5e565b0393f19081615030575b5061502b576001614f89612d80565b6308c379a01461500e575b614fa0575b3880614f16565b610f218b6040519182917f220266b600000000000000000000000000000000000000000000000000000000835260048301608091815260406020820152601260408201527f4141353020706f73744f7020726576657274000000000000000000000000000060608201520190565b615016612d9e565b806150215750614f94565b613dae8d91614aa8565b614f99565b80610f4861503d9261057b565b38614f7a565b8a80fd5b909392915a9480519161505983615318565b9260a081019561507d875173ffffffffffffffffffffffffffffffffffffffff1690565b73ffffffffffffffffffffffffffffffffffffffff938185169182615165575050506150bd825173ffffffffffffffffffffffffffffffffffffffff1690565b985b5a90030193840297604084019089825110614c37577f49628fd1471006c1482da88028e9ce4dbb080b815c9b0344d39e5a8e6ec1419f946151096020928c614c329551039061553a565b61511288614a25565b015194896020615139614be9865173ffffffffffffffffffffffffffffffffffffffff1690565b940151604080519182529815602082015297880152606087015290821695909116939081906080820190565b9a918151615175575b50506150bf565b8784026151818a614a25565b60028a1461520c576060860151823b15610359576151d493600080948d604051978896879586937fa9a2340900000000000000000000000000000000000000000000000000000000855260048501614a81565b0393f180156151ff575b6151ec575b505b388061516e565b80610f486151f99261057b565b386151e3565b615207612183565b6151de565b6060860151823b156103595761525793600080948d604051978896879586937fa9a2340900000000000000000000000000000000000000000000000000000000855260048501614a81565b0393f19081615305575b50615300576001615270612d80565b6308c379a0146152ed575b156151e5576040517f220266b600000000000000000000000000000000000000000000000000000000815280610f21600482016080906000815260406020820152601260408201527f4141353020706f73744f7020726576657274000000000000000000000000000060608201520190565b6152f5612d9e565b80614db7575061527b565b6151e5565b80610f486153129261057b565b38615261565b60e060c082015191015180821461533c57480180821015615337575090565b905090565b5090565b6040519061534d8261058f565b60006040838281528260208201520152565b615367615340565b5065ffffffffffff808260a01c1680156153b3575b604051926153898461058f565b73ffffffffffffffffffffffffffffffffffffffff8116845260d01c602084015216604082015290565b508061537c565b6153cf6153d5916153c9615340565b5061535f565b9161535f565b9073ffffffffffffffffffffffffffffffffffffffff9182825116928315615461575b65ffffffffffff928391826040816020850151169301511693836040816020840151169201511690808410615459575b50808511615451575b506040519561543f8761058f565b16855216602084015216604082015290565b935038615431565b925038615428565b8151811693506153f8565b73ffffffffffffffffffffffffffffffffffffffff16600052600160205267ffffffffffffffff6154c88260401c60406000209077ffffffffffffffffffffffffffffffffffffffffffffffff16600052602052604060002090565b918254926154d584612491565b9055161490565b9073ffffffffffffffffffffffffffffffffffffffff6154fa612b50565b9216600052600060205263ffffffff600160406000206dffffffffffffffffffffffffffff815460781c1685520154166020830152565b61044d3361562b565b73ffffffffffffffffffffffffffffffffffffffff16600052600060205260406000206dffffffffffffffffffffffffffff8082541692830180931161561e575b8083116155c05761044d92166dffffffffffffffffffffffffffff167fffffffffffffffffffffffffffffffffffff0000000000000000000000000000825416179055565b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601060248201527f6465706f736974206f766572666c6f77000000000000000000000000000000006044820152fd5b615626612190565b61557b565b73ffffffffffffffffffffffffffffffffffffffff9061564b348261553a565b168060005260006020527f2da466a7b24304f47e87fa2e1e5a81b9831ce54fec19055ce277ca2f39ba42c460206dffffffffffffffffffffffffffff60406000205416604051908152a2565b1561569e57565b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601a60248201527f6d757374207370656369667920756e7374616b652064656c61790000000000006044820152fd5b1561570357565b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601c60248201527f63616e6e6f7420646563726561736520756e7374616b652074696d65000000006044820152fd5b1561576857565b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601260248201527f6e6f207374616b652073706563696669656400000000000000000000000000006044820152fd5b156157cd57565b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152600e60248201527f7374616b65206f766572666c6f770000000000000000000000000000000000006044820152fd5b9065ffffffffffff6080600161044d9461588b6dffffffffffffffffffffffffffff86511682906dffffffffffffffffffffffffffff167fffffffffffffffffffffffffffffffffffff0000000000000000000000000000825416179055565b602085015115156eff000000000000000000000000000082549160701b16807fffffffffffffffffffffffffffffffffff00ffffffffffffffffffffffffffff83161783557fffffff000000000000000000000000000000ffffffffffffffffffffffffffff7cffffffffffffffffffffffffffff000000000000000000000000000000604089015160781b16921617178155019263ffffffff6060820151167fffffffffffffffffffffffffffffffffffffffffffffffffffffffff000000008554161784550151167fffffffffffffffffffffffffffffffffffffffffffff000000000000ffffffff69ffffffffffff0000000083549260201b169116179055565b1561599657565b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601160248201527f616c726561647920756e7374616b696e670000000000000000000000000000006044820152fd5b91909165ffffffffffff808094169116019182116121cd57565b15615a1557565b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601460248201527f4e6f207374616b6520746f2077697468647261770000000000000000000000006044820152fd5b15615a7a57565b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601d60248201527f6d7573742063616c6c20756e6c6f636b5374616b6528292066697273740000006044820152fd5b15615adf57565b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601b60248201527f5374616b65207769746864726177616c206973206e6f742064756500000000006044820152fd5b15615b4457565b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601860248201527f6661696c656420746f207769746864726177207374616b6500000000000000006044820152fd5b15615ba957565b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601260248201527f6661696c656420746f20776974686472617700000000000000000000000000006044820152fd5b816040519182372090565b9060009283809360208451940192f190565b3d610800808211615c4b575b50604051906020818301016040528082526000602083013e90565b905038615c3056fea2646970667358221220a706d8b02d7086d80e9330811f5af84b2614abdc5e9a1f2260126070a31d7cee64736f6c63430008110033"; + +bytes constant CREATOR_0_6_BYTECODE = hex"6080604052600436101561001257600080fd5b6000803560e01c63570e1a361461002857600080fd5b346100c95760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126100c95760043567ffffffffffffffff918282116100c957366023830112156100c95781600401359283116100c95736602484840101116100c9576100c561009e84602485016100fc565b60405173ffffffffffffffffffffffffffffffffffffffff90911681529081906020820190565b0390f35b80fd5b507f4e487b7100000000000000000000000000000000000000000000000000000000600052604160045260246000fd5b90806014116101bb5767ffffffffffffffff917fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffec82018381116101cd575b604051937fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0603f81600b8701160116850190858210908211176101c0575b604052808452602084019036848401116101bb576020946000600c819682946014880187378301015251923560601c5af19060005191156101b557565b60009150565b600080fd5b6101c86100cc565b610178565b6101d56100cc565b61013a56fea26469706673582212201927e80b76ab9b71c952137dd676621a9fdf520c25928815636594036eb1c40364736f6c63430008110033"; + +bytes constant VERIFYINGPAYMASTER_BYTECODE = hex"6080604052600436106100e85760003560e01c8063a9a234091161008a578063c399ec8811610059578063c399ec881461028d578063d0e30db0146102a2578063f2fde38b146102aa578063f465c77e146102ca57600080fd5b8063a9a2340914610204578063b0d691fe14610224578063bb9fe6bf14610258578063c23a5cea1461026d57600080fd5b8063715018a6116100c6578063715018a6146101735780638da5cb5b1461018857806394d4ad60146101a657806394e1fc19146101d657600080fd5b80630396cb60146100ed578063205c28781461010257806323d9ac9b14610122575b600080fd5b6101006100fb366004610dc7565b6102f8565b005b34801561010e57600080fd5b5061010061011d366004610e09565b610383565b34801561012e57600080fd5b506101567f000000000000000000000000074bd8c57fe6f33ec5b580c92401f4feafc4443381565b6040516001600160a01b0390911681526020015b60405180910390f35b34801561017f57600080fd5b506101006103f5565b34801561019457600080fd5b506000546001600160a01b0316610156565b3480156101b257600080fd5b506101c66101c1366004610e77565b610409565b60405161016a9493929190610eb9565b3480156101e257600080fd5b506101f66101f1366004610f39565b610446565b60405190815260200161016a565b34801561021057600080fd5b5061010061021f366004610f97565b610542565b34801561023057600080fd5b506101567f0000000000000000000000005ff137d4b0fdcd49dca30c7cf57e578a026d278981565b34801561026457600080fd5b5061010061055c565b34801561027957600080fd5b50610100610288366004610ff7565b6105d3565b34801561029957600080fd5b506101f6610659565b6101006106e9565b3480156102b657600080fd5b506101006102c5366004610ff7565b61074b565b3480156102d657600080fd5b506102ea6102e5366004611014565b6107c9565b60405161016a929190611062565b6103006107ed565b604051621cb65b60e51b815263ffffffff821660048201527f0000000000000000000000005ff137d4b0fdcd49dca30c7cf57e578a026d27896001600160a01b031690630396cb609034906024016000604051808303818588803b15801561036757600080fd5b505af115801561037b573d6000803e3d6000fd5b505050505050565b61038b6107ed565b60405163040b850f60e31b81526001600160a01b038381166004830152602482018390527f0000000000000000000000005ff137d4b0fdcd49dca30c7cf57e578a026d2789169063205c287890604401600060405180830381600087803b15801561036757600080fd5b6103fd6107ed565b6104076000610847565b565b600080368161041c6054601487896110b7565b81019061042991906110e1565b909450925061043b85605481896110b7565b949793965094505050565b6000833580602086013561045d6040880188611114565b60405161046b92919061115b565b6040519081900390206104816060890189611114565b60405161048f92919061115b565b604080519182900382206001600160a01b03909516602083015281019290925260608201526080808201929092529086013560a08083019190915286013560c08083019190915286013560e08083019190915286013561010080830191909152860135610120820152466101408201523061016082015265ffffffffffff80861661018083015284166101a08201526101c001604051602081830303815290604052805190602001209150509392505050565b61054a610897565b61055684848484610907565b50505050565b6105646107ed565b7f0000000000000000000000005ff137d4b0fdcd49dca30c7cf57e578a026d27896001600160a01b031663bb9fe6bf6040518163ffffffff1660e01b8152600401600060405180830381600087803b1580156105bf57600080fd5b505af1158015610556573d6000803e3d6000fd5b6105db6107ed565b60405163611d2e7560e11b81526001600160a01b0382811660048301527f0000000000000000000000005ff137d4b0fdcd49dca30c7cf57e578a026d2789169063c23a5cea90602401600060405180830381600087803b15801561063e57600080fd5b505af1158015610652573d6000803e3d6000fd5b5050505050565b6040516370a0823160e01b81523060048201526000907f0000000000000000000000005ff137d4b0fdcd49dca30c7cf57e578a026d27896001600160a01b0316906370a0823190602401602060405180830381865afa1580156106c0573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906106e4919061116b565b905090565b60405163b760faf960e01b81523060048201527f0000000000000000000000005ff137d4b0fdcd49dca30c7cf57e578a026d27896001600160a01b03169063b760faf99034906024016000604051808303818588803b15801561063e57600080fd5b6107536107ed565b6001600160a01b0381166107bd5760405162461bcd60e51b815260206004820152602660248201527f4f776e61626c653a206e6577206f776e657220697320746865207a65726f206160448201526564647265737360d01b60648201526084015b60405180910390fd5b6107c681610847565b50565b606060006107d5610897565b6107e085858561093f565b915091505b935093915050565b6000546001600160a01b031633146104075760405162461bcd60e51b815260206004820181905260248201527f4f776e61626c653a2063616c6c6572206973206e6f7420746865206f776e657260448201526064016107b4565b600080546001600160a01b038381166001600160a01b0319831681178455604051919092169283917f8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e09190a35050565b336001600160a01b037f0000000000000000000000005ff137d4b0fdcd49dca30c7cf57e578a026d278916146104075760405162461bcd60e51b815260206004820152601560248201527414d95b99195c881b9bdd08115b9d1c9e541bda5b9d605a1b60448201526064016107b4565b60405162461bcd60e51b815260206004820152600d60248201526c6d757374206f7665727269646560981b60448201526064016107b4565b60606000808036816109586101c16101208b018b611114565b9296509094509250905060408114806109715750604181145b6109e5576040805162461bcd60e51b81526020600482015260248101919091527f566572696679696e675061796d61737465723a20696e76616c6964207369676e60448201527f6174757265206c656e67746820696e207061796d6173746572416e644461746160648201526084016107b4565b6000610a486109f58b8787610446565b6040517f19457468657265756d205369676e6564204d6573736167653a0a3332000000006020820152603c8101829052600090605c01604051602081830303815290604052805190602001209050919050565b9050610a8a8184848080601f016020809104026020016040519081016040528093929190818152602001838380828437600092019190915250610b1892505050565b6001600160a01b03167f000000000000000000000000074bd8c57fe6f33ec5b580c92401f4feafc444336001600160a01b031614610aed57610ace60018686610b3c565b60405180602001604052806000815250909650965050505050506107e5565b610af960008686610b3c565b6040805160208101909152600081529b909a5098505050505050505050565b6000806000610b278585610b74565b91509150610b3481610bb9565b509392505050565b600060d08265ffffffffffff16901b60a08465ffffffffffff16901b85610b64576000610b67565b60015b60ff161717949350505050565b6000808251604103610baa5760208301516040840151606085015160001a610b9e87828585610d03565b94509450505050610bb2565b506000905060025b9250929050565b6000816004811115610bcd57610bcd611184565b03610bd55750565b6001816004811115610be957610be9611184565b03610c365760405162461bcd60e51b815260206004820152601860248201527f45434453413a20696e76616c6964207369676e6174757265000000000000000060448201526064016107b4565b6002816004811115610c4a57610c4a611184565b03610c975760405162461bcd60e51b815260206004820152601f60248201527f45434453413a20696e76616c6964207369676e6174757265206c656e6774680060448201526064016107b4565b6003816004811115610cab57610cab611184565b036107c65760405162461bcd60e51b815260206004820152602260248201527f45434453413a20696e76616c6964207369676e6174757265202773272076616c604482015261756560f01b60648201526084016107b4565b6000807f7fffffffffffffffffffffffffffffff5d576e7357a4501ddfe92f46681b20a0831115610d3a5750600090506003610dbe565b6040805160008082526020820180845289905260ff881692820192909252606081018690526080810185905260019060a0016020604051602081039080840390855afa158015610d8e573d6000803e3d6000fd5b5050604051601f1901519150506001600160a01b038116610db757600060019250925050610dbe565b9150600090505b94509492505050565b600060208284031215610dd957600080fd5b813563ffffffff81168114610ded57600080fd5b9392505050565b6001600160a01b03811681146107c657600080fd5b60008060408385031215610e1c57600080fd5b8235610e2781610df4565b946020939093013593505050565b60008083601f840112610e4757600080fd5b50813567ffffffffffffffff811115610e5f57600080fd5b602083019150836020828501011115610bb257600080fd5b60008060208385031215610e8a57600080fd5b823567ffffffffffffffff811115610ea157600080fd5b610ead85828601610e35565b90969095509350505050565b600065ffffffffffff808716835280861660208401525060606040830152826060830152828460808401376000608084840101526080601f19601f850116830101905095945050505050565b60006101608284031215610f1857600080fd5b50919050565b803565ffffffffffff81168114610f3457600080fd5b919050565b600080600060608486031215610f4e57600080fd5b833567ffffffffffffffff811115610f6557600080fd5b610f7186828701610f05565b935050610f8060208501610f1e565b9150610f8e60408501610f1e565b90509250925092565b60008060008060608587031215610fad57600080fd5b843560038110610fbc57600080fd5b9350602085013567ffffffffffffffff811115610fd857600080fd5b610fe487828801610e35565b9598909750949560400135949350505050565b60006020828403121561100957600080fd5b8135610ded81610df4565b60008060006060848603121561102957600080fd5b833567ffffffffffffffff81111561104057600080fd5b61104c86828701610f05565b9660208601359650604090950135949350505050565b604081526000835180604084015260005b818110156110905760208187018101516060868401015201611073565b506000606082850101526060601f19601f8301168401019150508260208301529392505050565b600080858511156110c757600080fd5b838611156110d457600080fd5b5050820193919092039150565b600080604083850312156110f457600080fd5b6110fd83610f1e565b915061110b60208401610f1e565b90509250929050565b6000808335601e1984360301811261112b57600080fd5b83018035915067ffffffffffffffff82111561114657600080fd5b602001915036819003821315610bb257600080fd5b8183823760009101908152919050565b60006020828403121561117d57600080fd5b5051919050565b634e487b7160e01b600052602160045260246000fd"; + +address constant VERIFYINGPAYMASTER_ADDRESS = 0xe1Fb85Ec54767ED89252751F6667CF566b16f1F0; diff --git a/src/test/smart-wallet/utils/AATestBase.sol b/src/test/smart-wallet/utils/AATestBase.sol new file mode 100644 index 000000000..74947587f --- /dev/null +++ b/src/test/smart-wallet/utils/AATestBase.sol @@ -0,0 +1,277 @@ +pragma solidity ^0.8.0; + +import { IEntryPoint } from "contracts/prebuilts/account/interface/IEntrypoint.sol"; +import { UserOperation } from "contracts/prebuilts/account/utils/UserOperation.sol"; +import { IAccount } from "contracts/prebuilts/account/interface/IAccount.sol"; +import { VERIFYINGPAYMASTER_BYTECODE, VERIFYINGPAYMASTER_ADDRESS, ENTRYPOINT_0_6_BYTECODE, CREATOR_0_6_BYTECODE } from "./AATestArtifacts.sol"; + +import "contracts/external-deps/openzeppelin/utils/cryptography/ECDSA.sol"; +import "forge-std/Test.sol"; +import "forge-std/console.sol"; + +import { MockERC20 } from "../../mocks/MockERC20.sol"; + +interface IVerifyingPaymaster { + function owner() external view returns (address); + + function getHash( + UserOperation calldata userOp, + uint48 validUntil, + uint48 validAfter + ) external view returns (bytes32); +} + +interface VmModified { + function cool(address _target) external; + + function keyExists(string calldata, string calldata) external returns (bool); + + function parseJsonKeys(string calldata json, string calldata key) external pure returns (string[] memory keys); +} + +uint256 constant OV_FIXED = 21000; +uint256 constant OV_PER_USEROP = 18300; +uint256 constant OV_PER_WORD = 4; +uint256 constant OV_PER_ZERO_BYTE = 4; +uint256 constant OV_PER_NONZERO_BYTE = 16; + +abstract contract AAGasProfileBase is Test { + string public name; + string public scenarioName; + uint256 sum; + string jsonObj; + IEntryPoint public entryPoint; + address payable public beneficiary; + IAccount public account; + address public owner; + uint256 public key; + IVerifyingPaymaster public paymaster; + address public verifier; + uint256 public verifierKey; + bool public writeGasProfile = false; + + function(UserOperation memory) internal view returns (bytes memory) paymasterData; + function(UserOperation memory) internal view returns (bytes memory) dummyPaymasterData; + + function initializeTest(string memory _name) internal { + writeGasProfile = vm.envOr("WRITE_GAS_PROFILE", false); + name = _name; + entryPoint = IEntryPoint(payable(address(0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789))); + vm.etch(address(entryPoint), ENTRYPOINT_0_6_BYTECODE); + vm.etch(0x7fc98430eAEdbb6070B35B39D798725049088348, CREATOR_0_6_BYTECODE); + beneficiary = payable(makeAddr("beneficiary")); + vm.deal(beneficiary, 1e18); + paymasterData = emptyPaymasterAndData; + dummyPaymasterData = emptyPaymasterAndData; + (verifier, verifierKey) = makeAddrAndKey("VERIFIER"); + paymaster = IVerifyingPaymaster(VERIFYINGPAYMASTER_ADDRESS); + vm.etch(address(paymaster), VERIFYINGPAYMASTER_BYTECODE); + vm.store(address(paymaster), bytes32(0), bytes32(uint256(uint160(verifier)))); + } + + function setAccount() internal { + (owner, key) = makeAddrAndKey("Owner"); + account = getAccountAddr(owner); + vm.deal(address(account), 1e18); + } + + function fillUserOp(bytes memory _data) internal view returns (UserOperation memory op) { + op.sender = address(account); + op.nonce = entryPoint.getNonce(address(account), 0); + if (address(account).code.length == 0) { + op.initCode = getInitCode(owner); + } + op.callData = _data; + op.callGasLimit = 1000000; + op.verificationGasLimit = 1000000; + op.preVerificationGas = 21000; + op.maxFeePerGas = 1; + op.maxPriorityFeePerGas = 1; + op.signature = getDummySig(op); + op.paymasterAndData = dummyPaymasterData(op); + op.preVerificationGas = calculatePreVerificationGas(op); + op.paymasterAndData = paymasterData(op); + op.signature = getSignature(op); + } + + function signUserOpHash(uint256 _key, UserOperation memory _op) internal view returns (bytes memory signature) { + bytes32 hash = entryPoint.getUserOpHash(_op); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(_key, ECDSA.toEthSignedMessageHash(hash)); + signature = abi.encodePacked(r, s, v); + } + + function executeUserOp(UserOperation memory _op, string memory _test, uint256 _value) internal { + UserOperation[] memory ops = new UserOperation[](1); + ops[0] = _op; + uint256 eth_before; + if (_op.paymasterAndData.length > 0) { + eth_before = entryPoint.balanceOf(address(paymaster)); + } else { + eth_before = entryPoint.balanceOf(address(account)) + address(account).balance; + } + // vm.cool to be introduced to foundry + //VmModified(address(vm)).cool(address(entryPoint)); + //VmModified(address(vm)).cool(address(account)); + entryPoint.handleOps(ops, beneficiary); + uint256 eth_after; + if (_op.paymasterAndData.length > 0) { + eth_after = entryPoint.balanceOf(address(paymaster)); + } else { + eth_after = entryPoint.balanceOf(address(account)) + address(account).balance + _value; + } + if (!writeGasProfile) { + console.log("case - %s", _test); + console.log(" gasUsed : ", eth_before - eth_after); + console.log(" calldatacost : ", calldataCost(pack(_op))); + } + if (writeGasProfile && bytes(scenarioName).length > 0) { + uint256 gasUsed = eth_before - eth_after; + vm.serializeUint(jsonObj, _test, gasUsed); + sum += gasUsed; + } + } + + function testCreation() internal { + UserOperation memory op = fillUserOp(fillData(address(0), 0, "")); + executeUserOp(op, "creation", 0); + } + + function testTransferNative(address _recipient, uint256 _amount) internal { + vm.skip(writeGasProfile); + createAccount(owner); + _amount = bound(_amount, 1, address(account).balance / 2); + UserOperation memory op = fillUserOp(fillData(_recipient, _amount, "")); + executeUserOp(op, "native", _amount); + } + + function testTransferNative() internal { + createAccount(owner); + uint256 amount = 5e17; + address recipient = makeAddr("recipient"); + UserOperation memory op = fillUserOp(fillData(recipient, amount, "")); + executeUserOp(op, "native", amount); + } + + function testTransferERC20() internal { + createAccount(owner); + MockERC20 mockERC20 = new MockERC20(); + mockERC20.mint(address(account), 1e18); + uint256 amount = 5e17; + address recipient = makeAddr("recipient"); + uint256 balance = mockERC20.balanceOf(recipient); + UserOperation memory op = fillUserOp( + fillData(address(mockERC20), 0, abi.encodeWithSelector(mockERC20.transfer.selector, recipient, amount)) + ); + executeUserOp(op, "erc20", 0); + assertEq(mockERC20.balanceOf(recipient), balance + amount); + } + + function testBenchmark1Vanila() external { + scenarioName = "vanila"; + jsonObj = string(abi.encodePacked(scenarioName, " ", name)); + testCreation(); + testTransferNative(); + testTransferERC20(); + if (writeGasProfile) { + string memory res = vm.serializeUint(jsonObj, "sum", sum); + console.log(res); + vm.writeJson(res, string.concat("./results/", scenarioName, "_", name, ".json")); + } + } + + function testBenchmark2Paymaster() external { + scenarioName = "paymaster"; + jsonObj = string(abi.encodePacked(scenarioName, " ", name)); + entryPoint.depositTo{ value: 100e18 }(address(paymaster)); + paymasterData = validatePaymasterAndData; + dummyPaymasterData = getDummyPaymasterAndData; + testCreation(); + testTransferNative(); + testTransferERC20(); + if (writeGasProfile) { + string memory res = vm.serializeUint(jsonObj, "sum", sum); + console.log(res); + vm.writeJson(res, string.concat("./results/", scenarioName, "_", name, ".json")); + } + } + + function testBenchmark3Deposit() external { + scenarioName = "deposit"; + jsonObj = string(abi.encodePacked(scenarioName, " ", name)); + entryPoint.depositTo{ value: 100e18 }(address(account)); + testCreation(); + testTransferNative(); + testTransferERC20(); + if (writeGasProfile) { + string memory res = vm.serializeUint(jsonObj, "sum", sum); + console.log(res); + vm.writeJson(res, string.concat("./results/", scenarioName, "_", name, ".json")); + } + } + + function emptyPaymasterAndData(UserOperation memory _op) internal pure returns (bytes memory ret) {} + + function validatePaymasterAndData(UserOperation memory _op) internal view returns (bytes memory ret) { + bytes32 hash = paymaster.getHash(_op, 0, 0); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(verifierKey, ECDSA.toEthSignedMessageHash(hash)); + ret = abi.encodePacked(address(paymaster), uint256(0), uint256(0), r, s, uint8(v)); + } + + function getDummyPaymasterAndData(UserOperation memory _op) internal view returns (bytes memory ret) { + ret = abi.encodePacked( + address(paymaster), + uint256(0), + uint256(0), + hex"fffffffffffffffffffffffffffffff0000000000000000000000000000000007aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1c" + ); + } + + function pack(UserOperation memory _op) internal pure returns (bytes memory) { + bytes memory packed = abi.encode( + _op.sender, + _op.nonce, + _op.initCode, + _op.callData, + _op.callGasLimit, + _op.verificationGasLimit, + _op.preVerificationGas, + _op.maxFeePerGas, + _op.maxPriorityFeePerGas, + _op.paymasterAndData, + _op.signature + ); + return packed; + } + + function calldataCost(bytes memory packed) internal view returns (uint256) { + uint256 cost = 0; + for (uint256 i = 0; i < packed.length; i++) { + if (packed[i] == 0) { + cost += OV_PER_ZERO_BYTE; + } else { + cost += OV_PER_NONZERO_BYTE; + } + } + return cost; + } + + // NOTE: this can vary depending on the bundler, this equation is referencing eth-infinitism bundler's pvg calculation + function calculatePreVerificationGas(UserOperation memory _op) internal view returns (uint256) { + bytes memory packed = pack(_op); + uint256 calculated = OV_FIXED + OV_PER_USEROP + (OV_PER_WORD * (packed.length + 31)) / 32; + calculated += calldataCost(packed); + return calculated; + } + + function createAccount(address _owner) internal virtual; + + function getSignature(UserOperation memory _op) internal view virtual returns (bytes memory); + + function getDummySig(UserOperation memory _op) internal pure virtual returns (bytes memory); + + function fillData(address _to, uint256 _amount, bytes memory _data) internal view virtual returns (bytes memory); + + function getAccountAddr(address _owner) internal view virtual returns (IAccount _account); + + function getInitCode(address _owner) internal view virtual returns (bytes memory); +} diff --git a/src/test/split-BTT/distribute-erc20/distribute.t.sol b/src/test/split-BTT/distribute-erc20/distribute.t.sol new file mode 100644 index 000000000..4c2d9c139 --- /dev/null +++ b/src/test/split-BTT/distribute-erc20/distribute.t.sol @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "../../utils/BaseTest.sol"; +import "@openzeppelin/contracts-upgradeable/token/ERC20/IERC20Upgradeable.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +contract MySplit is Split {} + +contract SplitTest_DistributeERC20 is BaseTest { + address payable public implementation; + address payable public proxy; + + address[] public payees; + uint256[] public shares; + + address internal caller; + string internal _contractURI; + + MySplit internal splitContract; + + event PaymentReleased(address to, uint256 amount); + + function setUp() public override { + super.setUp(); + + // Deploy implementation. + implementation = payable(address(new MySplit())); + + // create 5 payees and shares + for (uint160 i = 0; i < 5; i++) { + payees.push(getActor(i + 100)); + shares.push(i + 100); + } + + caller = getActor(1); + + // Deploy proxy pointing to implementaion. + vm.prank(deployer); + proxy = payable( + address( + new TWProxy( + implementation, + abi.encodeCall(Split.initialize, (deployer, CONTRACT_URI, forwarders(), payees, shares)) + ) + ) + ); + + splitContract = MySplit(proxy); + _contractURI = "ipfs://contracturi"; + + erc20.mint(address(splitContract), 100 ether); + } + + function test_distribute() public { + uint256[] memory pendingAmounts = new uint256[](payees.length); + + // get pending payments + for (uint256 i = 0; i < 5; i++) { + pendingAmounts[i] = splitContract.releasable(IERC20Upgradeable(address(erc20)), payees[i]); + } + + // distribute + splitContract.distribute(IERC20Upgradeable(address(erc20))); + + uint256 totalPaid; + for (uint256 i = 0; i < 5; i++) { + totalPaid += pendingAmounts[i]; + + assertEq(splitContract.released(IERC20Upgradeable(address(erc20)), payees[i]), pendingAmounts[i]); + assertEq(erc20.balanceOf(payees[i]), pendingAmounts[i]); + } + assertEq(splitContract.totalReleased(IERC20Upgradeable(address(erc20))), totalPaid); + + assertEq(erc20.balanceOf(address(splitContract)), 100 ether - totalPaid); + } +} diff --git a/src/test/split-BTT/distribute-erc20/distribute.tree b/src/test/split-BTT/distribute-erc20/distribute.tree new file mode 100644 index 000000000..320921cca --- /dev/null +++ b/src/test/split-BTT/distribute-erc20/distribute.tree @@ -0,0 +1,6 @@ +distribute() +├── it should update released mapping for all payees account by respective pending payments ✅ +├── it should update total released by total pending payments ✅ +├── it should send correct pending payment amounts of erc20 tokens to each account ✅ +├── it should reduce balance of contract by total paid in this call ✅ + \ No newline at end of file diff --git a/src/test/split-BTT/distribute-native-token/distribute.t.sol b/src/test/split-BTT/distribute-native-token/distribute.t.sol new file mode 100644 index 000000000..fd524c40d --- /dev/null +++ b/src/test/split-BTT/distribute-native-token/distribute.t.sol @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "../../utils/BaseTest.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +contract MySplit is Split {} + +contract SplitTest_DistributeNativeToken is BaseTest { + address payable public implementation; + address payable public proxy; + + address[] public payees; + uint256[] public shares; + + address internal caller; + string internal _contractURI; + + MySplit internal splitContract; + + event PaymentReleased(address to, uint256 amount); + + function setUp() public override { + super.setUp(); + + // Deploy implementation. + implementation = payable(address(new MySplit())); + + // create 5 payees and shares + for (uint160 i = 0; i < 5; i++) { + payees.push(getActor(i + 100)); + shares.push(i + 100); + } + + caller = getActor(1); + + // Deploy proxy pointing to implementaion. + vm.prank(deployer); + proxy = payable( + address( + new TWProxy( + implementation, + abi.encodeCall(Split.initialize, (deployer, CONTRACT_URI, forwarders(), payees, shares)) + ) + ) + ); + + splitContract = MySplit(proxy); + _contractURI = "ipfs://contracturi"; + + vm.deal(address(splitContract), 100 ether); + } + + function test_distribute() public { + uint256[] memory pendingAmounts = new uint256[](payees.length); + + // get pending payments + for (uint256 i = 0; i < 5; i++) { + pendingAmounts[i] = splitContract.releasable(payees[i]); + } + + // distribute + splitContract.distribute(); + + uint256 totalPaid; + for (uint256 i = 0; i < 5; i++) { + totalPaid += pendingAmounts[i]; + + assertEq(splitContract.released(payees[i]), pendingAmounts[i]); + assertEq(payees[i].balance, pendingAmounts[i]); + } + assertEq(splitContract.totalReleased(), totalPaid); + + assertEq(address(splitContract).balance, 100 ether - totalPaid); + } +} diff --git a/src/test/split-BTT/distribute-native-token/distribute.tree b/src/test/split-BTT/distribute-native-token/distribute.tree new file mode 100644 index 000000000..d75f4cc53 --- /dev/null +++ b/src/test/split-BTT/distribute-native-token/distribute.tree @@ -0,0 +1,6 @@ +distribute() +├── it should update released mapping for all payees account by respective pending payments ✅ +├── it should update total released by total pending payments ✅ +├── it should send correct pending payment amounts of native tokens to each account ✅ +├── it should reduce balance of contract by total paid in this call ✅ + \ No newline at end of file diff --git a/src/test/split-BTT/initialize/initialize.t.sol b/src/test/split-BTT/initialize/initialize.t.sol new file mode 100644 index 000000000..bc75ae266 --- /dev/null +++ b/src/test/split-BTT/initialize/initialize.t.sol @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "../../utils/BaseTest.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +contract MySplit is Split {} + +contract SplitTest_Initialize is BaseTest { + address payable public implementation; + address payable public proxy; + + address[] public payees; + uint256[] public shares; + + event RoleGranted(bytes32 indexed role, address indexed account, address indexed sender); + + function setUp() public override { + super.setUp(); + + // create 5 payees and shares + for (uint160 i = 0; i < 5; i++) { + payees.push(getActor(i + 100)); + shares.push(i + 100); + } + + // Deploy implementation. + implementation = payable(address(new MySplit())); + + // Deploy proxy pointing to implementaion. + vm.prank(deployer); + proxy = payable( + address( + new TWProxy( + implementation, + abi.encodeCall(Split.initialize, (deployer, CONTRACT_URI, forwarders(), payees, shares)) + ) + ) + ); + } + + function test_initialize_initializingImplementation() public { + vm.expectRevert("Initializable: contract is already initialized"); + Split(implementation).initialize(deployer, CONTRACT_URI, forwarders(), payees, shares); + } + + modifier whenNotImplementation() { + _; + } + + function test_initialize_proxyAlreadyInitialized() public whenNotImplementation { + vm.expectRevert("Initializable: contract is already initialized"); + MySplit(proxy).initialize(deployer, CONTRACT_URI, forwarders(), payees, shares); + } + + modifier whenProxyNotInitialized() { + proxy = payable(address(new TWProxy(implementation, ""))); + _; + } + + function test_initialize_payeeLengthZero() public whenNotImplementation whenProxyNotInitialized { + address[] memory _payees; + uint256[] memory _shares; + vm.expectRevert("PaymentSplitter: no payees"); + MySplit(proxy).initialize(deployer, CONTRACT_URI, forwarders(), _payees, _shares); + } + + modifier whenPayeeLengthNotZero() { + _; + } + + function test_initialize_payeesSharesUnequalLength() + public + whenNotImplementation + whenProxyNotInitialized + whenPayeeLengthNotZero + { + uint256[] memory _shares; + vm.expectRevert("PaymentSplitter: payees and shares length mismatch"); + MySplit(proxy).initialize(deployer, CONTRACT_URI, forwarders(), payees, _shares); + } + + modifier whenEqualLengths() { + _; + } + + function test_initialize() + public + whenNotImplementation + whenProxyNotInitialized + whenPayeeLengthNotZero + whenEqualLengths + { + MySplit(proxy).initialize(deployer, CONTRACT_URI, forwarders(), payees, shares); + + // check state + MySplit splitContract = MySplit(proxy); + + address[] memory _trustedForwarders = forwarders(); + for (uint256 i = 0; i < _trustedForwarders.length; i++) { + assertTrue(splitContract.isTrustedForwarder(_trustedForwarders[i])); + } + + uint256 totalShares; + for (uint160 i = 0; i < 5; i++) { + uint256 _shares = splitContract.shares(payees[i]); + assertEq(_shares, shares[i]); + + totalShares += _shares; + } + assertEq(totalShares, splitContract.totalShares()); + assertEq(splitContract.payeeCount(), payees.length); + assertEq(splitContract.contractURI(), CONTRACT_URI); + assertTrue(splitContract.hasRole(bytes32(0x00), deployer)); + } + + function test_initialize_event_RoleGranted_DefaultAdmin() + public + whenNotImplementation + whenProxyNotInitialized + whenPayeeLengthNotZero + whenEqualLengths + { + bytes32 _defaultAdminRole = bytes32(0x00); + vm.prank(deployer); + vm.expectEmit(true, true, true, false); + emit RoleGranted(_defaultAdminRole, deployer, deployer); + MySplit(proxy).initialize(deployer, CONTRACT_URI, forwarders(), payees, shares); + } +} diff --git a/src/test/split-BTT/initialize/initialize.tree b/src/test/split-BTT/initialize/initialize.tree new file mode 100644 index 000000000..4647d7f70 --- /dev/null +++ b/src/test/split-BTT/initialize/initialize.tree @@ -0,0 +1,25 @@ +initialize( + address _defaultAdmin, + string memory _contractURI, + address[] memory _trustedForwarders, + address[] memory _payees, + uint256[] memory _shares +) +├── when initializing the implementation contract (not proxy) +│ └── it should revert ✅ +└── when it is a proxy to the implementation + └── when it is already initialized + │ └── it should revert ✅ + └── when it is not initialized + └── when `_payees` length is zero + │ └── it should revert ✅ + └── `_payees` length is not zero + └── when `_payees` length not equal to `_shares` length + │ └── it should revert ✅ + └── when `_payees` length equal to `_shares` length + └── it should set trustedForwarder mapping to true for all addresses in `_trustedForwarders` ✅ + └── it should correctly save `_payees` and `_shares` in state ✅ + └── it should set contractURI to `_contractURI` param value ✅ + └── it should grant 0x00 (DEFAULT_ADMIN_ROLE) to `_defaultAdmin` address ✅ + └── it should emit RoleGranted event ✅ + diff --git a/src/test/split-BTT/other-functions/other.t.sol b/src/test/split-BTT/other-functions/other.t.sol new file mode 100644 index 000000000..2bf87b871 --- /dev/null +++ b/src/test/split-BTT/other-functions/other.t.sol @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "../../utils/BaseTest.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +contract MySplit is Split {} + +contract SplitTest_OtherFunctions is BaseTest { + address payable public implementation; + address payable public proxy; + + address[] public payees; + uint256[] public shares; + + address internal caller; + string internal _contractURI; + + MySplit internal splitContract; + + function setUp() public override { + super.setUp(); + + // Deploy implementation. + implementation = payable(address(new MySplit())); + + // create 5 payees and shares + for (uint160 i = 0; i < 5; i++) { + payees.push(getActor(i + 100)); + shares.push(i + 100); + } + + caller = getActor(1); + + // Deploy proxy pointing to implementaion. + vm.prank(deployer); + proxy = payable( + address( + new TWProxy( + implementation, + abi.encodeCall(Split.initialize, (deployer, CONTRACT_URI, forwarders(), payees, shares)) + ) + ) + ); + + splitContract = MySplit(proxy); + _contractURI = "ipfs://contracturi"; + } + + function test_contractType() public { + assertEq(splitContract.contractType(), bytes32("Split")); + } + + function test_contractVersion() public { + assertEq(splitContract.contractVersion(), uint8(1)); + } +} diff --git a/src/test/split-BTT/other-functions/other.tree b/src/test/split-BTT/other-functions/other.tree new file mode 100644 index 000000000..dd1798caa --- /dev/null +++ b/src/test/split-BTT/other-functions/other.tree @@ -0,0 +1,5 @@ +contractType() +├── it should return bytes32("TokenERC721") ✅ + +contractVersion() +├── it should return uint8(1) ✅ diff --git a/src/test/split-BTT/release-erc20/release.t.sol b/src/test/split-BTT/release-erc20/release.t.sol new file mode 100644 index 000000000..c5499f664 --- /dev/null +++ b/src/test/split-BTT/release-erc20/release.t.sol @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "../../utils/BaseTest.sol"; +import "@openzeppelin/contracts-upgradeable/token/ERC20/IERC20Upgradeable.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +contract MySplit is Split {} + +contract SplitTest_ReleaseERC20 is BaseTest { + address payable public implementation; + address payable public proxy; + + address[] public payees; + uint256[] public shares; + + address internal caller; + string internal _contractURI; + + MySplit internal splitContract; + + event ERC20PaymentReleased(IERC20Upgradeable indexed token, address to, uint256 amount); + + function setUp() public override { + super.setUp(); + + // Deploy implementation. + implementation = payable(address(new MySplit())); + + // create 5 payees and shares + for (uint160 i = 0; i < 5; i++) { + payees.push(getActor(i + 100)); + shares.push(i + 100); + } + + caller = getActor(1); + + // Deploy proxy pointing to implementaion. + vm.prank(deployer); + proxy = payable( + address( + new TWProxy( + implementation, + abi.encodeCall(Split.initialize, (deployer, CONTRACT_URI, forwarders(), payees, shares)) + ) + ) + ); + + splitContract = MySplit(proxy); + _contractURI = "ipfs://contracturi"; + } + + function test_release_zeroShares() public { + vm.expectRevert("PaymentSplitter: account has no shares"); + splitContract.release(IERC20Upgradeable(address(erc20)), payable(address(0x123))); // arbitrary address + } + + modifier whenNonZeroShares() { + _; + } + + function test_release_pendingPaymentZero() public { + vm.expectRevert("PaymentSplitter: account is not due payment"); + splitContract.release(IERC20Upgradeable(address(erc20)), payable(payees[1])); + } + + modifier whenPendingPaymentNonZero() { + erc20.mint(address(splitContract), 100 ether); + _; + } + + function test_release() public whenPendingPaymentNonZero { + address _payeeOne = payees[1]; // select a payee from the array + uint256 pendingPayment = splitContract.releasable(IERC20Upgradeable(address(erc20)), _payeeOne); + + splitContract.release(IERC20Upgradeable(address(erc20)), payable(_payeeOne)); + + uint256 totalReleased = splitContract.totalReleased(IERC20Upgradeable(address(erc20))); + assertEq(splitContract.released(IERC20Upgradeable(address(erc20)), _payeeOne), pendingPayment); + assertEq(totalReleased, pendingPayment); + assertEq(erc20.balanceOf(_payeeOne), pendingPayment); + + // check for another payee + address _payeeThree = payees[3]; + pendingPayment = splitContract.releasable(IERC20Upgradeable(address(erc20)), _payeeThree); + + splitContract.release(IERC20Upgradeable(address(erc20)), payable(_payeeThree)); + + assertEq(splitContract.released(IERC20Upgradeable(address(erc20)), _payeeThree), pendingPayment); + assertEq(splitContract.totalReleased(IERC20Upgradeable(address(erc20))), totalReleased + pendingPayment); + assertEq(erc20.balanceOf(_payeeThree), pendingPayment); + + assertEq( + erc20.balanceOf(address(splitContract)), + 100 ether - erc20.balanceOf(_payeeOne) - erc20.balanceOf(_payeeThree) + ); + } + + function test_release_event_PaymentReleased() public whenPendingPaymentNonZero { + address _payeeOne = payees[1]; // select a payee from the array + uint256 pendingPayment = splitContract.releasable(IERC20Upgradeable(address(erc20)), _payeeOne); + + vm.expectEmit(true, false, false, true); + emit ERC20PaymentReleased(IERC20Upgradeable(address(erc20)), _payeeOne, pendingPayment); + splitContract.release(IERC20Upgradeable(address(erc20)), payable(_payeeOne)); + } +} diff --git a/src/test/split-BTT/release-erc20/release.tree b/src/test/split-BTT/release-erc20/release.tree new file mode 100644 index 000000000..217ea3b6c --- /dev/null +++ b/src/test/split-BTT/release-erc20/release.tree @@ -0,0 +1,12 @@ +release(address payable account) +├── when account has zero shares + │ └── it should revert ✅ + └── when account has non-zero shares + └── when pending payment is zero + │ └── it should revert ✅ + └── when pending payment is not zero + └── it should update released mapping for the account by pending payment ✅ + └── it should update total released by pending payment ✅ + └── it should send pending payment amount of erc20 token to account ✅ + └── it should emit ERC20PaymentReleased event ✅ + \ No newline at end of file diff --git a/src/test/split-BTT/release-native-token/release.t.sol b/src/test/split-BTT/release-native-token/release.t.sol new file mode 100644 index 000000000..18210e0da --- /dev/null +++ b/src/test/split-BTT/release-native-token/release.t.sol @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "../../utils/BaseTest.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +contract MySplit is Split {} + +contract SplitTest_ReleaseNativeToken is BaseTest { + address payable public implementation; + address payable public proxy; + + address[] public payees; + uint256[] public shares; + + address internal caller; + string internal _contractURI; + + MySplit internal splitContract; + + event PaymentReleased(address to, uint256 amount); + + function setUp() public override { + super.setUp(); + + // Deploy implementation. + implementation = payable(address(new MySplit())); + + // create 5 payees and shares + for (uint160 i = 0; i < 5; i++) { + payees.push(getActor(i + 100)); + shares.push(i + 100); + } + + caller = getActor(1); + + // Deploy proxy pointing to implementaion. + vm.prank(deployer); + proxy = payable( + address( + new TWProxy( + implementation, + abi.encodeCall(Split.initialize, (deployer, CONTRACT_URI, forwarders(), payees, shares)) + ) + ) + ); + + splitContract = MySplit(proxy); + _contractURI = "ipfs://contracturi"; + } + + function test_release_zeroShares() public { + vm.expectRevert("PaymentSplitter: account has no shares"); + splitContract.release(payable(address(0x123))); // arbitrary address + } + + modifier whenNonZeroShares() { + _; + } + + function test_release_pendingPaymentZero() public { + vm.expectRevert("PaymentSplitter: account is not due payment"); + splitContract.release(payable(payees[1])); + } + + modifier whenPendingPaymentNonZero() { + vm.deal(address(splitContract), 100 ether); + _; + } + + function test_release() public whenPendingPaymentNonZero { + address _payeeOne = payees[1]; // select a payee from the array + uint256 pendingPayment = splitContract.releasable(_payeeOne); + + splitContract.release(payable(_payeeOne)); + + uint256 totalReleased = splitContract.totalReleased(); + assertEq(splitContract.released(_payeeOne), pendingPayment); + assertEq(totalReleased, pendingPayment); + assertEq(_payeeOne.balance, pendingPayment); + + // check for another payee + address _payeeThree = payees[3]; + pendingPayment = splitContract.releasable(_payeeThree); + + splitContract.release(payable(_payeeThree)); + + assertEq(splitContract.released(_payeeThree), pendingPayment); + assertEq(splitContract.totalReleased(), totalReleased + pendingPayment); + assertEq(_payeeThree.balance, pendingPayment); + + assertEq(address(splitContract).balance, 100 ether - _payeeOne.balance - _payeeThree.balance); + } + + function test_release_event_PaymentReleased() public whenPendingPaymentNonZero { + address _payeeOne = payees[1]; // select a payee from the array + uint256 pendingPayment = splitContract.releasable(_payeeOne); + + vm.expectEmit(false, false, false, true); + emit PaymentReleased(_payeeOne, pendingPayment); + splitContract.release(payable(_payeeOne)); + } +} diff --git a/src/test/split-BTT/release-native-token/release.tree b/src/test/split-BTT/release-native-token/release.tree new file mode 100644 index 000000000..afa44e86d --- /dev/null +++ b/src/test/split-BTT/release-native-token/release.tree @@ -0,0 +1,12 @@ +release(address payable account) +├── when account has zero shares + │ └── it should revert ✅ + └── when account has non-zero shares + └── when pending payment is zero + │ └── it should revert ✅ + └── when pending payment is not zero + └── it should update released mapping for the account by pending payment ✅ + └── it should update total released by pending payment ✅ + └── it should send pending payment amount of native tokens to account ✅ + └── it should emit PaymentReleased event ✅ + \ No newline at end of file diff --git a/src/test/split-BTT/set-contract-uri/setContractURI.t.sol b/src/test/split-BTT/set-contract-uri/setContractURI.t.sol new file mode 100644 index 000000000..af3cc85d1 --- /dev/null +++ b/src/test/split-BTT/set-contract-uri/setContractURI.t.sol @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "../../utils/BaseTest.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +contract MySplit is Split {} + +contract SplitTest_SetContractURI is BaseTest { + address payable public implementation; + address payable public proxy; + + address[] public payees; + uint256[] public shares; + + address internal caller; + string internal _contractURI; + + MySplit internal splitContract; + + function setUp() public override { + super.setUp(); + + // Deploy implementation. + implementation = payable(address(new MySplit())); + + // create 5 payees and shares + for (uint160 i = 0; i < 5; i++) { + payees.push(getActor(i + 100)); + shares.push(i + 100); + } + + caller = getActor(1); + + // Deploy proxy pointing to implementaion. + vm.prank(deployer); + proxy = payable( + address( + new TWProxy( + implementation, + abi.encodeCall(Split.initialize, (deployer, CONTRACT_URI, forwarders(), payees, shares)) + ) + ) + ); + + splitContract = MySplit(proxy); + _contractURI = "ipfs://contracturi"; + } + + function test_setContractURI_callerNotAuthorized() public { + vm.prank(address(caller)); + vm.expectRevert( + abi.encodePacked( + "AccessControl: account ", + Strings.toHexString(uint160(caller), 20), + " is missing role ", + Strings.toHexString(uint256(0), 32) + ) + ); + splitContract.setContractURI(_contractURI); + } + + modifier whenCallerAuthorized() { + vm.prank(deployer); + splitContract.grantRole(bytes32(0x00), caller); + _; + } + + function test_setContractURI_empty() public whenCallerAuthorized { + vm.prank(address(caller)); + splitContract.setContractURI(""); + + // get contract uri + assertEq(splitContract.contractURI(), ""); + } + + function test_setContractURI_notEmpty() public whenCallerAuthorized { + vm.prank(address(caller)); + splitContract.setContractURI(_contractURI); + + // get contract uri + assertEq(splitContract.contractURI(), _contractURI); + } +} diff --git a/src/test/split-BTT/set-contract-uri/setContractURI.tree b/src/test/split-BTT/set-contract-uri/setContractURI.tree new file mode 100644 index 000000000..8fc480b19 --- /dev/null +++ b/src/test/split-BTT/set-contract-uri/setContractURI.tree @@ -0,0 +1,8 @@ +setContractURI(string calldata _uri) +├── when caller not authorized + │ └── it should revert ✅ + └── when caller is authorized + └── when `uri` is empty + │ └── it should update contract URI to empty string ✅ + └── when `uri` is not empty + └── it should update contract URI to `_uri` ✅ \ No newline at end of file diff --git a/src/test/staking/EditionStake.t.sol b/src/test/staking/EditionStake.t.sol new file mode 100644 index 000000000..0df3b9e91 --- /dev/null +++ b/src/test/staking/EditionStake.t.sol @@ -0,0 +1,1153 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { EditionStake } from "contracts/prebuilts/staking/EditionStake.sol"; + +// Test imports + +import "../utils/BaseTest.sol"; + +contract EditionStakeTest is BaseTest { + EditionStake internal stakeContract; + + address internal stakerOne; + address internal stakerTwo; + + uint256 internal defaultTimeUnit; + uint256 internal defaultRewardsPerUnitTime; + + function setUp() public override { + super.setUp(); + + defaultTimeUnit = 60; + defaultRewardsPerUnitTime = 1; + + stakerOne = address(0x345); + stakerTwo = address(0x567); + + erc1155.mint(stakerOne, 0, 100); // mint 100 tokens with id 0 to stakerOne + erc1155.mint(stakerOne, 1, 100); // mint 100 tokens with id 1 to stakerOne + + erc1155.mint(stakerTwo, 0, 100); // mint 100 tokens with id 0 to stakerTwo + erc1155.mint(stakerTwo, 1, 100); // mint 100 tokens with id 1 to stakerTwo + + erc20.mint(deployer, 1000 ether); // mint reward tokens to contract admin + + stakeContract = EditionStake(payable(getContract("EditionStake"))); + + // set approvals + vm.prank(stakerOne); + erc1155.setApprovalForAll(address(stakeContract), true); + + vm.prank(stakerTwo); + erc1155.setApprovalForAll(address(stakeContract), true); + + vm.startPrank(deployer); + erc20.approve(address(stakeContract), type(uint256).max); + stakeContract.depositRewardTokens(100 ether); + // erc20.transfer(address(stakeContract), 100 ether); + vm.stopPrank(); + assertEq(stakeContract.getRewardTokenBalance(), 100 ether); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: Stake + - with default time-unit and rewards + - different token-ids staked by stakers + //////////////////////////////////////////////////////////////*/ + + function test_state_stake_defaults_differentTokens() public { + //================ first staker ====================== + vm.warp(1); + + // stake 50 tokens with token-id 0 + vm.prank(stakerOne); + stakeContract.stake(0, 50); + uint256 timeOfLastUpdate_one = block.timestamp; + + // check balances/ownership of staked tokens + assertEq(erc1155.balanceOf(address(stakeContract), 0), 50); + assertEq(erc1155.balanceOf(address(stakerOne), 0), 50); + + // check available rewards right after staking + (uint256 _amountStaked, uint256 _availableRewards) = stakeContract.getStakeInfoForToken(0, stakerOne); + + assertEq(_amountStaked, 50); + assertEq(_availableRewards, 0); + + //=================== warp timestamp to calculate rewards + vm.roll(100); + vm.warp(1000); + + // check available rewards after warp + (, _availableRewards) = stakeContract.getStakeInfoForToken(0, stakerOne); + + assertEq( + _availableRewards, + ((((block.timestamp - timeOfLastUpdate_one) * 50) * defaultRewardsPerUnitTime) / defaultTimeUnit) + ); + + //================ second staker ====================== + vm.roll(200); + vm.warp(2000); + + // stake 20 tokens with token-id 1 + vm.prank(stakerTwo); + stakeContract.stake(1, 20); + uint256 timeOfLastUpdate_two = block.timestamp; + + // check balances/ownership of staked tokens + assertEq(erc1155.balanceOf(address(stakeContract), 1), 20); + assertEq(erc1155.balanceOf(address(stakerTwo), 1), 80); + + // check available rewards right after staking + (_amountStaked, _availableRewards) = stakeContract.getStakeInfoForToken(1, stakerTwo); + + assertEq(_amountStaked, 20); + assertEq(_availableRewards, 0); + + //=================== warp timestamp to calculate rewards + vm.roll(300); + vm.warp(3000); + + // check available rewards for stakerOne + (, _availableRewards) = stakeContract.getStakeInfoForToken(0, stakerOne); + + assertEq( + _availableRewards, + ((((block.timestamp - timeOfLastUpdate_one) * 50) * defaultRewardsPerUnitTime) / defaultTimeUnit) + ); + + // check available rewards for stakerTwo + (, _availableRewards) = stakeContract.getStakeInfoForToken(1, stakerTwo); + + assertEq( + _availableRewards, + ((((block.timestamp - timeOfLastUpdate_two) * 20) * defaultRewardsPerUnitTime) / defaultTimeUnit) + ); + } + + function test_revert_stake_stakingZeroTokens() public { + // stake 0 tokens + + vm.prank(stakerOne); + vm.expectRevert("Staking 0 tokens"); + stakeContract.stake(0, 0); + } + + function test_revert_stake_notBalanceOrApproved() public { + // stake unowned tokens + vm.prank(stakerOne); + vm.expectRevert("ERC1155: insufficient balance for transfer"); + stakeContract.stake(2, 10); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: Stake + - with default time-unit and rewards + - same token-id staked by stakers + //////////////////////////////////////////////////////////////*/ + + function test_state_stake_defaults_sameToken() public { + //================ first staker ====================== + vm.warp(1); + + // stake 50 tokens with token-id 0 + vm.prank(stakerOne); + stakeContract.stake(0, 50); + uint256 timeOfLastUpdate_one = block.timestamp; + + // check balances/ownership of staked tokens + assertEq(erc1155.balanceOf(address(stakeContract), 0), 50); + assertEq(erc1155.balanceOf(address(stakerOne), 0), 50); + + // check available rewards right after staking + (uint256 _amountStaked, uint256 _availableRewards) = stakeContract.getStakeInfoForToken(0, stakerOne); + + assertEq(_amountStaked, 50); + assertEq(_availableRewards, 0); + + //=================== warp timestamp to calculate rewards + vm.roll(100); + vm.warp(1000); + + // check available rewards after warp + (, _availableRewards) = stakeContract.getStakeInfoForToken(0, stakerOne); + + assertEq( + _availableRewards, + ((((block.timestamp - timeOfLastUpdate_one) * 50) * defaultRewardsPerUnitTime) / defaultTimeUnit) + ); + + //================ second staker ====================== + vm.roll(200); + vm.warp(2000); + + // stake 20 tokens with token-id 0 + vm.prank(stakerTwo); + stakeContract.stake(0, 20); + uint256 timeOfLastUpdate_two = block.timestamp; + + // check balances/ownership of staked tokens + assertEq(erc1155.balanceOf(address(stakeContract), 0), 20 + 50); // sum of staked tokens by both stakers + assertEq(erc1155.balanceOf(address(stakerTwo), 0), 80); + + // check available rewards right after staking + (_amountStaked, _availableRewards) = stakeContract.getStakeInfoForToken(0, stakerTwo); + + assertEq(_amountStaked, 20); + assertEq(_availableRewards, 0); + + //=================== warp timestamp to calculate rewards + vm.roll(300); + vm.warp(3000); + + // check available rewards for stakerOne + (, _availableRewards) = stakeContract.getStakeInfoForToken(0, stakerOne); + + assertEq( + _availableRewards, + ((((block.timestamp - timeOfLastUpdate_one) * 50) * defaultRewardsPerUnitTime) / defaultTimeUnit) + ); + + // check available rewards for stakerTwo + (, _availableRewards) = stakeContract.getStakeInfoForToken(0, stakerTwo); + + assertEq( + _availableRewards, + ((((block.timestamp - timeOfLastUpdate_two) * 20) * defaultRewardsPerUnitTime) / defaultTimeUnit) + ); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: claimRewards + - default timeUnit and rewards + - different token-ids staked by stakers + //////////////////////////////////////////////////////////////*/ + + function test_state_claimRewards_defaults_differentTokens() public { + //================ setup stakerOne ====================== + vm.warp(1); + + // stake 50 tokens with token-id 0 + vm.prank(stakerOne); + stakeContract.stake(0, 50); + uint256 timeOfLastUpdate_one = block.timestamp; + + //=================== warp timestamp to claim rewards + vm.roll(100); + vm.warp(1000); + + uint256 rewardBalanceBefore = stakeContract.getRewardTokenBalance(); + vm.prank(stakerOne); + stakeContract.claimRewards(0); + uint256 rewardBalanceAfter = stakeContract.getRewardTokenBalance(); + + // check reward balances + assertEq( + erc20.balanceOf(stakerOne), + ((((block.timestamp - timeOfLastUpdate_one) * 50) * defaultRewardsPerUnitTime) / defaultTimeUnit) + ); + assertEq( + rewardBalanceAfter, + rewardBalanceBefore - + ((((block.timestamp - timeOfLastUpdate_one) * 50) * defaultRewardsPerUnitTime) / defaultTimeUnit) + ); + + // check available rewards after claiming + (uint256 _amountStaked, uint256 _availableRewards) = stakeContract.getStakeInfoForToken(0, stakerOne); + + assertEq(_amountStaked, 50); + assertEq(_availableRewards, 0); + + //================ setup stakerTwo ====================== + + // stake 20 tokens with token-id 1 + vm.prank(stakerTwo); + stakeContract.stake(1, 20); + uint256 timeOfLastUpdate_two = block.timestamp; + + //=================== warp timestamp to claim rewards + vm.roll(200); + vm.warp(2000); + + rewardBalanceBefore = stakeContract.getRewardTokenBalance(); + vm.prank(stakerTwo); + stakeContract.claimRewards(1); + rewardBalanceAfter = stakeContract.getRewardTokenBalance(); + + // check reward balances + assertEq( + erc20.balanceOf(stakerTwo), + ((((block.timestamp - timeOfLastUpdate_two) * 20) * defaultRewardsPerUnitTime) / defaultTimeUnit) + ); + assertEq( + rewardBalanceAfter, + rewardBalanceBefore - + ((((block.timestamp - timeOfLastUpdate_two) * 20) * defaultRewardsPerUnitTime) / defaultTimeUnit) + ); + + // check available rewards after claiming -- stakerTwo + (_amountStaked, _availableRewards) = stakeContract.getStakeInfoForToken(1, stakerTwo); + assertEq(_amountStaked, 20); + assertEq(_availableRewards, 0); + + // check available rewards -- stakerOne + (_amountStaked, _availableRewards) = stakeContract.getStakeInfoForToken(0, stakerOne); + assertEq(_amountStaked, 50); + assertEq( + _availableRewards, + ((((block.timestamp - timeOfLastUpdate_two) * 50) * defaultRewardsPerUnitTime) / defaultTimeUnit) + ); + } + + function test_revert_claimRewards_noRewards() public { + vm.warp(1); + + vm.prank(stakerOne); + stakeContract.stake(0, 50); + + //=================== try to claim rewards for a different token + + vm.prank(stakerOne); + vm.expectRevert("No rewards"); + stakeContract.claimRewards(1); + + //=================== try to claim rewards in same block + + vm.prank(stakerOne); + vm.expectRevert("No rewards"); + stakeContract.claimRewards(0); + + //======= withdraw tokens and claim rewards + vm.roll(100); + vm.warp(1000); + + vm.prank(stakerOne); + stakeContract.withdraw(0, 50); + vm.prank(stakerOne); + stakeContract.claimRewards(0); + + //===== try to claim rewards again + vm.roll(200); + vm.warp(2000); + vm.prank(stakerOne); + vm.expectRevert("No rewards"); + stakeContract.claimRewards(0); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: claimRewards + - default timeUnit and rewards + - same token-ids staked by stakers + //////////////////////////////////////////////////////////////*/ + + function test_state_claimRewards_defaults_sameToken() public { + //================ setup stakerOne ====================== + vm.warp(1); + + // stake 50 tokens with token-id 0 + vm.prank(stakerOne); + stakeContract.stake(0, 50); + uint256 timeOfLastUpdate_one = block.timestamp; + + //=================== warp timestamp to claim rewards + vm.roll(100); + vm.warp(1000); + + uint256 rewardBalanceBefore = stakeContract.getRewardTokenBalance(); + vm.prank(stakerOne); + stakeContract.claimRewards(0); + uint256 rewardBalanceAfter = stakeContract.getRewardTokenBalance(); + + // check reward balances + assertEq( + erc20.balanceOf(stakerOne), + ((((block.timestamp - timeOfLastUpdate_one) * 50) * defaultRewardsPerUnitTime) / defaultTimeUnit) + ); + assertEq( + rewardBalanceAfter, + rewardBalanceBefore - + ((((block.timestamp - timeOfLastUpdate_one) * 50) * defaultRewardsPerUnitTime) / defaultTimeUnit) + ); + + // check available rewards after claiming + (uint256 _amountStaked, uint256 _availableRewards) = stakeContract.getStakeInfoForToken(0, stakerOne); + + assertEq(_amountStaked, 50); + assertEq(_availableRewards, 0); + + //================ setup stakerTwo ====================== + + // stake 20 tokens with token-id 1 + vm.prank(stakerTwo); + stakeContract.stake(0, 20); + uint256 timeOfLastUpdate_two = block.timestamp; + + //=================== warp timestamp to claim rewards + vm.roll(200); + vm.warp(2000); + + rewardBalanceBefore = stakeContract.getRewardTokenBalance(); + vm.prank(stakerTwo); + stakeContract.claimRewards(0); + rewardBalanceAfter = stakeContract.getRewardTokenBalance(); + + // check reward balances + assertEq( + erc20.balanceOf(stakerTwo), + ((((block.timestamp - timeOfLastUpdate_two) * 20) * defaultRewardsPerUnitTime) / defaultTimeUnit) + ); + assertEq( + rewardBalanceAfter, + rewardBalanceBefore - + ((((block.timestamp - timeOfLastUpdate_two) * 20) * defaultRewardsPerUnitTime) / defaultTimeUnit) + ); + + // check available rewards after claiming -- stakerTwo + (_amountStaked, _availableRewards) = stakeContract.getStakeInfoForToken(0, stakerTwo); + assertEq(_amountStaked, 20); + assertEq(_availableRewards, 0); + + // check available rewards -- stakerOne + (_amountStaked, _availableRewards) = stakeContract.getStakeInfoForToken(0, stakerOne); + assertEq(_amountStaked, 50); + assertEq( + _availableRewards, + ((((block.timestamp - timeOfLastUpdate_two) * 50) * defaultRewardsPerUnitTime) / defaultTimeUnit) + ); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: stake conditions + - set rewards for token0 + - default time unit + //////////////////////////////////////////////////////////////*/ + + function test_state_setRewardsPerUnitTime_token0() public { + // set value and check + uint256 rewardsPerUnitTime = 50; + vm.prank(deployer); + stakeContract.setRewardsPerUnitTime(0, rewardsPerUnitTime); + assertEq(rewardsPerUnitTime, stakeContract.getRewardsPerUnitTime(0)); + + //================ stake tokens + vm.warp(1); + + vm.prank(stakerOne); + stakeContract.stake(0, 50); + uint256 timeOfLastUpdate = block.timestamp; + + //=================== warp timestamp and again set rewardsPerUnitTime + vm.roll(100); + vm.warp(1000); + + vm.prank(deployer); + stakeContract.setRewardsPerUnitTime(0, 200); + assertEq(200, stakeContract.getRewardsPerUnitTime(0)); + uint256 newTimeOfLastUpdate = block.timestamp; + + // check available rewards -- should use previous value for rewardsPerUnitTime for calculation + (, uint256 _availableRewards) = stakeContract.getStakeInfoForToken(0, stakerOne); + + assertEq( + _availableRewards, + ((((block.timestamp - timeOfLastUpdate) * 50) * rewardsPerUnitTime) / defaultTimeUnit) + ); + + //====== check rewards after some time + vm.roll(300); + vm.warp(3000); + + (, uint256 _newRewards) = stakeContract.getStakeInfoForToken(0, stakerOne); + + assertEq( + _newRewards, + _availableRewards + ((((block.timestamp - newTimeOfLastUpdate) * 50) * 200) / defaultTimeUnit) + ); + + // =========== token 1 + //================ stake tokens + + vm.prank(stakerOne); + stakeContract.stake(1, 20); + timeOfLastUpdate = block.timestamp; + + //=================== warp timestamp and again set rewardsPerUnitTime for token-0 + vm.roll(400); + vm.warp(4000); + + vm.prank(deployer); + stakeContract.setRewardsPerUnitTime(0, 300); + assertEq(300, stakeContract.getRewardsPerUnitTime(0)); + newTimeOfLastUpdate = block.timestamp; + + // check available rewards for token-1 -- should use defaultRewardsPerUnitTime for calculation + (, _availableRewards) = stakeContract.getStakeInfoForToken(1, stakerOne); + + assertEq( + _availableRewards, + ((((block.timestamp - timeOfLastUpdate) * 20) * defaultRewardsPerUnitTime) / defaultTimeUnit) + ); + + //====== check rewards after some time + vm.roll(500); + vm.warp(5000); + + (, _newRewards) = stakeContract.getStakeInfoForToken(1, stakerOne); + + assertEq( + _newRewards, + ((((block.timestamp - timeOfLastUpdate) * 20) * defaultRewardsPerUnitTime) / defaultTimeUnit) + ); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: stake conditions + - set rewards for both tokens + - default time unit + //////////////////////////////////////////////////////////////*/ + + function test_state_setRewardsPerUnitTime_bothTokens() public { + // set value and check + uint256 rewardsPerUnitTime = 50; + vm.prank(deployer); + stakeContract.setRewardsPerUnitTime(0, rewardsPerUnitTime); + assertEq(rewardsPerUnitTime, stakeContract.getRewardsPerUnitTime(0)); + + //================ stake tokens + vm.warp(1); + + vm.prank(stakerOne); + stakeContract.stake(0, 50); + uint256 timeOfLastUpdate = block.timestamp; + + //=================== warp timestamp and again set rewardsPerUnitTime + vm.roll(100); + vm.warp(1000); + + vm.prank(deployer); + stakeContract.setRewardsPerUnitTime(0, 200); + assertEq(200, stakeContract.getRewardsPerUnitTime(0)); + uint256 newTimeOfLastUpdate = block.timestamp; + + // check available rewards -- should use previous value for rewardsPerUnitTime for calculation + (, uint256 _availableRewards) = stakeContract.getStakeInfoForToken(0, stakerOne); + + assertEq( + _availableRewards, + ((((block.timestamp - timeOfLastUpdate) * 50) * rewardsPerUnitTime) / defaultTimeUnit) + ); + + //====== check rewards after some time + vm.roll(300); + vm.warp(3000); + + (, uint256 _newRewards) = stakeContract.getStakeInfoForToken(0, stakerOne); + + assertEq( + _newRewards, + _availableRewards + ((((block.timestamp - newTimeOfLastUpdate) * 50) * 200) / defaultTimeUnit) + ); + + // =========== token 1 + //================ stake tokens + + vm.prank(stakerOne); + stakeContract.stake(1, 20); + timeOfLastUpdate = block.timestamp; + + //=================== warp timestamp and set rewardsPerUnitTime for token-1 + vm.roll(400); + vm.warp(4000); + + vm.prank(deployer); + stakeContract.setRewardsPerUnitTime(1, 300); + assertEq(300, stakeContract.getRewardsPerUnitTime(1)); + newTimeOfLastUpdate = block.timestamp; + + // check available rewards for token-1 -- should use defaultRewardsPerUnitTime for calculation + (, _availableRewards) = stakeContract.getStakeInfoForToken(1, stakerOne); + + assertEq( + _availableRewards, + ((((block.timestamp - timeOfLastUpdate) * 20) * defaultRewardsPerUnitTime) / defaultTimeUnit) + ); + + //====== check rewards after some time + vm.roll(500); + vm.warp(5000); + + (, _newRewards) = stakeContract.getStakeInfoForToken(1, stakerOne); + + // should calculate based on newTimeOfLastUpdate and rewardsPerUnitTime (not default) + assertEq( + _newRewards, + _availableRewards + ((((block.timestamp - newTimeOfLastUpdate) * 20) * 300) / defaultTimeUnit) + ); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: stake conditions + - default rewards + - set time unit for token0 + //////////////////////////////////////////////////////////////*/ + + function test_state_setTimeUnit_token0() public { + // set value and check + uint80 timeUnit = 100; + vm.prank(deployer); + stakeContract.setTimeUnit(0, timeUnit); + assertEq(timeUnit, stakeContract.getTimeUnit(0)); + + //================ stake tokens + vm.warp(1); + + vm.prank(stakerOne); + stakeContract.stake(0, 50); + uint256 timeOfLastUpdate = block.timestamp; + + //=================== warp timestamp and again set timeUnit + vm.roll(100); + vm.warp(1000); + + vm.prank(deployer); + stakeContract.setTimeUnit(0, 200); + assertEq(200, stakeContract.getTimeUnit(0)); + uint256 newTimeOfLastUpdate = block.timestamp; + + // check available rewards -- should use previous value for timeUnit for calculation + (, uint256 _availableRewards) = stakeContract.getStakeInfoForToken(0, stakerOne); + + assertEq( + _availableRewards, + ((((block.timestamp - timeOfLastUpdate) * 50) * defaultRewardsPerUnitTime) / timeUnit) + ); + + //====== check rewards after some time + vm.roll(300); + vm.warp(3000); + + (, uint256 _newRewards) = stakeContract.getStakeInfoForToken(0, stakerOne); + + assertEq( + _newRewards, + _availableRewards + ((((block.timestamp - newTimeOfLastUpdate) * 50) * defaultRewardsPerUnitTime) / 200) + ); + + // =========== token 1 + //================ stake tokens + + vm.prank(stakerOne); + stakeContract.stake(1, 20); + timeOfLastUpdate = block.timestamp; + + //=================== warp timestamp and again set rewardsPerUnitTime for token-0 + vm.roll(400); + vm.warp(4000); + + vm.prank(deployer); + stakeContract.setTimeUnit(0, 10); + assertEq(10, stakeContract.getTimeUnit(0)); + newTimeOfLastUpdate = block.timestamp; + + // check available rewards for token-1 -- should use defaultTimeUnit for calculation + (, _availableRewards) = stakeContract.getStakeInfoForToken(1, stakerOne); + + assertEq( + _availableRewards, + ((((block.timestamp - timeOfLastUpdate) * 20) * defaultRewardsPerUnitTime) / defaultTimeUnit) + ); + + //====== check rewards after some time + vm.roll(500); + vm.warp(5000); + + (, _newRewards) = stakeContract.getStakeInfoForToken(1, stakerOne); + + assertEq( + _newRewards, + ((((block.timestamp - timeOfLastUpdate) * 20) * defaultRewardsPerUnitTime) / defaultTimeUnit) + ); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: stake conditions + - default rewards + - set time unit for both tokens + //////////////////////////////////////////////////////////////*/ + + function test_state_setTimeUnit_bothTokens() public { + // set value and check + uint80 timeUnit = 100; + vm.prank(deployer); + stakeContract.setTimeUnit(0, timeUnit); + assertEq(timeUnit, stakeContract.getTimeUnit(0)); + + //================ stake tokens + vm.warp(1); + + vm.prank(stakerOne); + stakeContract.stake(0, 50); + uint256 timeOfLastUpdate = block.timestamp; + + //=================== warp timestamp and again set rewardsPerUnitTime + vm.roll(100); + vm.warp(1000); + + vm.prank(deployer); + stakeContract.setTimeUnit(0, 200); + assertEq(200, stakeContract.getTimeUnit(0)); + uint256 newTimeOfLastUpdate = block.timestamp; + + // check available rewards -- should use previous value for timeUnit for calculation + (, uint256 _availableRewards) = stakeContract.getStakeInfoForToken(0, stakerOne); + + assertEq( + _availableRewards, + ((((block.timestamp - timeOfLastUpdate) * 50) * defaultRewardsPerUnitTime) / timeUnit) + ); + + //====== check rewards after some time + vm.roll(300); + vm.warp(3000); + + (, uint256 _newRewards) = stakeContract.getStakeInfoForToken(0, stakerOne); + + assertEq( + _newRewards, + _availableRewards + ((((block.timestamp - newTimeOfLastUpdate) * 50) * defaultRewardsPerUnitTime) / 200) + ); + + // =========== token 1 + //================ stake tokens + + vm.prank(stakerOne); + stakeContract.stake(1, 20); + timeOfLastUpdate = block.timestamp; + + //=================== warp timestamp and set timeUnit for token-1 + vm.roll(400); + vm.warp(4000); + + vm.prank(deployer); + stakeContract.setTimeUnit(1, 300); + assertEq(300, stakeContract.getTimeUnit(1)); + newTimeOfLastUpdate = block.timestamp; + + // check available rewards for token-1 -- should use defaultTimeUnit for calculation + (, _availableRewards) = stakeContract.getStakeInfoForToken(1, stakerOne); + + assertEq( + _availableRewards, + ((((block.timestamp - timeOfLastUpdate) * 20) * defaultRewardsPerUnitTime) / defaultTimeUnit) + ); + + //====== check rewards after some time + vm.roll(500); + vm.warp(5000); + + (, _newRewards) = stakeContract.getStakeInfoForToken(1, stakerOne); + + // should calculate based on newTimeOfLastUpdate and new time unit (not default) + assertEq( + _newRewards, + _availableRewards + ((((block.timestamp - newTimeOfLastUpdate) * 20) * defaultRewardsPerUnitTime) / 300) + ); + } + + function test_revert_setRewardsPerUnitTime_notAuthorized() public { + vm.expectRevert("Not authorized"); + stakeContract.setRewardsPerUnitTime(0, 1); + } + + function test_revert_setTimeUnit_notAuthorized() public { + vm.expectRevert("Not authorized"); + stakeContract.setTimeUnit(0, 1); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: withdraw + - different token-ids staked by stakers + //////////////////////////////////////////////////////////////*/ + + function test_state_withdraw_differentTokens() public { + //================ stake different tokens ====================== + vm.warp(1); + + vm.prank(stakerOne); + stakeContract.stake(0, 50); + + vm.prank(stakerTwo); + stakeContract.stake(1, 20); + + uint256 timeOfLastUpdate = block.timestamp; + + //========== warp timestamp before withdraw + vm.roll(100); + vm.warp(1000); + + // withdraw partially for stakerOne + vm.prank(stakerOne); + stakeContract.withdraw(0, 40); + uint256 timeOfLastUpdateLatest = block.timestamp; + + // check balances/ownership after withdraw + assertEq(erc1155.balanceOf(stakerOne, 0), 90); + assertEq(erc1155.balanceOf(address(stakeContract), 0), 10); + assertEq(erc1155.balanceOf(stakerTwo, 1), 80); + assertEq(erc1155.balanceOf(address(stakeContract), 1), 20); + + // check available rewards after withdraw + (, uint256 _availableRewards) = stakeContract.getStakeInfoForToken(0, stakerOne); + assertEq( + _availableRewards, + ((((block.timestamp - timeOfLastUpdate) * 50) * defaultRewardsPerUnitTime) / defaultTimeUnit) + ); + + (, _availableRewards) = stakeContract.getStakeInfoForToken(1, stakerTwo); + assertEq( + _availableRewards, + ((((block.timestamp - timeOfLastUpdate) * 20) * defaultRewardsPerUnitTime) / defaultTimeUnit) + ); + + // check available rewards some time after withdraw + vm.roll(200); + vm.warp(2000); + + // check rewards for stakerOne + (, _availableRewards) = stakeContract.getStakeInfoForToken(0, stakerOne); + + assertEq( + _availableRewards, + (((((timeOfLastUpdateLatest - timeOfLastUpdate) * 50)) * defaultRewardsPerUnitTime) / defaultTimeUnit) + + (((((block.timestamp - timeOfLastUpdateLatest) * 10)) * defaultRewardsPerUnitTime) / defaultTimeUnit) + ); + + // withdraw partially for stakerTwo + vm.prank(stakerTwo); + stakeContract.withdraw(1, 10); + timeOfLastUpdateLatest = block.timestamp; + + // check balances/ownership after withdraw + assertEq(erc1155.balanceOf(stakerOne, 0), 90); + assertEq(erc1155.balanceOf(address(stakeContract), 0), 10); + assertEq(erc1155.balanceOf(stakerTwo, 1), 90); + assertEq(erc1155.balanceOf(address(stakeContract), 1), 10); + + // check rewards for stakerTwo + (, _availableRewards) = stakeContract.getStakeInfoForToken(1, stakerTwo); + + assertEq( + _availableRewards, + (((((block.timestamp - timeOfLastUpdate) * 20)) * defaultRewardsPerUnitTime) / defaultTimeUnit) + ); + + // check rewards for stakerTwo after some time + vm.roll(300); + vm.warp(3000); + (, _availableRewards) = stakeContract.getStakeInfoForToken(1, stakerTwo); + + assertEq( + _availableRewards, + (((((timeOfLastUpdateLatest - timeOfLastUpdate) * 20)) * defaultRewardsPerUnitTime) / defaultTimeUnit) + + (((((block.timestamp - timeOfLastUpdateLatest) * 10)) * defaultRewardsPerUnitTime) / defaultTimeUnit) + ); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: withdraw + - same token-ids staked by stakers + //////////////////////////////////////////////////////////////*/ + + function test_state_withdraw_sameToken() public { + //================ stake different tokens ====================== + vm.warp(1); + + vm.prank(stakerOne); + stakeContract.stake(0, 50); + + vm.prank(stakerTwo); + stakeContract.stake(0, 20); + + uint256 timeOfLastUpdate = block.timestamp; + + //========== warp timestamp before withdraw + vm.roll(100); + vm.warp(1000); + + // withdraw partially for stakerOne + vm.prank(stakerOne); + stakeContract.withdraw(0, 40); + uint256 timeOfLastUpdateLatest = block.timestamp; + + // check balances/ownership after withdraw + assertEq(erc1155.balanceOf(stakerOne, 0), 90); + assertEq(erc1155.balanceOf(stakerTwo, 0), 80); + assertEq(erc1155.balanceOf(address(stakeContract), 0), 10 + 20); + + // check available rewards after withdraw + (, uint256 _availableRewards) = stakeContract.getStakeInfoForToken(0, stakerOne); + assertEq( + _availableRewards, + ((((block.timestamp - timeOfLastUpdate) * 50) * defaultRewardsPerUnitTime) / defaultTimeUnit) + ); + + (, _availableRewards) = stakeContract.getStakeInfoForToken(0, stakerTwo); + assertEq( + _availableRewards, + ((((block.timestamp - timeOfLastUpdate) * 20) * defaultRewardsPerUnitTime) / defaultTimeUnit) + ); + + // check available rewards some time after withdraw + vm.roll(200); + vm.warp(2000); + + // check rewards for stakerOne + (, _availableRewards) = stakeContract.getStakeInfoForToken(0, stakerOne); + + assertEq( + _availableRewards, + (((((timeOfLastUpdateLatest - timeOfLastUpdate) * 50)) * defaultRewardsPerUnitTime) / defaultTimeUnit) + + (((((block.timestamp - timeOfLastUpdateLatest) * 10)) * defaultRewardsPerUnitTime) / defaultTimeUnit) + ); + + // withdraw partially for stakerTwo + vm.prank(stakerTwo); + stakeContract.withdraw(0, 10); + timeOfLastUpdateLatest = block.timestamp; + + // check balances/ownership after withdraw + assertEq(erc1155.balanceOf(stakerOne, 0), 90); + assertEq(erc1155.balanceOf(stakerTwo, 0), 90); + assertEq(erc1155.balanceOf(address(stakeContract), 0), 10 + 10); + + // check rewards for stakerTwo + (, _availableRewards) = stakeContract.getStakeInfoForToken(0, stakerTwo); + + assertEq( + _availableRewards, + (((((block.timestamp - timeOfLastUpdate) * 20)) * defaultRewardsPerUnitTime) / defaultTimeUnit) + ); + + // check rewards for stakerTwo after some time + vm.roll(300); + vm.warp(3000); + (, _availableRewards) = stakeContract.getStakeInfoForToken(0, stakerTwo); + + assertEq( + _availableRewards, + (((((timeOfLastUpdateLatest - timeOfLastUpdate) * 20)) * defaultRewardsPerUnitTime) / defaultTimeUnit) + + (((((block.timestamp - timeOfLastUpdateLatest) * 10)) * defaultRewardsPerUnitTime) / defaultTimeUnit) + ); + } + + function test_revert_withdraw_withdrawingZeroTokens() public { + vm.expectRevert("Withdrawing 0 tokens"); + stakeContract.withdraw(0, 0); + } + + function test_revert_withdraw_withdrawingMoreThanStaked() public { + // stake tokens + vm.prank(stakerOne); + stakeContract.stake(0, 50); + + vm.prank(stakerTwo); + stakeContract.stake(1, 20); + + vm.prank(stakerTwo); + stakeContract.stake(0, 20); + + // view staked tokens + vm.roll(200); + vm.warp(2000); + (uint256[] memory _tokensStaked, uint256[] memory _tokenAmounts, uint256 _totalRewards) = stakeContract + .getStakeInfo(stakerOne); + + console.log("==== staker one ===="); + for (uint256 i = 0; i < _tokensStaked.length; i++) { + console.log(_tokensStaked[i], _tokenAmounts[i]); + } + + (_tokensStaked, _tokenAmounts, _totalRewards) = stakeContract.getStakeInfo(stakerTwo); + + console.log("==== staker two ===="); + for (uint256 i = 0; i < _tokensStaked.length; i++) { + console.log(_tokensStaked[i], _tokenAmounts[i]); + } + + // trying to withdraw more than staked + vm.prank(stakerOne); + vm.expectRevert("Withdrawing more than staked"); + stakeContract.withdraw(0, 60); + + // withdraw partially + vm.prank(stakerOne); + stakeContract.withdraw(0, 30); + + // trying to withdraw more than staked + vm.prank(stakerOne); + vm.expectRevert("Withdrawing more than staked"); + stakeContract.withdraw(0, 60); + + // re-stake + vm.prank(stakerOne); + stakeContract.stake(0, 30); + + // trying to withdraw more than staked + vm.prank(stakerOne); + vm.expectRevert("Withdrawing more than staked"); + stakeContract.withdraw(0, 60); + + // trying to withdraw different tokens + vm.prank(stakerOne); + vm.expectRevert("Withdrawing more than staked"); + stakeContract.withdraw(1, 20); + } + + /*/////////////////////////////////////////////////////////////// + Miscellaneous + //////////////////////////////////////////////////////////////*/ + + function test_revert_zeroTimeUnit_adminLockTokens() public { + //================ stake tokens + vm.warp(1); + + // User stakes tokens + vm.prank(stakerOne); + stakeContract.stake(0, 50); + + // set default timeUnit to zero + uint80 newTimeUnit = 0; + vm.prank(deployer); + vm.expectRevert("time-unit can't be 0"); + stakeContract.setDefaultTimeUnit(newTimeUnit); + + // set timeUnit to zero + vm.prank(deployer); + vm.expectRevert("time-unit can't be 0"); + stakeContract.setTimeUnit(0, newTimeUnit); + + // stakerOne and stakerTwo can withdraw their tokens + // vm.expectRevert(stdError.divisionError); + vm.prank(stakerOne); + stakeContract.withdraw(0, 50); + } + + function test_Macro_EditionDirectSafeTransferLocksToken() public { + uint256 tokenId = 0; + + // stakerOne mistakenly safe-transfers direct to the staking contract + vm.prank(stakerOne); + vm.expectRevert("Direct transfer"); + erc1155.safeTransferFrom(stakerOne, address(stakeContract), tokenId, 100, ""); + + // show that the transferred tokens were not properly staked + // (uint256 tokensStaked, uint256 rewards) = stakeContract.getStakeInfoForToken(tokenId, stakerOne); + // assertEq(0, tokensStaked); + + // // show that stakerOne cannot recover the tokens + // vm.expectRevert(); + // vm.prank(stakerOne); + // stakeContract.withdraw(tokenId, 100); + } +} + +contract Macro_EditionStakeTest is BaseTest { + EditionStake internal stakeContract; + + uint256 internal defaultTimeUnit; + uint256 internal defaultRewardsPerUnitTime; + uint64 internal tokenAmount = 100; + address internal stakerOne = address(0x345); + address internal stakerTwo = address(0x567); + + function setUp() public override { + super.setUp(); + + defaultTimeUnit = 60; + defaultRewardsPerUnitTime = 1; + + // mint erc1155 tokens to stakers + erc1155.mint(stakerOne, 1, tokenAmount); + erc1155.mint(stakerTwo, 2, tokenAmount); + + // mint reward tokens to contract admin + erc20.mint(deployer, 1000 ether); + + stakeContract = EditionStake(payable(getContract("EditionStake"))); + + // set approval + vm.prank(stakerOne); + erc1155.setApprovalForAll(address(stakeContract), true); + vm.prank(stakerTwo); + erc1155.setApprovalForAll(address(stakeContract), true); + } + + // Demostrate setting unitTime to 0 locks the tokens irreversibly + function testEdition_adminLockTokens() public { + //================ stake tokens + vm.warp(1); + + // Two users stake 1 tokens each + vm.prank(stakerOne); + stakeContract.stake(1, tokenAmount); + vm.prank(stakerTwo); + stakeContract.stake(2, tokenAmount); + + // set timeUnit to zero + uint80 newTimeUnit = 0; + vm.prank(deployer); + vm.expectRevert("time-unit can't be 0"); + stakeContract.setDefaultTimeUnit(newTimeUnit); + + // stakerOne and stakerTwo can't withdraw their tokens + // vm.expectRevert(stdError.divisionError); + vm.prank(stakerOne); + stakeContract.withdraw(1, tokenAmount); + + // vm.expectRevert(stdError.divisionError); + vm.prank(stakerTwo); + stakeContract.withdraw(2, tokenAmount); + + // timeUnit can't be changed back to a nonzero value + newTimeUnit = 40; + // vm.expectRevert(stdError.divisionError); + vm.prank(deployer); + stakeContract.setDefaultTimeUnit(newTimeUnit); + } + + // Demostrate setting rewardsPerTimeUnit to a high value locks the tokens irreversibly + function testEdition_demostrate_adminRewardsLock() public { + //================ stake tokens + vm.warp(1); + + // Two users stake 1 tokens each + vm.prank(stakerOne); + stakeContract.stake(1, tokenAmount); + vm.prank(stakerTwo); + stakeContract.stake(2, tokenAmount); + + // set rewardsPerTimeUnit to max value + uint256 rewardsPerTimeUnit = type(uint256).max; + vm.prank(deployer); + stakeContract.setDefaultRewardsPerUnitTime(rewardsPerTimeUnit); + + vm.warp(1 days); + + // stakerOne and stakerTwo can't withdraw their tokens + // vm.expectRevert(stdError.arithmeticError); + vm.prank(stakerOne); + stakeContract.withdraw(1, tokenAmount); + + // vm.expectRevert(stdError.arithmeticError); + vm.prank(stakerTwo); + stakeContract.withdraw(2, tokenAmount); + + // timeUnit can't be changed back + rewardsPerTimeUnit = 60; + // vm.expectRevert(stdError.arithmeticError); + vm.prank(deployer); + stakeContract.setDefaultRewardsPerUnitTime(rewardsPerTimeUnit); + } +} diff --git a/src/test/staking/EditionStake_EthReward.t.sol b/src/test/staking/EditionStake_EthReward.t.sol new file mode 100644 index 000000000..2f829cdb9 --- /dev/null +++ b/src/test/staking/EditionStake_EthReward.t.sol @@ -0,0 +1,1063 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { EditionStake } from "contracts/prebuilts/staking/EditionStake.sol"; + +// Test imports + +import "../utils/BaseTest.sol"; + +contract EditionStakeEthRewardTest is BaseTest { + EditionStake internal stakeContract; + + address internal stakerOne; + address internal stakerTwo; + + uint256 internal defaultTimeUnit; + uint256 internal defaultRewardsPerUnitTime; + + function setUp() public override { + super.setUp(); + + defaultTimeUnit = 60; + defaultRewardsPerUnitTime = 1; + + stakerOne = address(0x345); + stakerTwo = address(0x567); + + erc1155.mint(stakerOne, 0, 100); // mint 100 tokens with id 0 to stakerOne + erc1155.mint(stakerOne, 1, 100); // mint 100 tokens with id 1 to stakerOne + + erc1155.mint(stakerTwo, 0, 100); // mint 100 tokens with id 0 to stakerTwo + erc1155.mint(stakerTwo, 1, 100); // mint 100 tokens with id 1 to stakerTwo + + vm.deal(deployer, 1000 ether); // mint reward tokens (Eth) to contract admin + + stakeContract = EditionStake( + payable( + deployContractProxy( + "EditionStake", + abi.encodeCall( + EditionStake.initialize, + (deployer, CONTRACT_URI, forwarders(), NATIVE_TOKEN, address(erc1155), 60, 1) + ) + ) + ) + ); + + // set approvals + vm.prank(stakerOne); + erc1155.setApprovalForAll(address(stakeContract), true); + + vm.prank(stakerTwo); + erc1155.setApprovalForAll(address(stakeContract), true); + + vm.startPrank(deployer); + stakeContract.depositRewardTokens{ value: 100 ether }(100 ether); + // erc20.transfer(address(stakeContract), 100 ether); + vm.stopPrank(); + assertEq(stakeContract.getRewardTokenBalance(), 100 ether); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: Stake + - with default time-unit and rewards + - different token-ids staked by stakers + //////////////////////////////////////////////////////////////*/ + + function test_state_stake_defaults_differentTokens() public { + //================ first staker ====================== + vm.warp(1); + + // stake 50 tokens with token-id 0 + vm.prank(stakerOne); + stakeContract.stake(0, 50); + uint256 timeOfLastUpdate_one = block.timestamp; + + // check balances/ownership of staked tokens + assertEq(erc1155.balanceOf(address(stakeContract), 0), 50); + assertEq(erc1155.balanceOf(address(stakerOne), 0), 50); + + // check available rewards right after staking + (uint256 _amountStaked, uint256 _availableRewards) = stakeContract.getStakeInfoForToken(0, stakerOne); + + assertEq(_amountStaked, 50); + assertEq(_availableRewards, 0); + + //=================== warp timestamp to calculate rewards + vm.roll(100); + vm.warp(1000); + + // check available rewards after warp + (, _availableRewards) = stakeContract.getStakeInfoForToken(0, stakerOne); + + assertEq( + _availableRewards, + ((((block.timestamp - timeOfLastUpdate_one) * 50) * defaultRewardsPerUnitTime) / defaultTimeUnit) + ); + + //================ second staker ====================== + vm.roll(200); + vm.warp(2000); + + // stake 20 tokens with token-id 1 + vm.prank(stakerTwo); + stakeContract.stake(1, 20); + uint256 timeOfLastUpdate_two = block.timestamp; + + // check balances/ownership of staked tokens + assertEq(erc1155.balanceOf(address(stakeContract), 1), 20); + assertEq(erc1155.balanceOf(address(stakerTwo), 1), 80); + + // check available rewards right after staking + (_amountStaked, _availableRewards) = stakeContract.getStakeInfoForToken(1, stakerTwo); + + assertEq(_amountStaked, 20); + assertEq(_availableRewards, 0); + + //=================== warp timestamp to calculate rewards + vm.roll(300); + vm.warp(3000); + + // check available rewards for stakerOne + (, _availableRewards) = stakeContract.getStakeInfoForToken(0, stakerOne); + + assertEq( + _availableRewards, + ((((block.timestamp - timeOfLastUpdate_one) * 50) * defaultRewardsPerUnitTime) / defaultTimeUnit) + ); + + // check available rewards for stakerTwo + (, _availableRewards) = stakeContract.getStakeInfoForToken(1, stakerTwo); + + assertEq( + _availableRewards, + ((((block.timestamp - timeOfLastUpdate_two) * 20) * defaultRewardsPerUnitTime) / defaultTimeUnit) + ); + } + + function test_revert_stake_stakingZeroTokens() public { + // stake 0 tokens + + vm.prank(stakerOne); + vm.expectRevert("Staking 0 tokens"); + stakeContract.stake(0, 0); + } + + function test_revert_stake_notBalanceOrApproved() public { + // stake unowned tokens + vm.prank(stakerOne); + vm.expectRevert("ERC1155: insufficient balance for transfer"); + stakeContract.stake(2, 10); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: Stake + - with default time-unit and rewards + - same token-id staked by stakers + //////////////////////////////////////////////////////////////*/ + + function test_state_stake_defaults_sameToken() public { + //================ first staker ====================== + vm.warp(1); + + // stake 50 tokens with token-id 0 + vm.prank(stakerOne); + stakeContract.stake(0, 50); + uint256 timeOfLastUpdate_one = block.timestamp; + + // check balances/ownership of staked tokens + assertEq(erc1155.balanceOf(address(stakeContract), 0), 50); + assertEq(erc1155.balanceOf(address(stakerOne), 0), 50); + + // check available rewards right after staking + (uint256 _amountStaked, uint256 _availableRewards) = stakeContract.getStakeInfoForToken(0, stakerOne); + + assertEq(_amountStaked, 50); + assertEq(_availableRewards, 0); + + //=================== warp timestamp to calculate rewards + vm.roll(100); + vm.warp(1000); + + // check available rewards after warp + (, _availableRewards) = stakeContract.getStakeInfoForToken(0, stakerOne); + + assertEq( + _availableRewards, + ((((block.timestamp - timeOfLastUpdate_one) * 50) * defaultRewardsPerUnitTime) / defaultTimeUnit) + ); + + //================ second staker ====================== + vm.roll(200); + vm.warp(2000); + + // stake 20 tokens with token-id 0 + vm.prank(stakerTwo); + stakeContract.stake(0, 20); + uint256 timeOfLastUpdate_two = block.timestamp; + + // check balances/ownership of staked tokens + assertEq(erc1155.balanceOf(address(stakeContract), 0), 20 + 50); // sum of staked tokens by both stakers + assertEq(erc1155.balanceOf(address(stakerTwo), 0), 80); + + // check available rewards right after staking + (_amountStaked, _availableRewards) = stakeContract.getStakeInfoForToken(0, stakerTwo); + + assertEq(_amountStaked, 20); + assertEq(_availableRewards, 0); + + //=================== warp timestamp to calculate rewards + vm.roll(300); + vm.warp(3000); + + // check available rewards for stakerOne + (, _availableRewards) = stakeContract.getStakeInfoForToken(0, stakerOne); + + assertEq( + _availableRewards, + ((((block.timestamp - timeOfLastUpdate_one) * 50) * defaultRewardsPerUnitTime) / defaultTimeUnit) + ); + + // check available rewards for stakerTwo + (, _availableRewards) = stakeContract.getStakeInfoForToken(0, stakerTwo); + + assertEq( + _availableRewards, + ((((block.timestamp - timeOfLastUpdate_two) * 20) * defaultRewardsPerUnitTime) / defaultTimeUnit) + ); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: claimRewards + - default timeUnit and rewards + - different token-ids staked by stakers + //////////////////////////////////////////////////////////////*/ + + function test_state_claimRewards_defaults_differentTokens() public { + //================ setup stakerOne ====================== + vm.warp(1); + + // stake 50 tokens with token-id 0 + vm.prank(stakerOne); + stakeContract.stake(0, 50); + uint256 timeOfLastUpdate_one = block.timestamp; + + //=================== warp timestamp to claim rewards + vm.roll(100); + vm.warp(1000); + + uint256 rewardBalanceBefore = stakeContract.getRewardTokenBalance(); + vm.prank(stakerOne); + stakeContract.claimRewards(0); + uint256 rewardBalanceAfter = stakeContract.getRewardTokenBalance(); + + // check reward balances + assertEq( + stakerOne.balance, + ((((block.timestamp - timeOfLastUpdate_one) * 50) * defaultRewardsPerUnitTime) / defaultTimeUnit) + ); + assertEq( + rewardBalanceAfter, + rewardBalanceBefore - + ((((block.timestamp - timeOfLastUpdate_one) * 50) * defaultRewardsPerUnitTime) / defaultTimeUnit) + ); + + // check available rewards after claiming + (uint256 _amountStaked, uint256 _availableRewards) = stakeContract.getStakeInfoForToken(0, stakerOne); + + assertEq(_amountStaked, 50); + assertEq(_availableRewards, 0); + + //================ setup stakerTwo ====================== + + // stake 20 tokens with token-id 1 + vm.prank(stakerTwo); + stakeContract.stake(1, 20); + uint256 timeOfLastUpdate_two = block.timestamp; + + //=================== warp timestamp to claim rewards + vm.roll(200); + vm.warp(2000); + + rewardBalanceBefore = stakeContract.getRewardTokenBalance(); + vm.prank(stakerTwo); + stakeContract.claimRewards(1); + rewardBalanceAfter = stakeContract.getRewardTokenBalance(); + + // check reward balances + assertEq( + stakerTwo.balance, + ((((block.timestamp - timeOfLastUpdate_two) * 20) * defaultRewardsPerUnitTime) / defaultTimeUnit) + ); + assertEq( + rewardBalanceAfter, + rewardBalanceBefore - + ((((block.timestamp - timeOfLastUpdate_two) * 20) * defaultRewardsPerUnitTime) / defaultTimeUnit) + ); + + // check available rewards after claiming -- stakerTwo + (_amountStaked, _availableRewards) = stakeContract.getStakeInfoForToken(1, stakerTwo); + assertEq(_amountStaked, 20); + assertEq(_availableRewards, 0); + + // check available rewards -- stakerOne + (_amountStaked, _availableRewards) = stakeContract.getStakeInfoForToken(0, stakerOne); + assertEq(_amountStaked, 50); + assertEq( + _availableRewards, + ((((block.timestamp - timeOfLastUpdate_two) * 50) * defaultRewardsPerUnitTime) / defaultTimeUnit) + ); + } + + function test_revert_claimRewards_noRewards() public { + vm.warp(1); + + vm.prank(stakerOne); + stakeContract.stake(0, 50); + + //=================== try to claim rewards for a different token + + vm.prank(stakerOne); + vm.expectRevert("No rewards"); + stakeContract.claimRewards(1); + + //=================== try to claim rewards in same block + + vm.prank(stakerOne); + vm.expectRevert("No rewards"); + stakeContract.claimRewards(0); + + //======= withdraw tokens and claim rewards + vm.roll(100); + vm.warp(1000); + + vm.prank(stakerOne); + stakeContract.withdraw(0, 50); + vm.prank(stakerOne); + stakeContract.claimRewards(0); + + //===== try to claim rewards again + vm.roll(200); + vm.warp(2000); + vm.prank(stakerOne); + vm.expectRevert("No rewards"); + stakeContract.claimRewards(0); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: claimRewards + - default timeUnit and rewards + - same token-ids staked by stakers + //////////////////////////////////////////////////////////////*/ + + function test_state_claimRewards_defaults_sameToken() public { + //================ setup stakerOne ====================== + vm.warp(1); + + // stake 50 tokens with token-id 0 + vm.prank(stakerOne); + stakeContract.stake(0, 50); + uint256 timeOfLastUpdate_one = block.timestamp; + + //=================== warp timestamp to claim rewards + vm.roll(100); + vm.warp(1000); + + uint256 rewardBalanceBefore = stakeContract.getRewardTokenBalance(); + vm.prank(stakerOne); + stakeContract.claimRewards(0); + uint256 rewardBalanceAfter = stakeContract.getRewardTokenBalance(); + + // check reward balances + assertEq( + stakerOne.balance, + ((((block.timestamp - timeOfLastUpdate_one) * 50) * defaultRewardsPerUnitTime) / defaultTimeUnit) + ); + assertEq( + rewardBalanceAfter, + rewardBalanceBefore - + ((((block.timestamp - timeOfLastUpdate_one) * 50) * defaultRewardsPerUnitTime) / defaultTimeUnit) + ); + + // check available rewards after claiming + (uint256 _amountStaked, uint256 _availableRewards) = stakeContract.getStakeInfoForToken(0, stakerOne); + + assertEq(_amountStaked, 50); + assertEq(_availableRewards, 0); + + //================ setup stakerTwo ====================== + + // stake 20 tokens with token-id 1 + vm.prank(stakerTwo); + stakeContract.stake(0, 20); + uint256 timeOfLastUpdate_two = block.timestamp; + + //=================== warp timestamp to claim rewards + vm.roll(200); + vm.warp(2000); + + rewardBalanceBefore = stakeContract.getRewardTokenBalance(); + vm.prank(stakerTwo); + stakeContract.claimRewards(0); + rewardBalanceAfter = stakeContract.getRewardTokenBalance(); + + // check reward balances + assertEq( + stakerTwo.balance, + ((((block.timestamp - timeOfLastUpdate_two) * 20) * defaultRewardsPerUnitTime) / defaultTimeUnit) + ); + assertEq( + rewardBalanceAfter, + rewardBalanceBefore - + ((((block.timestamp - timeOfLastUpdate_two) * 20) * defaultRewardsPerUnitTime) / defaultTimeUnit) + ); + + // check available rewards after claiming -- stakerTwo + (_amountStaked, _availableRewards) = stakeContract.getStakeInfoForToken(0, stakerTwo); + assertEq(_amountStaked, 20); + assertEq(_availableRewards, 0); + + // check available rewards -- stakerOne + (_amountStaked, _availableRewards) = stakeContract.getStakeInfoForToken(0, stakerOne); + assertEq(_amountStaked, 50); + assertEq( + _availableRewards, + ((((block.timestamp - timeOfLastUpdate_two) * 50) * defaultRewardsPerUnitTime) / defaultTimeUnit) + ); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: stake conditions + - set rewards for token0 + - default time unit + //////////////////////////////////////////////////////////////*/ + + function test_state_setRewardsPerUnitTime_token0() public { + // set value and check + uint256 rewardsPerUnitTime = 50; + vm.prank(deployer); + stakeContract.setRewardsPerUnitTime(0, rewardsPerUnitTime); + assertEq(rewardsPerUnitTime, stakeContract.getRewardsPerUnitTime(0)); + + //================ stake tokens + vm.warp(1); + + vm.prank(stakerOne); + stakeContract.stake(0, 50); + uint256 timeOfLastUpdate = block.timestamp; + + //=================== warp timestamp and again set rewardsPerUnitTime + vm.roll(100); + vm.warp(1000); + + vm.prank(deployer); + stakeContract.setRewardsPerUnitTime(0, 200); + assertEq(200, stakeContract.getRewardsPerUnitTime(0)); + uint256 newTimeOfLastUpdate = block.timestamp; + + // check available rewards -- should use previous value for rewardsPerUnitTime for calculation + (, uint256 _availableRewards) = stakeContract.getStakeInfoForToken(0, stakerOne); + + assertEq( + _availableRewards, + ((((block.timestamp - timeOfLastUpdate) * 50) * rewardsPerUnitTime) / defaultTimeUnit) + ); + + //====== check rewards after some time + vm.roll(300); + vm.warp(3000); + + (, uint256 _newRewards) = stakeContract.getStakeInfoForToken(0, stakerOne); + + assertEq( + _newRewards, + _availableRewards + ((((block.timestamp - newTimeOfLastUpdate) * 50) * 200) / defaultTimeUnit) + ); + + // =========== token 1 + //================ stake tokens + + vm.prank(stakerOne); + stakeContract.stake(1, 20); + timeOfLastUpdate = block.timestamp; + + //=================== warp timestamp and again set rewardsPerUnitTime for token-0 + vm.roll(400); + vm.warp(4000); + + vm.prank(deployer); + stakeContract.setRewardsPerUnitTime(0, 300); + assertEq(300, stakeContract.getRewardsPerUnitTime(0)); + newTimeOfLastUpdate = block.timestamp; + + // check available rewards for token-1 -- should use defaultRewardsPerUnitTime for calculation + (, _availableRewards) = stakeContract.getStakeInfoForToken(1, stakerOne); + + assertEq( + _availableRewards, + ((((block.timestamp - timeOfLastUpdate) * 20) * defaultRewardsPerUnitTime) / defaultTimeUnit) + ); + + //====== check rewards after some time + vm.roll(500); + vm.warp(5000); + + (, _newRewards) = stakeContract.getStakeInfoForToken(1, stakerOne); + + assertEq( + _newRewards, + ((((block.timestamp - timeOfLastUpdate) * 20) * defaultRewardsPerUnitTime) / defaultTimeUnit) + ); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: stake conditions + - set rewards for both tokens + - default time unit + //////////////////////////////////////////////////////////////*/ + + function test_state_setRewardsPerUnitTime_bothTokens() public { + // set value and check + uint256 rewardsPerUnitTime = 50; + vm.prank(deployer); + stakeContract.setRewardsPerUnitTime(0, rewardsPerUnitTime); + assertEq(rewardsPerUnitTime, stakeContract.getRewardsPerUnitTime(0)); + + //================ stake tokens + vm.warp(1); + + vm.prank(stakerOne); + stakeContract.stake(0, 50); + uint256 timeOfLastUpdate = block.timestamp; + + //=================== warp timestamp and again set rewardsPerUnitTime + vm.roll(100); + vm.warp(1000); + + vm.prank(deployer); + stakeContract.setRewardsPerUnitTime(0, 200); + assertEq(200, stakeContract.getRewardsPerUnitTime(0)); + uint256 newTimeOfLastUpdate = block.timestamp; + + // check available rewards -- should use previous value for rewardsPerUnitTime for calculation + (, uint256 _availableRewards) = stakeContract.getStakeInfoForToken(0, stakerOne); + + assertEq( + _availableRewards, + ((((block.timestamp - timeOfLastUpdate) * 50) * rewardsPerUnitTime) / defaultTimeUnit) + ); + + //====== check rewards after some time + vm.roll(300); + vm.warp(3000); + + (, uint256 _newRewards) = stakeContract.getStakeInfoForToken(0, stakerOne); + + assertEq( + _newRewards, + _availableRewards + ((((block.timestamp - newTimeOfLastUpdate) * 50) * 200) / defaultTimeUnit) + ); + + // =========== token 1 + //================ stake tokens + + vm.prank(stakerOne); + stakeContract.stake(1, 20); + timeOfLastUpdate = block.timestamp; + + //=================== warp timestamp and set rewardsPerUnitTime for token-1 + vm.roll(400); + vm.warp(4000); + + vm.prank(deployer); + stakeContract.setRewardsPerUnitTime(1, 300); + assertEq(300, stakeContract.getRewardsPerUnitTime(1)); + newTimeOfLastUpdate = block.timestamp; + + // check available rewards for token-1 -- should use defaultRewardsPerUnitTime for calculation + (, _availableRewards) = stakeContract.getStakeInfoForToken(1, stakerOne); + + assertEq( + _availableRewards, + ((((block.timestamp - timeOfLastUpdate) * 20) * defaultRewardsPerUnitTime) / defaultTimeUnit) + ); + + //====== check rewards after some time + vm.roll(500); + vm.warp(5000); + + (, _newRewards) = stakeContract.getStakeInfoForToken(1, stakerOne); + + // should calculate based on newTimeOfLastUpdate and rewardsPerUnitTime (not default) + assertEq( + _newRewards, + _availableRewards + ((((block.timestamp - newTimeOfLastUpdate) * 20) * 300) / defaultTimeUnit) + ); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: stake conditions + - default rewards + - set time unit for token0 + //////////////////////////////////////////////////////////////*/ + + function test_state_setTimeUnit_token0() public { + // set value and check + uint80 timeUnit = 100; + vm.prank(deployer); + stakeContract.setTimeUnit(0, timeUnit); + assertEq(timeUnit, stakeContract.getTimeUnit(0)); + + //================ stake tokens + vm.warp(1); + + vm.prank(stakerOne); + stakeContract.stake(0, 50); + uint256 timeOfLastUpdate = block.timestamp; + + //=================== warp timestamp and again set timeUnit + vm.roll(100); + vm.warp(1000); + + vm.prank(deployer); + stakeContract.setTimeUnit(0, 200); + assertEq(200, stakeContract.getTimeUnit(0)); + uint256 newTimeOfLastUpdate = block.timestamp; + + // check available rewards -- should use previous value for timeUnit for calculation + (, uint256 _availableRewards) = stakeContract.getStakeInfoForToken(0, stakerOne); + + assertEq( + _availableRewards, + ((((block.timestamp - timeOfLastUpdate) * 50) * defaultRewardsPerUnitTime) / timeUnit) + ); + + //====== check rewards after some time + vm.roll(300); + vm.warp(3000); + + (, uint256 _newRewards) = stakeContract.getStakeInfoForToken(0, stakerOne); + + assertEq( + _newRewards, + _availableRewards + ((((block.timestamp - newTimeOfLastUpdate) * 50) * defaultRewardsPerUnitTime) / 200) + ); + + // =========== token 1 + //================ stake tokens + + vm.prank(stakerOne); + stakeContract.stake(1, 20); + timeOfLastUpdate = block.timestamp; + + //=================== warp timestamp and again set rewardsPerUnitTime for token-0 + vm.roll(400); + vm.warp(4000); + + vm.prank(deployer); + stakeContract.setTimeUnit(0, 10); + assertEq(10, stakeContract.getTimeUnit(0)); + newTimeOfLastUpdate = block.timestamp; + + // check available rewards for token-1 -- should use defaultTimeUnit for calculation + (, _availableRewards) = stakeContract.getStakeInfoForToken(1, stakerOne); + + assertEq( + _availableRewards, + ((((block.timestamp - timeOfLastUpdate) * 20) * defaultRewardsPerUnitTime) / defaultTimeUnit) + ); + + //====== check rewards after some time + vm.roll(500); + vm.warp(5000); + + (, _newRewards) = stakeContract.getStakeInfoForToken(1, stakerOne); + + assertEq( + _newRewards, + ((((block.timestamp - timeOfLastUpdate) * 20) * defaultRewardsPerUnitTime) / defaultTimeUnit) + ); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: stake conditions + - default rewards + - set time unit for both tokens + //////////////////////////////////////////////////////////////*/ + + function test_state_setTimeUnit_bothTokens() public { + // set value and check + uint80 timeUnit = 100; + vm.prank(deployer); + stakeContract.setTimeUnit(0, timeUnit); + assertEq(timeUnit, stakeContract.getTimeUnit(0)); + + //================ stake tokens + vm.warp(1); + + vm.prank(stakerOne); + stakeContract.stake(0, 50); + uint256 timeOfLastUpdate = block.timestamp; + + //=================== warp timestamp and again set rewardsPerUnitTime + vm.roll(100); + vm.warp(1000); + + vm.prank(deployer); + stakeContract.setTimeUnit(0, 200); + assertEq(200, stakeContract.getTimeUnit(0)); + uint256 newTimeOfLastUpdate = block.timestamp; + + // check available rewards -- should use previous value for timeUnit for calculation + (, uint256 _availableRewards) = stakeContract.getStakeInfoForToken(0, stakerOne); + + assertEq( + _availableRewards, + ((((block.timestamp - timeOfLastUpdate) * 50) * defaultRewardsPerUnitTime) / timeUnit) + ); + + //====== check rewards after some time + vm.roll(300); + vm.warp(3000); + + (, uint256 _newRewards) = stakeContract.getStakeInfoForToken(0, stakerOne); + + assertEq( + _newRewards, + _availableRewards + ((((block.timestamp - newTimeOfLastUpdate) * 50) * defaultRewardsPerUnitTime) / 200) + ); + + // =========== token 1 + //================ stake tokens + + vm.prank(stakerOne); + stakeContract.stake(1, 20); + timeOfLastUpdate = block.timestamp; + + //=================== warp timestamp and set timeUnit for token-1 + vm.roll(400); + vm.warp(4000); + + vm.prank(deployer); + stakeContract.setTimeUnit(1, 300); + assertEq(300, stakeContract.getTimeUnit(1)); + newTimeOfLastUpdate = block.timestamp; + + // check available rewards for token-1 -- should use defaultTimeUnit for calculation + (, _availableRewards) = stakeContract.getStakeInfoForToken(1, stakerOne); + + assertEq( + _availableRewards, + ((((block.timestamp - timeOfLastUpdate) * 20) * defaultRewardsPerUnitTime) / defaultTimeUnit) + ); + + //====== check rewards after some time + vm.roll(500); + vm.warp(5000); + + (, _newRewards) = stakeContract.getStakeInfoForToken(1, stakerOne); + + // should calculate based on newTimeOfLastUpdate and new time unit (not default) + assertEq( + _newRewards, + _availableRewards + ((((block.timestamp - newTimeOfLastUpdate) * 20) * defaultRewardsPerUnitTime) / 300) + ); + } + + function test_revert_setRewardsPerUnitTime_notAuthorized() public { + vm.expectRevert("Not authorized"); + stakeContract.setRewardsPerUnitTime(0, 1); + } + + function test_revert_setTimeUnit_notAuthorized() public { + vm.expectRevert("Not authorized"); + stakeContract.setTimeUnit(0, 1); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: withdraw + - different token-ids staked by stakers + //////////////////////////////////////////////////////////////*/ + + function test_state_withdraw_differentTokens() public { + //================ stake different tokens ====================== + vm.warp(1); + + vm.prank(stakerOne); + stakeContract.stake(0, 50); + + vm.prank(stakerTwo); + stakeContract.stake(1, 20); + + uint256 timeOfLastUpdate = block.timestamp; + + //========== warp timestamp before withdraw + vm.roll(100); + vm.warp(1000); + + // withdraw partially for stakerOne + vm.prank(stakerOne); + stakeContract.withdraw(0, 40); + uint256 timeOfLastUpdateLatest = block.timestamp; + + // check balances/ownership after withdraw + assertEq(erc1155.balanceOf(stakerOne, 0), 90); + assertEq(erc1155.balanceOf(address(stakeContract), 0), 10); + assertEq(erc1155.balanceOf(stakerTwo, 1), 80); + assertEq(erc1155.balanceOf(address(stakeContract), 1), 20); + + // check available rewards after withdraw + (, uint256 _availableRewards) = stakeContract.getStakeInfoForToken(0, stakerOne); + assertEq( + _availableRewards, + ((((block.timestamp - timeOfLastUpdate) * 50) * defaultRewardsPerUnitTime) / defaultTimeUnit) + ); + + (, _availableRewards) = stakeContract.getStakeInfoForToken(1, stakerTwo); + assertEq( + _availableRewards, + ((((block.timestamp - timeOfLastUpdate) * 20) * defaultRewardsPerUnitTime) / defaultTimeUnit) + ); + + // check available rewards some time after withdraw + vm.roll(200); + vm.warp(2000); + + // check rewards for stakerOne + (, _availableRewards) = stakeContract.getStakeInfoForToken(0, stakerOne); + + assertEq( + _availableRewards, + (((((timeOfLastUpdateLatest - timeOfLastUpdate) * 50)) * defaultRewardsPerUnitTime) / defaultTimeUnit) + + (((((block.timestamp - timeOfLastUpdateLatest) * 10)) * defaultRewardsPerUnitTime) / defaultTimeUnit) + ); + + // withdraw partially for stakerTwo + vm.prank(stakerTwo); + stakeContract.withdraw(1, 10); + timeOfLastUpdateLatest = block.timestamp; + + // check balances/ownership after withdraw + assertEq(erc1155.balanceOf(stakerOne, 0), 90); + assertEq(erc1155.balanceOf(address(stakeContract), 0), 10); + assertEq(erc1155.balanceOf(stakerTwo, 1), 90); + assertEq(erc1155.balanceOf(address(stakeContract), 1), 10); + + // check rewards for stakerTwo + (, _availableRewards) = stakeContract.getStakeInfoForToken(1, stakerTwo); + + assertEq( + _availableRewards, + (((((block.timestamp - timeOfLastUpdate) * 20)) * defaultRewardsPerUnitTime) / defaultTimeUnit) + ); + + // check rewards for stakerTwo after some time + vm.roll(300); + vm.warp(3000); + (, _availableRewards) = stakeContract.getStakeInfoForToken(1, stakerTwo); + + assertEq( + _availableRewards, + (((((timeOfLastUpdateLatest - timeOfLastUpdate) * 20)) * defaultRewardsPerUnitTime) / defaultTimeUnit) + + (((((block.timestamp - timeOfLastUpdateLatest) * 10)) * defaultRewardsPerUnitTime) / defaultTimeUnit) + ); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: withdraw + - same token-ids staked by stakers + //////////////////////////////////////////////////////////////*/ + + function test_state_withdraw_sameToken() public { + //================ stake different tokens ====================== + vm.warp(1); + + vm.prank(stakerOne); + stakeContract.stake(0, 50); + + vm.prank(stakerTwo); + stakeContract.stake(0, 20); + + uint256 timeOfLastUpdate = block.timestamp; + + //========== warp timestamp before withdraw + vm.roll(100); + vm.warp(1000); + + // withdraw partially for stakerOne + vm.prank(stakerOne); + stakeContract.withdraw(0, 40); + uint256 timeOfLastUpdateLatest = block.timestamp; + + // check balances/ownership after withdraw + assertEq(erc1155.balanceOf(stakerOne, 0), 90); + assertEq(erc1155.balanceOf(stakerTwo, 0), 80); + assertEq(erc1155.balanceOf(address(stakeContract), 0), 10 + 20); + + // check available rewards after withdraw + (, uint256 _availableRewards) = stakeContract.getStakeInfoForToken(0, stakerOne); + assertEq( + _availableRewards, + ((((block.timestamp - timeOfLastUpdate) * 50) * defaultRewardsPerUnitTime) / defaultTimeUnit) + ); + + (, _availableRewards) = stakeContract.getStakeInfoForToken(0, stakerTwo); + assertEq( + _availableRewards, + ((((block.timestamp - timeOfLastUpdate) * 20) * defaultRewardsPerUnitTime) / defaultTimeUnit) + ); + + // check available rewards some time after withdraw + vm.roll(200); + vm.warp(2000); + + // check rewards for stakerOne + (, _availableRewards) = stakeContract.getStakeInfoForToken(0, stakerOne); + + assertEq( + _availableRewards, + (((((timeOfLastUpdateLatest - timeOfLastUpdate) * 50)) * defaultRewardsPerUnitTime) / defaultTimeUnit) + + (((((block.timestamp - timeOfLastUpdateLatest) * 10)) * defaultRewardsPerUnitTime) / defaultTimeUnit) + ); + + // withdraw partially for stakerTwo + vm.prank(stakerTwo); + stakeContract.withdraw(0, 10); + timeOfLastUpdateLatest = block.timestamp; + + // check balances/ownership after withdraw + assertEq(erc1155.balanceOf(stakerOne, 0), 90); + assertEq(erc1155.balanceOf(stakerTwo, 0), 90); + assertEq(erc1155.balanceOf(address(stakeContract), 0), 10 + 10); + + // check rewards for stakerTwo + (, _availableRewards) = stakeContract.getStakeInfoForToken(0, stakerTwo); + + assertEq( + _availableRewards, + (((((block.timestamp - timeOfLastUpdate) * 20)) * defaultRewardsPerUnitTime) / defaultTimeUnit) + ); + + // check rewards for stakerTwo after some time + vm.roll(300); + vm.warp(3000); + (, _availableRewards) = stakeContract.getStakeInfoForToken(0, stakerTwo); + + assertEq( + _availableRewards, + (((((timeOfLastUpdateLatest - timeOfLastUpdate) * 20)) * defaultRewardsPerUnitTime) / defaultTimeUnit) + + (((((block.timestamp - timeOfLastUpdateLatest) * 10)) * defaultRewardsPerUnitTime) / defaultTimeUnit) + ); + } + + function test_revert_withdraw_withdrawingZeroTokens() public { + vm.expectRevert("Withdrawing 0 tokens"); + stakeContract.withdraw(0, 0); + } + + function test_revert_withdraw_withdrawingMoreThanStaked() public { + // stake tokens + vm.prank(stakerOne); + stakeContract.stake(0, 50); + + vm.prank(stakerTwo); + stakeContract.stake(1, 20); + + vm.prank(stakerTwo); + stakeContract.stake(0, 20); + + // view staked tokens + vm.roll(200); + vm.warp(2000); + (uint256[] memory _tokensStaked, uint256[] memory _tokenAmounts, uint256 _totalRewards) = stakeContract + .getStakeInfo(stakerOne); + + console.log("==== staker one ===="); + for (uint256 i = 0; i < _tokensStaked.length; i++) { + console.log(_tokensStaked[i], _tokenAmounts[i]); + } + + (_tokensStaked, _tokenAmounts, _totalRewards) = stakeContract.getStakeInfo(stakerTwo); + + console.log("==== staker two ===="); + for (uint256 i = 0; i < _tokensStaked.length; i++) { + console.log(_tokensStaked[i], _tokenAmounts[i]); + } + + // trying to withdraw more than staked + vm.prank(stakerOne); + vm.expectRevert("Withdrawing more than staked"); + stakeContract.withdraw(0, 60); + + // withdraw partially + vm.prank(stakerOne); + stakeContract.withdraw(0, 30); + + // trying to withdraw more than staked + vm.prank(stakerOne); + vm.expectRevert("Withdrawing more than staked"); + stakeContract.withdraw(0, 60); + + // re-stake + vm.prank(stakerOne); + stakeContract.stake(0, 30); + + // trying to withdraw more than staked + vm.prank(stakerOne); + vm.expectRevert("Withdrawing more than staked"); + stakeContract.withdraw(0, 60); + + // trying to withdraw different tokens + vm.prank(stakerOne); + vm.expectRevert("Withdrawing more than staked"); + stakeContract.withdraw(1, 20); + } + + /*/////////////////////////////////////////////////////////////// + Miscellaneous + //////////////////////////////////////////////////////////////*/ + + function test_revert_zeroTimeUnit_adminLockTokens() public { + //================ stake tokens + vm.warp(1); + + // User stakes tokens + vm.prank(stakerOne); + stakeContract.stake(0, 50); + + // set default timeUnit to zero + uint80 newTimeUnit = 0; + vm.prank(deployer); + vm.expectRevert("time-unit can't be 0"); + stakeContract.setDefaultTimeUnit(newTimeUnit); + + // set timeUnit to zero + vm.prank(deployer); + vm.expectRevert("time-unit can't be 0"); + stakeContract.setTimeUnit(0, newTimeUnit); + + // stakerOne and stakerTwo can withdraw their tokens + // vm.expectRevert(stdError.divisionError); + vm.prank(stakerOne); + stakeContract.withdraw(0, 50); + } + + function test_Macro_EditionDirectSafeTransferLocksToken() public { + uint256 tokenId = 0; + + // stakerOne mistakenly safe-transfers direct to the staking contract + vm.prank(stakerOne); + vm.expectRevert("Direct transfer"); + erc1155.safeTransferFrom(stakerOne, address(stakeContract), tokenId, 100, ""); + + // show that the transferred tokens were not properly staked + // (uint256 tokensStaked, uint256 rewards) = stakeContract.getStakeInfoForToken(tokenId, stakerOne); + // assertEq(0, tokensStaked); + + // // show that stakerOne cannot recover the tokens + // vm.expectRevert(); + // vm.prank(stakerOne); + // stakeContract.withdraw(tokenId, 100); + } +} diff --git a/src/test/staking/NFTStake.t.sol b/src/test/staking/NFTStake.t.sol new file mode 100644 index 000000000..8acd02214 --- /dev/null +++ b/src/test/staking/NFTStake.t.sol @@ -0,0 +1,583 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { NFTStake } from "contracts/prebuilts/staking/NFTStake.sol"; + +// Test imports + +import "../utils/BaseTest.sol"; + +contract NFTStakeTest is BaseTest { + NFTStake internal stakeContract; + + address internal stakerOne; + address internal stakerTwo; + + uint256 internal timeUnit; + uint256 internal rewardsPerUnitTime; + + function setUp() public override { + super.setUp(); + + timeUnit = 60; + rewardsPerUnitTime = 1; + + stakerOne = address(0x345); + stakerTwo = address(0x567); + + erc721.mint(stakerOne, 5); // mint token id 0 to 4 + erc721.mint(stakerTwo, 5); // mint token id 5 to 9 + erc20.mint(deployer, 1000 ether); // mint reward tokens to contract admin + + stakeContract = NFTStake(payable(getContract("NFTStake"))); + + // set approvals + vm.prank(stakerOne); + erc721.setApprovalForAll(address(stakeContract), true); + + vm.prank(stakerTwo); + erc721.setApprovalForAll(address(stakeContract), true); + + vm.startPrank(deployer); + erc20.approve(address(stakeContract), type(uint256).max); + stakeContract.depositRewardTokens(100 ether); + // erc20.transfer(address(stakeContract), 100 ether); + vm.stopPrank(); + assertEq(stakeContract.getRewardTokenBalance(), 100 ether); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: Stake + //////////////////////////////////////////////////////////////*/ + + function test_state_stake() public { + //================ first staker ====================== + vm.warp(1); + uint256[] memory _tokenIdsOne = new uint256[](3); + _tokenIdsOne[0] = 0; + _tokenIdsOne[1] = 1; + _tokenIdsOne[2] = 2; + + // stake 3 tokens + vm.prank(stakerOne); + stakeContract.stake(_tokenIdsOne); + uint256 timeOfLastUpdate_one = block.timestamp; + + // check balances/ownership of staked tokens + for (uint256 i = 0; i < _tokenIdsOne.length; i++) { + assertEq(erc721.ownerOf(_tokenIdsOne[i]), address(stakeContract)); + assertEq(stakeContract.stakerAddress(_tokenIdsOne[i]), stakerOne); + } + assertEq(erc721.balanceOf(stakerOne), 2); + assertEq(erc721.balanceOf(address(stakeContract)), _tokenIdsOne.length); + + // check available rewards right after staking + (uint256[] memory _amountStaked, uint256 _availableRewards) = stakeContract.getStakeInfo(stakerOne); + + assertEq(_amountStaked.length, _tokenIdsOne.length); + assertEq(_availableRewards, 0); + + //=================== warp timestamp to calculate rewards + vm.roll(100); + vm.warp(1000); + + // check available rewards after warp + (, _availableRewards) = stakeContract.getStakeInfo(stakerOne); + + assertEq( + _availableRewards, + ((((block.timestamp - timeOfLastUpdate_one) * _tokenIdsOne.length) * rewardsPerUnitTime) / timeUnit) + ); + + //================ second staker ====================== + vm.roll(200); + vm.warp(2000); + uint256[] memory _tokenIdsTwo = new uint256[](2); + _tokenIdsTwo[0] = 5; + _tokenIdsTwo[1] = 6; + + // stake 2 tokens + vm.prank(stakerTwo); + stakeContract.stake(_tokenIdsTwo); + uint256 timeOfLastUpdate_two = block.timestamp; + + // check balances/ownership of staked tokens + for (uint256 i = 0; i < _tokenIdsTwo.length; i++) { + assertEq(erc721.ownerOf(_tokenIdsTwo[i]), address(stakeContract)); + assertEq(stakeContract.stakerAddress(_tokenIdsTwo[i]), stakerTwo); + } + assertEq(erc721.balanceOf(stakerTwo), 3); + assertEq(erc721.balanceOf(address(stakeContract)), _tokenIdsTwo.length + _tokenIdsOne.length); + + // check available rewards right after staking + (_amountStaked, _availableRewards) = stakeContract.getStakeInfo(stakerTwo); + + assertEq(_amountStaked.length, _tokenIdsTwo.length); + assertEq(_availableRewards, 0); + + //=================== warp timestamp to calculate rewards + vm.roll(300); + vm.warp(3000); + + // check available rewards for stakerOne + (, _availableRewards) = stakeContract.getStakeInfo(stakerOne); + + assertEq( + _availableRewards, + ((((block.timestamp - timeOfLastUpdate_one) * _tokenIdsOne.length) * rewardsPerUnitTime) / timeUnit) + ); + + // check available rewards for stakerTwo + (, _availableRewards) = stakeContract.getStakeInfo(stakerTwo); + + assertEq( + _availableRewards, + ((((block.timestamp - timeOfLastUpdate_two) * _tokenIdsTwo.length) * rewardsPerUnitTime) / timeUnit) + ); + } + + function test_revert_stake_stakingZeroTokens() public { + // stake 0 tokens + uint256[] memory _tokenIds; + + vm.prank(stakerOne); + vm.expectRevert("Staking 0 tokens"); + stakeContract.stake(_tokenIds); + } + + function test_revert_stake_notStaker() public { + // stake unowned tokens + uint256[] memory _tokenIds = new uint256[](1); + _tokenIds[0] = 6; + + vm.prank(stakerOne); + vm.expectRevert("ERC721: transfer from incorrect owner"); + stakeContract.stake(_tokenIds); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: claimRewards + //////////////////////////////////////////////////////////////*/ + + function test_state_claimRewards() public { + //================ first staker ====================== + vm.warp(1); + uint256[] memory _tokenIdsOne = new uint256[](3); + _tokenIdsOne[0] = 0; + _tokenIdsOne[1] = 1; + _tokenIdsOne[2] = 2; + + // stake 3 tokens + vm.prank(stakerOne); + stakeContract.stake(_tokenIdsOne); + uint256 timeOfLastUpdate_one = block.timestamp; + + //=================== warp timestamp to claim rewards + vm.roll(100); + vm.warp(1000); + + uint256 rewardBalanceBefore = stakeContract.getRewardTokenBalance(); + vm.prank(stakerOne); + stakeContract.claimRewards(); + uint256 rewardBalanceAfter = stakeContract.getRewardTokenBalance(); + + // check reward balances + assertEq( + erc20.balanceOf(stakerOne), + ((((block.timestamp - timeOfLastUpdate_one) * _tokenIdsOne.length) * rewardsPerUnitTime) / timeUnit) + ); + assertEq( + rewardBalanceAfter, + rewardBalanceBefore - + ((((block.timestamp - timeOfLastUpdate_one) * _tokenIdsOne.length) * rewardsPerUnitTime) / timeUnit) + ); + + // check available rewards after claiming + (uint256[] memory _amountStaked, uint256 _availableRewards) = stakeContract.getStakeInfo(stakerOne); + + assertEq(_amountStaked.length, _tokenIdsOne.length); + assertEq(_availableRewards, 0); + } + + function test_revert_claimRewards_noRewards() public { + vm.warp(1); + uint256[] memory _tokenIdsOne = new uint256[](3); + _tokenIdsOne[0] = 0; + _tokenIdsOne[1] = 1; + _tokenIdsOne[2] = 2; + + // stake 3 tokens + vm.prank(stakerOne); + stakeContract.stake(_tokenIdsOne); + + //=================== try to claim rewards in same block + + vm.prank(stakerOne); + vm.expectRevert("No rewards"); + stakeContract.claimRewards(); + + //======= withdraw tokens and claim rewards + vm.roll(100); + vm.warp(1000); + + vm.prank(stakerOne); + stakeContract.withdraw(_tokenIdsOne); + vm.prank(stakerOne); + stakeContract.claimRewards(); + + //===== try to claim rewards again + vm.roll(200); + vm.warp(2000); + vm.prank(stakerOne); + vm.expectRevert("No rewards"); + stakeContract.claimRewards(); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: stake conditions + //////////////////////////////////////////////////////////////*/ + + function test_state_setRewardsPerUnitTime() public { + // check current value + assertEq(rewardsPerUnitTime, stakeContract.getRewardsPerUnitTime()); + + // set new value and check + uint256 newRewardsPerUnitTime = 50; + vm.prank(deployer); + stakeContract.setRewardsPerUnitTime(newRewardsPerUnitTime); + assertEq(newRewardsPerUnitTime, stakeContract.getRewardsPerUnitTime()); + + //================ stake tokens + vm.warp(1); + uint256[] memory _tokenIdsOne = new uint256[](3); + _tokenIdsOne[0] = 0; + _tokenIdsOne[1] = 1; + _tokenIdsOne[2] = 2; + + // stake 3 tokens + vm.prank(stakerOne); + stakeContract.stake(_tokenIdsOne); + uint256 timeOfLastUpdate = block.timestamp; + + //=================== warp timestamp and again set rewardsPerUnitTime + vm.roll(100); + vm.warp(1000); + + vm.prank(deployer); + stakeContract.setRewardsPerUnitTime(200); + assertEq(200, stakeContract.getRewardsPerUnitTime()); + uint256 newTimeOfLastUpdate = block.timestamp; + + // check available rewards -- should use previous value for rewardsPerUnitTime for calculation + (, uint256 _availableRewards) = stakeContract.getStakeInfo(stakerOne); + + assertEq( + _availableRewards, + ((((block.timestamp - timeOfLastUpdate) * _tokenIdsOne.length) * newRewardsPerUnitTime) / timeUnit) + ); + + //====== check rewards after some time + vm.roll(300); + vm.warp(3000); + + (, uint256 _newRewards) = stakeContract.getStakeInfo(stakerOne); + + assertEq( + _newRewards, + _availableRewards + ((((block.timestamp - newTimeOfLastUpdate) * _tokenIdsOne.length) * 200) / timeUnit) + ); + } + + function test_revert_setRewardsPerUnitTime_notAuthorized() public { + vm.expectRevert("Not authorized"); + stakeContract.setRewardsPerUnitTime(1); + } + + function test_state_setTimeUnit() public { + // check current value + assertEq(timeUnit, stakeContract.getTimeUnit()); + + // set new value and check + uint256 newTimeUnit = 2 minutes; + vm.prank(deployer); + stakeContract.setTimeUnit(newTimeUnit); + assertEq(newTimeUnit, stakeContract.getTimeUnit()); + + //================ stake tokens + vm.warp(1); + uint256[] memory _tokenIdsOne = new uint256[](3); + _tokenIdsOne[0] = 0; + _tokenIdsOne[1] = 1; + _tokenIdsOne[2] = 2; + + // stake 3 tokens + vm.prank(stakerOne); + stakeContract.stake(_tokenIdsOne); + uint256 timeOfLastUpdate = block.timestamp; + + //=================== warp timestamp and again set rewardsPerUnitTime + vm.roll(100); + vm.warp(1000); + + vm.prank(deployer); + stakeContract.setTimeUnit(1 seconds); + assertEq(1 seconds, stakeContract.getTimeUnit()); + uint256 newTimeOfLastUpdate = block.timestamp; + + // check available rewards -- should use previous value for rewardsPerUnitTime for calculation + (, uint256 _availableRewards) = stakeContract.getStakeInfo(stakerOne); + + assertEq( + _availableRewards, + ((((block.timestamp - timeOfLastUpdate) * _tokenIdsOne.length) * rewardsPerUnitTime) / newTimeUnit) + ); + + //====== check rewards after some time + vm.roll(300); + vm.warp(3000); + + (, uint256 _newRewards) = stakeContract.getStakeInfo(stakerOne); + + assertEq( + _newRewards, + _availableRewards + + ((((block.timestamp - newTimeOfLastUpdate) * _tokenIdsOne.length) * rewardsPerUnitTime) / (1 seconds)) + ); + } + + function test_revert_setTimeUnit_notAuthorized() public { + vm.expectRevert("Not authorized"); + stakeContract.setTimeUnit(1); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: withdraw + //////////////////////////////////////////////////////////////*/ + + function test_state_withdraw() public { + //================ first staker ====================== + vm.warp(1); + uint256[] memory _tokenIdsOne = new uint256[](3); + _tokenIdsOne[0] = 0; + _tokenIdsOne[1] = 1; + _tokenIdsOne[2] = 2; + + // stake 3 tokens + vm.prank(stakerOne); + stakeContract.stake(_tokenIdsOne); + uint256 timeOfLastUpdate = block.timestamp; + + // check balances/ownership of staked tokens + for (uint256 i = 0; i < _tokenIdsOne.length; i++) { + assertEq(erc721.ownerOf(_tokenIdsOne[i]), address(stakeContract)); + assertEq(stakeContract.stakerAddress(_tokenIdsOne[i]), stakerOne); + } + assertEq(erc721.balanceOf(stakerOne), 2); + assertEq(erc721.balanceOf(address(stakeContract)), _tokenIdsOne.length); + + // check available rewards right after staking + (uint256[] memory _amountStaked, uint256 _availableRewards) = stakeContract.getStakeInfo(stakerOne); + + assertEq(_amountStaked.length, _tokenIdsOne.length); + assertEq(_availableRewards, 0); + + console.log("==== staked tokens before withdraw ===="); + for (uint256 i = 0; i < _amountStaked.length; i++) { + console.log(_amountStaked[i]); + } + + //========== warp timestamp before withdraw + vm.roll(100); + vm.warp(1000); + + uint256[] memory _tokensToWithdraw = new uint256[](1); + _tokensToWithdraw[0] = 1; + + vm.prank(stakerOne); + stakeContract.withdraw(_tokensToWithdraw); + + // check balances/ownership after withdraw + for (uint256 i = 0; i < _tokensToWithdraw.length; i++) { + assertEq(erc721.ownerOf(_tokensToWithdraw[i]), stakerOne); + assertEq(stakeContract.stakerAddress(_tokensToWithdraw[i]), address(0)); + } + assertEq(erc721.balanceOf(stakerOne), 3); + assertEq(erc721.balanceOf(address(stakeContract)), 2); + + // check available rewards after withdraw + (_amountStaked, _availableRewards) = stakeContract.getStakeInfo(stakerOne); + assertEq(_availableRewards, ((((block.timestamp - timeOfLastUpdate) * 3) * rewardsPerUnitTime) / timeUnit)); + + console.log("==== staked tokens after withdraw ===="); + for (uint256 i = 0; i < _amountStaked.length; i++) { + console.log(_amountStaked[i]); + } + + uint256 timeOfLastUpdateLatest = block.timestamp; + + // check available rewards some time after withdraw + vm.roll(200); + vm.warp(2000); + + (, _availableRewards) = stakeContract.getStakeInfo(stakerOne); + + assertEq( + _availableRewards, + (((((timeOfLastUpdateLatest - timeOfLastUpdate) * 3)) * rewardsPerUnitTime) / timeUnit) + + (((((block.timestamp - timeOfLastUpdateLatest) * 2)) * rewardsPerUnitTime) / timeUnit) + ); + + // stake again + vm.prank(stakerOne); + stakeContract.stake(_tokensToWithdraw); + + _tokensToWithdraw[0] = 5; + vm.prank(stakerTwo); + stakeContract.stake(_tokensToWithdraw); + // check available rewards after re-staking + (_amountStaked, ) = stakeContract.getStakeInfo(stakerOne); + + console.log("==== staked tokens after re-staking ===="); + for (uint256 i = 0; i < _amountStaked.length; i++) { + console.log(_amountStaked[i]); + } + } + + function test_revert_withdraw_withdrawingZeroTokens() public { + uint256[] memory _tokensToWithdraw; + + vm.expectRevert("Withdrawing 0 tokens"); + stakeContract.withdraw(_tokensToWithdraw); + } + + function test_revert_withdraw_notStaker() public { + // stake tokens + uint256[] memory _tokenIds = new uint256[](2); + _tokenIds[0] = 0; + _tokenIds[1] = 1; + + vm.prank(stakerOne); + stakeContract.stake(_tokenIds); + + // trying to withdraw zero tokens + uint256[] memory _tokensToWithdraw = new uint256[](1); + _tokensToWithdraw[0] = 2; + + vm.prank(stakerOne); + vm.expectRevert("Not staker"); + stakeContract.withdraw(_tokensToWithdraw); + } + + function test_revert_withdraw_withdrawingMoreThanStaked() public { + // stake tokens + uint256[] memory _tokenIds = new uint256[](1); + _tokenIds[0] = 0; + + vm.prank(stakerOne); + stakeContract.stake(_tokenIds); + + // trying to withdraw tokens not staked by caller + uint256[] memory _tokensToWithdraw = new uint256[](2); + _tokensToWithdraw[0] = 0; + _tokensToWithdraw[1] = 1; + + vm.prank(stakerOne); + vm.expectRevert("Withdrawing more than staked"); + stakeContract.withdraw(_tokensToWithdraw); + } + + /*/////////////////////////////////////////////////////////////// + Miscellaneous + //////////////////////////////////////////////////////////////*/ + + function test_revert_zeroTimeUnit_adminLockTokens() public { + //================ stake tokens + vm.warp(1); + uint256[] memory _tokenIdsOne = new uint256[](1); + uint256[] memory _tokenIdsTwo = new uint256[](1); + _tokenIdsOne[0] = 0; + _tokenIdsTwo[0] = 5; + + // Two different users stake 1 tokens each + vm.prank(stakerOne); + stakeContract.stake(_tokenIdsOne); + vm.prank(stakerTwo); + stakeContract.stake(_tokenIdsTwo); + + // set timeUnit to zero + uint256 newTimeUnit = 0; + vm.prank(deployer); + vm.expectRevert("time-unit can't be 0"); + stakeContract.setTimeUnit(newTimeUnit); + + // stakerOne and stakerTwo can withdraw their tokens + // vm.expectRevert(stdError.divisionError); + vm.prank(stakerOne); + stakeContract.withdraw(_tokenIdsOne); + + // vm.expectRevert(stdError.divisionError); + vm.prank(stakerTwo); + stakeContract.withdraw(_tokenIdsTwo); + } + + function test_revert_largeRewardsPerUnitTime_adminRewardsLock() public { + //================ stake tokens + vm.warp(1); + uint256[] memory _tokenIdsOne = new uint256[](1); + uint256[] memory _tokenIdsTwo = new uint256[](1); + + uint256 stakerOneToken = erc721.nextTokenIdToMint(); + erc721.mint(stakerOne, 5); // mint token id 0 to 4 + uint256 stakerTwoToken = erc721.nextTokenIdToMint(); + erc721.mint(stakerTwo, 5); // mint token id 5 to 9 + _tokenIdsOne[0] = stakerOneToken; + _tokenIdsTwo[0] = stakerTwoToken; + + // Two users stake 1 tokens each + vm.prank(stakerOne); + stakeContract.stake(_tokenIdsOne); + vm.prank(stakerTwo); + stakeContract.stake(_tokenIdsTwo); + + // set rewardsPerTimeUnit to max value + uint256 rewardsPerTimeUnit = type(uint256).max; + vm.prank(deployer); + stakeContract.setRewardsPerUnitTime(rewardsPerTimeUnit); + + vm.warp(1 days); + + // stakerOne and stakerTwo can't withdraw their tokens + // vm.expectRevert(stdError.arithmeticError); + vm.prank(stakerOne); + stakeContract.withdraw(_tokenIdsOne); + + // vm.expectRevert(stdError.arithmeticError); + vm.prank(stakerTwo); + stakeContract.withdraw(_tokenIdsTwo); + + // rewardsPerTimeUnit can't be changed + rewardsPerTimeUnit = 60; + // vm.expectRevert(stdError.arithmeticError); + vm.prank(deployer); + stakeContract.setRewardsPerUnitTime(rewardsPerTimeUnit); + } + + function test_Macro_NFTDirectSafeTransferLocksToken() public { + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = 0; + + // stakerOne mistakenly safe-transfers direct to the staking contract + vm.prank(stakerOne); + vm.expectRevert("Direct transfer"); + erc721.safeTransferFrom(stakerOne, address(stakeContract), tokenIds[0]); + + // show that the transferred token was not properly staked + // (uint256[] memory tokensStaked, uint256 rewards) = stakeContract.getStakeInfo(stakerOne); + // assertEq(0, tokensStaked.length); + + // // show that stakerOne cannot recover the token + // vm.expectRevert(); + // vm.prank(stakerOne); + // stakeContract.withdraw(tokenIds); + } +} diff --git a/src/test/staking/NFTStake_EthReward.t.sol b/src/test/staking/NFTStake_EthReward.t.sol new file mode 100644 index 000000000..459f111e5 --- /dev/null +++ b/src/test/staking/NFTStake_EthReward.t.sol @@ -0,0 +1,592 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { NFTStake } from "contracts/prebuilts/staking/NFTStake.sol"; + +// Test imports + +import "../utils/BaseTest.sol"; + +contract NFTStakeEthRewardTest is BaseTest { + NFTStake internal stakeContract; + + address internal stakerOne; + address internal stakerTwo; + + uint256 internal timeUnit; + uint256 internal rewardsPerUnitTime; + + function setUp() public override { + super.setUp(); + + timeUnit = 60; + rewardsPerUnitTime = 1; + + stakerOne = address(0x345); + stakerTwo = address(0x567); + + erc721.mint(stakerOne, 5); // mint token id 0 to 4 + erc721.mint(stakerTwo, 5); // mint token id 5 to 9 + vm.deal(deployer, 1000 ether); // mint reward tokens (Eth) to contract admin + + stakeContract = NFTStake( + payable( + deployContractProxy( + "NFTStake", + abi.encodeCall( + NFTStake.initialize, + (deployer, CONTRACT_URI, forwarders(), NATIVE_TOKEN, address(erc721), 60, 1) + ) + ) + ) + ); + + // set approvals + vm.prank(stakerOne); + erc721.setApprovalForAll(address(stakeContract), true); + + vm.prank(stakerTwo); + erc721.setApprovalForAll(address(stakeContract), true); + + vm.startPrank(deployer); + stakeContract.depositRewardTokens{ value: 100 ether }(100 ether); + // erc20.transfer(address(stakeContract), 100 ether); + vm.stopPrank(); + assertEq(stakeContract.getRewardTokenBalance(), 100 ether); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: Stake + //////////////////////////////////////////////////////////////*/ + + function test_state_stake() public { + //================ first staker ====================== + vm.warp(1); + uint256[] memory _tokenIdsOne = new uint256[](3); + _tokenIdsOne[0] = 0; + _tokenIdsOne[1] = 1; + _tokenIdsOne[2] = 2; + + // stake 3 tokens + vm.prank(stakerOne); + stakeContract.stake(_tokenIdsOne); + uint256 timeOfLastUpdate_one = block.timestamp; + + // check balances/ownership of staked tokens + for (uint256 i = 0; i < _tokenIdsOne.length; i++) { + assertEq(erc721.ownerOf(_tokenIdsOne[i]), address(stakeContract)); + assertEq(stakeContract.stakerAddress(_tokenIdsOne[i]), stakerOne); + } + assertEq(erc721.balanceOf(stakerOne), 2); + assertEq(erc721.balanceOf(address(stakeContract)), _tokenIdsOne.length); + + // check available rewards right after staking + (uint256[] memory _amountStaked, uint256 _availableRewards) = stakeContract.getStakeInfo(stakerOne); + + assertEq(_amountStaked.length, _tokenIdsOne.length); + assertEq(_availableRewards, 0); + + //=================== warp timestamp to calculate rewards + vm.roll(100); + vm.warp(1000); + + // check available rewards after warp + (, _availableRewards) = stakeContract.getStakeInfo(stakerOne); + + assertEq( + _availableRewards, + ((((block.timestamp - timeOfLastUpdate_one) * _tokenIdsOne.length) * rewardsPerUnitTime) / timeUnit) + ); + + //================ second staker ====================== + vm.roll(200); + vm.warp(2000); + uint256[] memory _tokenIdsTwo = new uint256[](2); + _tokenIdsTwo[0] = 5; + _tokenIdsTwo[1] = 6; + + // stake 2 tokens + vm.prank(stakerTwo); + stakeContract.stake(_tokenIdsTwo); + uint256 timeOfLastUpdate_two = block.timestamp; + + // check balances/ownership of staked tokens + for (uint256 i = 0; i < _tokenIdsTwo.length; i++) { + assertEq(erc721.ownerOf(_tokenIdsTwo[i]), address(stakeContract)); + assertEq(stakeContract.stakerAddress(_tokenIdsTwo[i]), stakerTwo); + } + assertEq(erc721.balanceOf(stakerTwo), 3); + assertEq(erc721.balanceOf(address(stakeContract)), _tokenIdsTwo.length + _tokenIdsOne.length); + + // check available rewards right after staking + (_amountStaked, _availableRewards) = stakeContract.getStakeInfo(stakerTwo); + + assertEq(_amountStaked.length, _tokenIdsTwo.length); + assertEq(_availableRewards, 0); + + //=================== warp timestamp to calculate rewards + vm.roll(300); + vm.warp(3000); + + // check available rewards for stakerOne + (, _availableRewards) = stakeContract.getStakeInfo(stakerOne); + + assertEq( + _availableRewards, + ((((block.timestamp - timeOfLastUpdate_one) * _tokenIdsOne.length) * rewardsPerUnitTime) / timeUnit) + ); + + // check available rewards for stakerTwo + (, _availableRewards) = stakeContract.getStakeInfo(stakerTwo); + + assertEq( + _availableRewards, + ((((block.timestamp - timeOfLastUpdate_two) * _tokenIdsTwo.length) * rewardsPerUnitTime) / timeUnit) + ); + } + + function test_revert_stake_stakingZeroTokens() public { + // stake 0 tokens + uint256[] memory _tokenIds; + + vm.prank(stakerOne); + vm.expectRevert("Staking 0 tokens"); + stakeContract.stake(_tokenIds); + } + + function test_revert_stake_notStaker() public { + // stake unowned tokens + uint256[] memory _tokenIds = new uint256[](1); + _tokenIds[0] = 6; + + vm.prank(stakerOne); + vm.expectRevert("ERC721: transfer from incorrect owner"); + stakeContract.stake(_tokenIds); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: claimRewards + //////////////////////////////////////////////////////////////*/ + + function test_state_claimRewards() public { + //================ first staker ====================== + vm.warp(1); + uint256[] memory _tokenIdsOne = new uint256[](3); + _tokenIdsOne[0] = 0; + _tokenIdsOne[1] = 1; + _tokenIdsOne[2] = 2; + + // stake 3 tokens + vm.prank(stakerOne); + stakeContract.stake(_tokenIdsOne); + uint256 timeOfLastUpdate_one = block.timestamp; + + //=================== warp timestamp to claim rewards + vm.roll(100); + vm.warp(1000); + + uint256 rewardBalanceBefore = stakeContract.getRewardTokenBalance(); + vm.prank(stakerOne); + stakeContract.claimRewards(); + uint256 rewardBalanceAfter = stakeContract.getRewardTokenBalance(); + + // check reward balances + assertEq( + stakerOne.balance, + ((((block.timestamp - timeOfLastUpdate_one) * _tokenIdsOne.length) * rewardsPerUnitTime) / timeUnit) + ); + assertEq( + rewardBalanceAfter, + rewardBalanceBefore - + ((((block.timestamp - timeOfLastUpdate_one) * _tokenIdsOne.length) * rewardsPerUnitTime) / timeUnit) + ); + + // check available rewards after claiming + (uint256[] memory _amountStaked, uint256 _availableRewards) = stakeContract.getStakeInfo(stakerOne); + + assertEq(_amountStaked.length, _tokenIdsOne.length); + assertEq(_availableRewards, 0); + } + + function test_revert_claimRewards_noRewards() public { + vm.warp(1); + uint256[] memory _tokenIdsOne = new uint256[](3); + _tokenIdsOne[0] = 0; + _tokenIdsOne[1] = 1; + _tokenIdsOne[2] = 2; + + // stake 3 tokens + vm.prank(stakerOne); + stakeContract.stake(_tokenIdsOne); + + //=================== try to claim rewards in same block + + vm.prank(stakerOne); + vm.expectRevert("No rewards"); + stakeContract.claimRewards(); + + //======= withdraw tokens and claim rewards + vm.roll(100); + vm.warp(1000); + + vm.prank(stakerOne); + stakeContract.withdraw(_tokenIdsOne); + vm.prank(stakerOne); + stakeContract.claimRewards(); + + //===== try to claim rewards again + vm.roll(200); + vm.warp(2000); + vm.prank(stakerOne); + vm.expectRevert("No rewards"); + stakeContract.claimRewards(); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: stake conditions + //////////////////////////////////////////////////////////////*/ + + function test_state_setRewardsPerUnitTime() public { + // check current value + assertEq(rewardsPerUnitTime, stakeContract.getRewardsPerUnitTime()); + + // set new value and check + uint256 newRewardsPerUnitTime = 50; + vm.prank(deployer); + stakeContract.setRewardsPerUnitTime(newRewardsPerUnitTime); + assertEq(newRewardsPerUnitTime, stakeContract.getRewardsPerUnitTime()); + + //================ stake tokens + vm.warp(1); + uint256[] memory _tokenIdsOne = new uint256[](3); + _tokenIdsOne[0] = 0; + _tokenIdsOne[1] = 1; + _tokenIdsOne[2] = 2; + + // stake 3 tokens + vm.prank(stakerOne); + stakeContract.stake(_tokenIdsOne); + uint256 timeOfLastUpdate = block.timestamp; + + //=================== warp timestamp and again set rewardsPerUnitTime + vm.roll(100); + vm.warp(1000); + + vm.prank(deployer); + stakeContract.setRewardsPerUnitTime(200); + assertEq(200, stakeContract.getRewardsPerUnitTime()); + uint256 newTimeOfLastUpdate = block.timestamp; + + // check available rewards -- should use previous value for rewardsPerUnitTime for calculation + (, uint256 _availableRewards) = stakeContract.getStakeInfo(stakerOne); + + assertEq( + _availableRewards, + ((((block.timestamp - timeOfLastUpdate) * _tokenIdsOne.length) * newRewardsPerUnitTime) / timeUnit) + ); + + //====== check rewards after some time + vm.roll(300); + vm.warp(3000); + + (, uint256 _newRewards) = stakeContract.getStakeInfo(stakerOne); + + assertEq( + _newRewards, + _availableRewards + ((((block.timestamp - newTimeOfLastUpdate) * _tokenIdsOne.length) * 200) / timeUnit) + ); + } + + function test_revert_setRewardsPerUnitTime_notAuthorized() public { + vm.expectRevert("Not authorized"); + stakeContract.setRewardsPerUnitTime(1); + } + + function test_state_setTimeUnit() public { + // check current value + assertEq(timeUnit, stakeContract.getTimeUnit()); + + // set new value and check + uint256 newTimeUnit = 2 minutes; + vm.prank(deployer); + stakeContract.setTimeUnit(newTimeUnit); + assertEq(newTimeUnit, stakeContract.getTimeUnit()); + + //================ stake tokens + vm.warp(1); + uint256[] memory _tokenIdsOne = new uint256[](3); + _tokenIdsOne[0] = 0; + _tokenIdsOne[1] = 1; + _tokenIdsOne[2] = 2; + + // stake 3 tokens + vm.prank(stakerOne); + stakeContract.stake(_tokenIdsOne); + uint256 timeOfLastUpdate = block.timestamp; + + //=================== warp timestamp and again set rewardsPerUnitTime + vm.roll(100); + vm.warp(1000); + + vm.prank(deployer); + stakeContract.setTimeUnit(1 seconds); + assertEq(1 seconds, stakeContract.getTimeUnit()); + uint256 newTimeOfLastUpdate = block.timestamp; + + // check available rewards -- should use previous value for rewardsPerUnitTime for calculation + (, uint256 _availableRewards) = stakeContract.getStakeInfo(stakerOne); + + assertEq( + _availableRewards, + ((((block.timestamp - timeOfLastUpdate) * _tokenIdsOne.length) * rewardsPerUnitTime) / newTimeUnit) + ); + + //====== check rewards after some time + vm.roll(300); + vm.warp(3000); + + (, uint256 _newRewards) = stakeContract.getStakeInfo(stakerOne); + + assertEq( + _newRewards, + _availableRewards + + ((((block.timestamp - newTimeOfLastUpdate) * _tokenIdsOne.length) * rewardsPerUnitTime) / (1 seconds)) + ); + } + + function test_revert_setTimeUnit_notAuthorized() public { + vm.expectRevert("Not authorized"); + stakeContract.setTimeUnit(1); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: withdraw + //////////////////////////////////////////////////////////////*/ + + function test_state_withdraw() public { + //================ first staker ====================== + vm.warp(1); + uint256[] memory _tokenIdsOne = new uint256[](3); + _tokenIdsOne[0] = 0; + _tokenIdsOne[1] = 1; + _tokenIdsOne[2] = 2; + + // stake 3 tokens + vm.prank(stakerOne); + stakeContract.stake(_tokenIdsOne); + uint256 timeOfLastUpdate = block.timestamp; + + // check balances/ownership of staked tokens + for (uint256 i = 0; i < _tokenIdsOne.length; i++) { + assertEq(erc721.ownerOf(_tokenIdsOne[i]), address(stakeContract)); + assertEq(stakeContract.stakerAddress(_tokenIdsOne[i]), stakerOne); + } + assertEq(erc721.balanceOf(stakerOne), 2); + assertEq(erc721.balanceOf(address(stakeContract)), _tokenIdsOne.length); + + // check available rewards right after staking + (uint256[] memory _amountStaked, uint256 _availableRewards) = stakeContract.getStakeInfo(stakerOne); + + assertEq(_amountStaked.length, _tokenIdsOne.length); + assertEq(_availableRewards, 0); + + console.log("==== staked tokens before withdraw ===="); + for (uint256 i = 0; i < _amountStaked.length; i++) { + console.log(_amountStaked[i]); + } + + //========== warp timestamp before withdraw + vm.roll(100); + vm.warp(1000); + + uint256[] memory _tokensToWithdraw = new uint256[](1); + _tokensToWithdraw[0] = 1; + + vm.prank(stakerOne); + stakeContract.withdraw(_tokensToWithdraw); + + // check balances/ownership after withdraw + for (uint256 i = 0; i < _tokensToWithdraw.length; i++) { + assertEq(erc721.ownerOf(_tokensToWithdraw[i]), stakerOne); + assertEq(stakeContract.stakerAddress(_tokensToWithdraw[i]), address(0)); + } + assertEq(erc721.balanceOf(stakerOne), 3); + assertEq(erc721.balanceOf(address(stakeContract)), 2); + + // check available rewards after withdraw + (_amountStaked, _availableRewards) = stakeContract.getStakeInfo(stakerOne); + assertEq(_availableRewards, ((((block.timestamp - timeOfLastUpdate) * 3) * rewardsPerUnitTime) / timeUnit)); + + console.log("==== staked tokens after withdraw ===="); + for (uint256 i = 0; i < _amountStaked.length; i++) { + console.log(_amountStaked[i]); + } + + uint256 timeOfLastUpdateLatest = block.timestamp; + + // check available rewards some time after withdraw + vm.roll(200); + vm.warp(2000); + + (, _availableRewards) = stakeContract.getStakeInfo(stakerOne); + + assertEq( + _availableRewards, + (((((timeOfLastUpdateLatest - timeOfLastUpdate) * 3)) * rewardsPerUnitTime) / timeUnit) + + (((((block.timestamp - timeOfLastUpdateLatest) * 2)) * rewardsPerUnitTime) / timeUnit) + ); + + // stake again + vm.prank(stakerOne); + stakeContract.stake(_tokensToWithdraw); + + _tokensToWithdraw[0] = 5; + vm.prank(stakerTwo); + stakeContract.stake(_tokensToWithdraw); + // check available rewards after re-staking + (_amountStaked, ) = stakeContract.getStakeInfo(stakerOne); + + console.log("==== staked tokens after re-staking ===="); + for (uint256 i = 0; i < _amountStaked.length; i++) { + console.log(_amountStaked[i]); + } + } + + function test_revert_withdraw_withdrawingZeroTokens() public { + uint256[] memory _tokensToWithdraw; + + vm.expectRevert("Withdrawing 0 tokens"); + stakeContract.withdraw(_tokensToWithdraw); + } + + function test_revert_withdraw_notStaker() public { + // stake tokens + uint256[] memory _tokenIds = new uint256[](2); + _tokenIds[0] = 0; + _tokenIds[1] = 1; + + vm.prank(stakerOne); + stakeContract.stake(_tokenIds); + + // trying to withdraw zero tokens + uint256[] memory _tokensToWithdraw = new uint256[](1); + _tokensToWithdraw[0] = 2; + + vm.prank(stakerOne); + vm.expectRevert("Not staker"); + stakeContract.withdraw(_tokensToWithdraw); + } + + function test_revert_withdraw_withdrawingMoreThanStaked() public { + // stake tokens + uint256[] memory _tokenIds = new uint256[](1); + _tokenIds[0] = 0; + + vm.prank(stakerOne); + stakeContract.stake(_tokenIds); + + // trying to withdraw tokens not staked by caller + uint256[] memory _tokensToWithdraw = new uint256[](2); + _tokensToWithdraw[0] = 0; + _tokensToWithdraw[1] = 1; + + vm.prank(stakerOne); + vm.expectRevert("Withdrawing more than staked"); + stakeContract.withdraw(_tokensToWithdraw); + } + + /*/////////////////////////////////////////////////////////////// + Miscellaneous + //////////////////////////////////////////////////////////////*/ + + function test_revert_zeroTimeUnit_adminLockTokens() public { + //================ stake tokens + vm.warp(1); + uint256[] memory _tokenIdsOne = new uint256[](1); + uint256[] memory _tokenIdsTwo = new uint256[](1); + _tokenIdsOne[0] = 0; + _tokenIdsTwo[0] = 5; + + // Two different users stake 1 tokens each + vm.prank(stakerOne); + stakeContract.stake(_tokenIdsOne); + vm.prank(stakerTwo); + stakeContract.stake(_tokenIdsTwo); + + // set timeUnit to zero + uint256 newTimeUnit = 0; + vm.prank(deployer); + vm.expectRevert("time-unit can't be 0"); + stakeContract.setTimeUnit(newTimeUnit); + + // stakerOne and stakerTwo can withdraw their tokens + // vm.expectRevert(stdError.divisionError); + vm.prank(stakerOne); + stakeContract.withdraw(_tokenIdsOne); + + // vm.expectRevert(stdError.divisionError); + vm.prank(stakerTwo); + stakeContract.withdraw(_tokenIdsTwo); + } + + function test_revert_largeRewardsPerUnitTime_adminRewardsLock() public { + //================ stake tokens + vm.warp(1); + uint256[] memory _tokenIdsOne = new uint256[](1); + uint256[] memory _tokenIdsTwo = new uint256[](1); + + uint256 stakerOneToken = erc721.nextTokenIdToMint(); + erc721.mint(stakerOne, 5); // mint token id 0 to 4 + uint256 stakerTwoToken = erc721.nextTokenIdToMint(); + erc721.mint(stakerTwo, 5); // mint token id 5 to 9 + _tokenIdsOne[0] = stakerOneToken; + _tokenIdsTwo[0] = stakerTwoToken; + + // Two users stake 1 tokens each + vm.prank(stakerOne); + stakeContract.stake(_tokenIdsOne); + vm.prank(stakerTwo); + stakeContract.stake(_tokenIdsTwo); + + // set rewardsPerTimeUnit to max value + uint256 rewardsPerTimeUnit = type(uint256).max; + vm.prank(deployer); + stakeContract.setRewardsPerUnitTime(rewardsPerTimeUnit); + + vm.warp(1 days); + + // stakerOne and stakerTwo can't withdraw their tokens + // vm.expectRevert(stdError.arithmeticError); + vm.prank(stakerOne); + stakeContract.withdraw(_tokenIdsOne); + + // vm.expectRevert(stdError.arithmeticError); + vm.prank(stakerTwo); + stakeContract.withdraw(_tokenIdsTwo); + + // rewardsPerTimeUnit can't be changed + rewardsPerTimeUnit = 60; + // vm.expectRevert(stdError.arithmeticError); + vm.prank(deployer); + stakeContract.setRewardsPerUnitTime(rewardsPerTimeUnit); + } + + function test_Macro_NFTDirectSafeTransferLocksToken() public { + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = 0; + + // stakerOne mistakenly safe-transfers direct to the staking contract + vm.prank(stakerOne); + vm.expectRevert("Direct transfer"); + erc721.safeTransferFrom(stakerOne, address(stakeContract), tokenIds[0]); + + // show that the transferred token was not properly staked + // (uint256[] memory tokensStaked, uint256 rewards) = stakeContract.getStakeInfo(stakerOne); + // assertEq(0, tokensStaked.length); + + // // show that stakerOne cannot recover the token + // vm.expectRevert(); + // vm.prank(stakerOne); + // stakeContract.withdraw(tokenIds); + } +} diff --git a/src/test/staking/TokenStake.t.sol b/src/test/staking/TokenStake.t.sol new file mode 100644 index 000000000..f2a7d63ae --- /dev/null +++ b/src/test/staking/TokenStake.t.sol @@ -0,0 +1,876 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { TokenStake } from "contracts/prebuilts/staking/TokenStake.sol"; + +// Test imports + +import "../utils/BaseTest.sol"; + +contract TokenStakeTest is BaseTest { + TokenStake internal stakeContract; + + address internal stakerOne; + address internal stakerTwo; + + uint80 internal timeUnit; + uint256 internal rewardRatioNumerator; + uint256 internal rewardRatioDenominator; + + function setUp() public override { + super.setUp(); + + timeUnit = 60; + rewardRatioNumerator = 3; + rewardRatioDenominator = 50; + + stakerOne = address(0x345); + stakerTwo = address(0x567); + + erc20Aux.mint(stakerOne, 1000); // mint 1000 tokens to stakerOne + erc20Aux.mint(stakerTwo, 1000); // mint 1000 tokens to stakerTwo + + erc20.mint(deployer, 1000 ether); // mint reward tokens to contract admin + + stakeContract = TokenStake(payable(getContract("TokenStake"))); + + // set approvals + vm.prank(stakerOne); + erc20Aux.approve(address(stakeContract), type(uint256).max); + + vm.prank(stakerTwo); + erc20Aux.approve(address(stakeContract), type(uint256).max); + + vm.startPrank(deployer); + erc20.approve(address(stakeContract), type(uint256).max); + stakeContract.depositRewardTokens(100 ether); + // erc20.transfer(address(stakeContract), 100 ether); + vm.stopPrank(); + assertEq(stakeContract.getRewardTokenBalance(), 100 ether); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: Stake + //////////////////////////////////////////////////////////////*/ + + function test_state_stake() public { + //================ first staker ====================== + vm.warp(1); + + // stake 400 tokens + vm.prank(stakerOne); + stakeContract.stake(400); + uint256 timeOfLastUpdate_one = block.timestamp; + + // check balances/ownership of staked tokens + assertEq(erc20Aux.balanceOf(address(stakeContract)), 400); + assertEq(erc20Aux.balanceOf(address(stakerOne)), 600); + + // check available rewards right after staking + (uint256 _amountStaked, uint256 _availableRewards) = stakeContract.getStakeInfo(stakerOne); + + assertEq(_amountStaked, 400); + assertEq(_availableRewards, 0); + + //=================== warp timestamp to calculate rewards + vm.roll(100); + vm.warp(1000); + + // check available rewards after warp + (, _availableRewards) = stakeContract.getStakeInfo(stakerOne); + + assertEq( + _availableRewards, + (((((block.timestamp - timeOfLastUpdate_one) * 400) * rewardRatioNumerator) / timeUnit) / + rewardRatioDenominator) + ); + + //================ second staker ====================== + vm.roll(200); + vm.warp(2000); + + // stake 20 tokens with token-id 0 + vm.prank(stakerTwo); + stakeContract.stake(200); + uint256 timeOfLastUpdate_two = block.timestamp; + + // check balances/ownership of staked tokens + assertEq(erc20Aux.balanceOf(address(stakeContract)), 200 + 400); // sum of staked tokens by both stakers + assertEq(erc20Aux.balanceOf(address(stakerTwo)), 800); + + // check available rewards right after staking + (_amountStaked, _availableRewards) = stakeContract.getStakeInfo(stakerTwo); + + assertEq(_amountStaked, 200); + assertEq(_availableRewards, 0); + + //=================== warp timestamp to calculate rewards + vm.roll(300); + vm.warp(3000); + + // check available rewards for stakerOne + (, _availableRewards) = stakeContract.getStakeInfo(stakerOne); + + assertEq( + _availableRewards, + (((((block.timestamp - timeOfLastUpdate_one) * 400) * rewardRatioNumerator) / timeUnit) / + rewardRatioDenominator) + ); + + // check available rewards for stakerTwo + (, _availableRewards) = stakeContract.getStakeInfo(stakerTwo); + + assertEq( + _availableRewards, + (((((block.timestamp - timeOfLastUpdate_two) * 200) * rewardRatioNumerator) / timeUnit) / + rewardRatioDenominator) + ); + } + + function test_revert_stake_stakingZeroTokens() public { + // stake 0 tokens + + vm.prank(stakerOne); + vm.expectRevert("Staking 0 tokens"); + stakeContract.stake(0); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: claimRewards + //////////////////////////////////////////////////////////////*/ + + function test_state_claimRewards() public { + //================ setup stakerOne ====================== + vm.warp(1); + + // stake 50 tokens with token-id 0 + vm.prank(stakerOne); + stakeContract.stake(400); + uint256 timeOfLastUpdate_one = block.timestamp; + + //=================== warp timestamp to claim rewards + vm.roll(100); + vm.warp(1000); + + uint256 rewardBalanceBefore = stakeContract.getRewardTokenBalance(); + vm.prank(stakerOne); + stakeContract.claimRewards(); + uint256 rewardBalanceAfter = stakeContract.getRewardTokenBalance(); + + // check reward balances + assertEq( + erc20.balanceOf(stakerOne), + (((((block.timestamp - timeOfLastUpdate_one) * 400) * rewardRatioNumerator) / timeUnit) / + rewardRatioDenominator) + ); + assertEq( + rewardBalanceAfter, + rewardBalanceBefore - + (((((block.timestamp - timeOfLastUpdate_one) * 400) * rewardRatioNumerator) / timeUnit) / + rewardRatioDenominator) + ); + + // check available rewards after claiming + (uint256 _amountStaked, uint256 _availableRewards) = stakeContract.getStakeInfo(stakerOne); + + assertEq(_amountStaked, 400); + assertEq(_availableRewards, 0); + + //================ setup stakerTwo ====================== + + // stake 20 tokens with token-id 1 + vm.prank(stakerTwo); + stakeContract.stake(200); + uint256 timeOfLastUpdate_two = block.timestamp; + + //=================== warp timestamp to claim rewards + vm.roll(200); + vm.warp(2000); + + rewardBalanceBefore = stakeContract.getRewardTokenBalance(); + vm.prank(stakerTwo); + stakeContract.claimRewards(); + rewardBalanceAfter = stakeContract.getRewardTokenBalance(); + + // check reward balances + assertEq( + erc20.balanceOf(stakerTwo), + (((((block.timestamp - timeOfLastUpdate_two) * 200) * rewardRatioNumerator) / timeUnit) / + rewardRatioDenominator) + ); + assertEq( + rewardBalanceAfter, + rewardBalanceBefore - + (((((block.timestamp - timeOfLastUpdate_two) * 200) * rewardRatioNumerator) / timeUnit) / + rewardRatioDenominator) + ); + + // check available rewards after claiming -- stakerTwo + (_amountStaked, _availableRewards) = stakeContract.getStakeInfo(stakerTwo); + assertEq(_amountStaked, 200); + assertEq(_availableRewards, 0); + + // check available rewards -- stakerOne + (_amountStaked, _availableRewards) = stakeContract.getStakeInfo(stakerOne); + assertEq(_amountStaked, 400); + assertEq( + _availableRewards, + (((((block.timestamp - timeOfLastUpdate_two) * 400) * rewardRatioNumerator) / timeUnit) / + rewardRatioDenominator) + ); + } + + function test_revert_claimRewards_noRewards() public { + vm.warp(1); + + vm.prank(stakerOne); + stakeContract.stake(400); + + //=================== try to claim rewards in same block + + vm.prank(stakerOne); + vm.expectRevert("No rewards"); + stakeContract.claimRewards(); + + //======= withdraw tokens and claim rewards + vm.roll(100); + vm.warp(1000); + + vm.prank(stakerOne); + stakeContract.withdraw(400); + vm.prank(stakerOne); + stakeContract.claimRewards(); + + //===== try to claim rewards again + vm.roll(200); + vm.warp(2000); + vm.prank(stakerOne); + vm.expectRevert("No rewards"); + stakeContract.claimRewards(); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: stake conditions + //////////////////////////////////////////////////////////////*/ + + function test_state_setRewardRatio() public { + // set value and check + vm.prank(deployer); + stakeContract.setRewardRatio(3, 70); + (uint256 numerator, uint256 denominator) = stakeContract.getRewardRatio(); + assertEq(3, numerator); + assertEq(70, denominator); + + //================ stake tokens + vm.warp(1); + + vm.prank(stakerOne); + stakeContract.stake(400); + uint256 timeOfLastUpdate = block.timestamp; + + //=================== warp timestamp and again set rewardsPerUnitTime + vm.roll(100); + vm.warp(1000); + + vm.prank(deployer); + stakeContract.setRewardRatio(3, 80); + (numerator, denominator) = stakeContract.getRewardRatio(); + assertEq(3, numerator); + assertEq(80, denominator); + uint256 newTimeOfLastUpdate = block.timestamp; + + // check available rewards -- should use previous value for rewardsPerUnitTime for calculation + (, uint256 _availableRewards) = stakeContract.getStakeInfo(stakerOne); + + assertEq(_availableRewards, (((((block.timestamp - timeOfLastUpdate) * 400) * 3) / timeUnit) / 70)); + + //====== check rewards after some time + vm.roll(300); + vm.warp(3000); + + (, uint256 _newRewards) = stakeContract.getStakeInfo(stakerOne); + + assertEq( + _newRewards, + _availableRewards + (((((block.timestamp - newTimeOfLastUpdate) * 400) * 3) / timeUnit) / 80) + ); + } + + function test_state_setTimeUnit() public { + // set value and check + uint80 timeUnitToSet = 100; + vm.prank(deployer); + stakeContract.setTimeUnit(timeUnitToSet); + assertEq(timeUnitToSet, stakeContract.getTimeUnit()); + + //================ stake tokens + vm.warp(1); + + vm.prank(stakerOne); + stakeContract.stake(400); + uint256 timeOfLastUpdate = block.timestamp; + + //=================== warp timestamp and again set timeUnit + vm.roll(100); + vm.warp(1000); + + vm.prank(deployer); + stakeContract.setTimeUnit(200); + assertEq(200, stakeContract.getTimeUnit()); + uint256 newTimeOfLastUpdate = block.timestamp; + + // check available rewards -- should use previous value for timeUnit for calculation + (, uint256 _availableRewards) = stakeContract.getStakeInfo(stakerOne); + + assertEq( + _availableRewards, + (((((block.timestamp - timeOfLastUpdate) * 400) * rewardRatioNumerator) / timeUnitToSet) / + rewardRatioDenominator) + ); + + //====== check rewards after some time + vm.roll(300); + vm.warp(3000); + + (, uint256 _newRewards) = stakeContract.getStakeInfo(stakerOne); + + assertEq( + _newRewards, + _availableRewards + + (((((block.timestamp - newTimeOfLastUpdate) * 400) * rewardRatioNumerator) / 200) / + rewardRatioDenominator) + ); + } + + function test_revert_setRewardRatio_notAuthorized() public { + vm.expectRevert("Not authorized"); + stakeContract.setRewardRatio(1, 2); + } + + function test_revert_setRewardRatio_divideByZero() public { + vm.prank(deployer); + vm.expectRevert("divide by 0"); + stakeContract.setRewardRatio(1, 0); + } + + function test_revert_setTimeUnit_notAuthorized() public { + vm.expectRevert("Not authorized"); + stakeContract.setTimeUnit(1); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: withdraw + //////////////////////////////////////////////////////////////*/ + + function test_state_withdraw() public { + //================ stake different tokens ====================== + vm.warp(1); + + vm.prank(stakerOne); + stakeContract.stake(400); + + vm.prank(stakerTwo); + stakeContract.stake(200); + + uint256 timeOfLastUpdate = block.timestamp; + + //========== warp timestamp before withdraw + vm.roll(100); + vm.warp(1000); + + // withdraw partially for stakerOne + vm.prank(stakerOne); + stakeContract.withdraw(100); + uint256 timeOfLastUpdateLatest = block.timestamp; + + // check balances/ownership after withdraw + assertEq(erc20Aux.balanceOf(stakerOne), 700); + assertEq(erc20Aux.balanceOf(stakerTwo), 800); + assertEq(erc20Aux.balanceOf(address(stakeContract)), (400 - 100) + 200); + + // check available rewards after withdraw + (, uint256 _availableRewards) = stakeContract.getStakeInfo(stakerOne); + assertEq( + _availableRewards, + (((((block.timestamp - timeOfLastUpdate) * 400) * rewardRatioNumerator) / timeUnit) / + rewardRatioDenominator) + ); + + (, _availableRewards) = stakeContract.getStakeInfo(stakerTwo); + assertEq( + _availableRewards, + (((((block.timestamp - timeOfLastUpdate) * 200) * rewardRatioNumerator) / timeUnit) / + rewardRatioDenominator) + ); + + // check available rewards some time after withdraw + vm.roll(200); + vm.warp(2000); + + // check rewards for stakerOne + (, _availableRewards) = stakeContract.getStakeInfo(stakerOne); + + assertEq( + _availableRewards, + ((((((timeOfLastUpdateLatest - timeOfLastUpdate) * 400)) * rewardRatioNumerator) / timeUnit) / + rewardRatioDenominator) + + ((((((block.timestamp - timeOfLastUpdateLatest) * 300)) * rewardRatioNumerator) / timeUnit) / + rewardRatioDenominator) + ); + + // withdraw partially for stakerTwo + vm.prank(stakerTwo); + stakeContract.withdraw(100); + timeOfLastUpdateLatest = block.timestamp; + + // check balances/ownership after withdraw + assertEq(erc20Aux.balanceOf(stakerOne), 700); + assertEq(erc20Aux.balanceOf(stakerTwo), 900); + assertEq(erc20Aux.balanceOf(address(stakeContract)), (400 - 100) + (200 - 100)); + + // check rewards for stakerTwo + (, _availableRewards) = stakeContract.getStakeInfo(stakerTwo); + + assertEq( + _availableRewards, + ((((((block.timestamp - timeOfLastUpdate) * 200)) * rewardRatioNumerator) / timeUnit) / + rewardRatioDenominator) + ); + + // check rewards for stakerTwo after some time + vm.roll(300); + vm.warp(3000); + (, _availableRewards) = stakeContract.getStakeInfo(stakerTwo); + + assertEq( + _availableRewards, + ((((((timeOfLastUpdateLatest - timeOfLastUpdate) * 200)) * rewardRatioNumerator) / timeUnit) / + rewardRatioDenominator) + + ((((((block.timestamp - timeOfLastUpdateLatest) * 100)) * rewardRatioNumerator) / timeUnit) / + rewardRatioDenominator) + ); + } + + function test_revert_withdraw_withdrawingZeroTokens() public { + vm.expectRevert("Withdrawing 0 tokens"); + stakeContract.withdraw(0); + } + + function test_revert_withdraw_withdrawingMoreThanStaked() public { + // stake tokens + vm.prank(stakerOne); + stakeContract.stake(400); + + vm.prank(stakerTwo); + stakeContract.stake(200); + + // trying to withdraw more than staked + vm.roll(200); + vm.warp(2000); + + vm.prank(stakerOne); + vm.expectRevert("Withdrawing more than staked"); + stakeContract.withdraw(500); + + // withdraw partially + vm.prank(stakerOne); + stakeContract.withdraw(300); + + // trying to withdraw more than staked + vm.prank(stakerOne); + vm.expectRevert("Withdrawing more than staked"); + stakeContract.withdraw(500); + + // re-stake + vm.prank(stakerOne); + stakeContract.stake(300); + + // trying to withdraw more than staked + vm.prank(stakerOne); + vm.expectRevert("Withdrawing more than staked"); + stakeContract.withdraw(500); + } + + /*/////////////////////////////////////////////////////////////// + Miscellaneous + //////////////////////////////////////////////////////////////*/ + + function test_revert_zeroTimeUnit_adminLockTokens() public { + //================ stake tokens + vm.warp(1); + + // User stakes tokens + vm.prank(stakerOne); + stakeContract.stake(400); + + // set timeUnit to zero + uint80 newTimeUnit = 0; + vm.prank(deployer); + vm.expectRevert("time-unit can't be 0"); + stakeContract.setTimeUnit(newTimeUnit); + + // stakerOne and stakerTwo can withdraw their tokens + // vm.expectRevert(stdError.divisionError); + vm.prank(stakerOne); + stakeContract.withdraw(400); + } +} + +contract MockERC20Decimals is MockERC20 { + uint8 private immutable DECIMALS; + + constructor(uint8 _decimals) MockERC20() { + DECIMALS = _decimals; + } + + function decimals() public view virtual override returns (uint8) { + return DECIMALS; + } +} + +// Test scenario where reward token has 6 decimals and staking token has 18 +contract Macro_TokenStake_Rewards6_Staking18_Test is BaseTest { + MockERC20Decimals public erc20_reward6; + MockERC20Decimals public erc20_staking18; + + TokenStake internal stakeContract_reward6_staking18; + + address internal stakerOne; + + uint80 internal timeUnit; + uint256 internal rewardRatioNumerator; + uint256 internal rewardRatioDenominator; + + function setUp() public override { + super.setUp(); + + erc20_reward6 = new MockERC20Decimals(6); + erc20_staking18 = new MockERC20Decimals(18); + + // every 60s earns 1 reward token per 2 tokens staked + timeUnit = 60; + rewardRatioNumerator = 1; + rewardRatioDenominator = 2; + + deployContractProxy( + "TokenStake", + abi.encodeCall( + TokenStake.initialize, + ( + deployer, + CONTRACT_URI, + forwarders(), + address(erc20_reward6), + address(erc20_staking18), + timeUnit, + rewardRatioNumerator, + rewardRatioDenominator + ) + ) + ); + + stakeContract_reward6_staking18 = TokenStake(payable(getContract("TokenStake"))); + + stakerOne = address(0x345); + + // mint 1000 tokens to stakerOne + erc20_staking18.mint(stakerOne, 1000e18); + + // mint 1000 reward tokens to contract admin + erc20_reward6.mint(deployer, 1000e6); + + // set approvals + vm.prank(stakerOne); + erc20_staking18.approve(address(stakeContract_reward6_staking18), type(uint256).max); + + // transfer 100 reward tokens + vm.startPrank(deployer); + erc20_reward6.approve(address(stakeContract_reward6_staking18), type(uint256).max); + // erc20_reward6.transfer(address(stakeContract_reward6_staking18), 100e6); + stakeContract_reward6_staking18.depositRewardTokens(100e6); + vm.stopPrank(); + } + + //===== Reward Token 6 Decimals, Staking Token 18 Decimals =====// + function test_Macro_reward6_staking18() public { + vm.warp(1); + + // stake 400 tokens + vm.prank(stakerOne); + stakeContract_reward6_staking18.stake(400e18); + uint256 timeOfLastUpdate = block.timestamp; + + // check balances/ownership of staked tokens + assertEq(erc20_staking18.balanceOf(address(stakeContract_reward6_staking18)), 400e18); + assertEq(erc20_staking18.balanceOf(address(stakerOne)), 600e18); + + // check available rewards right after staking + (uint256 _amountStaked, uint256 _availableRewards) = stakeContract_reward6_staking18.getStakeInfo(stakerOne); + + assertEq(_amountStaked, 400e18); + assertEq(_availableRewards, 0); + + //=================== warp ahead exactly 1 timeUnit: 60s + vm.roll(4); + vm.warp(61); + assertEq(timeUnit, block.timestamp - timeOfLastUpdate); + + // With 400 tokens staked, we expect 200 reward tokens earned + (, _availableRewards) = stakeContract_reward6_staking18.getStakeInfo(stakerOne); + console2.log("Expect 200 reward tokens. Amount earned: ", _availableRewards / 1e6); + assertEq(_availableRewards, 200e6); + } +} + +// Test scenario where reward token has 18 decimals and staking token has 6 +contract Macro_TokenStake_Rewards18_Staking6_Test is BaseTest { + MockERC20Decimals public erc20_reward18; + MockERC20Decimals public erc20_staking6; + + TokenStake internal stakeContract_reward18_staking6; + + address internal stakerOne; + + uint80 internal timeUnit; + uint256 internal rewardRatioNumerator; + uint256 internal rewardRatioDenominator; + + function setUp() public override { + super.setUp(); + + erc20_reward18 = new MockERC20Decimals(18); + erc20_staking6 = new MockERC20Decimals(6); + + // every 60s earns 1 reward token per 2 tokens staked + timeUnit = 60; + rewardRatioNumerator = 1; + rewardRatioDenominator = 2; + + deployContractProxy( + "TokenStake", + abi.encodeCall( + TokenStake.initialize, + ( + deployer, + CONTRACT_URI, + forwarders(), + address(erc20_reward18), + address(erc20_staking6), + timeUnit, + rewardRatioNumerator, + rewardRatioDenominator + ) + ) + ); + + stakeContract_reward18_staking6 = TokenStake(payable(getContract("TokenStake"))); + + stakerOne = address(0x345); + + // mint 1000 tokens to stakerOne + erc20_staking6.mint(stakerOne, 1000e6); + + // mint 1000 reward tokens to contract admin + erc20_reward18.mint(deployer, 1000e18); + + // set approvals + vm.prank(stakerOne); + erc20_staking6.approve(address(stakeContract_reward18_staking6), type(uint256).max); + + // transfer 100 reward tokens + vm.startPrank(deployer); + erc20_reward18.approve(address(stakeContract_reward18_staking6), type(uint256).max); + // erc20_reward18.transfer(address(stakeContract_reward18_staking6), 100e18); + stakeContract_reward18_staking6.depositRewardTokens(100e18); + vm.stopPrank(); + } + + //===== Reward Token 18 Decimals, Staking Token 6 Decimals =====// + function test_Macro_reward18_staking6() public { + vm.warp(1); + + // stake 400 tokens + vm.prank(stakerOne); + stakeContract_reward18_staking6.stake(400e6); + uint256 timeOfLastUpdate = block.timestamp; + + // check balances/ownership of staked tokens + assertEq(erc20_staking6.balanceOf(address(stakeContract_reward18_staking6)), 400e6); + assertEq(erc20_staking6.balanceOf(address(stakerOne)), 600e6); + + // check available rewards right after staking + (uint256 _amountStaked, uint256 _availableRewards) = stakeContract_reward18_staking6.getStakeInfo(stakerOne); + + assertEq(_amountStaked, 400e6); + assertEq(_availableRewards, 0); + + //=================== warp ahead exactly 1 timeUnit: 60s + vm.roll(4); + vm.warp(61); + assertEq(timeUnit, block.timestamp - timeOfLastUpdate); + + // With 400 tokens staked, we expect 200 reward tokens earned + (, _availableRewards) = stakeContract_reward18_staking6.getStakeInfo(stakerOne); + console2.log("Expect 200 reward tokens. Amount earned: ", _availableRewards / 1e18); + assertEq(_availableRewards, 200e18); + } +} + +contract Macro_TokenStakeTest is BaseTest { + TokenStake internal stakeContract; + + uint80 internal timeUnit; + uint256 internal rewardsPerUnitTime; + uint256 internal rewardRatioNumerator; + uint256 internal rewardRatioDenominator; + uint256 internal tokenAmount = 100; + address internal stakerOne = address(0x345); + address internal stakerTwo = address(0x567); + + function setUp() public override { + super.setUp(); + + timeUnit = 60; + rewardRatioNumerator = 3; + rewardRatioDenominator = 50; + // mint 1000 tokens to stakerOne + erc20Aux.mint(stakerOne, tokenAmount); + // mint 1000 tokens to stakerOne + erc20Aux.mint(stakerTwo, tokenAmount); + // mint reward tokens to contract admin + erc20.mint(deployer, 1000 ether); + + stakeContract = TokenStake(payable(getContract("TokenStake"))); + + // set approvals + vm.prank(stakerOne); + erc20Aux.approve(address(stakeContract), type(uint256).max); + vm.prank(stakerTwo); + erc20Aux.approve(address(stakeContract), type(uint256).max); + + vm.startPrank(deployer); + erc20.approve(address(stakeContract), type(uint256).max); + // erc20.transfer(address(stakeContract), 100 ether); + stakeContract.depositRewardTokens(100 ether); + vm.stopPrank(); + } + + // Demostrate setting unitTime to 0 locks the tokens irreversibly + function testToken_adminLockTokens() public { + //================ stake tokens + vm.warp(1); + + // Two users stake 1 tokens each + vm.prank(stakerOne); + stakeContract.stake(tokenAmount); + vm.prank(stakerTwo); + stakeContract.stake(tokenAmount); + + // set timeUnit to zero + uint80 newTimeUnit = 0; + vm.prank(deployer); + vm.expectRevert("time-unit can't be 0"); + stakeContract.setTimeUnit(newTimeUnit); + } + + function testToken_demostrate_adminRewardsLock() public { + //================ stake tokens + vm.warp(1); + // Two users stake 1 tokens each + vm.prank(stakerOne); + stakeContract.stake(tokenAmount); + vm.prank(stakerTwo); + stakeContract.stake(tokenAmount); + + // set timeUnit to a fraction of uint256 maximum value + uint256 newRewardsPerTimeUnit = type(uint256).max / 100; + vm.prank(deployer); + stakeContract.setRewardRatio(newRewardsPerTimeUnit, 1); + + vm.warp(1 days); + + // stakerOne and stakerTwo can't withdraw their tokens + // vm.expectRevert(stdError.arithmeticError); + vm.prank(stakerOne); + stakeContract.withdraw(tokenAmount); + + // vm.expectRevert(stdError.arithmeticError); + vm.prank(stakerTwo); + stakeContract.withdraw(tokenAmount); + + // rewardRatio can't be changed back + newRewardsPerTimeUnit = 60; + // vm.expectRevert(stdError.arithmeticError); + vm.prank(deployer); + stakeContract.setRewardRatio(newRewardsPerTimeUnit, 1); + } +} + +contract Macro_TokenStake_Tax is BaseTest { + TokenStake internal stakeContract; + uint256 internal tokenAmount = 100 ether; + address internal stakerOne = address(0x345); + address internal stakerTwo = address(0x567); + + function setUp() public override { + super.setUp(); + + stakeContract = TokenStake(payable(getContract("TokenStake"))); + + // mint reward tokens to contract admin + erc20.mint(deployer, tokenAmount); + // mint 100 tokens to stakers + erc20Aux.mint(stakerOne, tokenAmount); + erc20Aux.mint(stakerTwo, tokenAmount); + + // Activate Mock tax + erc20Aux.toggleTax(); + + vm.prank(stakerOne); + erc20Aux.approve(address(stakeContract), type(uint256).max); + + vm.startPrank(deployer); + erc20.approve(address(stakeContract), type(uint256).max); + // erc20.transfer(address(stakeContract), 100 ether); + stakeContract.depositRewardTokens(100 ether); + vm.stopPrank(); + } + + // Demonstrate griefer can drain staked tokens for other users + function testToken_demonstrate_inaccurate_amount() public { + // First user stakes 100 tokens + vm.prank(stakerOne); + stakeContract.stake(tokenAmount); + + // Since there is 10% tax only 90 should be in the contract + uint256 stakingTokenBalance = erc20Aux.balanceOf(address(stakeContract)); + assertEq(stakingTokenBalance, 90 ether); + // Assert the amount was correctly assigned + (uint256 stakingTokenAmount, ) = stakeContract.getStakeInfo(stakerOne); + assertEq(stakingTokenAmount, 90 ether); + + // Users stake and withdraw tokens, draining other users staked balances + // for (uint256 i = 1; i <= 9; i++) { + // address staker = vm.addr(i); + // erc20Aux.mint(staker, tokenAmount); + // vm.startPrank(staker); + // erc20Aux.approve(address(stakeContract), type(uint256).max); + // stakeContract.stake(tokenAmount); + // stakeContract.withdraw(tokenAmount); + // vm.stopPrank(); + // } + + // // Staked amount still remains unchanged for stakerOne + // (stakingTokenAmount, ) = stakeContract.getStakeInfo(stakerOne); + // assertEq(stakingTokenAmount, 100 ether); + + // // However there are no tokens left in the contract + // stakingTokenBalance = erc20Aux.balanceOf(address(stakeContract)); + // assertEq(stakingTokenBalance, 0 ether); + + // // StakerOne can't withdraw since there is no balance left + // vm.expectRevert("ERC20: transfer amount exceeds balance"); + // vm.prank(stakerOne); + // stakeContract.withdraw(stakingTokenAmount); + } +} diff --git a/src/test/staking/TokenStake_EthReward.t.sol b/src/test/staking/TokenStake_EthReward.t.sol new file mode 100644 index 000000000..9cef37deb --- /dev/null +++ b/src/test/staking/TokenStake_EthReward.t.sol @@ -0,0 +1,526 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { TokenStake } from "contracts/prebuilts/staking/TokenStake.sol"; + +// Test imports + +import "../utils/BaseTest.sol"; + +contract TokenStakeEthRewardTest is BaseTest { + TokenStake internal stakeContract; + + address internal stakerOne; + address internal stakerTwo; + + uint256 internal timeUnit; + uint256 internal rewardRatioNumerator; + uint256 internal rewardRatioDenominator; + + function setUp() public override { + super.setUp(); + + timeUnit = 60; + rewardRatioNumerator = 3; + rewardRatioDenominator = 50; + + stakerOne = address(0x345); + stakerTwo = address(0x567); + + erc20Aux.mint(stakerOne, 1000); // mint 1000 tokens to stakerOne + erc20Aux.mint(stakerTwo, 1000); // mint 1000 tokens to stakerTwo + + vm.deal(deployer, 1000 ether); // mint reward tokens (Eth) to contract admin + + stakeContract = TokenStake( + payable( + deployContractProxy( + "TokenStake", + abi.encodeCall( + TokenStake.initialize, + (deployer, CONTRACT_URI, forwarders(), NATIVE_TOKEN, address(erc20Aux), 60, 3, 50) + ) + ) + ) + ); + + // set approvals + vm.prank(stakerOne); + erc20Aux.approve(address(stakeContract), type(uint256).max); + + vm.prank(stakerTwo); + erc20Aux.approve(address(stakeContract), type(uint256).max); + + vm.startPrank(deployer); + stakeContract.depositRewardTokens{ value: 100 ether }(100 ether); + // erc20.transfer(address(stakeContract), 100 ether); + vm.stopPrank(); + assertEq(stakeContract.getRewardTokenBalance(), 100 ether); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: Stake + //////////////////////////////////////////////////////////////*/ + + function test_state_stake() public { + //================ first staker ====================== + vm.warp(1); + + // stake 400 tokens + vm.prank(stakerOne); + stakeContract.stake(400); + uint256 timeOfLastUpdate_one = block.timestamp; + + // check balances/ownership of staked tokens + assertEq(erc20Aux.balanceOf(address(stakeContract)), 400); + assertEq(erc20Aux.balanceOf(address(stakerOne)), 600); + + // check available rewards right after staking + (uint256 _amountStaked, uint256 _availableRewards) = stakeContract.getStakeInfo(stakerOne); + + assertEq(_amountStaked, 400); + assertEq(_availableRewards, 0); + + //=================== warp timestamp to calculate rewards + vm.roll(100); + vm.warp(1000); + + // check available rewards after warp + (, _availableRewards) = stakeContract.getStakeInfo(stakerOne); + + assertEq( + _availableRewards, + (((((block.timestamp - timeOfLastUpdate_one) * 400) * rewardRatioNumerator) / timeUnit) / + rewardRatioDenominator) + ); + + //================ second staker ====================== + vm.roll(200); + vm.warp(2000); + + // stake 20 tokens with token-id 0 + vm.prank(stakerTwo); + stakeContract.stake(200); + uint256 timeOfLastUpdate_two = block.timestamp; + + // check balances/ownership of staked tokens + assertEq(erc20Aux.balanceOf(address(stakeContract)), 200 + 400); // sum of staked tokens by both stakers + assertEq(erc20Aux.balanceOf(address(stakerTwo)), 800); + + // check available rewards right after staking + (_amountStaked, _availableRewards) = stakeContract.getStakeInfo(stakerTwo); + + assertEq(_amountStaked, 200); + assertEq(_availableRewards, 0); + + //=================== warp timestamp to calculate rewards + vm.roll(300); + vm.warp(3000); + + // check available rewards for stakerOne + (, _availableRewards) = stakeContract.getStakeInfo(stakerOne); + + assertEq( + _availableRewards, + (((((block.timestamp - timeOfLastUpdate_one) * 400) * rewardRatioNumerator) / timeUnit) / + rewardRatioDenominator) + ); + + // check available rewards for stakerTwo + (, _availableRewards) = stakeContract.getStakeInfo(stakerTwo); + + assertEq( + _availableRewards, + (((((block.timestamp - timeOfLastUpdate_two) * 200) * rewardRatioNumerator) / timeUnit) / + rewardRatioDenominator) + ); + } + + function test_revert_stake_stakingZeroTokens() public { + // stake 0 tokens + + vm.prank(stakerOne); + vm.expectRevert("Staking 0 tokens"); + stakeContract.stake(0); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: claimRewards + //////////////////////////////////////////////////////////////*/ + + function test_state_claimRewards() public { + //================ setup stakerOne ====================== + vm.warp(1); + + // stake 50 tokens with token-id 0 + vm.prank(stakerOne); + stakeContract.stake(400); + uint256 timeOfLastUpdate_one = block.timestamp; + + //=================== warp timestamp to claim rewards + vm.roll(100); + vm.warp(1000); + + uint256 rewardBalanceBefore = stakeContract.getRewardTokenBalance(); + vm.prank(stakerOne); + stakeContract.claimRewards(); + uint256 rewardBalanceAfter = stakeContract.getRewardTokenBalance(); + + // check reward balances + assertEq( + stakerOne.balance, + (((((block.timestamp - timeOfLastUpdate_one) * 400) * rewardRatioNumerator) / timeUnit) / + rewardRatioDenominator) + ); + assertEq( + rewardBalanceAfter, + rewardBalanceBefore - + (((((block.timestamp - timeOfLastUpdate_one) * 400) * rewardRatioNumerator) / timeUnit) / + rewardRatioDenominator) + ); + + // check available rewards after claiming + (uint256 _amountStaked, uint256 _availableRewards) = stakeContract.getStakeInfo(stakerOne); + + assertEq(_amountStaked, 400); + assertEq(_availableRewards, 0); + + //================ setup stakerTwo ====================== + + // stake 20 tokens with token-id 1 + vm.prank(stakerTwo); + stakeContract.stake(200); + uint256 timeOfLastUpdate_two = block.timestamp; + + //=================== warp timestamp to claim rewards + vm.roll(200); + vm.warp(2000); + + rewardBalanceBefore = stakeContract.getRewardTokenBalance(); + vm.prank(stakerTwo); + stakeContract.claimRewards(); + rewardBalanceAfter = stakeContract.getRewardTokenBalance(); + + // check reward balances + assertEq( + stakerTwo.balance, + (((((block.timestamp - timeOfLastUpdate_two) * 200) * rewardRatioNumerator) / timeUnit) / + rewardRatioDenominator) + ); + assertEq( + rewardBalanceAfter, + rewardBalanceBefore - + (((((block.timestamp - timeOfLastUpdate_two) * 200) * rewardRatioNumerator) / timeUnit) / + rewardRatioDenominator) + ); + + // check available rewards after claiming -- stakerTwo + (_amountStaked, _availableRewards) = stakeContract.getStakeInfo(stakerTwo); + assertEq(_amountStaked, 200); + assertEq(_availableRewards, 0); + + // check available rewards -- stakerOne + (_amountStaked, _availableRewards) = stakeContract.getStakeInfo(stakerOne); + assertEq(_amountStaked, 400); + assertEq( + _availableRewards, + (((((block.timestamp - timeOfLastUpdate_two) * 400) * rewardRatioNumerator) / timeUnit) / + rewardRatioDenominator) + ); + } + + function test_revert_claimRewards_noRewards() public { + vm.warp(1); + + vm.prank(stakerOne); + stakeContract.stake(400); + + //=================== try to claim rewards in same block + + vm.prank(stakerOne); + vm.expectRevert("No rewards"); + stakeContract.claimRewards(); + + //======= withdraw tokens and claim rewards + vm.roll(100); + vm.warp(1000); + + vm.prank(stakerOne); + stakeContract.withdraw(400); + vm.prank(stakerOne); + stakeContract.claimRewards(); + + //===== try to claim rewards again + vm.roll(200); + vm.warp(2000); + vm.prank(stakerOne); + vm.expectRevert("No rewards"); + stakeContract.claimRewards(); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: stake conditions + //////////////////////////////////////////////////////////////*/ + + function test_state_setRewardRatio() public { + // set value and check + vm.prank(deployer); + stakeContract.setRewardRatio(3, 70); + (uint256 numerator, uint256 denominator) = stakeContract.getRewardRatio(); + assertEq(3, numerator); + assertEq(70, denominator); + + //================ stake tokens + vm.warp(1); + + vm.prank(stakerOne); + stakeContract.stake(400); + uint256 timeOfLastUpdate = block.timestamp; + + //=================== warp timestamp and again set rewardsPerUnitTime + vm.roll(100); + vm.warp(1000); + + vm.prank(deployer); + stakeContract.setRewardRatio(3, 80); + (numerator, denominator) = stakeContract.getRewardRatio(); + assertEq(3, numerator); + assertEq(80, denominator); + uint256 newTimeOfLastUpdate = block.timestamp; + + // check available rewards -- should use previous value for rewardsPerUnitTime for calculation + (, uint256 _availableRewards) = stakeContract.getStakeInfo(stakerOne); + + assertEq(_availableRewards, (((((block.timestamp - timeOfLastUpdate) * 400) * 3) / timeUnit) / 70)); + + //====== check rewards after some time + vm.roll(300); + vm.warp(3000); + + (, uint256 _newRewards) = stakeContract.getStakeInfo(stakerOne); + + assertEq( + _newRewards, + _availableRewards + (((((block.timestamp - newTimeOfLastUpdate) * 400) * 3) / timeUnit) / 80) + ); + } + + function test_state_setTimeUnit() public { + // set value and check + uint80 timeUnitToSet = 100; + vm.prank(deployer); + stakeContract.setTimeUnit(timeUnitToSet); + assertEq(timeUnitToSet, stakeContract.getTimeUnit()); + + //================ stake tokens + vm.warp(1); + + vm.prank(stakerOne); + stakeContract.stake(400); + uint256 timeOfLastUpdate = block.timestamp; + + //=================== warp timestamp and again set timeUnit + vm.roll(100); + vm.warp(1000); + + vm.prank(deployer); + stakeContract.setTimeUnit(200); + assertEq(200, stakeContract.getTimeUnit()); + uint256 newTimeOfLastUpdate = block.timestamp; + + // check available rewards -- should use previous value for timeUnit for calculation + (, uint256 _availableRewards) = stakeContract.getStakeInfo(stakerOne); + + assertEq( + _availableRewards, + (((((block.timestamp - timeOfLastUpdate) * 400) * rewardRatioNumerator) / timeUnitToSet) / + rewardRatioDenominator) + ); + + //====== check rewards after some time + vm.roll(300); + vm.warp(3000); + + (, uint256 _newRewards) = stakeContract.getStakeInfo(stakerOne); + + assertEq( + _newRewards, + _availableRewards + + (((((block.timestamp - newTimeOfLastUpdate) * 400) * rewardRatioNumerator) / 200) / + rewardRatioDenominator) + ); + } + + function test_revert_setRewardRatio_notAuthorized() public { + vm.expectRevert("Not authorized"); + stakeContract.setRewardRatio(1, 2); + } + + function test_revert_setRewardRatio_divideByZero() public { + vm.prank(deployer); + vm.expectRevert("divide by 0"); + stakeContract.setRewardRatio(1, 0); + } + + function test_revert_setTimeUnit_notAuthorized() public { + vm.expectRevert("Not authorized"); + stakeContract.setTimeUnit(1); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: withdraw + //////////////////////////////////////////////////////////////*/ + + function test_state_withdraw() public { + //================ stake different tokens ====================== + vm.warp(1); + + vm.prank(stakerOne); + stakeContract.stake(400); + + vm.prank(stakerTwo); + stakeContract.stake(200); + + uint256 timeOfLastUpdate = block.timestamp; + + //========== warp timestamp before withdraw + vm.roll(100); + vm.warp(1000); + + // withdraw partially for stakerOne + vm.prank(stakerOne); + stakeContract.withdraw(100); + uint256 timeOfLastUpdateLatest = block.timestamp; + + // check balances/ownership after withdraw + assertEq(erc20Aux.balanceOf(stakerOne), 700); + assertEq(erc20Aux.balanceOf(stakerTwo), 800); + assertEq(erc20Aux.balanceOf(address(stakeContract)), (400 - 100) + 200); + + // check available rewards after withdraw + (, uint256 _availableRewards) = stakeContract.getStakeInfo(stakerOne); + assertEq( + _availableRewards, + (((((block.timestamp - timeOfLastUpdate) * 400) * rewardRatioNumerator) / timeUnit) / + rewardRatioDenominator) + ); + + (, _availableRewards) = stakeContract.getStakeInfo(stakerTwo); + assertEq( + _availableRewards, + (((((block.timestamp - timeOfLastUpdate) * 200) * rewardRatioNumerator) / timeUnit) / + rewardRatioDenominator) + ); + + // check available rewards some time after withdraw + vm.roll(200); + vm.warp(2000); + + // check rewards for stakerOne + (, _availableRewards) = stakeContract.getStakeInfo(stakerOne); + + assertEq( + _availableRewards, + ((((((timeOfLastUpdateLatest - timeOfLastUpdate) * 400)) * rewardRatioNumerator) / timeUnit) / + rewardRatioDenominator) + + ((((((block.timestamp - timeOfLastUpdateLatest) * 300)) * rewardRatioNumerator) / timeUnit) / + rewardRatioDenominator) + ); + + // withdraw partially for stakerTwo + vm.prank(stakerTwo); + stakeContract.withdraw(100); + timeOfLastUpdateLatest = block.timestamp; + + // check balances/ownership after withdraw + assertEq(erc20Aux.balanceOf(stakerOne), 700); + assertEq(erc20Aux.balanceOf(stakerTwo), 900); + assertEq(erc20Aux.balanceOf(address(stakeContract)), (400 - 100) + (200 - 100)); + + // check rewards for stakerTwo + (, _availableRewards) = stakeContract.getStakeInfo(stakerTwo); + + assertEq( + _availableRewards, + ((((((block.timestamp - timeOfLastUpdate) * 200)) * rewardRatioNumerator) / timeUnit) / + rewardRatioDenominator) + ); + + // check rewards for stakerTwo after some time + vm.roll(300); + vm.warp(3000); + (, _availableRewards) = stakeContract.getStakeInfo(stakerTwo); + + assertEq( + _availableRewards, + ((((((timeOfLastUpdateLatest - timeOfLastUpdate) * 200)) * rewardRatioNumerator) / timeUnit) / + rewardRatioDenominator) + + ((((((block.timestamp - timeOfLastUpdateLatest) * 100)) * rewardRatioNumerator) / timeUnit) / + rewardRatioDenominator) + ); + } + + function test_revert_withdraw_withdrawingZeroTokens() public { + vm.expectRevert("Withdrawing 0 tokens"); + stakeContract.withdraw(0); + } + + function test_revert_withdraw_withdrawingMoreThanStaked() public { + // stake tokens + vm.prank(stakerOne); + stakeContract.stake(400); + + vm.prank(stakerTwo); + stakeContract.stake(200); + + // trying to withdraw more than staked + vm.roll(200); + vm.warp(2000); + + vm.prank(stakerOne); + vm.expectRevert("Withdrawing more than staked"); + stakeContract.withdraw(500); + + // withdraw partially + vm.prank(stakerOne); + stakeContract.withdraw(300); + + // trying to withdraw more than staked + vm.prank(stakerOne); + vm.expectRevert("Withdrawing more than staked"); + stakeContract.withdraw(500); + + // re-stake + vm.prank(stakerOne); + stakeContract.stake(300); + + // trying to withdraw more than staked + vm.prank(stakerOne); + vm.expectRevert("Withdrawing more than staked"); + stakeContract.withdraw(500); + } + + /*/////////////////////////////////////////////////////////////// + Miscellaneous + //////////////////////////////////////////////////////////////*/ + + function test_revert_zeroTimeUnit_adminLockTokens() public { + //================ stake tokens + vm.warp(1); + + // User stakes tokens + vm.prank(stakerOne); + stakeContract.stake(400); + + // set timeUnit to zero + uint80 newTimeUnit = 0; + vm.prank(deployer); + vm.expectRevert("time-unit can't be 0"); + stakeContract.setTimeUnit(newTimeUnit); + + // stakerOne and stakerTwo can withdraw their tokens + // vm.expectRevert(stdError.divisionError); + vm.prank(stakerOne); + stakeContract.withdraw(400); + } +} diff --git a/src/test/staking/TokenStake_EthStake.t.sol b/src/test/staking/TokenStake_EthStake.t.sol new file mode 100644 index 000000000..8630d08a9 --- /dev/null +++ b/src/test/staking/TokenStake_EthStake.t.sol @@ -0,0 +1,520 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { TokenStake } from "contracts/prebuilts/staking/TokenStake.sol"; + +// Test imports + +import "../utils/BaseTest.sol"; + +contract TokenStakeEthStakeTest is BaseTest { + TokenStake internal stakeContract; + + address internal stakerOne; + address internal stakerTwo; + + uint256 internal timeUnit; + uint256 internal rewardRatioNumerator; + uint256 internal rewardRatioDenominator; + + function setUp() public override { + super.setUp(); + + timeUnit = 60; + rewardRatioNumerator = 3; + rewardRatioDenominator = 50; + + stakerOne = address(0x345); + stakerTwo = address(0x567); + + vm.deal(stakerOne, 1000); // mint 1000 tokens to stakerOne + vm.deal(stakerTwo, 1000); // mint 1000 tokens to stakerTwo + + erc20.mint(deployer, 1000 ether); // mint reward tokens to contract admin + + stakeContract = TokenStake( + payable( + deployContractProxy( + "TokenStake", + abi.encodeCall( + TokenStake.initialize, + (deployer, CONTRACT_URI, forwarders(), address(erc20), NATIVE_TOKEN, 60, 3, 50) + ) + ) + ) + ); + + vm.startPrank(deployer); + erc20.approve(address(stakeContract), type(uint256).max); + stakeContract.depositRewardTokens(100 ether); + // erc20.transfer(address(stakeContract), 100 ether); + vm.stopPrank(); + assertEq(stakeContract.getRewardTokenBalance(), 100 ether); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: Stake + //////////////////////////////////////////////////////////////*/ + + function test_state_stake() public { + //================ first staker ====================== + vm.warp(1); + + // stake 400 tokens + vm.prank(stakerOne); + stakeContract.stake{ value: 400 }(400); + uint256 timeOfLastUpdate_one = block.timestamp; + + // check balances/ownership of staked tokens + assertEq(weth.balanceOf(address(stakeContract)), 400); + assertEq(address(stakerOne).balance, 600); + + // check available rewards right after staking + (uint256 _amountStaked, uint256 _availableRewards) = stakeContract.getStakeInfo(stakerOne); + + assertEq(_amountStaked, 400); + assertEq(_availableRewards, 0); + + //=================== warp timestamp to calculate rewards + vm.roll(100); + vm.warp(1000); + + // check available rewards after warp + (, _availableRewards) = stakeContract.getStakeInfo(stakerOne); + + assertEq( + _availableRewards, + (((((block.timestamp - timeOfLastUpdate_one) * 400) * rewardRatioNumerator) / timeUnit) / + rewardRatioDenominator) + ); + + //================ second staker ====================== + vm.roll(200); + vm.warp(2000); + + // stake 20 tokens with token-id 0 + vm.prank(stakerTwo); + stakeContract.stake{ value: 200 }(200); + uint256 timeOfLastUpdate_two = block.timestamp; + + // check balances/ownership of staked tokens + assertEq(weth.balanceOf(address(stakeContract)), 200 + 400); // sum of staked tokens by both stakers + assertEq(address(stakerTwo).balance, 800); + + // check available rewards right after staking + (_amountStaked, _availableRewards) = stakeContract.getStakeInfo(stakerTwo); + + assertEq(_amountStaked, 200); + assertEq(_availableRewards, 0); + + //=================== warp timestamp to calculate rewards + vm.roll(300); + vm.warp(3000); + + // check available rewards for stakerOne + (, _availableRewards) = stakeContract.getStakeInfo(stakerOne); + + assertEq( + _availableRewards, + (((((block.timestamp - timeOfLastUpdate_one) * 400) * rewardRatioNumerator) / timeUnit) / + rewardRatioDenominator) + ); + + // check available rewards for stakerTwo + (, _availableRewards) = stakeContract.getStakeInfo(stakerTwo); + + assertEq( + _availableRewards, + (((((block.timestamp - timeOfLastUpdate_two) * 200) * rewardRatioNumerator) / timeUnit) / + rewardRatioDenominator) + ); + } + + function test_revert_stake_stakingZeroTokens() public { + // stake 0 tokens + + vm.prank(stakerOne); + vm.expectRevert("Staking 0 tokens"); + stakeContract.stake(0); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: claimRewards + //////////////////////////////////////////////////////////////*/ + + function test_state_claimRewards() public { + //================ setup stakerOne ====================== + vm.warp(1); + + // stake 50 tokens with token-id 0 + vm.prank(stakerOne); + stakeContract.stake{ value: 400 }(400); + uint256 timeOfLastUpdate_one = block.timestamp; + + //=================== warp timestamp to claim rewards + vm.roll(100); + vm.warp(1000); + + uint256 rewardBalanceBefore = stakeContract.getRewardTokenBalance(); + vm.prank(stakerOne); + stakeContract.claimRewards(); + uint256 rewardBalanceAfter = stakeContract.getRewardTokenBalance(); + + // check reward balances + assertEq( + erc20.balanceOf(stakerOne), + (((((block.timestamp - timeOfLastUpdate_one) * 400) * rewardRatioNumerator) / timeUnit) / + rewardRatioDenominator) + ); + assertEq( + rewardBalanceAfter, + rewardBalanceBefore - + (((((block.timestamp - timeOfLastUpdate_one) * 400) * rewardRatioNumerator) / timeUnit) / + rewardRatioDenominator) + ); + + // check available rewards after claiming + (uint256 _amountStaked, uint256 _availableRewards) = stakeContract.getStakeInfo(stakerOne); + + assertEq(_amountStaked, 400); + assertEq(_availableRewards, 0); + + //================ setup stakerTwo ====================== + + // stake 20 tokens with token-id 1 + vm.prank(stakerTwo); + stakeContract.stake{ value: 200 }(200); + uint256 timeOfLastUpdate_two = block.timestamp; + + //=================== warp timestamp to claim rewards + vm.roll(200); + vm.warp(2000); + + rewardBalanceBefore = stakeContract.getRewardTokenBalance(); + vm.prank(stakerTwo); + stakeContract.claimRewards(); + rewardBalanceAfter = stakeContract.getRewardTokenBalance(); + + // check reward balances + assertEq( + erc20.balanceOf(stakerTwo), + (((((block.timestamp - timeOfLastUpdate_two) * 200) * rewardRatioNumerator) / timeUnit) / + rewardRatioDenominator) + ); + assertEq( + rewardBalanceAfter, + rewardBalanceBefore - + (((((block.timestamp - timeOfLastUpdate_two) * 200) * rewardRatioNumerator) / timeUnit) / + rewardRatioDenominator) + ); + + // check available rewards after claiming -- stakerTwo + (_amountStaked, _availableRewards) = stakeContract.getStakeInfo(stakerTwo); + assertEq(_amountStaked, 200); + assertEq(_availableRewards, 0); + + // check available rewards -- stakerOne + (_amountStaked, _availableRewards) = stakeContract.getStakeInfo(stakerOne); + assertEq(_amountStaked, 400); + assertEq( + _availableRewards, + (((((block.timestamp - timeOfLastUpdate_two) * 400) * rewardRatioNumerator) / timeUnit) / + rewardRatioDenominator) + ); + } + + function test_revert_claimRewards_noRewards() public { + vm.warp(1); + + vm.prank(stakerOne); + stakeContract.stake{ value: 400 }(400); + + //=================== try to claim rewards in same block + + vm.prank(stakerOne); + vm.expectRevert("No rewards"); + stakeContract.claimRewards(); + + //======= withdraw tokens and claim rewards + vm.roll(100); + vm.warp(1000); + + vm.prank(stakerOne); + stakeContract.withdraw(400); + vm.prank(stakerOne); + stakeContract.claimRewards(); + + //===== try to claim rewards again + vm.roll(200); + vm.warp(2000); + vm.prank(stakerOne); + vm.expectRevert("No rewards"); + stakeContract.claimRewards(); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: stake conditions + //////////////////////////////////////////////////////////////*/ + + function test_state_setRewardRatio() public { + // set value and check + vm.prank(deployer); + stakeContract.setRewardRatio(3, 70); + (uint256 numerator, uint256 denominator) = stakeContract.getRewardRatio(); + assertEq(3, numerator); + assertEq(70, denominator); + + //================ stake tokens + vm.warp(1); + + vm.prank(stakerOne); + stakeContract.stake{ value: 400 }(400); + uint256 timeOfLastUpdate = block.timestamp; + + //=================== warp timestamp and again set rewardsPerUnitTime + vm.roll(100); + vm.warp(1000); + + vm.prank(deployer); + stakeContract.setRewardRatio(3, 80); + (numerator, denominator) = stakeContract.getRewardRatio(); + assertEq(3, numerator); + assertEq(80, denominator); + uint256 newTimeOfLastUpdate = block.timestamp; + + // check available rewards -- should use previous value for rewardsPerUnitTime for calculation + (, uint256 _availableRewards) = stakeContract.getStakeInfo(stakerOne); + + assertEq(_availableRewards, (((((block.timestamp - timeOfLastUpdate) * 400) * 3) / timeUnit) / 70)); + + //====== check rewards after some time + vm.roll(300); + vm.warp(3000); + + (, uint256 _newRewards) = stakeContract.getStakeInfo(stakerOne); + + assertEq( + _newRewards, + _availableRewards + (((((block.timestamp - newTimeOfLastUpdate) * 400) * 3) / timeUnit) / 80) + ); + } + + function test_state_setTimeUnit() public { + // set value and check + uint80 timeUnitToSet = 100; + vm.prank(deployer); + stakeContract.setTimeUnit(timeUnitToSet); + assertEq(timeUnitToSet, stakeContract.getTimeUnit()); + + //================ stake tokens + vm.warp(1); + + vm.prank(stakerOne); + stakeContract.stake{ value: 400 }(400); + uint256 timeOfLastUpdate = block.timestamp; + + //=================== warp timestamp and again set timeUnit + vm.roll(100); + vm.warp(1000); + + vm.prank(deployer); + stakeContract.setTimeUnit(200); + assertEq(200, stakeContract.getTimeUnit()); + uint256 newTimeOfLastUpdate = block.timestamp; + + // check available rewards -- should use previous value for timeUnit for calculation + (, uint256 _availableRewards) = stakeContract.getStakeInfo(stakerOne); + + assertEq( + _availableRewards, + (((((block.timestamp - timeOfLastUpdate) * 400) * rewardRatioNumerator) / timeUnitToSet) / + rewardRatioDenominator) + ); + + //====== check rewards after some time + vm.roll(300); + vm.warp(3000); + + (, uint256 _newRewards) = stakeContract.getStakeInfo(stakerOne); + + assertEq( + _newRewards, + _availableRewards + + (((((block.timestamp - newTimeOfLastUpdate) * 400) * rewardRatioNumerator) / 200) / + rewardRatioDenominator) + ); + } + + function test_revert_setRewardRatio_notAuthorized() public { + vm.expectRevert("Not authorized"); + stakeContract.setRewardRatio(1, 2); + } + + function test_revert_setRewardRatio_divideByZero() public { + vm.prank(deployer); + vm.expectRevert("divide by 0"); + stakeContract.setRewardRatio(1, 0); + } + + function test_revert_setTimeUnit_notAuthorized() public { + vm.expectRevert("Not authorized"); + stakeContract.setTimeUnit(1); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: withdraw + //////////////////////////////////////////////////////////////*/ + + function test_state_withdraw() public { + //================ stake different tokens ====================== + vm.warp(1); + + vm.prank(stakerOne); + stakeContract.stake{ value: 400 }(400); + + vm.prank(stakerTwo); + stakeContract.stake{ value: 200 }(200); + + uint256 timeOfLastUpdate = block.timestamp; + + //========== warp timestamp before withdraw + vm.roll(100); + vm.warp(1000); + + // withdraw partially for stakerOne + vm.prank(stakerOne); + stakeContract.withdraw(100); + uint256 timeOfLastUpdateLatest = block.timestamp; + + // check balances/ownership after withdraw + assertEq(stakerOne.balance, 700); + assertEq(stakerTwo.balance, 800); + assertEq(weth.balanceOf(address(stakeContract)), (400 - 100) + 200); + + // check available rewards after withdraw + (, uint256 _availableRewards) = stakeContract.getStakeInfo(stakerOne); + assertEq( + _availableRewards, + (((((block.timestamp - timeOfLastUpdate) * 400) * rewardRatioNumerator) / timeUnit) / + rewardRatioDenominator) + ); + + (, _availableRewards) = stakeContract.getStakeInfo(stakerTwo); + assertEq( + _availableRewards, + (((((block.timestamp - timeOfLastUpdate) * 200) * rewardRatioNumerator) / timeUnit) / + rewardRatioDenominator) + ); + + // check available rewards some time after withdraw + vm.roll(200); + vm.warp(2000); + + // check rewards for stakerOne + (, _availableRewards) = stakeContract.getStakeInfo(stakerOne); + + assertEq( + _availableRewards, + ((((((timeOfLastUpdateLatest - timeOfLastUpdate) * 400)) * rewardRatioNumerator) / timeUnit) / + rewardRatioDenominator) + + ((((((block.timestamp - timeOfLastUpdateLatest) * 300)) * rewardRatioNumerator) / timeUnit) / + rewardRatioDenominator) + ); + + // withdraw partially for stakerTwo + vm.prank(stakerTwo); + stakeContract.withdraw(100); + timeOfLastUpdateLatest = block.timestamp; + + // check balances/ownership after withdraw + assertEq(stakerOne.balance, 700); + assertEq(stakerTwo.balance, 900); + assertEq(weth.balanceOf(address(stakeContract)), (400 - 100) + (200 - 100)); + + // check rewards for stakerTwo + (, _availableRewards) = stakeContract.getStakeInfo(stakerTwo); + + assertEq( + _availableRewards, + ((((((block.timestamp - timeOfLastUpdate) * 200)) * rewardRatioNumerator) / timeUnit) / + rewardRatioDenominator) + ); + + // check rewards for stakerTwo after some time + vm.roll(300); + vm.warp(3000); + (, _availableRewards) = stakeContract.getStakeInfo(stakerTwo); + + assertEq( + _availableRewards, + ((((((timeOfLastUpdateLatest - timeOfLastUpdate) * 200)) * rewardRatioNumerator) / timeUnit) / + rewardRatioDenominator) + + ((((((block.timestamp - timeOfLastUpdateLatest) * 100)) * rewardRatioNumerator) / timeUnit) / + rewardRatioDenominator) + ); + } + + function test_revert_withdraw_withdrawingZeroTokens() public { + vm.expectRevert("Withdrawing 0 tokens"); + stakeContract.withdraw(0); + } + + function test_revert_withdraw_withdrawingMoreThanStaked() public { + // stake tokens + vm.prank(stakerOne); + stakeContract.stake{ value: 400 }(400); + + vm.prank(stakerTwo); + stakeContract.stake{ value: 200 }(200); + + // trying to withdraw more than staked + vm.roll(200); + vm.warp(2000); + + vm.prank(stakerOne); + vm.expectRevert("Withdrawing more than staked"); + stakeContract.withdraw(500); + + // withdraw partially + vm.prank(stakerOne); + stakeContract.withdraw(300); + + // trying to withdraw more than staked + vm.prank(stakerOne); + vm.expectRevert("Withdrawing more than staked"); + stakeContract.withdraw(500); + + // re-stake + vm.prank(stakerOne); + stakeContract.stake{ value: 300 }(300); + + // trying to withdraw more than staked + vm.prank(stakerOne); + vm.expectRevert("Withdrawing more than staked"); + stakeContract.withdraw(500); + } + + /*/////////////////////////////////////////////////////////////// + Miscellaneous + //////////////////////////////////////////////////////////////*/ + + function test_revert_zeroTimeUnit_adminLockTokens() public { + //================ stake tokens + vm.warp(1); + + // User stakes tokens + vm.prank(stakerOne); + stakeContract.stake{ value: 400 }(400); + + // set timeUnit to zero + uint80 newTimeUnit = 0; + vm.prank(deployer); + vm.expectRevert("time-unit can't be 0"); + stakeContract.setTimeUnit(newTimeUnit); + + // stakerOne and stakerTwo can withdraw their tokens + // vm.expectRevert(stdError.divisionError); + vm.prank(stakerOne); + stakeContract.withdraw(400); + } +} diff --git a/src/test/token/TokenERC1155.t.sol b/src/test/token/TokenERC1155.t.sol new file mode 100644 index 000000000..ad7dbcaf8 --- /dev/null +++ b/src/test/token/TokenERC1155.t.sol @@ -0,0 +1,937 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { TokenERC1155, IPlatformFee, NFTMetadata } from "contracts/prebuilts/token/TokenERC1155.sol"; + +// Test imports + +import "../utils/BaseTest.sol"; +import "@openzeppelin/contracts-upgradeable/utils/StringsUpgradeable.sol"; + +contract TokenERC1155Test is BaseTest { + using Strings for uint256; + + event TokensMinted(address indexed mintedTo, uint256 indexed tokenIdMinted, string uri, uint256 quantityMinted); + event TokensMintedWithSignature( + address indexed signer, + address indexed mintedTo, + uint256 indexed tokenIdMinted, + TokenERC1155.MintRequest mintRequest + ); + event OwnerUpdated(address indexed prevOwner, address indexed newOwner); + event DefaultRoyalty(address indexed newRoyaltyRecipient, uint256 newRoyaltyBps); + event RoyaltyForToken(uint256 indexed tokenId, address indexed royaltyRecipient, uint256 royaltyBps); + event PrimarySaleRecipientUpdated(address indexed recipient); + event PlatformFeeInfoUpdated(address indexed platformFeeRecipient, uint256 platformFeeBps); + + TokenERC1155 public tokenContract; + bytes32 internal typehashMintRequest; + bytes32 internal nameHash; + bytes32 internal versionHash; + bytes32 internal typehashEip712; + bytes32 internal domainSeparator; + + bytes private emptyEncodedBytes = abi.encode("", ""); + + TokenERC1155.MintRequest _mintrequest; + bytes _signature; + + address internal deployerSigner; + address internal recipient; + + using stdStorage for StdStorage; + + function setUp() public override { + super.setUp(); + deployerSigner = signer; + recipient = address(0x123); + tokenContract = TokenERC1155(getContract("TokenERC1155")); + + erc20.mint(deployerSigner, 1_000); + vm.deal(deployerSigner, 1_000); + + erc20.mint(recipient, 1_000); + vm.deal(recipient, 1_000); + + typehashMintRequest = keccak256( + "MintRequest(address to,address royaltyRecipient,uint256 royaltyBps,address primarySaleRecipient,uint256 tokenId,string uri,uint256 quantity,uint256 pricePerToken,address currency,uint128 validityStartTimestamp,uint128 validityEndTimestamp,bytes32 uid)" + ); + nameHash = keccak256(bytes("TokenERC1155")); + versionHash = keccak256(bytes("1")); + typehashEip712 = keccak256( + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" + ); + domainSeparator = keccak256( + abi.encode(typehashEip712, nameHash, versionHash, block.chainid, address(tokenContract)) + ); + + // construct default mintrequest + _mintrequest.to = recipient; + _mintrequest.royaltyRecipient = royaltyRecipient; + _mintrequest.royaltyBps = royaltyBps; + _mintrequest.primarySaleRecipient = saleRecipient; + _mintrequest.tokenId = type(uint256).max; + _mintrequest.uri = "ipfs://"; + _mintrequest.quantity = 100; + _mintrequest.pricePerToken = 0; + _mintrequest.currency = address(0); + _mintrequest.validityStartTimestamp = 1000; + _mintrequest.validityEndTimestamp = 2000; + _mintrequest.uid = bytes32(0); + + _signature = signMintRequest(_mintrequest, privateKey); + } + + function signMintRequest( + TokenERC1155.MintRequest memory _request, + uint256 _privateKey + ) internal view returns (bytes memory) { + bytes memory encodedRequest = bytes.concat( + abi.encode( + typehashMintRequest, + _request.to, + _request.royaltyRecipient, + _request.royaltyBps, + _request.primarySaleRecipient, + _request.tokenId, + keccak256(bytes(_request.uri)) + ), + abi.encode( + _request.quantity, + _request.pricePerToken, + _request.currency, + _request.validityStartTimestamp, + _request.validityEndTimestamp, + _request.uid + ) + ); + bytes32 structHash = keccak256(encodedRequest); + bytes32 typedDataHash = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(_privateKey, typedDataHash); + bytes memory sig = abi.encodePacked(r, s, v); + + return sig; + } + + function test_compareEncoding() public { + bytes memory encodedRequestOne = abi.encode( + typehashMintRequest, + _mintrequest.to, + _mintrequest.royaltyRecipient, + _mintrequest.royaltyBps, + _mintrequest.primarySaleRecipient, + keccak256(bytes(_mintrequest.uri)), + _mintrequest.quantity, + _mintrequest.pricePerToken, + _mintrequest.currency, + _mintrequest.validityStartTimestamp, + _mintrequest.validityEndTimestamp, + _mintrequest.uid + ); + bytes memory encodedRequestTwo = bytes.concat( + abi.encode( + typehashMintRequest, + _mintrequest.to, + _mintrequest.royaltyRecipient, + _mintrequest.royaltyBps, + _mintrequest.primarySaleRecipient, + keccak256(bytes(_mintrequest.uri)) + ), + abi.encode( + _mintrequest.quantity, + _mintrequest.pricePerToken, + _mintrequest.currency, + _mintrequest.validityStartTimestamp, + _mintrequest.validityEndTimestamp, + _mintrequest.uid + ) + ); + bytes32 structHashOne = keccak256(encodedRequestOne); + bytes32 typedDataHashOne = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHashOne)); + + bytes32 structHashTwo = keccak256(encodedRequestTwo); + bytes32 typedDataHashTwo = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHashTwo)); + + assertEq(structHashOne, structHashTwo); + assertEq(typedDataHashOne, typedDataHashTwo); + console.logBytes32(structHashOne); + console.logBytes32(structHashTwo); + console.logBytes32(typedDataHashOne); + console.logBytes32(typedDataHashTwo); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `mintWithSignature` + //////////////////////////////////////////////////////////////*/ + + function test_state_mintWithSignature_NewTokenId() public { + vm.warp(1000); + + // initial balances and state + uint256 nextTokenId = tokenContract.nextTokenIdToMint(); + uint256 currentBalanceOfRecipient = tokenContract.balanceOf(recipient, nextTokenId); + + // mint with signature + vm.prank(recipient); + tokenContract.mintWithSignature(_mintrequest, _signature); + + // check state after minting + assertEq(tokenContract.nextTokenIdToMint(), nextTokenId + 1); + assertEq(tokenContract.uri(nextTokenId), string(_mintrequest.uri)); + assertEq(tokenContract.balanceOf(recipient, nextTokenId), currentBalanceOfRecipient + _mintrequest.quantity); + } + + function test_state_mintWithSignature_ExistingTokenId() public { + vm.warp(1000); + uint256 nextTokenId = tokenContract.nextTokenIdToMint(); + + // first mint of new tokenId + vm.prank(recipient); + tokenContract.mintWithSignature(_mintrequest, _signature); + + // initial balances and state + uint256 currentBalanceOfRecipient = tokenContract.balanceOf(recipient, nextTokenId); + uint256 _uid = 1; + + // update mintrequest + _mintrequest.tokenId = nextTokenId; + _mintrequest.uid = bytes32(_uid); + _signature = signMintRequest(_mintrequest, privateKey); + + // mint existing tokenId + vm.prank(recipient); + tokenContract.mintWithSignature(_mintrequest, _signature); + + // check state after minting + assertEq(tokenContract.nextTokenIdToMint(), nextTokenId + 1); + assertEq(tokenContract.uri(nextTokenId), string(_mintrequest.uri)); + assertEq(tokenContract.balanceOf(recipient, nextTokenId), currentBalanceOfRecipient + _mintrequest.quantity); + } + + function test_revert_mintWithSignature_InvalidTokenId() public { + vm.warp(1000); + + // update mintrequest + _mintrequest.tokenId = 0; + _signature = signMintRequest(_mintrequest, privateKey); + + // mint non-existent tokenId + vm.prank(recipient); + vm.expectRevert("invalid id"); + tokenContract.mintWithSignature(_mintrequest, _signature); + } + + function test_state_mintWithSignature_NonZeroPrice_ERC20() public { + vm.warp(1000); + + // update mintrequest data + _mintrequest.pricePerToken = 1; + _mintrequest.currency = address(erc20); + _signature = signMintRequest(_mintrequest, privateKey); + + // approve erc20 tokens to tokenContract + vm.prank(recipient); + erc20.approve(address(tokenContract), _mintrequest.pricePerToken * _mintrequest.quantity); + + // initial balances and state + uint256 nextTokenId = tokenContract.nextTokenIdToMint(); + uint256 currentBalanceOfRecipient = tokenContract.balanceOf(recipient, nextTokenId); + + uint256 erc20BalanceOfSeller = erc20.balanceOf(address(saleRecipient)); + uint256 erc20BalanceOfRecipient = erc20.balanceOf(address(recipient)); + + // mint with signature + vm.prank(recipient); + tokenContract.mintWithSignature(_mintrequest, _signature); + + // check state after minting + assertEq(tokenContract.nextTokenIdToMint(), nextTokenId + 1); + assertEq(tokenContract.uri(nextTokenId), string(_mintrequest.uri)); + assertEq(tokenContract.balanceOf(recipient, nextTokenId), currentBalanceOfRecipient + _mintrequest.quantity); + + // check erc20 balances after minting + uint256 _platformFees = ((_mintrequest.pricePerToken * _mintrequest.quantity) * platformFeeBps) / MAX_BPS; + assertEq( + erc20.balanceOf(recipient), + erc20BalanceOfRecipient - (_mintrequest.pricePerToken * _mintrequest.quantity) + ); + assertEq( + erc20.balanceOf(address(saleRecipient)), + erc20BalanceOfSeller + (_mintrequest.pricePerToken * _mintrequest.quantity) - _platformFees + ); + } + + function test_state_mintWithSignature_NonZeroPrice_NativeToken() public { + vm.warp(1000); + + // update mintrequest data + _mintrequest.pricePerToken = 1; + _mintrequest.currency = address(NATIVE_TOKEN); + _signature = signMintRequest(_mintrequest, privateKey); + + // initial balances and state + uint256 nextTokenId = tokenContract.nextTokenIdToMint(); + uint256 currentBalanceOfRecipient = tokenContract.balanceOf(recipient, nextTokenId); + + uint256 etherBalanceOfSeller = address(saleRecipient).balance; + uint256 etherBalanceOfRecipient = address(recipient).balance; + + // mint with signature + vm.prank(recipient); + tokenContract.mintWithSignature{ value: _mintrequest.pricePerToken * _mintrequest.quantity }( + _mintrequest, + _signature + ); + + // check state after minting + assertEq(tokenContract.nextTokenIdToMint(), nextTokenId + 1); + assertEq(tokenContract.uri(nextTokenId), string(_mintrequest.uri)); + assertEq(tokenContract.balanceOf(recipient, nextTokenId), currentBalanceOfRecipient + _mintrequest.quantity); + + // check balances after minting + uint256 _platformFees = ((_mintrequest.pricePerToken * _mintrequest.quantity) * platformFeeBps) / MAX_BPS; + assertEq( + address(recipient).balance, + etherBalanceOfRecipient - (_mintrequest.pricePerToken * _mintrequest.quantity) + ); + assertEq( + address(saleRecipient).balance, + etherBalanceOfSeller + (_mintrequest.pricePerToken * _mintrequest.quantity) - _platformFees + ); + } + + function test_revert_mintWithSignature_MustSendTotalPrice() public { + vm.warp(1000); + + _mintrequest.pricePerToken = 1; + _mintrequest.currency = address(NATIVE_TOKEN); + _signature = signMintRequest(_mintrequest, privateKey); + + vm.prank(recipient); + vm.expectRevert("must send total price."); + tokenContract.mintWithSignature{ value: 0 }(_mintrequest, _signature); + } + + function test_revert_mintWithSignature_MsgValueNotZero() public { + vm.warp(1000); + + _mintrequest.pricePerToken = 1; + _mintrequest.currency = address(erc20); + _signature = signMintRequest(_mintrequest, privateKey); + + // shouldn't send native-token when it is not the currency + vm.prank(recipient); + vm.expectRevert("msg value not zero"); + tokenContract.mintWithSignature{ value: 1 }(_mintrequest, _signature); + } + + function test_revert_mintWithSignature_InvalidSignature() public { + vm.warp(1000); + + uint256 incorrectKey = 3456; + _signature = signMintRequest(_mintrequest, incorrectKey); + + vm.prank(recipient); + vm.expectRevert("invalid signature"); + tokenContract.mintWithSignature(_mintrequest, _signature); + } + + function test_revert_mintWithSignature_RequestExpired() public { + _signature = signMintRequest(_mintrequest, privateKey); + + // warp time more out of range + vm.warp(3000); + + vm.prank(recipient); + vm.expectRevert("request expired"); + tokenContract.mintWithSignature(_mintrequest, _signature); + } + + function test_revert_mintWithSignature_RecipientUndefined() public { + vm.warp(1000); + + _mintrequest.to = address(0); + _signature = signMintRequest(_mintrequest, privateKey); + + vm.prank(recipient); + vm.expectRevert("recipient undefined"); + tokenContract.mintWithSignature(_mintrequest, _signature); + } + + function test_revert_mintWithSignature_ZeroQuantity() public { + vm.warp(1000); + + _mintrequest.quantity = 0; + _signature = signMintRequest(_mintrequest, privateKey); + + vm.prank(recipient); + vm.expectRevert("zero quantity"); + tokenContract.mintWithSignature(_mintrequest, _signature); + } + + function test_event_mintWithSignature() public { + vm.warp(1000); + + vm.expectEmit(true, true, true, true); + emit TokensMintedWithSignature(deployerSigner, recipient, 0, _mintrequest); + + // mint with signature + vm.prank(recipient); + tokenContract.mintWithSignature(_mintrequest, _signature); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `mintTo` + //////////////////////////////////////////////////////////////*/ + + function test_state_mintTo() public { + string memory _tokenURI = "tokenURI"; + uint256 _amount = 100; + + uint256 nextTokenId = tokenContract.nextTokenIdToMint(); + uint256 currentBalanceOfRecipient = tokenContract.balanceOf(recipient, nextTokenId); + + vm.prank(deployerSigner); + tokenContract.mintTo(recipient, type(uint256).max, _tokenURI, _amount); + + assertEq(tokenContract.nextTokenIdToMint(), nextTokenId + 1); + assertEq(tokenContract.uri(nextTokenId), _tokenURI); + assertEq(tokenContract.balanceOf(recipient, nextTokenId), currentBalanceOfRecipient + _amount); + } + + function test_revert_mintTo_NotAuthorized() public { + string memory _tokenURI = "tokenURI"; + uint256 _amount = 100; + bytes32 role = keccak256("MINTER_ROLE"); + + vm.expectRevert( + abi.encodePacked( + "AccessControl: account ", + Strings.toHexString(uint160(address(0x1)), 20), + " is missing role ", + Strings.toHexString(uint256(role), 32) + ) + ); + vm.prank(address(0x1)); + tokenContract.mintTo(recipient, type(uint256).max, _tokenURI, _amount); + } + + function test_event_mintTo() public { + string memory _tokenURI = "tokenURI"; + uint256 _amount = 100; + + vm.expectEmit(true, true, true, true); + emit TokensMinted(recipient, 0, _tokenURI, _amount); + + // mint + vm.prank(deployerSigner); + tokenContract.mintTo(recipient, type(uint256).max, _tokenURI, _amount); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `burn` + //////////////////////////////////////////////////////////////*/ + + function test_state_burn_TokenOwner() public { + string memory _tokenURI = "tokenURI"; + uint256 _amount = 100; + + uint256 nextTokenId = tokenContract.nextTokenIdToMint(); + uint256 currentBalanceOfRecipient = tokenContract.balanceOf(recipient, nextTokenId); + + vm.prank(deployerSigner); + tokenContract.mintTo(recipient, type(uint256).max, _tokenURI, _amount); + + vm.prank(recipient); + tokenContract.burn(recipient, nextTokenId, _amount); + + assertEq(tokenContract.nextTokenIdToMint(), nextTokenId + 1); + assertEq(tokenContract.uri(nextTokenId), _tokenURI); + assertEq(tokenContract.balanceOf(recipient, nextTokenId), currentBalanceOfRecipient); + } + + function test_state_burn_TokenOperator() public { + string memory _tokenURI = "tokenURI"; + uint256 _amount = 100; + + address operator = address(0x789); + + uint256 nextTokenId = tokenContract.nextTokenIdToMint(); + uint256 currentBalanceOfRecipient = tokenContract.balanceOf(recipient, nextTokenId); + + vm.prank(deployerSigner); + tokenContract.mintTo(recipient, type(uint256).max, _tokenURI, _amount); + + vm.prank(recipient); + tokenContract.setApprovalForAll(operator, true); + + vm.prank(operator); + tokenContract.burn(recipient, nextTokenId, _amount); + + assertEq(tokenContract.nextTokenIdToMint(), nextTokenId + 1); + assertEq(tokenContract.uri(nextTokenId), _tokenURI); + assertEq(tokenContract.balanceOf(recipient, nextTokenId), currentBalanceOfRecipient); + } + + function test_revert_burn_NotOwnerNorApproved() public { + string memory _tokenURI = "tokenURI"; + uint256 _amount = 100; + + uint256 nextTokenId = tokenContract.nextTokenIdToMint(); + + vm.prank(deployerSigner); + tokenContract.mintTo(recipient, type(uint256).max, _tokenURI, _amount); + + vm.prank(address(0x789)); + vm.expectRevert("ERC1155: caller is not owner nor approved."); + tokenContract.burn(recipient, nextTokenId, _amount); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: owner + //////////////////////////////////////////////////////////////*/ + + function test_state_setOwner() public { + address newOwner = address(0x123); + bytes32 role = tokenContract.DEFAULT_ADMIN_ROLE(); + + vm.prank(deployerSigner); + tokenContract.grantRole(role, newOwner); + + vm.prank(deployerSigner); + tokenContract.setOwner(newOwner); + + assertEq(tokenContract.owner(), newOwner); + } + + function test_revert_setOwner_NotModuleAdmin() public { + vm.expectRevert("new owner not module admin."); + vm.prank(deployerSigner); + tokenContract.setOwner(address(0x1234)); + } + + function test_event_setOwner() public { + address newOwner = address(0x123); + bytes32 role = tokenContract.DEFAULT_ADMIN_ROLE(); + + vm.startPrank(deployerSigner); + tokenContract.grantRole(role, newOwner); + + vm.expectEmit(true, true, true, true); + emit OwnerUpdated(deployerSigner, newOwner); + + tokenContract.setOwner(newOwner); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: royalty + //////////////////////////////////////////////////////////////*/ + + function test_state_setDefaultRoyaltyInfo() public { + address _royaltyRecipient = address(0x123); + uint256 _royaltyBps = 1000; + + vm.prank(deployerSigner); + tokenContract.setDefaultRoyaltyInfo(_royaltyRecipient, _royaltyBps); + + (address newRoyaltyRecipient, uint256 newRoyaltyBps) = tokenContract.getDefaultRoyaltyInfo(); + assertEq(newRoyaltyRecipient, _royaltyRecipient); + assertEq(newRoyaltyBps, _royaltyBps); + + (address receiver, uint256 royaltyAmount) = tokenContract.royaltyInfo(0, 100); + assertEq(receiver, _royaltyRecipient); + assertEq(royaltyAmount, (100 * 1000) / 10_000); + } + + function test_revert_setDefaultRoyaltyInfo_NotAuthorized() public { + address _royaltyRecipient = address(0x123); + uint256 _royaltyBps = 1000; + bytes32 role = tokenContract.DEFAULT_ADMIN_ROLE(); + + vm.expectRevert( + abi.encodePacked( + "AccessControl: account ", + Strings.toHexString(uint160(address(0x1)), 20), + " is missing role ", + Strings.toHexString(uint256(role), 32) + ) + ); + vm.prank(address(0x1)); + tokenContract.setDefaultRoyaltyInfo(_royaltyRecipient, _royaltyBps); + } + + function test_revert_setDefaultRoyaltyInfo_ExceedsRoyaltyBps() public { + address _royaltyRecipient = address(0x123); + uint256 _royaltyBps = 10001; + + vm.expectRevert("exceed royalty bps"); + vm.prank(deployerSigner); + tokenContract.setDefaultRoyaltyInfo(_royaltyRecipient, _royaltyBps); + } + + function test_state_setRoyaltyInfoForToken() public { + uint256 _tokenId = 1; + address _recipient = address(0x123); + uint256 _bps = 1000; + + vm.prank(deployerSigner); + tokenContract.setRoyaltyInfoForToken(_tokenId, _recipient, _bps); + + (address receiver, uint256 royaltyAmount) = tokenContract.royaltyInfo(_tokenId, 100); + assertEq(receiver, _recipient); + assertEq(royaltyAmount, (100 * 1000) / 10_000); + } + + function test_revert_setRoyaltyInfo_NotAuthorized() public { + uint256 _tokenId = 1; + address _recipient = address(0x123); + uint256 _bps = 1000; + bytes32 role = tokenContract.DEFAULT_ADMIN_ROLE(); + + vm.expectRevert( + abi.encodePacked( + "AccessControl: account ", + Strings.toHexString(uint160(address(0x1)), 20), + " is missing role ", + Strings.toHexString(uint256(role), 32) + ) + ); + vm.prank(address(0x1)); + tokenContract.setRoyaltyInfoForToken(_tokenId, _recipient, _bps); + } + + function test_revert_setRoyaltyInfoForToken_ExceedsRoyaltyBps() public { + uint256 _tokenId = 1; + address _recipient = address(0x123); + uint256 _bps = 10001; + + vm.expectRevert("exceed royalty bps"); + vm.prank(deployerSigner); + tokenContract.setRoyaltyInfoForToken(_tokenId, _recipient, _bps); + } + + function test_event_defaultRoyalty() public { + address _royaltyRecipient = address(0x123); + uint256 _royaltyBps = 1000; + + vm.expectEmit(true, true, true, true); + emit DefaultRoyalty(_royaltyRecipient, _royaltyBps); + + vm.prank(deployerSigner); + tokenContract.setDefaultRoyaltyInfo(_royaltyRecipient, _royaltyBps); + } + + function test_event_royaltyForToken() public { + uint256 _tokenId = 1; + address _recipient = address(0x123); + uint256 _bps = 1000; + + vm.expectEmit(true, true, true, true); + emit RoyaltyForToken(_tokenId, _recipient, _bps); + + vm.prank(deployerSigner); + tokenContract.setRoyaltyInfoForToken(_tokenId, _recipient, _bps); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: primary sale + //////////////////////////////////////////////////////////////*/ + + function test_state_setPrimarySaleRecipient() public { + address _primarySaleRecipient = address(0x123); + + vm.prank(deployerSigner); + tokenContract.setPrimarySaleRecipient(_primarySaleRecipient); + + address recipient_ = tokenContract.primarySaleRecipient(); + assertEq(recipient_, _primarySaleRecipient); + } + + function test_revert_setPrimarySaleRecipient_NotAuthorized() public { + address _primarySaleRecipient = address(0x123); + bytes32 role = tokenContract.DEFAULT_ADMIN_ROLE(); + + vm.expectRevert( + abi.encodePacked( + "AccessControl: account ", + Strings.toHexString(uint160(address(0x1)), 20), + " is missing role ", + Strings.toHexString(uint256(role), 32) + ) + ); + vm.prank(address(0x1)); + tokenContract.setPrimarySaleRecipient(_primarySaleRecipient); + } + + function test_event_setPrimarySaleRecipient() public { + address _primarySaleRecipient = address(0x123); + + vm.expectEmit(true, true, true, true); + emit PrimarySaleRecipientUpdated(_primarySaleRecipient); + + vm.prank(deployerSigner); + tokenContract.setPrimarySaleRecipient(_primarySaleRecipient); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: platform fee + //////////////////////////////////////////////////////////////*/ + + function test_state_PlatformFee_Flat_ERC20() public { + vm.warp(1000); + uint256 flatPlatformFee = 10; + + vm.startPrank(deployerSigner); + tokenContract.setFlatPlatformFeeInfo(platformFeeRecipient, flatPlatformFee); + tokenContract.setPlatformFeeType(IPlatformFee.PlatformFeeType.Flat); + vm.stopPrank(); + + // update mintrequest data + _mintrequest.pricePerToken = 1; + _mintrequest.currency = address(erc20); + _signature = signMintRequest(_mintrequest, privateKey); + + // approve erc20 tokens to tokenContract + vm.prank(recipient); + erc20.approve(address(tokenContract), _mintrequest.pricePerToken * _mintrequest.quantity); + + // initial balances and state + uint256 nextTokenId = tokenContract.nextTokenIdToMint(); + uint256 currentBalanceOfRecipient = tokenContract.balanceOf(recipient, nextTokenId); + + uint256 erc20BalanceOfSeller = erc20.balanceOf(address(saleRecipient)); + uint256 erc20BalanceOfRecipient = erc20.balanceOf(address(recipient)); + + // mint with signature + vm.prank(recipient); + tokenContract.mintWithSignature(_mintrequest, _signature); + + // check state after minting + assertEq(tokenContract.nextTokenIdToMint(), nextTokenId + 1); + assertEq(tokenContract.uri(nextTokenId), string(_mintrequest.uri)); + assertEq(tokenContract.balanceOf(recipient, nextTokenId), currentBalanceOfRecipient + _mintrequest.quantity); + + // check erc20 balances after minting + assertEq( + erc20.balanceOf(recipient), + erc20BalanceOfRecipient - (_mintrequest.pricePerToken * _mintrequest.quantity) + ); + assertEq( + erc20.balanceOf(address(saleRecipient)), + erc20BalanceOfSeller + (_mintrequest.pricePerToken * _mintrequest.quantity) - flatPlatformFee + ); + } + + function test_state_PlatformFee_NativeToken() public { + vm.warp(1000); + uint256 flatPlatformFee = 10; + + vm.startPrank(deployerSigner); + tokenContract.setFlatPlatformFeeInfo(platformFeeRecipient, flatPlatformFee); + tokenContract.setPlatformFeeType(IPlatformFee.PlatformFeeType.Flat); + vm.stopPrank(); + + // update mintrequest data + _mintrequest.pricePerToken = 1; + _mintrequest.currency = address(NATIVE_TOKEN); + _signature = signMintRequest(_mintrequest, privateKey); + + // initial balances and state + uint256 nextTokenId = tokenContract.nextTokenIdToMint(); + uint256 currentBalanceOfRecipient = tokenContract.balanceOf(recipient, nextTokenId); + + uint256 etherBalanceOfSeller = address(saleRecipient).balance; + uint256 etherBalanceOfRecipient = address(recipient).balance; + + // mint with signature + vm.prank(recipient); + tokenContract.mintWithSignature{ value: _mintrequest.pricePerToken * _mintrequest.quantity }( + _mintrequest, + _signature + ); + + // check state after minting + assertEq(tokenContract.nextTokenIdToMint(), nextTokenId + 1); + assertEq(tokenContract.uri(nextTokenId), string(_mintrequest.uri)); + assertEq(tokenContract.balanceOf(recipient, nextTokenId), currentBalanceOfRecipient + _mintrequest.quantity); + + // check balances after minting + assertEq( + address(recipient).balance, + etherBalanceOfRecipient - (_mintrequest.pricePerToken * _mintrequest.quantity) + ); + assertEq( + address(saleRecipient).balance, + etherBalanceOfSeller + (_mintrequest.pricePerToken * _mintrequest.quantity) - flatPlatformFee + ); + } + + function test_revert_PlatformFeeGreaterThanPrice() public { + vm.warp(1000); + uint256 flatPlatformFee = 1 ether; + + vm.startPrank(deployerSigner); + tokenContract.setFlatPlatformFeeInfo(platformFeeRecipient, flatPlatformFee); + tokenContract.setPlatformFeeType(IPlatformFee.PlatformFeeType.Flat); + vm.stopPrank(); + + // update mintrequest data + _mintrequest.pricePerToken = 1; + _mintrequest.currency = address(erc20); + _signature = signMintRequest(_mintrequest, privateKey); + + // mint with signature + vm.prank(recipient); + vm.expectRevert("price less than platform fee"); + tokenContract.mintWithSignature(_mintrequest, _signature); + } + + function test_state_setPlatformFeeInfo() public { + address _platformFeeRecipient = address(0x123); + uint256 _platformFeeBps = 1000; + + vm.prank(deployerSigner); + tokenContract.setPlatformFeeInfo(_platformFeeRecipient, _platformFeeBps); + + (address recipient_, uint16 bps) = tokenContract.getPlatformFeeInfo(); + assertEq(_platformFeeRecipient, recipient_); + assertEq(_platformFeeBps, bps); + } + + function test_state_setFlatPlatformFee() public { + address _platformFeeRecipient = address(0x123); + uint256 _flatFee = 1000; + + vm.prank(deployerSigner); + tokenContract.setFlatPlatformFeeInfo(_platformFeeRecipient, _flatFee); + + (address recipient_, uint256 fee) = tokenContract.getFlatPlatformFeeInfo(); + assertEq(_platformFeeRecipient, recipient_); + assertEq(_flatFee, fee); + } + + function test_state_setPlatformFeeType() public { + address _platformFeeRecipient = address(0x123); + uint256 _flatFee = 1000; + IPlatformFee.PlatformFeeType _feeType = IPlatformFee.PlatformFeeType.Flat; + + vm.prank(deployerSigner); + tokenContract.setFlatPlatformFeeInfo(_platformFeeRecipient, _flatFee); + + vm.prank(deployerSigner); + tokenContract.setPlatformFeeType(_feeType); + + IPlatformFee.PlatformFeeType updatedFeeType = tokenContract.getPlatformFeeType(); + assertTrue(updatedFeeType == _feeType); + } + + function test_revert_setPlatformFeeInfo_ExceedsMaxBps() public { + address _platformFeeRecipient = address(0x123); + uint256 _platformFeeBps = 10001; + + vm.expectRevert("exceeds MAX_BPS"); + vm.prank(deployerSigner); + tokenContract.setPlatformFeeInfo(_platformFeeRecipient, _platformFeeBps); + } + + function test_revert_setPlatformFeeInfo_NotAuthorized() public { + bytes32 role = tokenContract.DEFAULT_ADMIN_ROLE(); + + vm.expectRevert( + abi.encodePacked( + "AccessControl: account ", + Strings.toHexString(uint160(address(0x1)), 20), + " is missing role ", + Strings.toHexString(uint256(role), 32) + ) + ); + vm.prank(address(0x1)); + tokenContract.setPlatformFeeInfo(address(1), 1000); + + vm.expectRevert( + abi.encodePacked( + "AccessControl: account ", + Strings.toHexString(uint160(address(0x1)), 20), + " is missing role ", + Strings.toHexString(uint256(role), 32) + ) + ); + vm.prank(address(0x1)); + tokenContract.setFlatPlatformFeeInfo(address(1), 1000); + } + + function test_event_platformFeeInfo() public { + address _platformFeeRecipient = address(0x123); + uint256 _platformFeeBps = 1000; + + vm.expectEmit(true, true, true, true); + emit PlatformFeeInfoUpdated(_platformFeeRecipient, _platformFeeBps); + + vm.prank(deployerSigner); + tokenContract.setPlatformFeeInfo(_platformFeeRecipient, _platformFeeBps); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: contract metadata + //////////////////////////////////////////////////////////////*/ + + function test_state_setContractURI() public { + string memory uri = "uri_string"; + + vm.prank(deployerSigner); + tokenContract.setContractURI(uri); + + string memory _contractURI = tokenContract.contractURI(); + + assertEq(_contractURI, uri); + } + + function test_revert_setContractURI() public { + bytes32 role = tokenContract.DEFAULT_ADMIN_ROLE(); + + vm.expectRevert( + abi.encodePacked( + "AccessControl: account ", + Strings.toHexString(uint160(address(0x1)), 20), + " is missing role ", + Strings.toHexString(uint256(role), 32) + ) + ); + vm.prank(address(0x1)); + tokenContract.setContractURI(""); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: setTokenURI + //////////////////////////////////////////////////////////////*/ + + function test_setTokenURI_state() public { + string memory uri = "uri_string"; + + vm.prank(deployerSigner); + tokenContract.setTokenURI(0, uri); + + string memory _tokenURI = tokenContract.uri(0); + + assertEq(_tokenURI, uri); + } + + function test_setTokenURI_revert_NotAuthorized() public { + string memory uri = "uri_string"; + + vm.expectRevert(abi.encodeWithSelector(NFTMetadata.NFTMetadataUnauthorized.selector)); + vm.prank(address(0x1)); + tokenContract.setTokenURI(0, uri); + } + + function test_setTokenURI_revert_Frozen() public { + string memory uri = "uri_string"; + + vm.startPrank(deployerSigner); + tokenContract.freezeMetadata(); + + vm.expectRevert(abi.encodeWithSelector(NFTMetadata.NFTMetadataFrozen.selector, 0)); + tokenContract.setTokenURI(0, uri); + } +} diff --git a/src/test/token/TokenERC20.t.sol b/src/test/token/TokenERC20.t.sol new file mode 100644 index 000000000..03e44d81c --- /dev/null +++ b/src/test/token/TokenERC20.t.sol @@ -0,0 +1,424 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { TokenERC20 } from "contracts/prebuilts/token/TokenERC20.sol"; + +// Test imports + +import "../utils/BaseTest.sol"; +import "@openzeppelin/contracts-upgradeable/utils/StringsUpgradeable.sol"; + +contract TokenERC20Test is BaseTest { + using Strings for uint256; + + event TokensMinted(address indexed mintedTo, uint256 quantityMinted); + event TokensMintedWithSignature( + address indexed signer, + address indexed mintedTo, + TokenERC20.MintRequest mintRequest + ); + + event PrimarySaleRecipientUpdated(address indexed recipient); + event PlatformFeeInfoUpdated(address indexed platformFeeRecipient, uint256 platformFeeBps); + + TokenERC20 public tokenContract; + bytes32 internal typehashMintRequest; + bytes32 internal nameHash; + bytes32 internal versionHash; + bytes32 internal typehashEip712; + bytes32 internal domainSeparator; + bytes32 internal permitTypehash; + + bytes private emptyEncodedBytes = abi.encode("", ""); + + TokenERC20.MintRequest _mintrequest; + bytes _signature; + + address internal deployerSigner; + address internal recipient; + + using stdStorage for StdStorage; + + function setUp() public override { + super.setUp(); + deployerSigner = signer; + recipient = address(0x123); + tokenContract = TokenERC20(getContract("TokenERC20")); + + erc20.mint(deployerSigner, 1_000); + vm.deal(deployerSigner, 1_000); + + erc20.mint(recipient, 1_000); + vm.deal(recipient, 1_000); + + typehashMintRequest = keccak256( + "MintRequest(address to,address primarySaleRecipient,uint256 quantity,uint256 price,address currency,uint128 validityStartTimestamp,uint128 validityEndTimestamp,bytes32 uid)" + ); + nameHash = keccak256(bytes(NAME)); + versionHash = keccak256(bytes("1")); + typehashEip712 = keccak256( + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" + ); + domainSeparator = keccak256( + abi.encode(typehashEip712, nameHash, versionHash, block.chainid, address(tokenContract)) + ); + permitTypehash = keccak256( + "Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)" + ); + + // construct default mintrequest + _mintrequest.to = recipient; + _mintrequest.primarySaleRecipient = saleRecipient; + _mintrequest.quantity = 100; + _mintrequest.price = 0; + _mintrequest.currency = address(0); + _mintrequest.validityStartTimestamp = 1000; + _mintrequest.validityEndTimestamp = 2000; + _mintrequest.uid = bytes32(0); + + _signature = signMintRequest(_mintrequest, privateKey); + } + + function signMintRequest( + TokenERC20.MintRequest memory _request, + uint256 _privateKey + ) internal view returns (bytes memory) { + bytes memory encodedRequest = abi.encode( + typehashMintRequest, + _request.to, + _request.primarySaleRecipient, + _request.quantity, + _request.price, + _request.currency, + _request.validityStartTimestamp, + _request.validityEndTimestamp, + _request.uid + ); + bytes32 structHash = keccak256(encodedRequest); + bytes32 typedDataHash = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(_privateKey, typedDataHash); + bytes memory sig = abi.encodePacked(r, s, v); + + return sig; + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `mintWithSignature` + //////////////////////////////////////////////////////////////*/ + + function test_state_mintWithSignature_ZeroPrice() public { + vm.warp(1000); + + // initial balances and state + uint256 currentTotalSupply = tokenContract.totalSupply(); + uint256 currentBalanceOfRecipient = tokenContract.balanceOf(recipient); + + // mint with signature + vm.prank(recipient); + tokenContract.mintWithSignature(_mintrequest, _signature); + + // check state after minting + assertEq(tokenContract.totalSupply(), currentTotalSupply + _mintrequest.quantity); + assertEq(tokenContract.balanceOf(recipient), currentBalanceOfRecipient + _mintrequest.quantity); + } + + function test_state_mintWithSignature_NonZeroPrice_ERC20() public { + vm.warp(1000); + + // update mintrequest data + _mintrequest.price = 1; + _mintrequest.currency = address(erc20); + _signature = signMintRequest(_mintrequest, privateKey); + + // approve erc20 tokens to tokenContract + vm.prank(recipient); + erc20.approve(address(tokenContract), _mintrequest.price); + + // initial balances and state + uint256 currentTotalSupply = tokenContract.totalSupply(); + uint256 currentBalanceOfRecipient = tokenContract.balanceOf(recipient); + + uint256 erc20BalanceOfSeller = erc20.balanceOf(address(saleRecipient)); + uint256 erc20BalanceOfRecipient = erc20.balanceOf(address(recipient)); + + // mint with signature + vm.prank(recipient); + tokenContract.mintWithSignature(_mintrequest, _signature); + + // check state after minting + assertEq(tokenContract.totalSupply(), currentTotalSupply + _mintrequest.quantity); + assertEq(tokenContract.balanceOf(recipient), currentBalanceOfRecipient + _mintrequest.quantity); + + // check erc20 balances after minting + uint256 _platformFees = (_mintrequest.price * platformFeeBps) / MAX_BPS; + assertEq(erc20.balanceOf(recipient), erc20BalanceOfRecipient - _mintrequest.price); + assertEq(erc20.balanceOf(address(saleRecipient)), erc20BalanceOfSeller + _mintrequest.price - _platformFees); + } + + function test_state_mintWithSignature_NonZeroPrice_NativeToken() public { + vm.warp(1000); + + // update mintrequest data + _mintrequest.price = 1; + _mintrequest.currency = address(NATIVE_TOKEN); + _signature = signMintRequest(_mintrequest, privateKey); + + // initial balances and state + uint256 currentTotalSupply = tokenContract.totalSupply(); + uint256 currentBalanceOfRecipient = tokenContract.balanceOf(recipient); + + uint256 etherBalanceOfSeller = address(saleRecipient).balance; + uint256 etherBalanceOfRecipient = address(recipient).balance; + + // mint with signature + vm.prank(recipient); + tokenContract.mintWithSignature{ value: _mintrequest.price }(_mintrequest, _signature); + + // check state after minting + assertEq(tokenContract.totalSupply(), currentTotalSupply + _mintrequest.quantity); + assertEq(tokenContract.balanceOf(recipient), currentBalanceOfRecipient + _mintrequest.quantity); + + // check balances after minting + uint256 _platformFees = (_mintrequest.price * platformFeeBps) / MAX_BPS; + assertEq(address(recipient).balance, etherBalanceOfRecipient - _mintrequest.price); + assertEq(address(saleRecipient).balance, etherBalanceOfSeller + _mintrequest.price - _platformFees); + } + + function test_revert_mintWithSignature_MustSendTotalPrice() public { + vm.warp(1000); + + _mintrequest.price = 1; + _mintrequest.currency = address(NATIVE_TOKEN); + _signature = signMintRequest(_mintrequest, privateKey); + + vm.prank(recipient); + vm.expectRevert("must send total price."); + tokenContract.mintWithSignature{ value: 0 }(_mintrequest, _signature); + } + + function test_revert_mintWithSignature_MsgValueNotZero() public { + vm.warp(1000); + + _mintrequest.price = 1; + _mintrequest.currency = address(erc20); + _signature = signMintRequest(_mintrequest, privateKey); + + // shouldn't send native-token when it is not the currency + vm.prank(recipient); + vm.expectRevert("msg value not zero"); + tokenContract.mintWithSignature{ value: 1 }(_mintrequest, _signature); + } + + function test_revert_mintWithSignature_InvalidSignature() public { + vm.warp(1000); + + uint256 incorrectKey = 3456; + _signature = signMintRequest(_mintrequest, incorrectKey); + + vm.prank(recipient); + vm.expectRevert("invalid signature"); + tokenContract.mintWithSignature(_mintrequest, _signature); + } + + function test_revert_mintWithSignature_RequestExpired() public { + _signature = signMintRequest(_mintrequest, privateKey); + + // warp time more out of range + vm.warp(3000); + + vm.prank(recipient); + vm.expectRevert("request expired"); + tokenContract.mintWithSignature(_mintrequest, _signature); + } + + function test_revert_mintWithSignature_RecipientUndefined() public { + vm.warp(1000); + + _mintrequest.to = address(0); + _signature = signMintRequest(_mintrequest, privateKey); + + vm.prank(recipient); + vm.expectRevert("recipient undefined"); + tokenContract.mintWithSignature(_mintrequest, _signature); + } + + function test_revert_mintWithSignature_ZeroQuantity() public { + vm.warp(1000); + + _mintrequest.quantity = 0; + _signature = signMintRequest(_mintrequest, privateKey); + + vm.prank(recipient); + vm.expectRevert("zero quantity"); + tokenContract.mintWithSignature(_mintrequest, _signature); + } + + function test_event_mintWithSignature() public { + vm.warp(1000); + + vm.expectEmit(true, true, true, true); + emit TokensMintedWithSignature(deployerSigner, recipient, _mintrequest); + + // mint with signature + vm.prank(recipient); + tokenContract.mintWithSignature(_mintrequest, _signature); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `mintTo` + //////////////////////////////////////////////////////////////*/ + + function test_state_mintTo() public { + uint256 _amount = 100; + + uint256 currentTotalSupply = tokenContract.totalSupply(); + uint256 currentBalanceOfRecipient = tokenContract.balanceOf(recipient); + + vm.prank(deployerSigner); + tokenContract.mintTo(recipient, _amount); + + assertEq(tokenContract.totalSupply(), currentTotalSupply + _amount); + assertEq(tokenContract.balanceOf(recipient), currentBalanceOfRecipient + _amount); + } + + function test_revert_mintTo_NotAuthorized() public { + uint256 _amount = 100; + + vm.expectRevert("not minter."); + vm.prank(address(0x1)); + tokenContract.mintTo(recipient, _amount); + } + + function test_event_mintTo() public { + uint256 _amount = 100; + + vm.expectEmit(true, true, true, true); + emit TokensMinted(recipient, _amount); + + // mint + vm.prank(deployerSigner); + tokenContract.mintTo(recipient, _amount); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: primary sale + //////////////////////////////////////////////////////////////*/ + + function test_state_setPrimarySaleRecipient() public { + address _primarySaleRecipient = address(0x123); + + vm.prank(deployerSigner); + tokenContract.setPrimarySaleRecipient(_primarySaleRecipient); + + address recipient_ = tokenContract.primarySaleRecipient(); + assertEq(recipient_, _primarySaleRecipient); + } + + function test_revert_setPrimarySaleRecipient_NotAuthorized() public { + address _primarySaleRecipient = address(0x123); + bytes32 role = tokenContract.DEFAULT_ADMIN_ROLE(); + + vm.expectRevert( + abi.encodePacked( + "AccessControl: account ", + Strings.toHexString(uint160(address(0x1)), 20), + " is missing role ", + Strings.toHexString(uint256(role), 32) + ) + ); + vm.prank(address(0x1)); + tokenContract.setPrimarySaleRecipient(_primarySaleRecipient); + } + + function test_event_setPrimarySaleRecipient() public { + address _primarySaleRecipient = address(0x123); + + vm.expectEmit(true, true, true, true); + emit PrimarySaleRecipientUpdated(_primarySaleRecipient); + + vm.prank(deployerSigner); + tokenContract.setPrimarySaleRecipient(_primarySaleRecipient); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: platform fee + //////////////////////////////////////////////////////////////*/ + + function test_state_setPlatformFeeInfo() public { + address _platformFeeRecipient = address(0x123); + uint256 _platformFeeBps = 1000; + + vm.prank(deployerSigner); + tokenContract.setPlatformFeeInfo(_platformFeeRecipient, _platformFeeBps); + + (address recipient_, uint16 bps) = tokenContract.getPlatformFeeInfo(); + assertEq(_platformFeeRecipient, recipient_); + assertEq(_platformFeeBps, bps); + } + + function test_revert_setPlatformFeeInfo_ExceedsMaxBps() public { + address _platformFeeRecipient = address(0x123); + uint256 _platformFeeBps = 10001; + + vm.expectRevert("exceeds MAX_BPS"); + vm.prank(deployerSigner); + tokenContract.setPlatformFeeInfo(_platformFeeRecipient, _platformFeeBps); + } + + function test_revert_setPlatformFeeInfo_NotAuthorized() public { + bytes32 role = tokenContract.DEFAULT_ADMIN_ROLE(); + + vm.expectRevert( + abi.encodePacked( + "AccessControl: account ", + Strings.toHexString(uint160(address(0x1)), 20), + " is missing role ", + Strings.toHexString(uint256(role), 32) + ) + ); + vm.prank(address(0x1)); + tokenContract.setPlatformFeeInfo(address(1), 1000); + } + + function test_event_platformFeeInfo() public { + address _platformFeeRecipient = address(0x123); + uint256 _platformFeeBps = 1000; + + vm.expectEmit(true, true, true, true); + emit PlatformFeeInfoUpdated(_platformFeeRecipient, _platformFeeBps); + + vm.prank(deployerSigner); + tokenContract.setPlatformFeeInfo(_platformFeeRecipient, _platformFeeBps); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: contract metadata + //////////////////////////////////////////////////////////////*/ + + function test_state_setContractURI() public { + string memory uri = "uri_string"; + + vm.prank(deployerSigner); + tokenContract.setContractURI(uri); + + string memory _contractURI = tokenContract.contractURI(); + + assertEq(_contractURI, uri); + } + + function test_revert_setContractURI_NotAuthorized() public { + bytes32 role = tokenContract.DEFAULT_ADMIN_ROLE(); + + vm.expectRevert( + abi.encodePacked( + "AccessControl: account ", + Strings.toHexString(uint160(address(0x1)), 20), + " is missing role ", + Strings.toHexString(uint256(role), 32) + ) + ); + vm.prank(address(0x1)); + tokenContract.setContractURI(""); + } +} diff --git a/src/test/token/TokenERC721.t.sol b/src/test/token/TokenERC721.t.sol new file mode 100644 index 000000000..bd9c0519a --- /dev/null +++ b/src/test/token/TokenERC721.t.sol @@ -0,0 +1,692 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import { TokenERC721, NFTMetadata } from "contracts/prebuilts/token/TokenERC721.sol"; + +// Test imports + +import "../utils/BaseTest.sol"; +import "@openzeppelin/contracts-upgradeable/utils/StringsUpgradeable.sol"; + +contract TokenERC721Test is BaseTest { + using Strings for uint256; + + event TokensMinted(address indexed mintedTo, uint256 indexed tokenIdMinted, string uri); + event TokensMintedWithSignature( + address indexed signer, + address indexed mintedTo, + uint256 indexed tokenIdMinted, + TokenERC721.MintRequest mintRequest + ); + event OwnerUpdated(address indexed prevOwner, address indexed newOwner); + event DefaultRoyalty(address indexed newRoyaltyRecipient, uint256 newRoyaltyBps); + event RoyaltyForToken(uint256 indexed tokenId, address indexed royaltyRecipient, uint256 royaltyBps); + event PrimarySaleRecipientUpdated(address indexed recipient); + event PlatformFeeInfoUpdated(address indexed platformFeeRecipient, uint256 platformFeeBps); + + TokenERC721 public tokenContract; + bytes32 internal typehashMintRequest; + bytes32 internal nameHash; + bytes32 internal versionHash; + bytes32 internal typehashEip712; + bytes32 internal domainSeparator; + + bytes private emptyEncodedBytes = abi.encode("", ""); + + TokenERC721.MintRequest _mintrequest; + bytes _signature; + + address internal deployerSigner; + address internal recipient; + + using stdStorage for StdStorage; + + function setUp() public override { + super.setUp(); + deployerSigner = signer; + recipient = address(0x123); + tokenContract = TokenERC721(getContract("TokenERC721")); + + erc20.mint(deployerSigner, 1_000); + vm.deal(deployerSigner, 1_000); + + erc20.mint(recipient, 1_000); + vm.deal(recipient, 1_000); + + typehashMintRequest = keccak256( + "MintRequest(address to,address royaltyRecipient,uint256 royaltyBps,address primarySaleRecipient,string uri,uint256 price,address currency,uint128 validityStartTimestamp,uint128 validityEndTimestamp,bytes32 uid)" + ); + nameHash = keccak256(bytes("TokenERC721")); + versionHash = keccak256(bytes("1")); + typehashEip712 = keccak256( + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" + ); + domainSeparator = keccak256( + abi.encode(typehashEip712, nameHash, versionHash, block.chainid, address(tokenContract)) + ); + + // construct default mintrequest + _mintrequest.to = recipient; + _mintrequest.royaltyRecipient = royaltyRecipient; + _mintrequest.royaltyBps = royaltyBps; + _mintrequest.primarySaleRecipient = saleRecipient; + _mintrequest.uri = "ipfs://"; + _mintrequest.price = 0; + _mintrequest.currency = address(0); + _mintrequest.validityStartTimestamp = 1000; + _mintrequest.validityEndTimestamp = 2000; + _mintrequest.uid = bytes32(0); + + _signature = signMintRequest(_mintrequest, privateKey); + } + + function signMintRequest( + TokenERC721.MintRequest memory _request, + uint256 _privateKey + ) internal view returns (bytes memory) { + bytes memory encodedRequest = abi.encode( + typehashMintRequest, + _request.to, + _request.royaltyRecipient, + _request.royaltyBps, + _request.primarySaleRecipient, + keccak256(bytes(_request.uri)), + _request.price, + _request.currency, + _request.validityStartTimestamp, + _request.validityEndTimestamp, + _request.uid + ); + bytes32 structHash = keccak256(encodedRequest); + bytes32 typedDataHash = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(_privateKey, typedDataHash); + bytes memory sig = abi.encodePacked(r, s, v); + + return sig; + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `mintWithSignature` + //////////////////////////////////////////////////////////////*/ + + function test_state_mintWithSignature_ZeroPrice() public { + vm.warp(1000); + + // initial balances and state + uint256 nextTokenId = tokenContract.nextTokenIdToMint(); + uint256 currentTotalSupply = tokenContract.totalSupply(); + uint256 currentBalanceOfRecipient = tokenContract.balanceOf(recipient); + + // mint with signature + vm.prank(recipient); + tokenContract.mintWithSignature(_mintrequest, _signature); + + // check state after minting + assertEq(tokenContract.nextTokenIdToMint(), nextTokenId + 1); + assertEq(tokenContract.tokenURI(nextTokenId), string(_mintrequest.uri)); + assertEq(tokenContract.totalSupply(), currentTotalSupply + 1); + assertEq(tokenContract.balanceOf(recipient), currentBalanceOfRecipient + 1); + assertEq(tokenContract.ownerOf(nextTokenId), recipient); + } + + function test_state_mintWithSignature_NonZeroPrice_ERC20() public { + vm.warp(1000); + + // update mintrequest data + _mintrequest.price = 1; + _mintrequest.currency = address(erc20); + _signature = signMintRequest(_mintrequest, privateKey); + + // approve erc20 tokens to tokenContract + vm.prank(recipient); + erc20.approve(address(tokenContract), 1); + + // initial balances and state + uint256 nextTokenId = tokenContract.nextTokenIdToMint(); + uint256 currentTotalSupply = tokenContract.totalSupply(); + uint256 currentBalanceOfRecipient = tokenContract.balanceOf(recipient); + + uint256 erc20BalanceOfSeller = erc20.balanceOf(address(saleRecipient)); + uint256 erc20BalanceOfRecipient = erc20.balanceOf(address(recipient)); + + // mint with signature + vm.prank(recipient); + tokenContract.mintWithSignature(_mintrequest, _signature); + + // check state after minting + assertEq(tokenContract.nextTokenIdToMint(), nextTokenId + 1); + assertEq(tokenContract.tokenURI(nextTokenId), string(_mintrequest.uri)); + assertEq(tokenContract.totalSupply(), currentTotalSupply + 1); + assertEq(tokenContract.balanceOf(recipient), currentBalanceOfRecipient + 1); + assertEq(tokenContract.ownerOf(nextTokenId), recipient); + + // check erc20 balances after minting + uint256 _platformFees = (_mintrequest.price * platformFeeBps) / MAX_BPS; + assertEq(erc20.balanceOf(recipient), erc20BalanceOfRecipient - _mintrequest.price); + assertEq(erc20.balanceOf(address(saleRecipient)), erc20BalanceOfSeller + _mintrequest.price - _platformFees); + } + + function test_state_mintWithSignature_NonZeroPrice_NativeToken() public { + vm.warp(1000); + + // update mintrequest data + _mintrequest.price = 1; + _mintrequest.currency = address(NATIVE_TOKEN); + _signature = signMintRequest(_mintrequest, privateKey); + + // initial balances and state + uint256 nextTokenId = tokenContract.nextTokenIdToMint(); + uint256 currentTotalSupply = tokenContract.totalSupply(); + uint256 currentBalanceOfRecipient = tokenContract.balanceOf(recipient); + + uint256 etherBalanceOfSeller = address(saleRecipient).balance; + uint256 etherBalanceOfRecipient = address(recipient).balance; + + // mint with signature + vm.prank(recipient); + tokenContract.mintWithSignature{ value: 1 }(_mintrequest, _signature); + + // check state after minting + assertEq(tokenContract.nextTokenIdToMint(), nextTokenId + 1); + assertEq(tokenContract.tokenURI(nextTokenId), string(_mintrequest.uri)); + assertEq(tokenContract.totalSupply(), currentTotalSupply + 1); + assertEq(tokenContract.balanceOf(recipient), currentBalanceOfRecipient + 1); + assertEq(tokenContract.ownerOf(nextTokenId), recipient); + + // check erc20 balances after minting + uint256 _platformFees = (_mintrequest.price * platformFeeBps) / MAX_BPS; + assertEq(address(recipient).balance, etherBalanceOfRecipient - _mintrequest.price); + assertEq(address(saleRecipient).balance, etherBalanceOfSeller + _mintrequest.price - _platformFees); + } + + function test_revert_mintWithSignature_MustSendTotalPrice() public { + vm.warp(1000); + + _mintrequest.price = 1; + _mintrequest.currency = address(NATIVE_TOKEN); + _signature = signMintRequest(_mintrequest, privateKey); + + vm.prank(recipient); + vm.expectRevert("must send total price."); + tokenContract.mintWithSignature{ value: 0 }(_mintrequest, _signature); + } + + function test_revert_mintWithSignature_MsgValueNotZero() public { + vm.warp(1000); + + _mintrequest.price = 1; + _mintrequest.currency = address(erc20); + _signature = signMintRequest(_mintrequest, privateKey); + + // shouldn't send native-token when it is not the currency + vm.prank(recipient); + vm.expectRevert("msg value not zero"); + tokenContract.mintWithSignature{ value: 1 }(_mintrequest, _signature); + } + + function test_revert_mintWithSignature_InvalidSignature() public { + vm.warp(1000); + + uint256 incorrectKey = 3456; + _signature = signMintRequest(_mintrequest, incorrectKey); + + vm.prank(recipient); + vm.expectRevert("invalid signature"); + tokenContract.mintWithSignature(_mintrequest, _signature); + } + + function test_revert_mintWithSignature_RequestExpired() public { + _signature = signMintRequest(_mintrequest, privateKey); + + // warp time more out of range + vm.warp(3000); + + vm.prank(recipient); + vm.expectRevert("request expired"); + tokenContract.mintWithSignature(_mintrequest, _signature); + } + + function test_revert_mintWithSignature_RecipientUndefined() public { + vm.warp(1000); + + _mintrequest.to = address(0); + _signature = signMintRequest(_mintrequest, privateKey); + + vm.prank(recipient); + vm.expectRevert("recipient undefined"); + tokenContract.mintWithSignature(_mintrequest, _signature); + } + + function test_event_mintWithSignature() public { + vm.warp(1000); + + vm.expectEmit(true, true, true, true); + emit TokensMintedWithSignature(deployerSigner, recipient, 0, _mintrequest); + + // mint with signature + vm.prank(recipient); + tokenContract.mintWithSignature(_mintrequest, _signature); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `mintTo` + //////////////////////////////////////////////////////////////*/ + + function test_state_mintTo() public { + string memory _tokenURI = "tokenURI"; + + uint256 nextTokenId = tokenContract.nextTokenIdToMint(); + uint256 currentTotalSupply = tokenContract.totalSupply(); + uint256 currentBalanceOfRecipient = tokenContract.balanceOf(recipient); + + vm.prank(deployerSigner); + tokenContract.mintTo(recipient, _tokenURI); + + assertEq(tokenContract.nextTokenIdToMint(), nextTokenId + 1); + assertEq(tokenContract.tokenURI(nextTokenId), _tokenURI); + assertEq(tokenContract.totalSupply(), currentTotalSupply + 1); + assertEq(tokenContract.balanceOf(recipient), currentBalanceOfRecipient + 1); + assertEq(tokenContract.ownerOf(nextTokenId), recipient); + } + + function test_revert_mintTo_NotAuthorized() public { + string memory _tokenURI = "tokenURI"; + bytes32 role = keccak256("MINTER_ROLE"); + + vm.expectRevert( + abi.encodePacked( + "AccessControl: account ", + Strings.toHexString(uint160(address(0x1)), 20), + " is missing role ", + Strings.toHexString(uint256(role), 32) + ) + ); + vm.prank(address(0x1)); + tokenContract.mintTo(recipient, _tokenURI); + } + + function test_revert_mintTo_emptyURI() public { + // mint + vm.prank(deployerSigner); + vm.expectRevert("empty uri."); + tokenContract.mintTo(recipient, ""); + } + + function test_event_mintTo() public { + string memory _tokenURI = "tokenURI"; + + vm.expectEmit(true, true, true, true); + emit TokensMinted(recipient, 0, _tokenURI); + + // mint + vm.prank(deployerSigner); + tokenContract.mintTo(recipient, _tokenURI); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `burn` + //////////////////////////////////////////////////////////////*/ + + function test_state_burn_TokenOwner() public { + string memory _tokenURI = "tokenURI"; + + uint256 nextTokenId = tokenContract.nextTokenIdToMint(); + uint256 currentTotalSupply = tokenContract.totalSupply(); + uint256 currentBalanceOfRecipient = tokenContract.balanceOf(recipient); + + vm.prank(deployerSigner); + tokenContract.mintTo(recipient, _tokenURI); + + vm.prank(recipient); + tokenContract.burn(nextTokenId); + + assertEq(tokenContract.nextTokenIdToMint(), nextTokenId + 1); + assertEq(tokenContract.tokenURI(nextTokenId), _tokenURI); + assertEq(tokenContract.totalSupply(), currentTotalSupply); + assertEq(tokenContract.balanceOf(recipient), currentBalanceOfRecipient); + + vm.expectRevert("ERC721: invalid token ID"); + assertEq(tokenContract.ownerOf(nextTokenId), address(0)); + } + + function test_state_burn_TokenOperator() public { + string memory _tokenURI = "tokenURI"; + + address operator = address(0x789); + + uint256 nextTokenId = tokenContract.nextTokenIdToMint(); + uint256 currentTotalSupply = tokenContract.totalSupply(); + uint256 currentBalanceOfRecipient = tokenContract.balanceOf(recipient); + + vm.prank(deployerSigner); + tokenContract.mintTo(recipient, _tokenURI); + + vm.prank(recipient); + tokenContract.setApprovalForAll(operator, true); + + vm.prank(operator); + tokenContract.burn(nextTokenId); + + assertEq(tokenContract.nextTokenIdToMint(), nextTokenId + 1); + assertEq(tokenContract.tokenURI(nextTokenId), _tokenURI); + assertEq(tokenContract.totalSupply(), currentTotalSupply); + assertEq(tokenContract.balanceOf(recipient), currentBalanceOfRecipient); + + vm.expectRevert("ERC721: invalid token ID"); + assertEq(tokenContract.ownerOf(nextTokenId), address(0)); + } + + function test_revert_burn_NotOwnerNorApproved() public { + string memory _tokenURI = "tokenURI"; + + uint256 nextTokenId = tokenContract.nextTokenIdToMint(); + + vm.prank(deployerSigner); + tokenContract.mintTo(recipient, _tokenURI); + + vm.prank(address(0x789)); + vm.expectRevert("ERC721Burnable: caller is not owner nor approved"); + tokenContract.burn(nextTokenId); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: owner + //////////////////////////////////////////////////////////////*/ + + function test_state_setOwner() public { + address newOwner = address(0x123); + bytes32 role = tokenContract.DEFAULT_ADMIN_ROLE(); + + vm.prank(deployerSigner); + tokenContract.grantRole(role, newOwner); + + vm.prank(deployerSigner); + tokenContract.setOwner(newOwner); + + assertEq(tokenContract.owner(), newOwner); + } + + function test_revert_setOwner_NotModuleAdmin() public { + vm.expectRevert("new owner not module admin."); + vm.prank(deployerSigner); + tokenContract.setOwner(address(0x1234)); + } + + function test_event_setOwner() public { + address newOwner = address(0x123); + bytes32 role = tokenContract.DEFAULT_ADMIN_ROLE(); + + vm.startPrank(deployerSigner); + tokenContract.grantRole(role, newOwner); + + vm.expectEmit(true, true, true, true); + emit OwnerUpdated(deployerSigner, newOwner); + + tokenContract.setOwner(newOwner); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: royalty + //////////////////////////////////////////////////////////////*/ + + function test_state_setDefaultRoyaltyInfo() public { + address _royaltyRecipient = address(0x123); + uint256 _royaltyBps = 1000; + + vm.prank(deployerSigner); + tokenContract.setDefaultRoyaltyInfo(_royaltyRecipient, _royaltyBps); + + (address newRoyaltyRecipient, uint256 newRoyaltyBps) = tokenContract.getDefaultRoyaltyInfo(); + assertEq(newRoyaltyRecipient, _royaltyRecipient); + assertEq(newRoyaltyBps, _royaltyBps); + + (address receiver, uint256 royaltyAmount) = tokenContract.royaltyInfo(0, 100); + assertEq(receiver, _royaltyRecipient); + assertEq(royaltyAmount, (100 * 1000) / 10_000); + } + + function test_revert_setDefaultRoyaltyInfo_NotAuthorized() public { + address _royaltyRecipient = address(0x123); + uint256 _royaltyBps = 1000; + bytes32 role = tokenContract.DEFAULT_ADMIN_ROLE(); + + vm.expectRevert( + abi.encodePacked( + "AccessControl: account ", + Strings.toHexString(uint160(address(0x1)), 20), + " is missing role ", + Strings.toHexString(uint256(role), 32) + ) + ); + vm.prank(address(0x1)); + tokenContract.setDefaultRoyaltyInfo(_royaltyRecipient, _royaltyBps); + } + + function test_revert_setDefaultRoyaltyInfo_ExceedsRoyaltyBps() public { + address _royaltyRecipient = address(0x123); + uint256 _royaltyBps = 10001; + + vm.expectRevert("exceed royalty bps"); + vm.prank(deployerSigner); + tokenContract.setDefaultRoyaltyInfo(_royaltyRecipient, _royaltyBps); + } + + function test_state_setRoyaltyInfoForToken() public { + uint256 _tokenId = 1; + address _recipient = address(0x123); + uint256 _bps = 1000; + + vm.prank(deployerSigner); + tokenContract.setRoyaltyInfoForToken(_tokenId, _recipient, _bps); + + (address receiver, uint256 royaltyAmount) = tokenContract.royaltyInfo(_tokenId, 100); + assertEq(receiver, _recipient); + assertEq(royaltyAmount, (100 * 1000) / 10_000); + } + + function test_revert_setRoyaltyInfo_NotAuthorized() public { + uint256 _tokenId = 1; + address _recipient = address(0x123); + uint256 _bps = 1000; + bytes32 role = tokenContract.DEFAULT_ADMIN_ROLE(); + + vm.expectRevert( + abi.encodePacked( + "AccessControl: account ", + Strings.toHexString(uint160(address(0x1)), 20), + " is missing role ", + Strings.toHexString(uint256(role), 32) + ) + ); + vm.prank(address(0x1)); + tokenContract.setRoyaltyInfoForToken(_tokenId, _recipient, _bps); + } + + function test_revert_setRoyaltyInfoForToken_ExceedsRoyaltyBps() public { + uint256 _tokenId = 1; + address _recipient = address(0x123); + uint256 _bps = 10001; + + vm.expectRevert("exceed royalty bps"); + vm.prank(deployerSigner); + tokenContract.setRoyaltyInfoForToken(_tokenId, _recipient, _bps); + } + + function test_event_defaultRoyalty() public { + address _royaltyRecipient = address(0x123); + uint256 _royaltyBps = 1000; + + vm.expectEmit(true, true, true, true); + emit DefaultRoyalty(_royaltyRecipient, _royaltyBps); + + vm.prank(deployerSigner); + tokenContract.setDefaultRoyaltyInfo(_royaltyRecipient, _royaltyBps); + } + + function test_event_royaltyForToken() public { + uint256 _tokenId = 1; + address _recipient = address(0x123); + uint256 _bps = 1000; + + vm.expectEmit(true, true, true, true); + emit RoyaltyForToken(_tokenId, _recipient, _bps); + + vm.prank(deployerSigner); + tokenContract.setRoyaltyInfoForToken(_tokenId, _recipient, _bps); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: primary sale + //////////////////////////////////////////////////////////////*/ + + function test_state_setPrimarySaleRecipient() public { + address _primarySaleRecipient = address(0x123); + + vm.prank(deployerSigner); + tokenContract.setPrimarySaleRecipient(_primarySaleRecipient); + + address recipient_ = tokenContract.primarySaleRecipient(); + assertEq(recipient_, _primarySaleRecipient); + } + + function test_revert_setPrimarySaleRecipient_NotAuthorized() public { + address _primarySaleRecipient = address(0x123); + bytes32 role = tokenContract.DEFAULT_ADMIN_ROLE(); + + vm.expectRevert( + abi.encodePacked( + "AccessControl: account ", + Strings.toHexString(uint160(address(0x1)), 20), + " is missing role ", + Strings.toHexString(uint256(role), 32) + ) + ); + vm.prank(address(0x1)); + tokenContract.setPrimarySaleRecipient(_primarySaleRecipient); + } + + function test_event_setPrimarySaleRecipient() public { + address _primarySaleRecipient = address(0x123); + + vm.expectEmit(true, true, true, true); + emit PrimarySaleRecipientUpdated(_primarySaleRecipient); + + vm.prank(deployerSigner); + tokenContract.setPrimarySaleRecipient(_primarySaleRecipient); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: platform fee + //////////////////////////////////////////////////////////////*/ + + function test_state_setPlatformFeeInfo() public { + address _platformFeeRecipient = address(0x123); + uint256 _platformFeeBps = 1000; + + vm.prank(deployerSigner); + tokenContract.setPlatformFeeInfo(_platformFeeRecipient, _platformFeeBps); + + (address recipient_, uint16 bps) = tokenContract.getPlatformFeeInfo(); + assertEq(_platformFeeRecipient, recipient_); + assertEq(_platformFeeBps, bps); + } + + function test_revert_setPlatformFeeInfo_ExceedsMaxBps() public { + address _platformFeeRecipient = address(0x123); + uint256 _platformFeeBps = 10001; + + vm.expectRevert("exceeds MAX_BPS"); + vm.prank(deployerSigner); + tokenContract.setPlatformFeeInfo(_platformFeeRecipient, _platformFeeBps); + } + + function test_revert_setPlatformFeeInfo_NotAuthorized() public { + bytes32 role = tokenContract.DEFAULT_ADMIN_ROLE(); + + vm.expectRevert( + abi.encodePacked( + "AccessControl: account ", + Strings.toHexString(uint160(address(0x1)), 20), + " is missing role ", + Strings.toHexString(uint256(role), 32) + ) + ); + vm.prank(address(0x1)); + tokenContract.setPlatformFeeInfo(address(1), 1000); + } + + function test_event_platformFeeInfo() public { + address _platformFeeRecipient = address(0x123); + uint256 _platformFeeBps = 1000; + + vm.expectEmit(true, true, true, true); + emit PlatformFeeInfoUpdated(_platformFeeRecipient, _platformFeeBps); + + vm.prank(deployerSigner); + tokenContract.setPlatformFeeInfo(_platformFeeRecipient, _platformFeeBps); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: contract metadata + //////////////////////////////////////////////////////////////*/ + + function test_state_setContractURI() public { + string memory uri = "uri_string"; + + vm.prank(deployerSigner); + tokenContract.setContractURI(uri); + + string memory _contractURI = tokenContract.contractURI(); + + assertEq(_contractURI, uri); + } + + function test_revert_setContractURI() public { + bytes32 role = tokenContract.DEFAULT_ADMIN_ROLE(); + + vm.expectRevert( + abi.encodePacked( + "AccessControl: account ", + Strings.toHexString(uint160(address(0x1)), 20), + " is missing role ", + Strings.toHexString(uint256(role), 32) + ) + ); + vm.prank(address(0x1)); + tokenContract.setContractURI(""); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: setTokenURI + //////////////////////////////////////////////////////////////*/ + + function test_setTokenURI_state() public { + string memory uri = "uri_string"; + + vm.prank(deployerSigner); + tokenContract.setTokenURI(0, uri); + + string memory _tokenURI = tokenContract.tokenURI(0); + + assertEq(_tokenURI, uri); + } + + function test_setTokenURI_revert_NotAuthorized() public { + string memory uri = "uri_string"; + + vm.expectRevert(abi.encodeWithSelector(NFTMetadata.NFTMetadataUnauthorized.selector)); + vm.prank(address(0x1)); + tokenContract.setTokenURI(0, uri); + } + + function test_setTokenURI_revert_Frozen() public { + string memory uri = "uri_string"; + + vm.startPrank(deployerSigner); + tokenContract.freezeMetadata(); + + vm.expectRevert(abi.encodeWithSelector(NFTMetadata.NFTMetadataFrozen.selector, 0)); + tokenContract.setTokenURI(0, uri); + } +} diff --git a/src/test/tokenerc1155-BTT/burn-batch/burnBatch.t.sol b/src/test/tokenerc1155-BTT/burn-batch/burnBatch.t.sol new file mode 100644 index 000000000..273cb2db4 --- /dev/null +++ b/src/test/tokenerc1155-BTT/burn-batch/burnBatch.t.sol @@ -0,0 +1,148 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "../../utils/BaseTest.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +contract MyTokenERC1155 is TokenERC1155 {} + +contract TokenERC1155Test_BurnBatch is BaseTest { + address public implementation; + address public proxy; + address public caller; + address public recipient; + string public uri; + uint256 public amount; + + MyTokenERC1155 internal tokenContract; + + function setUp() public override { + super.setUp(); + + // Deploy implementation. + implementation = address(new MyTokenERC1155()); + caller = getActor(1); + recipient = getActor(2); + + // Deploy proxy pointing to implementaion. + vm.prank(deployer); + proxy = address( + new TWProxy( + implementation, + abi.encodeCall( + TokenERC1155.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ) + ); + + tokenContract = MyTokenERC1155(proxy); + uri = "uri"; + amount = 100; + + vm.prank(deployer); + tokenContract.grantRole(keccak256("MINTER_ROLE"), caller); + } + + function test_burn_whenNotOwnerNorApproved() public { + // mint two tokenIds + vm.startPrank(caller); + tokenContract.mintTo(recipient, type(uint256).max, uri, amount); + tokenContract.mintTo(recipient, type(uint256).max, uri, amount); + vm.stopPrank(); + + uint256[] memory ids = new uint256[](2); + uint256[] memory amounts = new uint256[](2); + + ids[0] = 0; + ids[1] = 1; + amounts[0] = 10; + amounts[1] = 10; + + // burn + vm.expectRevert("ERC1155: caller is not owner nor approved."); + tokenContract.burnBatch(recipient, ids, amounts); + } + + function test_burn_whenOwner_invalidAmount() public { + // mint two tokenIds + vm.startPrank(caller); + tokenContract.mintTo(recipient, type(uint256).max, uri, amount); + tokenContract.mintTo(recipient, type(uint256).max, uri, amount); + vm.stopPrank(); + + uint256[] memory ids = new uint256[](2); + uint256[] memory amounts = new uint256[](2); + + ids[0] = 0; + ids[1] = 1; + amounts[0] = 1000 ether; + amounts[1] = 10; + + // burn + vm.prank(recipient); + vm.expectRevert(); + tokenContract.burnBatch(recipient, ids, amounts); + } + + function test_burn_whenOwner() public { + // mint two tokenIds + vm.startPrank(caller); + tokenContract.mintTo(recipient, type(uint256).max, uri, amount); + tokenContract.mintTo(recipient, type(uint256).max, uri, amount); + vm.stopPrank(); + + uint256[] memory ids = new uint256[](2); + uint256[] memory amounts = new uint256[](2); + + ids[0] = 0; + ids[1] = 1; + amounts[0] = 10; + amounts[1] = 10; + + // burn + vm.prank(recipient); + tokenContract.burnBatch(recipient, ids, amounts); + + assertEq(tokenContract.balanceOf(recipient, ids[0]), amount - amounts[0]); + assertEq(tokenContract.balanceOf(recipient, ids[1]), amount - amounts[1]); + } + + function test_burn_whenApproved() public { + // mint two tokenIds + vm.startPrank(caller); + tokenContract.mintTo(recipient, type(uint256).max, uri, amount); + tokenContract.mintTo(recipient, type(uint256).max, uri, amount); + vm.stopPrank(); + + uint256[] memory ids = new uint256[](2); + uint256[] memory amounts = new uint256[](2); + + ids[0] = 0; + ids[1] = 1; + amounts[0] = 10; + amounts[1] = 10; + + vm.prank(recipient); + tokenContract.setApprovalForAll(caller, true); + + // burn + vm.prank(caller); + tokenContract.burnBatch(recipient, ids, amounts); + + assertEq(tokenContract.balanceOf(recipient, ids[0]), amount - amounts[0]); + assertEq(tokenContract.balanceOf(recipient, ids[1]), amount - amounts[1]); + } +} diff --git a/src/test/tokenerc1155-BTT/burn-batch/burnBatch.tree b/src/test/tokenerc1155-BTT/burn-batch/burnBatch.tree new file mode 100644 index 000000000..dca6fa537 --- /dev/null +++ b/src/test/tokenerc1155-BTT/burn-batch/burnBatch.tree @@ -0,0 +1,14 @@ +burnBatch( + address account, + uint256[] memory ids, + uint256[] memory values +) +├── when the caller isn't `account` or `account` hasn't approved tokens to caller +│ └── it should revert ✅ +└── when the caller is `account` with balances less than `values` for corresponding `ids` +│ └── it should revert ✅ +└── when the caller is `account` with balances greater than or equal to `values` +│ └── it should burn `values` amounts of `ids` tokens from account ✅ +└── when the `account` has approved `values` amount of tokens to caller + └── it should burn the token ✅ + diff --git a/src/test/tokenerc1155-BTT/burn/burn.t.sol b/src/test/tokenerc1155-BTT/burn/burn.t.sol new file mode 100644 index 000000000..1bf2575b9 --- /dev/null +++ b/src/test/tokenerc1155-BTT/burn/burn.t.sol @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "../../utils/BaseTest.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +contract MyTokenERC1155 is TokenERC1155 {} + +contract TokenERC1155Test_Burn is BaseTest { + address public implementation; + address public proxy; + address public caller; + address public recipient; + string public uri; + uint256 public amount; + + MyTokenERC1155 internal tokenContract; + + event MetadataUpdate(uint256 _tokenId); + event TokensMinted(address indexed mintedTo, uint256 indexed tokenIdMinted, string uri); + + function setUp() public override { + super.setUp(); + + // Deploy implementation. + implementation = address(new MyTokenERC1155()); + caller = getActor(1); + recipient = getActor(2); + + // Deploy proxy pointing to implementaion. + vm.prank(deployer); + proxy = address( + new TWProxy( + implementation, + abi.encodeCall( + TokenERC1155.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ) + ); + + tokenContract = MyTokenERC1155(proxy); + uri = "uri"; + amount = 100; + + vm.prank(deployer); + tokenContract.grantRole(keccak256("MINTER_ROLE"), caller); + } + + function test_burn_whenNotOwnerNorApproved() public { + // state before + uint256 _tokenIdToMint = tokenContract.nextTokenIdToMint(); + + // mint + vm.prank(caller); + tokenContract.mintTo(recipient, type(uint256).max, uri, amount); + + // burn + vm.expectRevert("ERC1155: caller is not owner nor approved."); + tokenContract.burn(recipient, _tokenIdToMint, amount); + } + + function test_burn_whenOwner_invalidAmount() public { + // state before + uint256 _tokenIdToMint = tokenContract.nextTokenIdToMint(); + + // mint + vm.prank(caller); + tokenContract.mintTo(recipient, type(uint256).max, uri, amount); + + // burn + vm.prank(recipient); + vm.expectRevert(); + tokenContract.burn(recipient, _tokenIdToMint, amount + 1); + } + + function test_burn_whenOwner() public { + // state before + uint256 _tokenIdToMint = tokenContract.nextTokenIdToMint(); + + // mint + vm.prank(caller); + tokenContract.mintTo(recipient, type(uint256).max, uri, amount); + + // burn + vm.prank(recipient); + tokenContract.burn(recipient, _tokenIdToMint, amount); + + assertEq(tokenContract.balanceOf(recipient, _tokenIdToMint), 0); + } + + function test_burn_whenApproved() public { + // state before + uint256 _tokenIdToMint = tokenContract.nextTokenIdToMint(); + + // mint + vm.prank(caller); + tokenContract.mintTo(recipient, type(uint256).max, uri, amount); + + vm.prank(recipient); + tokenContract.setApprovalForAll(caller, true); + + // burn + vm.prank(caller); + tokenContract.burn(recipient, _tokenIdToMint, amount); + + assertEq(tokenContract.balanceOf(recipient, _tokenIdToMint), 0); + } +} diff --git a/src/test/tokenerc1155-BTT/burn/burn.tree b/src/test/tokenerc1155-BTT/burn/burn.tree new file mode 100644 index 000000000..8232a832d --- /dev/null +++ b/src/test/tokenerc1155-BTT/burn/burn.tree @@ -0,0 +1,14 @@ +burn( + address account, + uint256 id, + uint256 value +) +├── when the caller isn't `account` or `account` hasn't approved tokens to caller +│ └── it should revert ✅ +└── when the caller is `account` with balance less than `value` +│ └── it should revert ✅ +└── when the caller is `account` with balance greater than or equal to `value` +│ └── it should burn `value` amount of `id` tokens from ✅ +└── when the `account` has approved `value` amount of tokens to caller + └── it should burn the token ✅ + diff --git a/src/test/tokenerc1155-BTT/initialize/initialize.t.sol b/src/test/tokenerc1155-BTT/initialize/initialize.t.sol new file mode 100644 index 000000000..5340ff595 --- /dev/null +++ b/src/test/tokenerc1155-BTT/initialize/initialize.t.sol @@ -0,0 +1,313 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "../../utils/BaseTest.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; +import { IPlatformFee } from "contracts/extension/interface/IPlatformFee.sol"; + +contract MyTokenERC1155 is TokenERC1155 { + function eip712NameHash() external view returns (bytes32) { + return _EIP712NameHash(); + } + + function eip712VersionHash() external view returns (bytes32) { + return _EIP712VersionHash(); + } +} + +contract TokenERC1155Test_Initialize is BaseTest { + address public implementation; + address public proxy; + + event RoleGranted(bytes32 indexed role, address indexed account, address indexed sender); + event RoleAdminChanged(bytes32 indexed role, bytes32 indexed previousAdminRole, bytes32 indexed newAdminRole); + + function setUp() public override { + super.setUp(); + + // Deploy implementation. + implementation = address(new MyTokenERC1155()); + + // Deploy proxy pointing to implementaion. + vm.prank(deployer); + proxy = address( + new TWProxy( + implementation, + abi.encodeCall( + TokenERC1155.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ) + ); + } + + function test_initialize_initializingImplementation() public { + vm.expectRevert("Initializable: contract is already initialized"); + TokenERC1155(implementation).initialize( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ); + } + + modifier whenNotImplementation() { + _; + } + + function test_initialize_proxyAlreadyInitialized() public whenNotImplementation { + vm.expectRevert("Initializable: contract is already initialized"); + MyTokenERC1155(proxy).initialize( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ); + } + + modifier whenProxyNotInitialized() { + proxy = address(new TWProxy(implementation, "")); + _; + } + + function test_initialize_exceedsMaxBps() public whenNotImplementation whenProxyNotInitialized { + vm.expectRevert("exceeds MAX_BPS"); + MyTokenERC1155(proxy).initialize( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + uint128(MAX_BPS) + 1, // platformFeeBps greater than MAX_BPS + platformFeeRecipient + ); + } + + modifier whenPlatformFeeBpsWithinMaxBps() { + _; + } + + function test_initialize() public whenNotImplementation whenProxyNotInitialized whenPlatformFeeBpsWithinMaxBps { + MyTokenERC1155(proxy).initialize( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ); + + // check state + MyTokenERC1155 tokenContract = MyTokenERC1155(proxy); + + assertEq(tokenContract.eip712NameHash(), keccak256(bytes("TokenERC1155"))); + assertEq(tokenContract.eip712VersionHash(), keccak256(bytes("1"))); + + address[] memory _trustedForwarders = forwarders(); + for (uint256 i = 0; i < _trustedForwarders.length; i++) { + assertTrue(tokenContract.isTrustedForwarder(_trustedForwarders[i])); + } + + assertEq(tokenContract.name(), NAME); + assertEq(tokenContract.symbol(), SYMBOL); + assertEq(tokenContract.contractURI(), CONTRACT_URI); + + (address _platformFeeRecipient, uint16 _platformFeeBps) = tokenContract.getPlatformFeeInfo(); + assertEq(_platformFeeBps, platformFeeBps); + assertEq(_platformFeeRecipient, platformFeeRecipient); + assertEq(tokenContract.platformFeeRecipient(), platformFeeRecipient); + assertEq(uint8(tokenContract.getPlatformFeeType()), uint8(IPlatformFee.PlatformFeeType.Bps)); + + (address _royaltyRecipient, uint16 _royaltyBps) = tokenContract.getDefaultRoyaltyInfo(); + (address _royaltyRecipientForToken, uint16 _royaltyBpsForToken) = tokenContract.getRoyaltyInfoForToken(1); // random tokenId + assertEq(_royaltyBps, royaltyBps); + assertEq(_royaltyRecipient, royaltyRecipient); + assertEq(_royaltyRecipient, _royaltyRecipientForToken); + assertEq(_royaltyBps, _royaltyBpsForToken); + + assertEq(tokenContract.primarySaleRecipient(), saleRecipient); + + assertEq(tokenContract.owner(), deployer); + assertTrue(tokenContract.hasRole(bytes32(0x00), deployer)); + assertTrue(tokenContract.hasRole(keccak256("TRANSFER_ROLE"), deployer)); + assertTrue(tokenContract.hasRole(keccak256("TRANSFER_ROLE"), address(0))); + assertTrue(tokenContract.hasRole(keccak256("MINTER_ROLE"), deployer)); + assertTrue(tokenContract.hasRole(keccak256("METADATA_ROLE"), deployer)); + assertEq(tokenContract.getRoleAdmin(keccak256("METADATA_ROLE")), keccak256("METADATA_ROLE")); + } + + function test_initialize_event_RoleGranted_DefaultAdmin() + public + whenNotImplementation + whenProxyNotInitialized + whenPlatformFeeBpsWithinMaxBps + { + bytes32 _defaultAdminRole = bytes32(0x00); + vm.prank(deployer); + vm.expectEmit(true, true, true, false); + emit RoleGranted(_defaultAdminRole, deployer, deployer); + MyTokenERC1155(proxy).initialize( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ); + } + + function test_initialize_event_RoleGranted_MinterRole() + public + whenNotImplementation + whenProxyNotInitialized + whenPlatformFeeBpsWithinMaxBps + { + bytes32 _minterRole = keccak256("MINTER_ROLE"); + vm.prank(deployer); + vm.expectEmit(true, true, true, false); + emit RoleGranted(_minterRole, deployer, deployer); + MyTokenERC1155(proxy).initialize( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ); + } + + function test_initialize_event_RoleGranted_TransferRole() + public + whenNotImplementation + whenProxyNotInitialized + whenPlatformFeeBpsWithinMaxBps + { + bytes32 _transferRole = keccak256("TRANSFER_ROLE"); + vm.prank(deployer); + vm.expectEmit(true, true, true, false); + emit RoleGranted(_transferRole, deployer, deployer); + MyTokenERC1155(proxy).initialize( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ); + } + + function test_initialize_event_RoleGranted_TransferRole_AddressZero() + public + whenNotImplementation + whenProxyNotInitialized + whenPlatformFeeBpsWithinMaxBps + { + bytes32 _transferRole = keccak256("TRANSFER_ROLE"); + vm.prank(deployer); + vm.expectEmit(true, true, true, false); + emit RoleGranted(_transferRole, address(0), deployer); + MyTokenERC1155(proxy).initialize( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ); + } + + function test_initialize_event_RoleGranted_MetadataRole() + public + whenNotImplementation + whenProxyNotInitialized + whenPlatformFeeBpsWithinMaxBps + { + bytes32 _metadataRole = keccak256("METADATA_ROLE"); + vm.prank(deployer); + vm.expectEmit(true, true, true, false); + emit RoleGranted(_metadataRole, deployer, deployer); + MyTokenERC1155(proxy).initialize( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ); + } + + function test_initialize_event_RoleAdminChanged_MetadataRole() + public + whenNotImplementation + whenProxyNotInitialized + whenPlatformFeeBpsWithinMaxBps + { + bytes32 _metadataRole = keccak256("METADATA_ROLE"); + vm.prank(deployer); + vm.expectEmit(true, true, true, false); + emit RoleAdminChanged(_metadataRole, bytes32(0x00), _metadataRole); + MyTokenERC1155(proxy).initialize( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ); + } +} diff --git a/src/test/tokenerc1155-BTT/initialize/initialize.tree b/src/test/tokenerc1155-BTT/initialize/initialize.tree new file mode 100644 index 000000000..15c2ba936 --- /dev/null +++ b/src/test/tokenerc1155-BTT/initialize/initialize.tree @@ -0,0 +1,43 @@ +initialize( + address _defaultAdmin, + string memory _name, + string memory _symbol, + string memory _contractURI, + address[] memory _trustedForwarders, + address _primarySaleRecipient, + address _royaltyRecipient, + uint128 _royaltyBps, + uint128 _platformFeeBps, + address _platformFeeRecipient +) +├── when initializing the implementation contract (not proxy) +│ └── it should revert ✅ +└── when it is a proxy to the implementation + └── when it is already initialized + │ └── it should revert ✅ + └── when it is not initialized + └── when platformFeeBps is greater than MAX_BPS + │ └── it should revert ✅ + └── when platformFeeBps is less than or equal to MAX_BPS + └── it should correctly set EIP712 name hash and version hash ✅ + └── it should set trustedForwarder mapping to true for all addresses in `_trustedForwarders` ✅ + └── it should set name and symbol to `_name` and `_symbol` param values respectively ✅ + └── it should set contractURI to `_contractURI` param value ✅ + └── it should set platformFeeRecipient and platformFeeBps as `_platformFeeRecipient` and `_platformFeeBps` respectively ✅ + └── it should set platformFeeType to `Bps` ✅ + └── it should set royaltyRecipient and royaltyBps as `_royaltyRecipient` and `_royaltyBps` respectively ✅ + └── it should set primary sale recipient as `_primarySaleRecipient` param value ✅ + └── it should set _owner to `_defaultAdmin` param value ✅ + └── it should grant 0x00 (DEFAULT_ADMIN_ROLE) to `_defaultAdmin` address ✅ + └── it should emit RoleGranted event ✅ + └── it should grant MINTER_ROLE to `_defaultAdmin` address ✅ + └── it should emit RoleGranted event ✅ + └── it should grant TRANSFER_ROLE to `_defaultAdmin` address ✅ + └── it should emit RoleGranted event ✅ + └── it should grant TRANSFER_ROLE to address(0) ✅ + └── it should emit RoleGranted event ✅ + └── it should grant METADATA_ROLE to `_defaultAdmin` address ✅ + └── it should emit RoleGranted event ✅ + └── it should set METADATA_ROLE as role admin for METADATA_ROLE ✅ + └── it should emit RoleAdminChanged event ✅ + diff --git a/src/test/tokenerc1155-BTT/mint-to/mintTo.t.sol b/src/test/tokenerc1155-BTT/mint-to/mintTo.t.sol new file mode 100644 index 000000000..b0a8703bb --- /dev/null +++ b/src/test/tokenerc1155-BTT/mint-to/mintTo.t.sol @@ -0,0 +1,307 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "../../utils/BaseTest.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +contract MyTokenERC1155 is TokenERC1155 {} + +contract ERC1155ReceiverCompliant is IERC1155Receiver { + function onERC1155Received( + address operator, + address from, + uint256 id, + uint256 value, + bytes calldata data + ) external view virtual override returns (bytes4) { + return this.onERC1155Received.selector; + } + + function onERC1155BatchReceived( + address operator, + address from, + uint256[] calldata ids, + uint256[] calldata values, + bytes calldata data + ) external returns (bytes4) { + return this.onERC1155BatchReceived.selector; + } + + function supportsInterface(bytes4 interfaceId) external view returns (bool) {} +} + +contract TokenERC1155Test_MintTo is BaseTest { + address public implementation; + address public proxy; + address public caller; + address public recipient; + string public uri; + uint256 public amount; + + MyTokenERC1155 internal tokenContract; + ERC1155ReceiverCompliant internal erc1155ReceiverContract; + + event MetadataUpdate(uint256 _tokenId); + event TokensMinted(address indexed mintedTo, uint256 indexed tokenIdMinted, string uri, uint256 quantityMinted); + + function setUp() public override { + super.setUp(); + + // Deploy implementation. + implementation = address(new MyTokenERC1155()); + erc1155ReceiverContract = new ERC1155ReceiverCompliant(); + caller = getActor(1); + recipient = getActor(2); + + // Deploy proxy pointing to implementaion. + vm.prank(deployer); + proxy = address( + new TWProxy( + implementation, + abi.encodeCall( + TokenERC1155.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ) + ); + + tokenContract = MyTokenERC1155(proxy); + amount = 100; + uri = "ipfs://uri"; + } + + function test_mintTo_notMinterRole() public { + vm.prank(caller); + vm.expectRevert( + abi.encodePacked( + "AccessControl: account ", + Strings.toHexString(uint160(caller), 20), + " is missing role ", + Strings.toHexString(uint256(keccak256("MINTER_ROLE")), 32) + ) + ); + tokenContract.mintTo(recipient, type(uint256).max, uri, amount); + } + + modifier whenMinterRole() { + vm.prank(deployer); + tokenContract.grantRole(keccak256("MINTER_ROLE"), caller); + _; + } + + // ================== + // ======= Test branch: `tokenId` input param is type(uint256).max + // ================== + + function test_mintTo_maxTokenId_EOA() public whenMinterRole { + // state before + uint256 _tokenIdToMint = tokenContract.nextTokenIdToMint(); + + // mint + vm.prank(caller); + tokenContract.mintTo(recipient, type(uint256).max, uri, amount); + + // check state after + assertEq(_tokenIdToMint, 0); + assertEq(tokenContract.nextTokenIdToMint(), _tokenIdToMint + 1); + assertEq(tokenContract.balanceOf(recipient, _tokenIdToMint), amount); + assertEq(tokenContract.uri(_tokenIdToMint), uri); + } + + function test_mintTo_maxTokenId_EOA_TokensMintedEvent() public whenMinterRole { + uint256 _tokenIdToMint = tokenContract.nextTokenIdToMint(); + + vm.prank(caller); + vm.expectEmit(true, true, false, true); + emit TokensMinted(recipient, _tokenIdToMint, uri, amount); + tokenContract.mintTo(recipient, type(uint256).max, uri, amount); + } + + function test_mintTo_maxTokenId_EOA_MetadataUpdateEvent() public whenMinterRole { + uint256 _tokenIdToMint = tokenContract.nextTokenIdToMint(); + + vm.prank(caller); + vm.expectEmit(false, false, false, true); + emit MetadataUpdate(_tokenIdToMint); + tokenContract.mintTo(recipient, type(uint256).max, uri, amount); + } + + function test_mintTo_maxTokenId_EOA_uriAlreadyPresent() public whenMinterRole { + // state before + uint256 _tokenIdToMint = tokenContract.nextTokenIdToMint(); + + vm.prank(deployer); + tokenContract.setTokenURI(_tokenIdToMint, "ipfs://uriOld"); + + // mint + vm.prank(caller); + tokenContract.mintTo(recipient, type(uint256).max, uri, amount); + + // check state after + assertEq(_tokenIdToMint, 0); + assertEq(tokenContract.nextTokenIdToMint(), _tokenIdToMint + 1); + assertEq(tokenContract.balanceOf(recipient, _tokenIdToMint), amount); + assertEq(tokenContract.uri(_tokenIdToMint), "ipfs://uriOld"); + } + + function test_mintTo_maxTokenId_nonERC1155ReceiverContract() public whenMinterRole { + recipient = address(this); + vm.prank(caller); + vm.expectRevert(); + tokenContract.mintTo(recipient, type(uint256).max, uri, amount); + } + + modifier whenERC1155Receiver() { + recipient = address(erc1155ReceiverContract); + _; + } + + function test_mintTo_maxTokenId_contract() public whenMinterRole whenERC1155Receiver { + // state before + uint256 _tokenIdToMint = tokenContract.nextTokenIdToMint(); + + // mint + vm.prank(caller); + tokenContract.mintTo(recipient, type(uint256).max, uri, amount); + + // check state after + assertEq(_tokenIdToMint, 0); + assertEq(tokenContract.nextTokenIdToMint(), _tokenIdToMint + 1); + assertEq(tokenContract.balanceOf(recipient, _tokenIdToMint), amount); + assertEq(tokenContract.uri(_tokenIdToMint), uri); + } + + function test_mintTo_maxTokenId_contract_TokensMintedEvent() public whenMinterRole whenERC1155Receiver { + uint256 _tokenIdToMint = tokenContract.nextTokenIdToMint(); + + vm.prank(caller); + vm.expectEmit(true, true, false, true); + emit TokensMinted(recipient, _tokenIdToMint, uri, amount); + tokenContract.mintTo(recipient, type(uint256).max, uri, amount); + } + + function test_mintTo_maxTokenId_contract_MetadataUpdateEvent() public whenMinterRole whenERC1155Receiver { + uint256 _tokenIdToMint = tokenContract.nextTokenIdToMint(); + + vm.prank(caller); + vm.expectEmit(false, false, false, true); + emit MetadataUpdate(_tokenIdToMint); + tokenContract.mintTo(recipient, type(uint256).max, uri, amount); + } + + function test_mintTo_maxTokenId_contract_uriAlreadyPresent() public whenMinterRole whenERC1155Receiver { + // state before + uint256 _tokenIdToMint = tokenContract.nextTokenIdToMint(); + + vm.prank(deployer); + tokenContract.setTokenURI(_tokenIdToMint, "ipfs://uriOld"); + + // mint + vm.prank(caller); + tokenContract.mintTo(recipient, type(uint256).max, uri, amount); + + // check state after + assertEq(_tokenIdToMint, 0); + assertEq(tokenContract.nextTokenIdToMint(), _tokenIdToMint + 1); + assertEq(tokenContract.balanceOf(recipient, _tokenIdToMint), amount); + assertEq(tokenContract.uri(_tokenIdToMint), "ipfs://uriOld"); + } + + // ================== + // ======= Test branch: `tokenId` input param is not type(uint256).max + // ================== + + modifier whenNotMaxTokenId() { + // pre-mint the first token (i.e. id 0), so that nextTokenIdToMint is 1, for this code path + vm.prank(deployer); + tokenContract.mintTo(deployer, type(uint256).max, "uri1", amount); + _; + } + + function test_mintTo_EOA_invalidId() public whenMinterRole whenNotMaxTokenId { + // state before + uint256 _tokenIdToMint = tokenContract.nextTokenIdToMint(); + + // mint + vm.prank(caller); + vm.expectRevert("invalid id"); + tokenContract.mintTo(recipient, _tokenIdToMint, uri, amount); + } + + modifier whenValidId() { + _; + } + + function test_mintTo_EOA() public whenMinterRole whenNotMaxTokenId whenValidId { + // state before + uint256 _tokenIdToMint = tokenContract.nextTokenIdToMint() - 1; + + // mint + vm.prank(caller); + tokenContract.mintTo(recipient, _tokenIdToMint, uri, amount); + + // check state after + assertEq(tokenContract.nextTokenIdToMint(), _tokenIdToMint + 1); + assertEq(tokenContract.balanceOf(recipient, _tokenIdToMint), amount); + assertEq(tokenContract.uri(_tokenIdToMint), "uri1"); + } + + function test_mintTo_EOA_TokensMintedEvent() public whenMinterRole whenNotMaxTokenId whenValidId { + uint256 _tokenIdToMint = tokenContract.nextTokenIdToMint() - 1; + + vm.prank(caller); + vm.expectEmit(true, true, false, true); + emit TokensMinted(recipient, _tokenIdToMint, "uri1", amount); + tokenContract.mintTo(recipient, _tokenIdToMint, uri, amount); + } + + function test_mintTo_nonERC1155ReceiverContract() public whenMinterRole whenNotMaxTokenId whenValidId { + uint256 _tokenIdToMint = tokenContract.nextTokenIdToMint() - 1; + + recipient = address(this); + vm.prank(caller); + vm.expectRevert(); + tokenContract.mintTo(recipient, _tokenIdToMint, uri, amount); + } + + function test_mintTo_contract() public whenMinterRole whenNotMaxTokenId whenERC1155Receiver whenValidId { + // state before + uint256 _tokenIdToMint = tokenContract.nextTokenIdToMint() - 1; + + // mint + vm.prank(caller); + tokenContract.mintTo(recipient, _tokenIdToMint, uri, amount); + + // check state after + assertEq(tokenContract.nextTokenIdToMint(), _tokenIdToMint + 1); + assertEq(tokenContract.balanceOf(recipient, _tokenIdToMint), amount); + assertEq(tokenContract.uri(_tokenIdToMint), "uri1"); + } + + function test_mintTo_contract_TokensMintedEvent() + public + whenMinterRole + whenNotMaxTokenId + whenERC1155Receiver + whenValidId + { + uint256 _tokenIdToMint = tokenContract.nextTokenIdToMint() - 1; + + vm.prank(caller); + vm.expectEmit(true, true, false, true); + emit TokensMinted(recipient, _tokenIdToMint, "uri1", amount); + tokenContract.mintTo(recipient, _tokenIdToMint, uri, amount); + } +} diff --git a/src/test/tokenerc1155-BTT/mint-to/mintTo.tree b/src/test/tokenerc1155-BTT/mint-to/mintTo.tree new file mode 100644 index 000000000..facd1e8eb --- /dev/null +++ b/src/test/tokenerc1155-BTT/mint-to/mintTo.tree @@ -0,0 +1,48 @@ +mintTo( + address _to, + uint256 _tokenId, + string calldata _uri, + uint256 _amount +) +├── when caller doesn't have MINTER_ROLE + │ └── it should revert ✅ + └── when caller has MINTER_ROLE + ├── when `_tokenId` is type(uint256).max + │ ├── when `_to` address is an EOA + │ │ └── it should mint tokenId equal to current value of `nextTokenIdToMint` ✅ + │ │ └── it should increment `nextTokenIdToMint` by 1 ✅ + │ │ └── it should mint the `_amount` number of tokens to the `_to` address ✅ + │ │ └── it should emit TokensMinted event ✅ + │ │ └── when there is no uri associated with the minted tokenId + │ │ └── it should set uri for minted tokenId equal to `_uri` ✅ + │ │ └── it should emit MetadataUpdate event ✅ + │ └── when `_to` address is a contract + │ ├── when `_to` address is non ERC1155Receiver implementer + │ │ └── it should revert ✅ + │ └── when `_to` address implements ERC1155Receiver + │ └── it should mint tokenId equal to current value of `nextTokenIdToMint` ✅ + │ └── it should increment `nextTokenIdToMint` by 1 ✅ + │ └── it should mint the `_amount` number of tokens to the `_to` address ✅ + │ └── it should emit TokensMinted event ✅ + │ └── when there is no uri associated with the minted tokenId + │ └── it should set uri for minted tokenId equal to `_uri` ✅ + │ └── it should emit MetadataUpdate event ✅ + │ + └── when `_tokenId` is not type(uint256).max + ├── when `_tokenId` is not less than nextTokenIdToMint + │ └── it should revert ✅ + └── when `_tokenId` is less than nextTokenIdToMint + ├── when `_to` address is an EOA + │ └── it should mint tokenId equal to current value of `nextTokenIdToMint` ✅ + │ └── it should increment `nextTokenIdToMint` by 1 ✅ + │ └── it should mint the `_amount` number of tokens to the `_to` address ✅ + │ └── it should emit TokensMinted event ✅ + └── when `_to` address is a contract + ├── when `_to` address is non ERC1155Receiver implementer + │ └── it should revert ✅ + └── when `_to` address implements ERC1155Receiver + └── it should mint tokenId equal to current value of `nextTokenIdToMint` ✅ + └── it should increment `nextTokenIdToMint` by 1 ✅ + └── it should mint the `_amount` number of tokens to the `_to` address ✅ + └── it should emit TokensMinted event ✅ + diff --git a/src/test/tokenerc1155-BTT/mint-with-signature/mintWithSignature.t.sol b/src/test/tokenerc1155-BTT/mint-with-signature/mintWithSignature.t.sol new file mode 100644 index 000000000..f39177c8a --- /dev/null +++ b/src/test/tokenerc1155-BTT/mint-with-signature/mintWithSignature.t.sol @@ -0,0 +1,894 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "../../utils/BaseTest.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; +import { IPlatformFee } from "contracts/extension/interface/IPlatformFee.sol"; + +contract MyTokenERC1155 is TokenERC1155 { + function setMintedURI(MintRequest calldata _req, bytes calldata _signature) external { + verifyRequest(_req, _signature); + } +} + +contract ERC1155ReceiverCompliant is IERC1155Receiver { + function onERC1155Received( + address operator, + address from, + uint256 id, + uint256 value, + bytes calldata data + ) external view virtual override returns (bytes4) { + return this.onERC1155Received.selector; + } + + function onERC1155BatchReceived( + address operator, + address from, + uint256[] calldata ids, + uint256[] calldata values, + bytes calldata data + ) external returns (bytes4) { + return this.onERC1155BatchReceived.selector; + } + + function supportsInterface(bytes4 interfaceId) external view returns (bool) {} +} + +contract ReentrantContract { + fallback() external payable { + TokenERC1155.MintRequest memory _mintrequest; + bytes memory _signature; + MyTokenERC1155(msg.sender).mintWithSignature(_mintrequest, _signature); + } +} + +contract TokenERC1155Test_MintWithSignature is BaseTest { + address public implementation; + address public proxy; + address public caller; + address public recipient; + string public uri; + + MyTokenERC1155 internal tokenContract; + ERC1155ReceiverCompliant internal erc1155ReceiverContract; + + bytes32 internal typehashMintRequest; + bytes32 internal nameHash; + bytes32 internal versionHash; + bytes32 internal typehashEip712; + bytes32 internal domainSeparator; + + TokenERC1155.MintRequest _mintrequest; + + event MetadataUpdate(uint256 _tokenId); + event TokensMinted(address indexed mintedTo, uint256 indexed tokenIdMinted, string uri); + event TokensMintedWithSignature( + address indexed signer, + address indexed mintedTo, + uint256 indexed tokenIdMinted, + TokenERC1155.MintRequest mintRequest + ); + + function setUp() public override { + super.setUp(); + + // Deploy implementation. + implementation = address(new MyTokenERC1155()); + erc1155ReceiverContract = new ERC1155ReceiverCompliant(); + caller = getActor(1); + recipient = getActor(2); + + // Deploy proxy pointing to implementaion. + vm.prank(deployer); + proxy = address( + new TWProxy( + implementation, + abi.encodeCall( + TokenERC1155.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ) + ); + + tokenContract = MyTokenERC1155(proxy); + + typehashMintRequest = keccak256( + "MintRequest(address to,address royaltyRecipient,uint256 royaltyBps,address primarySaleRecipient,uint256 tokenId,string uri,uint256 quantity,uint256 pricePerToken,address currency,uint128 validityStartTimestamp,uint128 validityEndTimestamp,bytes32 uid)" + ); + nameHash = keccak256(bytes("TokenERC1155")); + versionHash = keccak256(bytes("1")); + typehashEip712 = keccak256( + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" + ); + domainSeparator = keccak256( + abi.encode(typehashEip712, nameHash, versionHash, block.chainid, address(tokenContract)) + ); + + // construct default mintrequest + _mintrequest.to = address(0x1234); + _mintrequest.royaltyRecipient = royaltyRecipient; + _mintrequest.royaltyBps = royaltyBps; + _mintrequest.primarySaleRecipient = saleRecipient; + _mintrequest.tokenId = type(uint256).max; + _mintrequest.uri = "ipfs://"; + _mintrequest.quantity = 100; + _mintrequest.pricePerToken = 0; + _mintrequest.currency = address(0); + _mintrequest.validityStartTimestamp = 0; + _mintrequest.validityEndTimestamp = 2000; + _mintrequest.uid = bytes32(0); + + erc20.mint(deployer, 1_000 ether); + vm.deal(deployer, 1_000 ether); + erc20.mint(caller, 1_000 ether); + vm.deal(caller, 1_000 ether); + + vm.startPrank(deployer); + erc20.approve(address(tokenContract), type(uint256).max); + vm.stopPrank(); + + vm.startPrank(caller); + erc20.approve(address(tokenContract), type(uint256).max); + vm.stopPrank(); + } + + function signMintRequest( + TokenERC1155.MintRequest memory _request, + uint256 _privateKey + ) internal view returns (bytes memory) { + bytes memory encodedRequest = bytes.concat( + abi.encode( + typehashMintRequest, + _request.to, + _request.royaltyRecipient, + _request.royaltyBps, + _request.primarySaleRecipient, + _request.tokenId, + keccak256(bytes(_request.uri)) + ), + abi.encode( + _request.quantity, + _request.pricePerToken, + _request.currency, + _request.validityStartTimestamp, + _request.validityEndTimestamp, + _request.uid + ) + ); + bytes32 structHash = keccak256(encodedRequest); + bytes32 typedDataHash = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(_privateKey, typedDataHash); + bytes memory sig = abi.encodePacked(r, s, v); + + return sig; + } + + // ================== + // ======= Assume _req.tokenId input is type(uint256).max and platform fee type is Bps + // ================== + + function test_mintWithSignature_notMinterRole() public { + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + vm.prank(caller); + vm.expectRevert("invalid signature"); + tokenContract.mintWithSignature(_mintrequest, _signature); + } + + modifier whenMinterRole() { + vm.prank(deployer); + tokenContract.grantRole(keccak256("MINTER_ROLE"), signer); + _; + } + + function test_mintWithSignature_invalidUID() public whenMinterRole { + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + // set state with this mintrequest and signature, marking the UID as used + tokenContract.setMintedURI(_mintrequest, _signature); + + // pass the same UID mintrequest again + vm.prank(caller); + vm.expectRevert("invalid signature"); + tokenContract.mintWithSignature(_mintrequest, _signature); + } + + modifier whenUidNotUsed() { + _; + } + + function test_mintWithSignature_invalidStartTimestamp() public whenMinterRole whenUidNotUsed { + _mintrequest.validityStartTimestamp = uint128(block.timestamp + 1); + + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + vm.expectRevert("request expired"); + vm.prank(caller); + tokenContract.mintWithSignature(_mintrequest, _signature); + } + + modifier whenValidStartTimestamp() { + _; + } + + function test_mintWithSignature_invalidEndTimestamp() public whenMinterRole whenUidNotUsed whenValidStartTimestamp { + _mintrequest.validityEndTimestamp = uint128(block.timestamp - 1); + + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + vm.expectRevert("request expired"); + vm.prank(caller); + tokenContract.mintWithSignature(_mintrequest, _signature); + } + + modifier whenValidEndTimestamp() { + _; + } + + function test_mintWithSignature_recipientAddressZero() + public + whenMinterRole + whenUidNotUsed + whenValidStartTimestamp + whenValidEndTimestamp + { + _mintrequest.to = address(0); + + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + vm.expectRevert("recipient undefined"); + vm.prank(caller); + tokenContract.mintWithSignature(_mintrequest, _signature); + } + + modifier whenRecipientAddressNotZero() { + _; + } + + function test_mintWithSignature_zeroQuantity() + public + whenMinterRole + whenUidNotUsed + whenValidStartTimestamp + whenValidEndTimestamp + whenRecipientAddressNotZero + { + _mintrequest.quantity = 0; + + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + vm.expectRevert("zero quantity"); + vm.prank(caller); + tokenContract.mintWithSignature(_mintrequest, _signature); + } + + modifier whenNotZeroQuantity() { + _mintrequest.quantity = 100; + _; + } + + // ================== + // ======= Test branch: when mint price is zero + // ================== + + function test_mintWithSignature_zeroPrice_msgValueNonZero() + public + whenMinterRole + whenUidNotUsed + whenValidStartTimestamp + whenValidEndTimestamp + whenRecipientAddressNotZero + whenNotZeroQuantity + { + _mintrequest.pricePerToken = 0; + + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + vm.expectRevert("!Value"); + vm.prank(caller); + tokenContract.mintWithSignature{ value: 1 }(_mintrequest, _signature); + } + + modifier whenMsgValueZero() { + _; + } + + function test_mintWithSignature_zeroPrice_EOA() + public + whenMinterRole + whenUidNotUsed + whenValidStartTimestamp + whenValidEndTimestamp + whenRecipientAddressNotZero + whenNotZeroQuantity + whenMsgValueZero + { + _mintrequest.pricePerToken = 0; + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + // state before + uint256 _tokenIdToMint = tokenContract.nextTokenIdToMint(); + + // mint + vm.prank(caller); + tokenContract.mintWithSignature(_mintrequest, _signature); + + // check state after + assertEq(tokenContract.nextTokenIdToMint(), _tokenIdToMint + 1); + assertEq(tokenContract.balanceOf(_mintrequest.to, _tokenIdToMint), _mintrequest.quantity); + assertEq(tokenContract.uri(_tokenIdToMint), _mintrequest.uri); + assertEq(tokenContract.totalSupply(_tokenIdToMint), _mintrequest.quantity); + } + + function test_mintWithSignature_zeroPrice_EOA_MetadataUpdateEvent() + public + whenMinterRole + whenUidNotUsed + whenValidStartTimestamp + whenValidEndTimestamp + whenRecipientAddressNotZero + whenNotZeroQuantity + whenMsgValueZero + { + _mintrequest.pricePerToken = 0; + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + uint256 _tokenIdToMint = tokenContract.nextTokenIdToMint(); + + vm.prank(caller); + vm.expectEmit(false, false, false, true); + emit MetadataUpdate(_tokenIdToMint); + tokenContract.mintWithSignature(_mintrequest, _signature); + } + + function test_mintWithSignature_zeroPrice_EOA_TokensMintedWithSignatureEvent() + public + whenMinterRole + whenUidNotUsed + whenValidStartTimestamp + whenValidEndTimestamp + whenRecipientAddressNotZero + whenNotZeroQuantity + whenMsgValueZero + { + _mintrequest.pricePerToken = 0; + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + uint256 _tokenIdToMint = tokenContract.nextTokenIdToMint(); + + vm.prank(caller); + vm.expectEmit(true, true, false, true); + emit TokensMintedWithSignature(signer, _mintrequest.to, _tokenIdToMint, _mintrequest); + tokenContract.mintWithSignature(_mintrequest, _signature); + } + + function test_mintWithSignature_zeroPrice_nonERC1155ReceiverContract() + public + whenMinterRole + whenUidNotUsed + whenValidStartTimestamp + whenValidEndTimestamp + whenRecipientAddressNotZero + whenNotZeroQuantity + whenMsgValueZero + { + _mintrequest.pricePerToken = 0; + _mintrequest.to = address(this); + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + // mint + vm.prank(caller); + vm.expectRevert(); + tokenContract.mintWithSignature(_mintrequest, _signature); + } + + modifier whenERC1155Receiver() { + _mintrequest.to = address(erc1155ReceiverContract); + _; + } + + function test_mintWithSignature_zeroPrice_contract() + public + whenMinterRole + whenUidNotUsed + whenValidStartTimestamp + whenValidEndTimestamp + whenRecipientAddressNotZero + whenNotZeroQuantity + whenMsgValueZero + whenERC1155Receiver + { + _mintrequest.pricePerToken = 0; + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + // state before + uint256 _tokenIdToMint = tokenContract.nextTokenIdToMint(); + + // mint + vm.prank(caller); + tokenContract.mintWithSignature(_mintrequest, _signature); + + // check state after + assertEq(tokenContract.nextTokenIdToMint(), _tokenIdToMint + 1); + assertEq(tokenContract.balanceOf(_mintrequest.to, _tokenIdToMint), _mintrequest.quantity); + assertEq(tokenContract.uri(_tokenIdToMint), _mintrequest.uri); + assertEq(tokenContract.totalSupply(_tokenIdToMint), _mintrequest.quantity); + } + + function test_mintWithSignature_zeroPrice_contract_MetadataUpdateEvent() + public + whenMinterRole + whenUidNotUsed + whenValidStartTimestamp + whenValidEndTimestamp + whenRecipientAddressNotZero + whenNotZeroQuantity + whenMsgValueZero + whenERC1155Receiver + { + _mintrequest.pricePerToken = 0; + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + uint256 _tokenIdToMint = tokenContract.nextTokenIdToMint(); + + vm.prank(caller); + vm.expectEmit(false, false, false, true); + emit MetadataUpdate(_tokenIdToMint); + tokenContract.mintWithSignature(_mintrequest, _signature); + } + + function test_mintWithSignature_zeroPrice_contract_TokensMintedWithSignatureEvent() + public + whenMinterRole + whenUidNotUsed + whenValidStartTimestamp + whenValidEndTimestamp + whenRecipientAddressNotZero + whenNotZeroQuantity + whenMsgValueZero + whenERC1155Receiver + { + _mintrequest.pricePerToken = 0; + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + uint256 _tokenIdToMint = tokenContract.nextTokenIdToMint(); + + vm.prank(caller); + vm.expectEmit(true, true, true, true); + emit TokensMintedWithSignature(signer, _mintrequest.to, _tokenIdToMint, _mintrequest); + tokenContract.mintWithSignature(_mintrequest, _signature); + } + + // ================== + // ======= Test branch: when mint price is not zero + // ================== + + function test_mintWithSignature_nonZeroPrice_nativeToken_incorrectMsgValue() + public + whenMinterRole + whenUidNotUsed + whenValidStartTimestamp + whenValidEndTimestamp + whenRecipientAddressNotZero + whenNotZeroQuantity + { + _mintrequest.pricePerToken = 10; + _mintrequest.currency = NATIVE_TOKEN; + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + uint256 incorrectTotalPrice = (_mintrequest.pricePerToken * _mintrequest.quantity) + 1; + + vm.expectRevert("must send total price."); + vm.prank(caller); + tokenContract.mintWithSignature{ value: incorrectTotalPrice }(_mintrequest, _signature); + } + + modifier whenCorrectMsgValue() { + _; + } + + function test_mintWithSignature_nonZeroPrice_nativeToken() + public + whenMinterRole + whenUidNotUsed + whenValidStartTimestamp + whenValidEndTimestamp + whenRecipientAddressNotZero + whenNotZeroQuantity + whenCorrectMsgValue + { + _mintrequest.pricePerToken = 10; + _mintrequest.currency = NATIVE_TOKEN; + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + // state before + uint256 _tokenIdToMint = tokenContract.nextTokenIdToMint(); + + uint256 totalPrice = (_mintrequest.pricePerToken * _mintrequest.quantity); + + // mint + vm.prank(caller); + tokenContract.mintWithSignature{ value: totalPrice }(_mintrequest, _signature); + + // check state after + assertEq(tokenContract.nextTokenIdToMint(), _tokenIdToMint + 1); + assertEq(tokenContract.balanceOf(_mintrequest.to, _tokenIdToMint), _mintrequest.quantity); + assertEq(tokenContract.uri(_tokenIdToMint), _mintrequest.uri); + assertEq(tokenContract.totalSupply(_tokenIdToMint), _mintrequest.quantity); + + uint256 _platformFee = (totalPrice * platformFeeBps) / 10_000; + uint256 _saleProceeds = totalPrice - _platformFee; + assertEq(caller.balance, 1000 ether - totalPrice); + assertEq(tokenContract.platformFeeRecipient().balance, _platformFee); + assertEq(tokenContract.primarySaleRecipient().balance, _saleProceeds); + } + + function test_mintWithSignature_nonZeroPrice_nativeToken_MetadataUpdateEvent() + public + whenMinterRole + whenUidNotUsed + whenValidStartTimestamp + whenValidEndTimestamp + whenRecipientAddressNotZero + whenNotZeroQuantity + whenCorrectMsgValue + { + _mintrequest.pricePerToken = 10; + _mintrequest.currency = NATIVE_TOKEN; + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + uint256 _tokenIdToMint = tokenContract.nextTokenIdToMint(); + + vm.prank(caller); + vm.expectEmit(false, false, false, true); + emit MetadataUpdate(_tokenIdToMint); + tokenContract.mintWithSignature{ value: _mintrequest.pricePerToken * _mintrequest.quantity }( + _mintrequest, + _signature + ); + } + + function test_mintWithSignature_nonZeroPrice_nativeToken_TokensMintedWithSignatureEvent() + public + whenMinterRole + whenUidNotUsed + whenValidStartTimestamp + whenValidEndTimestamp + whenRecipientAddressNotZero + whenNotZeroQuantity + whenCorrectMsgValue + { + _mintrequest.pricePerToken = 10; + _mintrequest.currency = NATIVE_TOKEN; + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + uint256 _tokenIdToMint = tokenContract.nextTokenIdToMint(); + + vm.prank(caller); + vm.expectEmit(true, true, true, true); + emit TokensMintedWithSignature(signer, _mintrequest.to, _tokenIdToMint, _mintrequest); + tokenContract.mintWithSignature{ value: _mintrequest.pricePerToken * _mintrequest.quantity }( + _mintrequest, + _signature + ); + } + + function test_mintWithSignature_nonZeroPrice_ERC20_nonZeroMsgValue() + public + whenMinterRole + whenUidNotUsed + whenValidStartTimestamp + whenValidEndTimestamp + whenRecipientAddressNotZero + whenNotZeroQuantity + { + _mintrequest.pricePerToken = 10; + _mintrequest.currency = address(erc20); + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + vm.expectRevert("msg value not zero"); + vm.prank(caller); + tokenContract.mintWithSignature{ value: 1 }(_mintrequest, _signature); + } + + function test_mintWithSignature_nonZeroPrice_ERC20() + public + whenMinterRole + whenUidNotUsed + whenValidStartTimestamp + whenValidEndTimestamp + whenRecipientAddressNotZero + whenNotZeroQuantity + whenMsgValueZero + { + _mintrequest.pricePerToken = 10; + _mintrequest.currency = address(erc20); + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + // state before + uint256 _tokenIdToMint = tokenContract.nextTokenIdToMint(); + + uint256 totalPrice = (_mintrequest.pricePerToken * _mintrequest.quantity); + + // mint + vm.prank(caller); + tokenContract.mintWithSignature{ value: 0 }(_mintrequest, _signature); + + // check state after + assertEq(tokenContract.nextTokenIdToMint(), _tokenIdToMint + 1); + assertEq(tokenContract.balanceOf(_mintrequest.to, _tokenIdToMint), _mintrequest.quantity); + assertEq(tokenContract.uri(_tokenIdToMint), _mintrequest.uri); + assertEq(tokenContract.totalSupply(_tokenIdToMint), _mintrequest.quantity); + + uint256 _platformFee = (totalPrice * platformFeeBps) / 10_000; + uint256 _saleProceeds = totalPrice - _platformFee; + assertEq(erc20.balanceOf(caller), 1000 ether - totalPrice); + assertEq(erc20.balanceOf(tokenContract.platformFeeRecipient()), _platformFee); + assertEq(erc20.balanceOf(tokenContract.primarySaleRecipient()), _saleProceeds); + } + + function test_mintWithSignature_nonZeroPrice_ERC20_MetadataUpdateEvent() + public + whenMinterRole + whenUidNotUsed + whenValidStartTimestamp + whenValidEndTimestamp + whenRecipientAddressNotZero + whenNotZeroQuantity + whenMsgValueZero + { + _mintrequest.pricePerToken = 10; + _mintrequest.currency = address(erc20); + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + uint256 _tokenIdToMint = tokenContract.nextTokenIdToMint(); + + vm.prank(caller); + vm.expectEmit(false, false, false, true); + emit MetadataUpdate(_tokenIdToMint); + tokenContract.mintWithSignature{ value: 0 }(_mintrequest, _signature); + } + + function test_mintWithSignature_nonZeroPrice_ERC20_TokensMintedWithSignatureEvent() + public + whenMinterRole + whenUidNotUsed + whenValidStartTimestamp + whenValidEndTimestamp + whenRecipientAddressNotZero + whenNotZeroQuantity + whenMsgValueZero + { + _mintrequest.pricePerToken = 10; + _mintrequest.currency = address(erc20); + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + uint256 _tokenIdToMint = tokenContract.nextTokenIdToMint(); + + vm.prank(caller); + vm.expectEmit(true, true, true, true); + emit TokensMintedWithSignature(signer, _mintrequest.to, _tokenIdToMint, _mintrequest); + tokenContract.mintWithSignature{ value: 0 }(_mintrequest, _signature); + } + + // ================== + // ======= Test branch: other cases + // ================== + + function test_mintWithSignature_nonZeroRoyaltyRecipient() + public + whenMinterRole + whenUidNotUsed + whenValidStartTimestamp + whenValidEndTimestamp + whenRecipientAddressNotZero + whenNotZeroQuantity + whenMsgValueZero + { + _mintrequest.pricePerToken = 0; + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + vm.prank(caller); + tokenContract.mintWithSignature(_mintrequest, _signature); + + (address _royaltyRecipient, uint16 _royaltyBps) = tokenContract.getRoyaltyInfoForToken(0); + assertEq(_royaltyRecipient, royaltyRecipient); + assertEq(_royaltyBps, royaltyBps); + } + + function test_mintWithSignature_royaltyRecipientZeroAddress() + public + whenMinterRole + whenUidNotUsed + whenValidStartTimestamp + whenValidEndTimestamp + whenRecipientAddressNotZero + whenNotZeroQuantity + whenMsgValueZero + { + _mintrequest.pricePerToken = 0; + _mintrequest.royaltyRecipient = address(0); + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + vm.prank(caller); + tokenContract.mintWithSignature(_mintrequest, _signature); + + (address _royaltyRecipient, uint16 _royaltyBps) = tokenContract.getRoyaltyInfoForToken(0); + (address _defaultRoyaltyRecipient, uint16 _defaultRoyaltyBps) = tokenContract.getDefaultRoyaltyInfo(); + assertEq(_royaltyRecipient, _defaultRoyaltyRecipient); + assertEq(_royaltyBps, _defaultRoyaltyBps); + } + + function test_mintWithSignature_reentrantRecipientContract() + public + whenMinterRole + whenUidNotUsed + whenValidStartTimestamp + whenValidEndTimestamp + whenRecipientAddressNotZero + whenNotZeroQuantity + whenMsgValueZero + { + _mintrequest.pricePerToken = 0; + _mintrequest.to = address(new ReentrantContract()); + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + vm.prank(caller); + vm.expectRevert("ReentrancyGuard: reentrant call"); + tokenContract.mintWithSignature(_mintrequest, _signature); + } + + function test_mintWithSignature_nonZeroPrice_flatFee_exceedsTotalPrice() + public + whenMinterRole + whenUidNotUsed + whenValidStartTimestamp + whenValidEndTimestamp + whenRecipientAddressNotZero + whenNotZeroQuantity + whenMsgValueZero + { + vm.startPrank(deployer); + tokenContract.setPlatformFeeType(IPlatformFee.PlatformFeeType.Flat); + tokenContract.setFlatPlatformFeeInfo(platformFeeRecipient, 100 ether); + vm.stopPrank(); + + _mintrequest.pricePerToken = 10; + _mintrequest.currency = address(erc20); + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + // state before + uint256 _tokenIdToMint = tokenContract.nextTokenIdToMint(); + + uint256 totalPrice = (_mintrequest.pricePerToken * _mintrequest.quantity); + + // mint + vm.prank(caller); + vm.expectRevert("price less than platform fee"); + tokenContract.mintWithSignature{ value: 0 }(_mintrequest, _signature); + } + + function test_mintWithSignature_nonZeroPrice_flatFee() + public + whenMinterRole + whenUidNotUsed + whenValidStartTimestamp + whenValidEndTimestamp + whenRecipientAddressNotZero + whenNotZeroQuantity + whenMsgValueZero + { + vm.prank(deployer); + tokenContract.setPlatformFeeType(IPlatformFee.PlatformFeeType.Flat); + + _mintrequest.pricePerToken = 10; + _mintrequest.currency = address(erc20); + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + // state before + uint256 _tokenIdToMint = tokenContract.nextTokenIdToMint(); + + uint256 totalPrice = (_mintrequest.pricePerToken * _mintrequest.quantity); + + // mint + vm.prank(caller); + tokenContract.mintWithSignature{ value: 0 }(_mintrequest, _signature); + + // check state after + assertEq(tokenContract.nextTokenIdToMint(), _tokenIdToMint + 1); + assertEq(tokenContract.balanceOf(_mintrequest.to, _tokenIdToMint), _mintrequest.quantity); + assertEq(tokenContract.uri(_tokenIdToMint), _mintrequest.uri); + assertEq(tokenContract.totalSupply(_tokenIdToMint), _mintrequest.quantity); + + (, uint256 _platformFee) = tokenContract.getFlatPlatformFeeInfo(); + uint256 _saleProceeds = totalPrice - _platformFee; + assertEq(erc20.balanceOf(caller), 1000 ether - totalPrice); + assertEq(erc20.balanceOf(tokenContract.platformFeeRecipient()), _platformFee); + assertEq(erc20.balanceOf(tokenContract.primarySaleRecipient()), _saleProceeds); + } + + modifier whenNotMaxTokenId() { + // pre-mint the first token (i.e. id 0), so that nextTokenIdToMint is 1, for this code path + vm.prank(deployer); + tokenContract.mintTo(deployer, type(uint256).max, "uri1", 10); + _; + } + + function test_mintWithSignature_nonZeroPrice_notMaxTokenId_invalidId() + public + whenMinterRole + whenUidNotUsed + whenValidStartTimestamp + whenValidEndTimestamp + whenRecipientAddressNotZero + whenNotZeroQuantity + whenMsgValueZero + whenNotMaxTokenId + { + vm.prank(deployer); + tokenContract.setPlatformFeeType(IPlatformFee.PlatformFeeType.Flat); + + _mintrequest.pricePerToken = 10; + _mintrequest.currency = address(erc20); + _mintrequest.tokenId = 1; + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + vm.prank(caller); + vm.expectRevert("invalid id"); + tokenContract.mintWithSignature{ value: 0 }(_mintrequest, _signature); + } + + modifier whenValidId() { + _; + } + + function test_mintWithSignature_nonZeroPrice_notMaxTokenId() + public + whenMinterRole + whenUidNotUsed + whenValidStartTimestamp + whenValidEndTimestamp + whenRecipientAddressNotZero + whenNotZeroQuantity + whenMsgValueZero + whenNotMaxTokenId + whenValidId + { + vm.prank(deployer); + tokenContract.setPlatformFeeType(IPlatformFee.PlatformFeeType.Flat); + + _mintrequest.pricePerToken = 10; + _mintrequest.currency = address(erc20); + _mintrequest.tokenId = 0; + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + // state before + uint256 _tokenIdToMint = tokenContract.nextTokenIdToMint() - 1; + + uint256 totalPrice = (_mintrequest.pricePerToken * _mintrequest.quantity); + + // mint + vm.prank(caller); + tokenContract.mintWithSignature{ value: 0 }(_mintrequest, _signature); + + // check state after + assertEq(tokenContract.nextTokenIdToMint(), _tokenIdToMint + 1); + assertEq(tokenContract.balanceOf(_mintrequest.to, _tokenIdToMint), _mintrequest.quantity); + assertEq(tokenContract.uri(_tokenIdToMint), "uri1"); + assertEq(tokenContract.totalSupply(_tokenIdToMint), _mintrequest.quantity + 10); + } +} diff --git a/src/test/tokenerc1155-BTT/mint-with-signature/mintWithSignature.tree b/src/test/tokenerc1155-BTT/mint-with-signature/mintWithSignature.tree new file mode 100644 index 000000000..115264aec --- /dev/null +++ b/src/test/tokenerc1155-BTT/mint-with-signature/mintWithSignature.tree @@ -0,0 +1,102 @@ +mintWithSignature(MintRequest calldata _req, bytes calldata _signature) +// assuming _req.tokenId input is type(uint256).max and platform fee type is Bps +├── when signer doesn't have MINTER_ROLE +│ └── it should revert ✅ +└── when signer has MINTER_ROLE + └── when `_req.uid` has already been used + │ └── it should revert ✅ + └── when `_req.uid` has not been used + └── when `_req.validityStartTimestamp` is greater than block timestamp + │ └── it should revert ✅ + └── when `_req.validityStartTimestamp` is less than or equal to block timestamp + └── when `_req.validityEndTimestamp` is less than block timestamp + │ └── it should revert ✅ + └── when `_req.validityEndTimestamp` is greater than or equal to block timestamp + └── when `_req.to` is address(0) + │ └── it should revert ✅ + └── when `_req.to` is not address(0) + ├── when `_req.quantity` is zero + │ └── it should revert ✅ + └── when `_req.quantity` is not zero + │ + │ // case: price is zero + └── when `_req.pricePerToken` is zero + │ └── when msg.value is not zero + │ │ └── it should revert ✅ + │ └── when msg.value is zero + │ ├── when `_req.to` address is an EOA + │ │ └── it should mint tokenId equal to current value of `nextTokenIdToMint` ✅ + │ │ └── it should increment `nextTokenIdToMint` by 1 ✅ + │ │ └── it should mint the `_req.quantity` number of tokens to the `_req.to` address ✅ + │ │ └── it should increment totalSupply of tokenId by `_req.quantity` ✅ + │ │ └── it should set `_req.uid` as minted ✅ + │ │ └── it should set uri for minted tokenId equal to `_req.uri` ✅ + │ │ └── it should emit MetadataUpdate event ✅ + │ │ └── it should emit TokensMintedWithSignature event ✅ + │ └── when `_to` address is a contract + │ ├── when `_to` address is non ERC1155Receiver implementer + │ │ └── it should revert ✅ + │ └── when `_to` address implements ERC1155Receiver + │ └── it should mint tokenId equal to current value of `nextTokenIdToMint` ✅ + │ └── it should increment `nextTokenIdToMint` by 1 ✅ + │ └── it should mint the `_req.quantity` number of tokens to the `_req.to` address ✅ + │ └── it should increment totalSupply of tokenId by `_req.quantity` ✅ + │ └── it should set `_req.uid` as minted ✅ + │ └── it should set uri for minted tokenId equal to `_uri` ✅ + │ └── it should emit MetadataUpdate event ✅ + │ └── it should emit TokensMintedWithSignature event ✅ + │ + │ // case: price is not zero + └── when `_req.pricePerToken` is not zero + └── when currency is native token + │ └── when msg.value is not equal to total price + │ │ └── it should revert ✅ + │ └── when msg.value is equal to total price + │ └── it should mint tokenId equal to current value of `nextTokenIdToMint` ✅ + │ └── it should increment `nextTokenIdToMint` by 1 ✅ + │ └── it should mint the `_req.quantity` number of tokens to the `_req.to` address ✅ + │ └── it should increment totalSupply of tokenId by `_req.quantity` ✅ + │ └── it should set `_req.uid` as minted ✅ + │ └── it should set uri for minted tokenId equal to `_uri` ✅ + │ └── (transfer to sale recipient) ✅ + │ └── (transfer to fee recipient) ✅ + │ └── it should emit MetadataUpdate event ✅ + │ └── it should emit TokensMintedWithSignature event ✅ + └── when currency is some ERC20 token + └── when msg.value is not zero + │ └── it should revert ✅ + └── when msg.value is zero + └── it should mint tokenId equal to current value of `nextTokenIdToMint` ✅ + └── it should increment `nextTokenIdToMint` by 1 ✅ + └── it should mint the `_req.quantity` number of tokens to the `_req.to` address ✅ + └── it should increment totalSupply of tokenId by `_req.quantity` ✅ + └── it should set `_req.uid` as minted ✅ + └── it should set uri for minted tokenId equal to `_uri` ✅ + └── (transfer to sale recipient) ✅ + └── (transfer to fee recipient) ✅ + └── it should emit MetadataUpdate event ✅ + └── it should emit TokensMintedWithSignature event ✅ + +// other cases + +├── when `_req.royaltyRecipient` is not address(0) + │ └── it should set royaltyInfoForToken ✅ + └── when `_req.royaltyRecipient` is address(0) + └── it should use default royalty info ✅ + +├── when reentrant call + └── it should revert ✅ + +├── when platformFeeType is flat + └── when total price is less than platform fee + │ └── it should revert ✅ + └── when total price is greater than or equal to platform fee + └── (transfer to sale recipient) ✅ + └── (transfer to fee recipient) ✅ + +├── when tokenId input is greater than or equal to nextTokenIdToMint + └── it should revert ✅ +├── when tokenId input is less than nextTokenIdToMint + └── it should mint ✅ + + diff --git a/src/test/tokenerc1155-BTT/other-functions/other.t.sol b/src/test/tokenerc1155-BTT/other-functions/other.t.sol new file mode 100644 index 000000000..c95c15dab --- /dev/null +++ b/src/test/tokenerc1155-BTT/other-functions/other.t.sol @@ -0,0 +1,172 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "../../utils/BaseTest.sol"; +import { IStaking1155 } from "contracts/extension/interface/IStaking1155.sol"; +import { IERC2981 } from "contracts/eip/interface/IERC2981.sol"; + +import "@openzeppelin/contracts-upgradeable/access/IAccessControlEnumerableUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/access/IAccessControlUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/token/ERC1155/IERC1155Upgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/utils/introspection/IERC165Upgradeable.sol"; +import "@openzeppelin/contracts/utils/introspection/IERC165.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +contract MyTokenERC1155 is TokenERC1155 { + function canSetMetadata() public view returns (bool) { + return _canSetMetadata(); + } + + function canFreezeMetadata() public view returns (bool) { + return _canFreezeMetadata(); + } + + function beforeTokenTransfer( + address operator, + address from, + address to, + uint256[] memory ids, + uint256[] memory amounts, + bytes memory data + ) external { + _beforeTokenTransfer(operator, from, to, ids, amounts, data); + } + + function setTotalSupply(uint256 _tokenId, uint256 _totalSupply) external { + totalSupply[_tokenId] = _totalSupply; + } +} + +contract TokenERC1155Test_OtherFunctions is BaseTest { + address public implementation; + address public proxy; + + MyTokenERC1155 public tokenContract; + address internal caller; + + function setUp() public override { + super.setUp(); + + // Deploy implementation. + implementation = address(new MyTokenERC1155()); + caller = getActor(3); + + // Deploy proxy pointing to implementaion. + vm.prank(deployer); + proxy = address( + new TWProxy( + implementation, + abi.encodeCall( + TokenERC1155.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ) + ); + + tokenContract = MyTokenERC1155(proxy); + } + + function test_contractType() public { + assertEq(tokenContract.contractType(), bytes32("TokenERC1155")); + } + + function test_contractVersion() public { + assertEq(tokenContract.contractVersion(), uint8(1)); + } + + function test_beforeTokenTransfer_restricted_notTransferRole() public { + uint256[] memory ids; + uint256[] memory amounts; + + vm.prank(deployer); + tokenContract.revokeRole(keccak256("TRANSFER_ROLE"), address(0)); + vm.expectRevert("restricted to TRANSFER_ROLE holders."); + tokenContract.beforeTokenTransfer(caller, caller, address(0x123), ids, amounts, ""); + } + + modifier whenTransferRole() { + vm.prank(deployer); + tokenContract.grantRole(keccak256("TRANSFER_ROLE"), caller); + _; + } + + function test_beforeTokenTransfer_restricted() public whenTransferRole { + uint256[] memory ids; + uint256[] memory amounts; + tokenContract.beforeTokenTransfer(caller, caller, address(0x123), ids, amounts, ""); + } + + function test_beforeTokenTransfer_restricted_fromZero() public whenTransferRole { + uint256[] memory ids = new uint256[](1); + uint256[] memory amounts = new uint256[](1); + uint256 _initialSupply = 100; + + ids[0] = 1; + amounts[0] = 10; + tokenContract.setTotalSupply(ids[0], _initialSupply); // mock set supply + + tokenContract.beforeTokenTransfer(caller, address(0), address(0x123), ids, amounts, ""); + + assertEq(tokenContract.totalSupply(ids[0]), amounts[0] + _initialSupply); + } + + function test_beforeTokenTransfer_restricted_toZero() public whenTransferRole { + uint256[] memory ids = new uint256[](1); + uint256[] memory amounts = new uint256[](1); + uint256 _initialSupply = 100; + + ids[0] = 1; + amounts[0] = 10; + tokenContract.setTotalSupply(ids[0], _initialSupply); // mock set supply + + tokenContract.beforeTokenTransfer(caller, caller, address(0), ids, amounts, ""); + + assertEq(tokenContract.totalSupply(ids[0]), _initialSupply - amounts[0]); + } + + function test_canSetMetadata_notMetadataRole() public { + assertFalse(tokenContract.canSetMetadata()); + } + + modifier whenMetadataRoleRole() { + _; + } + + function test_canSetMetadata() public whenMetadataRoleRole { + vm.prank(deployer); + assertTrue(tokenContract.canSetMetadata()); + } + + function test_canFreezeMetadata_notMetadataRole() public { + assertFalse(tokenContract.canFreezeMetadata()); + } + + function test_canFreezeMetadata() public whenMetadataRoleRole { + vm.prank(deployer); + assertTrue(tokenContract.canFreezeMetadata()); + } + + function test_supportsInterface() public { + assertTrue(tokenContract.supportsInterface(type(IERC2981).interfaceId)); + assertTrue(tokenContract.supportsInterface(type(IERC165).interfaceId)); + assertTrue(tokenContract.supportsInterface(type(IERC165Upgradeable).interfaceId)); + assertTrue(tokenContract.supportsInterface(type(IAccessControlEnumerableUpgradeable).interfaceId)); + assertTrue(tokenContract.supportsInterface(type(IAccessControlUpgradeable).interfaceId)); + assertTrue(tokenContract.supportsInterface(type(IERC1155Upgradeable).interfaceId)); + + // false for other not supported interfaces + assertFalse(tokenContract.supportsInterface(type(IStaking1155).interfaceId)); + } +} diff --git a/src/test/tokenerc1155-BTT/other-functions/other.tree b/src/test/tokenerc1155-BTT/other-functions/other.tree new file mode 100644 index 000000000..6af7d78cf --- /dev/null +++ b/src/test/tokenerc1155-BTT/other-functions/other.tree @@ -0,0 +1,37 @@ +contractType() +├── it should return bytes32("TokenERC1155") ✅ + +contractVersion() +├── it should return uint8(1) ✅ + +_beforeTokenTransfers( + address operator, + address from, + address to, + uint256[] memory ids, + uint256[] memory amounts, + bytes memory data +) +├── when transfers are restricted (i.e. address(0) doesn't have transfer role, or from-to addresses are not address(0) + └── when from and to don't have transfer role + │ └── it should revert ✅ + └── when from is address(0) + │ └── it should increase totalSupply of `ids` by `amounts` ✅ + └── when to is address(0) + └── it should decrease totalSupply of `ids` by `amounts` ✅ + +_canSetMetadata() +├── when the caller doesn't have METADATA_ROLE +│ └── it should revert ✅ +└── when the caller has METADATA_ROLE + └── it should return true ✅ + +_canFreezeMetadata() +├── when the caller doesn't have METADATA_ROLE +│ └── it should revert ✅ +└── when the caller has METADATA_ROLE + └── it should return true ✅ + +supportsInterface(bytes4 interfaceId) +├── it should return true for supported interface ✅ +├── it should return false for not supported interface ✅ diff --git a/src/test/tokenerc1155-BTT/owner/owner.t.sol b/src/test/tokenerc1155-BTT/owner/owner.t.sol new file mode 100644 index 000000000..0615f32c4 --- /dev/null +++ b/src/test/tokenerc1155-BTT/owner/owner.t.sol @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "../../utils/BaseTest.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +contract MyTokenERC1155 is TokenERC1155 {} + +contract TokenERC1155Test_Owner is BaseTest { + address public implementation; + address public proxy; + + MyTokenERC1155 internal tokenContract; + + function setUp() public override { + super.setUp(); + + // Deploy implementation. + implementation = address(new MyTokenERC1155()); + + // Deploy proxy pointing to implementaion. + vm.prank(deployer); + proxy = address( + new TWProxy( + implementation, + abi.encodeCall( + TokenERC1155.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ) + ); + + tokenContract = MyTokenERC1155(proxy); + } + + function test_owner() public { + assertEq(tokenContract.owner(), deployer); + } + + function test_owner_notDefaultAdmin() public { + vm.prank(deployer); + tokenContract.renounceRole(bytes32(0x00), deployer); + + assertEq(tokenContract.owner(), address(0)); + } +} diff --git a/src/test/tokenerc1155-BTT/owner/owner.tree b/src/test/tokenerc1155-BTT/owner/owner.tree new file mode 100644 index 000000000..576cfcb91 --- /dev/null +++ b/src/test/tokenerc1155-BTT/owner/owner.tree @@ -0,0 +1,6 @@ +owner() +├── when private variable `_owner` DEFAULT_ADMIN_ROLE +│ └── it should return `_owner` ✅ +└── when private variable `_owner` doesn't have DEFAULT_ADMIN_ROLE + └── it should return address(0) ✅ + diff --git a/src/test/tokenerc1155-BTT/set-contract-uri/setContractURI.t.sol b/src/test/tokenerc1155-BTT/set-contract-uri/setContractURI.t.sol new file mode 100644 index 000000000..4f3739103 --- /dev/null +++ b/src/test/tokenerc1155-BTT/set-contract-uri/setContractURI.t.sol @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "../../utils/BaseTest.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +contract MyTokenERC1155 is TokenERC1155 {} + +contract TokenERC1155Test_SetContractURI is BaseTest { + address public implementation; + address public proxy; + address internal caller; + string internal _contractURI; + + MyTokenERC1155 internal tokenContract; + + function setUp() public override { + super.setUp(); + + // Deploy implementation. + implementation = address(new MyTokenERC1155()); + + caller = getActor(1); + + // Deploy proxy pointing to implementaion. + vm.prank(deployer); + proxy = address( + new TWProxy( + implementation, + abi.encodeCall( + TokenERC1155.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ) + ); + + tokenContract = MyTokenERC1155(proxy); + _contractURI = "ipfs://contracturi"; + } + + function test_setContractURI_callerNotAuthorized() public { + vm.prank(address(caller)); + vm.expectRevert( + abi.encodePacked( + "AccessControl: account ", + Strings.toHexString(uint160(caller), 20), + " is missing role ", + Strings.toHexString(uint256(0), 32) + ) + ); + tokenContract.setContractURI(_contractURI); + } + + modifier whenCallerAuthorized() { + vm.prank(deployer); + tokenContract.grantRole(bytes32(0x00), caller); + _; + } + + function test_setContractURI_empty() public whenCallerAuthorized { + vm.prank(address(caller)); + tokenContract.setContractURI(""); + + // get contract uri + assertEq(tokenContract.contractURI(), ""); + } + + function test_setContractURI_notEmpty() public whenCallerAuthorized { + vm.prank(address(caller)); + tokenContract.setContractURI(_contractURI); + + // get contract uri + assertEq(tokenContract.contractURI(), _contractURI); + } +} diff --git a/src/test/tokenerc1155-BTT/set-contract-uri/setContractURI.tree b/src/test/tokenerc1155-BTT/set-contract-uri/setContractURI.tree new file mode 100644 index 000000000..8fc480b19 --- /dev/null +++ b/src/test/tokenerc1155-BTT/set-contract-uri/setContractURI.tree @@ -0,0 +1,8 @@ +setContractURI(string calldata _uri) +├── when caller not authorized + │ └── it should revert ✅ + └── when caller is authorized + └── when `uri` is empty + │ └── it should update contract URI to empty string ✅ + └── when `uri` is not empty + └── it should update contract URI to `_uri` ✅ \ No newline at end of file diff --git a/src/test/tokenerc1155-BTT/set-default-royalty-info/setDefaultRoyaltyInfo.t.sol b/src/test/tokenerc1155-BTT/set-default-royalty-info/setDefaultRoyaltyInfo.t.sol new file mode 100644 index 000000000..552d9d75b --- /dev/null +++ b/src/test/tokenerc1155-BTT/set-default-royalty-info/setDefaultRoyaltyInfo.t.sol @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "../../utils/BaseTest.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +contract MyTokenERC1155 is TokenERC1155 {} + +contract TokenERC1155Test_SetDefaultRoyaltyInfo is BaseTest { + address public implementation; + address public proxy; + address internal caller; + address internal defaultRoyaltyRecipient; + uint256 internal defaultRoyaltyBps; + + MyTokenERC1155 internal tokenContract; + + event DefaultRoyalty(address indexed newRoyaltyRecipient, uint256 newRoyaltyBps); + + function setUp() public override { + super.setUp(); + + // Deploy implementation. + implementation = address(new MyTokenERC1155()); + + caller = getActor(1); + defaultRoyaltyRecipient = getActor(2); + + // Deploy proxy pointing to implementaion. + vm.prank(deployer); + proxy = address( + new TWProxy( + implementation, + abi.encodeCall( + TokenERC1155.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ) + ); + + tokenContract = MyTokenERC1155(proxy); + } + + function test_setDefaultRoyaltyInfo_callerNotAuthorized() public { + vm.prank(address(caller)); + vm.expectRevert( + abi.encodePacked( + "AccessControl: account ", + Strings.toHexString(uint160(caller), 20), + " is missing role ", + Strings.toHexString(uint256(0), 32) + ) + ); + tokenContract.setDefaultRoyaltyInfo(defaultRoyaltyRecipient, defaultRoyaltyBps); + } + + modifier whenCallerAuthorized() { + vm.prank(deployer); + tokenContract.grantRole(bytes32(0x00), caller); + _; + } + + function test_setDefaultRoyaltyInfo_exceedMaxBps() public whenCallerAuthorized { + defaultRoyaltyBps = 10_001; + vm.prank(address(caller)); + vm.expectRevert("exceed royalty bps"); + tokenContract.setDefaultRoyaltyInfo(defaultRoyaltyRecipient, defaultRoyaltyBps); + } + + modifier whenNotExceedMaxBps() { + defaultRoyaltyBps = 500; + _; + } + + function test_setDefaultRoyaltyInfo() public whenCallerAuthorized whenNotExceedMaxBps { + vm.prank(address(caller)); + tokenContract.setDefaultRoyaltyInfo(defaultRoyaltyRecipient, defaultRoyaltyBps); + + // get default royalty info + (address _recipient, uint16 _royaltyBps) = tokenContract.getDefaultRoyaltyInfo(); + assertEq(_recipient, defaultRoyaltyRecipient); + assertEq(_royaltyBps, uint16(defaultRoyaltyBps)); + + // get royalty info for token + uint256 tokenId = 0; + (_recipient, _royaltyBps) = tokenContract.getRoyaltyInfoForToken(tokenId); + assertEq(_recipient, defaultRoyaltyRecipient); + assertEq(_royaltyBps, uint16(defaultRoyaltyBps)); + + // royaltyInfo - ERC2981 + uint256 salePrice = 1000; + (address _royaltyRecipient, uint256 _royaltyAmount) = tokenContract.royaltyInfo(tokenId, salePrice); + assertEq(_royaltyRecipient, defaultRoyaltyRecipient); + assertEq(_royaltyAmount, (salePrice * defaultRoyaltyBps) / 10_000); + } + + function test_setDefaultRoyaltyInfo_event() public whenCallerAuthorized whenNotExceedMaxBps { + vm.prank(address(caller)); + vm.expectEmit(true, false, false, true); + emit DefaultRoyalty(defaultRoyaltyRecipient, defaultRoyaltyBps); + tokenContract.setDefaultRoyaltyInfo(defaultRoyaltyRecipient, defaultRoyaltyBps); + } +} diff --git a/src/test/tokenerc1155-BTT/set-default-royalty-info/setDefaultRoyaltyInfo.tree b/src/test/tokenerc1155-BTT/set-default-royalty-info/setDefaultRoyaltyInfo.tree new file mode 100644 index 000000000..78a4312de --- /dev/null +++ b/src/test/tokenerc1155-BTT/set-default-royalty-info/setDefaultRoyaltyInfo.tree @@ -0,0 +1,11 @@ +setDefaultRoyaltyInfo(address _royaltyRecipient, uint256 _royaltyBps) +├── when caller not authorized + │ └── it should revert ✅ + └── when caller is authorized + ├── when royalty bps input is greater than MAX_BPS + │ └── it should revert ✅ + └── when royalty bps input is less than or equal to MAX_BPS + └── it should update default royalty recipient ✅ + └── it should update default royalty bps ✅ + └── it should calculate royalty amount for a token-id based on default royalty info ✅ + └── it should emit DefaultRoyalty event ✅ \ No newline at end of file diff --git a/src/test/tokenerc1155-BTT/set-flat-platform-fee-info/setFlatPlatformFeeInfo.t.sol b/src/test/tokenerc1155-BTT/set-flat-platform-fee-info/setFlatPlatformFeeInfo.t.sol new file mode 100644 index 000000000..380d65921 --- /dev/null +++ b/src/test/tokenerc1155-BTT/set-flat-platform-fee-info/setFlatPlatformFeeInfo.t.sol @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "../../utils/BaseTest.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +contract MyTokenERC1155 is TokenERC1155 {} + +contract TokenERC1155Test_SetFlatPlatformFeeInfo is BaseTest { + address public implementation; + address public proxy; + address internal caller; + address internal _platformFeeRecipient; + uint256 internal _flatFee; + + MyTokenERC1155 internal tokenContract; + + event FlatPlatformFeeUpdated(address platformFeeRecipient, uint256 flatFee); + + function setUp() public override { + super.setUp(); + + // Deploy implementation. + implementation = address(new MyTokenERC1155()); + + caller = getActor(1); + _platformFeeRecipient = getActor(2); + + // Deploy proxy pointing to implementaion. + vm.prank(deployer); + proxy = address( + new TWProxy( + implementation, + abi.encodeCall( + TokenERC1155.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ) + ); + + tokenContract = MyTokenERC1155(proxy); + _flatFee = 25; + } + + function test_setFlatPlatformFeeInfo_callerNotAuthorized() public { + vm.prank(address(caller)); + vm.expectRevert( + abi.encodePacked( + "AccessControl: account ", + Strings.toHexString(uint160(caller), 20), + " is missing role ", + Strings.toHexString(uint256(0), 32) + ) + ); + tokenContract.setFlatPlatformFeeInfo(_platformFeeRecipient, _flatFee); + } + + modifier whenCallerAuthorized() { + vm.prank(deployer); + tokenContract.grantRole(bytes32(0x00), caller); + _; + } + + function test_setFlatPlatformFeeInfo() public whenCallerAuthorized { + vm.prank(address(caller)); + tokenContract.setFlatPlatformFeeInfo(_platformFeeRecipient, _flatFee); + + // get platform fee info + (address _recipient, uint256 _fee) = tokenContract.getFlatPlatformFeeInfo(); + assertEq(_recipient, _platformFeeRecipient); + assertEq(_fee, _flatFee); + assertEq(tokenContract.platformFeeRecipient(), _platformFeeRecipient); + } + + function test_setFlatPlatformFeeInfo_event() public whenCallerAuthorized { + vm.prank(address(caller)); + vm.expectEmit(false, false, false, true); + emit FlatPlatformFeeUpdated(_platformFeeRecipient, _flatFee); + tokenContract.setFlatPlatformFeeInfo(_platformFeeRecipient, _flatFee); + } +} diff --git a/src/test/tokenerc1155-BTT/set-flat-platform-fee-info/setFlatPlatformFeeInfo.tree b/src/test/tokenerc1155-BTT/set-flat-platform-fee-info/setFlatPlatformFeeInfo.tree new file mode 100644 index 000000000..95bfe1f2d --- /dev/null +++ b/src/test/tokenerc1155-BTT/set-flat-platform-fee-info/setFlatPlatformFeeInfo.tree @@ -0,0 +1,8 @@ +setFlatPlatformFeeInfo(address _platformFeeRecipient, uint256 _flatFee) +├── when caller not authorized + │ └── it should revert ✅ + └── when caller is authorized + └── when `_platformFeeBps` is less than or equal to MAX_BPS + └── it should update platform fee recipient ✅ + └── it should update flatPlatformFee ✅ + └── it should emit FlatPlatformFeeUpdated event ✅ \ No newline at end of file diff --git a/src/test/tokenerc1155-BTT/set-owner/setOwner.t.sol b/src/test/tokenerc1155-BTT/set-owner/setOwner.t.sol new file mode 100644 index 000000000..00dd0f2ef --- /dev/null +++ b/src/test/tokenerc1155-BTT/set-owner/setOwner.t.sol @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "../../utils/BaseTest.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +contract MyTokenERC1155 is TokenERC1155 {} + +contract TokenERC1155Test_SetOwner is BaseTest { + address public implementation; + address public proxy; + address internal caller; + address internal _newOwner; + + MyTokenERC1155 internal tokenContract; + + event OwnerUpdated(address indexed prevOwner, address indexed newOwner); + + function setUp() public override { + super.setUp(); + + // Deploy implementation. + implementation = address(new MyTokenERC1155()); + + caller = getActor(1); + _newOwner = getActor(2); + + // Deploy proxy pointing to implementaion. + vm.prank(deployer); + proxy = address( + new TWProxy( + implementation, + abi.encodeCall( + TokenERC1155.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ) + ); + + tokenContract = MyTokenERC1155(proxy); + } + + function test_setOwner_callerNotAuthorized() public { + vm.prank(address(caller)); + vm.expectRevert( + abi.encodePacked( + "AccessControl: account ", + Strings.toHexString(uint160(caller), 20), + " is missing role ", + Strings.toHexString(uint256(0), 32) + ) + ); + tokenContract.setOwner(_newOwner); + } + + modifier whenCallerAuthorized() { + vm.prank(deployer); + tokenContract.grantRole(bytes32(0x00), caller); + _; + } + + function test_setOwner_newOwnerNotAdmin() public whenCallerAuthorized { + vm.prank(address(caller)); + vm.expectRevert("new owner not module admin."); + tokenContract.setOwner(_newOwner); + } + + modifier whenNewOwnerIsAnAdmin() { + vm.prank(deployer); + tokenContract.grantRole(bytes32(0x00), _newOwner); + _; + } + + function test_setOwner() public whenCallerAuthorized whenNewOwnerIsAnAdmin { + vm.prank(address(caller)); + tokenContract.setOwner(_newOwner); + + assertEq(tokenContract.owner(), _newOwner); + } + + function test_setOwner_event() public whenCallerAuthorized whenNewOwnerIsAnAdmin { + vm.prank(address(caller)); + vm.expectEmit(true, true, false, false); + emit OwnerUpdated(deployer, _newOwner); + tokenContract.setOwner(_newOwner); + } +} diff --git a/src/test/tokenerc1155-BTT/set-owner/setOwner.tree b/src/test/tokenerc1155-BTT/set-owner/setOwner.tree new file mode 100644 index 000000000..964e97cac --- /dev/null +++ b/src/test/tokenerc1155-BTT/set-owner/setOwner.tree @@ -0,0 +1,9 @@ +setOwner(address _newOwner) +├── when caller not authorized + │ └── it should revert ✅ + └── when caller is authorized + └── when incoming `_owner` doesn't have DEFAULT_ADMIN_ROLE + │ └── it should revert ✅ + └── when incoming `_owner` has DEFAULT_ADMIN_ROLE + └── it should update owner ✅ + └── it should emit OwnerUpdated event ✅ \ No newline at end of file diff --git a/src/test/tokenerc1155-BTT/set-platform-fee-info/setPlatformFeeInfo.t.sol b/src/test/tokenerc1155-BTT/set-platform-fee-info/setPlatformFeeInfo.t.sol new file mode 100644 index 000000000..e52402a03 --- /dev/null +++ b/src/test/tokenerc1155-BTT/set-platform-fee-info/setPlatformFeeInfo.t.sol @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "../../utils/BaseTest.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +contract MyTokenERC1155 is TokenERC1155 {} + +contract TokenERC1155Test_SetPlatformFeeInfo is BaseTest { + address public implementation; + address public proxy; + address internal caller; + address internal _platformFeeRecipient; + uint256 internal _platformFeeBps; + + MyTokenERC1155 internal tokenContract; + + event PlatformFeeInfoUpdated(address indexed platformFeeRecipient, uint256 platformFeeBps); + + function setUp() public override { + super.setUp(); + + // Deploy implementation. + implementation = address(new MyTokenERC1155()); + + caller = getActor(1); + _platformFeeRecipient = getActor(2); + + // Deploy proxy pointing to implementaion. + vm.prank(deployer); + proxy = address( + new TWProxy( + implementation, + abi.encodeCall( + TokenERC1155.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ) + ); + + tokenContract = MyTokenERC1155(proxy); + } + + function test_setPlatformFeeInfo_callerNotAuthorized() public { + vm.prank(address(caller)); + vm.expectRevert( + abi.encodePacked( + "AccessControl: account ", + Strings.toHexString(uint160(caller), 20), + " is missing role ", + Strings.toHexString(uint256(0), 32) + ) + ); + tokenContract.setPlatformFeeInfo(_platformFeeRecipient, _platformFeeBps); + } + + modifier whenCallerAuthorized() { + vm.prank(deployer); + tokenContract.grantRole(bytes32(0x00), caller); + _; + } + + function test_setPlatformFeeInfo_exceedMaxBps() public whenCallerAuthorized { + _platformFeeBps = 10_001; + vm.prank(address(caller)); + vm.expectRevert("exceeds MAX_BPS"); + tokenContract.setPlatformFeeInfo(_platformFeeRecipient, _platformFeeBps); + } + + modifier whenNotExceedMaxBps() { + _platformFeeBps = 500; + _; + } + + function test_setPlatformFeeInfo() public whenCallerAuthorized whenNotExceedMaxBps { + vm.prank(address(caller)); + tokenContract.setPlatformFeeInfo(_platformFeeRecipient, _platformFeeBps); + + // get platform fee info + (address _recipient, uint16 _bps) = tokenContract.getPlatformFeeInfo(); + assertEq(_recipient, _platformFeeRecipient); + assertEq(_bps, uint16(_platformFeeBps)); + assertEq(tokenContract.platformFeeRecipient(), _platformFeeRecipient); + } + + function test_setPlatformFeeInfo_event() public whenCallerAuthorized whenNotExceedMaxBps { + vm.prank(address(caller)); + vm.expectEmit(true, false, false, true); + emit PlatformFeeInfoUpdated(_platformFeeRecipient, _platformFeeBps); + tokenContract.setPlatformFeeInfo(_platformFeeRecipient, _platformFeeBps); + } +} diff --git a/src/test/tokenerc1155-BTT/set-platform-fee-info/setPlatformFeeInfo.tree b/src/test/tokenerc1155-BTT/set-platform-fee-info/setPlatformFeeInfo.tree new file mode 100644 index 000000000..dcef9965e --- /dev/null +++ b/src/test/tokenerc1155-BTT/set-platform-fee-info/setPlatformFeeInfo.tree @@ -0,0 +1,10 @@ +setPlatformFeeInfo(address _platformFeeRecipient, uint256 _platformFeeBps) +├── when caller not authorized + │ └── it should revert ✅ + └── when caller is authorized + ├── when `_platformFeeBps` is greater than MAX_BPS + │ └── it should revert ✅ + └── when `_platformFeeBps` is less than or equal to MAX_BPS + └── it should update platform fee recipient ✅ + └── it should update platform fee bps ✅ + └── it should emit PlatformFeeInfoUpdated event ✅ \ No newline at end of file diff --git a/src/test/tokenerc1155-BTT/set-platform-fee-type/setPlatformFeeType.t.sol b/src/test/tokenerc1155-BTT/set-platform-fee-type/setPlatformFeeType.t.sol new file mode 100644 index 000000000..db96dd310 --- /dev/null +++ b/src/test/tokenerc1155-BTT/set-platform-fee-type/setPlatformFeeType.t.sol @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "../../utils/BaseTest.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; +import { IPlatformFee } from "contracts/extension/interface/IPlatformFee.sol"; + +contract MyTokenERC1155 is TokenERC1155 {} + +contract TokenERC1155Test_SetPlatformFeeType is BaseTest { + address public implementation; + address public proxy; + address internal caller; + IPlatformFee.PlatformFeeType internal _newFeeType; + + MyTokenERC1155 internal tokenContract; + + event PlatformFeeTypeUpdated(IPlatformFee.PlatformFeeType feeType); + + function setUp() public override { + super.setUp(); + + // Deploy implementation. + implementation = address(new MyTokenERC1155()); + + caller = getActor(1); + + // Deploy proxy pointing to implementaion. + vm.prank(deployer); + proxy = address( + new TWProxy( + implementation, + abi.encodeCall( + TokenERC1155.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ) + ); + + tokenContract = MyTokenERC1155(proxy); + _newFeeType = IPlatformFee.PlatformFeeType.Flat; + } + + function test_setPlatformFeeType_callerNotAuthorized() public { + vm.prank(address(caller)); + vm.expectRevert( + abi.encodePacked( + "AccessControl: account ", + Strings.toHexString(uint160(caller), 20), + " is missing role ", + Strings.toHexString(uint256(0), 32) + ) + ); + tokenContract.setPlatformFeeType(_newFeeType); + } + + modifier whenCallerAuthorized() { + vm.prank(deployer); + tokenContract.grantRole(bytes32(0x00), caller); + _; + } + + function test_setPlatformFeeType() public whenCallerAuthorized { + vm.prank(address(caller)); + tokenContract.setPlatformFeeType(_newFeeType); + + assertEq(uint8(tokenContract.getPlatformFeeType()), uint8(_newFeeType)); + } + + function test_setPlatformFeeType_event() public whenCallerAuthorized { + vm.prank(address(caller)); + vm.expectEmit(false, false, false, true); + emit PlatformFeeTypeUpdated(_newFeeType); + tokenContract.setPlatformFeeType(_newFeeType); + } +} diff --git a/src/test/tokenerc1155-BTT/set-platform-fee-type/setPlatformFeeType.tree b/src/test/tokenerc1155-BTT/set-platform-fee-type/setPlatformFeeType.tree new file mode 100644 index 000000000..e25a6bd4c --- /dev/null +++ b/src/test/tokenerc1155-BTT/set-platform-fee-type/setPlatformFeeType.tree @@ -0,0 +1,6 @@ +setPlatformFeeType(PlatformFeeType _feeType) +├── when caller not authorized + │ └── it should revert ✅ + └── when caller is authorized + └── it should update platformFeeType ✅ + └── it should emit PlatformFeeTypeUpdated event ✅ \ No newline at end of file diff --git a/src/test/tokenerc1155-BTT/set-primary-sale-recipient/setPrimarySaleRecipient.t.sol b/src/test/tokenerc1155-BTT/set-primary-sale-recipient/setPrimarySaleRecipient.t.sol new file mode 100644 index 000000000..2f838241e --- /dev/null +++ b/src/test/tokenerc1155-BTT/set-primary-sale-recipient/setPrimarySaleRecipient.t.sol @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "../../utils/BaseTest.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +contract MyTokenERC1155 is TokenERC1155 {} + +contract TokenERC1155Test_SetPrimarySaleRecipient is BaseTest { + address public implementation; + address public proxy; + address internal caller; + address internal _primarySaleRecipient; + + MyTokenERC1155 internal tokenContract; + + event PrimarySaleRecipientUpdated(address indexed recipient); + + function setUp() public override { + super.setUp(); + + // Deploy implementation. + implementation = address(new MyTokenERC1155()); + + caller = getActor(1); + _primarySaleRecipient = getActor(2); + + // Deploy proxy pointing to implementaion. + vm.prank(deployer); + proxy = address( + new TWProxy( + implementation, + abi.encodeCall( + TokenERC1155.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ) + ); + + tokenContract = MyTokenERC1155(proxy); + } + + function test_setPrimarySaleRecipient_callerNotAuthorized() public { + vm.prank(address(caller)); + vm.expectRevert( + abi.encodePacked( + "AccessControl: account ", + Strings.toHexString(uint160(caller), 20), + " is missing role ", + Strings.toHexString(uint256(0), 32) + ) + ); + tokenContract.setPrimarySaleRecipient(_primarySaleRecipient); + } + + modifier whenCallerAuthorized() { + vm.prank(deployer); + tokenContract.grantRole(bytes32(0x00), caller); + _; + } + + function test_setPrimarySaleRecipient() public whenCallerAuthorized { + vm.prank(address(caller)); + tokenContract.setPrimarySaleRecipient(_primarySaleRecipient); + + // get primary sale recipient info + assertEq(tokenContract.primarySaleRecipient(), _primarySaleRecipient); + } + + function test_setPrimarySaleRecipient_event() public whenCallerAuthorized { + vm.prank(address(caller)); + vm.expectEmit(true, false, false, false); + emit PrimarySaleRecipientUpdated(_primarySaleRecipient); + tokenContract.setPrimarySaleRecipient(_primarySaleRecipient); + } +} diff --git a/src/test/tokenerc1155-BTT/set-primary-sale-recipient/setPrimarySaleRecipient.tree b/src/test/tokenerc1155-BTT/set-primary-sale-recipient/setPrimarySaleRecipient.tree new file mode 100644 index 000000000..230035a07 --- /dev/null +++ b/src/test/tokenerc1155-BTT/set-primary-sale-recipient/setPrimarySaleRecipient.tree @@ -0,0 +1,6 @@ +setPrimarySaleRecipient(address _saleRecipient) +├── when caller not authorized + │ └── it should revert ✅ + └── when caller is authorized + └── it should update primary sale recipient ✅ + └── it should emit PrimarySaleRecipientUpdated event ✅ \ No newline at end of file diff --git a/src/test/tokenerc1155-BTT/set-royalty-info-for-token/setRoyaltyInfoForToken.t.sol b/src/test/tokenerc1155-BTT/set-royalty-info-for-token/setRoyaltyInfoForToken.t.sol new file mode 100644 index 000000000..051dd7918 --- /dev/null +++ b/src/test/tokenerc1155-BTT/set-royalty-info-for-token/setRoyaltyInfoForToken.t.sol @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "../../utils/BaseTest.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +contract MyTokenERC1155 is TokenERC1155 {} + +contract TokenERC1155Test_SetRoyaltyInfoForToken is BaseTest { + address public implementation; + address public proxy; + address internal caller; + address internal defaultRoyaltyRecipient; + uint256 internal defaultRoyaltyBps; + + MyTokenERC1155 internal tokenContract; + + address internal royaltyRecipientForToken; + uint256 internal royaltyBpsForToken; + uint256 internal tokenId; + + event RoyaltyForToken(uint256 indexed tokenId, address indexed royaltyRecipient, uint256 royaltyBps); + + function setUp() public override { + super.setUp(); + + // Deploy implementation. + implementation = address(new MyTokenERC1155()); + + caller = getActor(1); + defaultRoyaltyRecipient = getActor(2); + royaltyRecipientForToken = getActor(3); + defaultRoyaltyBps = 500; + tokenId = 1; + + // Deploy proxy pointing to implementaion. + vm.prank(deployer); + proxy = address( + new TWProxy( + implementation, + abi.encodeCall( + TokenERC1155.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ) + ); + + tokenContract = MyTokenERC1155(proxy); + + vm.prank(deployer); + tokenContract.setDefaultRoyaltyInfo(defaultRoyaltyRecipient, defaultRoyaltyBps); + } + + function test_setRoyaltyInfoForToken_callerNotAuthorized() public { + vm.prank(address(caller)); + vm.expectRevert( + abi.encodePacked( + "AccessControl: account ", + Strings.toHexString(uint160(caller), 20), + " is missing role ", + Strings.toHexString(uint256(0), 32) + ) + ); + tokenContract.setRoyaltyInfoForToken(tokenId, royaltyRecipientForToken, royaltyBpsForToken); + } + + modifier whenCallerAuthorized() { + vm.prank(deployer); + tokenContract.grantRole(bytes32(0x00), caller); + _; + } + + function test_setRoyaltyInfoForToken_exceedMaxBps() public whenCallerAuthorized { + royaltyBpsForToken = 10_001; + vm.prank(address(caller)); + vm.expectRevert("exceed royalty bps"); + tokenContract.setRoyaltyInfoForToken(tokenId, royaltyRecipientForToken, royaltyBpsForToken); + } + + modifier whenNotExceedMaxBps() { + royaltyBpsForToken = 1000; + _; + } + + function test_setRoyaltyInfoForToken() public whenCallerAuthorized whenNotExceedMaxBps { + vm.prank(address(caller)); + tokenContract.setRoyaltyInfoForToken(tokenId, royaltyRecipientForToken, royaltyBpsForToken); + + // get default royalty info + (address _defaultRecipient, uint16 _defaultRoyaltyBps) = tokenContract.getDefaultRoyaltyInfo(); + assertEq(_defaultRecipient, defaultRoyaltyRecipient); + assertEq(_defaultRoyaltyBps, uint16(defaultRoyaltyBps)); + + // get royalty info for token + (address _royaltyRecipientForToken, uint16 _royaltyBpsForToken) = tokenContract.getRoyaltyInfoForToken(tokenId); + assertEq(_royaltyRecipientForToken, royaltyRecipientForToken); + assertEq(_royaltyBpsForToken, uint16(royaltyBpsForToken)); + + // royaltyInfo - ERC2981: calculate for default + uint256 salePrice = 1000; + (address _royaltyRecipient, uint256 _royaltyAmount) = tokenContract.royaltyInfo(0, salePrice); + assertEq(_royaltyRecipient, defaultRoyaltyRecipient); + assertEq(_royaltyAmount, (salePrice * defaultRoyaltyBps) / 10_000); + + // royaltyInfo - ERC2981: calculate for specific tokenId we set the royalty info for + (_royaltyRecipient, _royaltyAmount) = tokenContract.royaltyInfo(tokenId, salePrice); + assertEq(_royaltyRecipient, royaltyRecipientForToken); + assertEq(_royaltyAmount, (salePrice * royaltyBpsForToken) / 10_000); + } + + function test_setRoyaltyInfoForToken_event() public whenCallerAuthorized whenNotExceedMaxBps { + vm.prank(address(caller)); + vm.expectEmit(true, true, false, true); + emit RoyaltyForToken(tokenId, royaltyRecipientForToken, royaltyBpsForToken); + tokenContract.setRoyaltyInfoForToken(tokenId, royaltyRecipientForToken, royaltyBpsForToken); + } +} diff --git a/src/test/tokenerc1155-BTT/set-royalty-info-for-token/setRoyaltyInfoForToken.tree b/src/test/tokenerc1155-BTT/set-royalty-info-for-token/setRoyaltyInfoForToken.tree new file mode 100644 index 000000000..cada076de --- /dev/null +++ b/src/test/tokenerc1155-BTT/set-royalty-info-for-token/setRoyaltyInfoForToken.tree @@ -0,0 +1,15 @@ +function setRoyaltyInfoForToken( + uint256 _tokenId, + address _recipient, + uint256 _bps +) +├── when caller not authorized + │ └── it should revert ✅ + └── when caller is authorized + ├── when royalty bps input is greater than MAX_BPS + │ └── it should revert ✅ + └── when royalty bps input is less than or equal to MAX_BPS + └── it should update default royalty recipient ✅ + └── it should update default royalty bps ✅ + └── it should calculate royalty amount for a token-id based on default royalty info ✅ + └── it should emit RoyaltyForToken event ✅ \ No newline at end of file diff --git a/src/test/tokenerc1155-BTT/uri/tokenURI.t.sol b/src/test/tokenerc1155-BTT/uri/tokenURI.t.sol new file mode 100644 index 000000000..1a2feb0a2 --- /dev/null +++ b/src/test/tokenerc1155-BTT/uri/tokenURI.t.sol @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "../../utils/BaseTest.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +contract MyTokenERC1155 is TokenERC1155 {} + +contract TokenERC1155Test_Uri is BaseTest { + address public implementation; + address public proxy; + + MyTokenERC1155 internal tokenContract; + + function setUp() public override { + super.setUp(); + + // Deploy implementation. + implementation = address(new MyTokenERC1155()); + + // Deploy proxy pointing to implementaion. + vm.prank(deployer); + proxy = address( + new TWProxy( + implementation, + abi.encodeCall( + TokenERC1155.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ) + ); + + tokenContract = MyTokenERC1155(proxy); + } + + function test_uri() public { + uint256 _tokenId = 1; + string memory _uri = "ipfs://uri/1"; + + vm.prank(deployer); + tokenContract.setTokenURI(_tokenId, _uri); + + assertEq(tokenContract.uri(_tokenId), _uri); + } +} diff --git a/src/test/tokenerc1155-BTT/uri/tokenURI.tree b/src/test/tokenerc1155-BTT/uri/tokenURI.tree new file mode 100644 index 000000000..2df0b55ed --- /dev/null +++ b/src/test/tokenerc1155-BTT/uri/tokenURI.tree @@ -0,0 +1,3 @@ +uri(uint256 _tokenId) +├── it should return uri associated with the given `_tokenId` ✅ + diff --git a/src/test/tokenerc1155-BTT/verify/verify.t.sol b/src/test/tokenerc1155-BTT/verify/verify.t.sol new file mode 100644 index 000000000..f584f57a8 --- /dev/null +++ b/src/test/tokenerc1155-BTT/verify/verify.t.sol @@ -0,0 +1,157 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "../../utils/BaseTest.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +contract MyTokenERC1155 is TokenERC1155 { + function setMintedURI(MintRequest calldata _req, bytes calldata _signature) external { + verifyRequest(_req, _signature); + } +} + +contract TokenERC1155Test_Verify is BaseTest { + address public implementation; + address public proxy; + + MyTokenERC1155 internal tokenContract; + bytes32 internal typehashMintRequest; + bytes32 internal nameHash; + bytes32 internal versionHash; + bytes32 internal typehashEip712; + bytes32 internal domainSeparator; + + TokenERC1155.MintRequest _mintrequest; + + function setUp() public override { + super.setUp(); + + // Deploy implementation. + implementation = address(new MyTokenERC1155()); + + // Deploy proxy pointing to implementaion. + vm.prank(deployer); + proxy = address( + new TWProxy( + implementation, + abi.encodeCall( + TokenERC1155.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ) + ); + + tokenContract = MyTokenERC1155(proxy); + + typehashMintRequest = keccak256( + "MintRequest(address to,address royaltyRecipient,uint256 royaltyBps,address primarySaleRecipient,uint256 tokenId,string uri,uint256 quantity,uint256 pricePerToken,address currency,uint128 validityStartTimestamp,uint128 validityEndTimestamp,bytes32 uid)" + ); + nameHash = keccak256(bytes("TokenERC1155")); + versionHash = keccak256(bytes("1")); + typehashEip712 = keccak256( + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" + ); + domainSeparator = keccak256( + abi.encode(typehashEip712, nameHash, versionHash, block.chainid, address(tokenContract)) + ); + + // construct default mintrequest + _mintrequest.to = address(0x1234); + _mintrequest.royaltyRecipient = royaltyRecipient; + _mintrequest.royaltyBps = royaltyBps; + _mintrequest.primarySaleRecipient = saleRecipient; + _mintrequest.tokenId = type(uint256).max; + _mintrequest.uri = "ipfs://"; + _mintrequest.quantity = 100; + _mintrequest.pricePerToken = 0; + _mintrequest.currency = address(0); + _mintrequest.validityStartTimestamp = 0; + _mintrequest.validityEndTimestamp = 2000; + _mintrequest.uid = bytes32(0); + } + + function signMintRequest( + TokenERC1155.MintRequest memory _request, + uint256 _privateKey + ) internal view returns (bytes memory) { + bytes memory encodedRequest = bytes.concat( + abi.encode( + typehashMintRequest, + _request.to, + _request.royaltyRecipient, + _request.royaltyBps, + _request.primarySaleRecipient, + _request.tokenId, + keccak256(bytes(_request.uri)) + ), + abi.encode( + _request.quantity, + _request.pricePerToken, + _request.currency, + _request.validityStartTimestamp, + _request.validityEndTimestamp, + _request.uid + ) + ); + bytes32 structHash = keccak256(encodedRequest); + bytes32 typedDataHash = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(_privateKey, typedDataHash); + bytes memory sig = abi.encodePacked(r, s, v); + + return sig; + } + + function test_verify_notMinterRole() public { + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + (bool _isValid, address _recoveredSigner) = tokenContract.verify(_mintrequest, _signature); + + assertFalse(_isValid); + assertEq(_recoveredSigner, signer); + } + + modifier whenMinterRole() { + vm.prank(deployer); + tokenContract.grantRole(keccak256("MINTER_ROLE"), signer); + _; + } + + function test_verify_invalidUID() public whenMinterRole { + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + // set state with this mintrequest and signature, marking the UID as used + tokenContract.setMintedURI(_mintrequest, _signature); + + // pass the same UID mintrequest again + (bool _isValid, address _recoveredSigner) = tokenContract.verify(_mintrequest, _signature); + + assertFalse(_isValid); + assertEq(_recoveredSigner, signer); + } + + modifier whenUidNotUsed() { + _; + } + + function test_verify() public whenMinterRole whenUidNotUsed { + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + (bool _isValid, address _recoveredSigner) = tokenContract.verify(_mintrequest, _signature); + + assertTrue(_isValid); + assertEq(_recoveredSigner, signer); + } +} diff --git a/src/test/tokenerc1155-BTT/verify/verify.tree b/src/test/tokenerc1155-BTT/verify/verify.tree new file mode 100644 index 000000000..c160faa0f --- /dev/null +++ b/src/test/tokenerc1155-BTT/verify/verify.tree @@ -0,0 +1,12 @@ +verify(MintRequest calldata _req, bytes calldata _signature) +├── when signer doesn't have MINTER_ROLE +│ └── it should return false ✅ +│ └── it should return recovered signer equal to the actual signer of the request ✅ +└── when signer has MINTER_ROLE + └── when `_req.uid` has already been used + │ └── it should return false ✅ + │ └── it should return recovered signer equal to the actual signer of the request ✅ + └── when `_req.uid` has not been used + └── it should return true ✅ + └── it should return recovered signer equal to the actual signer of the request ✅ + diff --git a/src/test/tokenerc20-BTT/initialize/initialize.t.sol b/src/test/tokenerc20-BTT/initialize/initialize.t.sol new file mode 100644 index 000000000..6dae4df15 --- /dev/null +++ b/src/test/tokenerc20-BTT/initialize/initialize.t.sol @@ -0,0 +1,233 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "../../utils/BaseTest.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +contract MyTokenERC20 is TokenERC20 { + function eip712NameHash() external view returns (bytes32) { + return _EIP712NameHash(); + } + + function eip712VersionHash() external view returns (bytes32) { + return _EIP712VersionHash(); + } +} + +contract TokenERC20Test_Initialize is BaseTest { + address public implementation; + address public proxy; + + event RoleGranted(bytes32 indexed role, address indexed account, address indexed sender); + + function setUp() public override { + super.setUp(); + + // Deploy implementation. + implementation = address(new MyTokenERC20()); + + // Deploy proxy pointing to implementaion. + vm.prank(deployer); + proxy = address( + new TWProxy( + implementation, + abi.encodeCall( + TokenERC20.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + platformFeeRecipient, + platformFeeBps + ) + ) + ) + ); + } + + function test_initialize_initializingImplementation() public { + vm.expectRevert("Initializable: contract is already initialized"); + TokenERC20(implementation).initialize( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + platformFeeRecipient, + platformFeeBps + ); + } + + modifier whenNotImplementation() { + _; + } + + function test_initialize_proxyAlreadyInitialized() public whenNotImplementation { + vm.expectRevert("Initializable: contract is already initialized"); + MyTokenERC20(proxy).initialize( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + platformFeeRecipient, + platformFeeBps + ); + } + + modifier whenProxyNotInitialized() { + proxy = address(new TWProxy(implementation, "")); + _; + } + + function test_initialize_exceedsMaxBps() public whenNotImplementation whenProxyNotInitialized { + vm.expectRevert("exceeds MAX_BPS"); + MyTokenERC20(proxy).initialize( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + platformFeeRecipient, + uint128(MAX_BPS) + 1 // platformFeeBps greater than MAX_BPS + ); + } + + modifier whenPlatformFeeBpsWithinMaxBps() { + _; + } + + function test_initialize() public whenNotImplementation whenProxyNotInitialized whenPlatformFeeBpsWithinMaxBps { + MyTokenERC20(proxy).initialize( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + platformFeeRecipient, + platformFeeBps + ); + + // check state + MyTokenERC20 tokenContract = MyTokenERC20(proxy); + + assertEq(tokenContract.eip712NameHash(), keccak256(bytes(NAME))); + assertEq(tokenContract.eip712VersionHash(), keccak256(bytes("1"))); + + address[] memory _trustedForwarders = forwarders(); + for (uint256 i = 0; i < _trustedForwarders.length; i++) { + assertTrue(tokenContract.isTrustedForwarder(_trustedForwarders[i])); + } + + assertEq(tokenContract.name(), NAME); + assertEq(tokenContract.symbol(), SYMBOL); + assertEq(tokenContract.contractURI(), CONTRACT_URI); + + (address _platformFeeRecipient, uint16 _platformFeeBps) = tokenContract.getPlatformFeeInfo(); + assertEq(_platformFeeBps, platformFeeBps); + assertEq(_platformFeeRecipient, platformFeeRecipient); + + assertEq(tokenContract.primarySaleRecipient(), saleRecipient); + + assertTrue(tokenContract.hasRole(bytes32(0x00), deployer)); + assertTrue(tokenContract.hasRole(keccak256("TRANSFER_ROLE"), deployer)); + assertTrue(tokenContract.hasRole(keccak256("TRANSFER_ROLE"), address(0))); + assertTrue(tokenContract.hasRole(keccak256("MINTER_ROLE"), deployer)); + } + + function test_initialize_event_RoleGranted_DefaultAdmin() + public + whenNotImplementation + whenProxyNotInitialized + whenPlatformFeeBpsWithinMaxBps + { + bytes32 _defaultAdminRole = bytes32(0x00); + vm.prank(deployer); + vm.expectEmit(true, true, true, false); + emit RoleGranted(_defaultAdminRole, deployer, deployer); + MyTokenERC20(proxy).initialize( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + platformFeeRecipient, + platformFeeBps + ); + } + + function test_initialize_event_RoleGranted_MinterRole() + public + whenNotImplementation + whenProxyNotInitialized + whenPlatformFeeBpsWithinMaxBps + { + bytes32 _minterRole = keccak256("MINTER_ROLE"); + vm.prank(deployer); + vm.expectEmit(true, true, true, false); + emit RoleGranted(_minterRole, deployer, deployer); + MyTokenERC20(proxy).initialize( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + platformFeeRecipient, + platformFeeBps + ); + } + + function test_initialize_event_RoleGranted_TransferRole() + public + whenNotImplementation + whenProxyNotInitialized + whenPlatformFeeBpsWithinMaxBps + { + bytes32 _transferRole = keccak256("TRANSFER_ROLE"); + vm.prank(deployer); + vm.expectEmit(true, true, true, false); + emit RoleGranted(_transferRole, deployer, deployer); + MyTokenERC20(proxy).initialize( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + platformFeeRecipient, + platformFeeBps + ); + } + + function test_initialize_event_RoleGranted_TransferRole_AddressZero() + public + whenNotImplementation + whenProxyNotInitialized + whenPlatformFeeBpsWithinMaxBps + { + bytes32 _transferRole = keccak256("TRANSFER_ROLE"); + vm.prank(deployer); + vm.expectEmit(true, true, true, false); + emit RoleGranted(_transferRole, address(0), deployer); + MyTokenERC20(proxy).initialize( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + platformFeeRecipient, + platformFeeBps + ); + } +} diff --git a/src/test/tokenerc20-BTT/initialize/initialize.tree b/src/test/tokenerc20-BTT/initialize/initialize.tree new file mode 100644 index 000000000..a3ead7790 --- /dev/null +++ b/src/test/tokenerc20-BTT/initialize/initialize.tree @@ -0,0 +1,34 @@ +initialize( + address _defaultAdmin, + string memory _name, + string memory _symbol, + string memory _contractURI, + address[] memory _trustedForwarders, + address _primarySaleRecipient, + address _platformFeeRecipient + uint256 _platformFeeBps, +) +├── when initializing the implementation contract (not proxy) +│ └── it should revert ✅ +└── when it is a proxy to the implementation + └── when it is already initialized + │ └── it should revert ✅ + └── when it is not initialized + └── when platformFeeBps is greater than MAX_BPS + │ └── it should revert ✅ + └── when platformFeeBps is less than or equal to MAX_BPS + └── it should correctly set EIP712 name hash and version hash ✅ + └── it should set trustedForwarder mapping to true for all addresses in `_trustedForwarders` ✅ + └── it should set _name and _symbol to `_name` and `_symbol` param values respectively ✅ + └── it should set contractURI to `_contractURI` param value ✅ + └── it should set platformFeeRecipient and platformFeeBps as `_platformFeeRecipient` and `_platformFeeBps` respectively ✅ + └── it should set primary sale recipient as `_saleRecipient` param value ✅ + └── it should grant 0x00 (DEFAULT_ADMIN_ROLE) to `_defaultAdmin` address ✅ + └── it should emit RoleGranted event ✅ + └── it should grant MINTER_ROLE to `_defaultAdmin` address ✅ + └── it should emit RoleGranted event ✅ + └── it should grant TRANSFER_ROLE to `_defaultAdmin` address ✅ + └── it should emit RoleGranted event ✅ + └── it should grant TRANSFER_ROLE to address(0) ✅ + └── it should emit RoleGranted event ✅ + diff --git a/src/test/tokenerc20-BTT/mint-to/mintTo.t.sol b/src/test/tokenerc20-BTT/mint-to/mintTo.t.sol new file mode 100644 index 000000000..ad5095640 --- /dev/null +++ b/src/test/tokenerc20-BTT/mint-to/mintTo.t.sol @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "../../utils/BaseTest.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +contract MyTokenERC20 is TokenERC20 {} + +contract TokenERC20Test_MintTo is BaseTest { + address public implementation; + address public proxy; + address public caller; + address public recipient; + uint256 public amount; + + MyTokenERC20 internal tokenContract; + + event TokensMinted(address indexed mintedTo, uint256 quantityMinted); + + function setUp() public override { + super.setUp(); + + // Deploy implementation. + implementation = address(new MyTokenERC20()); + caller = getActor(1); + recipient = getActor(2); + + // Deploy proxy pointing to implementaion. + vm.prank(deployer); + proxy = address( + new TWProxy( + implementation, + abi.encodeCall( + TokenERC20.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + platformFeeRecipient, + platformFeeBps + ) + ) + ) + ); + + tokenContract = MyTokenERC20(proxy); + amount = 100; + } + + function test_mintTo_notMinterRole() public { + vm.prank(caller); + vm.expectRevert("not minter."); + tokenContract.mintTo(recipient, amount); + } + + modifier whenMinterRole() { + vm.prank(deployer); + tokenContract.grantRole(keccak256("MINTER_ROLE"), caller); + _; + } + + function test_mintTo() public whenMinterRole { + // mint + vm.prank(caller); + tokenContract.mintTo(recipient, amount); + + // check state after + assertEq(tokenContract.balanceOf(recipient), amount); + } + + function test_mintTo_TokensMintedEvent() public whenMinterRole { + vm.prank(caller); + vm.expectEmit(true, false, false, true); + emit TokensMinted(recipient, amount); + tokenContract.mintTo(recipient, amount); + } +} diff --git a/src/test/tokenerc20-BTT/mint-to/mintTo.tree b/src/test/tokenerc20-BTT/mint-to/mintTo.tree new file mode 100644 index 000000000..33bb14c7e --- /dev/null +++ b/src/test/tokenerc20-BTT/mint-to/mintTo.tree @@ -0,0 +1,7 @@ +mintTo(address to, uint256 amount) +├── when caller doesn't have MINTER_ROLE + │ └── it should revert ✅ + └── when caller has MINTER_ROLE + └── it should mint `amount` to `to` ✅ + └── it should emit TokensMinted event ✅ + diff --git a/src/test/tokenerc20-BTT/mint-with-signature/mintWithSignature.t.sol b/src/test/tokenerc20-BTT/mint-with-signature/mintWithSignature.t.sol new file mode 100644 index 000000000..bbe988eb8 --- /dev/null +++ b/src/test/tokenerc20-BTT/mint-with-signature/mintWithSignature.t.sol @@ -0,0 +1,449 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "../../utils/BaseTest.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +contract MyTokenERC20 is TokenERC20 { + function setMintedUID(MintRequest calldata _req, bytes calldata _signature) external { + verifyRequest(_req, _signature); + } +} + +contract ReentrantContract { + fallback() external payable { + TokenERC20.MintRequest memory _mintrequest; + bytes memory _signature; + MyTokenERC20(msg.sender).mintWithSignature(_mintrequest, _signature); + } +} + +contract TokenERC20Test_MintWithSignature is BaseTest { + address public implementation; + address public proxy; + address public caller; + address public recipient; + + MyTokenERC20 internal tokenContract; + + bytes32 internal typehashMintRequest; + bytes32 internal nameHash; + bytes32 internal versionHash; + bytes32 internal typehashEip712; + bytes32 internal domainSeparator; + + TokenERC20.MintRequest _mintrequest; + + event TokensMintedWithSignature( + address indexed signer, + address indexed mintedTo, + TokenERC20.MintRequest mintRequest + ); + + function setUp() public override { + super.setUp(); + + // Deploy implementation. + implementation = address(new MyTokenERC20()); + caller = getActor(1); + recipient = getActor(2); + + // Deploy proxy pointing to implementaion. + vm.prank(deployer); + proxy = address( + new TWProxy( + implementation, + abi.encodeCall( + TokenERC20.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + platformFeeRecipient, + platformFeeBps + ) + ) + ) + ); + + tokenContract = MyTokenERC20(proxy); + + typehashMintRequest = keccak256( + "MintRequest(address to,address primarySaleRecipient,uint256 quantity,uint256 price,address currency,uint128 validityStartTimestamp,uint128 validityEndTimestamp,bytes32 uid)" + ); + nameHash = keccak256(bytes(NAME)); + versionHash = keccak256(bytes("1")); + typehashEip712 = keccak256( + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" + ); + domainSeparator = keccak256( + abi.encode(typehashEip712, nameHash, versionHash, block.chainid, address(tokenContract)) + ); + + // construct default mintrequest + _mintrequest.to = recipient; + _mintrequest.primarySaleRecipient = saleRecipient; + _mintrequest.quantity = 100; + _mintrequest.price = 0; + _mintrequest.currency = address(0); + _mintrequest.validityStartTimestamp = 0; + _mintrequest.validityEndTimestamp = 2000; + _mintrequest.uid = bytes32(0); + + erc20.mint(deployer, 1_000 ether); + vm.deal(deployer, 1_000 ether); + erc20.mint(caller, 1_000 ether); + vm.deal(caller, 1_000 ether); + + vm.startPrank(deployer); + erc20.approve(address(tokenContract), type(uint256).max); + vm.stopPrank(); + + vm.startPrank(caller); + erc20.approve(address(tokenContract), type(uint256).max); + vm.stopPrank(); + } + + function signMintRequest( + TokenERC20.MintRequest memory _request, + uint256 _privateKey + ) internal view returns (bytes memory) { + bytes memory encodedRequest = abi.encode( + typehashMintRequest, + _request.to, + _request.primarySaleRecipient, + _request.quantity, + _request.price, + _request.currency, + _request.validityStartTimestamp, + _request.validityEndTimestamp, + _request.uid + ); + bytes32 structHash = keccak256(encodedRequest); + bytes32 typedDataHash = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(_privateKey, typedDataHash); + bytes memory sig = abi.encodePacked(r, s, v); + + return sig; + } + + function test_mintWithSignature_notMinterRole() public { + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + vm.prank(caller); + vm.expectRevert("invalid signature"); + tokenContract.mintWithSignature(_mintrequest, _signature); + } + + modifier whenMinterRole() { + vm.prank(deployer); + tokenContract.grantRole(keccak256("MINTER_ROLE"), signer); + _; + } + + function test_mintWithSignature_invalidUID() public whenMinterRole { + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + // set state with this mintrequest and signature, marking the UID as used + tokenContract.setMintedUID(_mintrequest, _signature); + + // pass the same UID mintrequest again + vm.prank(caller); + vm.expectRevert("invalid signature"); + tokenContract.mintWithSignature(_mintrequest, _signature); + } + + modifier whenUidNotUsed() { + _; + } + + function test_mintWithSignature_invalidStartTimestamp() public whenMinterRole whenUidNotUsed { + _mintrequest.validityStartTimestamp = uint128(block.timestamp + 1); + + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + vm.expectRevert("request expired"); + vm.prank(caller); + tokenContract.mintWithSignature(_mintrequest, _signature); + } + + modifier whenValidStartTimestamp() { + _; + } + + function test_mintWithSignature_invalidEndTimestamp() public whenMinterRole whenUidNotUsed whenValidStartTimestamp { + _mintrequest.validityEndTimestamp = uint128(block.timestamp - 1); + + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + vm.expectRevert("request expired"); + vm.prank(caller); + tokenContract.mintWithSignature(_mintrequest, _signature); + } + + modifier whenValidEndTimestamp() { + _; + } + + function test_mintWithSignature_recipientAddressZero() + public + whenMinterRole + whenUidNotUsed + whenValidStartTimestamp + whenValidEndTimestamp + { + _mintrequest.to = address(0); + + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + vm.expectRevert("recipient undefined"); + vm.prank(caller); + tokenContract.mintWithSignature(_mintrequest, _signature); + } + + modifier whenRecipientAddressNotZero() { + _; + } + + function test_mintWithSignature_zeroQuantity() + public + whenMinterRole + whenUidNotUsed + whenValidStartTimestamp + whenValidEndTimestamp + whenRecipientAddressNotZero + { + _mintrequest.quantity = 0; + + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + vm.expectRevert("zero quantity"); + vm.prank(caller); + tokenContract.mintWithSignature(_mintrequest, _signature); + } + + modifier whenNotZeroQuantity() { + _mintrequest.quantity = 100; + _; + } + + // ================== + // ======= Test branch: when mint price is zero + // ================== + + function test_mintWithSignature_zeroPrice_msgValueNonZero() + public + whenMinterRole + whenUidNotUsed + whenValidStartTimestamp + whenValidEndTimestamp + whenRecipientAddressNotZero + whenNotZeroQuantity + { + _mintrequest.price = 0; + + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + vm.expectRevert("!Value"); + vm.prank(caller); + tokenContract.mintWithSignature{ value: 1 }(_mintrequest, _signature); + } + + modifier whenMsgValueZero() { + _; + } + + function test_mintWithSignature_zeroPrice() + public + whenMinterRole + whenUidNotUsed + whenValidStartTimestamp + whenValidEndTimestamp + whenRecipientAddressNotZero + whenNotZeroQuantity + whenMsgValueZero + { + _mintrequest.price = 0; + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + // mint + vm.prank(caller); + tokenContract.mintWithSignature(_mintrequest, _signature); + + // check state after + assertEq(tokenContract.balanceOf(recipient), _mintrequest.quantity); + } + + function test_mintWithSignature_zeroPrice_TokensMintedWithSignatureEvent() + public + whenMinterRole + whenUidNotUsed + whenValidStartTimestamp + whenValidEndTimestamp + whenRecipientAddressNotZero + whenNotZeroQuantity + whenMsgValueZero + { + _mintrequest.price = 0; + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + vm.prank(caller); + vm.expectEmit(true, true, false, true); + emit TokensMintedWithSignature(signer, _mintrequest.to, _mintrequest); + tokenContract.mintWithSignature(_mintrequest, _signature); + } + + // ================== + // ======= Test branch: when mint price is not zero + // ================== + + function test_mintWithSignature_nonZeroPrice_nativeToken_incorrectMsgValue() + public + whenMinterRole + whenUidNotUsed + whenValidStartTimestamp + whenValidEndTimestamp + whenRecipientAddressNotZero + whenNotZeroQuantity + { + _mintrequest.price = 10; + _mintrequest.currency = NATIVE_TOKEN; + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + uint256 incorrectTotalPrice = (_mintrequest.price) + 1; + + vm.expectRevert("must send total price."); + vm.prank(caller); + tokenContract.mintWithSignature{ value: incorrectTotalPrice }(_mintrequest, _signature); + } + + modifier whenCorrectMsgValue() { + _; + } + + function test_mintWithSignature_nonZeroPrice_nativeToken() + public + whenMinterRole + whenUidNotUsed + whenValidStartTimestamp + whenValidEndTimestamp + whenRecipientAddressNotZero + whenNotZeroQuantity + whenCorrectMsgValue + { + _mintrequest.price = 10; + _mintrequest.currency = NATIVE_TOKEN; + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + // mint + vm.prank(caller); + tokenContract.mintWithSignature{ value: _mintrequest.price }(_mintrequest, _signature); + + // check state after + assertEq(tokenContract.balanceOf(recipient), _mintrequest.quantity); + + uint256 _platformFee = (_mintrequest.price * platformFeeBps) / 10_000; + uint256 _saleProceeds = _mintrequest.price - _platformFee; + assertEq(caller.balance, 1000 ether - _mintrequest.price); + + (address _platformFeeRecipient, ) = tokenContract.getPlatformFeeInfo(); + assertEq(_platformFeeRecipient.balance, _platformFee); + assertEq(tokenContract.primarySaleRecipient().balance, _saleProceeds); + } + + function test_mintWithSignature_nonZeroPrice_nativeToken_TokensMintedWithSignatureEvent() + public + whenMinterRole + whenUidNotUsed + whenValidStartTimestamp + whenValidEndTimestamp + whenRecipientAddressNotZero + whenNotZeroQuantity + whenCorrectMsgValue + { + _mintrequest.price = 10; + _mintrequest.currency = NATIVE_TOKEN; + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + vm.prank(caller); + vm.expectEmit(true, true, false, true); + emit TokensMintedWithSignature(signer, _mintrequest.to, _mintrequest); + tokenContract.mintWithSignature{ value: _mintrequest.price }(_mintrequest, _signature); + } + + function test_mintWithSignature_nonZeroPrice_ERC20_nonZeroMsgValue() + public + whenMinterRole + whenUidNotUsed + whenValidStartTimestamp + whenValidEndTimestamp + whenRecipientAddressNotZero + whenNotZeroQuantity + { + _mintrequest.price = 10; + _mintrequest.currency = address(erc20); + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + vm.expectRevert("msg value not zero"); + vm.prank(caller); + tokenContract.mintWithSignature{ value: 1 }(_mintrequest, _signature); + } + + function test_mintWithSignature_nonZeroPrice_ERC20() + public + whenMinterRole + whenUidNotUsed + whenValidStartTimestamp + whenValidEndTimestamp + whenRecipientAddressNotZero + whenNotZeroQuantity + whenMsgValueZero + { + _mintrequest.price = 10; + _mintrequest.currency = address(erc20); + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + // mint + vm.prank(caller); + tokenContract.mintWithSignature{ value: 0 }(_mintrequest, _signature); + + // check state after + assertEq(tokenContract.balanceOf(recipient), _mintrequest.quantity); + + uint256 _platformFee = (_mintrequest.price * platformFeeBps) / 10_000; + uint256 _saleProceeds = _mintrequest.price - _platformFee; + assertEq(erc20.balanceOf(caller), 1000 ether - _mintrequest.price); + (address _platformFeeRecipient, ) = tokenContract.getPlatformFeeInfo(); + assertEq(erc20.balanceOf(_platformFeeRecipient), _platformFee); + assertEq(erc20.balanceOf(tokenContract.primarySaleRecipient()), _saleProceeds); + } + + function test_mintWithSignature_nonZeroPrice_ERC20_TokensMintedWithSignatureEvent() + public + whenMinterRole + whenUidNotUsed + whenValidStartTimestamp + whenValidEndTimestamp + whenRecipientAddressNotZero + whenNotZeroQuantity + whenMsgValueZero + { + _mintrequest.price = 10; + _mintrequest.currency = address(erc20); + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + vm.prank(caller); + vm.expectEmit(true, true, false, true); + emit TokensMintedWithSignature(signer, _mintrequest.to, _mintrequest); + tokenContract.mintWithSignature{ value: 0 }(_mintrequest, _signature); + } + + // ================== + // ======= Test branch: other cases + // ================== +} diff --git a/src/test/tokenerc20-BTT/mint-with-signature/mintWithSignature.tree b/src/test/tokenerc20-BTT/mint-with-signature/mintWithSignature.tree new file mode 100644 index 000000000..d7cfcdcab --- /dev/null +++ b/src/test/tokenerc20-BTT/mint-with-signature/mintWithSignature.tree @@ -0,0 +1,51 @@ +mintWithSignature(MintRequest calldata _req, bytes calldata _signature) +├── when signer doesn't have MINTER_ROLE +│ └── it should revert ✅ +└── when signer has MINTER_ROLE + └── when `_req.uid` has already been used + │ └── it should revert ✅ + └── when `_req.uid` has not been used + └── when `_req.validityStartTimestamp` is greater than block timestamp + │ └── it should revert ✅ + └── when `_req.validityStartTimestamp` is less than or equal to block timestamp + └── when `_req.validityEndTimestamp` is less than block timestamp + │ └── it should revert ✅ + └── when `_req.validityEndTimestamp` is greater than or equal to block timestamp + └── when `_req.to` is address(0) + │ └── it should revert ✅ + └── when `_req.to` is not address(0) + ├── when `_req.quantity` is zero + │ └── it should revert ✅ + └── when `_req.quantity` is not zero + │ + │ // case: price is zero + └── when `_req.price` is zero + │ └── when msg.value is not zero + │ │ └── it should revert ✅ + │ └── when msg.value is zero + │ └── it should mint `amount` to `to` ✅ + │ └── it should emit TokensMintedWithSignature event ✅ + │ + │ // case: price is not zero + └── when `_req.price` is not zero + └── when currency is native token + │ └── when msg.value is not equal to total price + │ │ └── it should revert ✅ + │ └── when msg.value is equal to total price + │ └── it should mint `amount` to `to` ✅ + │ └── (transfer to sale recipient) ✅ + │ └── (transfer to fee recipient) ✅ + │ └── it should emit TokensMintedWithSignature event ✅ + └── when currency is some ERC20 token + └── when msg.value is not zero + │ └── it should revert ✅ + └── when msg.value is zero + └── it should mint `amount` to `to` ✅ + └── (transfer to sale recipient) ✅ + └── (transfer to fee recipient) ✅ + └── it should emit TokensMintedWithSignature event ✅ + +// other cases + + + diff --git a/src/test/tokenerc20-BTT/other-functions/other.t.sol b/src/test/tokenerc20-BTT/other-functions/other.t.sol new file mode 100644 index 000000000..70b62669e --- /dev/null +++ b/src/test/tokenerc20-BTT/other-functions/other.t.sol @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "../../utils/BaseTest.sol"; +import { IStaking20 } from "contracts/extension/interface/IStaking20.sol"; + +import "@openzeppelin/contracts-upgradeable/access/IAccessControlEnumerableUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/access/IAccessControlUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/token/ERC20/IERC20Upgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/utils/introspection/IERC165Upgradeable.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +contract MyTokenERC20 is TokenERC20 { + function beforeTokenTransfer(address from, address to, uint256 amount) external { + _beforeTokenTransfer(from, to, amount); + } + + function mint(address account, uint256 amount) external { + _mint(account, amount); + } + + function burn(address account, uint256 amount) external { + _burn(account, amount); + } +} + +contract TokenERC20Test_OtherFunctions is BaseTest { + address public implementation; + address public proxy; + + MyTokenERC20 public tokenContract; + address internal caller; + + function setUp() public override { + super.setUp(); + + // Deploy implementation. + implementation = address(new MyTokenERC20()); + caller = getActor(3); + + // Deploy proxy pointing to implementaion. + vm.prank(deployer); + proxy = address( + new TWProxy( + implementation, + abi.encodeCall( + TokenERC20.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + platformFeeRecipient, + platformFeeBps + ) + ) + ) + ); + + tokenContract = MyTokenERC20(proxy); + } + + function test_contractType() public { + assertEq(tokenContract.contractType(), bytes32("TokenERC20")); + } + + function test_contractVersion() public { + assertEq(tokenContract.contractVersion(), uint8(1)); + } + + function test_beforeTokenTransfer_restricted_notTransferRole() public { + vm.prank(deployer); + tokenContract.revokeRole(keccak256("TRANSFER_ROLE"), address(0)); + vm.expectRevert("transfers restricted."); + tokenContract.beforeTokenTransfer(caller, address(0x123), 100); + } + + modifier whenTransferRole() { + vm.prank(deployer); + tokenContract.grantRole(keccak256("TRANSFER_ROLE"), caller); + _; + } + + function test_beforeTokenTransfer_restricted() public whenTransferRole { + tokenContract.beforeTokenTransfer(caller, address(0x123), 100); + } + + function test_mint() public { + tokenContract.mint(caller, 100); + assertEq(tokenContract.balanceOf(caller), 100); + } + + function test_burn() public { + tokenContract.mint(caller, 100); + assertEq(tokenContract.balanceOf(caller), 100); + + tokenContract.burn(caller, 60); + assertEq(tokenContract.balanceOf(caller), 40); + } +} diff --git a/src/test/tokenerc20-BTT/other-functions/other.tree b/src/test/tokenerc20-BTT/other-functions/other.tree new file mode 100644 index 000000000..57a1466a8 --- /dev/null +++ b/src/test/tokenerc20-BTT/other-functions/other.tree @@ -0,0 +1,20 @@ +contractType() +├── it should return bytes32("TokenERC20") ✅ + +contractVersion() +├── it should return uint8(1) ✅ + +_beforeTokenTransfers( + address from, + address to, + uint256 amount +) +├── when transfers are restricted (i.e. address(0) doesn't have transfer role, or from-to addresses are not address(0) + └── when from and to don't have transfer role + │ └── it should revert ✅ + +_mint(address account, uint256 amount) +├── it should mint amount to account ✅ + +_burn(address account, uint256 amount) +├── it should mint amount from account ✅ diff --git a/src/test/tokenerc20-BTT/set-contract-uri/setContractURI.t.sol b/src/test/tokenerc20-BTT/set-contract-uri/setContractURI.t.sol new file mode 100644 index 000000000..faccf3f9b --- /dev/null +++ b/src/test/tokenerc20-BTT/set-contract-uri/setContractURI.t.sol @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "../../utils/BaseTest.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +contract MyTokenERC20 is TokenERC20 {} + +contract TokenERC20Test_SetContractURI is BaseTest { + address public implementation; + address public proxy; + address internal caller; + string internal _contractURI; + + MyTokenERC20 internal tokenContract; + + function setUp() public override { + super.setUp(); + + // Deploy implementation. + implementation = address(new MyTokenERC20()); + + caller = getActor(1); + + // Deploy proxy pointing to implementaion. + vm.prank(deployer); + proxy = address( + new TWProxy( + implementation, + abi.encodeCall( + TokenERC20.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + platformFeeRecipient, + platformFeeBps + ) + ) + ) + ); + + tokenContract = MyTokenERC20(proxy); + _contractURI = "ipfs://contracturi"; + } + + function test_setContractURI_callerNotAuthorized() public { + vm.prank(address(caller)); + vm.expectRevert( + abi.encodePacked( + "AccessControl: account ", + Strings.toHexString(uint160(caller), 20), + " is missing role ", + Strings.toHexString(uint256(0), 32) + ) + ); + tokenContract.setContractURI(_contractURI); + } + + modifier whenCallerAuthorized() { + vm.prank(deployer); + tokenContract.grantRole(bytes32(0x00), caller); + _; + } + + function test_setContractURI_empty() public whenCallerAuthorized { + vm.prank(address(caller)); + tokenContract.setContractURI(""); + + // get contract uri + assertEq(tokenContract.contractURI(), ""); + } + + function test_setContractURI_notEmpty() public whenCallerAuthorized { + vm.prank(address(caller)); + tokenContract.setContractURI(_contractURI); + + // get contract uri + assertEq(tokenContract.contractURI(), _contractURI); + } +} diff --git a/src/test/tokenerc20-BTT/set-contract-uri/setContractURI.tree b/src/test/tokenerc20-BTT/set-contract-uri/setContractURI.tree new file mode 100644 index 000000000..8fc480b19 --- /dev/null +++ b/src/test/tokenerc20-BTT/set-contract-uri/setContractURI.tree @@ -0,0 +1,8 @@ +setContractURI(string calldata _uri) +├── when caller not authorized + │ └── it should revert ✅ + └── when caller is authorized + └── when `uri` is empty + │ └── it should update contract URI to empty string ✅ + └── when `uri` is not empty + └── it should update contract URI to `_uri` ✅ \ No newline at end of file diff --git a/src/test/tokenerc20-BTT/set-platform-fee-info/setPlatformFeeInfo.t.sol b/src/test/tokenerc20-BTT/set-platform-fee-info/setPlatformFeeInfo.t.sol new file mode 100644 index 000000000..d2a14a7f1 --- /dev/null +++ b/src/test/tokenerc20-BTT/set-platform-fee-info/setPlatformFeeInfo.t.sol @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "../../utils/BaseTest.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +contract MyTokenERC20 is TokenERC20 {} + +contract TokenERC20Test_SetPlatformFeeInfo is BaseTest { + address public implementation; + address public proxy; + address internal caller; + address internal _platformFeeRecipient; + uint256 internal _platformFeeBps; + + MyTokenERC20 internal tokenContract; + + event PlatformFeeInfoUpdated(address indexed platformFeeRecipient, uint256 platformFeeBps); + + function setUp() public override { + super.setUp(); + + // Deploy implementation. + implementation = address(new MyTokenERC20()); + + caller = getActor(1); + _platformFeeRecipient = getActor(2); + + // Deploy proxy pointing to implementaion. + vm.prank(deployer); + proxy = address( + new TWProxy( + implementation, + abi.encodeCall( + TokenERC20.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + platformFeeRecipient, + platformFeeBps + ) + ) + ) + ); + + tokenContract = MyTokenERC20(proxy); + } + + function test_setPlatformFeeInfo_callerNotAuthorized() public { + vm.prank(address(caller)); + vm.expectRevert( + abi.encodePacked( + "AccessControl: account ", + Strings.toHexString(uint160(caller), 20), + " is missing role ", + Strings.toHexString(uint256(0), 32) + ) + ); + tokenContract.setPlatformFeeInfo(_platformFeeRecipient, _platformFeeBps); + } + + modifier whenCallerAuthorized() { + vm.prank(deployer); + tokenContract.grantRole(bytes32(0x00), caller); + _; + } + + function test_setPlatformFeeInfo_exceedMaxBps() public whenCallerAuthorized { + _platformFeeBps = 10_001; + vm.prank(address(caller)); + vm.expectRevert("exceeds MAX_BPS"); + tokenContract.setPlatformFeeInfo(_platformFeeRecipient, _platformFeeBps); + } + + modifier whenNotExceedMaxBps() { + _platformFeeBps = 500; + _; + } + + function test_setPlatformFeeInfo() public whenCallerAuthorized whenNotExceedMaxBps { + vm.prank(address(caller)); + tokenContract.setPlatformFeeInfo(_platformFeeRecipient, _platformFeeBps); + + // get platform fee info + (address _recipient, uint16 _bps) = tokenContract.getPlatformFeeInfo(); + assertEq(_recipient, _platformFeeRecipient); + assertEq(_bps, uint16(_platformFeeBps)); + } + + function test_setPlatformFeeInfo_event() public whenCallerAuthorized whenNotExceedMaxBps { + vm.prank(address(caller)); + vm.expectEmit(true, false, false, true); + emit PlatformFeeInfoUpdated(_platformFeeRecipient, _platformFeeBps); + tokenContract.setPlatformFeeInfo(_platformFeeRecipient, _platformFeeBps); + } +} diff --git a/src/test/tokenerc20-BTT/set-platform-fee-info/setPlatformFeeInfo.tree b/src/test/tokenerc20-BTT/set-platform-fee-info/setPlatformFeeInfo.tree new file mode 100644 index 000000000..dcef9965e --- /dev/null +++ b/src/test/tokenerc20-BTT/set-platform-fee-info/setPlatformFeeInfo.tree @@ -0,0 +1,10 @@ +setPlatformFeeInfo(address _platformFeeRecipient, uint256 _platformFeeBps) +├── when caller not authorized + │ └── it should revert ✅ + └── when caller is authorized + ├── when `_platformFeeBps` is greater than MAX_BPS + │ └── it should revert ✅ + └── when `_platformFeeBps` is less than or equal to MAX_BPS + └── it should update platform fee recipient ✅ + └── it should update platform fee bps ✅ + └── it should emit PlatformFeeInfoUpdated event ✅ \ No newline at end of file diff --git a/src/test/tokenerc20-BTT/set-primary-sale-recipient/setPrimarySaleRecipient.t.sol b/src/test/tokenerc20-BTT/set-primary-sale-recipient/setPrimarySaleRecipient.t.sol new file mode 100644 index 000000000..070f9bfaf --- /dev/null +++ b/src/test/tokenerc20-BTT/set-primary-sale-recipient/setPrimarySaleRecipient.t.sol @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "../../utils/BaseTest.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +contract MyTokenERC20 is TokenERC20 {} + +contract TokenERC20Test_SetPrimarySaleRecipient is BaseTest { + address public implementation; + address public proxy; + address internal caller; + address internal _primarySaleRecipient; + + MyTokenERC20 internal tokenContract; + + event PrimarySaleRecipientUpdated(address indexed recipient); + + function setUp() public override { + super.setUp(); + + // Deploy implementation. + implementation = address(new MyTokenERC20()); + + caller = getActor(1); + _primarySaleRecipient = getActor(2); + + // Deploy proxy pointing to implementaion. + vm.prank(deployer); + proxy = address( + new TWProxy( + implementation, + abi.encodeCall( + TokenERC20.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + platformFeeRecipient, + platformFeeBps + ) + ) + ) + ); + + tokenContract = MyTokenERC20(proxy); + } + + function test_setPrimarySaleRecipient_callerNotAuthorized() public { + vm.prank(address(caller)); + vm.expectRevert( + abi.encodePacked( + "AccessControl: account ", + Strings.toHexString(uint160(caller), 20), + " is missing role ", + Strings.toHexString(uint256(0), 32) + ) + ); + tokenContract.setPrimarySaleRecipient(_primarySaleRecipient); + } + + modifier whenCallerAuthorized() { + vm.prank(deployer); + tokenContract.grantRole(bytes32(0x00), caller); + _; + } + + function test_setPrimarySaleRecipient() public whenCallerAuthorized { + vm.prank(address(caller)); + tokenContract.setPrimarySaleRecipient(_primarySaleRecipient); + + // get primary sale recipient info + assertEq(tokenContract.primarySaleRecipient(), _primarySaleRecipient); + } + + function test_setPrimarySaleRecipient_event() public whenCallerAuthorized { + vm.prank(address(caller)); + vm.expectEmit(true, false, false, false); + emit PrimarySaleRecipientUpdated(_primarySaleRecipient); + tokenContract.setPrimarySaleRecipient(_primarySaleRecipient); + } +} diff --git a/src/test/tokenerc20-BTT/set-primary-sale-recipient/setPrimarySaleRecipient.tree b/src/test/tokenerc20-BTT/set-primary-sale-recipient/setPrimarySaleRecipient.tree new file mode 100644 index 000000000..230035a07 --- /dev/null +++ b/src/test/tokenerc20-BTT/set-primary-sale-recipient/setPrimarySaleRecipient.tree @@ -0,0 +1,6 @@ +setPrimarySaleRecipient(address _saleRecipient) +├── when caller not authorized + │ └── it should revert ✅ + └── when caller is authorized + └── it should update primary sale recipient ✅ + └── it should emit PrimarySaleRecipientUpdated event ✅ \ No newline at end of file diff --git a/src/test/tokenerc20-BTT/verify/verify.t.sol b/src/test/tokenerc20-BTT/verify/verify.t.sol new file mode 100644 index 000000000..fb54c7868 --- /dev/null +++ b/src/test/tokenerc20-BTT/verify/verify.t.sol @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "../../utils/BaseTest.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +contract MyTokenERC20 is TokenERC20 { + function setMintedUID(MintRequest calldata _req, bytes calldata _signature) external { + verifyRequest(_req, _signature); + } +} + +contract TokenERC20Test_Verify is BaseTest { + address public implementation; + address public proxy; + + MyTokenERC20 internal tokenContract; + bytes32 internal typehashMintRequest; + bytes32 internal nameHash; + bytes32 internal versionHash; + bytes32 internal typehashEip712; + bytes32 internal domainSeparator; + + TokenERC20.MintRequest _mintrequest; + + function setUp() public override { + super.setUp(); + + // Deploy implementation. + implementation = address(new MyTokenERC20()); + + // Deploy proxy pointing to implementaion. + vm.prank(deployer); + proxy = address( + new TWProxy( + implementation, + abi.encodeCall( + TokenERC20.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + platformFeeRecipient, + platformFeeBps + ) + ) + ) + ); + + tokenContract = MyTokenERC20(proxy); + + typehashMintRequest = keccak256( + "MintRequest(address to,address primarySaleRecipient,uint256 quantity,uint256 price,address currency,uint128 validityStartTimestamp,uint128 validityEndTimestamp,bytes32 uid)" + ); + nameHash = keccak256(bytes(NAME)); + versionHash = keccak256(bytes("1")); + typehashEip712 = keccak256( + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" + ); + domainSeparator = keccak256( + abi.encode(typehashEip712, nameHash, versionHash, block.chainid, address(tokenContract)) + ); + + // construct default mintrequest + _mintrequest.to = address(123); + _mintrequest.primarySaleRecipient = saleRecipient; + _mintrequest.quantity = 100; + _mintrequest.price = 0; + _mintrequest.currency = address(0); + _mintrequest.validityStartTimestamp = 0; + _mintrequest.validityEndTimestamp = 2000; + _mintrequest.uid = bytes32(0); + } + + function signMintRequest( + TokenERC20.MintRequest memory _request, + uint256 _privateKey + ) internal view returns (bytes memory) { + bytes memory encodedRequest = abi.encode( + typehashMintRequest, + _request.to, + _request.primarySaleRecipient, + _request.quantity, + _request.price, + _request.currency, + _request.validityStartTimestamp, + _request.validityEndTimestamp, + _request.uid + ); + bytes32 structHash = keccak256(encodedRequest); + bytes32 typedDataHash = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(_privateKey, typedDataHash); + bytes memory sig = abi.encodePacked(r, s, v); + + return sig; + } + + function test_verify_notMinterRole() public { + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + (bool _isValid, address _recoveredSigner) = tokenContract.verify(_mintrequest, _signature); + + assertFalse(_isValid); + assertEq(_recoveredSigner, signer); + } + + modifier whenMinterRole() { + vm.prank(deployer); + tokenContract.grantRole(keccak256("MINTER_ROLE"), signer); + _; + } + + function test_verify_invalidUID() public whenMinterRole { + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + // set state with this mintrequest and signature, marking the UID as used + tokenContract.setMintedUID(_mintrequest, _signature); + + // pass the same UID mintrequest again + (bool _isValid, address _recoveredSigner) = tokenContract.verify(_mintrequest, _signature); + + assertFalse(_isValid); + assertEq(_recoveredSigner, signer); + } + + modifier whenUidNotUsed() { + _; + } + + function test_verify() public whenMinterRole whenUidNotUsed { + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + (bool _isValid, address _recoveredSigner) = tokenContract.verify(_mintrequest, _signature); + + assertTrue(_isValid); + assertEq(_recoveredSigner, signer); + } +} diff --git a/src/test/tokenerc20-BTT/verify/verify.tree b/src/test/tokenerc20-BTT/verify/verify.tree new file mode 100644 index 000000000..c160faa0f --- /dev/null +++ b/src/test/tokenerc20-BTT/verify/verify.tree @@ -0,0 +1,12 @@ +verify(MintRequest calldata _req, bytes calldata _signature) +├── when signer doesn't have MINTER_ROLE +│ └── it should return false ✅ +│ └── it should return recovered signer equal to the actual signer of the request ✅ +└── when signer has MINTER_ROLE + └── when `_req.uid` has already been used + │ └── it should return false ✅ + │ └── it should return recovered signer equal to the actual signer of the request ✅ + └── when `_req.uid` has not been used + └── it should return true ✅ + └── it should return recovered signer equal to the actual signer of the request ✅ + diff --git a/src/test/tokenerc721-BTT/burn/burn.t.sol b/src/test/tokenerc721-BTT/burn/burn.t.sol new file mode 100644 index 000000000..01fa7b43c --- /dev/null +++ b/src/test/tokenerc721-BTT/burn/burn.t.sol @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "../../utils/BaseTest.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +contract MyTokenERC721 is TokenERC721 {} + +contract TokenERC721Test_Burn is BaseTest { + address public implementation; + address public proxy; + address public caller; + address public recipient; + string public uri; + + MyTokenERC721 internal tokenContract; + + event MetadataUpdate(uint256 _tokenId); + event TokensMinted(address indexed mintedTo, uint256 indexed tokenIdMinted, string uri); + + function setUp() public override { + super.setUp(); + + // Deploy implementation. + implementation = address(new MyTokenERC721()); + caller = getActor(1); + recipient = getActor(2); + + // Deploy proxy pointing to implementaion. + vm.prank(deployer); + proxy = address( + new TWProxy( + implementation, + abi.encodeCall( + TokenERC721.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ) + ); + + tokenContract = MyTokenERC721(proxy); + uri = "uri"; + + vm.prank(deployer); + tokenContract.grantRole(keccak256("MINTER_ROLE"), caller); + } + + function test_burn_whenNotOwnerNorApproved() public { + // state before + uint256 _tokenIdToMint = tokenContract.nextTokenIdToMint(); + + // mint + vm.prank(caller); + uint256 _tokenId = tokenContract.mintTo(recipient, uri); + + // burn + vm.expectRevert("ERC721Burnable: caller is not owner nor approved"); + tokenContract.burn(_tokenId); + } + + function test_burn_whenOwner() public { + // state before + uint256 _tokenIdToMint = tokenContract.nextTokenIdToMint(); + + // mint + vm.prank(caller); + uint256 _tokenId = tokenContract.mintTo(recipient, uri); + + // burn + vm.prank(recipient); + tokenContract.burn(_tokenId); + + vm.expectRevert(); // checking non-existent token, because burned + tokenContract.ownerOf(_tokenId); + } + + function test_burn_whenApproved() public { + // state before + uint256 _tokenIdToMint = tokenContract.nextTokenIdToMint(); + + // mint + vm.prank(caller); + uint256 _tokenId = tokenContract.mintTo(recipient, uri); + + vm.prank(recipient); + tokenContract.setApprovalForAll(caller, true); + + // burn + vm.prank(caller); + tokenContract.burn(_tokenId); + + vm.expectRevert(); // checking non-existent token, because burned + tokenContract.ownerOf(_tokenId); + } +} diff --git a/src/test/tokenerc721-BTT/burn/burn.tree b/src/test/tokenerc721-BTT/burn/burn.tree new file mode 100644 index 000000000..0a6e2ff43 --- /dev/null +++ b/src/test/tokenerc721-BTT/burn/burn.tree @@ -0,0 +1,8 @@ +burn(uint256 tokenId) +├── when the caller isn't the owner of `tokenId` or token not approved to caller +│ └── it should revert ✅ +└── when the caller owns `tokenId` +│ └── it should burn the token ✅ +└── when the `tokenId` is approved to caller + └── it should burn the token ✅ + diff --git a/src/test/tokenerc721-BTT/initialize/initialize.t.sol b/src/test/tokenerc721-BTT/initialize/initialize.t.sol new file mode 100644 index 000000000..e16c27cf7 --- /dev/null +++ b/src/test/tokenerc721-BTT/initialize/initialize.t.sol @@ -0,0 +1,311 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "../../utils/BaseTest.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +contract MyTokenERC721 is TokenERC721 { + function eip712NameHash() external view returns (bytes32) { + return _EIP712NameHash(); + } + + function eip712VersionHash() external view returns (bytes32) { + return _EIP712VersionHash(); + } +} + +contract TokenERC721Test_Initialize is BaseTest { + address public implementation; + address public proxy; + + event RoleGranted(bytes32 indexed role, address indexed account, address indexed sender); + event RoleAdminChanged(bytes32 indexed role, bytes32 indexed previousAdminRole, bytes32 indexed newAdminRole); + + function setUp() public override { + super.setUp(); + + // Deploy implementation. + implementation = address(new MyTokenERC721()); + + // Deploy proxy pointing to implementaion. + vm.prank(deployer); + proxy = address( + new TWProxy( + implementation, + abi.encodeCall( + TokenERC721.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ) + ); + } + + function test_initialize_initializingImplementation() public { + vm.expectRevert("Initializable: contract is already initialized"); + TokenERC721(implementation).initialize( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ); + } + + modifier whenNotImplementation() { + _; + } + + function test_initialize_proxyAlreadyInitialized() public whenNotImplementation { + vm.expectRevert("Initializable: contract is already initialized"); + MyTokenERC721(proxy).initialize( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ); + } + + modifier whenProxyNotInitialized() { + proxy = address(new TWProxy(implementation, "")); + _; + } + + function test_initialize_exceedsMaxBps() public whenNotImplementation whenProxyNotInitialized { + vm.expectRevert("exceeds MAX_BPS"); + MyTokenERC721(proxy).initialize( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + uint128(MAX_BPS) + 1, // platformFeeBps greater than MAX_BPS + platformFeeRecipient + ); + } + + modifier whenPlatformFeeBpsWithinMaxBps() { + _; + } + + function test_initialize() public whenNotImplementation whenProxyNotInitialized whenPlatformFeeBpsWithinMaxBps { + MyTokenERC721(proxy).initialize( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ); + + // check state + MyTokenERC721 tokenContract = MyTokenERC721(proxy); + + assertEq(tokenContract.eip712NameHash(), keccak256(bytes("TokenERC721"))); + assertEq(tokenContract.eip712VersionHash(), keccak256(bytes("1"))); + + address[] memory _trustedForwarders = forwarders(); + for (uint256 i = 0; i < _trustedForwarders.length; i++) { + assertTrue(tokenContract.isTrustedForwarder(_trustedForwarders[i])); + } + + assertEq(tokenContract.name(), NAME); + assertEq(tokenContract.symbol(), SYMBOL); + assertEq(tokenContract.contractURI(), CONTRACT_URI); + + (address _platformFeeRecipient, uint16 _platformFeeBps) = tokenContract.getPlatformFeeInfo(); + assertEq(_platformFeeBps, platformFeeBps); + assertEq(_platformFeeRecipient, platformFeeRecipient); + assertEq(tokenContract.platformFeeRecipient(), platformFeeRecipient); + + (address _royaltyRecipient, uint16 _royaltyBps) = tokenContract.getDefaultRoyaltyInfo(); + (address _royaltyRecipientForToken, uint16 _royaltyBpsForToken) = tokenContract.getRoyaltyInfoForToken(1); // random tokenId + assertEq(_royaltyBps, royaltyBps); + assertEq(_royaltyRecipient, royaltyRecipient); + assertEq(_royaltyRecipient, _royaltyRecipientForToken); + assertEq(_royaltyBps, _royaltyBpsForToken); + + assertEq(tokenContract.primarySaleRecipient(), saleRecipient); + + assertEq(tokenContract.owner(), deployer); + assertTrue(tokenContract.hasRole(bytes32(0x00), deployer)); + assertTrue(tokenContract.hasRole(keccak256("TRANSFER_ROLE"), deployer)); + assertTrue(tokenContract.hasRole(keccak256("TRANSFER_ROLE"), address(0))); + assertTrue(tokenContract.hasRole(keccak256("MINTER_ROLE"), deployer)); + assertTrue(tokenContract.hasRole(keccak256("METADATA_ROLE"), deployer)); + assertEq(tokenContract.getRoleAdmin(keccak256("METADATA_ROLE")), keccak256("METADATA_ROLE")); + } + + function test_initialize_event_RoleGranted_DefaultAdmin() + public + whenNotImplementation + whenProxyNotInitialized + whenPlatformFeeBpsWithinMaxBps + { + bytes32 _defaultAdminRole = bytes32(0x00); + vm.prank(deployer); + vm.expectEmit(true, true, true, false); + emit RoleGranted(_defaultAdminRole, deployer, deployer); + MyTokenERC721(proxy).initialize( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ); + } + + function test_initialize_event_RoleGranted_MinterRole() + public + whenNotImplementation + whenProxyNotInitialized + whenPlatformFeeBpsWithinMaxBps + { + bytes32 _minterRole = keccak256("MINTER_ROLE"); + vm.prank(deployer); + vm.expectEmit(true, true, true, false); + emit RoleGranted(_minterRole, deployer, deployer); + MyTokenERC721(proxy).initialize( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ); + } + + function test_initialize_event_RoleGranted_TransferRole() + public + whenNotImplementation + whenProxyNotInitialized + whenPlatformFeeBpsWithinMaxBps + { + bytes32 _transferRole = keccak256("TRANSFER_ROLE"); + vm.prank(deployer); + vm.expectEmit(true, true, true, false); + emit RoleGranted(_transferRole, deployer, deployer); + MyTokenERC721(proxy).initialize( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ); + } + + function test_initialize_event_RoleGranted_TransferRole_AddressZero() + public + whenNotImplementation + whenProxyNotInitialized + whenPlatformFeeBpsWithinMaxBps + { + bytes32 _transferRole = keccak256("TRANSFER_ROLE"); + vm.prank(deployer); + vm.expectEmit(true, true, true, false); + emit RoleGranted(_transferRole, address(0), deployer); + MyTokenERC721(proxy).initialize( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ); + } + + function test_initialize_event_RoleGranted_MetadataRole() + public + whenNotImplementation + whenProxyNotInitialized + whenPlatformFeeBpsWithinMaxBps + { + bytes32 _metadataRole = keccak256("METADATA_ROLE"); + vm.prank(deployer); + vm.expectEmit(true, true, true, false); + emit RoleGranted(_metadataRole, deployer, deployer); + MyTokenERC721(proxy).initialize( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ); + } + + function test_initialize_event_RoleAdminChanged_MetadataRole() + public + whenNotImplementation + whenProxyNotInitialized + whenPlatformFeeBpsWithinMaxBps + { + bytes32 _metadataRole = keccak256("METADATA_ROLE"); + vm.prank(deployer); + vm.expectEmit(true, true, true, false); + emit RoleAdminChanged(_metadataRole, bytes32(0x00), _metadataRole); + MyTokenERC721(proxy).initialize( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ); + } +} diff --git a/src/test/tokenerc721-BTT/initialize/initialize.tree b/src/test/tokenerc721-BTT/initialize/initialize.tree new file mode 100644 index 000000000..041a9e248 --- /dev/null +++ b/src/test/tokenerc721-BTT/initialize/initialize.tree @@ -0,0 +1,42 @@ +initialize( + address _defaultAdmin, + string memory _name, + string memory _symbol, + string memory _contractURI, + address[] memory _trustedForwarders, + address _saleRecipient, + address _royaltyRecipient, + uint128 _royaltyBps, + uint128 _platformFeeBps, + address _platformFeeRecipient +) +├── when initializing the implementation contract (not proxy) +│ └── it should revert ✅ +└── when it is a proxy to the implementation + └── when it is already initialized + │ └── it should revert ✅ + └── when it is not initialized + └── when platformFeeBps is greater than MAX_BPS + │ └── it should revert ✅ + └── when platformFeeBps is less than or equal to MAX_BPS + └── it should correctly set EIP712 name hash and version hash ✅ + └── it should set trustedForwarder mapping to true for all addresses in `_trustedForwarders` ✅ + └── it should set _name and _symbol to `_name` and `_symbol` param values respectively ✅ + └── it should set contractURI to `_contractURI` param value ✅ + └── it should set platformFeeRecipient and platformFeeBps as `_platformFeeRecipient` and `_platformFeeBps` respectively ✅ + └── it should set royaltyRecipient and royaltyBps as `_royaltyRecipient` and `_royaltyBps` respectively ✅ + └── it should set primary sale recipient as `_saleRecipient` param value ✅ + └── it should set _owner to `_defaultAdmin` param value ✅ + └── it should grant 0x00 (DEFAULT_ADMIN_ROLE) to `_defaultAdmin` address ✅ + └── it should emit RoleGranted event ✅ + └── it should grant MINTER_ROLE to `_defaultAdmin` address ✅ + └── it should emit RoleGranted event ✅ + └── it should grant TRANSFER_ROLE to `_defaultAdmin` address ✅ + └── it should emit RoleGranted event ✅ + └── it should grant TRANSFER_ROLE to address(0) ✅ + └── it should emit RoleGranted event ✅ + └── it should grant METADATA_ROLE to `_defaultAdmin` address ✅ + └── it should emit RoleGranted event ✅ + └── it should set METADATA_ROLE as role admin for METADATA_ROLE ✅ + └── it should emit RoleAdminChanged event ✅ + diff --git a/src/test/tokenerc721-BTT/mint-to/mintTo.t.sol b/src/test/tokenerc721-BTT/mint-to/mintTo.t.sol new file mode 100644 index 000000000..4c9900e4c --- /dev/null +++ b/src/test/tokenerc721-BTT/mint-to/mintTo.t.sol @@ -0,0 +1,184 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "../../utils/BaseTest.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +contract MyTokenERC721 is TokenERC721 {} + +contract ERC721ReceiverCompliant is IERC721Receiver { + function onERC721Received( + address, + address, + uint256, + bytes calldata + ) external view virtual override returns (bytes4) { + return this.onERC721Received.selector; + } +} + +contract TokenERC721Test_MintTo is BaseTest { + address public implementation; + address public proxy; + address public caller; + address public recipient; + string public uri; + + MyTokenERC721 internal tokenContract; + ERC721ReceiverCompliant internal erc721ReceiverContract; + + event MetadataUpdate(uint256 _tokenId); + event TokensMinted(address indexed mintedTo, uint256 indexed tokenIdMinted, string uri); + + function setUp() public override { + super.setUp(); + + // Deploy implementation. + implementation = address(new MyTokenERC721()); + erc721ReceiverContract = new ERC721ReceiverCompliant(); + caller = getActor(1); + recipient = getActor(2); + + // Deploy proxy pointing to implementaion. + vm.prank(deployer); + proxy = address( + new TWProxy( + implementation, + abi.encodeCall( + TokenERC721.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ) + ); + + tokenContract = MyTokenERC721(proxy); + } + + function test_mintTo_notMinterRole() public { + vm.prank(caller); + vm.expectRevert( + abi.encodePacked( + "AccessControl: account ", + Strings.toHexString(uint160(caller), 20), + " is missing role ", + Strings.toHexString(uint256(keccak256("MINTER_ROLE")), 32) + ) + ); + tokenContract.mintTo(recipient, uri); + } + + modifier whenMinterRole() { + vm.prank(deployer); + tokenContract.grantRole(keccak256("MINTER_ROLE"), caller); + _; + } + + function test_mintTo_emptyUri() public whenMinterRole { + vm.prank(caller); + vm.expectRevert("empty uri."); + tokenContract.mintTo(recipient, uri); + } + + modifier whenNotEmptyUri() { + uri = "ipfs://uri/1"; + _; + } + + // ================== + // ======= Test branch: recipient EOA + // ================== + + function test_mintTo_EOA() public whenMinterRole whenNotEmptyUri { + // state before + uint256 _tokenIdToMint = tokenContract.nextTokenIdToMint(); + + // mint + vm.prank(caller); + uint256 _tokenId = tokenContract.mintTo(recipient, uri); + + // check state after + assertEq(_tokenId, _tokenIdToMint); + assertEq(tokenContract.tokenURI(_tokenId), uri); + assertEq(tokenContract.nextTokenIdToMint(), _tokenIdToMint + 1); + assertEq(tokenContract.ownerOf(_tokenId), recipient); + } + + function test_mintTo_EOA_MetadataUpdateEvent() public whenMinterRole whenNotEmptyUri { + uint256 _tokenIdToMint = tokenContract.nextTokenIdToMint(); + + vm.prank(caller); + vm.expectEmit(false, false, false, true); + emit MetadataUpdate(_tokenIdToMint); + tokenContract.mintTo(recipient, uri); + } + + function test_mintTo_EOA_TokensMintedEvent() public whenMinterRole whenNotEmptyUri { + uint256 _tokenIdToMint = tokenContract.nextTokenIdToMint(); + + vm.prank(caller); + vm.expectEmit(true, true, false, true); + emit TokensMinted(recipient, _tokenIdToMint, uri); + tokenContract.mintTo(recipient, uri); + } + + // ================== + // ======= Test branch: recipient is a contract + // ================== + + function test_mintTo_nonERC721ReceiverContract() public whenMinterRole whenNotEmptyUri { + recipient = address(this); + vm.prank(caller); + vm.expectRevert(); + uint256 _tokenId = tokenContract.mintTo(recipient, uri); + } + + modifier whenERC721Receiver() { + recipient = address(erc721ReceiverContract); + _; + } + + function test_mintTo_contract() public whenMinterRole whenNotEmptyUri whenERC721Receiver { + // state before + uint256 _tokenIdToMint = tokenContract.nextTokenIdToMint(); + + // mint + vm.prank(caller); + uint256 _tokenId = tokenContract.mintTo(recipient, uri); + + // check state after + assertEq(_tokenId, _tokenIdToMint); + assertEq(tokenContract.tokenURI(_tokenId), uri); + assertEq(tokenContract.nextTokenIdToMint(), _tokenIdToMint + 1); + assertEq(tokenContract.ownerOf(_tokenId), recipient); + } + + function test_mintTo_contract_MetadataUpdateEvent() public whenMinterRole whenNotEmptyUri whenERC721Receiver { + uint256 _tokenIdToMint = tokenContract.nextTokenIdToMint(); + + vm.prank(caller); + vm.expectEmit(false, false, false, true); + emit MetadataUpdate(_tokenIdToMint); + tokenContract.mintTo(recipient, uri); + } + + function test_mintTo_contract_TokensMintedEvent() public whenMinterRole whenNotEmptyUri whenERC721Receiver { + uint256 _tokenIdToMint = tokenContract.nextTokenIdToMint(); + + vm.prank(caller); + vm.expectEmit(true, true, false, true); + emit TokensMinted(recipient, _tokenIdToMint, uri); + tokenContract.mintTo(recipient, uri); + } +} diff --git a/src/test/tokenerc721-BTT/mint-to/mintTo.tree b/src/test/tokenerc721-BTT/mint-to/mintTo.tree new file mode 100644 index 000000000..408dd48c9 --- /dev/null +++ b/src/test/tokenerc721-BTT/mint-to/mintTo.tree @@ -0,0 +1,25 @@ +mintTo(address _to, string calldata _uri) +├── when caller doesn't have MINTER_ROLE + │ └── it should revert ✅ + └── when caller has MINTER_ROLE + ├── when `_uri` is empty i.e. length is zero + │ └── it should revert ✅ + └── when `_uri` is not empty + ├── when `_to` address is an EOA + │ └── it should mint tokenId equal to current value of `nextTokenIdToMint` ✅ + │ └── it should set tokenURI for minted tokenId equal to `_uri` ✅ + │ └── it should increment `nextTokenIdToMint` by 1 ✅ + │ └── it should mint the tokenId to the `_to` address ✅ + │ └── it should emit MetadataUpdate event ✅ + │ └── it should emit TokensMinted event ✅ + └── when `_to` address is a contract + ├── when `_to` address is non ERC721Receiver implementer + │ └── it should revert ✅ + └── when `_to` address implements ERC721Receiver + └── it should mint tokenId equal to current value of `nextTokenIdToMint` ✅ + └── it should set tokenURI for minted tokenId equal to `_uri` ✅ + └── it should increment `nextTokenIdToMint` by 1 ✅ + └── it should mint the tokenId to the `_to` address ✅ + └── it should emit MetadataUpdate event ✅ + └── it should emit TokensMinted event ✅ + diff --git a/src/test/tokenerc721-BTT/mint-with-signature/mintWithSignature.t.sol b/src/test/tokenerc721-BTT/mint-with-signature/mintWithSignature.t.sol new file mode 100644 index 000000000..4ad653f7b --- /dev/null +++ b/src/test/tokenerc721-BTT/mint-with-signature/mintWithSignature.t.sol @@ -0,0 +1,717 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "../../utils/BaseTest.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +contract MyTokenERC721 is TokenERC721 { + function setMintedURI(MintRequest calldata _req, bytes calldata _signature) external { + verifyRequest(_req, _signature); + } +} + +contract ERC721ReceiverCompliant is IERC721Receiver { + function onERC721Received( + address, + address, + uint256, + bytes calldata + ) external view virtual override returns (bytes4) { + return this.onERC721Received.selector; + } +} + +contract ReentrantContract { + fallback() external payable { + TokenERC721.MintRequest memory _mintrequest; + bytes memory _signature; + MyTokenERC721(msg.sender).mintWithSignature(_mintrequest, _signature); + } +} + +contract TokenERC721Test_MintWithSignature is BaseTest { + address public implementation; + address public proxy; + address public caller; + address public recipient; + string public uri; + + MyTokenERC721 internal tokenContract; + ERC721ReceiverCompliant internal erc721ReceiverContract; + + bytes32 internal typehashMintRequest; + bytes32 internal nameHash; + bytes32 internal versionHash; + bytes32 internal typehashEip712; + bytes32 internal domainSeparator; + + TokenERC721.MintRequest _mintrequest; + + event MetadataUpdate(uint256 _tokenId); + event TokensMinted(address indexed mintedTo, uint256 indexed tokenIdMinted, string uri); + event TokensMintedWithSignature( + address indexed signer, + address indexed mintedTo, + uint256 indexed tokenIdMinted, + TokenERC721.MintRequest mintRequest + ); + + function setUp() public override { + super.setUp(); + + // Deploy implementation. + implementation = address(new MyTokenERC721()); + erc721ReceiverContract = new ERC721ReceiverCompliant(); + caller = getActor(1); + recipient = getActor(2); + + // Deploy proxy pointing to implementaion. + vm.prank(deployer); + proxy = address( + new TWProxy( + implementation, + abi.encodeCall( + TokenERC721.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ) + ); + + tokenContract = MyTokenERC721(proxy); + + typehashMintRequest = keccak256( + "MintRequest(address to,address royaltyRecipient,uint256 royaltyBps,address primarySaleRecipient,string uri,uint256 price,address currency,uint128 validityStartTimestamp,uint128 validityEndTimestamp,bytes32 uid)" + ); + nameHash = keccak256(bytes("TokenERC721")); + versionHash = keccak256(bytes("1")); + typehashEip712 = keccak256( + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" + ); + domainSeparator = keccak256( + abi.encode(typehashEip712, nameHash, versionHash, block.chainid, address(tokenContract)) + ); + + // construct default mintrequest + _mintrequest.to = address(0x1234); + _mintrequest.royaltyRecipient = royaltyRecipient; + _mintrequest.royaltyBps = royaltyBps; + _mintrequest.primarySaleRecipient = saleRecipient; + _mintrequest.uri = "ipfs://"; + _mintrequest.price = 0; + _mintrequest.currency = address(0); + _mintrequest.validityStartTimestamp = 0; + _mintrequest.validityEndTimestamp = 2000; + _mintrequest.uid = bytes32(0); + + erc20.mint(deployer, 1_000 ether); + vm.deal(deployer, 1_000 ether); + erc20.mint(caller, 1_000 ether); + vm.deal(caller, 1_000 ether); + + vm.startPrank(deployer); + erc20.approve(address(tokenContract), type(uint256).max); + vm.stopPrank(); + + vm.startPrank(caller); + erc20.approve(address(tokenContract), type(uint256).max); + vm.stopPrank(); + } + + function signMintRequest( + TokenERC721.MintRequest memory _request, + uint256 _privateKey + ) internal view returns (bytes memory) { + bytes memory encodedRequest = abi.encode( + typehashMintRequest, + _request.to, + _request.royaltyRecipient, + _request.royaltyBps, + _request.primarySaleRecipient, + keccak256(bytes(_request.uri)), + _request.price, + _request.currency, + _request.validityStartTimestamp, + _request.validityEndTimestamp, + _request.uid + ); + bytes32 structHash = keccak256(encodedRequest); + bytes32 typedDataHash = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(_privateKey, typedDataHash); + bytes memory sig = abi.encodePacked(r, s, v); + + return sig; + } + + function test_mintWithSignature_notMinterRole() public { + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + vm.prank(caller); + vm.expectRevert("invalid signature"); + tokenContract.mintWithSignature(_mintrequest, _signature); + } + + modifier whenMinterRole() { + vm.prank(deployer); + tokenContract.grantRole(keccak256("MINTER_ROLE"), signer); + _; + } + + function test_mintWithSignature_invalidUID() public whenMinterRole { + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + // set state with this mintrequest and signature, marking the UID as used + tokenContract.setMintedURI(_mintrequest, _signature); + + // pass the same UID mintrequest again + vm.prank(caller); + vm.expectRevert("invalid signature"); + tokenContract.mintWithSignature(_mintrequest, _signature); + } + + modifier whenUidNotUsed() { + _; + } + + function test_mintWithSignature_invalidStartTimestamp() public whenMinterRole whenUidNotUsed { + _mintrequest.validityStartTimestamp = uint128(block.timestamp + 1); + + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + vm.expectRevert("request expired"); + vm.prank(caller); + tokenContract.mintWithSignature(_mintrequest, _signature); + } + + modifier whenValidStartTimestamp() { + _; + } + + function test_mintWithSignature_invalidEndTimestamp() public whenMinterRole whenUidNotUsed whenValidStartTimestamp { + _mintrequest.validityEndTimestamp = uint128(block.timestamp - 1); + + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + vm.expectRevert("request expired"); + vm.prank(caller); + tokenContract.mintWithSignature(_mintrequest, _signature); + } + + modifier whenValidEndTimestamp() { + _; + } + + function test_mintWithSignature_recipientAddressZero() + public + whenMinterRole + whenUidNotUsed + whenValidStartTimestamp + whenValidEndTimestamp + { + _mintrequest.to = address(0); + + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + vm.expectRevert("recipient undefined"); + vm.prank(caller); + tokenContract.mintWithSignature(_mintrequest, _signature); + } + + modifier whenRecipientAddressNotZero() { + _; + } + + function test_mintWithSignature_emptyUri() + public + whenMinterRole + whenUidNotUsed + whenValidStartTimestamp + whenValidEndTimestamp + whenRecipientAddressNotZero + { + _mintrequest.uri = ""; + + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + vm.expectRevert("empty uri."); + vm.prank(caller); + tokenContract.mintWithSignature(_mintrequest, _signature); + } + + modifier whenNotEmptyUri() { + _; + } + + // ================== + // ======= Test branch: when mint price is zero + // ================== + + function test_mintWithSignature_zeroPrice_msgValueNonZero() + public + whenMinterRole + whenUidNotUsed + whenValidStartTimestamp + whenValidEndTimestamp + whenRecipientAddressNotZero + whenNotEmptyUri + { + _mintrequest.price = 0; + + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + vm.expectRevert("!Value"); + vm.prank(caller); + tokenContract.mintWithSignature{ value: 1 }(_mintrequest, _signature); + } + + modifier whenMsgValueZero() { + _; + } + + function test_mintWithSignature_zeroPrice_EOA() + public + whenMinterRole + whenUidNotUsed + whenValidStartTimestamp + whenValidEndTimestamp + whenRecipientAddressNotZero + whenNotEmptyUri + whenMsgValueZero + { + _mintrequest.price = 0; + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + // state before + uint256 _tokenIdToMint = tokenContract.nextTokenIdToMint(); + + // mint + vm.prank(caller); + uint256 _tokenId = tokenContract.mintWithSignature(_mintrequest, _signature); + + // check state after + assertEq(_tokenId, _tokenIdToMint); + assertEq(tokenContract.tokenURI(_tokenId), _mintrequest.uri); + assertEq(tokenContract.nextTokenIdToMint(), _tokenIdToMint + 1); + assertEq(tokenContract.ownerOf(_tokenId), _mintrequest.to); + } + + function test_mintWithSignature_zeroPrice_EOA_MetadataUpdateEvent() + public + whenMinterRole + whenUidNotUsed + whenValidStartTimestamp + whenValidEndTimestamp + whenRecipientAddressNotZero + whenNotEmptyUri + whenMsgValueZero + { + _mintrequest.price = 0; + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + uint256 _tokenIdToMint = tokenContract.nextTokenIdToMint(); + + vm.prank(caller); + vm.expectEmit(false, false, false, true); + emit MetadataUpdate(_tokenIdToMint); + tokenContract.mintWithSignature(_mintrequest, _signature); + } + + function test_mintWithSignature_zeroPrice_EOA_TokensMintedWithSignatureEvent() + public + whenMinterRole + whenUidNotUsed + whenValidStartTimestamp + whenValidEndTimestamp + whenRecipientAddressNotZero + whenNotEmptyUri + whenMsgValueZero + { + _mintrequest.price = 0; + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + uint256 _tokenIdToMint = tokenContract.nextTokenIdToMint(); + + vm.prank(caller); + vm.expectEmit(true, true, false, true); + emit TokensMintedWithSignature(signer, _mintrequest.to, _tokenIdToMint, _mintrequest); + tokenContract.mintWithSignature(_mintrequest, _signature); + } + + function test_mintWithSignature_zeroPrice_nonERC721ReceiverContract() + public + whenMinterRole + whenUidNotUsed + whenValidStartTimestamp + whenValidEndTimestamp + whenRecipientAddressNotZero + whenNotEmptyUri + whenMsgValueZero + { + _mintrequest.price = 0; + _mintrequest.to = address(this); + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + // mint + vm.prank(caller); + vm.expectRevert(); + tokenContract.mintWithSignature(_mintrequest, _signature); + } + + modifier whenERC721Receiver() { + _mintrequest.to = address(erc721ReceiverContract); + _; + } + + function test_mintWithSignature_zeroPrice_contract() + public + whenMinterRole + whenUidNotUsed + whenValidStartTimestamp + whenValidEndTimestamp + whenRecipientAddressNotZero + whenNotEmptyUri + whenMsgValueZero + whenERC721Receiver + { + _mintrequest.price = 0; + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + // state before + uint256 _tokenIdToMint = tokenContract.nextTokenIdToMint(); + + // mint + vm.prank(caller); + uint256 _tokenId = tokenContract.mintWithSignature(_mintrequest, _signature); + + // check state after + assertEq(_tokenId, _tokenIdToMint); + assertEq(tokenContract.tokenURI(_tokenId), _mintrequest.uri); + assertEq(tokenContract.nextTokenIdToMint(), _tokenIdToMint + 1); + assertEq(tokenContract.ownerOf(_tokenId), _mintrequest.to); + } + + function test_mintWithSignature_zeroPrice_contract_MetadataUpdateEvent() + public + whenMinterRole + whenUidNotUsed + whenValidStartTimestamp + whenValidEndTimestamp + whenRecipientAddressNotZero + whenNotEmptyUri + whenMsgValueZero + whenERC721Receiver + { + _mintrequest.price = 0; + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + uint256 _tokenIdToMint = tokenContract.nextTokenIdToMint(); + + vm.prank(caller); + vm.expectEmit(false, false, false, true); + emit MetadataUpdate(_tokenIdToMint); + tokenContract.mintWithSignature(_mintrequest, _signature); + } + + function test_mintWithSignature_zeroPrice_contract_TokensMintedWithSignatureEvent() + public + whenMinterRole + whenUidNotUsed + whenValidStartTimestamp + whenValidEndTimestamp + whenRecipientAddressNotZero + whenNotEmptyUri + whenMsgValueZero + whenERC721Receiver + { + _mintrequest.price = 0; + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + uint256 _tokenIdToMint = tokenContract.nextTokenIdToMint(); + + vm.prank(caller); + vm.expectEmit(true, true, true, true); + emit TokensMintedWithSignature(signer, _mintrequest.to, _tokenIdToMint, _mintrequest); + tokenContract.mintWithSignature(_mintrequest, _signature); + } + + // ================== + // ======= Test branch: when mint price is not zero + // ================== + + function test_mintWithSignature_nonZeroPrice_nativeToken_incorrectMsgValue() + public + whenMinterRole + whenUidNotUsed + whenValidStartTimestamp + whenValidEndTimestamp + whenRecipientAddressNotZero + whenNotEmptyUri + { + _mintrequest.price = 10; + _mintrequest.currency = NATIVE_TOKEN; + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + uint256 incorrectTotalPrice = (_mintrequest.price) + 1; + + vm.expectRevert("must send total price."); + vm.prank(caller); + tokenContract.mintWithSignature{ value: incorrectTotalPrice }(_mintrequest, _signature); + } + + modifier whenCorrectMsgValue() { + _; + } + + function test_mintWithSignature_nonZeroPrice_nativeToken() + public + whenMinterRole + whenUidNotUsed + whenValidStartTimestamp + whenValidEndTimestamp + whenRecipientAddressNotZero + whenNotEmptyUri + whenCorrectMsgValue + { + _mintrequest.price = 10; + _mintrequest.currency = NATIVE_TOKEN; + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + // state before + uint256 _tokenIdToMint = tokenContract.nextTokenIdToMint(); + + // mint + vm.prank(caller); + uint256 _tokenId = tokenContract.mintWithSignature{ value: _mintrequest.price }(_mintrequest, _signature); + + // check state after + assertEq(_tokenId, _tokenIdToMint); + assertEq(tokenContract.tokenURI(_tokenId), _mintrequest.uri); + assertEq(tokenContract.nextTokenIdToMint(), _tokenIdToMint + 1); + assertEq(tokenContract.ownerOf(_tokenId), _mintrequest.to); + + uint256 _platformFee = (_mintrequest.price * platformFeeBps) / 10_000; + uint256 _saleProceeds = _mintrequest.price - _platformFee; + assertEq(caller.balance, 1000 ether - _mintrequest.price); + assertEq(tokenContract.platformFeeRecipient().balance, _platformFee); + assertEq(tokenContract.primarySaleRecipient().balance, _saleProceeds); + } + + function test_mintWithSignature_nonZeroPrice_nativeToken_MetadataUpdateEvent() + public + whenMinterRole + whenUidNotUsed + whenValidStartTimestamp + whenValidEndTimestamp + whenRecipientAddressNotZero + whenNotEmptyUri + whenCorrectMsgValue + { + _mintrequest.price = 10; + _mintrequest.currency = NATIVE_TOKEN; + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + uint256 _tokenIdToMint = tokenContract.nextTokenIdToMint(); + + vm.prank(caller); + vm.expectEmit(false, false, false, true); + emit MetadataUpdate(_tokenIdToMint); + tokenContract.mintWithSignature{ value: _mintrequest.price }(_mintrequest, _signature); + } + + function test_mintWithSignature_nonZeroPrice_nativeToken_TokensMintedWithSignatureEvent() + public + whenMinterRole + whenUidNotUsed + whenValidStartTimestamp + whenValidEndTimestamp + whenRecipientAddressNotZero + whenNotEmptyUri + whenCorrectMsgValue + { + _mintrequest.price = 10; + _mintrequest.currency = NATIVE_TOKEN; + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + uint256 _tokenIdToMint = tokenContract.nextTokenIdToMint(); + + vm.prank(caller); + vm.expectEmit(true, true, true, true); + emit TokensMintedWithSignature(signer, _mintrequest.to, _tokenIdToMint, _mintrequest); + tokenContract.mintWithSignature{ value: _mintrequest.price }(_mintrequest, _signature); + } + + function test_mintWithSignature_nonZeroPrice_ERC20_nonZeroMsgValue() + public + whenMinterRole + whenUidNotUsed + whenValidStartTimestamp + whenValidEndTimestamp + whenRecipientAddressNotZero + whenNotEmptyUri + { + _mintrequest.price = 10; + _mintrequest.currency = address(erc20); + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + vm.expectRevert("msg value not zero"); + vm.prank(caller); + tokenContract.mintWithSignature{ value: 1 }(_mintrequest, _signature); + } + + function test_mintWithSignature_nonZeroPrice_ERC20() + public + whenMinterRole + whenUidNotUsed + whenValidStartTimestamp + whenValidEndTimestamp + whenRecipientAddressNotZero + whenNotEmptyUri + whenMsgValueZero + { + _mintrequest.price = 10; + _mintrequest.currency = address(erc20); + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + // state before + uint256 _tokenIdToMint = tokenContract.nextTokenIdToMint(); + + // mint + vm.prank(caller); + uint256 _tokenId = tokenContract.mintWithSignature{ value: 0 }(_mintrequest, _signature); + + // check state after + assertEq(_tokenId, _tokenIdToMint); + assertEq(tokenContract.tokenURI(_tokenId), _mintrequest.uri); + assertEq(tokenContract.nextTokenIdToMint(), _tokenIdToMint + 1); + assertEq(tokenContract.ownerOf(_tokenId), _mintrequest.to); + + uint256 _platformFee = (_mintrequest.price * platformFeeBps) / 10_000; + uint256 _saleProceeds = _mintrequest.price - _platformFee; + assertEq(erc20.balanceOf(caller), 1000 ether - _mintrequest.price); + assertEq(erc20.balanceOf(tokenContract.platformFeeRecipient()), _platformFee); + assertEq(erc20.balanceOf(tokenContract.primarySaleRecipient()), _saleProceeds); + } + + function test_mintWithSignature_nonZeroPrice_ERC20_MetadataUpdateEvent() + public + whenMinterRole + whenUidNotUsed + whenValidStartTimestamp + whenValidEndTimestamp + whenRecipientAddressNotZero + whenNotEmptyUri + whenMsgValueZero + { + _mintrequest.price = 10; + _mintrequest.currency = address(erc20); + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + uint256 _tokenIdToMint = tokenContract.nextTokenIdToMint(); + + vm.prank(caller); + vm.expectEmit(false, false, false, true); + emit MetadataUpdate(_tokenIdToMint); + tokenContract.mintWithSignature{ value: 0 }(_mintrequest, _signature); + } + + function test_mintWithSignature_nonZeroPrice_ERC20_TokensMintedWithSignatureEvent() + public + whenMinterRole + whenUidNotUsed + whenValidStartTimestamp + whenValidEndTimestamp + whenRecipientAddressNotZero + whenNotEmptyUri + whenMsgValueZero + { + _mintrequest.price = 10; + _mintrequest.currency = address(erc20); + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + uint256 _tokenIdToMint = tokenContract.nextTokenIdToMint(); + + vm.prank(caller); + vm.expectEmit(true, true, true, true); + emit TokensMintedWithSignature(signer, _mintrequest.to, _tokenIdToMint, _mintrequest); + tokenContract.mintWithSignature{ value: 0 }(_mintrequest, _signature); + } + + // ================== + // ======= Test branch: other cases + // ================== + + function test_mintWithSignature_nonZeroRoyaltyRecipient() + public + whenMinterRole + whenUidNotUsed + whenValidStartTimestamp + whenValidEndTimestamp + whenRecipientAddressNotZero + whenNotEmptyUri + whenMsgValueZero + { + _mintrequest.price = 0; + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + vm.prank(caller); + uint256 _tokenId = tokenContract.mintWithSignature(_mintrequest, _signature); + + (address _royaltyRecipient, uint16 _royaltyBps) = tokenContract.getRoyaltyInfoForToken(_tokenId); + assertEq(_royaltyRecipient, royaltyRecipient); + assertEq(_royaltyBps, royaltyBps); + } + + function test_mintWithSignature_royaltyRecipientZeroAddress() + public + whenMinterRole + whenUidNotUsed + whenValidStartTimestamp + whenValidEndTimestamp + whenRecipientAddressNotZero + whenNotEmptyUri + whenMsgValueZero + { + _mintrequest.price = 0; + _mintrequest.royaltyRecipient = address(0); + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + vm.prank(caller); + uint256 _tokenId = tokenContract.mintWithSignature(_mintrequest, _signature); + + (address _royaltyRecipient, uint16 _royaltyBps) = tokenContract.getRoyaltyInfoForToken(_tokenId); + (address _defaultRoyaltyRecipient, uint16 _defaultRoyaltyBps) = tokenContract.getDefaultRoyaltyInfo(); + assertEq(_royaltyRecipient, _defaultRoyaltyRecipient); + assertEq(_royaltyBps, _defaultRoyaltyBps); + } + + function test_mintWithSignature_reentrantRecipientContract() + public + whenMinterRole + whenUidNotUsed + whenValidStartTimestamp + whenValidEndTimestamp + whenRecipientAddressNotZero + whenNotEmptyUri + whenMsgValueZero + { + _mintrequest.price = 0; + _mintrequest.to = address(new ReentrantContract()); + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + vm.prank(caller); + vm.expectRevert("ReentrancyGuard: reentrant call"); + uint256 _tokenId = tokenContract.mintWithSignature(_mintrequest, _signature); + } +} diff --git a/src/test/tokenerc721-BTT/mint-with-signature/mintWithSignature.tree b/src/test/tokenerc721-BTT/mint-with-signature/mintWithSignature.tree new file mode 100644 index 000000000..347c0f674 --- /dev/null +++ b/src/test/tokenerc721-BTT/mint-with-signature/mintWithSignature.tree @@ -0,0 +1,85 @@ +mintWithSignature(MintRequest calldata _req, bytes calldata _signature) +├── when signer doesn't have MINTER_ROLE +│ └── it should revert ✅ +└── when signer has MINTER_ROLE + └── when `_req.uid` has already been used + │ └── it should revert ✅ + └── when `_req.uid` has not been used + └── when `_req.validityStartTimestamp` is greater than block timestamp + │ └── it should revert ✅ + └── when `_req.validityStartTimestamp` is less than or equal to block timestamp + └── when `_req.validityEndTimestamp` is less than block timestamp + │ └── it should revert ✅ + └── when `_req.validityEndTimestamp` is greater than or equal to block timestamp + └── when `_req.to` is address(0) + │ └── it should revert ✅ + └── when `_req.to` is not address(0) + ├── when `_req.uri` is empty i.e. length is zero + │ └── it should revert ✅ + └── when `_req.uri` is not empty + │ + │ // case: price is zero + └── when `_req.price` is zero + │ └── when msg.value is not zero + │ │ └── it should revert ✅ + │ └── when msg.value is zero + │ ├── when `_req.to` address is an EOA + │ │ └── it should mint tokenId equal to current value of `nextTokenIdToMint` ✅ + │ │ └── it should set tokenURI for minted tokenId equal to `_req.uri` ✅ + │ │ └── it should increment `nextTokenIdToMint` by 1 ✅ + │ │ └── it should mint the tokenId to the `_req.to` address ✅ + │ │ └── it should set `_req.uid` as minted ✅ + │ │ └── it should emit MetadataUpdate event ✅ + │ │ └── it should emit TokensMintedWithSignature event ✅ + │ └── when `_to` address is a contract + │ ├── when `_to` address is non ERC721Receiver implementer + │ │ └── it should revert ✅ + │ └── when `_to` address implements ERC721Receiver + │ └── it should mint tokenId equal to current value of `nextTokenIdToMint` ✅ + │ └── it should set tokenURI for minted tokenId equal to `_uri` ✅ + │ └── it should increment `nextTokenIdToMint` by 1 ✅ + │ └── it should mint the tokenId to the `_to` address ✅ + │ └── it should set `_req.uid` as minted ✅ + │ └── it should emit MetadataUpdate event ✅ + │ └── it should emit TokensMintedWithSignature event ✅ + │ + │ // case: price is not zero + └── when `_req.price` is not zero + └── when currency is native token + │ └── when msg.value is not equal to total price + │ │ └── it should revert ✅ + │ └── when msg.value is equal to total price + │ └── it should mint tokenId equal to current value of `nextTokenIdToMint` ✅ + │ └── it should set tokenURI for minted tokenId equal to `_uri` ✅ + │ └── it should increment `nextTokenIdToMint` by 1 ✅ + │ └── it should mint the tokenId to the `_to` address ✅ + │ └── it should set `_req.uid` as minted ✅ + │ └── (transfer to sale recipient) ✅ + │ └── (transfer to fee recipient) ✅ + │ └── it should emit MetadataUpdate event ✅ + │ └── it should emit TokensMintedWithSignature event ✅ + └── when currency is some ERC20 token + └── when msg.value is not zero + │ └── it should revert ✅ + └── when msg.value is zero + └── it should mint tokenId equal to current value of `nextTokenIdToMint` ✅ + └── it should set tokenURI for minted tokenId equal to `_uri` ✅ + └── it should increment `nextTokenIdToMint` by 1 ✅ + └── it should mint the tokenId to the `_to` address ✅ + └── it should set `_req.uid` as minted ✅ + └── (transfer to sale recipient) ✅ + └── (transfer to fee recipient) ✅ + └── it should emit MetadataUpdate event ✅ + └── it should emit TokensMintedWithSignature event ✅ + +// other cases + +├── when `_req.royaltyRecipient` is not address(0) + │ └── it should set royaltyInfoForToken ✅ + └── when `_req.royaltyRecipient` is address(0) + └── it should use default royalty info ✅ + +├── when reentrant call + └── it should revert ✅ + + diff --git a/src/test/tokenerc721-BTT/other-functions/other.t.sol b/src/test/tokenerc721-BTT/other-functions/other.t.sol new file mode 100644 index 000000000..43a70d662 --- /dev/null +++ b/src/test/tokenerc721-BTT/other-functions/other.t.sol @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "../../utils/BaseTest.sol"; +import { IStaking721 } from "contracts/extension/interface/IStaking721.sol"; +import { IERC2981 } from "contracts/eip/interface/IERC2981.sol"; + +import "@openzeppelin/contracts-upgradeable/access/IAccessControlEnumerableUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/access/IAccessControlUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/token/ERC721/extensions/IERC721EnumerableUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/token/ERC721/IERC721Upgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/utils/introspection/IERC165Upgradeable.sol"; +import "@openzeppelin/contracts/utils/introspection/IERC165.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +contract MyTokenERC721 is TokenERC721 { + function canSetMetadata() public view returns (bool) { + return _canSetMetadata(); + } + + function canFreezeMetadata() public view returns (bool) { + return _canFreezeMetadata(); + } +} + +contract TokenERC721Test_OtherFunctions is BaseTest { + address public implementation; + address public proxy; + + MyTokenERC721 public tokenContract; + + function setUp() public override { + super.setUp(); + + // Deploy implementation. + implementation = address(new MyTokenERC721()); + + // Deploy proxy pointing to implementaion. + vm.prank(deployer); + proxy = address( + new TWProxy( + implementation, + abi.encodeCall( + TokenERC721.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ) + ); + + tokenContract = MyTokenERC721(proxy); + } + + function test_contractType() public { + assertEq(tokenContract.contractType(), bytes32("TokenERC721")); + } + + function test_contractVersion() public { + assertEq(tokenContract.contractVersion(), uint8(1)); + } + + function test_canSetMetadata_notMetadataRole() public { + assertFalse(tokenContract.canSetMetadata()); + } + + modifier whenMetadataRoleRole() { + _; + } + + function test_canSetMetadata() public whenMetadataRoleRole { + vm.prank(deployer); + assertTrue(tokenContract.canSetMetadata()); + } + + function test_canFreezeMetadata_notMetadataRole() public { + assertFalse(tokenContract.canFreezeMetadata()); + } + + function test_canFreezeMetadata() public whenMetadataRoleRole { + vm.prank(deployer); + assertTrue(tokenContract.canFreezeMetadata()); + } + + function test_supportsInterface() public { + assertTrue(tokenContract.supportsInterface(type(IERC2981).interfaceId)); + assertTrue(tokenContract.supportsInterface(type(IERC165).interfaceId)); + assertTrue(tokenContract.supportsInterface(type(IERC165Upgradeable).interfaceId)); + assertTrue(tokenContract.supportsInterface(type(IAccessControlEnumerableUpgradeable).interfaceId)); + assertTrue(tokenContract.supportsInterface(type(IAccessControlUpgradeable).interfaceId)); + assertTrue(tokenContract.supportsInterface(type(IERC721EnumerableUpgradeable).interfaceId)); + assertTrue(tokenContract.supportsInterface(type(IERC721Upgradeable).interfaceId)); + + // false for other not supported interfaces + assertFalse(tokenContract.supportsInterface(type(IStaking721).interfaceId)); + } +} diff --git a/src/test/tokenerc721-BTT/other-functions/other.tree b/src/test/tokenerc721-BTT/other-functions/other.tree new file mode 100644 index 000000000..c6611fa64 --- /dev/null +++ b/src/test/tokenerc721-BTT/other-functions/other.tree @@ -0,0 +1,31 @@ +contractType() +├── it should return bytes32("TokenERC721") ✅ + +contractVersion() +├── it should return uint8(1) ✅ + +_beforeTokenTransfers( + address from, + address to, + uint256 startTokenId, + uint256 quantity +) +├── when transfers are restricted (i.e. address(0) doesn't have transfer role, or from-to addresses are not address(0) +│ └── when from and to don't have transfer role +│ └── it should revert ✅ + +_canSetMetadata() +├── when the caller doesn't have METADATA_ROLE +│ └── it should revert ✅ +└── when the caller has METADATA_ROLE + └── it should return true ✅ + +_canFreezeMetadata() +├── when the caller doesn't have METADATA_ROLE +│ └── it should revert ✅ +└── when the caller has METADATA_ROLE + └── it should return true ✅ + +supportsInterface(bytes4 interfaceId) +├── it should return true for supported interface ✅ +├── it should return false for not supported interface ✅ diff --git a/src/test/tokenerc721-BTT/owner/owner.t.sol b/src/test/tokenerc721-BTT/owner/owner.t.sol new file mode 100644 index 000000000..1f9f88ddc --- /dev/null +++ b/src/test/tokenerc721-BTT/owner/owner.t.sol @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "../../utils/BaseTest.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +contract MyTokenERC721 is TokenERC721 {} + +contract TokenERC721Test_Owner is BaseTest { + address public implementation; + address public proxy; + + MyTokenERC721 internal tokenContract; + + function setUp() public override { + super.setUp(); + + // Deploy implementation. + implementation = address(new MyTokenERC721()); + + // Deploy proxy pointing to implementaion. + vm.prank(deployer); + proxy = address( + new TWProxy( + implementation, + abi.encodeCall( + TokenERC721.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ) + ); + + tokenContract = MyTokenERC721(proxy); + } + + function test_owner() public { + assertEq(tokenContract.owner(), deployer); + } + + function test_owner_notDefaultAdmin() public { + vm.prank(deployer); + tokenContract.renounceRole(bytes32(0x00), deployer); + + assertEq(tokenContract.owner(), address(0)); + } +} diff --git a/src/test/tokenerc721-BTT/owner/owner.tree b/src/test/tokenerc721-BTT/owner/owner.tree new file mode 100644 index 000000000..576cfcb91 --- /dev/null +++ b/src/test/tokenerc721-BTT/owner/owner.tree @@ -0,0 +1,6 @@ +owner() +├── when private variable `_owner` DEFAULT_ADMIN_ROLE +│ └── it should return `_owner` ✅ +└── when private variable `_owner` doesn't have DEFAULT_ADMIN_ROLE + └── it should return address(0) ✅ + diff --git a/src/test/tokenerc721-BTT/set-contract-uri/setContractURI.t.sol b/src/test/tokenerc721-BTT/set-contract-uri/setContractURI.t.sol new file mode 100644 index 000000000..1dc0d8855 --- /dev/null +++ b/src/test/tokenerc721-BTT/set-contract-uri/setContractURI.t.sol @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "../../utils/BaseTest.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +contract MyTokenERC721 is TokenERC721 {} + +contract TokenERC721Test_SetContractURI is BaseTest { + address public implementation; + address public proxy; + address internal caller; + string internal _contractURI; + + MyTokenERC721 internal tokenContract; + + function setUp() public override { + super.setUp(); + + // Deploy implementation. + implementation = address(new MyTokenERC721()); + + caller = getActor(1); + + // Deploy proxy pointing to implementaion. + vm.prank(deployer); + proxy = address( + new TWProxy( + implementation, + abi.encodeCall( + TokenERC721.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ) + ); + + tokenContract = MyTokenERC721(proxy); + _contractURI = "ipfs://contracturi"; + } + + function test_setContractURI_callerNotAuthorized() public { + vm.prank(address(caller)); + vm.expectRevert( + abi.encodePacked( + "AccessControl: account ", + Strings.toHexString(uint160(caller), 20), + " is missing role ", + Strings.toHexString(uint256(0), 32) + ) + ); + tokenContract.setContractURI(_contractURI); + } + + modifier whenCallerAuthorized() { + vm.prank(deployer); + tokenContract.grantRole(bytes32(0x00), caller); + _; + } + + function test_setContractURI_empty() public whenCallerAuthorized { + vm.prank(address(caller)); + tokenContract.setContractURI(""); + + // get contract uri + assertEq(tokenContract.contractURI(), ""); + } + + function test_setContractURI_notEmpty() public whenCallerAuthorized { + vm.prank(address(caller)); + tokenContract.setContractURI(_contractURI); + + // get contract uri + assertEq(tokenContract.contractURI(), _contractURI); + } +} diff --git a/src/test/tokenerc721-BTT/set-contract-uri/setContractURI.tree b/src/test/tokenerc721-BTT/set-contract-uri/setContractURI.tree new file mode 100644 index 000000000..8fc480b19 --- /dev/null +++ b/src/test/tokenerc721-BTT/set-contract-uri/setContractURI.tree @@ -0,0 +1,8 @@ +setContractURI(string calldata _uri) +├── when caller not authorized + │ └── it should revert ✅ + └── when caller is authorized + └── when `uri` is empty + │ └── it should update contract URI to empty string ✅ + └── when `uri` is not empty + └── it should update contract URI to `_uri` ✅ \ No newline at end of file diff --git a/src/test/tokenerc721-BTT/set-default-royalty-info/setDefaultRoyaltyInfo.t.sol b/src/test/tokenerc721-BTT/set-default-royalty-info/setDefaultRoyaltyInfo.t.sol new file mode 100644 index 000000000..d63175b76 --- /dev/null +++ b/src/test/tokenerc721-BTT/set-default-royalty-info/setDefaultRoyaltyInfo.t.sol @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "../../utils/BaseTest.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +contract MyTokenERC721 is TokenERC721 {} + +contract TokenERC721Test_SetDefaultRoyaltyInfo is BaseTest { + address public implementation; + address public proxy; + address internal caller; + address internal defaultRoyaltyRecipient; + uint256 internal defaultRoyaltyBps; + + MyTokenERC721 internal tokenContract; + + event DefaultRoyalty(address indexed newRoyaltyRecipient, uint256 newRoyaltyBps); + + function setUp() public override { + super.setUp(); + + // Deploy implementation. + implementation = address(new MyTokenERC721()); + + caller = getActor(1); + defaultRoyaltyRecipient = getActor(2); + + // Deploy proxy pointing to implementaion. + vm.prank(deployer); + proxy = address( + new TWProxy( + implementation, + abi.encodeCall( + TokenERC721.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ) + ); + + tokenContract = MyTokenERC721(proxy); + } + + function test_setDefaultRoyaltyInfo_callerNotAuthorized() public { + vm.prank(address(caller)); + vm.expectRevert( + abi.encodePacked( + "AccessControl: account ", + Strings.toHexString(uint160(caller), 20), + " is missing role ", + Strings.toHexString(uint256(0), 32) + ) + ); + tokenContract.setDefaultRoyaltyInfo(defaultRoyaltyRecipient, defaultRoyaltyBps); + } + + modifier whenCallerAuthorized() { + vm.prank(deployer); + tokenContract.grantRole(bytes32(0x00), caller); + _; + } + + function test_setDefaultRoyaltyInfo_exceedMaxBps() public whenCallerAuthorized { + defaultRoyaltyBps = 10_001; + vm.prank(address(caller)); + vm.expectRevert("exceed royalty bps"); + tokenContract.setDefaultRoyaltyInfo(defaultRoyaltyRecipient, defaultRoyaltyBps); + } + + modifier whenNotExceedMaxBps() { + defaultRoyaltyBps = 500; + _; + } + + function test_setDefaultRoyaltyInfo() public whenCallerAuthorized whenNotExceedMaxBps { + vm.prank(address(caller)); + tokenContract.setDefaultRoyaltyInfo(defaultRoyaltyRecipient, defaultRoyaltyBps); + + // get default royalty info + (address _recipient, uint16 _royaltyBps) = tokenContract.getDefaultRoyaltyInfo(); + assertEq(_recipient, defaultRoyaltyRecipient); + assertEq(_royaltyBps, uint16(defaultRoyaltyBps)); + + // get royalty info for token + uint256 tokenId = 0; + (_recipient, _royaltyBps) = tokenContract.getRoyaltyInfoForToken(tokenId); + assertEq(_recipient, defaultRoyaltyRecipient); + assertEq(_royaltyBps, uint16(defaultRoyaltyBps)); + + // royaltyInfo - ERC2981 + uint256 salePrice = 1000; + (address _royaltyRecipient, uint256 _royaltyAmount) = tokenContract.royaltyInfo(tokenId, salePrice); + assertEq(_royaltyRecipient, defaultRoyaltyRecipient); + assertEq(_royaltyAmount, (salePrice * defaultRoyaltyBps) / 10_000); + } + + function test_setDefaultRoyaltyInfo_event() public whenCallerAuthorized whenNotExceedMaxBps { + vm.prank(address(caller)); + vm.expectEmit(true, false, false, true); + emit DefaultRoyalty(defaultRoyaltyRecipient, defaultRoyaltyBps); + tokenContract.setDefaultRoyaltyInfo(defaultRoyaltyRecipient, defaultRoyaltyBps); + } +} diff --git a/src/test/tokenerc721-BTT/set-default-royalty-info/setDefaultRoyaltyInfo.tree b/src/test/tokenerc721-BTT/set-default-royalty-info/setDefaultRoyaltyInfo.tree new file mode 100644 index 000000000..78a4312de --- /dev/null +++ b/src/test/tokenerc721-BTT/set-default-royalty-info/setDefaultRoyaltyInfo.tree @@ -0,0 +1,11 @@ +setDefaultRoyaltyInfo(address _royaltyRecipient, uint256 _royaltyBps) +├── when caller not authorized + │ └── it should revert ✅ + └── when caller is authorized + ├── when royalty bps input is greater than MAX_BPS + │ └── it should revert ✅ + └── when royalty bps input is less than or equal to MAX_BPS + └── it should update default royalty recipient ✅ + └── it should update default royalty bps ✅ + └── it should calculate royalty amount for a token-id based on default royalty info ✅ + └── it should emit DefaultRoyalty event ✅ \ No newline at end of file diff --git a/src/test/tokenerc721-BTT/set-owner/setOwner.t.sol b/src/test/tokenerc721-BTT/set-owner/setOwner.t.sol new file mode 100644 index 000000000..1ea57ba2c --- /dev/null +++ b/src/test/tokenerc721-BTT/set-owner/setOwner.t.sol @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "../../utils/BaseTest.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +contract MyTokenERC721 is TokenERC721 {} + +contract TokenERC721Test_SetOwner is BaseTest { + address public implementation; + address public proxy; + address internal caller; + address internal _newOwner; + + MyTokenERC721 internal tokenContract; + + event OwnerUpdated(address indexed prevOwner, address indexed newOwner); + + function setUp() public override { + super.setUp(); + + // Deploy implementation. + implementation = address(new MyTokenERC721()); + + caller = getActor(1); + _newOwner = getActor(2); + + // Deploy proxy pointing to implementaion. + vm.prank(deployer); + proxy = address( + new TWProxy( + implementation, + abi.encodeCall( + TokenERC721.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ) + ); + + tokenContract = MyTokenERC721(proxy); + } + + function test_setOwner_callerNotAuthorized() public { + vm.prank(address(caller)); + vm.expectRevert( + abi.encodePacked( + "AccessControl: account ", + Strings.toHexString(uint160(caller), 20), + " is missing role ", + Strings.toHexString(uint256(0), 32) + ) + ); + tokenContract.setOwner(_newOwner); + } + + modifier whenCallerAuthorized() { + vm.prank(deployer); + tokenContract.grantRole(bytes32(0x00), caller); + _; + } + + function test_setOwner_newOwnerNotAdmin() public whenCallerAuthorized { + vm.prank(address(caller)); + vm.expectRevert("new owner not module admin."); + tokenContract.setOwner(_newOwner); + } + + modifier whenNewOwnerIsAnAdmin() { + vm.prank(deployer); + tokenContract.grantRole(bytes32(0x00), _newOwner); + _; + } + + function test_setOwner() public whenCallerAuthorized whenNewOwnerIsAnAdmin { + vm.prank(address(caller)); + tokenContract.setOwner(_newOwner); + + assertEq(tokenContract.owner(), _newOwner); + } + + function test_setOwner_event() public whenCallerAuthorized whenNewOwnerIsAnAdmin { + vm.prank(address(caller)); + vm.expectEmit(true, true, false, false); + emit OwnerUpdated(deployer, _newOwner); + tokenContract.setOwner(_newOwner); + } +} diff --git a/src/test/tokenerc721-BTT/set-owner/setOwner.tree b/src/test/tokenerc721-BTT/set-owner/setOwner.tree new file mode 100644 index 000000000..964e97cac --- /dev/null +++ b/src/test/tokenerc721-BTT/set-owner/setOwner.tree @@ -0,0 +1,9 @@ +setOwner(address _newOwner) +├── when caller not authorized + │ └── it should revert ✅ + └── when caller is authorized + └── when incoming `_owner` doesn't have DEFAULT_ADMIN_ROLE + │ └── it should revert ✅ + └── when incoming `_owner` has DEFAULT_ADMIN_ROLE + └── it should update owner ✅ + └── it should emit OwnerUpdated event ✅ \ No newline at end of file diff --git a/src/test/tokenerc721-BTT/set-platform-fee-info/setPlatformFeeInfo.t.sol b/src/test/tokenerc721-BTT/set-platform-fee-info/setPlatformFeeInfo.t.sol new file mode 100644 index 000000000..e2a9b0ab4 --- /dev/null +++ b/src/test/tokenerc721-BTT/set-platform-fee-info/setPlatformFeeInfo.t.sol @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "../../utils/BaseTest.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +contract MyTokenERC721 is TokenERC721 {} + +contract TokenERC721Test_SetPlatformFeeInfo is BaseTest { + address public implementation; + address public proxy; + address internal caller; + address internal _platformFeeRecipient; + uint256 internal _platformFeeBps; + + MyTokenERC721 internal tokenContract; + + event PlatformFeeInfoUpdated(address indexed platformFeeRecipient, uint256 platformFeeBps); + + function setUp() public override { + super.setUp(); + + // Deploy implementation. + implementation = address(new MyTokenERC721()); + + caller = getActor(1); + _platformFeeRecipient = getActor(2); + + // Deploy proxy pointing to implementaion. + vm.prank(deployer); + proxy = address( + new TWProxy( + implementation, + abi.encodeCall( + TokenERC721.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ) + ); + + tokenContract = MyTokenERC721(proxy); + } + + function test_setPlatformFeeInfo_callerNotAuthorized() public { + vm.prank(address(caller)); + vm.expectRevert( + abi.encodePacked( + "AccessControl: account ", + Strings.toHexString(uint160(caller), 20), + " is missing role ", + Strings.toHexString(uint256(0), 32) + ) + ); + tokenContract.setPlatformFeeInfo(_platformFeeRecipient, _platformFeeBps); + } + + modifier whenCallerAuthorized() { + vm.prank(deployer); + tokenContract.grantRole(bytes32(0x00), caller); + _; + } + + function test_setPlatformFeeInfo_exceedMaxBps() public whenCallerAuthorized { + _platformFeeBps = 10_001; + vm.prank(address(caller)); + vm.expectRevert("exceeds MAX_BPS"); + tokenContract.setPlatformFeeInfo(_platformFeeRecipient, _platformFeeBps); + } + + modifier whenNotExceedMaxBps() { + _platformFeeBps = 500; + _; + } + + function test_setPlatformFeeInfo() public whenCallerAuthorized whenNotExceedMaxBps { + vm.prank(address(caller)); + tokenContract.setPlatformFeeInfo(_platformFeeRecipient, _platformFeeBps); + + // get platform fee info + (address _recipient, uint16 _bps) = tokenContract.getPlatformFeeInfo(); + assertEq(_recipient, _platformFeeRecipient); + assertEq(_bps, uint16(_platformFeeBps)); + assertEq(tokenContract.platformFeeRecipient(), _platformFeeRecipient); + } + + function test_setPlatformFeeInfo_event() public whenCallerAuthorized whenNotExceedMaxBps { + vm.prank(address(caller)); + vm.expectEmit(true, false, false, true); + emit PlatformFeeInfoUpdated(_platformFeeRecipient, _platformFeeBps); + tokenContract.setPlatformFeeInfo(_platformFeeRecipient, _platformFeeBps); + } +} diff --git a/src/test/tokenerc721-BTT/set-platform-fee-info/setPlatformFeeInfo.tree b/src/test/tokenerc721-BTT/set-platform-fee-info/setPlatformFeeInfo.tree new file mode 100644 index 000000000..dcef9965e --- /dev/null +++ b/src/test/tokenerc721-BTT/set-platform-fee-info/setPlatformFeeInfo.tree @@ -0,0 +1,10 @@ +setPlatformFeeInfo(address _platformFeeRecipient, uint256 _platformFeeBps) +├── when caller not authorized + │ └── it should revert ✅ + └── when caller is authorized + ├── when `_platformFeeBps` is greater than MAX_BPS + │ └── it should revert ✅ + └── when `_platformFeeBps` is less than or equal to MAX_BPS + └── it should update platform fee recipient ✅ + └── it should update platform fee bps ✅ + └── it should emit PlatformFeeInfoUpdated event ✅ \ No newline at end of file diff --git a/src/test/tokenerc721-BTT/set-primary-sale-recipient/setPrimarySaleRecipient.t.sol b/src/test/tokenerc721-BTT/set-primary-sale-recipient/setPrimarySaleRecipient.t.sol new file mode 100644 index 000000000..325b801ac --- /dev/null +++ b/src/test/tokenerc721-BTT/set-primary-sale-recipient/setPrimarySaleRecipient.t.sol @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "../../utils/BaseTest.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +contract MyTokenERC721 is TokenERC721 {} + +contract TokenERC721Test_SetPrimarySaleRecipient is BaseTest { + address public implementation; + address public proxy; + address internal caller; + address internal _primarySaleRecipient; + + MyTokenERC721 internal tokenContract; + + event PrimarySaleRecipientUpdated(address indexed recipient); + + function setUp() public override { + super.setUp(); + + // Deploy implementation. + implementation = address(new MyTokenERC721()); + + caller = getActor(1); + _primarySaleRecipient = getActor(2); + + // Deploy proxy pointing to implementaion. + vm.prank(deployer); + proxy = address( + new TWProxy( + implementation, + abi.encodeCall( + TokenERC721.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ) + ); + + tokenContract = MyTokenERC721(proxy); + } + + function test_setPrimarySaleRecipient_callerNotAuthorized() public { + vm.prank(address(caller)); + vm.expectRevert( + abi.encodePacked( + "AccessControl: account ", + Strings.toHexString(uint160(caller), 20), + " is missing role ", + Strings.toHexString(uint256(0), 32) + ) + ); + tokenContract.setPrimarySaleRecipient(_primarySaleRecipient); + } + + modifier whenCallerAuthorized() { + vm.prank(deployer); + tokenContract.grantRole(bytes32(0x00), caller); + _; + } + + function test_setPrimarySaleRecipient() public whenCallerAuthorized { + vm.prank(address(caller)); + tokenContract.setPrimarySaleRecipient(_primarySaleRecipient); + + // get primary sale recipient info + assertEq(tokenContract.primarySaleRecipient(), _primarySaleRecipient); + } + + function test_setPrimarySaleRecipient_event() public whenCallerAuthorized { + vm.prank(address(caller)); + vm.expectEmit(true, false, false, false); + emit PrimarySaleRecipientUpdated(_primarySaleRecipient); + tokenContract.setPrimarySaleRecipient(_primarySaleRecipient); + } +} diff --git a/src/test/tokenerc721-BTT/set-primary-sale-recipient/setPrimarySaleRecipient.tree b/src/test/tokenerc721-BTT/set-primary-sale-recipient/setPrimarySaleRecipient.tree new file mode 100644 index 000000000..230035a07 --- /dev/null +++ b/src/test/tokenerc721-BTT/set-primary-sale-recipient/setPrimarySaleRecipient.tree @@ -0,0 +1,6 @@ +setPrimarySaleRecipient(address _saleRecipient) +├── when caller not authorized + │ └── it should revert ✅ + └── when caller is authorized + └── it should update primary sale recipient ✅ + └── it should emit PrimarySaleRecipientUpdated event ✅ \ No newline at end of file diff --git a/src/test/tokenerc721-BTT/set-royalty-info-for-token/setRoyaltyInfoForToken.t.sol b/src/test/tokenerc721-BTT/set-royalty-info-for-token/setRoyaltyInfoForToken.t.sol new file mode 100644 index 000000000..f5c5e3b83 --- /dev/null +++ b/src/test/tokenerc721-BTT/set-royalty-info-for-token/setRoyaltyInfoForToken.t.sol @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "../../utils/BaseTest.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +contract MyTokenERC721 is TokenERC721 {} + +contract TokenERC721Test_SetRoyaltyInfoForToken is BaseTest { + address public implementation; + address public proxy; + address internal caller; + address internal defaultRoyaltyRecipient; + uint256 internal defaultRoyaltyBps; + + MyTokenERC721 internal tokenContract; + + address internal royaltyRecipientForToken; + uint256 internal royaltyBpsForToken; + uint256 internal tokenId; + + event RoyaltyForToken(uint256 indexed tokenId, address indexed royaltyRecipient, uint256 royaltyBps); + + function setUp() public override { + super.setUp(); + + // Deploy implementation. + implementation = address(new MyTokenERC721()); + + caller = getActor(1); + defaultRoyaltyRecipient = getActor(2); + royaltyRecipientForToken = getActor(3); + defaultRoyaltyBps = 500; + tokenId = 1; + + // Deploy proxy pointing to implementaion. + vm.prank(deployer); + proxy = address( + new TWProxy( + implementation, + abi.encodeCall( + TokenERC721.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ) + ); + + tokenContract = MyTokenERC721(proxy); + + vm.prank(deployer); + tokenContract.setDefaultRoyaltyInfo(defaultRoyaltyRecipient, defaultRoyaltyBps); + } + + function test_setRoyaltyInfoForToken_callerNotAuthorized() public { + vm.prank(address(caller)); + vm.expectRevert( + abi.encodePacked( + "AccessControl: account ", + Strings.toHexString(uint160(caller), 20), + " is missing role ", + Strings.toHexString(uint256(0), 32) + ) + ); + tokenContract.setRoyaltyInfoForToken(tokenId, royaltyRecipientForToken, royaltyBpsForToken); + } + + modifier whenCallerAuthorized() { + vm.prank(deployer); + tokenContract.grantRole(bytes32(0x00), caller); + _; + } + + function test_setRoyaltyInfoForToken_exceedMaxBps() public whenCallerAuthorized { + royaltyBpsForToken = 10_001; + vm.prank(address(caller)); + vm.expectRevert("exceed royalty bps"); + tokenContract.setRoyaltyInfoForToken(tokenId, royaltyRecipientForToken, royaltyBpsForToken); + } + + modifier whenNotExceedMaxBps() { + royaltyBpsForToken = 1000; + _; + } + + function test_setRoyaltyInfoForToken() public whenCallerAuthorized whenNotExceedMaxBps { + vm.prank(address(caller)); + tokenContract.setRoyaltyInfoForToken(tokenId, royaltyRecipientForToken, royaltyBpsForToken); + + // get default royalty info + (address _defaultRecipient, uint16 _defaultRoyaltyBps) = tokenContract.getDefaultRoyaltyInfo(); + assertEq(_defaultRecipient, defaultRoyaltyRecipient); + assertEq(_defaultRoyaltyBps, uint16(defaultRoyaltyBps)); + + // get royalty info for token + (address _royaltyRecipientForToken, uint16 _royaltyBpsForToken) = tokenContract.getRoyaltyInfoForToken(tokenId); + assertEq(_royaltyRecipientForToken, royaltyRecipientForToken); + assertEq(_royaltyBpsForToken, uint16(royaltyBpsForToken)); + + // royaltyInfo - ERC2981: calculate for default + uint256 salePrice = 1000; + (address _royaltyRecipient, uint256 _royaltyAmount) = tokenContract.royaltyInfo(0, salePrice); + assertEq(_royaltyRecipient, defaultRoyaltyRecipient); + assertEq(_royaltyAmount, (salePrice * defaultRoyaltyBps) / 10_000); + + // royaltyInfo - ERC2981: calculate for specific tokenId we set the royalty info for + (_royaltyRecipient, _royaltyAmount) = tokenContract.royaltyInfo(tokenId, salePrice); + assertEq(_royaltyRecipient, royaltyRecipientForToken); + assertEq(_royaltyAmount, (salePrice * royaltyBpsForToken) / 10_000); + } + + function test_setRoyaltyInfoForToken_event() public whenCallerAuthorized whenNotExceedMaxBps { + vm.prank(address(caller)); + vm.expectEmit(true, true, false, true); + emit RoyaltyForToken(tokenId, royaltyRecipientForToken, royaltyBpsForToken); + tokenContract.setRoyaltyInfoForToken(tokenId, royaltyRecipientForToken, royaltyBpsForToken); + } +} diff --git a/src/test/tokenerc721-BTT/set-royalty-info-for-token/setRoyaltyInfoForToken.tree b/src/test/tokenerc721-BTT/set-royalty-info-for-token/setRoyaltyInfoForToken.tree new file mode 100644 index 000000000..e28295634 --- /dev/null +++ b/src/test/tokenerc721-BTT/set-royalty-info-for-token/setRoyaltyInfoForToken.tree @@ -0,0 +1,15 @@ +function setRoyaltyInfoForToken( + uint256 _tokenId, + address _recipient, + uint256 _bps +) +├── when caller not authorized + │ └── it should revert ✅ + └── when caller is authorized + ├── when royalty bps input is greater than MAX_BPS + │ └── it should revert ✅ + └── when royalty bps input is less than or equal to MAX_BPS + └── it should update default royalty recipient ✅ + └── it should update default royalty bps ✅ + └── it should calculate royalty amount for a token-id based on default royalty info ✅ + └── it should emit DefaultRoyalty event ✅ \ No newline at end of file diff --git a/src/test/tokenerc721-BTT/token-uri/tokenURI.t.sol b/src/test/tokenerc721-BTT/token-uri/tokenURI.t.sol new file mode 100644 index 000000000..260048e0c --- /dev/null +++ b/src/test/tokenerc721-BTT/token-uri/tokenURI.t.sol @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "../../utils/BaseTest.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +contract MyTokenERC721 is TokenERC721 {} + +contract TokenERC721Test_TokenURI is BaseTest { + address public implementation; + address public proxy; + + MyTokenERC721 internal tokenContract; + + function setUp() public override { + super.setUp(); + + // Deploy implementation. + implementation = address(new MyTokenERC721()); + + // Deploy proxy pointing to implementaion. + vm.prank(deployer); + proxy = address( + new TWProxy( + implementation, + abi.encodeCall( + TokenERC721.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ) + ); + + tokenContract = MyTokenERC721(proxy); + } + + function test_tokenURI() public { + uint256 _tokenId = 1; + string memory _uri = "ipfs://uri/1"; + + vm.prank(deployer); + tokenContract.setTokenURI(_tokenId, _uri); + + assertEq(tokenContract.tokenURI(_tokenId), _uri); + } +} diff --git a/src/test/tokenerc721-BTT/token-uri/tokenURI.tree b/src/test/tokenerc721-BTT/token-uri/tokenURI.tree new file mode 100644 index 000000000..c97d2c9d1 --- /dev/null +++ b/src/test/tokenerc721-BTT/token-uri/tokenURI.tree @@ -0,0 +1,3 @@ +tokenURI(uint256 _tokenId) +├── it should return tokenURI associated with the given `_tokenId` ✅ + diff --git a/src/test/tokenerc721-BTT/verify/verify.t.sol b/src/test/tokenerc721-BTT/verify/verify.t.sol new file mode 100644 index 000000000..2d219b9f0 --- /dev/null +++ b/src/test/tokenerc721-BTT/verify/verify.t.sol @@ -0,0 +1,149 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "../../utils/BaseTest.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; + +contract MyTokenERC721 is TokenERC721 { + function setMintedURI(MintRequest calldata _req, bytes calldata _signature) external { + verifyRequest(_req, _signature); + } +} + +contract TokenERC721Test_Verify is BaseTest { + address public implementation; + address public proxy; + + MyTokenERC721 internal tokenContract; + bytes32 internal typehashMintRequest; + bytes32 internal nameHash; + bytes32 internal versionHash; + bytes32 internal typehashEip712; + bytes32 internal domainSeparator; + + TokenERC721.MintRequest _mintrequest; + + function setUp() public override { + super.setUp(); + + // Deploy implementation. + implementation = address(new MyTokenERC721()); + + // Deploy proxy pointing to implementaion. + vm.prank(deployer); + proxy = address( + new TWProxy( + implementation, + abi.encodeCall( + TokenERC721.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ) + ); + + tokenContract = MyTokenERC721(proxy); + + typehashMintRequest = keccak256( + "MintRequest(address to,address royaltyRecipient,uint256 royaltyBps,address primarySaleRecipient,string uri,uint256 price,address currency,uint128 validityStartTimestamp,uint128 validityEndTimestamp,bytes32 uid)" + ); + nameHash = keccak256(bytes("TokenERC721")); + versionHash = keccak256(bytes("1")); + typehashEip712 = keccak256( + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" + ); + domainSeparator = keccak256( + abi.encode(typehashEip712, nameHash, versionHash, block.chainid, address(tokenContract)) + ); + + // construct default mintrequest + _mintrequest.to = address(0x1234); + _mintrequest.royaltyRecipient = royaltyRecipient; + _mintrequest.royaltyBps = royaltyBps; + _mintrequest.primarySaleRecipient = saleRecipient; + _mintrequest.uri = "ipfs://"; + _mintrequest.price = 0; + _mintrequest.currency = address(0); + _mintrequest.validityStartTimestamp = 0; + _mintrequest.validityEndTimestamp = 2000; + _mintrequest.uid = bytes32(0); + } + + function signMintRequest( + TokenERC721.MintRequest memory _request, + uint256 _privateKey + ) internal view returns (bytes memory) { + bytes memory encodedRequest = abi.encode( + typehashMintRequest, + _request.to, + _request.royaltyRecipient, + _request.royaltyBps, + _request.primarySaleRecipient, + keccak256(bytes(_request.uri)), + _request.price, + _request.currency, + _request.validityStartTimestamp, + _request.validityEndTimestamp, + _request.uid + ); + bytes32 structHash = keccak256(encodedRequest); + bytes32 typedDataHash = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(_privateKey, typedDataHash); + bytes memory sig = abi.encodePacked(r, s, v); + + return sig; + } + + function test_verify_notMinterRole() public { + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + (bool _isValid, address _recoveredSigner) = tokenContract.verify(_mintrequest, _signature); + + assertFalse(_isValid); + assertEq(_recoveredSigner, signer); + } + + modifier whenMinterRole() { + vm.prank(deployer); + tokenContract.grantRole(keccak256("MINTER_ROLE"), signer); + _; + } + + function test_verify_invalidUID() public whenMinterRole { + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + // set state with this mintrequest and signature, marking the UID as used + tokenContract.setMintedURI(_mintrequest, _signature); + + // pass the same UID mintrequest again + (bool _isValid, address _recoveredSigner) = tokenContract.verify(_mintrequest, _signature); + + assertFalse(_isValid); + assertEq(_recoveredSigner, signer); + } + + modifier whenUidNotUsed() { + _; + } + + function test_verify() public whenMinterRole whenUidNotUsed { + bytes memory _signature = signMintRequest(_mintrequest, privateKey); + + (bool _isValid, address _recoveredSigner) = tokenContract.verify(_mintrequest, _signature); + + assertTrue(_isValid); + assertEq(_recoveredSigner, signer); + } +} diff --git a/src/test/tokenerc721-BTT/verify/verify.tree b/src/test/tokenerc721-BTT/verify/verify.tree new file mode 100644 index 000000000..c160faa0f --- /dev/null +++ b/src/test/tokenerc721-BTT/verify/verify.tree @@ -0,0 +1,12 @@ +verify(MintRequest calldata _req, bytes calldata _signature) +├── when signer doesn't have MINTER_ROLE +│ └── it should return false ✅ +│ └── it should return recovered signer equal to the actual signer of the request ✅ +└── when signer has MINTER_ROLE + └── when `_req.uid` has already been used + │ └── it should return false ✅ + │ └── it should return recovered signer equal to the actual signer of the request ✅ + └── when `_req.uid` has not been used + └── it should return true ✅ + └── it should return recovered signer equal to the actual signer of the request ✅ + diff --git a/src/test/utils/BaseTest.sol b/src/test/utils/BaseTest.sol new file mode 100644 index 000000000..454ea8235 --- /dev/null +++ b/src/test/utils/BaseTest.sol @@ -0,0 +1,447 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@std/Test.sol"; +import "@ds-test/test.sol"; +// import "./Console.sol"; +import { Wallet } from "./Wallet.sol"; +import "./ChainlinkVRF.sol"; +import { WETH9 } from "../mocks/WETH9.sol"; +import { MockERC20, ERC20, IERC20 } from "../mocks/MockERC20.sol"; +import { MockERC721, IERC721 } from "../mocks/MockERC721.sol"; +import { MockERC1155, IERC1155 } from "../mocks/MockERC1155.sol"; +import { MockERC721NonBurnable } from "../mocks/MockERC721NonBurnable.sol"; +import { MockERC1155NonBurnable } from "../mocks/MockERC1155NonBurnable.sol"; +import { Forwarder } from "contracts/infra/forwarder/Forwarder.sol"; +import { ForwarderEOAOnly } from "contracts/infra/forwarder/ForwarderEOAOnly.sol"; +import { TWRegistry } from "contracts/infra/TWRegistry.sol"; +import { TWFactory } from "contracts/infra/TWFactory.sol"; +import { Multiwrap } from "contracts/prebuilts/multiwrap/Multiwrap.sol"; +import { Pack } from "contracts/prebuilts/pack/Pack.sol"; +import { PackVRFDirect } from "contracts/prebuilts/pack/PackVRFDirect.sol"; +import { Split } from "contracts/prebuilts/split/Split.sol"; +import { DropERC20 } from "contracts/prebuilts/drop/DropERC20.sol"; +import { DropERC721 } from "contracts/prebuilts/drop/DropERC721.sol"; +import { DropERC1155 } from "contracts/prebuilts/drop/DropERC1155.sol"; +import { TokenERC20 } from "contracts/prebuilts/token/TokenERC20.sol"; +import { TokenERC721 } from "contracts/prebuilts/token/TokenERC721.sol"; +import { TokenERC1155 } from "contracts/prebuilts/token/TokenERC1155.sol"; +import { Marketplace } from "contracts/prebuilts/marketplace-legacy/Marketplace.sol"; +import { VoteERC20 } from "contracts/prebuilts/vote/VoteERC20.sol"; +import { SignatureDrop } from "contracts/prebuilts/signature-drop/SignatureDrop.sol"; +import { ContractPublisher } from "contracts/infra/ContractPublisher.sol"; +import { IContractPublisher } from "contracts/infra/interface/IContractPublisher.sol"; +import { AirdropERC721 } from "contracts/prebuilts/unaudited/airdrop/AirdropERC721.sol"; +import { AirdropERC721Claimable } from "contracts/prebuilts/unaudited/airdrop/AirdropERC721Claimable.sol"; +import { AirdropERC20 } from "contracts/prebuilts/unaudited/airdrop/AirdropERC20.sol"; +import { AirdropERC20Claimable } from "contracts/prebuilts/unaudited/airdrop/AirdropERC20Claimable.sol"; +import { AirdropERC1155 } from "contracts/prebuilts/unaudited/airdrop/AirdropERC1155.sol"; +import { AirdropERC1155Claimable } from "contracts/prebuilts/unaudited/airdrop/AirdropERC1155Claimable.sol"; +import { NFTStake } from "contracts/prebuilts/staking/NFTStake.sol"; +import { EditionStake } from "contracts/prebuilts/staking/EditionStake.sol"; +import { TokenStake } from "contracts/prebuilts/staking/TokenStake.sol"; +import { Mock, MockContract } from "../mocks/Mock.sol"; +import { MockContractPublisher } from "../mocks/MockContractPublisher.sol"; +import { Permissions } from "contracts/extension/Permissions.sol"; +import { PermissionsEnumerable } from "contracts/extension/PermissionsEnumerable.sol"; +import { ERC1155Holder, IERC1155Receiver } from "@openzeppelin/contracts/token/ERC1155/utils/ERC1155Holder.sol"; +import { ERC721Holder, IERC721Receiver } from "@openzeppelin/contracts/token/ERC721/utils/ERC721Holder.sol"; +import { Clones } from "@openzeppelin/contracts/proxy/Clones.sol"; +import { Strings } from "contracts/lib/Strings.sol"; +import { IERC165 } from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; + +abstract contract BaseTest is DSTest, Test { + string public constant NAME = "NAME"; + string public constant SYMBOL = "SYMBOL"; + string public constant CONTRACT_URI = "CONTRACT_URI"; + address public constant NATIVE_TOKEN = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + + MockERC20 public erc20; + MockERC20 public erc20Aux; + MockERC721 public erc721; + MockERC1155 public erc1155; + MockERC721NonBurnable public erc721NonBurnable; + MockERC1155NonBurnable public erc1155NonBurnable; + WETH9 public weth; + + address public forwarder; + address public eoaForwarder; + address public registry; + address public factory; + address public fee; + address public contractPublisher; + address public linkToken; + address public vrfV2Wrapper; + + address public factoryAdmin = address(0x10000); + address public deployer = address(0x20000); + address public saleRecipient = address(0x30000); + address public royaltyRecipient = address(0x30001); + address public platformFeeRecipient = address(0x30002); + uint128 public royaltyBps = 500; // 5% + uint128 public platformFeeBps = 500; // 5% + uint256 public constant MAX_BPS = 10_000; // 100% + + uint256 public privateKey = 1234; + address public signer; + + // airdrop-claimable inputs + uint256[] internal _airdropTokenIdsERC721; + bytes32 internal _airdropMerkleRootERC721; + + uint256[] internal _airdropTokenIdsERC1155; + uint256[] internal _airdropWalletClaimCountERC1155; + uint256[] internal _airdropAmountsERC1155; + bytes32[] internal _airdropMerkleRootERC1155; + + bytes32 internal _airdropMerkleRootERC20; + + Wallet internal airdropTokenOwner; + // airdrop-claimable inputs -- over + + mapping(bytes32 => address) public contracts; + + function setUp() public virtual { + /// setup main factory contracts. registry, fee, factory. + vm.startPrank(factoryAdmin); + + signer = vm.addr(privateKey); + + erc20 = new MockERC20(); + erc20Aux = new MockERC20(); + erc721 = new MockERC721(); + erc1155 = new MockERC1155(); + erc721NonBurnable = new MockERC721NonBurnable(); + erc1155NonBurnable = new MockERC1155NonBurnable(); + weth = new WETH9(); + forwarder = address(new Forwarder()); + eoaForwarder = address(new ForwarderEOAOnly()); + registry = address(new TWRegistry(forwarder)); + factory = address(new TWFactory(forwarder, registry)); + contractPublisher = address(new ContractPublisher(factoryAdmin, forwarder, new MockContractPublisher())); + linkToken = address(new Link()); + vrfV2Wrapper = address(new VRFV2Wrapper()); + TWRegistry(registry).grantRole(TWRegistry(registry).OPERATOR_ROLE(), factory); + TWRegistry(registry).grantRole(TWRegistry(registry).OPERATOR_ROLE(), contractPublisher); + + TWFactory(factory).addImplementation(address(new TokenERC20())); + TWFactory(factory).addImplementation(address(new TokenERC721())); + TWFactory(factory).addImplementation(address(new TokenERC1155())); + TWFactory(factory).addImplementation(address(new DropERC20())); + TWFactory(factory).addImplementation(address(new MockContract(bytes32("DropERC721"), 1))); + TWFactory(factory).addImplementation(address(new DropERC721())); + TWFactory(factory).addImplementation(address(new MockContract(bytes32("DropERC1155"), 1))); + TWFactory(factory).addImplementation(address(new DropERC1155())); + TWFactory(factory).addImplementation(address(new MockContract(bytes32("SignatureDrop"), 1))); + TWFactory(factory).addImplementation(address(new SignatureDrop())); + TWFactory(factory).addImplementation(address(new MockContract(bytes32("Marketplace"), 1))); + TWFactory(factory).addImplementation(address(new Marketplace(address(weth)))); + TWFactory(factory).addImplementation(address(new Split())); + TWFactory(factory).addImplementation(address(new Multiwrap(address(weth)))); + TWFactory(factory).addImplementation(address(new MockContract(bytes32("Pack"), 1))); + TWFactory(factory).addImplementation(address(new MockContract(bytes32("AirdropERC721"), 1))); + TWFactory(factory).addImplementation(address(new AirdropERC721())); + TWFactory(factory).addImplementation(address(new MockContract(bytes32("AirdropERC20"), 1))); + TWFactory(factory).addImplementation(address(new AirdropERC20())); + TWFactory(factory).addImplementation(address(new MockContract(bytes32("AirdropERC1155"), 1))); + TWFactory(factory).addImplementation(address(new AirdropERC1155())); + TWFactory(factory).addImplementation( + address(new PackVRFDirect(address(weth), eoaForwarder, linkToken, vrfV2Wrapper)) + ); + TWFactory(factory).addImplementation(address(new Pack(address(weth)))); + TWFactory(factory).addImplementation(address(new VoteERC20())); + TWFactory(factory).addImplementation(address(new MockContract(bytes32("NFTStake"), 1))); + TWFactory(factory).addImplementation(address(new NFTStake(address(weth)))); + TWFactory(factory).addImplementation(address(new MockContract(bytes32("EditionStake"), 1))); + TWFactory(factory).addImplementation(address(new EditionStake(address(weth)))); + TWFactory(factory).addImplementation(address(new MockContract(bytes32("TokenStake"), 1))); + TWFactory(factory).addImplementation(address(new TokenStake(address(weth)))); + vm.stopPrank(); + + // setup airdrop logic + setupAirdropClaimable(); + + /// deploy proxy for tests + deployContractProxy( + "TokenERC20", + abi.encodeCall( + TokenERC20.initialize, + (signer, NAME, SYMBOL, CONTRACT_URI, forwarders(), saleRecipient, platformFeeRecipient, platformFeeBps) + ) + ); + deployContractProxy( + "TokenERC721", + abi.encodeCall( + TokenERC721.initialize, + ( + signer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ); + deployContractProxy( + "TokenERC1155", + abi.encodeCall( + TokenERC1155.initialize, + ( + signer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ); + deployContractProxy( + "DropERC20", + abi.encodeCall( + DropERC20.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + platformFeeRecipient, + platformFeeBps + ) + ) + ); + deployContractProxy( + "DropERC721", + abi.encodeCall( + DropERC721.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ); + deployContractProxy( + "DropERC1155", + abi.encodeCall( + DropERC1155.initialize, + ( + deployer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ); + deployContractProxy( + "SignatureDrop", + abi.encodeCall( + SignatureDrop.initialize, + ( + signer, + NAME, + SYMBOL, + CONTRACT_URI, + forwarders(), + saleRecipient, + royaltyRecipient, + royaltyBps, + platformFeeBps, + platformFeeRecipient + ) + ) + ); + deployContractProxy( + "Marketplace", + abi.encodeCall( + Marketplace.initialize, + (deployer, CONTRACT_URI, forwarders(), platformFeeRecipient, platformFeeBps) + ) + ); + deployContractProxy( + "Multiwrap", + abi.encodeCall( + Multiwrap.initialize, + (deployer, NAME, SYMBOL, CONTRACT_URI, forwarders(), royaltyRecipient, royaltyBps) + ) + ); + deployContractProxy( + "Pack", + abi.encodeCall( + Pack.initialize, + (deployer, NAME, SYMBOL, CONTRACT_URI, forwarders(), royaltyRecipient, royaltyBps) + ) + ); + + deployContractProxy( + "PackVRFDirect", + abi.encodeCall( + PackVRFDirect.initialize, + (deployer, NAME, SYMBOL, CONTRACT_URI, forwarders(), royaltyRecipient, royaltyBps) + ) + ); + + deployContractProxy( + "AirdropERC721", + abi.encodeCall(AirdropERC721.initialize, (deployer, CONTRACT_URI, forwarders())) + ); + deployContractProxy( + "AirdropERC20", + abi.encodeCall(AirdropERC20.initialize, (deployer, CONTRACT_URI, forwarders())) + ); + deployContractProxy( + "AirdropERC1155", + abi.encodeCall(AirdropERC1155.initialize, (deployer, CONTRACT_URI, forwarders())) + ); + deployContractProxy( + "NFTStake", + abi.encodeCall( + NFTStake.initialize, + (deployer, CONTRACT_URI, forwarders(), address(erc20), address(erc721), 60, 1) + ) + ); + deployContractProxy( + "EditionStake", + abi.encodeCall( + EditionStake.initialize, + (deployer, CONTRACT_URI, forwarders(), address(erc20), address(erc1155), 60, 1) + ) + ); + deployContractProxy( + "TokenStake", + abi.encodeCall( + TokenStake.initialize, + (deployer, CONTRACT_URI, forwarders(), address(erc20), address(erc20Aux), 60, 3, 50) + ) + ); + } + + function deployContractProxy( + string memory _contractType, + bytes memory _initializer + ) public returns (address proxyAddress) { + vm.startPrank(deployer); + proxyAddress = TWFactory(factory).deployProxy(bytes32(bytes(_contractType)), _initializer); + contracts[bytes32(bytes(_contractType))] = proxyAddress; + vm.stopPrank(); + } + + function getContract(string memory _name) public view returns (address) { + return contracts[bytes32(bytes(_name))]; + } + + function getActor(uint160 _index) public pure returns (address) { + return address(uint160(0x50000 + _index)); + } + + function getWallet() public returns (Wallet wallet) { + wallet = new Wallet(); + } + + function assertIsOwnerERC721(address _token, address _owner, uint256[] memory _tokenIds) internal { + for (uint256 i = 0; i < _tokenIds.length; i += 1) { + bool isOwnerOfToken = MockERC721(_token).ownerOf(_tokenIds[i]) == _owner; + assertTrue(isOwnerOfToken); + } + } + + function assertIsNotOwnerERC721(address _token, address _owner, uint256[] memory _tokenIds) internal { + for (uint256 i = 0; i < _tokenIds.length; i += 1) { + bool isOwnerOfToken = MockERC721(_token).ownerOf(_tokenIds[i]) == _owner; + assertTrue(!isOwnerOfToken); + } + } + + function assertBalERC1155Eq( + address _token, + address _owner, + uint256[] memory _tokenIds, + uint256[] memory _amounts + ) internal { + require(_tokenIds.length == _amounts.length, "unequal lengths"); + + for (uint256 i = 0; i < _tokenIds.length; i += 1) { + assertEq(MockERC1155(_token).balanceOf(_owner, _tokenIds[i]), _amounts[i]); + } + } + + function assertBalERC1155Gte( + address _token, + address _owner, + uint256[] memory _tokenIds, + uint256[] memory _amounts + ) internal { + require(_tokenIds.length == _amounts.length, "unequal lengths"); + + for (uint256 i = 0; i < _tokenIds.length; i += 1) { + assertTrue(MockERC1155(_token).balanceOf(_owner, _tokenIds[i]) >= _amounts[i]); + } + } + + function assertBalERC20Eq(address _token, address _owner, uint256 _amount) internal { + assertEq(MockERC20(_token).balanceOf(_owner), _amount); + } + + function assertBalERC20Gte(address _token, address _owner, uint256 _amount) internal { + assertTrue(MockERC20(_token).balanceOf(_owner) >= _amount); + } + + function forwarders() public view returns (address[] memory) { + address[] memory _forwarders = new address[](1); + _forwarders[0] = forwarder; + return _forwarders; + } + + function setupAirdropClaimable() public { + string[] memory inputs = new string[](3); + inputs[0] = "node"; + inputs[1] = "src/test/scripts/generateRootAirdrop.ts"; + inputs[2] = Strings.toString(uint256(5)); + + bytes memory result = vm.ffi(inputs); + bytes32 root = abi.decode(result, (bytes32)); + + airdropTokenOwner = getWallet(); + + // ERC721 + for (uint256 i = 0; i < 1000; i++) { + _airdropTokenIdsERC721.push(i); + } + _airdropMerkleRootERC721 = root; + + // ERC1155 + for (uint256 i = 0; i < 5; i++) { + _airdropTokenIdsERC1155.push(i); + _airdropAmountsERC1155.push(100); + _airdropWalletClaimCountERC1155.push(1); + _airdropMerkleRootERC1155.push(root); + } + + // ERC20 + _airdropMerkleRootERC20 = root; + } +} diff --git a/src/test/utils/ChainlinkVRF.sol b/src/test/utils/ChainlinkVRF.sol new file mode 100644 index 000000000..cd5eaf76a --- /dev/null +++ b/src/test/utils/ChainlinkVRF.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: Apache 2.0 +pragma solidity ^0.8.0; + +contract Link { + function transferAndCall(address, uint256, bytes calldata) external returns (bool) {} +} + +contract VRFV2Wrapper { + uint256 private nextId = 5; + + function lastRequestId() external view returns (uint256 id) { + id = nextId; + } + + function calculateRequestPrice(uint32 _callbackGasLimit) external pure returns (uint256) { + return _callbackGasLimit; + } +} diff --git a/src/test/utils/Console.sol b/src/test/utils/Console.sol new file mode 100644 index 000000000..c8e655ca6 --- /dev/null +++ b/src/test/utils/Console.sol @@ -0,0 +1,1531 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.4.22 <0.9.0; + +library console { + address constant CONSOLE_ADDRESS = address(0x000000000000000000636F6e736F6c652e6c6f67); + + function _sendLogPayload(bytes memory payload) private view { + uint256 payloadLength = payload.length; + address consoleAddress = CONSOLE_ADDRESS; + assembly { + let payloadStart := add(payload, 32) + let r := staticcall(gas(), consoleAddress, payloadStart, payloadLength, 0, 0) + } + } + + function log() internal view { + _sendLogPayload(abi.encodeWithSignature("log()")); + } + + function logInt(int256 p0) internal view { + _sendLogPayload(abi.encodeWithSignature("log(int)", p0)); + } + + function logUint(uint256 p0) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint)", p0)); + } + + function logString(string memory p0) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string)", p0)); + } + + function logBool(bool p0) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool)", p0)); + } + + function logAddress(address p0) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address)", p0)); + } + + function logBytes(bytes memory p0) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bytes)", p0)); + } + + function logBytes1(bytes1 p0) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bytes1)", p0)); + } + + function logBytes2(bytes2 p0) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bytes2)", p0)); + } + + function logBytes3(bytes3 p0) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bytes3)", p0)); + } + + function logBytes4(bytes4 p0) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bytes4)", p0)); + } + + function logBytes5(bytes5 p0) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bytes5)", p0)); + } + + function logBytes6(bytes6 p0) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bytes6)", p0)); + } + + function logBytes7(bytes7 p0) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bytes7)", p0)); + } + + function logBytes8(bytes8 p0) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bytes8)", p0)); + } + + function logBytes9(bytes9 p0) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bytes9)", p0)); + } + + function logBytes10(bytes10 p0) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bytes10)", p0)); + } + + function logBytes11(bytes11 p0) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bytes11)", p0)); + } + + function logBytes12(bytes12 p0) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bytes12)", p0)); + } + + function logBytes13(bytes13 p0) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bytes13)", p0)); + } + + function logBytes14(bytes14 p0) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bytes14)", p0)); + } + + function logBytes15(bytes15 p0) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bytes15)", p0)); + } + + function logBytes16(bytes16 p0) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bytes16)", p0)); + } + + function logBytes17(bytes17 p0) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bytes17)", p0)); + } + + function logBytes18(bytes18 p0) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bytes18)", p0)); + } + + function logBytes19(bytes19 p0) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bytes19)", p0)); + } + + function logBytes20(bytes20 p0) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bytes20)", p0)); + } + + function logBytes21(bytes21 p0) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bytes21)", p0)); + } + + function logBytes22(bytes22 p0) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bytes22)", p0)); + } + + function logBytes23(bytes23 p0) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bytes23)", p0)); + } + + function logBytes24(bytes24 p0) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bytes24)", p0)); + } + + function logBytes25(bytes25 p0) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bytes25)", p0)); + } + + function logBytes26(bytes26 p0) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bytes26)", p0)); + } + + function logBytes27(bytes27 p0) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bytes27)", p0)); + } + + function logBytes28(bytes28 p0) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bytes28)", p0)); + } + + function logBytes29(bytes29 p0) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bytes29)", p0)); + } + + function logBytes30(bytes30 p0) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bytes30)", p0)); + } + + function logBytes31(bytes31 p0) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bytes31)", p0)); + } + + function logBytes32(bytes32 p0) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bytes32)", p0)); + } + + function log(uint256 p0) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint)", p0)); + } + + function log(string memory p0) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string)", p0)); + } + + function log(bool p0) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool)", p0)); + } + + function log(address p0) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address)", p0)); + } + + function log(uint256 p0, uint256 p1) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,uint)", p0, p1)); + } + + function log(uint256 p0, string memory p1) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,string)", p0, p1)); + } + + function log(uint256 p0, bool p1) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,bool)", p0, p1)); + } + + function log(uint256 p0, address p1) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,address)", p0, p1)); + } + + function log(string memory p0, uint256 p1) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,uint)", p0, p1)); + } + + function log(string memory p0, string memory p1) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,string)", p0, p1)); + } + + function log(string memory p0, bool p1) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,bool)", p0, p1)); + } + + function log(string memory p0, address p1) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,address)", p0, p1)); + } + + function log(bool p0, uint256 p1) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,uint)", p0, p1)); + } + + function log(bool p0, string memory p1) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,string)", p0, p1)); + } + + function log(bool p0, bool p1) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,bool)", p0, p1)); + } + + function log(bool p0, address p1) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,address)", p0, p1)); + } + + function log(address p0, uint256 p1) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,uint)", p0, p1)); + } + + function log(address p0, string memory p1) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,string)", p0, p1)); + } + + function log(address p0, bool p1) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,bool)", p0, p1)); + } + + function log(address p0, address p1) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,address)", p0, p1)); + } + + function log(uint256 p0, uint256 p1, uint256 p2) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,uint,uint)", p0, p1, p2)); + } + + function log(uint256 p0, uint256 p1, string memory p2) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,uint,string)", p0, p1, p2)); + } + + function log(uint256 p0, uint256 p1, bool p2) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,uint,bool)", p0, p1, p2)); + } + + function log(uint256 p0, uint256 p1, address p2) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,uint,address)", p0, p1, p2)); + } + + function log(uint256 p0, string memory p1, uint256 p2) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,string,uint)", p0, p1, p2)); + } + + function log(uint256 p0, string memory p1, string memory p2) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,string,string)", p0, p1, p2)); + } + + function log(uint256 p0, string memory p1, bool p2) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,string,bool)", p0, p1, p2)); + } + + function log(uint256 p0, string memory p1, address p2) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,string,address)", p0, p1, p2)); + } + + function log(uint256 p0, bool p1, uint256 p2) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,bool,uint)", p0, p1, p2)); + } + + function log(uint256 p0, bool p1, string memory p2) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,bool,string)", p0, p1, p2)); + } + + function log(uint256 p0, bool p1, bool p2) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,bool,bool)", p0, p1, p2)); + } + + function log(uint256 p0, bool p1, address p2) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,bool,address)", p0, p1, p2)); + } + + function log(uint256 p0, address p1, uint256 p2) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,address,uint)", p0, p1, p2)); + } + + function log(uint256 p0, address p1, string memory p2) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,address,string)", p0, p1, p2)); + } + + function log(uint256 p0, address p1, bool p2) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,address,bool)", p0, p1, p2)); + } + + function log(uint256 p0, address p1, address p2) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,address,address)", p0, p1, p2)); + } + + function log(string memory p0, uint256 p1, uint256 p2) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,uint,uint)", p0, p1, p2)); + } + + function log(string memory p0, uint256 p1, string memory p2) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,uint,string)", p0, p1, p2)); + } + + function log(string memory p0, uint256 p1, bool p2) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,uint,bool)", p0, p1, p2)); + } + + function log(string memory p0, uint256 p1, address p2) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,uint,address)", p0, p1, p2)); + } + + function log(string memory p0, string memory p1, uint256 p2) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,string,uint)", p0, p1, p2)); + } + + function log(string memory p0, string memory p1, string memory p2) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,string,string)", p0, p1, p2)); + } + + function log(string memory p0, string memory p1, bool p2) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,string,bool)", p0, p1, p2)); + } + + function log(string memory p0, string memory p1, address p2) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,string,address)", p0, p1, p2)); + } + + function log(string memory p0, bool p1, uint256 p2) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,bool,uint)", p0, p1, p2)); + } + + function log(string memory p0, bool p1, string memory p2) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,bool,string)", p0, p1, p2)); + } + + function log(string memory p0, bool p1, bool p2) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,bool,bool)", p0, p1, p2)); + } + + function log(string memory p0, bool p1, address p2) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,bool,address)", p0, p1, p2)); + } + + function log(string memory p0, address p1, uint256 p2) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,address,uint)", p0, p1, p2)); + } + + function log(string memory p0, address p1, string memory p2) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,address,string)", p0, p1, p2)); + } + + function log(string memory p0, address p1, bool p2) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,address,bool)", p0, p1, p2)); + } + + function log(string memory p0, address p1, address p2) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,address,address)", p0, p1, p2)); + } + + function log(bool p0, uint256 p1, uint256 p2) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,uint,uint)", p0, p1, p2)); + } + + function log(bool p0, uint256 p1, string memory p2) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,uint,string)", p0, p1, p2)); + } + + function log(bool p0, uint256 p1, bool p2) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,uint,bool)", p0, p1, p2)); + } + + function log(bool p0, uint256 p1, address p2) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,uint,address)", p0, p1, p2)); + } + + function log(bool p0, string memory p1, uint256 p2) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,string,uint)", p0, p1, p2)); + } + + function log(bool p0, string memory p1, string memory p2) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,string,string)", p0, p1, p2)); + } + + function log(bool p0, string memory p1, bool p2) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,string,bool)", p0, p1, p2)); + } + + function log(bool p0, string memory p1, address p2) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,string,address)", p0, p1, p2)); + } + + function log(bool p0, bool p1, uint256 p2) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,bool,uint)", p0, p1, p2)); + } + + function log(bool p0, bool p1, string memory p2) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,bool,string)", p0, p1, p2)); + } + + function log(bool p0, bool p1, bool p2) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,bool,bool)", p0, p1, p2)); + } + + function log(bool p0, bool p1, address p2) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,bool,address)", p0, p1, p2)); + } + + function log(bool p0, address p1, uint256 p2) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,address,uint)", p0, p1, p2)); + } + + function log(bool p0, address p1, string memory p2) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,address,string)", p0, p1, p2)); + } + + function log(bool p0, address p1, bool p2) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,address,bool)", p0, p1, p2)); + } + + function log(bool p0, address p1, address p2) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,address,address)", p0, p1, p2)); + } + + function log(address p0, uint256 p1, uint256 p2) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,uint,uint)", p0, p1, p2)); + } + + function log(address p0, uint256 p1, string memory p2) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,uint,string)", p0, p1, p2)); + } + + function log(address p0, uint256 p1, bool p2) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,uint,bool)", p0, p1, p2)); + } + + function log(address p0, uint256 p1, address p2) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,uint,address)", p0, p1, p2)); + } + + function log(address p0, string memory p1, uint256 p2) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,string,uint)", p0, p1, p2)); + } + + function log(address p0, string memory p1, string memory p2) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,string,string)", p0, p1, p2)); + } + + function log(address p0, string memory p1, bool p2) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,string,bool)", p0, p1, p2)); + } + + function log(address p0, string memory p1, address p2) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,string,address)", p0, p1, p2)); + } + + function log(address p0, bool p1, uint256 p2) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,bool,uint)", p0, p1, p2)); + } + + function log(address p0, bool p1, string memory p2) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,bool,string)", p0, p1, p2)); + } + + function log(address p0, bool p1, bool p2) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,bool,bool)", p0, p1, p2)); + } + + function log(address p0, bool p1, address p2) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,bool,address)", p0, p1, p2)); + } + + function log(address p0, address p1, uint256 p2) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,address,uint)", p0, p1, p2)); + } + + function log(address p0, address p1, string memory p2) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,address,string)", p0, p1, p2)); + } + + function log(address p0, address p1, bool p2) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,address,bool)", p0, p1, p2)); + } + + function log(address p0, address p1, address p2) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,address,address)", p0, p1, p2)); + } + + function log(uint256 p0, uint256 p1, uint256 p2, uint256 p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,uint,uint,uint)", p0, p1, p2, p3)); + } + + function log(uint256 p0, uint256 p1, uint256 p2, string memory p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,uint,uint,string)", p0, p1, p2, p3)); + } + + function log(uint256 p0, uint256 p1, uint256 p2, bool p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,uint,uint,bool)", p0, p1, p2, p3)); + } + + function log(uint256 p0, uint256 p1, uint256 p2, address p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,uint,uint,address)", p0, p1, p2, p3)); + } + + function log(uint256 p0, uint256 p1, string memory p2, uint256 p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,uint,string,uint)", p0, p1, p2, p3)); + } + + function log(uint256 p0, uint256 p1, string memory p2, string memory p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,uint,string,string)", p0, p1, p2, p3)); + } + + function log(uint256 p0, uint256 p1, string memory p2, bool p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,uint,string,bool)", p0, p1, p2, p3)); + } + + function log(uint256 p0, uint256 p1, string memory p2, address p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,uint,string,address)", p0, p1, p2, p3)); + } + + function log(uint256 p0, uint256 p1, bool p2, uint256 p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,uint,bool,uint)", p0, p1, p2, p3)); + } + + function log(uint256 p0, uint256 p1, bool p2, string memory p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,uint,bool,string)", p0, p1, p2, p3)); + } + + function log(uint256 p0, uint256 p1, bool p2, bool p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,uint,bool,bool)", p0, p1, p2, p3)); + } + + function log(uint256 p0, uint256 p1, bool p2, address p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,uint,bool,address)", p0, p1, p2, p3)); + } + + function log(uint256 p0, uint256 p1, address p2, uint256 p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,uint,address,uint)", p0, p1, p2, p3)); + } + + function log(uint256 p0, uint256 p1, address p2, string memory p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,uint,address,string)", p0, p1, p2, p3)); + } + + function log(uint256 p0, uint256 p1, address p2, bool p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,uint,address,bool)", p0, p1, p2, p3)); + } + + function log(uint256 p0, uint256 p1, address p2, address p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,uint,address,address)", p0, p1, p2, p3)); + } + + function log(uint256 p0, string memory p1, uint256 p2, uint256 p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,string,uint,uint)", p0, p1, p2, p3)); + } + + function log(uint256 p0, string memory p1, uint256 p2, string memory p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,string,uint,string)", p0, p1, p2, p3)); + } + + function log(uint256 p0, string memory p1, uint256 p2, bool p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,string,uint,bool)", p0, p1, p2, p3)); + } + + function log(uint256 p0, string memory p1, uint256 p2, address p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,string,uint,address)", p0, p1, p2, p3)); + } + + function log(uint256 p0, string memory p1, string memory p2, uint256 p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,string,string,uint)", p0, p1, p2, p3)); + } + + function log(uint256 p0, string memory p1, string memory p2, string memory p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,string,string,string)", p0, p1, p2, p3)); + } + + function log(uint256 p0, string memory p1, string memory p2, bool p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,string,string,bool)", p0, p1, p2, p3)); + } + + function log(uint256 p0, string memory p1, string memory p2, address p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,string,string,address)", p0, p1, p2, p3)); + } + + function log(uint256 p0, string memory p1, bool p2, uint256 p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,string,bool,uint)", p0, p1, p2, p3)); + } + + function log(uint256 p0, string memory p1, bool p2, string memory p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,string,bool,string)", p0, p1, p2, p3)); + } + + function log(uint256 p0, string memory p1, bool p2, bool p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,string,bool,bool)", p0, p1, p2, p3)); + } + + function log(uint256 p0, string memory p1, bool p2, address p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,string,bool,address)", p0, p1, p2, p3)); + } + + function log(uint256 p0, string memory p1, address p2, uint256 p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,string,address,uint)", p0, p1, p2, p3)); + } + + function log(uint256 p0, string memory p1, address p2, string memory p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,string,address,string)", p0, p1, p2, p3)); + } + + function log(uint256 p0, string memory p1, address p2, bool p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,string,address,bool)", p0, p1, p2, p3)); + } + + function log(uint256 p0, string memory p1, address p2, address p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,string,address,address)", p0, p1, p2, p3)); + } + + function log(uint256 p0, bool p1, uint256 p2, uint256 p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,bool,uint,uint)", p0, p1, p2, p3)); + } + + function log(uint256 p0, bool p1, uint256 p2, string memory p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,bool,uint,string)", p0, p1, p2, p3)); + } + + function log(uint256 p0, bool p1, uint256 p2, bool p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,bool,uint,bool)", p0, p1, p2, p3)); + } + + function log(uint256 p0, bool p1, uint256 p2, address p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,bool,uint,address)", p0, p1, p2, p3)); + } + + function log(uint256 p0, bool p1, string memory p2, uint256 p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,bool,string,uint)", p0, p1, p2, p3)); + } + + function log(uint256 p0, bool p1, string memory p2, string memory p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,bool,string,string)", p0, p1, p2, p3)); + } + + function log(uint256 p0, bool p1, string memory p2, bool p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,bool,string,bool)", p0, p1, p2, p3)); + } + + function log(uint256 p0, bool p1, string memory p2, address p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,bool,string,address)", p0, p1, p2, p3)); + } + + function log(uint256 p0, bool p1, bool p2, uint256 p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,bool,bool,uint)", p0, p1, p2, p3)); + } + + function log(uint256 p0, bool p1, bool p2, string memory p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,bool,bool,string)", p0, p1, p2, p3)); + } + + function log(uint256 p0, bool p1, bool p2, bool p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,bool,bool,bool)", p0, p1, p2, p3)); + } + + function log(uint256 p0, bool p1, bool p2, address p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,bool,bool,address)", p0, p1, p2, p3)); + } + + function log(uint256 p0, bool p1, address p2, uint256 p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,bool,address,uint)", p0, p1, p2, p3)); + } + + function log(uint256 p0, bool p1, address p2, string memory p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,bool,address,string)", p0, p1, p2, p3)); + } + + function log(uint256 p0, bool p1, address p2, bool p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,bool,address,bool)", p0, p1, p2, p3)); + } + + function log(uint256 p0, bool p1, address p2, address p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,bool,address,address)", p0, p1, p2, p3)); + } + + function log(uint256 p0, address p1, uint256 p2, uint256 p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,address,uint,uint)", p0, p1, p2, p3)); + } + + function log(uint256 p0, address p1, uint256 p2, string memory p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,address,uint,string)", p0, p1, p2, p3)); + } + + function log(uint256 p0, address p1, uint256 p2, bool p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,address,uint,bool)", p0, p1, p2, p3)); + } + + function log(uint256 p0, address p1, uint256 p2, address p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,address,uint,address)", p0, p1, p2, p3)); + } + + function log(uint256 p0, address p1, string memory p2, uint256 p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,address,string,uint)", p0, p1, p2, p3)); + } + + function log(uint256 p0, address p1, string memory p2, string memory p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,address,string,string)", p0, p1, p2, p3)); + } + + function log(uint256 p0, address p1, string memory p2, bool p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,address,string,bool)", p0, p1, p2, p3)); + } + + function log(uint256 p0, address p1, string memory p2, address p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,address,string,address)", p0, p1, p2, p3)); + } + + function log(uint256 p0, address p1, bool p2, uint256 p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,address,bool,uint)", p0, p1, p2, p3)); + } + + function log(uint256 p0, address p1, bool p2, string memory p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,address,bool,string)", p0, p1, p2, p3)); + } + + function log(uint256 p0, address p1, bool p2, bool p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,address,bool,bool)", p0, p1, p2, p3)); + } + + function log(uint256 p0, address p1, bool p2, address p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,address,bool,address)", p0, p1, p2, p3)); + } + + function log(uint256 p0, address p1, address p2, uint256 p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,address,address,uint)", p0, p1, p2, p3)); + } + + function log(uint256 p0, address p1, address p2, string memory p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,address,address,string)", p0, p1, p2, p3)); + } + + function log(uint256 p0, address p1, address p2, bool p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,address,address,bool)", p0, p1, p2, p3)); + } + + function log(uint256 p0, address p1, address p2, address p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(uint,address,address,address)", p0, p1, p2, p3)); + } + + function log(string memory p0, uint256 p1, uint256 p2, uint256 p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,uint,uint,uint)", p0, p1, p2, p3)); + } + + function log(string memory p0, uint256 p1, uint256 p2, string memory p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,uint,uint,string)", p0, p1, p2, p3)); + } + + function log(string memory p0, uint256 p1, uint256 p2, bool p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,uint,uint,bool)", p0, p1, p2, p3)); + } + + function log(string memory p0, uint256 p1, uint256 p2, address p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,uint,uint,address)", p0, p1, p2, p3)); + } + + function log(string memory p0, uint256 p1, string memory p2, uint256 p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,uint,string,uint)", p0, p1, p2, p3)); + } + + function log(string memory p0, uint256 p1, string memory p2, string memory p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,uint,string,string)", p0, p1, p2, p3)); + } + + function log(string memory p0, uint256 p1, string memory p2, bool p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,uint,string,bool)", p0, p1, p2, p3)); + } + + function log(string memory p0, uint256 p1, string memory p2, address p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,uint,string,address)", p0, p1, p2, p3)); + } + + function log(string memory p0, uint256 p1, bool p2, uint256 p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,uint,bool,uint)", p0, p1, p2, p3)); + } + + function log(string memory p0, uint256 p1, bool p2, string memory p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,uint,bool,string)", p0, p1, p2, p3)); + } + + function log(string memory p0, uint256 p1, bool p2, bool p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,uint,bool,bool)", p0, p1, p2, p3)); + } + + function log(string memory p0, uint256 p1, bool p2, address p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,uint,bool,address)", p0, p1, p2, p3)); + } + + function log(string memory p0, uint256 p1, address p2, uint256 p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,uint,address,uint)", p0, p1, p2, p3)); + } + + function log(string memory p0, uint256 p1, address p2, string memory p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,uint,address,string)", p0, p1, p2, p3)); + } + + function log(string memory p0, uint256 p1, address p2, bool p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,uint,address,bool)", p0, p1, p2, p3)); + } + + function log(string memory p0, uint256 p1, address p2, address p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,uint,address,address)", p0, p1, p2, p3)); + } + + function log(string memory p0, string memory p1, uint256 p2, uint256 p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,string,uint,uint)", p0, p1, p2, p3)); + } + + function log(string memory p0, string memory p1, uint256 p2, string memory p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,string,uint,string)", p0, p1, p2, p3)); + } + + function log(string memory p0, string memory p1, uint256 p2, bool p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,string,uint,bool)", p0, p1, p2, p3)); + } + + function log(string memory p0, string memory p1, uint256 p2, address p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,string,uint,address)", p0, p1, p2, p3)); + } + + function log(string memory p0, string memory p1, string memory p2, uint256 p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,string,string,uint)", p0, p1, p2, p3)); + } + + function log(string memory p0, string memory p1, string memory p2, string memory p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,string,string,string)", p0, p1, p2, p3)); + } + + function log(string memory p0, string memory p1, string memory p2, bool p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,string,string,bool)", p0, p1, p2, p3)); + } + + function log(string memory p0, string memory p1, string memory p2, address p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,string,string,address)", p0, p1, p2, p3)); + } + + function log(string memory p0, string memory p1, bool p2, uint256 p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,string,bool,uint)", p0, p1, p2, p3)); + } + + function log(string memory p0, string memory p1, bool p2, string memory p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,string,bool,string)", p0, p1, p2, p3)); + } + + function log(string memory p0, string memory p1, bool p2, bool p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,string,bool,bool)", p0, p1, p2, p3)); + } + + function log(string memory p0, string memory p1, bool p2, address p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,string,bool,address)", p0, p1, p2, p3)); + } + + function log(string memory p0, string memory p1, address p2, uint256 p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,string,address,uint)", p0, p1, p2, p3)); + } + + function log(string memory p0, string memory p1, address p2, string memory p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,string,address,string)", p0, p1, p2, p3)); + } + + function log(string memory p0, string memory p1, address p2, bool p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,string,address,bool)", p0, p1, p2, p3)); + } + + function log(string memory p0, string memory p1, address p2, address p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,string,address,address)", p0, p1, p2, p3)); + } + + function log(string memory p0, bool p1, uint256 p2, uint256 p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,bool,uint,uint)", p0, p1, p2, p3)); + } + + function log(string memory p0, bool p1, uint256 p2, string memory p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,bool,uint,string)", p0, p1, p2, p3)); + } + + function log(string memory p0, bool p1, uint256 p2, bool p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,bool,uint,bool)", p0, p1, p2, p3)); + } + + function log(string memory p0, bool p1, uint256 p2, address p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,bool,uint,address)", p0, p1, p2, p3)); + } + + function log(string memory p0, bool p1, string memory p2, uint256 p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,bool,string,uint)", p0, p1, p2, p3)); + } + + function log(string memory p0, bool p1, string memory p2, string memory p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,bool,string,string)", p0, p1, p2, p3)); + } + + function log(string memory p0, bool p1, string memory p2, bool p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,bool,string,bool)", p0, p1, p2, p3)); + } + + function log(string memory p0, bool p1, string memory p2, address p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,bool,string,address)", p0, p1, p2, p3)); + } + + function log(string memory p0, bool p1, bool p2, uint256 p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,bool,bool,uint)", p0, p1, p2, p3)); + } + + function log(string memory p0, bool p1, bool p2, string memory p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,bool,bool,string)", p0, p1, p2, p3)); + } + + function log(string memory p0, bool p1, bool p2, bool p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,bool,bool,bool)", p0, p1, p2, p3)); + } + + function log(string memory p0, bool p1, bool p2, address p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,bool,bool,address)", p0, p1, p2, p3)); + } + + function log(string memory p0, bool p1, address p2, uint256 p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,bool,address,uint)", p0, p1, p2, p3)); + } + + function log(string memory p0, bool p1, address p2, string memory p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,bool,address,string)", p0, p1, p2, p3)); + } + + function log(string memory p0, bool p1, address p2, bool p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,bool,address,bool)", p0, p1, p2, p3)); + } + + function log(string memory p0, bool p1, address p2, address p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,bool,address,address)", p0, p1, p2, p3)); + } + + function log(string memory p0, address p1, uint256 p2, uint256 p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,address,uint,uint)", p0, p1, p2, p3)); + } + + function log(string memory p0, address p1, uint256 p2, string memory p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,address,uint,string)", p0, p1, p2, p3)); + } + + function log(string memory p0, address p1, uint256 p2, bool p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,address,uint,bool)", p0, p1, p2, p3)); + } + + function log(string memory p0, address p1, uint256 p2, address p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,address,uint,address)", p0, p1, p2, p3)); + } + + function log(string memory p0, address p1, string memory p2, uint256 p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,address,string,uint)", p0, p1, p2, p3)); + } + + function log(string memory p0, address p1, string memory p2, string memory p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,address,string,string)", p0, p1, p2, p3)); + } + + function log(string memory p0, address p1, string memory p2, bool p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,address,string,bool)", p0, p1, p2, p3)); + } + + function log(string memory p0, address p1, string memory p2, address p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,address,string,address)", p0, p1, p2, p3)); + } + + function log(string memory p0, address p1, bool p2, uint256 p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,address,bool,uint)", p0, p1, p2, p3)); + } + + function log(string memory p0, address p1, bool p2, string memory p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,address,bool,string)", p0, p1, p2, p3)); + } + + function log(string memory p0, address p1, bool p2, bool p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,address,bool,bool)", p0, p1, p2, p3)); + } + + function log(string memory p0, address p1, bool p2, address p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,address,bool,address)", p0, p1, p2, p3)); + } + + function log(string memory p0, address p1, address p2, uint256 p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,address,address,uint)", p0, p1, p2, p3)); + } + + function log(string memory p0, address p1, address p2, string memory p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,address,address,string)", p0, p1, p2, p3)); + } + + function log(string memory p0, address p1, address p2, bool p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,address,address,bool)", p0, p1, p2, p3)); + } + + function log(string memory p0, address p1, address p2, address p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(string,address,address,address)", p0, p1, p2, p3)); + } + + function log(bool p0, uint256 p1, uint256 p2, uint256 p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,uint,uint,uint)", p0, p1, p2, p3)); + } + + function log(bool p0, uint256 p1, uint256 p2, string memory p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,uint,uint,string)", p0, p1, p2, p3)); + } + + function log(bool p0, uint256 p1, uint256 p2, bool p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,uint,uint,bool)", p0, p1, p2, p3)); + } + + function log(bool p0, uint256 p1, uint256 p2, address p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,uint,uint,address)", p0, p1, p2, p3)); + } + + function log(bool p0, uint256 p1, string memory p2, uint256 p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,uint,string,uint)", p0, p1, p2, p3)); + } + + function log(bool p0, uint256 p1, string memory p2, string memory p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,uint,string,string)", p0, p1, p2, p3)); + } + + function log(bool p0, uint256 p1, string memory p2, bool p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,uint,string,bool)", p0, p1, p2, p3)); + } + + function log(bool p0, uint256 p1, string memory p2, address p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,uint,string,address)", p0, p1, p2, p3)); + } + + function log(bool p0, uint256 p1, bool p2, uint256 p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,uint,bool,uint)", p0, p1, p2, p3)); + } + + function log(bool p0, uint256 p1, bool p2, string memory p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,uint,bool,string)", p0, p1, p2, p3)); + } + + function log(bool p0, uint256 p1, bool p2, bool p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,uint,bool,bool)", p0, p1, p2, p3)); + } + + function log(bool p0, uint256 p1, bool p2, address p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,uint,bool,address)", p0, p1, p2, p3)); + } + + function log(bool p0, uint256 p1, address p2, uint256 p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,uint,address,uint)", p0, p1, p2, p3)); + } + + function log(bool p0, uint256 p1, address p2, string memory p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,uint,address,string)", p0, p1, p2, p3)); + } + + function log(bool p0, uint256 p1, address p2, bool p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,uint,address,bool)", p0, p1, p2, p3)); + } + + function log(bool p0, uint256 p1, address p2, address p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,uint,address,address)", p0, p1, p2, p3)); + } + + function log(bool p0, string memory p1, uint256 p2, uint256 p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,string,uint,uint)", p0, p1, p2, p3)); + } + + function log(bool p0, string memory p1, uint256 p2, string memory p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,string,uint,string)", p0, p1, p2, p3)); + } + + function log(bool p0, string memory p1, uint256 p2, bool p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,string,uint,bool)", p0, p1, p2, p3)); + } + + function log(bool p0, string memory p1, uint256 p2, address p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,string,uint,address)", p0, p1, p2, p3)); + } + + function log(bool p0, string memory p1, string memory p2, uint256 p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,string,string,uint)", p0, p1, p2, p3)); + } + + function log(bool p0, string memory p1, string memory p2, string memory p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,string,string,string)", p0, p1, p2, p3)); + } + + function log(bool p0, string memory p1, string memory p2, bool p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,string,string,bool)", p0, p1, p2, p3)); + } + + function log(bool p0, string memory p1, string memory p2, address p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,string,string,address)", p0, p1, p2, p3)); + } + + function log(bool p0, string memory p1, bool p2, uint256 p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,string,bool,uint)", p0, p1, p2, p3)); + } + + function log(bool p0, string memory p1, bool p2, string memory p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,string,bool,string)", p0, p1, p2, p3)); + } + + function log(bool p0, string memory p1, bool p2, bool p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,string,bool,bool)", p0, p1, p2, p3)); + } + + function log(bool p0, string memory p1, bool p2, address p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,string,bool,address)", p0, p1, p2, p3)); + } + + function log(bool p0, string memory p1, address p2, uint256 p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,string,address,uint)", p0, p1, p2, p3)); + } + + function log(bool p0, string memory p1, address p2, string memory p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,string,address,string)", p0, p1, p2, p3)); + } + + function log(bool p0, string memory p1, address p2, bool p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,string,address,bool)", p0, p1, p2, p3)); + } + + function log(bool p0, string memory p1, address p2, address p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,string,address,address)", p0, p1, p2, p3)); + } + + function log(bool p0, bool p1, uint256 p2, uint256 p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,bool,uint,uint)", p0, p1, p2, p3)); + } + + function log(bool p0, bool p1, uint256 p2, string memory p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,bool,uint,string)", p0, p1, p2, p3)); + } + + function log(bool p0, bool p1, uint256 p2, bool p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,bool,uint,bool)", p0, p1, p2, p3)); + } + + function log(bool p0, bool p1, uint256 p2, address p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,bool,uint,address)", p0, p1, p2, p3)); + } + + function log(bool p0, bool p1, string memory p2, uint256 p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,bool,string,uint)", p0, p1, p2, p3)); + } + + function log(bool p0, bool p1, string memory p2, string memory p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,bool,string,string)", p0, p1, p2, p3)); + } + + function log(bool p0, bool p1, string memory p2, bool p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,bool,string,bool)", p0, p1, p2, p3)); + } + + function log(bool p0, bool p1, string memory p2, address p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,bool,string,address)", p0, p1, p2, p3)); + } + + function log(bool p0, bool p1, bool p2, uint256 p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,bool,bool,uint)", p0, p1, p2, p3)); + } + + function log(bool p0, bool p1, bool p2, string memory p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,bool,bool,string)", p0, p1, p2, p3)); + } + + function log(bool p0, bool p1, bool p2, bool p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,bool,bool,bool)", p0, p1, p2, p3)); + } + + function log(bool p0, bool p1, bool p2, address p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,bool,bool,address)", p0, p1, p2, p3)); + } + + function log(bool p0, bool p1, address p2, uint256 p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,bool,address,uint)", p0, p1, p2, p3)); + } + + function log(bool p0, bool p1, address p2, string memory p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,bool,address,string)", p0, p1, p2, p3)); + } + + function log(bool p0, bool p1, address p2, bool p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,bool,address,bool)", p0, p1, p2, p3)); + } + + function log(bool p0, bool p1, address p2, address p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,bool,address,address)", p0, p1, p2, p3)); + } + + function log(bool p0, address p1, uint256 p2, uint256 p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,address,uint,uint)", p0, p1, p2, p3)); + } + + function log(bool p0, address p1, uint256 p2, string memory p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,address,uint,string)", p0, p1, p2, p3)); + } + + function log(bool p0, address p1, uint256 p2, bool p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,address,uint,bool)", p0, p1, p2, p3)); + } + + function log(bool p0, address p1, uint256 p2, address p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,address,uint,address)", p0, p1, p2, p3)); + } + + function log(bool p0, address p1, string memory p2, uint256 p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,address,string,uint)", p0, p1, p2, p3)); + } + + function log(bool p0, address p1, string memory p2, string memory p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,address,string,string)", p0, p1, p2, p3)); + } + + function log(bool p0, address p1, string memory p2, bool p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,address,string,bool)", p0, p1, p2, p3)); + } + + function log(bool p0, address p1, string memory p2, address p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,address,string,address)", p0, p1, p2, p3)); + } + + function log(bool p0, address p1, bool p2, uint256 p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,address,bool,uint)", p0, p1, p2, p3)); + } + + function log(bool p0, address p1, bool p2, string memory p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,address,bool,string)", p0, p1, p2, p3)); + } + + function log(bool p0, address p1, bool p2, bool p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,address,bool,bool)", p0, p1, p2, p3)); + } + + function log(bool p0, address p1, bool p2, address p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,address,bool,address)", p0, p1, p2, p3)); + } + + function log(bool p0, address p1, address p2, uint256 p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,address,address,uint)", p0, p1, p2, p3)); + } + + function log(bool p0, address p1, address p2, string memory p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,address,address,string)", p0, p1, p2, p3)); + } + + function log(bool p0, address p1, address p2, bool p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,address,address,bool)", p0, p1, p2, p3)); + } + + function log(bool p0, address p1, address p2, address p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(bool,address,address,address)", p0, p1, p2, p3)); + } + + function log(address p0, uint256 p1, uint256 p2, uint256 p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,uint,uint,uint)", p0, p1, p2, p3)); + } + + function log(address p0, uint256 p1, uint256 p2, string memory p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,uint,uint,string)", p0, p1, p2, p3)); + } + + function log(address p0, uint256 p1, uint256 p2, bool p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,uint,uint,bool)", p0, p1, p2, p3)); + } + + function log(address p0, uint256 p1, uint256 p2, address p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,uint,uint,address)", p0, p1, p2, p3)); + } + + function log(address p0, uint256 p1, string memory p2, uint256 p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,uint,string,uint)", p0, p1, p2, p3)); + } + + function log(address p0, uint256 p1, string memory p2, string memory p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,uint,string,string)", p0, p1, p2, p3)); + } + + function log(address p0, uint256 p1, string memory p2, bool p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,uint,string,bool)", p0, p1, p2, p3)); + } + + function log(address p0, uint256 p1, string memory p2, address p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,uint,string,address)", p0, p1, p2, p3)); + } + + function log(address p0, uint256 p1, bool p2, uint256 p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,uint,bool,uint)", p0, p1, p2, p3)); + } + + function log(address p0, uint256 p1, bool p2, string memory p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,uint,bool,string)", p0, p1, p2, p3)); + } + + function log(address p0, uint256 p1, bool p2, bool p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,uint,bool,bool)", p0, p1, p2, p3)); + } + + function log(address p0, uint256 p1, bool p2, address p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,uint,bool,address)", p0, p1, p2, p3)); + } + + function log(address p0, uint256 p1, address p2, uint256 p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,uint,address,uint)", p0, p1, p2, p3)); + } + + function log(address p0, uint256 p1, address p2, string memory p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,uint,address,string)", p0, p1, p2, p3)); + } + + function log(address p0, uint256 p1, address p2, bool p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,uint,address,bool)", p0, p1, p2, p3)); + } + + function log(address p0, uint256 p1, address p2, address p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,uint,address,address)", p0, p1, p2, p3)); + } + + function log(address p0, string memory p1, uint256 p2, uint256 p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,string,uint,uint)", p0, p1, p2, p3)); + } + + function log(address p0, string memory p1, uint256 p2, string memory p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,string,uint,string)", p0, p1, p2, p3)); + } + + function log(address p0, string memory p1, uint256 p2, bool p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,string,uint,bool)", p0, p1, p2, p3)); + } + + function log(address p0, string memory p1, uint256 p2, address p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,string,uint,address)", p0, p1, p2, p3)); + } + + function log(address p0, string memory p1, string memory p2, uint256 p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,string,string,uint)", p0, p1, p2, p3)); + } + + function log(address p0, string memory p1, string memory p2, string memory p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,string,string,string)", p0, p1, p2, p3)); + } + + function log(address p0, string memory p1, string memory p2, bool p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,string,string,bool)", p0, p1, p2, p3)); + } + + function log(address p0, string memory p1, string memory p2, address p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,string,string,address)", p0, p1, p2, p3)); + } + + function log(address p0, string memory p1, bool p2, uint256 p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,string,bool,uint)", p0, p1, p2, p3)); + } + + function log(address p0, string memory p1, bool p2, string memory p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,string,bool,string)", p0, p1, p2, p3)); + } + + function log(address p0, string memory p1, bool p2, bool p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,string,bool,bool)", p0, p1, p2, p3)); + } + + function log(address p0, string memory p1, bool p2, address p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,string,bool,address)", p0, p1, p2, p3)); + } + + function log(address p0, string memory p1, address p2, uint256 p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,string,address,uint)", p0, p1, p2, p3)); + } + + function log(address p0, string memory p1, address p2, string memory p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,string,address,string)", p0, p1, p2, p3)); + } + + function log(address p0, string memory p1, address p2, bool p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,string,address,bool)", p0, p1, p2, p3)); + } + + function log(address p0, string memory p1, address p2, address p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,string,address,address)", p0, p1, p2, p3)); + } + + function log(address p0, bool p1, uint256 p2, uint256 p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,bool,uint,uint)", p0, p1, p2, p3)); + } + + function log(address p0, bool p1, uint256 p2, string memory p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,bool,uint,string)", p0, p1, p2, p3)); + } + + function log(address p0, bool p1, uint256 p2, bool p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,bool,uint,bool)", p0, p1, p2, p3)); + } + + function log(address p0, bool p1, uint256 p2, address p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,bool,uint,address)", p0, p1, p2, p3)); + } + + function log(address p0, bool p1, string memory p2, uint256 p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,bool,string,uint)", p0, p1, p2, p3)); + } + + function log(address p0, bool p1, string memory p2, string memory p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,bool,string,string)", p0, p1, p2, p3)); + } + + function log(address p0, bool p1, string memory p2, bool p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,bool,string,bool)", p0, p1, p2, p3)); + } + + function log(address p0, bool p1, string memory p2, address p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,bool,string,address)", p0, p1, p2, p3)); + } + + function log(address p0, bool p1, bool p2, uint256 p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,bool,bool,uint)", p0, p1, p2, p3)); + } + + function log(address p0, bool p1, bool p2, string memory p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,bool,bool,string)", p0, p1, p2, p3)); + } + + function log(address p0, bool p1, bool p2, bool p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,bool,bool,bool)", p0, p1, p2, p3)); + } + + function log(address p0, bool p1, bool p2, address p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,bool,bool,address)", p0, p1, p2, p3)); + } + + function log(address p0, bool p1, address p2, uint256 p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,bool,address,uint)", p0, p1, p2, p3)); + } + + function log(address p0, bool p1, address p2, string memory p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,bool,address,string)", p0, p1, p2, p3)); + } + + function log(address p0, bool p1, address p2, bool p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,bool,address,bool)", p0, p1, p2, p3)); + } + + function log(address p0, bool p1, address p2, address p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,bool,address,address)", p0, p1, p2, p3)); + } + + function log(address p0, address p1, uint256 p2, uint256 p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,address,uint,uint)", p0, p1, p2, p3)); + } + + function log(address p0, address p1, uint256 p2, string memory p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,address,uint,string)", p0, p1, p2, p3)); + } + + function log(address p0, address p1, uint256 p2, bool p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,address,uint,bool)", p0, p1, p2, p3)); + } + + function log(address p0, address p1, uint256 p2, address p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,address,uint,address)", p0, p1, p2, p3)); + } + + function log(address p0, address p1, string memory p2, uint256 p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,address,string,uint)", p0, p1, p2, p3)); + } + + function log(address p0, address p1, string memory p2, string memory p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,address,string,string)", p0, p1, p2, p3)); + } + + function log(address p0, address p1, string memory p2, bool p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,address,string,bool)", p0, p1, p2, p3)); + } + + function log(address p0, address p1, string memory p2, address p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,address,string,address)", p0, p1, p2, p3)); + } + + function log(address p0, address p1, bool p2, uint256 p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,address,bool,uint)", p0, p1, p2, p3)); + } + + function log(address p0, address p1, bool p2, string memory p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,address,bool,string)", p0, p1, p2, p3)); + } + + function log(address p0, address p1, bool p2, bool p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,address,bool,bool)", p0, p1, p2, p3)); + } + + function log(address p0, address p1, bool p2, address p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,address,bool,address)", p0, p1, p2, p3)); + } + + function log(address p0, address p1, address p2, uint256 p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,address,address,uint)", p0, p1, p2, p3)); + } + + function log(address p0, address p1, address p2, string memory p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,address,address,string)", p0, p1, p2, p3)); + } + + function log(address p0, address p1, address p2, bool p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,address,address,bool)", p0, p1, p2, p3)); + } + + function log(address p0, address p1, address p2, address p3) internal view { + _sendLogPayload(abi.encodeWithSignature("log(address,address,address,address)", p0, p1, p2, p3)); + } +} diff --git a/src/test/utils/SignatureMint1155Utils.sol b/src/test/utils/SignatureMint1155Utils.sol new file mode 100644 index 000000000..e6be03f55 --- /dev/null +++ b/src/test/utils/SignatureMint1155Utils.sol @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: Apache 2.0 +pragma solidity ^0.8.0; + +import "contracts/extension/interface/ISignatureMintERC1155.sol"; + +contract SignatureMint1155Utils { + bytes32 internal DOMAIN_SEPARATOR; + + constructor() { + bytes32 typeHash = keccak256( + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" + ); + bytes32 hashedName = keccak256(bytes("SignatureMintERC1155")); + bytes32 hashedVersion = keccak256(bytes("1")); + DOMAIN_SEPARATOR = _buildDomainSeparator(typeHash, hashedName, hashedVersion); + } + + bytes32 internal constant TYPEHASH = + keccak256( + "MintRequest(address to,address royaltyRecipient,uint256 royaltyBps,address primarySaleRecipient,uint256 tokenId,string uri,uint256 quantity,uint256 pricePerToken,address currency,uint128 validityStartTimestamp,uint128 validityEndTimestamp,bytes32 uid)" + ); + + function _buildDomainSeparator( + bytes32 typeHash, + bytes32 nameHash, + bytes32 versionHash + ) private view returns (bytes32) { + return keccak256(abi.encode(typeHash, nameHash, versionHash, block.chainid, address(this))); + } + + // computes the hash of a permit + function getStructHash(ISignatureMintERC1155.MintRequest memory _req) internal pure returns (bytes32) { + return + keccak256( + bytes.concat( + abi.encode( + TYPEHASH, + _req.to, + _req.royaltyRecipient, + _req.royaltyBps, + _req.primarySaleRecipient, + _req.tokenId, + keccak256(bytes(_req.uri)) + ), + abi.encode( + _req.quantity, + _req.pricePerToken, + _req.currency, + _req.validityStartTimestamp, + _req.validityEndTimestamp, + _req.uid + ) + ) + ); + } + + // computes the hash of the fully encoded EIP-712 message for the domain, which can be used to recover the signer + function getTypedDataHash(ISignatureMintERC1155.MintRequest memory _req) public view returns (bytes32) { + return keccak256(abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR, getStructHash(_req))); + } +} diff --git a/src/test/utils/Wallet.sol b/src/test/utils/Wallet.sol new file mode 100644 index 000000000..e3f8dc4a9 --- /dev/null +++ b/src/test/utils/Wallet.sol @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@openzeppelin/contracts/token/ERC721/utils/ERC721Holder.sol"; +import "@openzeppelin/contracts/token/ERC1155/utils/ERC1155Holder.sol"; + +import "../mocks/MockERC20.sol"; +import "../mocks/MockERC721.sol"; +import "../mocks/MockERC1155.sol"; + +contract Wallet is ERC721Holder, ERC1155Holder { + function transferERC20(address token, address to, uint256 amount) public { + MockERC20(token).transfer(to, amount); + } + + function setAllowanceERC20(address token, address spender, uint256 allowanceAmount) public { + MockERC20(token).approve(spender, allowanceAmount); + } + + function burnERC20(address token, uint256 amount) public { + MockERC20(token).burn(amount); + } + + function transferERC721(address token, address to, uint256 tokenId) public { + MockERC721(token).transferFrom(address(this), to, tokenId); + } + + function setApprovalForAllERC721(address token, address operator, bool toApprove) public { + MockERC721(token).setApprovalForAll(operator, toApprove); + } + + function burnERC721(address token, uint256 tokenId) public { + MockERC721(token).burn(tokenId); + } + + function transferERC1155(address token, address to, uint256 tokenId, uint256 amount, bytes calldata data) external { + MockERC1155(token).safeTransferFrom(address(this), to, tokenId, amount, data); + } + + function setApprovalForAllERC1155(address token, address operator, bool toApprove) public { + MockERC1155(token).setApprovalForAll(operator, toApprove); + } + + function burnERC1155(address token, uint256 tokenId, uint256 amount) public { + MockERC1155(token).burn(address(this), tokenId, amount); + } +} diff --git a/src/test/vote-BTT/initialize/initialize.t.sol b/src/test/vote-BTT/initialize/initialize.t.sol new file mode 100644 index 000000000..9e9227ac6 --- /dev/null +++ b/src/test/vote-BTT/initialize/initialize.t.sol @@ -0,0 +1,200 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "../../utils/BaseTest.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; +import { ERC20Vote } from "contracts/base/ERC20Vote.sol"; + +contract MyVoteERC20 is VoteERC20 { + function eip712NameHash() external view returns (bytes32) { + return _EIP712NameHash(); + } + + function eip712VersionHash() external view returns (bytes32) { + return _EIP712VersionHash(); + } +} + +contract VoteERC20Test_Initialize is BaseTest { + address payable public implementation; + address payable public proxy; + address public token; + uint256 public initialVotingDelay; + uint256 public initialVotingPeriod; + uint256 public initialProposalThreshold; + uint256 public initialVoteQuorumFraction; + + event VotingDelaySet(uint256 oldVotingDelay, uint256 newVotingDelay); + event VotingPeriodSet(uint256 oldVotingPeriod, uint256 newVotingPeriod); + event ProposalThresholdSet(uint256 oldProposalThreshold, uint256 newProposalThreshold); + event QuorumNumeratorUpdated(uint256 oldQuorumNumerator, uint256 newQuorumNumerator); + + function setUp() public override { + super.setUp(); + + // Deploy voting token + token = address(new ERC20Vote(deployer, "Voting VoteERC20", "VT")); + + // Voting param initial values + initialVotingDelay = 5; + initialVotingPeriod = 10; + initialProposalThreshold = 100; + initialVoteQuorumFraction = 50; + + // Deploy implementation. + implementation = payable(address(new MyVoteERC20())); + + // Deploy proxy pointing to implementaion. + vm.prank(deployer); + proxy = payable( + address( + new TWProxy( + implementation, + abi.encodeCall( + VoteERC20.initialize, + ( + NAME, + CONTRACT_URI, + forwarders(), + token, + initialVotingDelay, + initialVotingPeriod, + initialProposalThreshold, + initialVoteQuorumFraction + ) + ) + ) + ) + ); + } + + function test_initialize_initializingImplementation() public { + vm.expectRevert("Initializable: contract is already initialized"); + VoteERC20(implementation).initialize( + NAME, + CONTRACT_URI, + forwarders(), + token, + initialVotingDelay, + initialVotingPeriod, + initialProposalThreshold, + initialVoteQuorumFraction + ); + } + + modifier whenNotImplementation() { + _; + } + + function test_initialize_proxyAlreadyInitialized() public whenNotImplementation { + vm.expectRevert("Initializable: contract is already initialized"); + MyVoteERC20(proxy).initialize( + NAME, + CONTRACT_URI, + forwarders(), + token, + initialVotingDelay, + initialVotingPeriod, + initialProposalThreshold, + initialVoteQuorumFraction + ); + } + + modifier whenProxyNotInitialized() { + proxy = payable(address(new TWProxy(implementation, ""))); + _; + } + + function test_initialize() public whenNotImplementation whenProxyNotInitialized { + MyVoteERC20(proxy).initialize( + NAME, + CONTRACT_URI, + forwarders(), + token, + initialVotingDelay, + initialVotingPeriod, + initialProposalThreshold, + initialVoteQuorumFraction + ); + + // check state + MyVoteERC20 voteContract = MyVoteERC20(proxy); + + assertEq(voteContract.eip712NameHash(), keccak256(bytes(NAME))); + assertEq(voteContract.eip712VersionHash(), keccak256(bytes("1"))); + + address[] memory _trustedForwarders = forwarders(); + for (uint256 i = 0; i < _trustedForwarders.length; i++) { + assertTrue(voteContract.isTrustedForwarder(_trustedForwarders[i])); + } + + assertEq(voteContract.name(), NAME); + assertEq(voteContract.contractURI(), CONTRACT_URI); + assertEq(voteContract.votingDelay(), initialVotingDelay); + assertEq(voteContract.votingPeriod(), initialVotingPeriod); + assertEq(voteContract.proposalThreshold(), initialProposalThreshold); + assertEq(voteContract.quorumNumerator(), initialVoteQuorumFraction); + assertEq(address(voteContract.token()), token); + } + + function test_initialize_event_VotingDelaySet() public whenNotImplementation whenProxyNotInitialized { + vm.expectEmit(false, false, false, true); + emit VotingDelaySet(0, initialVotingDelay); + MyVoteERC20(proxy).initialize( + NAME, + CONTRACT_URI, + forwarders(), + token, + initialVotingDelay, + initialVotingPeriod, + initialProposalThreshold, + initialVoteQuorumFraction + ); + } + + function test_initialize_event_VotingPeriodSet() public whenNotImplementation whenProxyNotInitialized { + vm.expectEmit(false, false, false, true); + emit VotingPeriodSet(0, initialVotingPeriod); + MyVoteERC20(proxy).initialize( + NAME, + CONTRACT_URI, + forwarders(), + token, + initialVotingDelay, + initialVotingPeriod, + initialProposalThreshold, + initialVoteQuorumFraction + ); + } + + function test_initialize_event_ProposalThresholdSet() public whenNotImplementation whenProxyNotInitialized { + vm.expectEmit(false, false, false, true); + emit ProposalThresholdSet(0, initialProposalThreshold); + MyVoteERC20(proxy).initialize( + NAME, + CONTRACT_URI, + forwarders(), + token, + initialVotingDelay, + initialVotingPeriod, + initialProposalThreshold, + initialVoteQuorumFraction + ); + } + + function test_initialize_event_QuorumNumeratorUpdated() public whenNotImplementation whenProxyNotInitialized { + vm.expectEmit(false, false, false, true); + emit QuorumNumeratorUpdated(0, initialVoteQuorumFraction); + MyVoteERC20(proxy).initialize( + NAME, + CONTRACT_URI, + forwarders(), + token, + initialVotingDelay, + initialVotingPeriod, + initialProposalThreshold, + initialVoteQuorumFraction + ); + } +} diff --git a/src/test/vote-BTT/initialize/initialize.tree b/src/test/vote-BTT/initialize/initialize.tree new file mode 100644 index 000000000..6b935174b --- /dev/null +++ b/src/test/vote-BTT/initialize/initialize.tree @@ -0,0 +1,30 @@ +initialize( + string memory _name, + string memory _contractURI, + address[] memory _trustedForwarders, + address _token, + uint256 _initialVotingDelay, + uint256 _initialVotingPeriod, + uint256 _initialProposalThreshold, + uint256 _initialVoteQuorumFraction +) +├── when initializing the implementation contract (not proxy) +│ └── it should revert ✅ +└── when it is a proxy to the implementation + └── when it is already initialized + │ └── it should revert ✅ + └── when it is not initialized + └── it should set trustedForwarder mapping to true for all addresses in `_trustedForwarders` ✅ + └── it should correctly set EIP712 name hash and version hash ✅ + └── it should set name to `_name` input param ✅ + └── it should set contractURI to `_contractURI` param value ✅ + └── it should set votingDelay to `_initialVotingDelay` param value ✅ + └── it should emit VotingDelaySet event ✅ + └── it should set votingPeriod to `_initialVotingPeriod` param value ✅ + └── it should emit VotingPeriodSet event ✅ + └── it should set proposalThreshold to `_initialProposalThreshold` param value ✅ + └── it should emit ProposalThresholdSet event ✅ + └── it should set voting token address as the `_token` param value ✅ + └── it should set initial quorum numerator as `_initialVoteQuorumFraction` param value ✅ + └── it should emit QuorumNumeratorUpdated event ✅ + diff --git a/src/test/vote-BTT/other-functions/other.t.sol b/src/test/vote-BTT/other-functions/other.t.sol new file mode 100644 index 000000000..960a94e7e --- /dev/null +++ b/src/test/vote-BTT/other-functions/other.t.sol @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "../../utils/BaseTest.sol"; +import { IStaking721 } from "contracts/extension/interface/IStaking721.sol"; +import { IERC2981 } from "contracts/eip/interface/IERC2981.sol"; + +import "@openzeppelin/contracts-upgradeable/governance/GovernorUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/token/ERC721/IERC721ReceiverUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/token/ERC1155/IERC1155ReceiverUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/utils/introspection/IERC165Upgradeable.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; +import { ERC20Vote } from "contracts/base/ERC20Vote.sol"; + +contract MyVoteERC20 is VoteERC20 {} + +contract VoteERC20Test_OtherFunctions is BaseTest { + address payable public implementation; + address payable public proxy; + + address public token; + uint256 public initialVotingDelay; + uint256 public initialVotingPeriod; + uint256 public initialProposalThreshold; + uint256 public initialVoteQuorumFraction; + + MyVoteERC20 public voteContract; + + function setUp() public override { + super.setUp(); + + // Deploy voting token + vm.prank(deployer); + token = address(new ERC20Vote(deployer, "Voting VoteERC20", "VT")); + + // Voting param initial values + initialVotingDelay = 1; + initialVotingPeriod = 100; + initialProposalThreshold = 10; + initialVoteQuorumFraction = 1; + + // Deploy implementation. + implementation = payable(address(new MyVoteERC20())); + + // Deploy proxy pointing to implementaion. + vm.prank(deployer); + proxy = payable( + address( + new TWProxy( + implementation, + abi.encodeCall( + VoteERC20.initialize, + ( + NAME, + CONTRACT_URI, + forwarders(), + token, + initialVotingDelay, + initialVotingPeriod, + initialProposalThreshold, + initialVoteQuorumFraction + ) + ) + ) + ) + ); + + voteContract = MyVoteERC20(proxy); + } + + function test_contractType() public { + assertEq(voteContract.contractType(), bytes32("VoteERC20")); + } + + function test_contractVersion() public { + assertEq(voteContract.contractVersion(), uint8(1)); + } + + function test_supportsInterface() public { + assertTrue(voteContract.supportsInterface(type(IERC165).interfaceId)); + assertTrue(voteContract.supportsInterface(type(IERC165Upgradeable).interfaceId)); + assertTrue(voteContract.supportsInterface(type(IERC721ReceiverUpgradeable).interfaceId)); + assertTrue(voteContract.supportsInterface(type(IERC1155ReceiverUpgradeable).interfaceId)); + // assertTrue(voteContract.supportsInterface(type(IGovernorUpgradeable).interfaceId)); + + // false for other not supported interfaces + assertFalse(voteContract.supportsInterface(type(IStaking721).interfaceId)); + } +} diff --git a/src/test/vote-BTT/other-functions/other.tree b/src/test/vote-BTT/other-functions/other.tree new file mode 100644 index 000000000..2649d89ae --- /dev/null +++ b/src/test/vote-BTT/other-functions/other.tree @@ -0,0 +1,9 @@ +contractType() +├── it should return bytes32("VoteERC20") ✅ + +contractVersion() +├── it should return uint8(1) ✅ + +supportsInterface(bytes4 interfaceId) +├── it should return true for supported interface ✅ +├── it should return false for not supported interface ✅ diff --git a/src/test/vote-BTT/propose/propose.t.sol b/src/test/vote-BTT/propose/propose.t.sol new file mode 100644 index 000000000..4bdd7f2ad --- /dev/null +++ b/src/test/vote-BTT/propose/propose.t.sol @@ -0,0 +1,281 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "../../utils/BaseTest.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; +import { ERC20Vote } from "contracts/base/ERC20Vote.sol"; + +contract MyVoteERC20 is VoteERC20 {} + +contract VoteERC20Test_Propose is BaseTest { + address payable public implementation; + address payable public proxy; + address internal caller; + string internal _contractURI; + + address public token; + uint256 public initialVotingDelay; + uint256 public initialVotingPeriod; + uint256 public initialProposalThreshold; + uint256 public initialVoteQuorumFraction; + + uint256 public proposalIdOne; + address[] public targetsOne; + uint256[] public valuesOne; + bytes[] public calldatasOne; + string public descriptionOne; + + uint256 public proposalIdTwo; + address[] public targetsTwo; + uint256[] public valuesTwo; + bytes[] public calldatasTwo; + string public descriptionTwo; + + MyVoteERC20 internal voteContract; + + event ProposalCreated( + uint256 proposalId, + address proposer, + address[] targets, + uint256[] values, + string[] signatures, + bytes[] calldatas, + uint256 startBlock, + uint256 endBlock, + string description + ); + + function setUp() public override { + super.setUp(); + + // Deploy voting token + vm.prank(deployer); + token = address(new ERC20Vote(deployer, "Voting VoteERC20", "VT")); + + // Voting param initial values + initialVotingDelay = 1; + initialVotingPeriod = 100; + initialProposalThreshold = 10; + initialVoteQuorumFraction = 1; + + // Deploy implementation. + implementation = payable(address(new MyVoteERC20())); + + caller = getActor(1); + + // Deploy proxy pointing to implementaion. + vm.prank(deployer); + proxy = payable( + address( + new TWProxy( + implementation, + abi.encodeCall( + VoteERC20.initialize, + ( + NAME, + CONTRACT_URI, + forwarders(), + token, + initialVotingDelay, + initialVotingPeriod, + initialProposalThreshold, + initialVoteQuorumFraction + ) + ) + ) + ) + ); + + voteContract = MyVoteERC20(proxy); + _contractURI = "ipfs://contracturi"; + + // mint governance tokens + vm.startPrank(deployer); + ERC20Vote(token).mintTo(caller, 100); + ERC20Vote(token).mintTo(deployer, 100); + vm.stopPrank(); + + // delegate votes to self + vm.prank(caller); + ERC20Vote(token).delegate(caller); + vm.prank(deployer); + ERC20Vote(token).delegate(deployer); + + vm.roll(2); + + // create first proposal + _createProposalOne(); + } + + function _createProposalOne() internal { + descriptionOne = "set proposal one"; + + bytes memory data = abi.encodeWithSelector(VoteERC20.setContractURI.selector, _contractURI); + + targetsOne.push(address(voteContract)); + valuesOne.push(0); + calldatasOne.push(data); + + vm.prank(deployer); + proposalIdOne = voteContract.propose(targetsOne, valuesOne, calldatasOne, descriptionOne); + } + + function _setupProposalTwo() internal { + descriptionTwo = "set proposal two"; + + bytes memory data = abi.encodeWithSelector(VoteERC20.setContractURI.selector, _contractURI); + + targetsTwo.push(address(voteContract)); + valuesTwo.push(0); + calldatasTwo.push(data); + } + + function test_propose_votesBelowThreshold() public { + _setupProposalTwo(); + + vm.prank(address(0x123)); // random address that doesn't have threshold votes + vm.expectRevert("Governor: proposer votes below proposal threshold"); + voteContract.propose(targetsTwo, valuesTwo, calldatasTwo, descriptionTwo); + } + + modifier hasThresholdVotes() { + _; + } + + function test_propose_emptyTargets() public hasThresholdVotes { + address[] memory _targets; + uint256[] memory _values; + bytes[] memory _calldatas; + string memory _description; + + vm.prank(caller); + vm.expectRevert("Governor: empty proposal"); + voteContract.propose(_targets, _values, _calldatas, _description); + } + + modifier whenNotEmptyTargets() { + _; + } + + function test_propose_lengthMismatchTargetsValues() public hasThresholdVotes whenNotEmptyTargets { + _setupProposalTwo(); + + uint256[] memory _values; + + vm.prank(caller); + vm.expectRevert("Governor: invalid proposal length"); + voteContract.propose(targetsTwo, _values, calldatasTwo, descriptionTwo); + } + + modifier whenTargetValuesEqualLength() { + _; + } + + function test_propose_lengthMismatchTargetsCalldatas() + public + hasThresholdVotes + whenNotEmptyTargets + whenTargetValuesEqualLength + { + _setupProposalTwo(); + + bytes[] memory _calldatas; + + vm.prank(caller); + vm.expectRevert("Governor: invalid proposal length"); + voteContract.propose(targetsTwo, valuesTwo, _calldatas, descriptionTwo); + } + + modifier whenTargetCalldatasEqualLength() { + _; + } + + function test_propose_proposalAlreadyExists() + public + hasThresholdVotes + whenNotEmptyTargets + whenTargetValuesEqualLength + whenTargetCalldatasEqualLength + { + // creating proposalOne again + + vm.prank(caller); + vm.expectRevert("Governor: proposal already exists"); + voteContract.propose(targetsOne, valuesOne, calldatasOne, descriptionOne); + } + + modifier whenProposalNotAlreadyExists() { + _; + } + + function test_propose() + public + hasThresholdVotes + whenNotEmptyTargets + whenTargetValuesEqualLength + whenTargetCalldatasEqualLength + whenProposalNotAlreadyExists + { + _setupProposalTwo(); + + vm.prank(caller); + proposalIdTwo = voteContract.propose(targetsTwo, valuesTwo, calldatasTwo, descriptionTwo); + + assertEq(voteContract.proposalSnapshot(proposalIdTwo), voteContract.votingDelay() + block.number); + assertEq( + voteContract.proposalDeadline(proposalIdTwo), + voteContract.proposalSnapshot(proposalIdTwo) + voteContract.votingPeriod() + ); + assertEq(voteContract.proposalIndex(), 2); // because two proposals have been created + assertEq(voteContract.getAllProposals().length, 2); + + ( + uint256 _proposalId, + address _proposer, + uint256 _startBlock, + uint256 _endBlock, + string memory _description + ) = voteContract.proposals(1); + + assertEq(_proposalId, proposalIdTwo); + assertEq(_proposer, caller); + assertEq(_startBlock, voteContract.proposalSnapshot(proposalIdTwo)); + assertEq(_endBlock, voteContract.proposalDeadline(proposalIdTwo)); + assertEq(_description, descriptionTwo); + } + + function test_propose_event_ProposalCreated() + public + hasThresholdVotes + whenNotEmptyTargets + whenTargetValuesEqualLength + whenTargetCalldatasEqualLength + whenProposalNotAlreadyExists + { + _setupProposalTwo(); + uint256 _expectedProposalId = voteContract.hashProposal( + targetsTwo, + valuesTwo, + calldatasTwo, + keccak256(bytes(descriptionTwo)) + ); + string[] memory signatures = new string[](targetsTwo.length); + + vm.startPrank(caller); + vm.expectEmit(false, false, false, true); + emit ProposalCreated( + _expectedProposalId, + caller, + targetsTwo, + valuesTwo, + signatures, + calldatasTwo, + voteContract.votingDelay() + block.number, + voteContract.votingDelay() + block.number + voteContract.votingPeriod(), + descriptionTwo + ); + voteContract.propose(targetsTwo, valuesTwo, calldatasTwo, descriptionTwo); + vm.stopPrank(); + } +} diff --git a/src/test/vote-BTT/propose/propose.tree b/src/test/vote-BTT/propose/propose.tree new file mode 100644 index 000000000..5df017a7e --- /dev/null +++ b/src/test/vote-BTT/propose/propose.tree @@ -0,0 +1,26 @@ +propose( + address[] memory targets, + uint256[] memory values, + bytes[] memory calldatas, + string memory description +) +├── when caller has votes below proposal threshold + │ └── it should revert ✅ + └── when caller has votes above or equal to proposal threshold + └── when length of `targets` is zero + │ └── it should revert ✅ + └── when length of `targets` is not zero + └── when lengths of `targets` and `values` not equal + │ └── it should revert ✅ + └── when lengths of `targets` and `values` are equal + └── when lengths of `targets` and `calldatas` not equal + │ └── it should revert ✅ + └── when lengths of `targets` and `calldatas` are equal + └── when proposal already exists + │ └── it should revert ✅ + └── when proposal doesn't already exist + └── it should set vote start deadline equal to block number plus voting delay ✅ + └── it should set vote end deadline equal to voting period plus vote start deadline ✅ + └── it should increment proposalIndex by 1 ✅ + └── it should add the new proposal in proposals mapping ✅ + └── it should emit ProposalCreated event ✅ \ No newline at end of file diff --git a/src/test/vote-BTT/set-contract-uri/setContractURI.t.sol b/src/test/vote-BTT/set-contract-uri/setContractURI.t.sol new file mode 100644 index 000000000..cc35af82e --- /dev/null +++ b/src/test/vote-BTT/set-contract-uri/setContractURI.t.sol @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "../../utils/BaseTest.sol"; + +import { TWProxy } from "contracts/infra/TWProxy.sol"; +import { ERC20Vote } from "contracts/base/ERC20Vote.sol"; + +contract MyVoteERC20 is VoteERC20 {} + +contract VoteERC20Test_SetContractURI is BaseTest { + address payable public implementation; + address payable public proxy; + address internal caller; + string internal _contractURI; + + address public token; + uint256 public initialVotingDelay; + uint256 public initialVotingPeriod; + uint256 public initialProposalThreshold; + uint256 public initialVoteQuorumFraction; + + uint256 public proposalId; + address[] public targets; + uint256[] public values; + bytes[] public calldatas; + string public description; + + MyVoteERC20 internal voteContract; + + function setUp() public override { + super.setUp(); + + // Deploy voting token + vm.prank(deployer); + token = address(new ERC20Vote(deployer, "Voting VoteERC20", "VT")); + + // Voting param initial values + initialVotingDelay = 1; + initialVotingPeriod = 100; + initialProposalThreshold = 10; + initialVoteQuorumFraction = 1; + + // Deploy implementation. + implementation = payable(address(new MyVoteERC20())); + + caller = getActor(1); + + // Deploy proxy pointing to implementaion. + vm.prank(deployer); + proxy = payable( + address( + new TWProxy( + implementation, + abi.encodeCall( + VoteERC20.initialize, + ( + NAME, + CONTRACT_URI, + forwarders(), + token, + initialVotingDelay, + initialVotingPeriod, + initialProposalThreshold, + initialVoteQuorumFraction + ) + ) + ) + ) + ); + + voteContract = MyVoteERC20(proxy); + _contractURI = "ipfs://contracturi"; + + // mint governance tokens + vm.startPrank(deployer); + ERC20Vote(token).mintTo(caller, 100); + ERC20Vote(token).mintTo(deployer, 100); + vm.stopPrank(); + + // delegate votes to self + vm.prank(caller); + ERC20Vote(token).delegate(caller); + vm.prank(deployer); + ERC20Vote(token).delegate(deployer); + } + + function _createProposalForSetContractURI() internal { + description = "set contract URI"; + + bytes memory data = abi.encodeWithSelector(VoteERC20.setContractURI.selector, _contractURI); + + targets.push(address(voteContract)); + values.push(0); + calldatas.push(data); + + vm.prank(deployer); + proposalId = voteContract.propose(targets, values, calldatas, description); + } + + function test_setContractURI_callerNotAuthorized() public { + vm.prank(address(0x123)); + vm.expectRevert("Governor: onlyGovernance"); + voteContract.setContractURI(_contractURI); + } + + modifier whenCallerAuthorized() { + vm.roll(2); + _createProposalForSetContractURI(); + _; + } + + function test_setContractURI_empty() public whenCallerAuthorized { + vm.roll(10); + // first try execute without votes + vm.expectRevert("Governor: proposal not successful"); + voteContract.execute(targets, values, calldatas, keccak256(bytes(description))); + + // vote on proposal + vm.prank(caller); + voteContract.castVote(proposalId, 1); + + // execute + vm.roll(200); // deadline must be over, before execute can be called + voteContract.execute(targets, values, calldatas, keccak256(bytes(description))); + + // check state: get contract uri + assertEq(voteContract.contractURI(), _contractURI); + } +} diff --git a/src/test/vote-BTT/set-contract-uri/setContractURI.tree b/src/test/vote-BTT/set-contract-uri/setContractURI.tree new file mode 100644 index 000000000..f7819fc38 --- /dev/null +++ b/src/test/vote-BTT/set-contract-uri/setContractURI.tree @@ -0,0 +1,8 @@ +setContractURI(string calldata uri) +├── when caller is not authorized (i.e. execution not going through governance proposals) + │ └── it should revert ✅ + └── when caller is authorized (execution through governance proposals) + └── when `uri` is empty + │ └── it should update contract URI to empty string ✅ + └── when `uri` is not empty + └── it should update contract URI to `uri` ✅ \ No newline at end of file diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 000000000..2c7893d9e --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,40 @@ +{ + // see https://www.typescriptlang.org/tsconfig to better understand tsconfigs + "include": ["typechain"], + "compilerOptions": { + "module": "esnext", + "lib": ["esnext"], + "importHelpers": true, + // output .d.ts declaration files for consumers + "declaration": true, + // output .js.map sourcemap files for consumers + "sourceMap": true, + // match output dir to input dir. e.g. dist/index instead of dist/src/index + "rootDir": "./typechain", + // stricter type-checking for stronger correctness. Recommended by TS + "strict": true, + // linter checks for common issues + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + // noUnused* overlap with @typescript-eslint/no-unused-vars, can disable if duplicative + "noUnusedLocals": false, + "noUnusedParameters": false, + // use Node's module resolution algorithm, instead of the legacy TS one + "moduleResolution": "node", + "resolveJsonModule": true, + // interop between ESM and CJS modules. Recommended by TS + "esModuleInterop": true, + // significant perf increase by skipping checking .d.ts files, particularly those in node_modules. Recommended by TS + "skipLibCheck": true, + // error out if import and file system have a casing mismatch. Recommended by TS + "forceConsistentCasingInFileNames": true, + // `tsdx build` ignores this option, but it is commonly used when type-checking separately with `tsc` + "noEmit": true, + "target": "ES6" + }, + "ts-node": { + "compilerOptions": { + "module": "CommonJS" + } + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 000000000..e65b2c0e0 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,42 @@ +{ + // see https://www.typescriptlang.org/tsconfig to better understand tsconfigs + "include": ["typechain", "./scripts", "./test", "./utils"], + "files": ["./hardhat.config.ts"], + "compilerOptions": { + "module": "esnext", + "lib": ["esnext"], + "importHelpers": true, + // output .d.ts declaration files for consumers + "declaration": true, + // output .js.map sourcemap files for consumers + "sourceMap": true, + // match output dir to input dir. e.g. dist/index instead of dist/src/index + "rootDir": "./", + "baseUrl": "./", + // stricter type-checking for stronger correctness. Recommended by TS + "strict": true, + // linter checks for common issues + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + // noUnused* overlap with @typescript-eslint/no-unused-vars, can disable if duplicative + "noUnusedLocals": false, + "noUnusedParameters": false, + // use Node's module resolution algorithm, instead of the legacy TS one + "moduleResolution": "node", + "resolveJsonModule": true, + // interop between ESM and CJS modules. Recommended by TS + "esModuleInterop": true, + // significant perf increase by skipping checking .d.ts files, particularly those in node_modules. Recommended by TS + "skipLibCheck": true, + // error out if import and file system have a casing mismatch. Recommended by TS + "forceConsistentCasingInFileNames": true, + // `tsdx build` ignores this option, but it is commonly used when type-checking separately with `tsc` + "noEmit": true, + "target": "ES6" + }, + "ts-node": { + "compilerOptions": { + "module": "CommonJS" + } + } +} diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 000000000..d9f06021f --- /dev/null +++ b/yarn.lock @@ -0,0 +1,6403 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@aashutoshrathi/word-wrap@^1.2.3": + version "1.2.6" + resolved "https://registry.yarnpkg.com/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz#bd9154aec9983f77b3a034ecaa015c2e4201f6cf" + integrity sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA== + +"@babel/code-frame@^7.0.0": + version "7.23.5" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.23.5.tgz#9009b69a8c602293476ad598ff53e4562e15c244" + integrity sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA== + dependencies: + "@babel/highlight" "^7.23.4" + chalk "^2.4.2" + +"@babel/helper-validator-identifier@^7.22.20": + version "7.22.20" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz#c4ae002c61d2879e724581d96665583dbc1dc0e0" + integrity sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A== + +"@babel/highlight@^7.23.4": + version "7.23.4" + resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.23.4.tgz#edaadf4d8232e1a961432db785091207ead0621b" + integrity sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A== + dependencies: + "@babel/helper-validator-identifier" "^7.22.20" + chalk "^2.4.2" + js-tokens "^4.0.0" + +"@balena/dockerignore@^1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@balena/dockerignore/-/dockerignore-1.0.2.tgz#9ffe4726915251e8eb69f44ef3547e0da2c03e0d" + integrity sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q== + +"@cspotcode/source-map-support@^0.8.0": + version "0.8.1" + resolved "https://registry.yarnpkg.com/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz#00629c35a688e05a88b1cda684fb9d5e73f000a1" + integrity sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw== + dependencies: + "@jridgewell/trace-mapping" "0.3.9" + +"@esbuild/linux-loong64@0.14.54": + version "0.14.54" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.14.54.tgz#de2a4be678bd4d0d1ffbb86e6de779cde5999028" + integrity sha512-bZBrLAIX1kpWelV0XemxBZllyRmM6vgFQQG2GdNb+r3Fkp0FOh1NJSvekXDs7jq70k4euu1cryLMfU+mTXlEpw== + +"@eslint-community/eslint-utils@^4.2.0": + version "4.4.0" + resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz#a23514e8fb9af1269d5f7788aa556798d61c6b59" + integrity sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA== + dependencies: + eslint-visitor-keys "^3.3.0" + +"@eslint-community/regexpp@^4.4.0", "@eslint-community/regexpp@^4.6.1": + version "4.10.0" + resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.10.0.tgz#548f6de556857c8bb73bbee70c35dc82a2e74d63" + integrity sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA== + +"@eslint/eslintrc@^2.1.4": + version "2.1.4" + resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-2.1.4.tgz#388a269f0f25c1b6adc317b5a2c55714894c70ad" + integrity sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ== + dependencies: + ajv "^6.12.4" + debug "^4.3.2" + espree "^9.6.0" + globals "^13.19.0" + ignore "^5.2.0" + import-fresh "^3.2.1" + js-yaml "^4.1.0" + minimatch "^3.1.2" + strip-json-comments "^3.1.1" + +"@eslint/js@8.56.0": + version "8.56.0" + resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.56.0.tgz#ef20350fec605a7f7035a01764731b2de0f3782b" + integrity sha512-gMsVel9D7f2HLkBma9VbtzZRehRogVRfbr++f06nL2vnCGCNlzOD+/MUov/F4p8myyAHspEhVobgjpX64q5m6A== + +"@ethereumjs/rlp@^4.0.1": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@ethereumjs/rlp/-/rlp-4.0.1.tgz#626fabfd9081baab3d0a3074b0c7ecaf674aaa41" + integrity sha512-tqsQiBQDQdmPWE1xkkBq4rlSW5QZpLOUJ5RJh2/9fug+q9tnUhuZoVLk7s0scUIKTOzEtR72DFBXI4WiZcMpvw== + +"@ethereumjs/util@^8.1.0": + version "8.1.0" + resolved "https://registry.yarnpkg.com/@ethereumjs/util/-/util-8.1.0.tgz#299df97fb6b034e0577ce9f94c7d9d1004409ed4" + integrity sha512-zQ0IqbdX8FZ9aw11vP+dZkKDkS+kgIvQPHnSAXzP9pLu+Rfu3D3XEeLbicvoXJTYnhZiPmsZUxgdzXwNKxRPbA== + dependencies: + "@ethereumjs/rlp" "^4.0.1" + ethereum-cryptography "^2.0.0" + micro-ftch "^0.3.1" + +"@ethersproject/abi@5.7.0", "@ethersproject/abi@^5.0.9", "@ethersproject/abi@^5.1.2", "@ethersproject/abi@^5.7.0": + version "5.7.0" + resolved "https://registry.yarnpkg.com/@ethersproject/abi/-/abi-5.7.0.tgz#b3f3e045bbbeed1af3947335c247ad625a44e449" + integrity sha512-351ktp42TiRcYB3H1OP8yajPeAQstMW/yCFokj/AthP9bLHzQFPlOrxOcwYEDkUAICmOHljvN4K39OMTMUa9RA== + dependencies: + "@ethersproject/address" "^5.7.0" + "@ethersproject/bignumber" "^5.7.0" + "@ethersproject/bytes" "^5.7.0" + "@ethersproject/constants" "^5.7.0" + "@ethersproject/hash" "^5.7.0" + "@ethersproject/keccak256" "^5.7.0" + "@ethersproject/logger" "^5.7.0" + "@ethersproject/properties" "^5.7.0" + "@ethersproject/strings" "^5.7.0" + +"@ethersproject/abstract-provider@5.7.0", "@ethersproject/abstract-provider@^5.7.0": + version "5.7.0" + resolved "https://registry.yarnpkg.com/@ethersproject/abstract-provider/-/abstract-provider-5.7.0.tgz#b0a8550f88b6bf9d51f90e4795d48294630cb9ef" + integrity sha512-R41c9UkchKCpAqStMYUpdunjo3pkEvZC3FAwZn5S5MGbXoMQOHIdHItezTETxAO5bevtMApSyEhn9+CHcDsWBw== + dependencies: + "@ethersproject/bignumber" "^5.7.0" + "@ethersproject/bytes" "^5.7.0" + "@ethersproject/logger" "^5.7.0" + "@ethersproject/networks" "^5.7.0" + "@ethersproject/properties" "^5.7.0" + "@ethersproject/transactions" "^5.7.0" + "@ethersproject/web" "^5.7.0" + +"@ethersproject/abstract-signer@5.7.0", "@ethersproject/abstract-signer@^5.7.0": + version "5.7.0" + resolved "https://registry.yarnpkg.com/@ethersproject/abstract-signer/-/abstract-signer-5.7.0.tgz#13f4f32117868452191a4649723cb086d2b596b2" + integrity sha512-a16V8bq1/Cz+TGCkE2OPMTOUDLS3grCpdjoJCYNnVBbdYEMSgKrU0+B90s8b6H+ByYTBZN7a3g76jdIJi7UfKQ== + dependencies: + "@ethersproject/abstract-provider" "^5.7.0" + "@ethersproject/bignumber" "^5.7.0" + "@ethersproject/bytes" "^5.7.0" + "@ethersproject/logger" "^5.7.0" + "@ethersproject/properties" "^5.7.0" + +"@ethersproject/address@5.7.0", "@ethersproject/address@^5.0.2", "@ethersproject/address@^5.7.0": + version "5.7.0" + resolved "https://registry.yarnpkg.com/@ethersproject/address/-/address-5.7.0.tgz#19b56c4d74a3b0a46bfdbb6cfcc0a153fc697f37" + integrity sha512-9wYhYt7aghVGo758POM5nqcOMaE168Q6aRLJZwUmiqSrAungkG74gSSeKEIR7ukixesdRZGPgVqme6vmxs1fkA== + dependencies: + "@ethersproject/bignumber" "^5.7.0" + "@ethersproject/bytes" "^5.7.0" + "@ethersproject/keccak256" "^5.7.0" + "@ethersproject/logger" "^5.7.0" + "@ethersproject/rlp" "^5.7.0" + +"@ethersproject/base64@5.7.0", "@ethersproject/base64@^5.7.0": + version "5.7.0" + resolved "https://registry.yarnpkg.com/@ethersproject/base64/-/base64-5.7.0.tgz#ac4ee92aa36c1628173e221d0d01f53692059e1c" + integrity sha512-Dr8tcHt2mEbsZr/mwTPIQAf3Ai0Bks/7gTw9dSqk1mQvhW3XvRlmDJr/4n+wg1JmCl16NZue17CDh8xb/vZ0sQ== + dependencies: + "@ethersproject/bytes" "^5.7.0" + +"@ethersproject/basex@5.7.0", "@ethersproject/basex@^5.7.0": + version "5.7.0" + resolved "https://registry.yarnpkg.com/@ethersproject/basex/-/basex-5.7.0.tgz#97034dc7e8938a8ca943ab20f8a5e492ece4020b" + integrity sha512-ywlh43GwZLv2Voc2gQVTKBoVQ1mti3d8HK5aMxsfu/nRDnMmNqaSJ3r3n85HBByT8OpoY96SXM1FogC533T4zw== + dependencies: + "@ethersproject/bytes" "^5.7.0" + "@ethersproject/properties" "^5.7.0" + +"@ethersproject/bignumber@5.7.0", "@ethersproject/bignumber@^5.7.0": + version "5.7.0" + resolved "https://registry.yarnpkg.com/@ethersproject/bignumber/-/bignumber-5.7.0.tgz#e2f03837f268ba655ffba03a57853e18a18dc9c2" + integrity sha512-n1CAdIHRWjSucQO3MC1zPSVgV/6dy/fjL9pMrPP9peL+QxEg9wOsVqwD4+818B6LUEtaXzVHQiuivzRoxPxUGw== + dependencies: + "@ethersproject/bytes" "^5.7.0" + "@ethersproject/logger" "^5.7.0" + bn.js "^5.2.1" + +"@ethersproject/bytes@5.7.0", "@ethersproject/bytes@^5.7.0": + version "5.7.0" + resolved "https://registry.yarnpkg.com/@ethersproject/bytes/-/bytes-5.7.0.tgz#a00f6ea8d7e7534d6d87f47188af1148d71f155d" + integrity sha512-nsbxwgFXWh9NyYWo+U8atvmMsSdKJprTcICAkvbBffT75qDocbuggBU0SJiVK2MuTrp0q+xvLkTnGMPK1+uA9A== + dependencies: + "@ethersproject/logger" "^5.7.0" + +"@ethersproject/constants@5.7.0", "@ethersproject/constants@^5.7.0": + version "5.7.0" + resolved "https://registry.yarnpkg.com/@ethersproject/constants/-/constants-5.7.0.tgz#df80a9705a7e08984161f09014ea012d1c75295e" + integrity sha512-DHI+y5dBNvkpYUMiRQyxRBYBefZkJfo70VUkUAsRjcPs47muV9evftfZ0PJVCXYbAiCgght0DtcF9srFQmIgWA== + dependencies: + "@ethersproject/bignumber" "^5.7.0" + +"@ethersproject/contracts@5.7.0": + version "5.7.0" + resolved "https://registry.yarnpkg.com/@ethersproject/contracts/-/contracts-5.7.0.tgz#c305e775abd07e48aa590e1a877ed5c316f8bd1e" + integrity sha512-5GJbzEU3X+d33CdfPhcyS+z8MzsTrBGk/sc+G+59+tPa9yFkl6HQ9D6L0QMgNTA9q8dT0XKxxkyp883XsQvbbg== + dependencies: + "@ethersproject/abi" "^5.7.0" + "@ethersproject/abstract-provider" "^5.7.0" + "@ethersproject/abstract-signer" "^5.7.0" + "@ethersproject/address" "^5.7.0" + "@ethersproject/bignumber" "^5.7.0" + "@ethersproject/bytes" "^5.7.0" + "@ethersproject/constants" "^5.7.0" + "@ethersproject/logger" "^5.7.0" + "@ethersproject/properties" "^5.7.0" + "@ethersproject/transactions" "^5.7.0" + +"@ethersproject/hash@5.7.0", "@ethersproject/hash@^5.7.0": + version "5.7.0" + resolved "https://registry.yarnpkg.com/@ethersproject/hash/-/hash-5.7.0.tgz#eb7aca84a588508369562e16e514b539ba5240a7" + integrity sha512-qX5WrQfnah1EFnO5zJv1v46a8HW0+E5xuBBDTwMFZLuVTx0tbU2kkx15NqdjxecrLGatQN9FGQKpb1FKdHCt+g== + dependencies: + "@ethersproject/abstract-signer" "^5.7.0" + "@ethersproject/address" "^5.7.0" + "@ethersproject/base64" "^5.7.0" + "@ethersproject/bignumber" "^5.7.0" + "@ethersproject/bytes" "^5.7.0" + "@ethersproject/keccak256" "^5.7.0" + "@ethersproject/logger" "^5.7.0" + "@ethersproject/properties" "^5.7.0" + "@ethersproject/strings" "^5.7.0" + +"@ethersproject/hdnode@5.7.0", "@ethersproject/hdnode@^5.7.0": + version "5.7.0" + resolved "https://registry.yarnpkg.com/@ethersproject/hdnode/-/hdnode-5.7.0.tgz#e627ddc6b466bc77aebf1a6b9e47405ca5aef9cf" + integrity sha512-OmyYo9EENBPPf4ERhR7oj6uAtUAhYGqOnIS+jE5pTXvdKBS99ikzq1E7Iv0ZQZ5V36Lqx1qZLeak0Ra16qpeOg== + dependencies: + "@ethersproject/abstract-signer" "^5.7.0" + "@ethersproject/basex" "^5.7.0" + "@ethersproject/bignumber" "^5.7.0" + "@ethersproject/bytes" "^5.7.0" + "@ethersproject/logger" "^5.7.0" + "@ethersproject/pbkdf2" "^5.7.0" + "@ethersproject/properties" "^5.7.0" + "@ethersproject/sha2" "^5.7.0" + "@ethersproject/signing-key" "^5.7.0" + "@ethersproject/strings" "^5.7.0" + "@ethersproject/transactions" "^5.7.0" + "@ethersproject/wordlists" "^5.7.0" + +"@ethersproject/json-wallets@5.7.0", "@ethersproject/json-wallets@^5.7.0": + version "5.7.0" + resolved "https://registry.yarnpkg.com/@ethersproject/json-wallets/-/json-wallets-5.7.0.tgz#5e3355287b548c32b368d91014919ebebddd5360" + integrity sha512-8oee5Xgu6+RKgJTkvEMl2wDgSPSAQ9MB/3JYjFV9jlKvcYHUXZC+cQp0njgmxdHkYWn8s6/IqIZYm0YWCjO/0g== + dependencies: + "@ethersproject/abstract-signer" "^5.7.0" + "@ethersproject/address" "^5.7.0" + "@ethersproject/bytes" "^5.7.0" + "@ethersproject/hdnode" "^5.7.0" + "@ethersproject/keccak256" "^5.7.0" + "@ethersproject/logger" "^5.7.0" + "@ethersproject/pbkdf2" "^5.7.0" + "@ethersproject/properties" "^5.7.0" + "@ethersproject/random" "^5.7.0" + "@ethersproject/strings" "^5.7.0" + "@ethersproject/transactions" "^5.7.0" + aes-js "3.0.0" + scrypt-js "3.0.1" + +"@ethersproject/keccak256@5.7.0", "@ethersproject/keccak256@^5.7.0": + version "5.7.0" + resolved "https://registry.yarnpkg.com/@ethersproject/keccak256/-/keccak256-5.7.0.tgz#3186350c6e1cd6aba7940384ec7d6d9db01f335a" + integrity sha512-2UcPboeL/iW+pSg6vZ6ydF8tCnv3Iu/8tUmLLzWWGzxWKFFqOBQFLo6uLUv6BDrLgCDfN28RJ/wtByx+jZ4KBg== + dependencies: + "@ethersproject/bytes" "^5.7.0" + js-sha3 "0.8.0" + +"@ethersproject/logger@5.7.0", "@ethersproject/logger@^5.7.0": + version "5.7.0" + resolved "https://registry.yarnpkg.com/@ethersproject/logger/-/logger-5.7.0.tgz#6ce9ae168e74fecf287be17062b590852c311892" + integrity sha512-0odtFdXu/XHtjQXJYA3u9G0G8btm0ND5Cu8M7i5vhEcE8/HmF4Lbdqanwyv4uQTr2tx6b7fQRmgLrsnpQlmnig== + +"@ethersproject/networks@5.7.1", "@ethersproject/networks@^5.7.0": + version "5.7.1" + resolved "https://registry.yarnpkg.com/@ethersproject/networks/-/networks-5.7.1.tgz#118e1a981d757d45ccea6bb58d9fd3d9db14ead6" + integrity sha512-n/MufjFYv3yFcUyfhnXotyDlNdFb7onmkSy8aQERi2PjNcnWQ66xXxa3XlS8nCcA8aJKJjIIMNJTC7tu80GwpQ== + dependencies: + "@ethersproject/logger" "^5.7.0" + +"@ethersproject/pbkdf2@5.7.0", "@ethersproject/pbkdf2@^5.7.0": + version "5.7.0" + resolved "https://registry.yarnpkg.com/@ethersproject/pbkdf2/-/pbkdf2-5.7.0.tgz#d2267d0a1f6e123f3771007338c47cccd83d3102" + integrity sha512-oR/dBRZR6GTyaofd86DehG72hY6NpAjhabkhxgr3X2FpJtJuodEl2auADWBZfhDHgVCbu3/H/Ocq2uC6dpNjjw== + dependencies: + "@ethersproject/bytes" "^5.7.0" + "@ethersproject/sha2" "^5.7.0" + +"@ethersproject/properties@5.7.0", "@ethersproject/properties@^5.7.0": + version "5.7.0" + resolved "https://registry.yarnpkg.com/@ethersproject/properties/-/properties-5.7.0.tgz#a6e12cb0439b878aaf470f1902a176033067ed30" + integrity sha512-J87jy8suntrAkIZtecpxEPxY//szqr1mlBaYlQ0r4RCaiD2hjheqF9s1LVE8vVuJCXisjIP+JgtK/Do54ej4Sw== + dependencies: + "@ethersproject/logger" "^5.7.0" + +"@ethersproject/providers@5.7.2": + version "5.7.2" + resolved "https://registry.yarnpkg.com/@ethersproject/providers/-/providers-5.7.2.tgz#f8b1a4f275d7ce58cf0a2eec222269a08beb18cb" + integrity sha512-g34EWZ1WWAVgr4aptGlVBF8mhl3VWjv+8hoAnzStu8Ah22VHBsuGzP17eb6xDVRzw895G4W7vvx60lFFur/1Rg== + dependencies: + "@ethersproject/abstract-provider" "^5.7.0" + "@ethersproject/abstract-signer" "^5.7.0" + "@ethersproject/address" "^5.7.0" + "@ethersproject/base64" "^5.7.0" + "@ethersproject/basex" "^5.7.0" + "@ethersproject/bignumber" "^5.7.0" + "@ethersproject/bytes" "^5.7.0" + "@ethersproject/constants" "^5.7.0" + "@ethersproject/hash" "^5.7.0" + "@ethersproject/logger" "^5.7.0" + "@ethersproject/networks" "^5.7.0" + "@ethersproject/properties" "^5.7.0" + "@ethersproject/random" "^5.7.0" + "@ethersproject/rlp" "^5.7.0" + "@ethersproject/sha2" "^5.7.0" + "@ethersproject/strings" "^5.7.0" + "@ethersproject/transactions" "^5.7.0" + "@ethersproject/web" "^5.7.0" + bech32 "1.1.4" + ws "7.4.6" + +"@ethersproject/random@5.7.0", "@ethersproject/random@^5.7.0": + version "5.7.0" + resolved "https://registry.yarnpkg.com/@ethersproject/random/-/random-5.7.0.tgz#af19dcbc2484aae078bb03656ec05df66253280c" + integrity sha512-19WjScqRA8IIeWclFme75VMXSBvi4e6InrUNuaR4s5pTF2qNhcGdCUwdxUVGtDDqC00sDLCO93jPQoDUH4HVmQ== + dependencies: + "@ethersproject/bytes" "^5.7.0" + "@ethersproject/logger" "^5.7.0" + +"@ethersproject/rlp@5.7.0", "@ethersproject/rlp@^5.7.0": + version "5.7.0" + resolved "https://registry.yarnpkg.com/@ethersproject/rlp/-/rlp-5.7.0.tgz#de39e4d5918b9d74d46de93af80b7685a9c21304" + integrity sha512-rBxzX2vK8mVF7b0Tol44t5Tb8gomOHkj5guL+HhzQ1yBh/ydjGnpw6at+X6Iw0Kp3OzzzkcKp8N9r0W4kYSs9w== + dependencies: + "@ethersproject/bytes" "^5.7.0" + "@ethersproject/logger" "^5.7.0" + +"@ethersproject/sha2@5.7.0", "@ethersproject/sha2@^5.7.0": + version "5.7.0" + resolved "https://registry.yarnpkg.com/@ethersproject/sha2/-/sha2-5.7.0.tgz#9a5f7a7824ef784f7f7680984e593a800480c9fb" + integrity sha512-gKlH42riwb3KYp0reLsFTokByAKoJdgFCwI+CCiX/k+Jm2mbNs6oOaCjYQSlI1+XBVejwH2KrmCbMAT/GnRDQw== + dependencies: + "@ethersproject/bytes" "^5.7.0" + "@ethersproject/logger" "^5.7.0" + hash.js "1.1.7" + +"@ethersproject/signing-key@5.7.0", "@ethersproject/signing-key@^5.7.0": + version "5.7.0" + resolved "https://registry.yarnpkg.com/@ethersproject/signing-key/-/signing-key-5.7.0.tgz#06b2df39411b00bc57c7c09b01d1e41cf1b16ab3" + integrity sha512-MZdy2nL3wO0u7gkB4nA/pEf8lu1TlFswPNmy8AiYkfKTdO6eXBJyUdmHO/ehm/htHw9K/qF8ujnTyUAD+Ry54Q== + dependencies: + "@ethersproject/bytes" "^5.7.0" + "@ethersproject/logger" "^5.7.0" + "@ethersproject/properties" "^5.7.0" + bn.js "^5.2.1" + elliptic "6.5.4" + hash.js "1.1.7" + +"@ethersproject/solidity@5.7.0": + version "5.7.0" + resolved "https://registry.yarnpkg.com/@ethersproject/solidity/-/solidity-5.7.0.tgz#5e9c911d8a2acce2a5ebb48a5e2e0af20b631cb8" + integrity sha512-HmabMd2Dt/raavyaGukF4XxizWKhKQ24DoLtdNbBmNKUOPqwjsKQSdV9GQtj9CBEea9DlzETlVER1gYeXXBGaA== + dependencies: + "@ethersproject/bignumber" "^5.7.0" + "@ethersproject/bytes" "^5.7.0" + "@ethersproject/keccak256" "^5.7.0" + "@ethersproject/logger" "^5.7.0" + "@ethersproject/sha2" "^5.7.0" + "@ethersproject/strings" "^5.7.0" + +"@ethersproject/strings@5.7.0", "@ethersproject/strings@^5.7.0": + version "5.7.0" + resolved "https://registry.yarnpkg.com/@ethersproject/strings/-/strings-5.7.0.tgz#54c9d2a7c57ae8f1205c88a9d3a56471e14d5ed2" + integrity sha512-/9nu+lj0YswRNSH0NXYqrh8775XNyEdUQAuf3f+SmOrnVewcJ5SBNAjF7lpgehKi4abvNNXyf+HX86czCdJ8Mg== + dependencies: + "@ethersproject/bytes" "^5.7.0" + "@ethersproject/constants" "^5.7.0" + "@ethersproject/logger" "^5.7.0" + +"@ethersproject/transactions@5.7.0", "@ethersproject/transactions@^5.7.0": + version "5.7.0" + resolved "https://registry.yarnpkg.com/@ethersproject/transactions/-/transactions-5.7.0.tgz#91318fc24063e057885a6af13fdb703e1f993d3b" + integrity sha512-kmcNicCp1lp8qanMTC3RIikGgoJ80ztTyvtsFvCYpSCfkjhD0jZ2LOrnbcuxuToLIUYYf+4XwD1rP+B/erDIhQ== + dependencies: + "@ethersproject/address" "^5.7.0" + "@ethersproject/bignumber" "^5.7.0" + "@ethersproject/bytes" "^5.7.0" + "@ethersproject/constants" "^5.7.0" + "@ethersproject/keccak256" "^5.7.0" + "@ethersproject/logger" "^5.7.0" + "@ethersproject/properties" "^5.7.0" + "@ethersproject/rlp" "^5.7.0" + "@ethersproject/signing-key" "^5.7.0" + +"@ethersproject/units@5.7.0": + version "5.7.0" + resolved "https://registry.yarnpkg.com/@ethersproject/units/-/units-5.7.0.tgz#637b563d7e14f42deeee39245275d477aae1d8b1" + integrity sha512-pD3xLMy3SJu9kG5xDGI7+xhTEmGXlEqXU4OfNapmfnxLVY4EMSSRp7j1k7eezutBPH7RBN/7QPnwR7hzNlEFeg== + dependencies: + "@ethersproject/bignumber" "^5.7.0" + "@ethersproject/constants" "^5.7.0" + "@ethersproject/logger" "^5.7.0" + +"@ethersproject/wallet@5.7.0": + version "5.7.0" + resolved "https://registry.yarnpkg.com/@ethersproject/wallet/-/wallet-5.7.0.tgz#4e5d0790d96fe21d61d38fb40324e6c7ef350b2d" + integrity sha512-MhmXlJXEJFBFVKrDLB4ZdDzxcBxQ3rLyCkhNqVu3CDYvR97E+8r01UgrI+TI99Le+aYm/in/0vp86guJuM7FCA== + dependencies: + "@ethersproject/abstract-provider" "^5.7.0" + "@ethersproject/abstract-signer" "^5.7.0" + "@ethersproject/address" "^5.7.0" + "@ethersproject/bignumber" "^5.7.0" + "@ethersproject/bytes" "^5.7.0" + "@ethersproject/hash" "^5.7.0" + "@ethersproject/hdnode" "^5.7.0" + "@ethersproject/json-wallets" "^5.7.0" + "@ethersproject/keccak256" "^5.7.0" + "@ethersproject/logger" "^5.7.0" + "@ethersproject/properties" "^5.7.0" + "@ethersproject/random" "^5.7.0" + "@ethersproject/signing-key" "^5.7.0" + "@ethersproject/transactions" "^5.7.0" + "@ethersproject/wordlists" "^5.7.0" + +"@ethersproject/web@5.7.1", "@ethersproject/web@^5.7.0": + version "5.7.1" + resolved "https://registry.yarnpkg.com/@ethersproject/web/-/web-5.7.1.tgz#de1f285b373149bee5928f4eb7bcb87ee5fbb4ae" + integrity sha512-Gueu8lSvyjBWL4cYsWsjh6MtMwM0+H4HvqFPZfB6dV8ctbP9zFAO73VG1cMWae0FLPCtz0peKPpZY8/ugJJX2w== + dependencies: + "@ethersproject/base64" "^5.7.0" + "@ethersproject/bytes" "^5.7.0" + "@ethersproject/logger" "^5.7.0" + "@ethersproject/properties" "^5.7.0" + "@ethersproject/strings" "^5.7.0" + +"@ethersproject/wordlists@5.7.0", "@ethersproject/wordlists@^5.7.0": + version "5.7.0" + resolved "https://registry.yarnpkg.com/@ethersproject/wordlists/-/wordlists-5.7.0.tgz#8fb2c07185d68c3e09eb3bfd6e779ba2774627f5" + integrity sha512-S2TFNJNfHWVHNE6cNDjbVlZ6MgE17MIxMbMg2zv3wn+3XSJGosL1m9ZVv3GXCf/2ymSsQ+hRI5IzoMJTG6aoVA== + dependencies: + "@ethersproject/bytes" "^5.7.0" + "@ethersproject/hash" "^5.7.0" + "@ethersproject/logger" "^5.7.0" + "@ethersproject/properties" "^5.7.0" + "@ethersproject/strings" "^5.7.0" + +"@fastify/busboy@^2.0.0": + version "2.1.1" + resolved "https://registry.yarnpkg.com/@fastify/busboy/-/busboy-2.1.1.tgz#b9da6a878a371829a0502c9b6c1c143ef6663f4d" + integrity sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA== + +"@humanwhocodes/config-array@^0.11.13": + version "0.11.14" + resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.14.tgz#d78e481a039f7566ecc9660b4ea7fe6b1fec442b" + integrity sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg== + dependencies: + "@humanwhocodes/object-schema" "^2.0.2" + debug "^4.3.1" + minimatch "^3.0.5" + +"@humanwhocodes/module-importer@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz#af5b2691a22b44be847b0ca81641c5fb6ad0172c" + integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA== + +"@humanwhocodes/object-schema@^2.0.2": + version "2.0.2" + resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-2.0.2.tgz#d9fae00a2d5cb40f92cfe64b47ad749fbc38f917" + integrity sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw== + +"@isaacs/cliui@^8.0.2": + version "8.0.2" + resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550" + integrity sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA== + dependencies: + string-width "^5.1.2" + string-width-cjs "npm:string-width@^4.2.0" + strip-ansi "^7.0.1" + strip-ansi-cjs "npm:strip-ansi@^6.0.1" + wrap-ansi "^8.1.0" + wrap-ansi-cjs "npm:wrap-ansi@^7.0.0" + +"@jridgewell/gen-mapping@^0.3.2": + version "0.3.3" + resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz#7e02e6eb5df901aaedb08514203b096614024098" + integrity sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ== + dependencies: + "@jridgewell/set-array" "^1.0.1" + "@jridgewell/sourcemap-codec" "^1.4.10" + "@jridgewell/trace-mapping" "^0.3.9" + +"@jridgewell/resolve-uri@^3.0.3", "@jridgewell/resolve-uri@^3.1.0": + version "3.1.1" + resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz#c08679063f279615a3326583ba3a90d1d82cc721" + integrity sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA== + +"@jridgewell/set-array@^1.0.1": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@jridgewell/set-array/-/set-array-1.1.2.tgz#7c6cf998d6d20b914c0a55a91ae928ff25965e72" + integrity sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw== + +"@jridgewell/sourcemap-codec@^1.4.10", "@jridgewell/sourcemap-codec@^1.4.14": + version "1.4.15" + resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz#d7c6e6755c78567a951e04ab52ef0fd26de59f32" + integrity sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg== + +"@jridgewell/trace-mapping@0.3.9": + version "0.3.9" + resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz#6534fd5933a53ba7cbf3a17615e273a0d1273ff9" + integrity sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ== + dependencies: + "@jridgewell/resolve-uri" "^3.0.3" + "@jridgewell/sourcemap-codec" "^1.4.10" + +"@jridgewell/trace-mapping@^0.3.9": + version "0.3.22" + resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.22.tgz#72a621e5de59f5f1ef792d0793a82ee20f645e4c" + integrity sha512-Wf963MzWtA2sjrNt+g18IAln9lKnlRp+K2eH4jjIoF1wYeq3aMREpG09xhlhdzS0EjwU7qmUJYangWa+151vZw== + dependencies: + "@jridgewell/resolve-uri" "^3.1.0" + "@jridgewell/sourcemap-codec" "^1.4.14" + +"@matterlabs/hardhat-zksync-deploy@^0.9.0": + version "0.9.0" + resolved "https://registry.yarnpkg.com/@matterlabs/hardhat-zksync-deploy/-/hardhat-zksync-deploy-0.9.0.tgz#026815303df792af50d722e4a59b5aa26baa5ed7" + integrity sha512-F9qPa7+Etq9/zAWEhsJ+oHCJy+B+yXxt8Tv1wgJsw5yc/race7VIdrdWW6xUS5YNpwhsiuM2cxYBcrE+FSU/TA== + dependencies: + "@matterlabs/hardhat-zksync-solc" "^1.0.5" + chai "^4.3.6" + chalk "4.1.2" + fs-extra "^11.2.0" + glob "^10.3.10" + lodash "^4.17.21" + sinon "^17.0.1" + sinon-chai "^3.7.0" + ts-morph "^21.0.1" + +"@matterlabs/hardhat-zksync-deploy@^1.3.0": + version "1.3.0" + resolved "https://registry.yarnpkg.com/@matterlabs/hardhat-zksync-deploy/-/hardhat-zksync-deploy-1.3.0.tgz#5c2b723318ddf6c4d3929ec225401864ff54557a" + integrity sha512-4UHOgOwIBC4JA3W8DE9GHqbAuBhCPAjtM+Oew1aiYYGkIsPUAMYsH35+4I2FzJsYyE6mD6ATmoS/HfZweQHTlQ== + dependencies: + "@matterlabs/hardhat-zksync-solc" "^1.0.4" + chai "^4.3.6" + chalk "4.1.2" + fs-extra "^11.2.0" + glob "^10.3.10" + lodash "^4.17.21" + sinon "^17.0.1" + sinon-chai "^3.7.0" + ts-morph "^21.0.1" + +"@matterlabs/hardhat-zksync-node@^0.1.0": + version "0.1.0" + resolved "https://registry.yarnpkg.com/@matterlabs/hardhat-zksync-node/-/hardhat-zksync-node-0.1.0.tgz#de9b6b277727457ee981208030f33d06537db5d7" + integrity sha512-P3QZkcajplkQZg4Mj7soAlvH7JZObn853GtsD6NRCcjnwn3Id2yV1B5Iokg/BACRAwXCSLHRTn4nc2B/xkyqfg== + dependencies: + "@matterlabs/hardhat-zksync-solc" "^1.1.4" + axios "^1.4.0" + chai "^4.3.6" + chalk "4.1.2" + fs-extra "^11.1.1" + proxyquire "^2.1.3" + sinon "^17.0.1" + sinon-chai "^3.7.0" + undici "^5.14.0" + +"@matterlabs/hardhat-zksync-solc@^1.0.4", "@matterlabs/hardhat-zksync-solc@^1.0.5", "@matterlabs/hardhat-zksync-solc@^1.1.4": + version "1.1.4" + resolved "https://registry.yarnpkg.com/@matterlabs/hardhat-zksync-solc/-/hardhat-zksync-solc-1.1.4.tgz#04a2fad6fb6b6944c64ad969080ee65b9af3f617" + integrity sha512-4/usbogh9neewR2/v8Dn2OzqVblZMUuT/iH2MyPZgPRZYQlL4SlZtMvokU9UQjZT6iSoaKCbbdWESHDHSzfUjA== + dependencies: + "@nomiclabs/hardhat-docker" "^2.0.0" + chai "^4.3.6" + chalk "4.1.2" + debug "^4.3.4" + dockerode "^4.0.2" + fs-extra "^11.1.1" + proper-lockfile "^4.1.2" + semver "^7.5.1" + sinon "^17.0.1" + sinon-chai "^3.7.0" + undici "^5.14.0" + +"@matterlabs/hardhat-zksync-upgradable@^0.4.0": + version "0.4.1" + resolved "https://registry.yarnpkg.com/@matterlabs/hardhat-zksync-upgradable/-/hardhat-zksync-upgradable-0.4.1.tgz#b5e7833da9921516b52bee8b897d4f3e78a72075" + integrity sha512-GCz+R7Nj41N70N6v8/0J3oCkaOtjuOOOxedMdOoKow6Zaoe+/vGIDfAqhVOZaJn2sIVdJpSizjaoHEYDNe/HMQ== + dependencies: + "@ethersproject/abi" "^5.1.2" + "@matterlabs/hardhat-zksync-deploy" "^0.9.0" + "@matterlabs/hardhat-zksync-solc" "^1.1.4" + "@openzeppelin/upgrades-core" "1.27.0" + chalk "4.1.2" + compare-versions "^6.0.0" + dockerode "^3.3.4" + ethereumjs-util "^7.1.5" + ethers "~5.7.2" + fs-extra "^11.1.1" + hardhat "^2.14.0" + proper-lockfile "^4.1.1" + solidity-ast "npm:solidity-ast@0.4.45" + zksync-ethers "^5.0.0" + +"@matterlabs/hardhat-zksync-verify@^0.6.0": + version "0.6.0" + resolved "https://registry.yarnpkg.com/@matterlabs/hardhat-zksync-verify/-/hardhat-zksync-verify-0.6.0.tgz#8923a124d7d2a84fea9a037b654a733bec07836e" + integrity sha512-LndlCZLgAd7r2zB/VJCuLOhVNpcWFbk3UOqv5sVOtO+XdV0A74tiLaxlyGNILm9lsbP+cPnGArDq9KI0yV4FEw== + dependencies: + "@ethersproject/abi" "^5.1.2" + "@ethersproject/address" "5.7.0" + "@matterlabs/hardhat-zksync-solc" "^1.1.4" + "@nomicfoundation/hardhat-verify" "^2.0.0" + "@openzeppelin/contracts" "^4.9.2" + axios "^1.6.2" + cbor "^8.1.0" + chai "^4.3.6" + chalk "4.1.2" + debug "^4.1.1" + hardhat "^2.14.0" + sinon "^17.0.1" + sinon-chai "^3.7.0" + zksync-ethers "^5.0.0" + +"@matterlabs/hardhat-zksync@^0.1.0": + version "0.1.0" + resolved "https://registry.yarnpkg.com/@matterlabs/hardhat-zksync/-/hardhat-zksync-0.1.0.tgz#83fb8562f8be144615ec90a0f2dff33956e2ee09" + integrity sha512-LyiTnh7of0TSzXqSBC2cp8pjwPlVcetBCmAFWFiEXEfwAPSiQ0Bb2O/z5g999PcoTjm1NhHa6tT6YpZCtcPCPA== + dependencies: + "@matterlabs/hardhat-zksync-deploy" "^0.9.0" + "@matterlabs/hardhat-zksync-node" "^0.1.0" + "@matterlabs/hardhat-zksync-solc" "^1.1.4" + "@matterlabs/hardhat-zksync-upgradable" "^0.4.0" + "@matterlabs/hardhat-zksync-verify" "^0.6.0" + "@matterlabs/zksync-contracts" "^0.6.1" + "@nomicfoundation/hardhat-verify" "^2.0.0" + "@nomiclabs/hardhat-ethers" "^2.2.1" + "@openzeppelin/contracts" "^4.9.2" + "@openzeppelin/contracts-upgradeable" "^4.9.2" + "@openzeppelin/upgrades-core" "^1.27.0" + chai "^4.3.7" + ethers "^5.7.2" + hardhat "^2.14.0" + sinon "^17.0.1" + sinon-chai "^3.7.0" + zksync-ethers "^5.0.0" + +"@matterlabs/zksync-contracts@^0.6.1": + version "0.6.1" + resolved "https://registry.yarnpkg.com/@matterlabs/zksync-contracts/-/zksync-contracts-0.6.1.tgz#39f061959d5890fd0043a2f1ae710f764b172230" + integrity sha512-+hucLw4DhGmTmQlXOTEtpboYCaOm/X2VJcWmnW4abNcOgQXEHX+mTxQrxEfPjIZT0ZE6z5FTUrOK9+RgUZwBMQ== + +"@metamask/eth-sig-util@^4.0.0": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@metamask/eth-sig-util/-/eth-sig-util-4.0.1.tgz#3ad61f6ea9ad73ba5b19db780d40d9aae5157088" + integrity sha512-tghyZKLHZjcdlDqCA3gNZmLeR0XvOE9U1qoQO9ohyAZT6Pya+H9vkBPcsyXytmYLNgVoin7CKCmweo/R43V+tQ== + dependencies: + ethereumjs-abi "^0.6.8" + ethereumjs-util "^6.2.1" + ethjs-util "^0.1.6" + tweetnacl "^1.0.3" + tweetnacl-util "^0.15.1" + +"@noble/curves@1.3.0", "@noble/curves@~1.3.0": + version "1.3.0" + resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-1.3.0.tgz#01be46da4fd195822dab821e72f71bf4aeec635e" + integrity sha512-t01iSXPuN+Eqzb4eBX0S5oubSqXbK/xXa1Ne18Hj8f9pStxztHCE2gfboSp/dZRLSqfuLpRK2nDXDK+W9puocA== + dependencies: + "@noble/hashes" "1.3.3" + +"@noble/hashes@1.2.0", "@noble/hashes@~1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.2.0.tgz#a3150eeb09cc7ab207ebf6d7b9ad311a9bdbed12" + integrity sha512-FZfhjEDbT5GRswV3C6uvLPHMiVD6lQBmpoX5+eSiPaMTXte/IKqI5dykDxzZB/WBeK/CDuQRBWarPdi3FNY2zQ== + +"@noble/hashes@1.3.3", "@noble/hashes@~1.3.2": + version "1.3.3" + resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.3.3.tgz#39908da56a4adc270147bb07968bf3b16cfe1699" + integrity sha512-V7/fPHgl+jsVPXqqeOzT8egNj2iBIVt+ECeMMG8TdcnTikP3oaBtUVqpT/gYCR68aEBJSF+XbYUxStjbFMqIIA== + +"@noble/hashes@^1.3.2", "@noble/hashes@^1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.4.0.tgz#45814aa329f30e4fe0ba49426f49dfccdd066426" + integrity sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg== + +"@noble/secp256k1@1.7.1", "@noble/secp256k1@~1.7.0": + version "1.7.1" + resolved "https://registry.yarnpkg.com/@noble/secp256k1/-/secp256k1-1.7.1.tgz#b251c70f824ce3ca7f8dc3df08d58f005cc0507c" + integrity sha512-hOUk6AyBFmqVrv7k5WAw/LpszxVbj9gGN4JRkIX52fdFAj1UA61KXmZDvqVEm+pOyec3+fIeZB02LYa/pWOArw== + +"@nodelib/fs.scandir@2.1.5": + version "2.1.5" + resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" + integrity sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g== + dependencies: + "@nodelib/fs.stat" "2.0.5" + run-parallel "^1.1.9" + +"@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2": + version "2.0.5" + resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz#5bd262af94e9d25bd1e71b05deed44876a222e8b" + integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A== + +"@nodelib/fs.walk@^1.2.3", "@nodelib/fs.walk@^1.2.8": + version "1.2.8" + resolved "https://registry.yarnpkg.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz#e95737e8bb6746ddedf69c556953494f196fe69a" + integrity sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg== + dependencies: + "@nodelib/fs.scandir" "2.1.5" + fastq "^1.6.0" + +"@nomicfoundation/edr-darwin-arm64@0.3.7": + version "0.3.7" + resolved "https://registry.yarnpkg.com/@nomicfoundation/edr-darwin-arm64/-/edr-darwin-arm64-0.3.7.tgz#c204edc79643624dbd431b489b254778817d8244" + integrity sha512-6tK9Lv/lSfyBvpEQ4nsTfgxyDT1y1Uv/x8Wa+aB+E8qGo3ToexQ1BMVjxJk6PChXCDOWxB3B4KhqaZFjdhl3Ow== + +"@nomicfoundation/edr-darwin-x64@0.3.7": + version "0.3.7" + resolved "https://registry.yarnpkg.com/@nomicfoundation/edr-darwin-x64/-/edr-darwin-x64-0.3.7.tgz#c3b394445084270cc5250d6c1869b0574e7ef810" + integrity sha512-1RrQ/1JPwxrYO69e0tglFv5H+ggour5Ii3bb727+yBpBShrxtOTQ7fZyfxA5h62LCN+0Z9wYOPeQ7XFcVurMaQ== + +"@nomicfoundation/edr-linux-arm64-gnu@0.3.7": + version "0.3.7" + resolved "https://registry.yarnpkg.com/@nomicfoundation/edr-linux-arm64-gnu/-/edr-linux-arm64-gnu-0.3.7.tgz#6d65545a44d1323bb7ab08c3306947165d2071de" + integrity sha512-ds/CKlBoVXIihjhflhgPn13EdKWed6r5bgvMs/YwRqT5wldQAQJZWAfA2+nYm0Yi2gMGh1RUpBcfkyl4pq7G+g== + +"@nomicfoundation/edr-linux-arm64-musl@0.3.7": + version "0.3.7" + resolved "https://registry.yarnpkg.com/@nomicfoundation/edr-linux-arm64-musl/-/edr-linux-arm64-musl-0.3.7.tgz#5368534bceac1a8c18b1be6b908caca5d39b0c03" + integrity sha512-e29udiRaPujhLkM3+R6ju7QISrcyOqpcaxb2FsDWBkuD7H8uU9JPZEyyUIpEp5uIY0Jh1eEJPKZKIXQmQAEAuw== + +"@nomicfoundation/edr-linux-x64-gnu@0.3.7": + version "0.3.7" + resolved "https://registry.yarnpkg.com/@nomicfoundation/edr-linux-x64-gnu/-/edr-linux-x64-gnu-0.3.7.tgz#42349bf5941dbb54a5719942924c6e4e8cde348e" + integrity sha512-/xkjmTyv+bbJ4akBCW0qzFKxPOV4AqLOmqurov+s9umHb16oOv72osSa3SdzJED2gHDaKmpMITT4crxbar4Axg== + +"@nomicfoundation/edr-linux-x64-musl@0.3.7": + version "0.3.7" + resolved "https://registry.yarnpkg.com/@nomicfoundation/edr-linux-x64-musl/-/edr-linux-x64-musl-0.3.7.tgz#e6babe11c9a8012f1284e6e48c3551861f2a7cd4" + integrity sha512-QwBP9xlmsbf/ldZDGLcE4QiAb8Zt46E/+WLpxHBATFhGa7MrpJh6Zse+h2VlrT/SYLPbh2cpHgSmoSlqVxWG9g== + +"@nomicfoundation/edr-win32-x64-msvc@0.3.7": + version "0.3.7" + resolved "https://registry.yarnpkg.com/@nomicfoundation/edr-win32-x64-msvc/-/edr-win32-x64-msvc-0.3.7.tgz#1504b98f305f03be153b0220a546985660de9dc6" + integrity sha512-j/80DEnkxrF2ewdbk/gQ2EOPvgF0XSsg8D0o4+6cKhUVAW6XwtWKzIphNL6dyD2YaWEPgIrNvqiJK/aln0ww4Q== + +"@nomicfoundation/edr@^0.3.5": + version "0.3.7" + resolved "https://registry.yarnpkg.com/@nomicfoundation/edr/-/edr-0.3.7.tgz#9c75edf1fcf601617905b2c89acf103f4786d017" + integrity sha512-v2JFWnFKRsnOa6PDUrD+sr8amcdhxnG/YbL7LzmgRGU1odWEyOF4/EwNeUajQr4ZNKVWrYnJ6XjydXtUge5OBQ== + optionalDependencies: + "@nomicfoundation/edr-darwin-arm64" "0.3.7" + "@nomicfoundation/edr-darwin-x64" "0.3.7" + "@nomicfoundation/edr-linux-arm64-gnu" "0.3.7" + "@nomicfoundation/edr-linux-arm64-musl" "0.3.7" + "@nomicfoundation/edr-linux-x64-gnu" "0.3.7" + "@nomicfoundation/edr-linux-x64-musl" "0.3.7" + "@nomicfoundation/edr-win32-x64-msvc" "0.3.7" + +"@nomicfoundation/ethereumjs-common@4.0.4": + version "4.0.4" + resolved "https://registry.yarnpkg.com/@nomicfoundation/ethereumjs-common/-/ethereumjs-common-4.0.4.tgz#9901f513af2d4802da87c66d6f255b510bef5acb" + integrity sha512-9Rgb658lcWsjiicr5GzNCjI1llow/7r0k50dLL95OJ+6iZJcVbi15r3Y0xh2cIO+zgX0WIHcbzIu6FeQf9KPrg== + dependencies: + "@nomicfoundation/ethereumjs-util" "9.0.4" + +"@nomicfoundation/ethereumjs-rlp@5.0.4": + version "5.0.4" + resolved "https://registry.yarnpkg.com/@nomicfoundation/ethereumjs-rlp/-/ethereumjs-rlp-5.0.4.tgz#66c95256fc3c909f6fb18f6a586475fc9762fa30" + integrity sha512-8H1S3s8F6QueOc/X92SdrA4RDenpiAEqMg5vJH99kcQaCy/a3Q6fgseo75mgWlbanGJXSlAPtnCeG9jvfTYXlw== + +"@nomicfoundation/ethereumjs-tx@5.0.4": + version "5.0.4" + resolved "https://registry.yarnpkg.com/@nomicfoundation/ethereumjs-tx/-/ethereumjs-tx-5.0.4.tgz#b0ceb58c98cc34367d40a30d255d6315b2f456da" + integrity sha512-Xjv8wAKJGMrP1f0n2PeyfFCCojHd7iS3s/Ab7qzF1S64kxZ8Z22LCMynArYsVqiFx6rzYy548HNVEyI+AYN/kw== + dependencies: + "@nomicfoundation/ethereumjs-common" "4.0.4" + "@nomicfoundation/ethereumjs-rlp" "5.0.4" + "@nomicfoundation/ethereumjs-util" "9.0.4" + ethereum-cryptography "0.1.3" + +"@nomicfoundation/ethereumjs-util@9.0.4": + version "9.0.4" + resolved "https://registry.yarnpkg.com/@nomicfoundation/ethereumjs-util/-/ethereumjs-util-9.0.4.tgz#84c5274e82018b154244c877b76bc049a4ed7b38" + integrity sha512-sLOzjnSrlx9Bb9EFNtHzK/FJFsfg2re6bsGqinFinH1gCqVfz9YYlXiMWwDM4C/L4ywuHFCYwfKTVr/QHQcU0Q== + dependencies: + "@nomicfoundation/ethereumjs-rlp" "5.0.4" + ethereum-cryptography "0.1.3" + +"@nomicfoundation/hardhat-chai-matchers@^2.0.0": + version "2.0.6" + resolved "https://registry.yarnpkg.com/@nomicfoundation/hardhat-chai-matchers/-/hardhat-chai-matchers-2.0.6.tgz#ef88be3bd666adf29c06ac7882e96c8dbaaa32ba" + integrity sha512-Te1Uyo9oJcTCF0Jy9dztaLpshmlpjLf2yPtWXlXuLjMt3RRSmJLm/+rKVTW6gfadAEs12U/it6D0ZRnnRGiICQ== + dependencies: + "@types/chai-as-promised" "^7.1.3" + chai-as-promised "^7.1.1" + deep-eql "^4.0.1" + ordinal "^1.0.3" + +"@nomicfoundation/hardhat-ethers@^3.0.0": + version "3.0.5" + resolved "https://registry.yarnpkg.com/@nomicfoundation/hardhat-ethers/-/hardhat-ethers-3.0.5.tgz#0422c2123dec7c42e7fb2be8e1691f1d9708db56" + integrity sha512-RNFe8OtbZK6Ila9kIlHp0+S80/0Bu/3p41HUpaRIoHLm6X3WekTd83vob3rE54Duufu1edCiBDxspBzi2rxHHw== + dependencies: + debug "^4.1.1" + lodash.isequal "^4.5.0" + +"@nomicfoundation/hardhat-foundry@^1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@nomicfoundation/hardhat-foundry/-/hardhat-foundry-1.1.1.tgz#db72b1f33f9cfaecc27e67f69ad436f8710162d6" + integrity sha512-cXGCBHAiXas9Pg9MhMOpBVQCkWRYoRFG7GJJAph+sdQsfd22iRs5U5Vs9XmpGEQd1yEvYISQZMeE68Nxj65iUQ== + dependencies: + chalk "^2.4.2" + +"@nomicfoundation/hardhat-network-helpers@^1.0.0": + version "1.0.10" + resolved "https://registry.yarnpkg.com/@nomicfoundation/hardhat-network-helpers/-/hardhat-network-helpers-1.0.10.tgz#c61042ceb104fdd6c10017859fdef6529c1d6585" + integrity sha512-R35/BMBlx7tWN5V6d/8/19QCwEmIdbnA4ZrsuXgvs8i2qFx5i7h6mH5pBS4Pwi4WigLH+upl6faYusrNPuzMrQ== + dependencies: + ethereumjs-util "^7.1.4" + +"@nomicfoundation/hardhat-toolbox@^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@nomicfoundation/hardhat-toolbox/-/hardhat-toolbox-4.0.0.tgz#eb1f619218dd1414fa161dfec92d3e5e53a2f407" + integrity sha512-jhcWHp0aHaL0aDYj8IJl80v4SZXWMS1A2XxXa1CA6pBiFfJKuZinCkO6wb+POAt0LIfXB3gA3AgdcOccrcwBwA== + +"@nomicfoundation/hardhat-verify@^2.0.0": + version "2.0.6" + resolved "https://registry.yarnpkg.com/@nomicfoundation/hardhat-verify/-/hardhat-verify-2.0.6.tgz#02623c431244c92a852c524008239fc616e1c658" + integrity sha512-oKUI5fl8QC8jysE2LUBHE6rObzEmccJcc4b43Ov7LFMlCBZJE27qoqGIsg/++wX7L8Jdga+bkejPxl8NvsecpQ== + dependencies: + "@ethersproject/abi" "^5.1.2" + "@ethersproject/address" "^5.0.2" + cbor "^8.1.0" + chalk "^2.4.2" + debug "^4.1.1" + lodash.clonedeep "^4.5.0" + semver "^6.3.0" + table "^6.8.0" + undici "^5.14.0" + +"@nomicfoundation/solidity-analyzer-darwin-arm64@0.1.1": + version "0.1.1" + resolved "https://registry.yarnpkg.com/@nomicfoundation/solidity-analyzer-darwin-arm64/-/solidity-analyzer-darwin-arm64-0.1.1.tgz#4c858096b1c17fe58a474fe81b46815f93645c15" + integrity sha512-KcTodaQw8ivDZyF+D76FokN/HdpgGpfjc/gFCImdLUyqB6eSWVaZPazMbeAjmfhx3R0zm/NYVzxwAokFKgrc0w== + +"@nomicfoundation/solidity-analyzer-darwin-x64@0.1.1": + version "0.1.1" + resolved "https://registry.yarnpkg.com/@nomicfoundation/solidity-analyzer-darwin-x64/-/solidity-analyzer-darwin-x64-0.1.1.tgz#6e25ccdf6e2d22389c35553b64fe6f3fdaec432c" + integrity sha512-XhQG4BaJE6cIbjAVtzGOGbK3sn1BO9W29uhk9J8y8fZF1DYz0Doj8QDMfpMu+A6TjPDs61lbsmeYodIDnfveSA== + +"@nomicfoundation/solidity-analyzer-freebsd-x64@0.1.1": + version "0.1.1" + resolved "https://registry.yarnpkg.com/@nomicfoundation/solidity-analyzer-freebsd-x64/-/solidity-analyzer-freebsd-x64-0.1.1.tgz#0a224ea50317139caeebcdedd435c28a039d169c" + integrity sha512-GHF1VKRdHW3G8CndkwdaeLkVBi5A9u2jwtlS7SLhBc8b5U/GcoL39Q+1CSO3hYqePNP+eV5YI7Zgm0ea6kMHoA== + +"@nomicfoundation/solidity-analyzer-linux-arm64-gnu@0.1.1": + version "0.1.1" + resolved "https://registry.yarnpkg.com/@nomicfoundation/solidity-analyzer-linux-arm64-gnu/-/solidity-analyzer-linux-arm64-gnu-0.1.1.tgz#dfa085d9ffab9efb2e7b383aed3f557f7687ac2b" + integrity sha512-g4Cv2fO37ZsUENQ2vwPnZc2zRenHyAxHcyBjKcjaSmmkKrFr64yvzeNO8S3GBFCo90rfochLs99wFVGT/0owpg== + +"@nomicfoundation/solidity-analyzer-linux-arm64-musl@0.1.1": + version "0.1.1" + resolved "https://registry.yarnpkg.com/@nomicfoundation/solidity-analyzer-linux-arm64-musl/-/solidity-analyzer-linux-arm64-musl-0.1.1.tgz#c9e06b5d513dd3ab02a7ac069c160051675889a4" + integrity sha512-WJ3CE5Oek25OGE3WwzK7oaopY8xMw9Lhb0mlYuJl/maZVo+WtP36XoQTb7bW/i8aAdHW5Z+BqrHMux23pvxG3w== + +"@nomicfoundation/solidity-analyzer-linux-x64-gnu@0.1.1": + version "0.1.1" + resolved "https://registry.yarnpkg.com/@nomicfoundation/solidity-analyzer-linux-x64-gnu/-/solidity-analyzer-linux-x64-gnu-0.1.1.tgz#8d328d16839e52571f72f2998c81e46bf320f893" + integrity sha512-5WN7leSr5fkUBBjE4f3wKENUy9HQStu7HmWqbtknfXkkil+eNWiBV275IOlpXku7v3uLsXTOKpnnGHJYI2qsdA== + +"@nomicfoundation/solidity-analyzer-linux-x64-musl@0.1.1": + version "0.1.1" + resolved "https://registry.yarnpkg.com/@nomicfoundation/solidity-analyzer-linux-x64-musl/-/solidity-analyzer-linux-x64-musl-0.1.1.tgz#9b49d0634b5976bb5ed1604a1e1b736f390959bb" + integrity sha512-KdYMkJOq0SYPQMmErv/63CwGwMm5XHenEna9X9aB8mQmhDBrYrlAOSsIPgFCUSL0hjxE3xHP65/EPXR/InD2+w== + +"@nomicfoundation/solidity-analyzer-win32-arm64-msvc@0.1.1": + version "0.1.1" + resolved "https://registry.yarnpkg.com/@nomicfoundation/solidity-analyzer-win32-arm64-msvc/-/solidity-analyzer-win32-arm64-msvc-0.1.1.tgz#e2867af7264ebbcc3131ef837878955dd6a3676f" + integrity sha512-VFZASBfl4qiBYwW5xeY20exWhmv6ww9sWu/krWSesv3q5hA0o1JuzmPHR4LPN6SUZj5vcqci0O6JOL8BPw+APg== + +"@nomicfoundation/solidity-analyzer-win32-ia32-msvc@0.1.1": + version "0.1.1" + resolved "https://registry.yarnpkg.com/@nomicfoundation/solidity-analyzer-win32-ia32-msvc/-/solidity-analyzer-win32-ia32-msvc-0.1.1.tgz#0685f78608dd516c8cdfb4896ed451317e559585" + integrity sha512-JnFkYuyCSA70j6Si6cS1A9Gh1aHTEb8kOTBApp/c7NRTFGNMH8eaInKlyuuiIbvYFhlXW4LicqyYuWNNq9hkpQ== + +"@nomicfoundation/solidity-analyzer-win32-x64-msvc@0.1.1": + version "0.1.1" + resolved "https://registry.yarnpkg.com/@nomicfoundation/solidity-analyzer-win32-x64-msvc/-/solidity-analyzer-win32-x64-msvc-0.1.1.tgz#c9a44f7108646f083b82e851486e0f6aeb785836" + integrity sha512-HrVJr6+WjIXGnw3Q9u6KQcbZCtk0caVWhCdFADySvRyUxJ8PnzlaP+MhwNE8oyT8OZ6ejHBRrrgjSqDCFXGirw== + +"@nomicfoundation/solidity-analyzer@^0.1.0": + version "0.1.1" + resolved "https://registry.yarnpkg.com/@nomicfoundation/solidity-analyzer/-/solidity-analyzer-0.1.1.tgz#f5f4d36d3f66752f59a57e7208cd856f3ddf6f2d" + integrity sha512-1LMtXj1puAxyFusBgUIy5pZk3073cNXYnXUpuNKFghHbIit/xZgbk0AokpUADbNm3gyD6bFWl3LRFh3dhVdREg== + optionalDependencies: + "@nomicfoundation/solidity-analyzer-darwin-arm64" "0.1.1" + "@nomicfoundation/solidity-analyzer-darwin-x64" "0.1.1" + "@nomicfoundation/solidity-analyzer-freebsd-x64" "0.1.1" + "@nomicfoundation/solidity-analyzer-linux-arm64-gnu" "0.1.1" + "@nomicfoundation/solidity-analyzer-linux-arm64-musl" "0.1.1" + "@nomicfoundation/solidity-analyzer-linux-x64-gnu" "0.1.1" + "@nomicfoundation/solidity-analyzer-linux-x64-musl" "0.1.1" + "@nomicfoundation/solidity-analyzer-win32-arm64-msvc" "0.1.1" + "@nomicfoundation/solidity-analyzer-win32-ia32-msvc" "0.1.1" + "@nomicfoundation/solidity-analyzer-win32-x64-msvc" "0.1.1" + +"@nomiclabs/hardhat-docker@^2.0.0": + version "2.0.2" + resolved "https://registry.yarnpkg.com/@nomiclabs/hardhat-docker/-/hardhat-docker-2.0.2.tgz#ae964be17951275a55859ff7358e9e7c77448846" + integrity sha512-XgGEpRT3wlA1VslyB57zyAHV+oll8KnV1TjwnxxC1tpAL04/lbdwpdO5KxInVN8irMSepqFpsiSkqlcnvbE7Ng== + dependencies: + dockerode "^2.5.8" + fs-extra "^7.0.1" + node-fetch "^2.6.0" + +"@nomiclabs/hardhat-ethers@^2.2.1": + version "2.2.3" + resolved "https://registry.yarnpkg.com/@nomiclabs/hardhat-ethers/-/hardhat-ethers-2.2.3.tgz#b41053e360c31a32c2640c9a45ee981a7e603fe0" + integrity sha512-YhzPdzb612X591FOe68q+qXVXGG2ANZRvDo0RRUtimev85rCrAlv/TLMEZw5c+kq9AbzocLTVX/h2jVIFPL9Xg== + +"@nomiclabs/hardhat-etherscan@^3.1.7": + version "3.1.8" + resolved "https://registry.yarnpkg.com/@nomiclabs/hardhat-etherscan/-/hardhat-etherscan-3.1.8.tgz#3c12ee90b3733e0775e05111146ef9418d4f5a38" + integrity sha512-v5F6IzQhrsjHh6kQz4uNrym49brK9K5bYCq2zQZ729RYRaifI9hHbtmK+KkIVevfhut7huQFEQ77JLRMAzWYjQ== + dependencies: + "@ethersproject/abi" "^5.1.2" + "@ethersproject/address" "^5.0.2" + cbor "^8.1.0" + chalk "^2.4.2" + debug "^4.1.1" + fs-extra "^7.0.1" + lodash "^4.17.11" + semver "^6.3.0" + table "^6.8.0" + undici "^5.14.0" + +"@openzeppelin/contracts-upgradeable@^4.4.2", "@openzeppelin/contracts-upgradeable@^4.9.3": + version "4.9.5" + resolved "https://registry.yarnpkg.com/@openzeppelin/contracts-upgradeable/-/contracts-upgradeable-4.9.5.tgz#572b5da102fc9be1d73f34968e0ca56765969812" + integrity sha512-f7L1//4sLlflAN7fVzJLoRedrf5Na3Oal5PZfIq55NFcVZ90EpV1q5xOvL4lFvg3MNICSDr2hH0JUBxwlxcoPg== + +"@openzeppelin/contracts-upgradeable@^4.9.2": + version "4.9.6" + resolved "https://registry.yarnpkg.com/@openzeppelin/contracts-upgradeable/-/contracts-upgradeable-4.9.6.tgz#38b21708a719da647de4bb0e4802ee235a0d24df" + integrity sha512-m4iHazOsOCv1DgM7eD7GupTJ+NFVujRZt1wzddDPSVGpWdKq1SKkla5htKG7+IS4d2XOCtzkUNwRZ7Vq5aEUMA== + +"@openzeppelin/contracts@^4.4.2", "@openzeppelin/contracts@^4.9.3": + version "4.9.5" + resolved "https://registry.yarnpkg.com/@openzeppelin/contracts/-/contracts-4.9.5.tgz#1eed23d4844c861a1835b5d33507c1017fa98de8" + integrity sha512-ZK+W5mVhRppff9BE6YdR8CC52C8zAvsVAiWhEtQ5+oNxFE6h1WdeWo+FJSF8KKvtxxVYZ7MTP/5KoVpAU3aSWg== + +"@openzeppelin/contracts@^4.9.2": + version "4.9.6" + resolved "https://registry.yarnpkg.com/@openzeppelin/contracts/-/contracts-4.9.6.tgz#2a880a24eb19b4f8b25adc2a5095f2aa27f39677" + integrity sha512-xSmezSupL+y9VkHZJGDoCBpmnB2ogM13ccaYDWqJTfS3dbuHkgjuwDFUmaFauBCboQMGB/S5UqUl2y54X99BmA== + +"@openzeppelin/upgrades-core@1.27.0": + version "1.27.0" + resolved "https://registry.yarnpkg.com/@openzeppelin/upgrades-core/-/upgrades-core-1.27.0.tgz#43f05c3e45b21bdc583488aa42297fbb0d065d17" + integrity sha512-FBIuFPKiRNMhW09HS8jkmV5DueGfxO2wp/kmCa0m0SMDyX4ROumgy/4Ao0/yH8/JZZPDiH1q3EnTRn+B7TGYgg== + dependencies: + cbor "^8.0.0" + chalk "^4.1.0" + compare-versions "^5.0.0" + debug "^4.1.1" + ethereumjs-util "^7.0.3" + minimist "^1.2.7" + proper-lockfile "^4.1.1" + solidity-ast "^0.4.15" + +"@openzeppelin/upgrades-core@^1.27.0": + version "1.33.1" + resolved "https://registry.yarnpkg.com/@openzeppelin/upgrades-core/-/upgrades-core-1.33.1.tgz#2e129ce1ab7bd07d07e98822ca8bb8de1d3b008e" + integrity sha512-YRxIRhTY1b+j7+NUUu8Uuem5ugxKexEMVd8dBRWNgWeoN1gS1OCrhgUg0ytL+54vzQ+SGWZDfNnzjVuI1Cj1Zw== + dependencies: + cbor "^9.0.0" + chalk "^4.1.0" + compare-versions "^6.0.0" + debug "^4.1.1" + ethereumjs-util "^7.0.3" + minimist "^1.2.7" + proper-lockfile "^4.1.1" + solidity-ast "^0.4.51" + +"@pkgjs/parseargs@^0.11.0": + version "0.11.0" + resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" + integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== + +"@scure/base@~1.1.0", "@scure/base@~1.1.4": + version "1.1.6" + resolved "https://registry.yarnpkg.com/@scure/base/-/base-1.1.6.tgz#8ce5d304b436e4c84f896e0550c83e4d88cb917d" + integrity sha512-ok9AWwhcgYuGG3Zfhyqg+zwl+Wn5uE+dwC0NV/2qQkx4dABbb/bx96vWu8NSj+BNjjSjno+JRYRjle1jV08k3g== + +"@scure/bip32@1.1.5": + version "1.1.5" + resolved "https://registry.yarnpkg.com/@scure/bip32/-/bip32-1.1.5.tgz#d2ccae16dcc2e75bc1d75f5ef3c66a338d1ba300" + integrity sha512-XyNh1rB0SkEqd3tXcXMi+Xe1fvg+kUIcoRIEujP1Jgv7DqW2r9lg3Ah0NkFaCs9sTkQAQA8kw7xiRXzENi9Rtw== + dependencies: + "@noble/hashes" "~1.2.0" + "@noble/secp256k1" "~1.7.0" + "@scure/base" "~1.1.0" + +"@scure/bip32@1.3.3": + version "1.3.3" + resolved "https://registry.yarnpkg.com/@scure/bip32/-/bip32-1.3.3.tgz#a9624991dc8767087c57999a5d79488f48eae6c8" + integrity sha512-LJaN3HwRbfQK0X1xFSi0Q9amqOgzQnnDngIt+ZlsBC3Bm7/nE7K0kwshZHyaru79yIVRv/e1mQAjZyuZG6jOFQ== + dependencies: + "@noble/curves" "~1.3.0" + "@noble/hashes" "~1.3.2" + "@scure/base" "~1.1.4" + +"@scure/bip39@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@scure/bip39/-/bip39-1.1.1.tgz#b54557b2e86214319405db819c4b6a370cf340c5" + integrity sha512-t+wDck2rVkh65Hmv280fYdVdY25J9YeEUIgn2LG1WM6gxFkGzcksoDiUkWVpVp3Oex9xGC68JU2dSbUfwZ2jPg== + dependencies: + "@noble/hashes" "~1.2.0" + "@scure/base" "~1.1.0" + +"@scure/bip39@1.2.2": + version "1.2.2" + resolved "https://registry.yarnpkg.com/@scure/bip39/-/bip39-1.2.2.tgz#f3426813f4ced11a47489cbcf7294aa963966527" + integrity sha512-HYf9TUXG80beW+hGAt3TRM8wU6pQoYur9iNypTROm42dorCGmLnFe3eWjz3gOq6G62H2WRh0FCzAR1PI+29zIA== + dependencies: + "@noble/hashes" "~1.3.2" + "@scure/base" "~1.1.4" + +"@sentry/core@5.30.0": + version "5.30.0" + resolved "https://registry.yarnpkg.com/@sentry/core/-/core-5.30.0.tgz#6b203664f69e75106ee8b5a2fe1d717379b331f3" + integrity sha512-TmfrII8w1PQZSZgPpUESqjB+jC6MvZJZdLtE/0hZ+SrnKhW3x5WlYLvTXZpcWePYBku7rl2wn1RZu6uT0qCTeg== + dependencies: + "@sentry/hub" "5.30.0" + "@sentry/minimal" "5.30.0" + "@sentry/types" "5.30.0" + "@sentry/utils" "5.30.0" + tslib "^1.9.3" + +"@sentry/hub@5.30.0": + version "5.30.0" + resolved "https://registry.yarnpkg.com/@sentry/hub/-/hub-5.30.0.tgz#2453be9b9cb903404366e198bd30c7ca74cdc100" + integrity sha512-2tYrGnzb1gKz2EkMDQcfLrDTvmGcQPuWxLnJKXJvYTQDGLlEvi2tWz1VIHjunmOvJrB5aIQLhm+dcMRwFZDCqQ== + dependencies: + "@sentry/types" "5.30.0" + "@sentry/utils" "5.30.0" + tslib "^1.9.3" + +"@sentry/minimal@5.30.0": + version "5.30.0" + resolved "https://registry.yarnpkg.com/@sentry/minimal/-/minimal-5.30.0.tgz#ce3d3a6a273428e0084adcb800bc12e72d34637b" + integrity sha512-BwWb/owZKtkDX+Sc4zCSTNcvZUq7YcH3uAVlmh/gtR9rmUvbzAA3ewLuB3myi4wWRAMEtny6+J/FN/x+2wn9Xw== + dependencies: + "@sentry/hub" "5.30.0" + "@sentry/types" "5.30.0" + tslib "^1.9.3" + +"@sentry/node@^5.18.1": + version "5.30.0" + resolved "https://registry.yarnpkg.com/@sentry/node/-/node-5.30.0.tgz#4ca479e799b1021285d7fe12ac0858951c11cd48" + integrity sha512-Br5oyVBF0fZo6ZS9bxbJZG4ApAjRqAnqFFurMVJJdunNb80brh7a5Qva2kjhm+U6r9NJAB5OmDyPkA1Qnt+QVg== + dependencies: + "@sentry/core" "5.30.0" + "@sentry/hub" "5.30.0" + "@sentry/tracing" "5.30.0" + "@sentry/types" "5.30.0" + "@sentry/utils" "5.30.0" + cookie "^0.4.1" + https-proxy-agent "^5.0.0" + lru_map "^0.3.3" + tslib "^1.9.3" + +"@sentry/tracing@5.30.0": + version "5.30.0" + resolved "https://registry.yarnpkg.com/@sentry/tracing/-/tracing-5.30.0.tgz#501d21f00c3f3be7f7635d8710da70d9419d4e1f" + integrity sha512-dUFowCr0AIMwiLD7Fs314Mdzcug+gBVo/+NCMyDw8tFxJkwWAKl7Qa2OZxLQ0ZHjakcj1hNKfCQJ9rhyfOl4Aw== + dependencies: + "@sentry/hub" "5.30.0" + "@sentry/minimal" "5.30.0" + "@sentry/types" "5.30.0" + "@sentry/utils" "5.30.0" + tslib "^1.9.3" + +"@sentry/types@5.30.0": + version "5.30.0" + resolved "https://registry.yarnpkg.com/@sentry/types/-/types-5.30.0.tgz#19709bbe12a1a0115bc790b8942917da5636f402" + integrity sha512-R8xOqlSTZ+htqrfteCWU5Nk0CDN5ApUTvrlvBuiH1DyP6czDZ4ktbZB0hAgBlVcK0U+qpD3ag3Tqqpa5Q67rPw== + +"@sentry/utils@5.30.0": + version "5.30.0" + resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-5.30.0.tgz#9a5bd7ccff85ccfe7856d493bffa64cabc41e980" + integrity sha512-zaYmoH0NWWtvnJjC9/CBseXMtKHm/tm40sz3YfJRxeQjyzRqNQPgivpd9R/oDJCYj999mzdW382p/qi2ypjLww== + dependencies: + "@sentry/types" "5.30.0" + tslib "^1.9.3" + +"@sinonjs/commons@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-2.0.0.tgz#fd4ca5b063554307e8327b4564bd56d3b73924a3" + integrity sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg== + dependencies: + type-detect "4.0.8" + +"@sinonjs/commons@^3.0.0", "@sinonjs/commons@^3.0.1": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-3.0.1.tgz#1029357e44ca901a615585f6d27738dbc89084cd" + integrity sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ== + dependencies: + type-detect "4.0.8" + +"@sinonjs/fake-timers@^11.2.2": + version "11.2.2" + resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-11.2.2.tgz#50063cc3574f4a27bd8453180a04171c85cc9699" + integrity sha512-G2piCSxQ7oWOxwGSAyFHfPIsyeJGXYtc6mFbnFA+kRXkiEnTl8c/8jul2S329iFBnDI9HGoeWWAZvuvOkZccgw== + dependencies: + "@sinonjs/commons" "^3.0.0" + +"@sinonjs/samsam@^8.0.0": + version "8.0.0" + resolved "https://registry.yarnpkg.com/@sinonjs/samsam/-/samsam-8.0.0.tgz#0d488c91efb3fa1442e26abea81759dfc8b5ac60" + integrity sha512-Bp8KUVlLp8ibJZrnvq2foVhP0IVX2CIprMJPK0vqGqgrDa0OHVKeZyBykqskkrdxV6yKBPmGasO8LVjAKR3Gew== + dependencies: + "@sinonjs/commons" "^2.0.0" + lodash.get "^4.4.2" + type-detect "^4.0.8" + +"@sinonjs/text-encoding@^0.7.2": + version "0.7.2" + resolved "https://registry.yarnpkg.com/@sinonjs/text-encoding/-/text-encoding-0.7.2.tgz#5981a8db18b56ba38ef0efb7d995b12aa7b51918" + integrity sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ== + +"@solidity-parser/parser@^0.14.0": + version "0.14.5" + resolved "https://registry.yarnpkg.com/@solidity-parser/parser/-/parser-0.14.5.tgz#87bc3cc7b068e08195c219c91cd8ddff5ef1a804" + integrity sha512-6dKnHZn7fg/iQATVEzqyUOyEidbn05q7YA2mQ9hC0MMXhhV3/JrsxmFSYZAcr7j1yUP700LLhTruvJ3MiQmjJg== + dependencies: + antlr4ts "^0.5.0-alpha.4" + +"@solidity-parser/parser@^0.16.0": + version "0.16.2" + resolved "https://registry.yarnpkg.com/@solidity-parser/parser/-/parser-0.16.2.tgz#42cb1e3d88b3e8029b0c9befff00b634cd92d2fa" + integrity sha512-PI9NfoA3P8XK2VBkK5oIfRgKDsicwDZfkVq9ZTBCQYGOP1N2owgY2dyLGyU5/J/hQs8KRk55kdmvTLjy3Mu3vg== + dependencies: + antlr4ts "^0.5.0-alpha.4" + +"@solidity-parser/parser@^0.17.0": + version "0.17.0" + resolved "https://registry.yarnpkg.com/@solidity-parser/parser/-/parser-0.17.0.tgz#52a2fcc97ff609f72011014e4c5b485ec52243ef" + integrity sha512-Nko8R0/kUo391jsEHHxrGM07QFdnPGvlmox4rmH0kNiNAashItAilhy4Mv4pK5gQmW5f4sXAF58fwJbmlkGcVw== + +"@solidity-parser/parser@^0.18.0": + version "0.18.0" + resolved "https://registry.yarnpkg.com/@solidity-parser/parser/-/parser-0.18.0.tgz#8e77a02a09ecce957255a2f48c9a7178ec191908" + integrity sha512-yfORGUIPgLck41qyN7nbwJRAx17/jAIXCTanHOJZhB6PJ1iAk/84b/xlsVKFSyNyLXIj0dhppoE0+CRws7wlzA== + +"@thirdweb-dev/crypto@0.2.2": + version "0.2.2" + resolved "https://registry.yarnpkg.com/@thirdweb-dev/crypto/-/crypto-0.2.2.tgz#455c7564610a1eb4597ae1d02c0ce3d722072709" + integrity sha512-jOwHtdViJYZ5015F3xZvwmnFZLrgTx2RkE7bAiG/N83f5TduwQBM3PAPTbW3aBOECaoSrbmgj/lQEOv7543z3Q== + dependencies: + "@noble/hashes" "^1.3.2" + js-sha3 "^0.9.2" + +"@thirdweb-dev/dynamic-contracts@^1.2.4": + version "1.2.5" + resolved "https://registry.yarnpkg.com/@thirdweb-dev/dynamic-contracts/-/dynamic-contracts-1.2.5.tgz#f9735c0d46198e7bf2f98c277f0a9a79c54da1e8" + integrity sha512-YVsz+jUWbwj+6aF2eTZGMfyw47a1HRmgNl4LQ3gW9gwYL5y5+OX/yOzv6aV5ibvoqCk/k10aIVK2eFrcpMubQA== + +"@thirdweb-dev/merkletree@^0.2.2": + version "0.2.2" + resolved "https://registry.yarnpkg.com/@thirdweb-dev/merkletree/-/merkletree-0.2.2.tgz#179faa2cbfaaab0a8dfc2b4fb9601a4ec87f60f8" + integrity sha512-cOEU6ga8+Lyk3b/XsI0h40ljxcTyommQhA38eAWXxUYV1wxH/g7Mry3OOHyY1HCBC2R2MXykCdiFuaoUsQB6Pw== + dependencies: + "@thirdweb-dev/crypto" "0.2.2" + buffer "^6.0.3" + buffer-reverse "^1.0.1" + treeify "^1.1.0" + +"@ts-morph/common@~0.22.0": + version "0.22.0" + resolved "https://registry.yarnpkg.com/@ts-morph/common/-/common-0.22.0.tgz#8951d451622a26472fbc3a227d6c3a90e687a683" + integrity sha512-HqNBuV/oIlMKdkLshXd1zKBqNQCsuPEsgQOkfFQ/eUKjRlwndXW1AjN9LVkBEIukm00gGXSRmfkl0Wv5VXLnlw== + dependencies: + fast-glob "^3.3.2" + minimatch "^9.0.3" + mkdirp "^3.0.1" + path-browserify "^1.0.1" + +"@tsconfig/node10@^1.0.7": + version "1.0.9" + resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.9.tgz#df4907fc07a886922637b15e02d4cebc4c0021b2" + integrity sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA== + +"@tsconfig/node12@^1.0.7": + version "1.0.11" + resolved "https://registry.yarnpkg.com/@tsconfig/node12/-/node12-1.0.11.tgz#ee3def1f27d9ed66dac6e46a295cffb0152e058d" + integrity sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag== + +"@tsconfig/node14@^1.0.0": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@tsconfig/node14/-/node14-1.0.3.tgz#e4386316284f00b98435bf40f72f75a09dabf6c1" + integrity sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow== + +"@tsconfig/node16@^1.0.2": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.4.tgz#0b92dcc0cc1c81f6f306a381f28e31b1a56536e9" + integrity sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA== + +"@typechain/ethers-v5@^10.2.1": + version "10.2.1" + resolved "https://registry.yarnpkg.com/@typechain/ethers-v5/-/ethers-v5-10.2.1.tgz#50241e6957683281ecfa03fb5a6724d8a3ce2391" + integrity sha512-n3tQmCZjRE6IU4h6lqUGiQ1j866n5MTCBJreNEHHVWXa2u9GJTaeYyU1/k+1qLutkyw+sS6VAN+AbeiTqsxd/A== + dependencies: + lodash "^4.17.15" + ts-essentials "^7.0.1" + +"@typechain/ethers-v6@^0.5.0": + version "0.5.1" + resolved "https://registry.yarnpkg.com/@typechain/ethers-v6/-/ethers-v6-0.5.1.tgz#42fe214a19a8b687086c93189b301e2b878797ea" + integrity sha512-F+GklO8jBWlsaVV+9oHaPh5NJdd6rAKN4tklGfInX1Q7h0xPgVLP39Jl3eCulPB5qexI71ZFHwbljx4ZXNfouA== + dependencies: + lodash "^4.17.15" + ts-essentials "^7.0.1" + +"@typechain/hardhat@^9.0.0": + version "9.1.0" + resolved "https://registry.yarnpkg.com/@typechain/hardhat/-/hardhat-9.1.0.tgz#6985015f01dfb37ef2ca8a29c742d05890351ddc" + integrity sha512-mtaUlzLlkqTlfPwB3FORdejqBskSnh+Jl8AIJGjXNAQfRQ4ofHADPl1+oU7Z3pAJzmZbUXII8MhOLQltcHgKnA== + dependencies: + fs-extra "^9.1.0" + +"@types/bn.js@^4.11.3": + version "4.11.6" + resolved "https://registry.yarnpkg.com/@types/bn.js/-/bn.js-4.11.6.tgz#c306c70d9358aaea33cd4eda092a742b9505967c" + integrity sha512-pqr857jrp2kPuO9uRjZ3PwnJTjoQy+fcdxvBTvHm6dkmEL9q+hDD/2j/0ELOBPtPnS8LjCX0gI9nbl8lVkadpg== + dependencies: + "@types/node" "*" + +"@types/bn.js@^5.1.0": + version "5.1.5" + resolved "https://registry.yarnpkg.com/@types/bn.js/-/bn.js-5.1.5.tgz#2e0dacdcce2c0f16b905d20ff87aedbc6f7b4bf0" + integrity sha512-V46N0zwKRF5Q00AZ6hWtN0T8gGmDUaUzLWQvHFo5yThtVwK/VCenFY3wXVbOvNfajEpsTfQM4IN9k/d6gUVX3A== + dependencies: + "@types/node" "*" + +"@types/chai-as-promised@^7.1.3": + version "7.1.8" + resolved "https://registry.yarnpkg.com/@types/chai-as-promised/-/chai-as-promised-7.1.8.tgz#f2b3d82d53c59626b5d6bbc087667ccb4b677fe9" + integrity sha512-ThlRVIJhr69FLlh6IctTXFkmhtP3NpMZ2QGq69StYLyKZFp/HOp1VdKZj7RvfNWYYcJ1xlbLGLLWj1UvP5u/Gw== + dependencies: + "@types/chai" "*" + +"@types/chai@*", "@types/chai@^4.2.0": + version "4.3.16" + resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.3.16.tgz#b1572967f0b8b60bf3f87fe1d854a5604ea70c82" + integrity sha512-PatH4iOdyh3MyWtmHVFXLWCCIhUbopaltqddG9BzB+gMIzee2MJrvd+jouii9Z3wzQJruGWAm7WOMjgfG8hQlQ== + +"@types/concat-stream@^1.6.0": + version "1.6.1" + resolved "https://registry.yarnpkg.com/@types/concat-stream/-/concat-stream-1.6.1.tgz#24bcfc101ecf68e886aaedce60dfd74b632a1b74" + integrity sha512-eHE4cQPoj6ngxBZMvVf6Hw7Mh4jMW4U9lpGmS5GBPB9RYxlFg+CHaVN7ErNY4W9XfLIEn20b4VDYaIrbq0q4uA== + dependencies: + "@types/node" "*" + +"@types/form-data@0.0.33": + version "0.0.33" + resolved "https://registry.yarnpkg.com/@types/form-data/-/form-data-0.0.33.tgz#c9ac85b2a5fd18435b8c85d9ecb50e6d6c893ff8" + integrity sha512-8BSvG1kGm83cyJITQMZSulnl6QV8jqAGreJsc5tPu1Jq0vTSOiY/k24Wx82JRpWwZSqrala6sd5rWi6aNXvqcw== + dependencies: + "@types/node" "*" + +"@types/fs-extra@^9.0.13": + version "9.0.13" + resolved "https://registry.yarnpkg.com/@types/fs-extra/-/fs-extra-9.0.13.tgz#7594fbae04fe7f1918ce8b3d213f74ff44ac1f45" + integrity sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA== + dependencies: + "@types/node" "*" + +"@types/glob@^7.1.1": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@types/glob/-/glob-7.2.0.tgz#bc1b5bf3aa92f25bd5dd39f35c57361bdce5b2eb" + integrity sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA== + dependencies: + "@types/minimatch" "*" + "@types/node" "*" + +"@types/json-schema@^7.0.9": + version "7.0.15" + resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" + integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== + +"@types/lru-cache@^5.1.0": + version "5.1.1" + resolved "https://registry.yarnpkg.com/@types/lru-cache/-/lru-cache-5.1.1.tgz#c48c2e27b65d2a153b19bfc1a317e30872e01eef" + integrity sha512-ssE3Vlrys7sdIzs5LOxCzTVMsU7i9oa/IaW92wF32JFb3CVczqOkru2xspuKczHEbG3nvmPY7IFqVmGGHdNbYw== + +"@types/minimatch@*": + version "5.1.2" + resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-5.1.2.tgz#07508b45797cb81ec3f273011b054cd0755eddca" + integrity sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA== + +"@types/mocha@^9.1.1": + version "9.1.1" + resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-9.1.1.tgz#e7c4f1001eefa4b8afbd1eee27a237fee3bf29c4" + integrity sha512-Z61JK7DKDtdKTWwLeElSEBcWGRLY8g95ic5FoQqI9CMx0ns/Ghep3B4DfcEimiKMvtamNVULVNKEsiwV3aQmXw== + +"@types/node@*": + version "20.11.6" + resolved "https://registry.yarnpkg.com/@types/node/-/node-20.11.6.tgz#6adf4241460e28be53836529c033a41985f85b6e" + integrity sha512-+EOokTnksGVgip2PbYbr3xnR7kZigh4LbybAfBAw5BpnQ+FqBYUsvCEjYd70IXKlbohQ64mzEYmMtlWUY8q//Q== + dependencies: + undici-types "~5.26.4" + +"@types/node@^10.0.3": + version "10.17.60" + resolved "https://registry.yarnpkg.com/@types/node/-/node-10.17.60.tgz#35f3d6213daed95da7f0f73e75bcc6980e90597b" + integrity sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw== + +"@types/node@^17.0.45": + version "17.0.45" + resolved "https://registry.yarnpkg.com/@types/node/-/node-17.0.45.tgz#2c0fafd78705e7a18b7906b5201a522719dc5190" + integrity sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw== + +"@types/node@^8.0.0": + version "8.10.66" + resolved "https://registry.yarnpkg.com/@types/node/-/node-8.10.66.tgz#dd035d409df322acc83dff62a602f12a5783bbb3" + integrity sha512-tktOkFUA4kXx2hhhrB8bIFb5TbwzS4uOhKEmwiD+NoiL0qtP2OQ9mFldbgD4dV1djrlBYP6eBuQZiWjuHUpqFw== + +"@types/pbkdf2@^3.0.0": + version "3.1.2" + resolved "https://registry.yarnpkg.com/@types/pbkdf2/-/pbkdf2-3.1.2.tgz#2dc43808e9985a2c69ff02e2d2027bd4fe33e8dc" + integrity sha512-uRwJqmiXmh9++aSu1VNEn3iIxWOhd8AHXNSdlaLfdAAdSTY9jYVeGWnzejM3dvrkbqE3/hyQkQQ29IFATEGlew== + dependencies: + "@types/node" "*" + +"@types/prettier@^2.1.1": + version "2.7.3" + resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.7.3.tgz#3e51a17e291d01d17d3fc61422015a933af7a08f" + integrity sha512-+68kP9yzs4LMp7VNh8gdzMSPZFL44MLGqiHWvttYJe+6qnuVr4Ek9wSBQoveqY/r+LwjCcU29kNVkidwim+kYA== + +"@types/qs@^6.2.31": + version "6.9.15" + resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.15.tgz#adde8a060ec9c305a82de1babc1056e73bd64dce" + integrity sha512-uXHQKES6DQKKCLh441Xv/dwxOq1TVS3JPUMlEqoEglvlhR6Mxnlew/Xq/LRVHpLyk7iK3zODe1qYHIMltO7XGg== + +"@types/secp256k1@^4.0.1": + version "4.0.6" + resolved "https://registry.yarnpkg.com/@types/secp256k1/-/secp256k1-4.0.6.tgz#d60ba2349a51c2cbc5e816dcd831a42029d376bf" + integrity sha512-hHxJU6PAEUn0TP4S/ZOzuTUvJWuZ6eIKeNKb5RBpODvSl6hp1Wrw4s7ATY50rklRCScUDpHzVA/DQdSjJ3UoYQ== + dependencies: + "@types/node" "*" + +"@types/semver@^7.3.12": + version "7.5.6" + resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.5.6.tgz#c65b2bfce1bec346582c07724e3f8c1017a20339" + integrity sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A== + +"@typescript-eslint/eslint-plugin@^5.62.0": + version "5.62.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz#aeef0328d172b9e37d9bab6dbc13b87ed88977db" + integrity sha512-TiZzBSJja/LbhNPvk6yc0JrX9XqhQ0hdh6M2svYfsHGejaKFIAGd9MQ+ERIMzLGlN/kZoYIgdxFV0PuljTKXag== + dependencies: + "@eslint-community/regexpp" "^4.4.0" + "@typescript-eslint/scope-manager" "5.62.0" + "@typescript-eslint/type-utils" "5.62.0" + "@typescript-eslint/utils" "5.62.0" + debug "^4.3.4" + graphemer "^1.4.0" + ignore "^5.2.0" + natural-compare-lite "^1.4.0" + semver "^7.3.7" + tsutils "^3.21.0" + +"@typescript-eslint/parser@^5.62.0": + version "5.62.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.62.0.tgz#1b63d082d849a2fcae8a569248fbe2ee1b8a56c7" + integrity sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA== + dependencies: + "@typescript-eslint/scope-manager" "5.62.0" + "@typescript-eslint/types" "5.62.0" + "@typescript-eslint/typescript-estree" "5.62.0" + debug "^4.3.4" + +"@typescript-eslint/scope-manager@5.62.0": + version "5.62.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.62.0.tgz#d9457ccc6a0b8d6b37d0eb252a23022478c5460c" + integrity sha512-VXuvVvZeQCQb5Zgf4HAxc04q5j+WrNAtNh9OwCsCgpKqESMTu3tF/jhZ3xG6T4NZwWl65Bg8KuS2uEvhSfLl0w== + dependencies: + "@typescript-eslint/types" "5.62.0" + "@typescript-eslint/visitor-keys" "5.62.0" + +"@typescript-eslint/type-utils@5.62.0": + version "5.62.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.62.0.tgz#286f0389c41681376cdad96b309cedd17d70346a" + integrity sha512-xsSQreu+VnfbqQpW5vnCJdq1Z3Q0U31qiWmRhr98ONQmcp/yhiPJFPq8MXiJVLiksmOKSjIldZzkebzHuCGzew== + dependencies: + "@typescript-eslint/typescript-estree" "5.62.0" + "@typescript-eslint/utils" "5.62.0" + debug "^4.3.4" + tsutils "^3.21.0" + +"@typescript-eslint/types@5.62.0": + version "5.62.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.62.0.tgz#258607e60effa309f067608931c3df6fed41fd2f" + integrity sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ== + +"@typescript-eslint/typescript-estree@5.62.0": + version "5.62.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.62.0.tgz#7d17794b77fabcac615d6a48fb143330d962eb9b" + integrity sha512-CmcQ6uY7b9y694lKdRB8FEel7JbU/40iSAPomu++SjLMntB+2Leay2LO6i8VnJk58MtE9/nQSFIH6jpyRWyYzA== + dependencies: + "@typescript-eslint/types" "5.62.0" + "@typescript-eslint/visitor-keys" "5.62.0" + debug "^4.3.4" + globby "^11.1.0" + is-glob "^4.0.3" + semver "^7.3.7" + tsutils "^3.21.0" + +"@typescript-eslint/utils@5.62.0": + version "5.62.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.62.0.tgz#141e809c71636e4a75daa39faed2fb5f4b10df86" + integrity sha512-n8oxjeb5aIbPFEtmQxQYOLI0i9n5ySBEY/ZEHHZqKQSFnxio1rv6dthascc9dLuwrL0RC5mPCxB7vnAVGAYWAQ== + dependencies: + "@eslint-community/eslint-utils" "^4.2.0" + "@types/json-schema" "^7.0.9" + "@types/semver" "^7.3.12" + "@typescript-eslint/scope-manager" "5.62.0" + "@typescript-eslint/types" "5.62.0" + "@typescript-eslint/typescript-estree" "5.62.0" + eslint-scope "^5.1.1" + semver "^7.3.7" + +"@typescript-eslint/visitor-keys@5.62.0": + version "5.62.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.62.0.tgz#2174011917ce582875954ffe2f6912d5931e353e" + integrity sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw== + dependencies: + "@typescript-eslint/types" "5.62.0" + eslint-visitor-keys "^3.3.0" + +"@ungap/promise-all-settled@1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@ungap/promise-all-settled/-/promise-all-settled-1.1.2.tgz#aa58042711d6e3275dd37dc597e5d31e8c290a44" + integrity sha512-sL/cEvJWAnClXw0wHk85/2L0G6Sj8UB0Ctc1TEMbKSsmpRosqhwj9gWgFRZSrBr2f9tiXISwNhCPmlfqUqyb9Q== + +"@ungap/structured-clone@^1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.2.0.tgz#756641adb587851b5ccb3e095daf27ae581c8406" + integrity sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ== + +JSONStream@1.3.2: + version "1.3.2" + resolved "https://registry.yarnpkg.com/JSONStream/-/JSONStream-1.3.2.tgz#c102371b6ec3a7cf3b847ca00c20bb0fce4c6dea" + integrity sha512-mn0KSip7N4e0UDPZHnqDsHECo5uGQrixQKnAskOM1BIB8hd7QKbd6il8IPRPudPHOeHiECoCFqhyMaRO9+nWyA== + dependencies: + jsonparse "^1.2.0" + through ">=2.2.7 <3" + +abbrev@1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" + integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q== + +abbrev@1.0.x: + version "1.0.9" + resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.0.9.tgz#91b4792588a7738c25f35dd6f63752a2f8776135" + integrity sha512-LEyx4aLEC3x6T0UguF6YILf+ntvmOaWsVfENmIW0E9H09vKlLDGelMjjSm0jkDHALj8A8quZ/HapKNigzwge+Q== + +acorn-jsx@^5.3.2: + version "5.3.2" + resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" + integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== + +acorn-walk@^8.1.1: + version "8.3.2" + resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.3.2.tgz#7703af9415f1b6db9315d6895503862e231d34aa" + integrity sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A== + +acorn@^8.4.1, acorn@^8.9.0: + version "8.11.3" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.11.3.tgz#71e0b14e13a4ec160724b38fb7b0f233b1b81d7a" + integrity sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg== + +adm-zip@^0.4.16: + version "0.4.16" + resolved "https://registry.yarnpkg.com/adm-zip/-/adm-zip-0.4.16.tgz#cf4c508fdffab02c269cbc7f471a875f05570365" + integrity sha512-TFi4HBKSGfIKsK5YCkKaaFG2m4PEDyViZmEwof3MTIgzimHLto6muaHVpbrljdIvIrFZzEq/p4nafOeLcYegrg== + +aes-js@3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/aes-js/-/aes-js-3.0.0.tgz#e21df10ad6c2053295bcbb8dab40b09dbea87e4d" + integrity sha512-H7wUZRn8WpTq9jocdxQ2c8x2sKo9ZVmzfRE13GiNJXfp7NcKYEdvl3vspKjXox6RIG2VtaRe4JFvxG4rqp2Zuw== + +agent-base@6: + version "6.0.2" + resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77" + integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ== + dependencies: + debug "4" + +aggregate-error@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/aggregate-error/-/aggregate-error-3.1.0.tgz#92670ff50f5359bdb7a3e0d40d0ec30c5737687a" + integrity sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA== + dependencies: + clean-stack "^2.0.0" + indent-string "^4.0.0" + +ajv@^6.12.4, ajv@^6.12.6: + version "6.12.6" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" + integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== + dependencies: + fast-deep-equal "^3.1.1" + fast-json-stable-stringify "^2.0.0" + json-schema-traverse "^0.4.1" + uri-js "^4.2.2" + +ajv@^8.0.1: + version "8.12.0" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.12.0.tgz#d1a0527323e22f53562c567c00991577dfbe19d1" + integrity sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA== + dependencies: + fast-deep-equal "^3.1.1" + json-schema-traverse "^1.0.0" + require-from-string "^2.0.2" + uri-js "^4.2.2" + +amdefine@>=0.0.4: + version "1.0.1" + resolved "https://registry.yarnpkg.com/amdefine/-/amdefine-1.0.1.tgz#4a5282ac164729e93619bcfd3ad151f817ce91f5" + integrity sha512-S2Hw0TtNkMJhIabBwIojKL9YHO5T0n5eNqWJ7Lrlel/zDbftQpxpapi8tZs3X1HWa+u+QeydGmzzNU0m09+Rcg== + +ansi-align@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/ansi-align/-/ansi-align-3.0.1.tgz#0cdf12e111ace773a86e9a1fad1225c43cb19a59" + integrity sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w== + dependencies: + string-width "^4.1.0" + +ansi-colors@4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.1.tgz#cbb9ae256bf750af1eab344f229aa27fe94ba348" + integrity sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA== + +ansi-colors@^4.1.1: + version "4.1.3" + resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.3.tgz#37611340eb2243e70cc604cad35d63270d48781b" + integrity sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw== + +ansi-escapes@^4.3.0: + version "4.3.2" + resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.2.tgz#6b2291d1db7d98b6521d5f1efa42d0f3a9feb65e" + integrity sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ== + dependencies: + type-fest "^0.21.3" + +ansi-regex@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-3.0.1.tgz#123d6479e92ad45ad897d4054e3c7ca7db4944e1" + integrity sha512-+O9Jct8wf++lXxxFc4hc8LsjaSq0HFzzL7cVsw8pRDIPdjKD2mT4ytDZlLuSBZ4cLKZFXIrMGO7DbQCtMJJMKw== + +ansi-regex@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" + integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== + +ansi-regex@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.0.1.tgz#3183e38fae9a65d7cb5e53945cd5897d0260a06a" + integrity sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA== + +ansi-styles@^3.2.1: + version "3.2.1" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" + integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA== + dependencies: + color-convert "^1.9.0" + +ansi-styles@^4.0.0, ansi-styles@^4.1.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" + integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== + dependencies: + color-convert "^2.0.1" + +ansi-styles@^6.1.0: + version "6.2.1" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.1.tgz#0e62320cf99c21afff3b3012192546aacbfb05c5" + integrity sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug== + +antlr4@^4.11.0: + version "4.13.1" + resolved "https://registry.yarnpkg.com/antlr4/-/antlr4-4.13.1.tgz#1e0a1830a08faeb86217cb2e6c34716004e4253d" + integrity sha512-kiXTspaRYvnIArgE97z5YVVf/cDVQABr3abFRR6mE7yesLMkgu4ujuyV/sgxafQ8wgve0DJQUJ38Z8tkgA2izA== + +antlr4ts@^0.5.0-alpha.4: + version "0.5.0-alpha.4" + resolved "https://registry.yarnpkg.com/antlr4ts/-/antlr4ts-0.5.0-alpha.4.tgz#71702865a87478ed0b40c0709f422cf14d51652a" + integrity sha512-WPQDt1B74OfPv/IMS2ekXAKkTZIHl88uMetg6q3OTqgFxZ/dxDXI0EWLyZid/1Pe6hTftyg5N7gel5wNAGxXyQ== + +any-promise@^1.0.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/any-promise/-/any-promise-1.3.0.tgz#abc6afeedcea52e809cdc0376aed3ce39635d17f" + integrity sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A== + +anymatch@~3.1.2: + version "3.1.3" + resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e" + integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw== + dependencies: + normalize-path "^3.0.0" + picomatch "^2.0.4" + +arg@^4.1.0: + version "4.1.3" + resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089" + integrity sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA== + +argparse@^1.0.7: + version "1.0.10" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" + integrity sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg== + dependencies: + sprintf-js "~1.0.2" + +argparse@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" + integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== + +array-back@^3.0.1, array-back@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/array-back/-/array-back-3.1.0.tgz#b8859d7a508871c9a7b2cf42f99428f65e96bfb0" + integrity sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q== + +array-back@^4.0.1, array-back@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/array-back/-/array-back-4.0.2.tgz#8004e999a6274586beeb27342168652fdb89fa1e" + integrity sha512-NbdMezxqf94cnNfWLL7V/im0Ub+Anbb0IoZhvzie8+4HJ4nMQuzHuy49FkGYCJK2yAloZ3meiB6AVMClbrI1vg== + +array-buffer-byte-length@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz#1e5583ec16763540a27ae52eed99ff899223568f" + integrity sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg== + dependencies: + call-bind "^1.0.5" + is-array-buffer "^3.0.4" + +array-union@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/array-union/-/array-union-2.1.0.tgz#b798420adbeb1de828d84acd8a2e23d3efe85e8d" + integrity sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw== + +array-uniq@1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/array-uniq/-/array-uniq-1.0.3.tgz#af6ac877a25cc7f74e058894753858dfdb24fdb6" + integrity sha512-MNha4BWQ6JbwhFhj03YK552f7cb3AzoE8SzeljgChvL1dl3IcvggXVz1DilzySZkCja+CXuZbdW7yATchWn8/Q== + +array.prototype.findlast@^1.2.2: + version "1.2.5" + resolved "https://registry.yarnpkg.com/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz#3e4fbcb30a15a7f5bf64cf2faae22d139c2e4904" + integrity sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + es-abstract "^1.23.2" + es-errors "^1.3.0" + es-object-atoms "^1.0.0" + es-shim-unscopables "^1.0.2" + +arraybuffer.prototype.slice@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.3.tgz#097972f4255e41bc3425e37dc3f6421cf9aefde6" + integrity sha512-bMxMKAjg13EBSVscxTaYA4mRc5t1UAXa2kXiGTNfZ079HIWXEkKmkgFrh/nJqamaLSrXO5H4WFFkPEaLJWbs3A== + dependencies: + array-buffer-byte-length "^1.0.1" + call-bind "^1.0.5" + define-properties "^1.2.1" + es-abstract "^1.22.3" + es-errors "^1.2.1" + get-intrinsic "^1.2.3" + is-array-buffer "^3.0.4" + is-shared-array-buffer "^1.0.2" + +asap@~2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46" + integrity sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA== + +asn1@^0.2.6: + version "0.2.6" + resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.6.tgz#0d3a7bb6e64e02a90c0303b31f292868ea09a08d" + integrity sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ== + dependencies: + safer-buffer "~2.1.0" + +assertion-error@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-1.1.0.tgz#e60b6b0e8f301bd97e5375215bda406c85118c0b" + integrity sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw== + +ast-parents@^0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/ast-parents/-/ast-parents-0.0.1.tgz#508fd0f05d0c48775d9eccda2e174423261e8dd3" + integrity sha512-XHusKxKz3zoYk1ic8Un640joHbFMhbqneyoZfoKnEGtf2ey9Uh/IdpcQplODdO/kENaMIWsD0nJm4+wX3UNLHA== + +astral-regex@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-2.0.0.tgz#483143c567aeed4785759c0865786dc77d7d2e31" + integrity sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ== + +async@1.x: + version "1.5.2" + resolved "https://registry.yarnpkg.com/async/-/async-1.5.2.tgz#ec6a61ae56480c0c3cb241c95618e20892f9672a" + integrity sha512-nSVgobk4rv61R9PUSDtYt7mPVB2olxNR5RWJcAsH676/ef11bUZwvu7+RGYrYauVdDPcO519v68wRhXQtxsV9w== + +asynckit@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" + integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== + +at-least-node@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/at-least-node/-/at-least-node-1.0.0.tgz#602cd4b46e844ad4effc92a8011a3c46e0238dc2" + integrity sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg== + +available-typed-arrays@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz#a5cc375d6a03c2efc87a553f3e0b1522def14846" + integrity sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ== + dependencies: + possible-typed-array-names "^1.0.0" + +axios@^1.4.0, axios@^1.5.1, axios@^1.6.2: + version "1.6.8" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.6.8.tgz#66d294951f5d988a00e87a0ffb955316a619ea66" + integrity sha512-v/ZHtJDU39mDpyBoFVkETcd/uNdxrWRrg3bKpOKzXFA6Bvqopts6ALSMU3y6ijYxbw2B+wPrIv46egTzJXCLGQ== + dependencies: + follow-redirects "^1.15.6" + form-data "^4.0.0" + proxy-from-env "^1.1.0" + +balanced-match@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" + integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== + +base-x@^3.0.2: + version "3.0.9" + resolved "https://registry.yarnpkg.com/base-x/-/base-x-3.0.9.tgz#6349aaabb58526332de9f60995e548a53fe21320" + integrity sha512-H7JU6iBHTal1gp56aKoaa//YUxEaAOUiydvrV/pILqIHXTtqxSkATOnDA2u+jZ/61sD+L/412+7kzXRtWukhpQ== + dependencies: + safe-buffer "^5.0.1" + +base64-js@^1.3.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" + integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== + +bcrypt-pbkdf@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz#a4301d389b6a43f9b67ff3ca11a3f6637e360e9e" + integrity sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w== + dependencies: + tweetnacl "^0.14.3" + +bech32@1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/bech32/-/bech32-1.1.4.tgz#e38c9f37bf179b8eb16ae3a772b40c356d4832e9" + integrity sha512-s0IrSOzLlbvX7yp4WBfPITzpAU8sqQcpsmwXDiKwrG4r491vwCO/XpejasRNl0piBMe/DvP4Tz0mIS/X1DPJBQ== + +binary-extensions@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" + integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA== + +bl@^1.0.0: + version "1.2.3" + resolved "https://registry.yarnpkg.com/bl/-/bl-1.2.3.tgz#1e8dd80142eac80d7158c9dccc047fb620e035e7" + integrity sha512-pvcNpa0UU69UT341rO6AYy4FVAIkUHuZXRIWbq+zHnsVcRzDDjIAhGuuYoi0d//cwIwtt4pkpKycWEfjdV+vww== + dependencies: + readable-stream "^2.3.5" + safe-buffer "^5.1.1" + +bl@^4.0.3: + version "4.1.0" + resolved "https://registry.yarnpkg.com/bl/-/bl-4.1.0.tgz#451535264182bec2fbbc83a62ab98cf11d9f7b3a" + integrity sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w== + dependencies: + buffer "^5.5.0" + inherits "^2.0.4" + readable-stream "^3.4.0" + +blakejs@^1.1.0: + version "1.2.1" + resolved "https://registry.yarnpkg.com/blakejs/-/blakejs-1.2.1.tgz#5057e4206eadb4a97f7c0b6e197a505042fc3814" + integrity sha512-QXUSXI3QVc/gJME0dBpXrag1kbzOqCjCX8/b54ntNyW6sjtoqxqRk3LTmXzaJoh71zMsDCjM+47jS7XiwN/+fQ== + +bn.js@4.11.6: + version "4.11.6" + resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.6.tgz#53344adb14617a13f6e8dd2ce28905d1c0ba3215" + integrity sha512-XWwnNNFCuuSQ0m3r3C4LE3EiORltHd9M05pq6FOlVeiophzRbMo50Sbz1ehl8K3Z+jw9+vmgnXefY1hz8X+2wA== + +bn.js@^4.11.0, bn.js@^4.11.8, bn.js@^4.11.9: + version "4.12.0" + resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.12.0.tgz#775b3f278efbb9718eec7361f483fb36fbbfea88" + integrity sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA== + +bn.js@^5.1.2, bn.js@^5.2.0, bn.js@^5.2.1: + version "5.2.1" + resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-5.2.1.tgz#0bc527a6a0d18d0aa8d5b0538ce4a77dccfa7b70" + integrity sha512-eXRvHzWyYPBuB4NBy0cmYQjGitUrtqwbvlzP3G6VFnNRbsZQIxQ10PbKKHt8gZ/HW/D/747aDl+QkDqg3KQLMQ== + +boxen@^5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/boxen/-/boxen-5.1.2.tgz#788cb686fc83c1f486dfa8a40c68fc2b831d2b50" + integrity sha512-9gYgQKXx+1nP8mP7CzFyaUARhg7D3n1dF/FnErWmu9l6JvGpNUN278h0aSb+QjoiKSWG+iZ3uHrcqk0qrY9RQQ== + dependencies: + ansi-align "^3.0.0" + camelcase "^6.2.0" + chalk "^4.1.0" + cli-boxes "^2.2.1" + string-width "^4.2.2" + type-fest "^0.20.2" + widest-line "^3.1.0" + wrap-ansi "^7.0.0" + +brace-expansion@^1.1.7: + version "1.1.11" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" + integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== + dependencies: + balanced-match "^1.0.0" + concat-map "0.0.1" + +brace-expansion@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.1.tgz#1edc459e0f0c548486ecf9fc99f2221364b9a0ae" + integrity sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA== + dependencies: + balanced-match "^1.0.0" + +braces@^3.0.2, braces@~3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107" + integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A== + dependencies: + fill-range "^7.0.1" + +brorand@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/brorand/-/brorand-1.1.0.tgz#12c25efe40a45e3c323eb8675a0a0ce57b22371f" + integrity sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w== + +browser-stdout@1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/browser-stdout/-/browser-stdout-1.3.1.tgz#baa559ee14ced73452229bad7326467c61fabd60" + integrity sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw== + +browserify-aes@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/browserify-aes/-/browserify-aes-1.2.0.tgz#326734642f403dabc3003209853bb70ad428ef48" + integrity sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA== + dependencies: + buffer-xor "^1.0.3" + cipher-base "^1.0.0" + create-hash "^1.1.0" + evp_bytestokey "^1.0.3" + inherits "^2.0.1" + safe-buffer "^5.0.1" + +bs58@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/bs58/-/bs58-4.0.1.tgz#be161e76c354f6f788ae4071f63f34e8c4f0a42a" + integrity sha512-Ok3Wdf5vOIlBrgCvTq96gBkJw+JUEzdBgyaza5HLtPm7yTHkjRy8+JzNyHF7BHa0bNWOQIp3m5YF0nnFcOIKLw== + dependencies: + base-x "^3.0.2" + +bs58check@^2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/bs58check/-/bs58check-2.1.2.tgz#53b018291228d82a5aa08e7d796fdafda54aebfc" + integrity sha512-0TS1jicxdU09dwJMNZtVAfzPi6Q6QeN0pM1Fkzrjn+XYHvzMKPU3pHVpva+769iNVSfIYWf7LJ6WR+BuuMf8cA== + dependencies: + bs58 "^4.0.0" + create-hash "^1.1.0" + safe-buffer "^5.1.2" + +buffer-alloc-unsafe@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz#bd7dc26ae2972d0eda253be061dba992349c19f0" + integrity sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg== + +buffer-alloc@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/buffer-alloc/-/buffer-alloc-1.2.0.tgz#890dd90d923a873e08e10e5fd51a57e5b7cce0ec" + integrity sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow== + dependencies: + buffer-alloc-unsafe "^1.1.0" + buffer-fill "^1.0.0" + +buffer-fill@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/buffer-fill/-/buffer-fill-1.0.0.tgz#f8f78b76789888ef39f205cd637f68e702122b2c" + integrity sha512-T7zexNBwiiaCOGDg9xNX9PBmjrubblRkENuptryuI64URkXDFum9il/JGL8Lm8wYfAXpredVXXZz7eMHilimiQ== + +buffer-from@^1.0.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" + integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== + +buffer-reverse@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/buffer-reverse/-/buffer-reverse-1.0.1.tgz#49283c8efa6f901bc01fa3304d06027971ae2f60" + integrity sha512-M87YIUBsZ6N924W57vDwT/aOu8hw7ZgdByz6ijksLjmHJELBASmYTTlNHRgjE+pTsT9oJXGaDSgqqwfdHotDUg== + +buffer-xor@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/buffer-xor/-/buffer-xor-1.0.3.tgz#26e61ed1422fb70dd42e6e36729ed51d855fe8d9" + integrity sha512-571s0T7nZWK6vB67HI5dyUF7wXiNcfaPPPTl6zYCNApANjIvYJTg7hlud/+cJpdAhS7dVzqMLmfhfHR3rAcOjQ== + +buffer@^5.5.0: + version "5.7.1" + resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0" + integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ== + dependencies: + base64-js "^1.3.1" + ieee754 "^1.1.13" + +buffer@^6.0.3: + version "6.0.3" + resolved "https://registry.yarnpkg.com/buffer/-/buffer-6.0.3.tgz#2ace578459cc8fbe2a70aaa8f52ee63b6a74c6c6" + integrity sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA== + dependencies: + base64-js "^1.3.1" + ieee754 "^1.2.1" + +buildcheck@~0.0.6: + version "0.0.6" + resolved "https://registry.yarnpkg.com/buildcheck/-/buildcheck-0.0.6.tgz#89aa6e417cfd1e2196e3f8fe915eb709d2fe4238" + integrity sha512-8f9ZJCUXyT1M35Jx7MkBgmBMo3oHTTBIPLiY9xyL0pl3T5RwcPEY8cUHr5LBNfu/fk6c2T4DJZuVM/8ZZT2D2A== + +bundle-require@^3.0.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/bundle-require/-/bundle-require-3.1.2.tgz#1374a7bdcb8b330a7ccc862ccbf7c137cc43ad27" + integrity sha512-Of6l6JBAxiyQ5axFxUM6dYeP/W7X2Sozeo/4EYB9sJhL+dqL7TKjg+shwxp6jlu/6ZSERfsYtIpSJ1/x3XkAEA== + dependencies: + load-tsconfig "^0.2.0" + +bytes@3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" + integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== + +cac@^6.7.12: + version "6.7.14" + resolved "https://registry.yarnpkg.com/cac/-/cac-6.7.14.tgz#804e1e6f506ee363cb0e3ccbb09cad5dd9870959" + integrity sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ== + +call-bind@^1.0.2, call-bind@^1.0.5, call-bind@^1.0.6, call-bind@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.7.tgz#06016599c40c56498c18769d2730be242b6fa3b9" + integrity sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w== + dependencies: + es-define-property "^1.0.0" + es-errors "^1.3.0" + function-bind "^1.1.2" + get-intrinsic "^1.2.4" + set-function-length "^1.2.1" + +callsites@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" + integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== + +camelcase@^6.0.0, camelcase@^6.2.0: + version "6.3.0" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a" + integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== + +caseless@^0.12.0, caseless@~0.12.0: + version "0.12.0" + resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" + integrity sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw== + +cbor@^8.0.0, cbor@^8.1.0: + version "8.1.0" + resolved "https://registry.yarnpkg.com/cbor/-/cbor-8.1.0.tgz#cfc56437e770b73417a2ecbfc9caf6b771af60d5" + integrity sha512-DwGjNW9omn6EwP70aXsn7FQJx5kO12tX0bZkaTjzdVFM6/7nhA4t0EENocKGx6D2Bch9PE2KzCUf5SceBdeijg== + dependencies: + nofilter "^3.1.0" + +cbor@^9.0.0: + version "9.0.2" + resolved "https://registry.yarnpkg.com/cbor/-/cbor-9.0.2.tgz#536b4f2d544411e70ec2b19a2453f10f83cd9fdb" + integrity sha512-JPypkxsB10s9QOWwa6zwPzqE1Md3vqpPc+cai4sAecuCsRyAtAl/pMyhPlMbT/xtPnm2dznJZYRLui57qiRhaQ== + dependencies: + nofilter "^3.1.0" + +chai-as-promised@^7.1.1: + version "7.1.1" + resolved "https://registry.yarnpkg.com/chai-as-promised/-/chai-as-promised-7.1.1.tgz#08645d825deb8696ee61725dbf590c012eb00ca0" + integrity sha512-azL6xMoi+uxu6z4rhWQ1jbdUhOMhis2PvscD/xjLqNMkv3BPPp2JyyuTHOrf9BOosGpNQ11v6BKv/g57RXbiaA== + dependencies: + check-error "^1.0.2" + +chai@^4.2.0, chai@^4.3.6, chai@^4.3.7: + version "4.4.1" + resolved "https://registry.yarnpkg.com/chai/-/chai-4.4.1.tgz#3603fa6eba35425b0f2ac91a009fe924106e50d1" + integrity sha512-13sOfMv2+DWduEU+/xbun3LScLoqN17nBeTLUsmDfKdoiC1fr0n9PU4guu4AhRcOVFk/sW8LyZWHuhWtQZiF+g== + dependencies: + assertion-error "^1.1.0" + check-error "^1.0.3" + deep-eql "^4.1.3" + get-func-name "^2.0.2" + loupe "^2.3.6" + pathval "^1.1.1" + type-detect "^4.0.8" + +chalk@4.1.2, chalk@^4.0.0, chalk@^4.1.0, chalk@^4.1.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" + integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + +chalk@^2.4.2: + version "2.4.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" + integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== + dependencies: + ansi-styles "^3.2.1" + escape-string-regexp "^1.0.5" + supports-color "^5.3.0" + +"charenc@>= 0.0.1": + version "0.0.2" + resolved "https://registry.yarnpkg.com/charenc/-/charenc-0.0.2.tgz#c0a1d2f3a7092e03774bfa83f14c0fc5790a8667" + integrity sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA== + +check-error@^1.0.2, check-error@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/check-error/-/check-error-1.0.3.tgz#a6502e4312a7ee969f646e83bb3ddd56281bd694" + integrity sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg== + dependencies: + get-func-name "^2.0.2" + +chokidar@3.5.3, chokidar@^3.5.1: + version "3.5.3" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd" + integrity sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw== + dependencies: + anymatch "~3.1.2" + braces "~3.0.2" + glob-parent "~5.1.2" + is-binary-path "~2.1.0" + is-glob "~4.0.1" + normalize-path "~3.0.0" + readdirp "~3.6.0" + optionalDependencies: + fsevents "~2.3.2" + +chokidar@^3.4.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b" + integrity sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw== + dependencies: + anymatch "~3.1.2" + braces "~3.0.2" + glob-parent "~5.1.2" + is-binary-path "~2.1.0" + is-glob "~4.0.1" + normalize-path "~3.0.0" + readdirp "~3.6.0" + optionalDependencies: + fsevents "~2.3.2" + +chownr@^1.0.1, chownr@^1.1.1: + version "1.1.4" + resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b" + integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg== + +ci-info@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-2.0.0.tgz#67a9e964be31a51e15e5010d58e6f12834002f46" + integrity sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ== + +cipher-base@^1.0.0, cipher-base@^1.0.1, cipher-base@^1.0.3: + version "1.0.4" + resolved "https://registry.yarnpkg.com/cipher-base/-/cipher-base-1.0.4.tgz#8760e4ecc272f4c363532f926d874aae2c1397de" + integrity sha512-Kkht5ye6ZGmwv40uUDZztayT2ThLQGfnj/T71N/XzeZeo3nf8foyW7zGTsPYkEya3m5f3cAypH+qe7YOrM1U2Q== + dependencies: + inherits "^2.0.1" + safe-buffer "^5.0.1" + +clean-stack@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/clean-stack/-/clean-stack-2.2.0.tgz#ee8472dbb129e727b31e8a10a427dee9dfe4008b" + integrity sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A== + +cli-boxes@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/cli-boxes/-/cli-boxes-2.2.1.tgz#ddd5035d25094fce220e9cab40a45840a440318f" + integrity sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw== + +cli-table3@^0.5.0: + version "0.5.1" + resolved "https://registry.yarnpkg.com/cli-table3/-/cli-table3-0.5.1.tgz#0252372d94dfc40dbd8df06005f48f31f656f202" + integrity sha512-7Qg2Jrep1S/+Q3EceiZtQcDPWxhAvBw+ERf1162v4sikJrvojMHFqXt8QIVha8UlH9rgU0BeWPytZ9/TzYqlUw== + dependencies: + object-assign "^4.1.0" + string-width "^2.1.1" + optionalDependencies: + colors "^1.1.2" + +cliui@^7.0.2: + version "7.0.4" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-7.0.4.tgz#a0265ee655476fc807aea9df3df8df7783808b4f" + integrity sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ== + dependencies: + string-width "^4.2.0" + strip-ansi "^6.0.0" + wrap-ansi "^7.0.0" + +code-block-writer@^12.0.0: + version "12.0.0" + resolved "https://registry.yarnpkg.com/code-block-writer/-/code-block-writer-12.0.0.tgz#4dd58946eb4234105aff7f0035977b2afdc2a770" + integrity sha512-q4dMFMlXtKR3XNBHyMHt/3pwYNA69EDk00lloMOaaUMKPUXBw6lpXtbu3MMVG6/uOihGnRDOlkyqsONEUj60+w== + +color-convert@^1.9.0: + version "1.9.3" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" + integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== + dependencies: + color-name "1.1.3" + +color-convert@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" + integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== + dependencies: + color-name "~1.1.4" + +color-name@1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" + integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw== + +color-name@~1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" + integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== + +colors@1.4.0, colors@^1.1.2: + version "1.4.0" + resolved "https://registry.yarnpkg.com/colors/-/colors-1.4.0.tgz#c50491479d4c1bdaed2c9ced32cf7c7dc2360f78" + integrity sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA== + +combined-stream@^1.0.6, combined-stream@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" + integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== + dependencies: + delayed-stream "~1.0.0" + +command-exists@^1.2.8: + version "1.2.9" + resolved "https://registry.yarnpkg.com/command-exists/-/command-exists-1.2.9.tgz#c50725af3808c8ab0260fd60b01fbfa25b954f69" + integrity sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w== + +command-line-args@^5.1.1: + version "5.2.1" + resolved "https://registry.yarnpkg.com/command-line-args/-/command-line-args-5.2.1.tgz#c44c32e437a57d7c51157696893c5909e9cec42e" + integrity sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg== + dependencies: + array-back "^3.1.0" + find-replace "^3.0.0" + lodash.camelcase "^4.3.0" + typical "^4.0.0" + +command-line-usage@^6.1.0: + version "6.1.3" + resolved "https://registry.yarnpkg.com/command-line-usage/-/command-line-usage-6.1.3.tgz#428fa5acde6a838779dfa30e44686f4b6761d957" + integrity sha512-sH5ZSPr+7UStsloltmDh7Ce5fb8XPlHyoPzTpyyMuYCtervL65+ubVZ6Q61cFtFl62UyJlc8/JwERRbAFPUqgw== + dependencies: + array-back "^4.0.2" + chalk "^2.4.2" + table-layout "^1.0.2" + typical "^5.2.0" + +commander@3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/commander/-/commander-3.0.2.tgz#6837c3fb677ad9933d1cfba42dd14d5117d6b39e" + integrity sha512-Gar0ASD4BDyKC4hl4DwHqDrmvjoxWKZigVnAbn5H1owvm4CxCPdb0HQDehwNYMJpla5+M2tPmPARzhtYuwpHow== + +commander@^10.0.0: + version "10.0.1" + resolved "https://registry.yarnpkg.com/commander/-/commander-10.0.1.tgz#881ee46b4f77d1c1dccc5823433aa39b022cbe06" + integrity sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug== + +commander@^4.0.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/commander/-/commander-4.1.1.tgz#9fd602bd936294e9e9ef46a3f4d6964044b18068" + integrity sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA== + +compare-versions@^5.0.0: + version "5.0.3" + resolved "https://registry.yarnpkg.com/compare-versions/-/compare-versions-5.0.3.tgz#a9b34fea217472650ef4a2651d905f42c28ebfd7" + integrity sha512-4UZlZP8Z99MGEY+Ovg/uJxJuvoXuN4M6B3hKaiackiHrgzQFEe3diJi1mf1PNHbFujM7FvLrK2bpgIaImbtZ1A== + +compare-versions@^6.0.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/compare-versions/-/compare-versions-6.1.0.tgz#3f2131e3ae93577df111dba133e6db876ffe127a" + integrity sha512-LNZQXhqUvqUTotpZ00qLSaify3b4VFD588aRr8MKFw4CMUr98ytzCW5wDH5qx/DEY5kCDXcbcRuCqL0szEf2tg== + +concat-map@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" + integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== + +concat-stream@^1.6.0, concat-stream@^1.6.2, concat-stream@~1.6.2: + version "1.6.2" + resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.6.2.tgz#904bdf194cd3122fc675c77fc4ac3d4ff0fd1a34" + integrity sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw== + dependencies: + buffer-from "^1.0.0" + inherits "^2.0.3" + readable-stream "^2.2.2" + typedarray "^0.0.6" + +cookie@^0.4.1: + version "0.4.2" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.2.tgz#0e41f24de5ecf317947c82fc789e06a884824432" + integrity sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA== + +core-util-is@~1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85" + integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ== + +cosmiconfig@^8.0.0: + version "8.3.6" + resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-8.3.6.tgz#060a2b871d66dba6c8538ea1118ba1ac16f5fae3" + integrity sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA== + dependencies: + import-fresh "^3.3.0" + js-yaml "^4.1.0" + parse-json "^5.2.0" + path-type "^4.0.0" + +cpu-features@~0.0.9: + version "0.0.10" + resolved "https://registry.yarnpkg.com/cpu-features/-/cpu-features-0.0.10.tgz#9aae536db2710c7254d7ed67cb3cbc7d29ad79c5" + integrity sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA== + dependencies: + buildcheck "~0.0.6" + nan "^2.19.0" + +create-hash@^1.1.0, create-hash@^1.1.2, create-hash@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/create-hash/-/create-hash-1.2.0.tgz#889078af11a63756bcfb59bd221996be3a9ef196" + integrity sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg== + dependencies: + cipher-base "^1.0.1" + inherits "^2.0.1" + md5.js "^1.3.4" + ripemd160 "^2.0.1" + sha.js "^2.4.0" + +create-hmac@^1.1.4, create-hmac@^1.1.7: + version "1.1.7" + resolved "https://registry.yarnpkg.com/create-hmac/-/create-hmac-1.1.7.tgz#69170c78b3ab957147b2b8b04572e47ead2243ff" + integrity sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg== + dependencies: + cipher-base "^1.0.3" + create-hash "^1.1.0" + inherits "^2.0.1" + ripemd160 "^2.0.0" + safe-buffer "^5.0.1" + sha.js "^2.4.8" + +create-require@^1.1.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333" + integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== + +cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3: + version "7.0.3" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" + integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== + dependencies: + path-key "^3.1.0" + shebang-command "^2.0.0" + which "^2.0.1" + +"crypt@>= 0.0.1": + version "0.0.2" + resolved "https://registry.yarnpkg.com/crypt/-/crypt-0.0.2.tgz#88d7ff7ec0dfb86f713dc87bbb42d044d3e6c41b" + integrity sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow== + +data-view-buffer@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/data-view-buffer/-/data-view-buffer-1.0.1.tgz#8ea6326efec17a2e42620696e671d7d5a8bc66b2" + integrity sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA== + dependencies: + call-bind "^1.0.6" + es-errors "^1.3.0" + is-data-view "^1.0.1" + +data-view-byte-length@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/data-view-byte-length/-/data-view-byte-length-1.0.1.tgz#90721ca95ff280677eb793749fce1011347669e2" + integrity sha512-4J7wRJD3ABAzr8wP+OcIcqq2dlUKp4DVflx++hs5h5ZKydWMI6/D/fAot+yh6g2tHh8fLFTvNOaVN357NvSrOQ== + dependencies: + call-bind "^1.0.7" + es-errors "^1.3.0" + is-data-view "^1.0.1" + +data-view-byte-offset@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/data-view-byte-offset/-/data-view-byte-offset-1.0.0.tgz#5e0bbfb4828ed2d1b9b400cd8a7d119bca0ff18a" + integrity sha512-t/Ygsytq+R995EJ5PZlD4Cu56sWa8InXySaViRzw9apusqsOO2bQP+SbYzAhR0pFKoB+43lYy8rWban9JSuXnA== + dependencies: + call-bind "^1.0.6" + es-errors "^1.3.0" + is-data-view "^1.0.1" + +death@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/death/-/death-1.1.0.tgz#01aa9c401edd92750514470b8266390c66c67318" + integrity sha512-vsV6S4KVHvTGxbEcij7hkWRv0It+sGGWVOM67dQde/o5Xjnr+KmLjxWJii2uEObIrt1CcM9w0Yaovx+iOlIL+w== + +debug@4, debug@4.3.4, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4: + version "4.3.4" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" + integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== + dependencies: + ms "2.1.2" + +debug@4.3.3: + version "4.3.3" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.3.tgz#04266e0b70a98d4462e6e288e38259213332b664" + integrity sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q== + dependencies: + ms "2.1.2" + +debug@^3.2.6: + version "3.2.7" + resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a" + integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ== + dependencies: + ms "^2.1.1" + +decamelize@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-4.0.0.tgz#aa472d7bf660eb15f3494efd531cab7f2a709837" + integrity sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ== + +deep-eql@^4.0.1, deep-eql@^4.1.3: + version "4.1.3" + resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-4.1.3.tgz#7c7775513092f7df98d8df9996dd085eb668cc6d" + integrity sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw== + dependencies: + type-detect "^4.0.0" + +deep-extend@~0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac" + integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA== + +deep-is@^0.1.3, deep-is@~0.1.3: + version "0.1.4" + resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" + integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== + +define-data-property@^1.0.1, define-data-property@^1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.4.tgz#894dc141bb7d3060ae4366f6a0107e68fbe48c5e" + integrity sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A== + dependencies: + es-define-property "^1.0.0" + es-errors "^1.3.0" + gopd "^1.0.1" + +define-properties@^1.2.0, define-properties@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.2.1.tgz#10781cc616eb951a80a034bafcaa7377f6af2b6c" + integrity sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg== + dependencies: + define-data-property "^1.0.1" + has-property-descriptors "^1.0.0" + object-keys "^1.1.1" + +delayed-stream@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" + integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== + +depd@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" + integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== + +diff@5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/diff/-/diff-5.0.0.tgz#7ed6ad76d859d030787ec35855f5b1daf31d852b" + integrity sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w== + +diff@^4.0.1: + version "4.0.2" + resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" + integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A== + +diff@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/diff/-/diff-5.2.0.tgz#26ded047cd1179b78b9537d5ef725503ce1ae531" + integrity sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A== + +difflib@^0.2.4: + version "0.2.4" + resolved "https://registry.yarnpkg.com/difflib/-/difflib-0.2.4.tgz#b5e30361a6db023176d562892db85940a718f47e" + integrity sha512-9YVwmMb0wQHQNr5J9m6BSj6fk4pfGITGQOOs+D9Fl+INODWFOfvhIU1hNv6GgR1RBoC/9NJcwu77zShxV0kT7w== + dependencies: + heap ">= 0.2.0" + +dir-glob@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f" + integrity sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA== + dependencies: + path-type "^4.0.0" + +docker-modem@^1.0.8: + version "1.0.9" + resolved "https://registry.yarnpkg.com/docker-modem/-/docker-modem-1.0.9.tgz#a1f13e50e6afb6cf3431b2d5e7aac589db6aaba8" + integrity sha512-lVjqCSCIAUDZPAZIeyM125HXfNvOmYYInciphNrLrylUtKyW66meAjSPXWchKVzoIYZx69TPnAepVSSkeawoIw== + dependencies: + JSONStream "1.3.2" + debug "^3.2.6" + readable-stream "~1.0.26-4" + split-ca "^1.0.0" + +docker-modem@^3.0.0: + version "3.0.8" + resolved "https://registry.yarnpkg.com/docker-modem/-/docker-modem-3.0.8.tgz#ef62c8bdff6e8a7d12f0160988c295ea8705e77a" + integrity sha512-f0ReSURdM3pcKPNS30mxOHSbaFLcknGmQjwSfmbcdOw1XWKXVhukM3NJHhr7NpY9BIyyWQb0EBo3KQvvuU5egQ== + dependencies: + debug "^4.1.1" + readable-stream "^3.5.0" + split-ca "^1.0.1" + ssh2 "^1.11.0" + +docker-modem@^5.0.3: + version "5.0.3" + resolved "https://registry.yarnpkg.com/docker-modem/-/docker-modem-5.0.3.tgz#50c06f11285289f58112b5c4c4d89824541c41d0" + integrity sha512-89zhop5YVhcPEt5FpUFGr3cDyceGhq/F9J+ZndQ4KfqNvfbJpPMfgeixFgUj5OjCYAboElqODxY5Z1EBsSa6sg== + dependencies: + debug "^4.1.1" + readable-stream "^3.5.0" + split-ca "^1.0.1" + ssh2 "^1.15.0" + +dockerode@^2.5.8: + version "2.5.8" + resolved "https://registry.yarnpkg.com/dockerode/-/dockerode-2.5.8.tgz#1b661e36e1e4f860e25f56e0deabe9f87f1d0acc" + integrity sha512-+7iOUYBeDTScmOmQqpUYQaE7F4vvIt6+gIZNHWhqAQEI887tiPFB9OvXI/HzQYqfUNvukMK+9myLW63oTJPZpw== + dependencies: + concat-stream "~1.6.2" + docker-modem "^1.0.8" + tar-fs "~1.16.3" + +dockerode@^3.3.4: + version "3.3.5" + resolved "https://registry.yarnpkg.com/dockerode/-/dockerode-3.3.5.tgz#7ae3f40f2bec53ae5e9a741ce655fff459745629" + integrity sha512-/0YNa3ZDNeLr/tSckmD69+Gq+qVNhvKfAHNeZJBnp7EOP6RGKV8ORrJHkUn20So5wU+xxT7+1n5u8PjHbfjbSA== + dependencies: + "@balena/dockerignore" "^1.0.2" + docker-modem "^3.0.0" + tar-fs "~2.0.1" + +dockerode@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/dockerode/-/dockerode-4.0.2.tgz#dedc8529a1db3ac46d186f5912389899bc309f7d" + integrity sha512-9wM1BVpVMFr2Pw3eJNXrYYt6DT9k0xMcsSCjtPvyQ+xa1iPg/Mo3T/gUcwI0B2cczqCeCYRPF8yFYDwtFXT0+w== + dependencies: + "@balena/dockerignore" "^1.0.2" + docker-modem "^5.0.3" + tar-fs "~2.0.1" + +doctrine@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-3.0.0.tgz#addebead72a6574db783639dc87a121773973961" + integrity sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w== + dependencies: + esutils "^2.0.2" + +dotenv@^16.3.1: + version "16.4.1" + resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.4.1.tgz#1d9931f1d3e5d2959350d1250efab299561f7f11" + integrity sha512-CjA3y+Dr3FyFDOAMnxZEGtnW9KBR2M0JvvUtXNW+dYJL5ROWxP9DUHCwgFqpMk0OXCc0ljhaNTr2w/kutYIcHQ== + +eastasianwidth@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb" + integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== + +elliptic@6.5.4: + version "6.5.4" + resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.5.4.tgz#da37cebd31e79a1367e941b592ed1fbebd58abbb" + integrity sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ== + dependencies: + bn.js "^4.11.9" + brorand "^1.1.0" + hash.js "^1.0.0" + hmac-drbg "^1.0.1" + inherits "^2.0.4" + minimalistic-assert "^1.0.1" + minimalistic-crypto-utils "^1.0.1" + +elliptic@^6.5.2, elliptic@^6.5.4: + version "6.5.5" + resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.5.5.tgz#c715e09f78b6923977610d4c2346d6ce22e6dded" + integrity sha512-7EjbcmUm17NQFu4Pmgmq2olYMj8nwMnpcddByChSUjArp8F5DQWcIcpriwO4ZToLNAJig0yiyjswfyGNje/ixw== + dependencies: + bn.js "^4.11.9" + brorand "^1.1.0" + hash.js "^1.0.0" + hmac-drbg "^1.0.1" + inherits "^2.0.4" + minimalistic-assert "^1.0.1" + minimalistic-crypto-utils "^1.0.1" + +emoji-regex@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" + integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== + +emoji-regex@^9.2.2: + version "9.2.2" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72" + integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== + +end-of-stream@^1.0.0, end-of-stream@^1.1.0, end-of-stream@^1.4.1: + version "1.4.4" + resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" + integrity sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q== + dependencies: + once "^1.4.0" + +enquirer@^2.3.0: + version "2.4.1" + resolved "https://registry.yarnpkg.com/enquirer/-/enquirer-2.4.1.tgz#93334b3fbd74fc7097b224ab4a8fb7e40bf4ae56" + integrity sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ== + dependencies: + ansi-colors "^4.1.1" + strip-ansi "^6.0.1" + +env-paths@^2.2.0: + version "2.2.1" + resolved "https://registry.yarnpkg.com/env-paths/-/env-paths-2.2.1.tgz#420399d416ce1fbe9bc0a07c62fa68d67fd0f8f2" + integrity sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A== + +erc721a-upgradeable@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/erc721a-upgradeable/-/erc721a-upgradeable-3.3.0.tgz#c7b481668694756120868261fe98ab3a245a06b4" + integrity sha512-ILE0SjKuvhx+PABG0A/41QUp0MFiYmzrgo71htQ0Ov6JfDOmgUzGxDW8gZuYfKrdlYjNwSAqMpUFWBbyW3sWBA== + dependencies: + "@openzeppelin/contracts-upgradeable" "^4.4.2" + +erc721a@3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/erc721a/-/erc721a-3.3.0.tgz#ff0fa7880759766ae44916fb7f53eb178e14b044" + integrity sha512-LqwmMcDPS3H9y7ZO+9B7R9sEoWApra17d4PwodXuP1072jP653jdo0TYkJbK4G5pBUFDdB5TCZwmJ6EQbmrysQ== + dependencies: + "@openzeppelin/contracts" "^4.4.2" + +error-ex@^1.3.1: + version "1.3.2" + resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf" + integrity sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g== + dependencies: + is-arrayish "^0.2.1" + +es-abstract@^1.22.1, es-abstract@^1.22.3, es-abstract@^1.23.0, es-abstract@^1.23.2: + version "1.23.3" + resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.23.3.tgz#8f0c5a35cd215312573c5a27c87dfd6c881a0aa0" + integrity sha512-e+HfNH61Bj1X9/jLc5v1owaLYuHdeHHSQlkhCBiTK8rBvKaULl/beGMxwrMXjpYrv4pz22BlY570vVePA2ho4A== + dependencies: + array-buffer-byte-length "^1.0.1" + arraybuffer.prototype.slice "^1.0.3" + available-typed-arrays "^1.0.7" + call-bind "^1.0.7" + data-view-buffer "^1.0.1" + data-view-byte-length "^1.0.1" + data-view-byte-offset "^1.0.0" + es-define-property "^1.0.0" + es-errors "^1.3.0" + es-object-atoms "^1.0.0" + es-set-tostringtag "^2.0.3" + es-to-primitive "^1.2.1" + function.prototype.name "^1.1.6" + get-intrinsic "^1.2.4" + get-symbol-description "^1.0.2" + globalthis "^1.0.3" + gopd "^1.0.1" + has-property-descriptors "^1.0.2" + has-proto "^1.0.3" + has-symbols "^1.0.3" + hasown "^2.0.2" + internal-slot "^1.0.7" + is-array-buffer "^3.0.4" + is-callable "^1.2.7" + is-data-view "^1.0.1" + is-negative-zero "^2.0.3" + is-regex "^1.1.4" + is-shared-array-buffer "^1.0.3" + is-string "^1.0.7" + is-typed-array "^1.1.13" + is-weakref "^1.0.2" + object-inspect "^1.13.1" + object-keys "^1.1.1" + object.assign "^4.1.5" + regexp.prototype.flags "^1.5.2" + safe-array-concat "^1.1.2" + safe-regex-test "^1.0.3" + string.prototype.trim "^1.2.9" + string.prototype.trimend "^1.0.8" + string.prototype.trimstart "^1.0.8" + typed-array-buffer "^1.0.2" + typed-array-byte-length "^1.0.1" + typed-array-byte-offset "^1.0.2" + typed-array-length "^1.0.6" + unbox-primitive "^1.0.2" + which-typed-array "^1.1.15" + +es-define-property@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.0.tgz#c7faefbdff8b2696cf5f46921edfb77cc4ba3845" + integrity sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ== + dependencies: + get-intrinsic "^1.2.4" + +es-errors@^1.2.1, es-errors@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" + integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== + +es-object-atoms@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.0.0.tgz#ddb55cd47ac2e240701260bc2a8e31ecb643d941" + integrity sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw== + dependencies: + es-errors "^1.3.0" + +es-set-tostringtag@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/es-set-tostringtag/-/es-set-tostringtag-2.0.3.tgz#8bb60f0a440c2e4281962428438d58545af39777" + integrity sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ== + dependencies: + get-intrinsic "^1.2.4" + has-tostringtag "^1.0.2" + hasown "^2.0.1" + +es-shim-unscopables@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/es-shim-unscopables/-/es-shim-unscopables-1.0.2.tgz#1f6942e71ecc7835ed1c8a83006d8771a63a3763" + integrity sha512-J3yBRXCzDu4ULnQwxyToo/OjdMx6akgVC7K6few0a7F/0wLtmKKN7I73AH5T2836UuXRqN7Qg+IIUw/+YJksRw== + dependencies: + hasown "^2.0.0" + +es-to-primitive@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.2.1.tgz#e55cd4c9cdc188bcefb03b366c736323fc5c898a" + integrity sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA== + dependencies: + is-callable "^1.1.4" + is-date-object "^1.0.1" + is-symbol "^1.0.2" + +esbuild-android-64@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-android-64/-/esbuild-android-64-0.14.54.tgz#505f41832884313bbaffb27704b8bcaa2d8616be" + integrity sha512-Tz2++Aqqz0rJ7kYBfz+iqyE3QMycD4vk7LBRyWaAVFgFtQ/O8EJOnVmTOiDWYZ/uYzB4kvP+bqejYdVKzE5lAQ== + +esbuild-android-arm64@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-android-arm64/-/esbuild-android-arm64-0.14.54.tgz#8ce69d7caba49646e009968fe5754a21a9871771" + integrity sha512-F9E+/QDi9sSkLaClO8SOV6etqPd+5DgJje1F9lOWoNncDdOBL2YF59IhsWATSt0TLZbYCf3pNlTHvVV5VfHdvg== + +esbuild-darwin-64@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-darwin-64/-/esbuild-darwin-64-0.14.54.tgz#24ba67b9a8cb890a3c08d9018f887cc221cdda25" + integrity sha512-jtdKWV3nBviOd5v4hOpkVmpxsBy90CGzebpbO9beiqUYVMBtSc0AL9zGftFuBon7PNDcdvNCEuQqw2x0wP9yug== + +esbuild-darwin-arm64@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.14.54.tgz#3f7cdb78888ee05e488d250a2bdaab1fa671bf73" + integrity sha512-OPafJHD2oUPyvJMrsCvDGkRrVCar5aVyHfWGQzY1dWnzErjrDuSETxwA2HSsyg2jORLY8yBfzc1MIpUkXlctmw== + +esbuild-freebsd-64@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-freebsd-64/-/esbuild-freebsd-64-0.14.54.tgz#09250f997a56ed4650f3e1979c905ffc40bbe94d" + integrity sha512-OKwd4gmwHqOTp4mOGZKe/XUlbDJ4Q9TjX0hMPIDBUWWu/kwhBAudJdBoxnjNf9ocIB6GN6CPowYpR/hRCbSYAg== + +esbuild-freebsd-arm64@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.14.54.tgz#bafb46ed04fc5f97cbdb016d86947a79579f8e48" + integrity sha512-sFwueGr7OvIFiQT6WeG0jRLjkjdqWWSrfbVwZp8iMP+8UHEHRBvlaxL6IuKNDwAozNUmbb8nIMXa7oAOARGs1Q== + +esbuild-linux-32@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-linux-32/-/esbuild-linux-32-0.14.54.tgz#e2a8c4a8efdc355405325033fcebeb941f781fe5" + integrity sha512-1ZuY+JDI//WmklKlBgJnglpUL1owm2OX+8E1syCD6UAxcMM/XoWd76OHSjl/0MR0LisSAXDqgjT3uJqT67O3qw== + +esbuild-linux-64@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-linux-64/-/esbuild-linux-64-0.14.54.tgz#de5fdba1c95666cf72369f52b40b03be71226652" + integrity sha512-EgjAgH5HwTbtNsTqQOXWApBaPVdDn7XcK+/PtJwZLT1UmpLoznPd8c5CxqsH2dQK3j05YsB3L17T8vE7cp4cCg== + +esbuild-linux-arm64@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-linux-arm64/-/esbuild-linux-arm64-0.14.54.tgz#dae4cd42ae9787468b6a5c158da4c84e83b0ce8b" + integrity sha512-WL71L+0Rwv+Gv/HTmxTEmpv0UgmxYa5ftZILVi2QmZBgX3q7+tDeOQNqGtdXSdsL8TQi1vIaVFHUPDe0O0kdig== + +esbuild-linux-arm@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-linux-arm/-/esbuild-linux-arm-0.14.54.tgz#a2c1dff6d0f21dbe8fc6998a122675533ddfcd59" + integrity sha512-qqz/SjemQhVMTnvcLGoLOdFpCYbz4v4fUo+TfsWG+1aOu70/80RV6bgNpR2JCrppV2moUQkww+6bWxXRL9YMGw== + +esbuild-linux-mips64le@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.14.54.tgz#d9918e9e4cb972f8d6dae8e8655bf9ee131eda34" + integrity sha512-qTHGQB8D1etd0u1+sB6p0ikLKRVuCWhYQhAHRPkO+OF3I/iSlTKNNS0Lh2Oc0g0UFGguaFZZiPJdJey3AGpAlw== + +esbuild-linux-ppc64le@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.14.54.tgz#3f9a0f6d41073fb1a640680845c7de52995f137e" + integrity sha512-j3OMlzHiqwZBDPRCDFKcx595XVfOfOnv68Ax3U4UKZ3MTYQB5Yz3X1mn5GnodEVYzhtZgxEBidLWeIs8FDSfrQ== + +esbuild-linux-riscv64@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.14.54.tgz#618853c028178a61837bc799d2013d4695e451c8" + integrity sha512-y7Vt7Wl9dkOGZjxQZnDAqqn+XOqFD7IMWiewY5SPlNlzMX39ocPQlOaoxvT4FllA5viyV26/QzHtvTjVNOxHZg== + +esbuild-linux-s390x@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-linux-s390x/-/esbuild-linux-s390x-0.14.54.tgz#d1885c4c5a76bbb5a0fe182e2c8c60eb9e29f2a6" + integrity sha512-zaHpW9dziAsi7lRcyV4r8dhfG1qBidQWUXweUjnw+lliChJqQr+6XD71K41oEIC3Mx1KStovEmlzm+MkGZHnHA== + +esbuild-netbsd-64@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-netbsd-64/-/esbuild-netbsd-64-0.14.54.tgz#69ae917a2ff241b7df1dbf22baf04bd330349e81" + integrity sha512-PR01lmIMnfJTgeU9VJTDY9ZerDWVFIUzAtJuDHwwceppW7cQWjBBqP48NdeRtoP04/AtO9a7w3viI+PIDr6d+w== + +esbuild-openbsd-64@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-openbsd-64/-/esbuild-openbsd-64-0.14.54.tgz#db4c8495287a350a6790de22edea247a57c5d47b" + integrity sha512-Qyk7ikT2o7Wu76UsvvDS5q0amJvmRzDyVlL0qf5VLsLchjCa1+IAvd8kTBgUxD7VBUUVgItLkk609ZHUc1oCaw== + +esbuild-sunos-64@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-sunos-64/-/esbuild-sunos-64-0.14.54.tgz#54287ee3da73d3844b721c21bc80c1dc7e1bf7da" + integrity sha512-28GZ24KmMSeKi5ueWzMcco6EBHStL3B6ubM7M51RmPwXQGLe0teBGJocmWhgwccA1GeFXqxzILIxXpHbl9Q/Kw== + +esbuild-windows-32@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-windows-32/-/esbuild-windows-32-0.14.54.tgz#f8aaf9a5667630b40f0fb3aa37bf01bbd340ce31" + integrity sha512-T+rdZW19ql9MjS7pixmZYVObd9G7kcaZo+sETqNH4RCkuuYSuv9AGHUVnPoP9hhuE1WM1ZimHz1CIBHBboLU7w== + +esbuild-windows-64@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-windows-64/-/esbuild-windows-64-0.14.54.tgz#bf54b51bd3e9b0f1886ffdb224a4176031ea0af4" + integrity sha512-AoHTRBUuYwXtZhjXZbA1pGfTo8cJo3vZIcWGLiUcTNgHpJJMC1rVA44ZereBHMJtotyN71S8Qw0npiCIkW96cQ== + +esbuild-windows-arm64@0.14.54: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild-windows-arm64/-/esbuild-windows-arm64-0.14.54.tgz#937d15675a15e4b0e4fafdbaa3a01a776a2be982" + integrity sha512-M0kuUvXhot1zOISQGXwWn6YtS+Y/1RT9WrVIOywZnJHo3jCDyewAc79aKNQWFCQm+xNHVTq9h8dZKvygoXQQRg== + +esbuild@^0.14.25: + version "0.14.54" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.14.54.tgz#8b44dcf2b0f1a66fc22459943dccf477535e9aa2" + integrity sha512-Cy9llcy8DvET5uznocPyqL3BFRrFXSVqbgpMJ9Wz8oVjZlh/zUSNbPRbov0VX7VxN2JH1Oa0uNxZ7eLRb62pJA== + optionalDependencies: + "@esbuild/linux-loong64" "0.14.54" + esbuild-android-64 "0.14.54" + esbuild-android-arm64 "0.14.54" + esbuild-darwin-64 "0.14.54" + esbuild-darwin-arm64 "0.14.54" + esbuild-freebsd-64 "0.14.54" + esbuild-freebsd-arm64 "0.14.54" + esbuild-linux-32 "0.14.54" + esbuild-linux-64 "0.14.54" + esbuild-linux-arm "0.14.54" + esbuild-linux-arm64 "0.14.54" + esbuild-linux-mips64le "0.14.54" + esbuild-linux-ppc64le "0.14.54" + esbuild-linux-riscv64 "0.14.54" + esbuild-linux-s390x "0.14.54" + esbuild-netbsd-64 "0.14.54" + esbuild-openbsd-64 "0.14.54" + esbuild-sunos-64 "0.14.54" + esbuild-windows-32 "0.14.54" + esbuild-windows-64 "0.14.54" + esbuild-windows-arm64 "0.14.54" + +escalade@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" + integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw== + +escape-string-regexp@4.0.0, escape-string-regexp@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" + integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== + +escape-string-regexp@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" + integrity sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg== + +escodegen@1.8.x: + version "1.8.1" + resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-1.8.1.tgz#5a5b53af4693110bebb0867aa3430dd3b70a1018" + integrity sha512-yhi5S+mNTOuRvyW4gWlg5W1byMaQGWWSYHXsuFZ7GBo7tpyOwi2EdzMP/QWxh9hwkD2m+wDVHJsxhRIj+v/b/A== + dependencies: + esprima "^2.7.1" + estraverse "^1.9.1" + esutils "^2.0.2" + optionator "^0.8.1" + optionalDependencies: + source-map "~0.2.0" + +eslint-config-prettier@^8.10.0: + version "8.10.0" + resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-8.10.0.tgz#3a06a662130807e2502fc3ff8b4143d8a0658e11" + integrity sha512-SM8AMJdeQqRYT9O9zguiruQZaN7+z+E4eAP9oiLNGKMtomwaB1E9dcgUD6ZAn/eQAb52USbvezbiljfZUhbJcg== + +eslint-scope@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-5.1.1.tgz#e786e59a66cb92b3f6c1fb0d508aab174848f48c" + integrity sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw== + dependencies: + esrecurse "^4.3.0" + estraverse "^4.1.1" + +eslint-scope@^7.2.2: + version "7.2.2" + resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-7.2.2.tgz#deb4f92563390f32006894af62a22dba1c46423f" + integrity sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg== + dependencies: + esrecurse "^4.3.0" + estraverse "^5.2.0" + +eslint-visitor-keys@^3.3.0, eslint-visitor-keys@^3.4.1, eslint-visitor-keys@^3.4.3: + version "3.4.3" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz#0cd72fe8550e3c2eae156a96a4dddcd1c8ac5800" + integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== + +eslint@^8.54.0: + version "8.56.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.56.0.tgz#4957ce8da409dc0809f99ab07a1b94832ab74b15" + integrity sha512-Go19xM6T9puCOWntie1/P997aXxFsOi37JIHRWI514Hc6ZnaHGKY9xFhrU65RT6CcBEzZoGG1e6Nq+DT04ZtZQ== + dependencies: + "@eslint-community/eslint-utils" "^4.2.0" + "@eslint-community/regexpp" "^4.6.1" + "@eslint/eslintrc" "^2.1.4" + "@eslint/js" "8.56.0" + "@humanwhocodes/config-array" "^0.11.13" + "@humanwhocodes/module-importer" "^1.0.1" + "@nodelib/fs.walk" "^1.2.8" + "@ungap/structured-clone" "^1.2.0" + ajv "^6.12.4" + chalk "^4.0.0" + cross-spawn "^7.0.2" + debug "^4.3.2" + doctrine "^3.0.0" + escape-string-regexp "^4.0.0" + eslint-scope "^7.2.2" + eslint-visitor-keys "^3.4.3" + espree "^9.6.1" + esquery "^1.4.2" + esutils "^2.0.2" + fast-deep-equal "^3.1.3" + file-entry-cache "^6.0.1" + find-up "^5.0.0" + glob-parent "^6.0.2" + globals "^13.19.0" + graphemer "^1.4.0" + ignore "^5.2.0" + imurmurhash "^0.1.4" + is-glob "^4.0.0" + is-path-inside "^3.0.3" + js-yaml "^4.1.0" + json-stable-stringify-without-jsonify "^1.0.1" + levn "^0.4.1" + lodash.merge "^4.6.2" + minimatch "^3.1.2" + natural-compare "^1.4.0" + optionator "^0.9.3" + strip-ansi "^6.0.1" + text-table "^0.2.0" + +espree@^9.6.0, espree@^9.6.1: + version "9.6.1" + resolved "https://registry.yarnpkg.com/espree/-/espree-9.6.1.tgz#a2a17b8e434690a5432f2f8018ce71d331a48c6f" + integrity sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ== + dependencies: + acorn "^8.9.0" + acorn-jsx "^5.3.2" + eslint-visitor-keys "^3.4.1" + +esprima@2.7.x, esprima@^2.7.1: + version "2.7.3" + resolved "https://registry.yarnpkg.com/esprima/-/esprima-2.7.3.tgz#96e3b70d5779f6ad49cd032673d1c312767ba581" + integrity sha512-OarPfz0lFCiW4/AV2Oy1Rp9qu0iusTKqykwTspGCZtPxmF81JR4MmIebvF1F9+UOKth2ZubLQ4XGGaU+hSn99A== + +esprima@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" + integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== + +esquery@^1.4.2: + version "1.5.0" + resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.5.0.tgz#6ce17738de8577694edd7361c57182ac8cb0db0b" + integrity sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg== + dependencies: + estraverse "^5.1.0" + +esrecurse@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921" + integrity sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag== + dependencies: + estraverse "^5.2.0" + +estraverse@^1.9.1: + version "1.9.3" + resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-1.9.3.tgz#af67f2dc922582415950926091a4005d29c9bb44" + integrity sha512-25w1fMXQrGdoquWnScXZGckOv+Wes+JDnuN/+7ex3SauFRS72r2lFDec0EKPt2YD1wUJ/IrfEex+9yp4hfSOJA== + +estraverse@^4.1.1: + version "4.3.0" + resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.3.0.tgz#398ad3f3c5a24948be7725e83d11a7de28cdbd1d" + integrity sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw== + +estraverse@^5.1.0, estraverse@^5.2.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123" + integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== + +esutils@^2.0.2: + version "2.0.3" + resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" + integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== + +eth-gas-reporter@^0.2.25: + version "0.2.27" + resolved "https://registry.yarnpkg.com/eth-gas-reporter/-/eth-gas-reporter-0.2.27.tgz#928de8548a674ed64c7ba0bf5795e63079150d4e" + integrity sha512-femhvoAM7wL0GcI8ozTdxfuBtBFJ9qsyIAsmKVjlWAHUbdnnXHt+lKzz/kmldM5lA9jLuNHGwuIxorNpLbR1Zw== + dependencies: + "@solidity-parser/parser" "^0.14.0" + axios "^1.5.1" + cli-table3 "^0.5.0" + colors "1.4.0" + ethereum-cryptography "^1.0.3" + ethers "^5.7.2" + fs-readdir-recursive "^1.1.0" + lodash "^4.17.14" + markdown-table "^1.1.3" + mocha "^10.2.0" + req-cwd "^2.0.0" + sha1 "^1.1.1" + sync-request "^6.0.0" + +ethereum-bloom-filters@^1.0.6: + version "1.1.0" + resolved "https://registry.yarnpkg.com/ethereum-bloom-filters/-/ethereum-bloom-filters-1.1.0.tgz#b3fc1eb789509ee30db0bf99a2988ccacb8d0397" + integrity sha512-J1gDRkLpuGNvWYzWslBQR9cDV4nd4kfvVTE/Wy4Kkm4yb3EYRSlyi0eB/inTsSTTVyA0+HyzHgbr95Fn/Z1fSw== + dependencies: + "@noble/hashes" "^1.4.0" + +ethereum-cryptography@0.1.3, ethereum-cryptography@^0.1.3: + version "0.1.3" + resolved "https://registry.yarnpkg.com/ethereum-cryptography/-/ethereum-cryptography-0.1.3.tgz#8d6143cfc3d74bf79bbd8edecdf29e4ae20dd191" + integrity sha512-w8/4x1SGGzc+tO97TASLja6SLd3fRIK2tLVcV2Gx4IB21hE19atll5Cq9o3d0ZmAYC/8aw0ipieTSiekAea4SQ== + dependencies: + "@types/pbkdf2" "^3.0.0" + "@types/secp256k1" "^4.0.1" + blakejs "^1.1.0" + browserify-aes "^1.2.0" + bs58check "^2.1.2" + create-hash "^1.2.0" + create-hmac "^1.1.7" + hash.js "^1.1.7" + keccak "^3.0.0" + pbkdf2 "^3.0.17" + randombytes "^2.1.0" + safe-buffer "^5.1.2" + scrypt-js "^3.0.0" + secp256k1 "^4.0.1" + setimmediate "^1.0.5" + +ethereum-cryptography@^1.0.3: + version "1.2.0" + resolved "https://registry.yarnpkg.com/ethereum-cryptography/-/ethereum-cryptography-1.2.0.tgz#5ccfa183e85fdaf9f9b299a79430c044268c9b3a" + integrity sha512-6yFQC9b5ug6/17CQpCyE3k9eKBMdhyVjzUy1WkiuY/E4vj/SXDBbCw8QEIaXqf0Mf2SnY6RmpDcwlUmBSS0EJw== + dependencies: + "@noble/hashes" "1.2.0" + "@noble/secp256k1" "1.7.1" + "@scure/bip32" "1.1.5" + "@scure/bip39" "1.1.1" + +ethereum-cryptography@^2.0.0, ethereum-cryptography@^2.1.2: + version "2.1.3" + resolved "https://registry.yarnpkg.com/ethereum-cryptography/-/ethereum-cryptography-2.1.3.tgz#1352270ed3b339fe25af5ceeadcf1b9c8e30768a" + integrity sha512-BlwbIL7/P45W8FGW2r7LGuvoEZ+7PWsniMvQ4p5s2xCyw9tmaDlpfsN9HjAucbF+t/qpVHwZUisgfK24TCW8aA== + dependencies: + "@noble/curves" "1.3.0" + "@noble/hashes" "1.3.3" + "@scure/bip32" "1.3.3" + "@scure/bip39" "1.2.2" + +ethereumjs-abi@^0.6.8: + version "0.6.8" + resolved "https://registry.yarnpkg.com/ethereumjs-abi/-/ethereumjs-abi-0.6.8.tgz#71bc152db099f70e62f108b7cdfca1b362c6fcae" + integrity sha512-Tx0r/iXI6r+lRsdvkFDlut0N08jWMnKRZ6Gkq+Nmw75lZe4e6o3EkSnkaBP5NF6+m5PTGAr9JP43N3LyeoglsA== + dependencies: + bn.js "^4.11.8" + ethereumjs-util "^6.0.0" + +ethereumjs-util@^6.0.0, ethereumjs-util@^6.2.1: + version "6.2.1" + resolved "https://registry.yarnpkg.com/ethereumjs-util/-/ethereumjs-util-6.2.1.tgz#fcb4e4dd5ceacb9d2305426ab1a5cd93e3163b69" + integrity sha512-W2Ktez4L01Vexijrm5EB6w7dg4n/TgpoYU4avuT5T3Vmnw/eCRtiBrJfQYS/DCSvDIOLn2k57GcHdeBcgVxAqw== + dependencies: + "@types/bn.js" "^4.11.3" + bn.js "^4.11.0" + create-hash "^1.1.2" + elliptic "^6.5.2" + ethereum-cryptography "^0.1.3" + ethjs-util "0.1.6" + rlp "^2.2.3" + +ethereumjs-util@^7.0.3, ethereumjs-util@^7.1.4, ethereumjs-util@^7.1.5: + version "7.1.5" + resolved "https://registry.yarnpkg.com/ethereumjs-util/-/ethereumjs-util-7.1.5.tgz#9ecf04861e4fbbeed7465ece5f23317ad1129181" + integrity sha512-SDl5kKrQAudFBUe5OJM9Ac6WmMyYmXX/6sTmLZ3ffG2eY6ZIGBes3pEDxNN6V72WyOw4CPD5RomKdsa8DAAwLg== + dependencies: + "@types/bn.js" "^5.1.0" + bn.js "^5.1.2" + create-hash "^1.1.2" + ethereum-cryptography "^0.1.3" + rlp "^2.2.4" + +ethers@^5.7.2, ethers@~5.7.0, ethers@~5.7.2: + version "5.7.2" + resolved "https://registry.yarnpkg.com/ethers/-/ethers-5.7.2.tgz#3a7deeabbb8c030d4126b24f84e525466145872e" + integrity sha512-wswUsmWo1aOK8rR7DIKiWSw9DbLWe6x98Jrn8wcTflTVvaXhAMaB5zGAXy0GYQEQp9iO1iSHWVyARQm11zUtyg== + dependencies: + "@ethersproject/abi" "5.7.0" + "@ethersproject/abstract-provider" "5.7.0" + "@ethersproject/abstract-signer" "5.7.0" + "@ethersproject/address" "5.7.0" + "@ethersproject/base64" "5.7.0" + "@ethersproject/basex" "5.7.0" + "@ethersproject/bignumber" "5.7.0" + "@ethersproject/bytes" "5.7.0" + "@ethersproject/constants" "5.7.0" + "@ethersproject/contracts" "5.7.0" + "@ethersproject/hash" "5.7.0" + "@ethersproject/hdnode" "5.7.0" + "@ethersproject/json-wallets" "5.7.0" + "@ethersproject/keccak256" "5.7.0" + "@ethersproject/logger" "5.7.0" + "@ethersproject/networks" "5.7.1" + "@ethersproject/pbkdf2" "5.7.0" + "@ethersproject/properties" "5.7.0" + "@ethersproject/providers" "5.7.2" + "@ethersproject/random" "5.7.0" + "@ethersproject/rlp" "5.7.0" + "@ethersproject/sha2" "5.7.0" + "@ethersproject/signing-key" "5.7.0" + "@ethersproject/solidity" "5.7.0" + "@ethersproject/strings" "5.7.0" + "@ethersproject/transactions" "5.7.0" + "@ethersproject/units" "5.7.0" + "@ethersproject/wallet" "5.7.0" + "@ethersproject/web" "5.7.1" + "@ethersproject/wordlists" "5.7.0" + +ethjs-unit@0.1.6: + version "0.1.6" + resolved "https://registry.yarnpkg.com/ethjs-unit/-/ethjs-unit-0.1.6.tgz#c665921e476e87bce2a9d588a6fe0405b2c41699" + integrity sha512-/Sn9Y0oKl0uqQuvgFk/zQgR7aw1g36qX/jzSQ5lSwlO0GigPymk4eGQfeNTD03w1dPOqfz8V77Cy43jH56pagw== + dependencies: + bn.js "4.11.6" + number-to-bn "1.7.0" + +ethjs-util@0.1.6, ethjs-util@^0.1.6: + version "0.1.6" + resolved "https://registry.yarnpkg.com/ethjs-util/-/ethjs-util-0.1.6.tgz#f308b62f185f9fe6237132fb2a9818866a5cd536" + integrity sha512-CUnVOQq7gSpDHZVVrQW8ExxUETWrnrvXYvYz55wOU8Uj4VCgw56XC2B/fVqQN+f7gmrnRHSLVnFAwsCuNwji8w== + dependencies: + is-hex-prefixed "1.0.0" + strip-hex-prefix "1.0.0" + +evp_bytestokey@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz#7fcbdb198dc71959432efe13842684e0525acb02" + integrity sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA== + dependencies: + md5.js "^1.3.4" + safe-buffer "^5.1.1" + +execa@^5.0.0: + version "5.1.1" + resolved "https://registry.yarnpkg.com/execa/-/execa-5.1.1.tgz#f80ad9cbf4298f7bd1d4c9555c21e93741c411dd" + integrity sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg== + dependencies: + cross-spawn "^7.0.3" + get-stream "^6.0.0" + human-signals "^2.1.0" + is-stream "^2.0.0" + merge-stream "^2.0.0" + npm-run-path "^4.0.1" + onetime "^5.1.2" + signal-exit "^3.0.3" + strip-final-newline "^2.0.0" + +fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" + integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== + +fast-diff@^1.1.2, fast-diff@^1.2.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/fast-diff/-/fast-diff-1.3.0.tgz#ece407fa550a64d638536cd727e129c61616e0f0" + integrity sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw== + +fast-glob@^3.0.3, fast-glob@^3.2.9, fast-glob@^3.3.2: + version "3.3.2" + resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.2.tgz#a904501e57cfdd2ffcded45e99a54fef55e46129" + integrity sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow== + dependencies: + "@nodelib/fs.stat" "^2.0.2" + "@nodelib/fs.walk" "^1.2.3" + glob-parent "^5.1.2" + merge2 "^1.3.0" + micromatch "^4.0.4" + +fast-json-stable-stringify@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" + integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== + +fast-levenshtein@^2.0.6, fast-levenshtein@~2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" + integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== + +fastq@^1.6.0: + version "1.16.0" + resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.16.0.tgz#83b9a9375692db77a822df081edb6a9cf6839320" + integrity sha512-ifCoaXsDrsdkWTtiNJX5uzHDsrck5TzfKKDcuFFTIrrc/BS076qgEIfoIy1VeZqViznfKiysPYTh/QeHtnIsYA== + dependencies: + reusify "^1.0.4" + +file-entry-cache@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz#211b2dd9659cb0394b073e7323ac3c933d522027" + integrity sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg== + dependencies: + flat-cache "^3.0.4" + +fill-keys@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/fill-keys/-/fill-keys-1.0.2.tgz#9a8fa36f4e8ad634e3bf6b4f3c8882551452eb20" + integrity sha512-tcgI872xXjwFF4xgQmLxi76GnwJG3g/3isB1l4/G5Z4zrbddGpBjqZCO9oEAcB5wX0Hj/5iQB3toxfO7in1hHA== + dependencies: + is-object "~1.0.1" + merge-descriptors "~1.0.0" + +fill-range@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40" + integrity sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ== + dependencies: + to-regex-range "^5.0.1" + +find-replace@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/find-replace/-/find-replace-3.0.0.tgz#3e7e23d3b05167a76f770c9fbd5258b0def68c38" + integrity sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ== + dependencies: + array-back "^3.0.1" + +find-up@5.0.0, find-up@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" + integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== + dependencies: + locate-path "^6.0.0" + path-exists "^4.0.0" + +find-up@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-2.1.0.tgz#45d1b7e506c717ddd482775a2b77920a3c0c57a7" + integrity sha512-NWzkk0jSJtTt08+FBFMvXoeZnOJD+jTtsRmBYbAIzJdX6l7dLgR7CTubCM5/eDdPUBvLCeVasP1brfVR/9/EZQ== + dependencies: + locate-path "^2.0.0" + +flat-cache@^3.0.4: + version "3.2.0" + resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-3.2.0.tgz#2c0c2d5040c99b1632771a9d105725c0115363ee" + integrity sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw== + dependencies: + flatted "^3.2.9" + keyv "^4.5.3" + rimraf "^3.0.2" + +flat@^5.0.2: + version "5.0.2" + resolved "https://registry.yarnpkg.com/flat/-/flat-5.0.2.tgz#8ca6fe332069ffa9d324c327198c598259ceb241" + integrity sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ== + +flatted@^3.2.9: + version "3.2.9" + resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.9.tgz#7eb4c67ca1ba34232ca9d2d93e9886e611ad7daf" + integrity sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ== + +follow-redirects@^1.12.1, follow-redirects@^1.15.6: + version "1.15.6" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.6.tgz#7f815c0cda4249c74ff09e95ef97c23b5fd0399b" + integrity sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA== + +for-each@^0.3.3: + version "0.3.3" + resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.3.tgz#69b447e88a0a5d32c3e7084f3f1710034b21376e" + integrity sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw== + dependencies: + is-callable "^1.1.3" + +foreground-child@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.1.1.tgz#1d173e776d75d2772fed08efe4a0de1ea1b12d0d" + integrity sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg== + dependencies: + cross-spawn "^7.0.0" + signal-exit "^4.0.1" + +form-data@^2.2.0: + version "2.5.1" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.5.1.tgz#f2cbec57b5e59e23716e128fe44d4e5dd23895f4" + integrity sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.6" + mime-types "^2.1.12" + +form-data@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452" + integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + mime-types "^2.1.12" + +fp-ts@1.19.3: + version "1.19.3" + resolved "https://registry.yarnpkg.com/fp-ts/-/fp-ts-1.19.3.tgz#261a60d1088fbff01f91256f91d21d0caaaaa96f" + integrity sha512-H5KQDspykdHuztLTg+ajGN0Z2qUjcEf3Ybxc6hLt0k7/zPkn29XnKnxlBPyW2XIddWrGaJBzBl4VLYOtk39yZg== + +fp-ts@^1.0.0: + version "1.19.5" + resolved "https://registry.yarnpkg.com/fp-ts/-/fp-ts-1.19.5.tgz#3da865e585dfa1fdfd51785417357ac50afc520a" + integrity sha512-wDNqTimnzs8QqpldiId9OavWK2NptormjXnRJTQecNjzwfyp6P/8s/zG8e4h3ja3oqkKaY72UlTjQYt/1yXf9A== + +fs-constants@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad" + integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow== + +fs-extra@^0.30.0: + version "0.30.0" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-0.30.0.tgz#f233ffcc08d4da7d432daa449776989db1df93f0" + integrity sha512-UvSPKyhMn6LEd/WpUaV9C9t3zATuqoqfWc3QdPhPLb58prN9tqYPlPWi8Krxi44loBoUzlobqZ3+8tGpxxSzwA== + dependencies: + graceful-fs "^4.1.2" + jsonfile "^2.1.0" + klaw "^1.0.0" + path-is-absolute "^1.0.0" + rimraf "^2.2.8" + +fs-extra@^10.1.0: + version "10.1.0" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-10.1.0.tgz#02873cfbc4084dde127eaa5f9905eef2325d1abf" + integrity sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ== + dependencies: + graceful-fs "^4.2.0" + jsonfile "^6.0.1" + universalify "^2.0.0" + +fs-extra@^11.1.1, fs-extra@^11.2.0: + version "11.2.0" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-11.2.0.tgz#e70e17dfad64232287d01929399e0ea7c86b0e5b" + integrity sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw== + dependencies: + graceful-fs "^4.2.0" + jsonfile "^6.0.1" + universalify "^2.0.0" + +fs-extra@^7.0.0, fs-extra@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-7.0.1.tgz#4f189c44aa123b895f722804f55ea23eadc348e9" + integrity sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw== + dependencies: + graceful-fs "^4.1.2" + jsonfile "^4.0.0" + universalify "^0.1.0" + +fs-extra@^8.1.0: + version "8.1.0" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-8.1.0.tgz#49d43c45a88cd9677668cb7be1b46efdb8d2e1c0" + integrity sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g== + dependencies: + graceful-fs "^4.2.0" + jsonfile "^4.0.0" + universalify "^0.1.0" + +fs-extra@^9.1.0: + version "9.1.0" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-9.1.0.tgz#5954460c764a8da2094ba3554bf839e6b9a7c86d" + integrity sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ== + dependencies: + at-least-node "^1.0.0" + graceful-fs "^4.2.0" + jsonfile "^6.0.1" + universalify "^2.0.0" + +fs-readdir-recursive@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/fs-readdir-recursive/-/fs-readdir-recursive-1.1.0.tgz#e32fc030a2ccee44a6b5371308da54be0b397d27" + integrity sha512-GNanXlVr2pf02+sPN40XN8HG+ePaNcvM0q5mZBd668Obwb0yD5GiUbZOFgwn8kGMY6I3mdyDJzieUy3PTYyTRA== + +fs.realpath@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" + integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== + +fsevents@~2.3.2: + version "2.3.3" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + +function-bind@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" + integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== + +function.prototype.name@^1.1.6: + version "1.1.6" + resolved "https://registry.yarnpkg.com/function.prototype.name/-/function.prototype.name-1.1.6.tgz#cdf315b7d90ee77a4c6ee216c3c3362da07533fd" + integrity sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg== + dependencies: + call-bind "^1.0.2" + define-properties "^1.2.0" + es-abstract "^1.22.1" + functions-have-names "^1.2.3" + +functions-have-names@^1.2.3: + version "1.2.3" + resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834" + integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ== + +get-caller-file@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" + integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== + +get-func-name@^2.0.1, get-func-name@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/get-func-name/-/get-func-name-2.0.2.tgz#0d7cf20cd13fda808669ffa88f4ffc7a3943fc41" + integrity sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ== + +get-intrinsic@^1.1.3, get-intrinsic@^1.2.1, get-intrinsic@^1.2.3, get-intrinsic@^1.2.4: + version "1.2.4" + resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.4.tgz#e385f5a4b5227d449c3eabbad05494ef0abbeadd" + integrity sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ== + dependencies: + es-errors "^1.3.0" + function-bind "^1.1.2" + has-proto "^1.0.1" + has-symbols "^1.0.3" + hasown "^2.0.0" + +get-port@^3.1.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/get-port/-/get-port-3.2.0.tgz#dd7ce7de187c06c8bf353796ac71e099f0980ebc" + integrity sha512-x5UJKlgeUiNT8nyo/AcnwLnZuZNcSjSw0kogRB+Whd1fjjFq4B1hySFxSFWWSn4mIBzg3sRNUDFYc4g5gjPoLg== + +get-stream@^6.0.0: + version "6.0.1" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.1.tgz#a262d8eef67aced57c2852ad6167526a43cbf7b7" + integrity sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg== + +get-symbol-description@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/get-symbol-description/-/get-symbol-description-1.0.2.tgz#533744d5aa20aca4e079c8e5daf7fd44202821f5" + integrity sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg== + dependencies: + call-bind "^1.0.5" + es-errors "^1.3.0" + get-intrinsic "^1.2.4" + +ghost-testrpc@^0.0.2: + version "0.0.2" + resolved "https://registry.yarnpkg.com/ghost-testrpc/-/ghost-testrpc-0.0.2.tgz#c4de9557b1d1ae7b2d20bbe474a91378ca90ce92" + integrity sha512-i08dAEgJ2g8z5buJIrCTduwPIhih3DP+hOCTyyryikfV8T0bNvHnGXO67i0DD1H4GBDETTclPy9njZbfluQYrQ== + dependencies: + chalk "^2.4.2" + node-emoji "^1.10.0" + +glob-parent@^5.1.2, glob-parent@~5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" + integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== + dependencies: + is-glob "^4.0.1" + +glob-parent@^6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3" + integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A== + dependencies: + is-glob "^4.0.3" + +glob@7.1.7: + version "7.1.7" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.7.tgz#3b193e9233f01d42d0b3f78294bbeeb418f94a90" + integrity sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.0.4" + once "^1.3.0" + path-is-absolute "^1.0.0" + +glob@7.2.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.0.tgz#d15535af7732e02e948f4c41628bd910293f6023" + integrity sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.0.4" + once "^1.3.0" + path-is-absolute "^1.0.0" + +glob@8.1.0, glob@^8.0.3: + version "8.1.0" + resolved "https://registry.yarnpkg.com/glob/-/glob-8.1.0.tgz#d388f656593ef708ee3e34640fdfb99a9fd1c33e" + integrity sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^5.0.1" + once "^1.3.0" + +glob@^10.3.10: + version "10.3.10" + resolved "https://registry.yarnpkg.com/glob/-/glob-10.3.10.tgz#0351ebb809fd187fe421ab96af83d3a70715df4b" + integrity sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g== + dependencies: + foreground-child "^3.1.0" + jackspeak "^2.3.5" + minimatch "^9.0.1" + minipass "^5.0.0 || ^6.0.2 || ^7.0.0" + path-scurry "^1.10.1" + +glob@^5.0.15: + version "5.0.15" + resolved "https://registry.yarnpkg.com/glob/-/glob-5.0.15.tgz#1bc936b9e02f4a603fcc222ecf7633d30b8b93b1" + integrity sha512-c9IPMazfRITpmAAKi22dK1VKxGDX9ehhqfABDriL/lzO92xcUKEJPQHrVA/2YHSNFB4iFlykVmWvwo48nr3OxA== + dependencies: + inflight "^1.0.4" + inherits "2" + minimatch "2 || 3" + once "^1.3.0" + path-is-absolute "^1.0.0" + +glob@^7.0.0, glob@^7.1.3: + version "7.2.3" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" + integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.1.1" + once "^1.3.0" + path-is-absolute "^1.0.0" + +global-modules@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/global-modules/-/global-modules-2.0.0.tgz#997605ad2345f27f51539bea26574421215c7780" + integrity sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A== + dependencies: + global-prefix "^3.0.0" + +global-prefix@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/global-prefix/-/global-prefix-3.0.0.tgz#fc85f73064df69f50421f47f883fe5b913ba9b97" + integrity sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg== + dependencies: + ini "^1.3.5" + kind-of "^6.0.2" + which "^1.3.1" + +globals@^13.19.0: + version "13.24.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-13.24.0.tgz#8432a19d78ce0c1e833949c36adb345400bb1171" + integrity sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ== + dependencies: + type-fest "^0.20.2" + +globalthis@^1.0.3: + version "1.0.4" + resolved "https://registry.yarnpkg.com/globalthis/-/globalthis-1.0.4.tgz#7430ed3a975d97bfb59bcce41f5cabbafa651236" + integrity sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ== + dependencies: + define-properties "^1.2.1" + gopd "^1.0.1" + +globby@^10.0.1: + version "10.0.2" + resolved "https://registry.yarnpkg.com/globby/-/globby-10.0.2.tgz#277593e745acaa4646c3ab411289ec47a0392543" + integrity sha512-7dUi7RvCoT/xast/o/dLN53oqND4yk0nsHkhRgn9w65C4PofCLOoJ39iSOg+qVDdWQPIEj+eszMHQ+aLVwwQSg== + dependencies: + "@types/glob" "^7.1.1" + array-union "^2.1.0" + dir-glob "^3.0.1" + fast-glob "^3.0.3" + glob "^7.1.3" + ignore "^5.1.1" + merge2 "^1.2.3" + slash "^3.0.0" + +globby@^11.0.3, globby@^11.1.0: + version "11.1.0" + resolved "https://registry.yarnpkg.com/globby/-/globby-11.1.0.tgz#bd4be98bb042f83d796f7e3811991fbe82a0d34b" + integrity sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g== + dependencies: + array-union "^2.1.0" + dir-glob "^3.0.1" + fast-glob "^3.2.9" + ignore "^5.2.0" + merge2 "^1.4.1" + slash "^3.0.0" + +gopd@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.0.1.tgz#29ff76de69dac7489b7c0918a5788e56477c332c" + integrity sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA== + dependencies: + get-intrinsic "^1.1.3" + +graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.1.9, graceful-fs@^4.2.0, graceful-fs@^4.2.4: + version "4.2.11" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" + integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== + +graphemer@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6" + integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag== + +growl@1.10.5: + version "1.10.5" + resolved "https://registry.yarnpkg.com/growl/-/growl-1.10.5.tgz#f2735dc2283674fa67478b10181059355c369e5e" + integrity sha512-qBr4OuELkhPenW6goKVXiv47US3clb3/IbuWF9KNKEijAy9oeHxU9IgzjvJhHkUzhaj7rOUD7+YGWqUjLp5oSA== + +handlebars@^4.0.1: + version "4.7.8" + resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.8.tgz#41c42c18b1be2365439188c77c6afae71c0cd9e9" + integrity sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ== + dependencies: + minimist "^1.2.5" + neo-async "^2.6.2" + source-map "^0.6.1" + wordwrap "^1.0.0" + optionalDependencies: + uglify-js "^3.1.4" + +hardhat-gas-reporter@^1.0.8: + version "1.0.10" + resolved "https://registry.yarnpkg.com/hardhat-gas-reporter/-/hardhat-gas-reporter-1.0.10.tgz#ebe5bda5334b5def312747580cd923c2b09aef1b" + integrity sha512-02N4+So/fZrzJ88ci54GqwVA3Zrf0C9duuTyGt0CFRIh/CdNwbnTgkXkRfojOMLBQ+6t+lBIkgbsOtqMvNwikA== + dependencies: + array-uniq "1.0.3" + eth-gas-reporter "^0.2.25" + sha1 "^1.1.1" + +hardhat@^2.14.0, hardhat@^2.19.1: + version "2.22.3" + resolved "https://registry.yarnpkg.com/hardhat/-/hardhat-2.22.3.tgz#50605daca6b29862397e446c42ec14c89430bec3" + integrity sha512-k8JV2ECWNchD6ahkg2BR5wKVxY0OiKot7fuxiIpRK0frRqyOljcR2vKwgWSLw6YIeDcNNA4xybj7Og7NSxr2hA== + dependencies: + "@ethersproject/abi" "^5.1.2" + "@metamask/eth-sig-util" "^4.0.0" + "@nomicfoundation/edr" "^0.3.5" + "@nomicfoundation/ethereumjs-common" "4.0.4" + "@nomicfoundation/ethereumjs-tx" "5.0.4" + "@nomicfoundation/ethereumjs-util" "9.0.4" + "@nomicfoundation/solidity-analyzer" "^0.1.0" + "@sentry/node" "^5.18.1" + "@types/bn.js" "^5.1.0" + "@types/lru-cache" "^5.1.0" + adm-zip "^0.4.16" + aggregate-error "^3.0.0" + ansi-escapes "^4.3.0" + boxen "^5.1.2" + chalk "^2.4.2" + chokidar "^3.4.0" + ci-info "^2.0.0" + debug "^4.1.1" + enquirer "^2.3.0" + env-paths "^2.2.0" + ethereum-cryptography "^1.0.3" + ethereumjs-abi "^0.6.8" + find-up "^2.1.0" + fp-ts "1.19.3" + fs-extra "^7.0.1" + glob "7.2.0" + immutable "^4.0.0-rc.12" + io-ts "1.10.4" + keccak "^3.0.2" + lodash "^4.17.11" + mnemonist "^0.38.0" + mocha "^10.0.0" + p-map "^4.0.0" + raw-body "^2.4.1" + resolve "1.17.0" + semver "^6.3.0" + solc "0.7.3" + source-map-support "^0.5.13" + stacktrace-parser "^0.1.10" + tsort "0.0.1" + undici "^5.14.0" + uuid "^8.3.2" + ws "^7.4.6" + +has-bigints@^1.0.1, has-bigints@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.0.2.tgz#0871bd3e3d51626f6ca0966668ba35d5602d6eaa" + integrity sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ== + +has-flag@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-1.0.0.tgz#9d9e793165ce017a00f00418c43f942a7b1d11fa" + integrity sha512-DyYHfIYwAJmjAjSSPKANxI8bFY9YtFrgkAfinBojQ8YJTOuOuav64tMUJv584SES4xl74PmuaevIyaLESHdTAA== + +has-flag@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" + integrity sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw== + +has-flag@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" + integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== + +has-property-descriptors@^1.0.0, has-property-descriptors@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz#963ed7d071dc7bf5f084c5bfbe0d1b6222586854" + integrity sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg== + dependencies: + es-define-property "^1.0.0" + +has-proto@^1.0.1, has-proto@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/has-proto/-/has-proto-1.0.3.tgz#b31ddfe9b0e6e9914536a6ab286426d0214f77fd" + integrity sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q== + +has-symbols@^1.0.2, has-symbols@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8" + integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A== + +has-tostringtag@^1.0.0, has-tostringtag@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz#2cdc42d40bef2e5b4eeab7c01a73c54ce7ab5abc" + integrity sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw== + dependencies: + has-symbols "^1.0.3" + +hash-base@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/hash-base/-/hash-base-3.1.0.tgz#55c381d9e06e1d2997a883b4a3fddfe7f0d3af33" + integrity sha512-1nmYp/rhMDiE7AYkDw+lLwlAzz0AntGIe51F3RfFfEqyQ3feY2eI/NcwC6umIQVOASPMsWJLJScWKSSvzL9IVA== + dependencies: + inherits "^2.0.4" + readable-stream "^3.6.0" + safe-buffer "^5.2.0" + +hash.js@1.1.7, hash.js@^1.0.0, hash.js@^1.0.3, hash.js@^1.1.7: + version "1.1.7" + resolved "https://registry.yarnpkg.com/hash.js/-/hash.js-1.1.7.tgz#0babca538e8d4ee4a0f8988d68866537a003cf42" + integrity sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA== + dependencies: + inherits "^2.0.3" + minimalistic-assert "^1.0.1" + +hasown@^2.0.0, hasown@^2.0.1, hasown@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003" + integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ== + dependencies: + function-bind "^1.1.2" + +he@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" + integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== + +"heap@>= 0.2.0": + version "0.2.7" + resolved "https://registry.yarnpkg.com/heap/-/heap-0.2.7.tgz#1e6adf711d3f27ce35a81fe3b7bd576c2260a8fc" + integrity sha512-2bsegYkkHO+h/9MGbn6KWcE45cHZgPANo5LXF7EvWdT0yT2EguSVO1nDgU5c8+ZOPwp2vMNa7YFsJhVcDR9Sdg== + +hmac-drbg@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1" + integrity sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg== + dependencies: + hash.js "^1.0.3" + minimalistic-assert "^1.0.0" + minimalistic-crypto-utils "^1.0.1" + +http-basic@^8.1.1: + version "8.1.3" + resolved "https://registry.yarnpkg.com/http-basic/-/http-basic-8.1.3.tgz#a7cabee7526869b9b710136970805b1004261bbf" + integrity sha512-/EcDMwJZh3mABI2NhGfHOGOeOZITqfkEO4p/xK+l3NpyncIHUQBoMvCSF/b5GqvKtySC2srL/GGG3+EtlqlmCw== + dependencies: + caseless "^0.12.0" + concat-stream "^1.6.2" + http-response-object "^3.0.1" + parse-cache-control "^1.0.1" + +http-errors@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3" + integrity sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ== + dependencies: + depd "2.0.0" + inherits "2.0.4" + setprototypeof "1.2.0" + statuses "2.0.1" + toidentifier "1.0.1" + +http-response-object@^3.0.1: + version "3.0.2" + resolved "https://registry.yarnpkg.com/http-response-object/-/http-response-object-3.0.2.tgz#7f435bb210454e4360d074ef1f989d5ea8aa9810" + integrity sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA== + dependencies: + "@types/node" "^10.0.3" + +https-proxy-agent@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6" + integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA== + dependencies: + agent-base "6" + debug "4" + +human-signals@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0" + integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw== + +iconv-lite@0.4.24: + version "0.4.24" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" + integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== + dependencies: + safer-buffer ">= 2.1.2 < 3" + +ieee754@^1.1.13, ieee754@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" + integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== + +ignore@^5.1.1: + version "5.3.1" + resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.1.tgz#5073e554cd42c5b33b394375f538b8593e34d4ef" + integrity sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw== + +ignore@^5.2.0, ignore@^5.2.4: + version "5.3.0" + resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.0.tgz#67418ae40d34d6999c95ff56016759c718c82f78" + integrity sha512-g7dmpshy+gD7mh88OC9NwSGTKoc3kyLAZQRU1mt53Aw/vnvfXnbC+F/7F7QoYVKbV+KNvJx8wArewKy1vXMtlg== + +immutable@^4.0.0-rc.12: + version "4.3.5" + resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.3.5.tgz#f8b436e66d59f99760dc577f5c99a4fd2a5cc5a0" + integrity sha512-8eabxkth9gZatlwl5TBuJnCsoTADlL6ftEr7A4qgdaTsPyreilDSnUk57SO+jfKcNtxPa22U5KK6DSeAYhpBJw== + +import-fresh@^3.2.1, import-fresh@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b" + integrity sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw== + dependencies: + parent-module "^1.0.0" + resolve-from "^4.0.0" + +imurmurhash@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" + integrity sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA== + +indent-string@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-4.0.0.tgz#624f8f4497d619b2d9768531d58f4122854d7251" + integrity sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg== + +inflight@^1.0.4: + version "1.0.6" + resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" + integrity sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA== + dependencies: + once "^1.3.0" + wrappy "1" + +inherits@2, inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.1, inherits@~2.0.3: + version "2.0.4" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" + integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== + +ini@^1.3.5: + version "1.3.8" + resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" + integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== + +internal-slot@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.0.7.tgz#c06dcca3ed874249881007b0a5523b172a190802" + integrity sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g== + dependencies: + es-errors "^1.3.0" + hasown "^2.0.0" + side-channel "^1.0.4" + +interpret@^1.0.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.4.0.tgz#665ab8bc4da27a774a40584e812e3e0fa45b1a1e" + integrity sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA== + +io-ts@1.10.4: + version "1.10.4" + resolved "https://registry.yarnpkg.com/io-ts/-/io-ts-1.10.4.tgz#cd5401b138de88e4f920adbcb7026e2d1967e6e2" + integrity sha512-b23PteSnYXSONJ6JQXRAlvJhuw8KOtkqa87W4wDtvMrud/DTJd5X+NpOOI+O/zZwVq6v0VLAaJ+1EDViKEuN9g== + dependencies: + fp-ts "^1.0.0" + +is-array-buffer@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/is-array-buffer/-/is-array-buffer-3.0.4.tgz#7a1f92b3d61edd2bc65d24f130530ea93d7fae98" + integrity sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw== + dependencies: + call-bind "^1.0.2" + get-intrinsic "^1.2.1" + +is-arrayish@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" + integrity sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg== + +is-bigint@^1.0.1: + version "1.0.4" + resolved "https://registry.yarnpkg.com/is-bigint/-/is-bigint-1.0.4.tgz#08147a1875bc2b32005d41ccd8291dffc6691df3" + integrity sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg== + dependencies: + has-bigints "^1.0.1" + +is-binary-path@~2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09" + integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw== + dependencies: + binary-extensions "^2.0.0" + +is-boolean-object@^1.1.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/is-boolean-object/-/is-boolean-object-1.1.2.tgz#5c6dc200246dd9321ae4b885a114bb1f75f63719" + integrity sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA== + dependencies: + call-bind "^1.0.2" + has-tostringtag "^1.0.0" + +is-callable@^1.1.3, is-callable@^1.1.4, is-callable@^1.2.7: + version "1.2.7" + resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.7.tgz#3bc2a85ea742d9e36205dcacdd72ca1fdc51b055" + integrity sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA== + +is-core-module@^2.13.0: + version "2.13.1" + resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.13.1.tgz#ad0d7532c6fea9da1ebdc82742d74525c6273384" + integrity sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw== + dependencies: + hasown "^2.0.0" + +is-data-view@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-data-view/-/is-data-view-1.0.1.tgz#4b4d3a511b70f3dc26d42c03ca9ca515d847759f" + integrity sha512-AHkaJrsUVW6wq6JS8y3JnM/GJF/9cf+k20+iDzlSaJrinEo5+7vRiteOSwBhHRiAyQATN1AmY4hwzxJKPmYf+w== + dependencies: + is-typed-array "^1.1.13" + +is-date-object@^1.0.1: + version "1.0.5" + resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.0.5.tgz#0841d5536e724c25597bf6ea62e1bd38298df31f" + integrity sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ== + dependencies: + has-tostringtag "^1.0.0" + +is-extglob@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" + integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== + +is-fullwidth-code-point@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f" + integrity sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w== + +is-fullwidth-code-point@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" + integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== + +is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1: + version "4.0.3" + resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" + integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== + dependencies: + is-extglob "^2.1.1" + +is-hex-prefixed@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-hex-prefixed/-/is-hex-prefixed-1.0.0.tgz#7d8d37e6ad77e5d127148913c573e082d777f554" + integrity sha512-WvtOiug1VFrE9v1Cydwm+FnXd3+w9GaeVUss5W4v/SLy3UW00vP+6iNF2SdnfiBoLy4bTqVdkftNGTUeOFVsbA== + +is-negative-zero@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.3.tgz#ced903a027aca6381b777a5743069d7376a49747" + integrity sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw== + +is-number-object@^1.0.4: + version "1.0.7" + resolved "https://registry.yarnpkg.com/is-number-object/-/is-number-object-1.0.7.tgz#59d50ada4c45251784e9904f5246c742f07a42fc" + integrity sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ== + dependencies: + has-tostringtag "^1.0.0" + +is-number@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" + integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== + +is-object@~1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-object/-/is-object-1.0.2.tgz#a56552e1c665c9e950b4a025461da87e72f86fcf" + integrity sha512-2rRIahhZr2UWb45fIOuvZGpFtz0TyOZLf32KxBbSoUCeZR495zCKlWUKKUByk3geS2eAs7ZAABt0Y/Rx0GiQGA== + +is-path-inside@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-3.0.3.tgz#d231362e53a07ff2b0e0ea7fed049161ffd16283" + integrity sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ== + +is-plain-obj@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-2.1.0.tgz#45e42e37fccf1f40da8e5f76ee21515840c09287" + integrity sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA== + +is-regex@^1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.4.tgz#eef5663cd59fa4c0ae339505323df6854bb15958" + integrity sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg== + dependencies: + call-bind "^1.0.2" + has-tostringtag "^1.0.0" + +is-shared-array-buffer@^1.0.2, is-shared-array-buffer@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/is-shared-array-buffer/-/is-shared-array-buffer-1.0.3.tgz#1237f1cba059cdb62431d378dcc37d9680181688" + integrity sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg== + dependencies: + call-bind "^1.0.7" + +is-stream@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077" + integrity sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg== + +is-string@^1.0.5, is-string@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/is-string/-/is-string-1.0.7.tgz#0dd12bf2006f255bb58f695110eff7491eebc0fd" + integrity sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg== + dependencies: + has-tostringtag "^1.0.0" + +is-symbol@^1.0.2, is-symbol@^1.0.3: + version "1.0.4" + resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.0.4.tgz#a6dac93b635b063ca6872236de88910a57af139c" + integrity sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg== + dependencies: + has-symbols "^1.0.2" + +is-typed-array@^1.1.13: + version "1.1.13" + resolved "https://registry.yarnpkg.com/is-typed-array/-/is-typed-array-1.1.13.tgz#d6c5ca56df62334959322d7d7dd1cca50debe229" + integrity sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw== + dependencies: + which-typed-array "^1.1.14" + +is-unicode-supported@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz#3f26c76a809593b52bfa2ecb5710ed2779b522a7" + integrity sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw== + +is-weakref@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-weakref/-/is-weakref-1.0.2.tgz#9529f383a9338205e89765e0392efc2f100f06f2" + integrity sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ== + dependencies: + call-bind "^1.0.2" + +isarray@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf" + integrity sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ== + +isarray@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.5.tgz#8af1e4c1221244cc62459faf38940d4e644a5723" + integrity sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw== + +isarray@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" + integrity sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ== + +isexe@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" + integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== + +jackspeak@^2.3.5: + version "2.3.6" + resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-2.3.6.tgz#647ecc472238aee4b06ac0e461acc21a8c505ca8" + integrity sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ== + dependencies: + "@isaacs/cliui" "^8.0.2" + optionalDependencies: + "@pkgjs/parseargs" "^0.11.0" + +joycon@^3.0.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/joycon/-/joycon-3.1.1.tgz#bce8596d6ae808f8b68168f5fc69280996894f03" + integrity sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw== + +js-sha3@0.8.0, js-sha3@^0.8.0: + version "0.8.0" + resolved "https://registry.yarnpkg.com/js-sha3/-/js-sha3-0.8.0.tgz#b9b7a5da73afad7dedd0f8c463954cbde6818840" + integrity sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q== + +js-sha3@^0.9.2: + version "0.9.3" + resolved "https://registry.yarnpkg.com/js-sha3/-/js-sha3-0.9.3.tgz#f0209432b23a66a0f6c7af592c26802291a75c2a" + integrity sha512-BcJPCQeLg6WjEx3FE591wVAevlli8lxsxm9/FzV4HXkV49TmBH38Yvrpce6fjbADGMKFrBMGTqrVz3qPIZ88Gg== + +js-tokens@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" + integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== + +js-yaml@3.x: + version "3.14.1" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.1.tgz#dae812fdb3825fa306609a8717383c50c36a0537" + integrity sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g== + dependencies: + argparse "^1.0.7" + esprima "^4.0.0" + +js-yaml@4.1.0, js-yaml@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" + integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== + dependencies: + argparse "^2.0.1" + +json-buffer@3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.1.tgz#9338802a30d3b6605fbe0613e094008ca8c05a13" + integrity sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ== + +json-parse-even-better-errors@^2.3.0: + version "2.3.1" + resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz#7c47805a94319928e05777405dc12e1f7a4ee02d" + integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w== + +json-schema-traverse@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" + integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== + +json-schema-traverse@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz#ae7bcb3656ab77a73ba5c49bf654f38e6b6860e2" + integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug== + +json-stable-stringify-without-jsonify@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" + integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw== + +jsonfile@^2.1.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-2.4.0.tgz#3736a2b428b87bbda0cc83b53fa3d633a35c2ae8" + integrity sha512-PKllAqbgLgxHaj8TElYymKCAgrASebJrWpTnEkOaTowt23VKXXN0sUeriJ+eh7y6ufb/CC5ap11pz71/cM0hUw== + optionalDependencies: + graceful-fs "^4.1.6" + +jsonfile@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-4.0.0.tgz#8771aae0799b64076b76640fca058f9c10e33ecb" + integrity sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg== + optionalDependencies: + graceful-fs "^4.1.6" + +jsonfile@^6.0.1: + version "6.1.0" + resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.1.0.tgz#bc55b2634793c679ec6403094eb13698a6ec0aae" + integrity sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ== + dependencies: + universalify "^2.0.0" + optionalDependencies: + graceful-fs "^4.1.6" + +jsonparse@^1.2.0: + version "1.3.1" + resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280" + integrity sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg== + +jsonschema@^1.2.4: + version "1.4.1" + resolved "https://registry.yarnpkg.com/jsonschema/-/jsonschema-1.4.1.tgz#cc4c3f0077fb4542982973d8a083b6b34f482dab" + integrity sha512-S6cATIPVv1z0IlxdN+zUk5EPjkGCdnhN4wVSBlvoUO1tOLJootbo9CquNJmbIh4yikWHiUedhRYrNPn1arpEmQ== + +just-extend@^6.2.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/just-extend/-/just-extend-6.2.0.tgz#b816abfb3d67ee860482e7401564672558163947" + integrity sha512-cYofQu2Xpom82S6qD778jBDpwvvy39s1l/hrYij2u9AMdQcGRpaBu6kY4mVhuno5kJVi1DAz4aiphA2WI1/OAw== + +keccak256@^1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/keccak256/-/keccak256-1.0.6.tgz#dd32fb771558fed51ce4e45a035ae7515573da58" + integrity sha512-8GLiM01PkdJVGUhR1e6M/AvWnSqYS0HaERI+K/QtStGDGlSTx2B1zTqZk4Zlqu5TxHJNTxWAdP9Y+WI50OApUw== + dependencies: + bn.js "^5.2.0" + buffer "^6.0.3" + keccak "^3.0.2" + +keccak@^3.0.0, keccak@^3.0.2: + version "3.0.4" + resolved "https://registry.yarnpkg.com/keccak/-/keccak-3.0.4.tgz#edc09b89e633c0549da444432ecf062ffadee86d" + integrity sha512-3vKuW0jV8J3XNTzvfyicFR5qvxrSAGl7KIhvgOu5cmWwM7tZRj3fMbj/pfIf4be7aznbc+prBWGjywox/g2Y6Q== + dependencies: + node-addon-api "^2.0.0" + node-gyp-build "^4.2.0" + readable-stream "^3.6.0" + +keyv@^4.5.3: + version "4.5.4" + resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93" + integrity sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw== + dependencies: + json-buffer "3.0.1" + +kind-of@^6.0.2: + version "6.0.3" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd" + integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw== + +klaw@^1.0.0: + version "1.3.1" + resolved "https://registry.yarnpkg.com/klaw/-/klaw-1.3.1.tgz#4088433b46b3b1ba259d78785d8e96f73ba02439" + integrity sha512-TED5xi9gGQjGpNnvRWknrwAB1eL5GciPfVFOt3Vk1OJCVDQbzuSfrF3hkUQKlsgKrG1F+0t5W0m+Fje1jIt8rw== + optionalDependencies: + graceful-fs "^4.1.9" + +levn@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/levn/-/levn-0.4.1.tgz#ae4562c007473b932a6200d403268dd2fffc6ade" + integrity sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ== + dependencies: + prelude-ls "^1.2.1" + type-check "~0.4.0" + +levn@~0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/levn/-/levn-0.3.0.tgz#3b09924edf9f083c0490fdd4c0bc4421e04764ee" + integrity sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA== + dependencies: + prelude-ls "~1.1.2" + type-check "~0.3.2" + +lilconfig@^2.0.5: + version "2.1.0" + resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.1.0.tgz#78e23ac89ebb7e1bfbf25b18043de756548e7f52" + integrity sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ== + +lines-and-columns@^1.1.6: + version "1.2.4" + resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" + integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== + +load-tsconfig@^0.2.0: + version "0.2.5" + resolved "https://registry.yarnpkg.com/load-tsconfig/-/load-tsconfig-0.2.5.tgz#453b8cd8961bfb912dea77eb6c168fe8cca3d3a1" + integrity sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg== + +locate-path@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-2.0.0.tgz#2b568b265eec944c6d9c0de9c3dbbbca0354cd8e" + integrity sha512-NCI2kiDkyR7VeEKm27Kda/iQHyKJe1Bu0FlTbYp3CqJu+9IFe9bLyAjMxf5ZDDbEg+iMPzB5zYyUTSm8wVTKmA== + dependencies: + p-locate "^2.0.0" + path-exists "^3.0.0" + +locate-path@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286" + integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw== + dependencies: + p-locate "^5.0.0" + +lodash.camelcase@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6" + integrity sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA== + +lodash.clonedeep@^4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef" + integrity sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ== + +lodash.get@^4.4.2: + version "4.4.2" + resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99" + integrity sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ== + +lodash.isequal@^4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0" + integrity sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ== + +lodash.merge@^4.6.2: + version "4.6.2" + resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" + integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== + +lodash.sortby@^4.7.0: + version "4.7.0" + resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438" + integrity sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA== + +lodash.truncate@^4.4.2: + version "4.4.2" + resolved "https://registry.yarnpkg.com/lodash.truncate/-/lodash.truncate-4.4.2.tgz#5a350da0b1113b837ecfffd5812cbe58d6eae193" + integrity sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw== + +lodash@^4.17.11, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.21: + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" + integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== + +log-symbols@4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-4.1.0.tgz#3fbdbb95b4683ac9fc785111e792e558d4abd503" + integrity sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg== + dependencies: + chalk "^4.1.0" + is-unicode-supported "^0.1.0" + +loupe@^2.3.6: + version "2.3.7" + resolved "https://registry.yarnpkg.com/loupe/-/loupe-2.3.7.tgz#6e69b7d4db7d3ab436328013d37d1c8c3540c697" + integrity sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA== + dependencies: + get-func-name "^2.0.1" + +lru-cache@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94" + integrity sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA== + dependencies: + yallist "^4.0.0" + +"lru-cache@^9.1.1 || ^10.0.0": + version "10.1.0" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.1.0.tgz#2098d41c2dc56500e6c88584aa656c84de7d0484" + integrity sha512-/1clY/ui8CzjKFyjdvwPWJUYKiFVXG2I2cY0ssG7h4+hwk+XOIX7ZSG9Q7TW8TW3Kp3BUSqgFWBLgL4PJ+Blag== + +lru_map@^0.3.3: + version "0.3.3" + resolved "https://registry.yarnpkg.com/lru_map/-/lru_map-0.3.3.tgz#b5c8351b9464cbd750335a79650a0ec0e56118dd" + integrity sha512-Pn9cox5CsMYngeDbmChANltQl+5pi6XmTrraMSzhPmMBbmgcxmqWry0U3PGapCU1yB4/LqCcom7qhHZiF/jGfQ== + +make-error@^1.1.1: + version "1.3.6" + resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2" + integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw== + +markdown-table@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/markdown-table/-/markdown-table-1.1.3.tgz#9fcb69bcfdb8717bfd0398c6ec2d93036ef8de60" + integrity sha512-1RUZVgQlpJSPWYbFSpmudq5nHY1doEIv89gBtF0s4gW1GF2XorxcA/70M5vq7rLv0a6mhOUccRsqkwhwLCIQ2Q== + +md5.js@^1.3.4: + version "1.3.5" + resolved "https://registry.yarnpkg.com/md5.js/-/md5.js-1.3.5.tgz#b5d07b8e3216e3e27cd728d72f70d1e6a342005f" + integrity sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg== + dependencies: + hash-base "^3.0.0" + inherits "^2.0.1" + safe-buffer "^5.1.2" + +memorystream@^0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/memorystream/-/memorystream-0.3.1.tgz#86d7090b30ce455d63fbae12dda51a47ddcaf9b2" + integrity sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw== + +merge-descriptors@~1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.3.tgz#d80319a65f3c7935351e5cfdac8f9318504dbed5" + integrity sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ== + +merge-stream@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" + integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w== + +merge2@^1.2.3, merge2@^1.3.0, merge2@^1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" + integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== + +micro-ftch@^0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/micro-ftch/-/micro-ftch-0.3.1.tgz#6cb83388de4c1f279a034fb0cf96dfc050853c5f" + integrity sha512-/0LLxhzP0tfiR5hcQebtudP56gUurs2CLkGarnCiB/OqEyUFQ6U3paQi/tgLv0hBJYt2rnr9MNpxz4fiiugstg== + +micromatch@^4.0.4: + version "4.0.5" + resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.5.tgz#bc8999a7cbbf77cdc89f132f6e467051b49090c6" + integrity sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA== + dependencies: + braces "^3.0.2" + picomatch "^2.3.1" + +mime-db@1.52.0: + version "1.52.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" + integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== + +mime-types@^2.1.12: + version "2.1.35" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" + integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== + dependencies: + mime-db "1.52.0" + +mimic-fn@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" + integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== + +minimalistic-assert@^1.0.0, minimalistic-assert@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7" + integrity sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A== + +minimalistic-crypto-utils@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz#f6c00c1c0b082246e5c4d99dfb8c7c083b2b582a" + integrity sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg== + +"minimatch@2 || 3", minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" + integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== + dependencies: + brace-expansion "^1.1.7" + +minimatch@4.2.1: + version "4.2.1" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-4.2.1.tgz#40d9d511a46bdc4e563c22c3080cde9c0d8299b4" + integrity sha512-9Uq1ChtSZO+Mxa/CL1eGizn2vRn3MlLgzhT0Iz8zaY8NdvxvB0d5QdPFmCKf7JKA9Lerx5vRrnwO03jsSfGG9g== + dependencies: + brace-expansion "^1.1.7" + +minimatch@5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.0.1.tgz#fb9022f7528125187c92bd9e9b6366be1cf3415b" + integrity sha512-nLDxIFRyhDblz3qMuq+SoRZED4+miJ/G+tdDrjkkkRnjAsBexeGpgjLEQ0blJy7rHhR2b93rhQY4SvyWu9v03g== + dependencies: + brace-expansion "^2.0.1" + +minimatch@^5.0.1: + version "5.1.6" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.6.tgz#1cfcb8cf5522ea69952cd2af95ae09477f122a96" + integrity sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g== + dependencies: + brace-expansion "^2.0.1" + +minimatch@^9.0.1: + version "9.0.3" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.3.tgz#a6e00c3de44c3a542bfaae70abfc22420a6da825" + integrity sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg== + dependencies: + brace-expansion "^2.0.1" + +minimatch@^9.0.3: + version "9.0.4" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.4.tgz#8e49c731d1749cbec05050ee5145147b32496a51" + integrity sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw== + dependencies: + brace-expansion "^2.0.1" + +minimist@^1.2.5, minimist@^1.2.6, minimist@^1.2.7: + version "1.2.8" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" + integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== + +"minipass@^5.0.0 || ^6.0.2 || ^7.0.0": + version "7.0.4" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.0.4.tgz#dbce03740f50a4786ba994c1fb908844d27b038c" + integrity sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ== + +mkdirp-classic@^0.5.2: + version "0.5.3" + resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113" + integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A== + +mkdirp@0.5.x, mkdirp@^0.5.1: + version "0.5.6" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.6.tgz#7def03d2432dcae4ba1d611445c48396062255f6" + integrity sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw== + dependencies: + minimist "^1.2.6" + +mkdirp@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" + integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== + +mkdirp@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-3.0.1.tgz#e44e4c5607fb279c168241713cc6e0fea9adcb50" + integrity sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg== + +mnemonist@^0.38.0: + version "0.38.5" + resolved "https://registry.yarnpkg.com/mnemonist/-/mnemonist-0.38.5.tgz#4adc7f4200491237fe0fa689ac0b86539685cade" + integrity sha512-bZTFT5rrPKtPJxj8KSV0WkPyNxl72vQepqqVUAW2ARUpUSF2qXMB6jZj7hW5/k7C1rtpzqbD/IIbJwLXUjCHeg== + dependencies: + obliterator "^2.0.0" + +mocha@^10.0.0, mocha@^10.2.0: + version "10.4.0" + resolved "https://registry.yarnpkg.com/mocha/-/mocha-10.4.0.tgz#ed03db96ee9cfc6d20c56f8e2af07b961dbae261" + integrity sha512-eqhGB8JKapEYcC4ytX/xrzKforgEc3j1pGlAXVy3eRwrtAy5/nIfT1SvgGzfN0XZZxeLq0aQWkOUAmqIJiv+bA== + dependencies: + ansi-colors "4.1.1" + browser-stdout "1.3.1" + chokidar "3.5.3" + debug "4.3.4" + diff "5.0.0" + escape-string-regexp "4.0.0" + find-up "5.0.0" + glob "8.1.0" + he "1.2.0" + js-yaml "4.1.0" + log-symbols "4.1.0" + minimatch "5.0.1" + ms "2.1.3" + serialize-javascript "6.0.0" + strip-json-comments "3.1.1" + supports-color "8.1.1" + workerpool "6.2.1" + yargs "16.2.0" + yargs-parser "20.2.4" + yargs-unparser "2.0.0" + +mocha@^9.2.2: + version "9.2.2" + resolved "https://registry.yarnpkg.com/mocha/-/mocha-9.2.2.tgz#d70db46bdb93ca57402c809333e5a84977a88fb9" + integrity sha512-L6XC3EdwT6YrIk0yXpavvLkn8h+EU+Y5UcCHKECyMbdUIxyMuZj4bX4U9e1nvnvUUvQVsV2VHQr5zLdcUkhW/g== + dependencies: + "@ungap/promise-all-settled" "1.1.2" + ansi-colors "4.1.1" + browser-stdout "1.3.1" + chokidar "3.5.3" + debug "4.3.3" + diff "5.0.0" + escape-string-regexp "4.0.0" + find-up "5.0.0" + glob "7.2.0" + growl "1.10.5" + he "1.2.0" + js-yaml "4.1.0" + log-symbols "4.1.0" + minimatch "4.2.1" + ms "2.1.3" + nanoid "3.3.1" + serialize-javascript "6.0.0" + strip-json-comments "3.1.1" + supports-color "8.1.1" + which "2.0.2" + workerpool "6.2.0" + yargs "16.2.0" + yargs-parser "20.2.4" + yargs-unparser "2.0.0" + +module-not-found-error@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/module-not-found-error/-/module-not-found-error-1.0.1.tgz#cf8b4ff4f29640674d6cdd02b0e3bc523c2bbdc0" + integrity sha512-pEk4ECWQXV6z2zjhRZUongnLJNUeGQJ3w6OQ5ctGwD+i5o93qjRQUk2Rt6VdNeu3sEP0AB4LcfvdebpxBRVr4g== + +ms@2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" + integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== + +ms@2.1.3, ms@^2.1.1: + version "2.1.3" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + +mz@^2.7.0: + version "2.7.0" + resolved "https://registry.yarnpkg.com/mz/-/mz-2.7.0.tgz#95008057a56cafadc2bc63dde7f9ff6955948e32" + integrity sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q== + dependencies: + any-promise "^1.0.0" + object-assign "^4.0.1" + thenify-all "^1.0.0" + +nan@^2.18.0, nan@^2.19.0: + version "2.19.0" + resolved "https://registry.yarnpkg.com/nan/-/nan-2.19.0.tgz#bb58122ad55a6c5bc973303908d5b16cfdd5a8c0" + integrity sha512-nO1xXxfh/RWNxfd/XPfbIfFk5vgLsAxUR9y5O0cHMJu/AW9U95JLXqthYHjEp+8gQ5p96K9jUp8nbVOxCdRbtw== + +nanoid@3.3.1: + version "3.3.1" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.1.tgz#6347a18cac88af88f58af0b3594b723d5e99bb35" + integrity sha512-n6Vs/3KGyxPQd6uO0eH4Bv0ojGSUvuLlIHtC3Y0kEO23YRge8H9x1GCzLn28YX0H66pMkxuaeESFq4tKISKwdw== + +natural-compare-lite@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/natural-compare-lite/-/natural-compare-lite-1.4.0.tgz#17b09581988979fddafe0201e931ba933c96cbb4" + integrity sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g== + +natural-compare@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" + integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== + +neo-async@^2.6.2: + version "2.6.2" + resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" + integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== + +nise@^5.1.9: + version "5.1.9" + resolved "https://registry.yarnpkg.com/nise/-/nise-5.1.9.tgz#0cb73b5e4499d738231a473cd89bd8afbb618139" + integrity sha512-qOnoujW4SV6e40dYxJOb3uvuoPHtmLzIk4TFo+j0jPJoC+5Z9xja5qH5JZobEPsa8+YYphMrOSwnrshEhG2qww== + dependencies: + "@sinonjs/commons" "^3.0.0" + "@sinonjs/fake-timers" "^11.2.2" + "@sinonjs/text-encoding" "^0.7.2" + just-extend "^6.2.0" + path-to-regexp "^6.2.1" + +node-addon-api@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-2.0.2.tgz#432cfa82962ce494b132e9d72a15b29f71ff5d32" + integrity sha512-Ntyt4AIXyaLIuMHF6IOoTakB3K+RWxwtsHNRxllEoA6vPwP9o4866g6YWDLUdnucilZhmkxiHwHr11gAENw+QA== + +node-emoji@^1.10.0: + version "1.11.0" + resolved "https://registry.yarnpkg.com/node-emoji/-/node-emoji-1.11.0.tgz#69a0150e6946e2f115e9d7ea4df7971e2628301c" + integrity sha512-wo2DpQkQp7Sjm2A0cq+sN7EHKO6Sl0ctXeBdFZrL9T9+UywORbufTcTZxom8YqpLQt/FqNMUkOpkZrJVYSKD3A== + dependencies: + lodash "^4.17.21" + +node-fetch@^2.6.0: + version "2.7.0" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" + integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== + dependencies: + whatwg-url "^5.0.0" + +node-gyp-build@^4.2.0: + version "4.8.0" + resolved "https://registry.yarnpkg.com/node-gyp-build/-/node-gyp-build-4.8.0.tgz#3fee9c1731df4581a3f9ead74664369ff00d26dd" + integrity sha512-u6fs2AEUljNho3EYTJNBfImO5QTo/J/1Etd+NVdCj7qWKUSN/bSLkZwhDv7I+w/MSC6qJ4cknepkAYykDdK8og== + +nofilter@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/nofilter/-/nofilter-3.1.0.tgz#c757ba68801d41ff930ba2ec55bab52ca184aa66" + integrity sha512-l2NNj07e9afPnhAhvgVrCD/oy2Ai1yfLpuo3EpiO1jFTsB4sFz6oIfAfSZyQzVpkZQ9xS8ZS5g1jCBgq4Hwo0g== + +nopt@3.x: + version "3.0.6" + resolved "https://registry.yarnpkg.com/nopt/-/nopt-3.0.6.tgz#c6465dbf08abcd4db359317f79ac68a646b28ff9" + integrity sha512-4GUt3kSEYmk4ITxzB/b9vaIDfUVWN/Ml1Fwl11IlnIG2iaJ9O6WXZ9SrYM9NLI8OCBieN2Y8SWC2oJV0RQ7qYg== + dependencies: + abbrev "1" + +normalize-path@^3.0.0, normalize-path@~3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" + integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== + +npm-run-path@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-4.0.1.tgz#b7ecd1e5ed53da8e37a55e1c2269e0b97ed748ea" + integrity sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw== + dependencies: + path-key "^3.0.0" + +number-to-bn@1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/number-to-bn/-/number-to-bn-1.7.0.tgz#bb3623592f7e5f9e0030b1977bd41a0c53fe1ea0" + integrity sha512-wsJ9gfSz1/s4ZsJN01lyonwuxA1tml6X1yBDnfpMglypcBRFZZkus26EdPSlqS5GJfYddVZa22p3VNb3z5m5Ig== + dependencies: + bn.js "4.11.6" + strip-hex-prefix "1.0.0" + +object-assign@^4.0.1, object-assign@^4.1.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" + integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== + +object-inspect@^1.13.1: + version "1.13.1" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.1.tgz#b96c6109324ccfef6b12216a956ca4dc2ff94bc2" + integrity sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ== + +object-keys@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" + integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== + +object.assign@^4.1.5: + version "4.1.5" + resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.5.tgz#3a833f9ab7fdb80fc9e8d2300c803d216d8fdbb0" + integrity sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ== + dependencies: + call-bind "^1.0.5" + define-properties "^1.2.1" + has-symbols "^1.0.3" + object-keys "^1.1.1" + +obliterator@^2.0.0: + version "2.0.4" + resolved "https://registry.yarnpkg.com/obliterator/-/obliterator-2.0.4.tgz#fa650e019b2d075d745e44f1effeb13a2adbe816" + integrity sha512-lgHwxlxV1qIg1Eap7LgIeoBWIMFibOjbrYPIPJZcI1mmGAI2m3lNYpK12Y+GBdPQ0U1hRwSord7GIaawz962qQ== + +once@1.x, once@^1.3.0, once@^1.3.1, once@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" + integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== + dependencies: + wrappy "1" + +onetime@^5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/onetime/-/onetime-5.1.2.tgz#d0e96ebb56b07476df1dd9c4806e5237985ca45e" + integrity sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg== + dependencies: + mimic-fn "^2.1.0" + +optionator@^0.8.1: + version "0.8.3" + resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.8.3.tgz#84fa1d036fe9d3c7e21d99884b601167ec8fb495" + integrity sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA== + dependencies: + deep-is "~0.1.3" + fast-levenshtein "~2.0.6" + levn "~0.3.0" + prelude-ls "~1.1.2" + type-check "~0.3.2" + word-wrap "~1.2.3" + +optionator@^0.9.3: + version "0.9.3" + resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.3.tgz#007397d44ed1872fdc6ed31360190f81814e2c64" + integrity sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg== + dependencies: + "@aashutoshrathi/word-wrap" "^1.2.3" + deep-is "^0.1.3" + fast-levenshtein "^2.0.6" + levn "^0.4.1" + prelude-ls "^1.2.1" + type-check "^0.4.0" + +ordinal@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/ordinal/-/ordinal-1.0.3.tgz#1a3c7726a61728112f50944ad7c35c06ae3a0d4d" + integrity sha512-cMddMgb2QElm8G7vdaa02jhUNbTSrhsgAGUz1OokD83uJTwSUn+nKoNoKVVaRa08yF6sgfO7Maou1+bgLd9rdQ== + +os-tmpdir@~1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274" + integrity sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g== + +p-limit@^1.1.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-1.3.0.tgz#b86bd5f0c25690911c7590fcbfc2010d54b3ccb8" + integrity sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q== + dependencies: + p-try "^1.0.0" + +p-limit@^3.0.2: + version "3.1.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" + integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== + dependencies: + yocto-queue "^0.1.0" + +p-locate@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-2.0.0.tgz#20a0103b222a70c8fd39cc2e580680f3dde5ec43" + integrity sha512-nQja7m7gSKuewoVRen45CtVfODR3crN3goVQ0DDZ9N3yHxgpkuBhZqsaiotSQRrADUrne346peY7kT3TSACykg== + dependencies: + p-limit "^1.1.0" + +p-locate@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834" + integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw== + dependencies: + p-limit "^3.0.2" + +p-map@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/p-map/-/p-map-4.0.0.tgz#bb2f95a5eda2ec168ec9274e06a747c3e2904d2b" + integrity sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ== + dependencies: + aggregate-error "^3.0.0" + +p-try@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/p-try/-/p-try-1.0.0.tgz#cbc79cdbaf8fd4228e13f621f2b1a237c1b207b3" + integrity sha512-U1etNYuMJoIz3ZXSrrySFjsXQTWOx2/jdi86L+2pRvph/qMKL6sbcCYdH23fqsbm8TH2Gn0OybpT4eSFlCVHww== + +parent-module@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2" + integrity sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g== + dependencies: + callsites "^3.0.0" + +parse-cache-control@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/parse-cache-control/-/parse-cache-control-1.0.1.tgz#8eeab3e54fa56920fe16ba38f77fa21aacc2d74e" + integrity sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg== + +parse-json@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-5.2.0.tgz#c76fc66dee54231c962b22bcc8a72cf2f99753cd" + integrity sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg== + dependencies: + "@babel/code-frame" "^7.0.0" + error-ex "^1.3.1" + json-parse-even-better-errors "^2.3.0" + lines-and-columns "^1.1.6" + +path-browserify@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/path-browserify/-/path-browserify-1.0.1.tgz#d98454a9c3753d5790860f16f68867b9e46be1fd" + integrity sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g== + +path-exists@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-3.0.0.tgz#ce0ebeaa5f78cb18925ea7d810d7b59b010fd515" + integrity sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ== + +path-exists@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" + integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== + +path-is-absolute@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" + integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg== + +path-key@^3.0.0, path-key@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" + integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== + +path-parse@^1.0.6, path-parse@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" + integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== + +path-scurry@^1.10.1: + version "1.10.1" + resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-1.10.1.tgz#9ba6bf5aa8500fe9fd67df4f0d9483b2b0bfc698" + integrity sha512-MkhCqzzBEpPvxxQ71Md0b1Kk51W01lrYvlMzSUaIzNsODdd7mqhiimSZlr+VegAz5Z6Vzt9Xg2ttE//XBhH3EQ== + dependencies: + lru-cache "^9.1.1 || ^10.0.0" + minipass "^5.0.0 || ^6.0.2 || ^7.0.0" + +path-to-regexp@^6.2.1: + version "6.2.2" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-6.2.2.tgz#324377a83e5049cbecadc5554d6a63a9a4866b36" + integrity sha512-GQX3SSMokngb36+whdpRXE+3f9V8UzyAorlYvOGx87ufGHehNTn5lCxrKtLyZ4Yl/wEKnNnr98ZzOwwDZV5ogw== + +path-type@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" + integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== + +pathval@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/pathval/-/pathval-1.1.1.tgz#8534e77a77ce7ac5a2512ea21e0fdb8fcf6c3d8d" + integrity sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ== + +pbkdf2@^3.0.17: + version "3.1.2" + resolved "https://registry.yarnpkg.com/pbkdf2/-/pbkdf2-3.1.2.tgz#dd822aa0887580e52f1a039dc3eda108efae3075" + integrity sha512-iuh7L6jA7JEGu2WxDwtQP1ddOpaJNC4KlDEFfdQajSGgGPNi4OyDc2R7QnbY2bR9QjBVGwgvTdNJZoE7RaxUMA== + dependencies: + create-hash "^1.1.2" + create-hmac "^1.1.4" + ripemd160 "^2.0.1" + safe-buffer "^5.0.1" + sha.js "^2.4.8" + +picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" + integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== + +pify@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/pify/-/pify-4.0.1.tgz#4b2cd25c50d598735c50292224fd8c6df41e3231" + integrity sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g== + +pirates@^4.0.1: + version "4.0.6" + resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.6.tgz#3018ae32ecfcff6c29ba2267cbf21166ac1f36b9" + integrity sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg== + +pluralize@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/pluralize/-/pluralize-8.0.0.tgz#1a6fa16a38d12a1901e0320fa017051c539ce3b1" + integrity sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA== + +possible-typed-array-names@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz#89bb63c6fada2c3e90adc4a647beeeb39cc7bf8f" + integrity sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q== + +postcss-load-config@^3.0.1: + version "3.1.4" + resolved "https://registry.yarnpkg.com/postcss-load-config/-/postcss-load-config-3.1.4.tgz#1ab2571faf84bb078877e1d07905eabe9ebda855" + integrity sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg== + dependencies: + lilconfig "^2.0.5" + yaml "^1.10.2" + +prelude-ls@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" + integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== + +prelude-ls@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" + integrity sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w== + +prettier-linter-helpers@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz#d23d41fe1375646de2d0104d3454a3008802cf7b" + integrity sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w== + dependencies: + fast-diff "^1.1.2" + +prettier-plugin-solidity@^1.2.0: + version "1.3.1" + resolved "https://registry.yarnpkg.com/prettier-plugin-solidity/-/prettier-plugin-solidity-1.3.1.tgz#59944d3155b249f7f234dee29f433524b9a4abcf" + integrity sha512-MN4OP5I2gHAzHZG1wcuJl0FsLS3c4Cc5494bbg+6oQWBPuEamjwDvmGfFMZ6NFzsh3Efd9UUxeT7ImgjNH4ozA== + dependencies: + "@solidity-parser/parser" "^0.17.0" + semver "^7.5.4" + solidity-comments-extractor "^0.0.8" + +prettier@^2.3.1, prettier@^2.8.3, prettier@^2.8.8: + version "2.8.8" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.8.8.tgz#e8c5d7e98a4305ffe3de2e1fc4aca1a71c28b1da" + integrity sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q== + +process-nextick-args@~2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" + integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== + +promise@^8.0.0: + version "8.3.0" + resolved "https://registry.yarnpkg.com/promise/-/promise-8.3.0.tgz#8cb333d1edeb61ef23869fbb8a4ea0279ab60e0a" + integrity sha512-rZPNPKTOYVNEEKFaq1HqTgOwZD+4/YHS5ukLzQCypkj+OkYx7iv0mA91lJlpPPZ8vMau3IIGj5Qlwrx+8iiSmg== + dependencies: + asap "~2.0.6" + +proper-lockfile@^4.1.1, proper-lockfile@^4.1.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/proper-lockfile/-/proper-lockfile-4.1.2.tgz#c8b9de2af6b2f1601067f98e01ac66baa223141f" + integrity sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA== + dependencies: + graceful-fs "^4.2.4" + retry "^0.12.0" + signal-exit "^3.0.2" + +proxy-from-env@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" + integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg== + +proxyquire@^2.1.3: + version "2.1.3" + resolved "https://registry.yarnpkg.com/proxyquire/-/proxyquire-2.1.3.tgz#2049a7eefa10a9a953346a18e54aab2b4268df39" + integrity sha512-BQWfCqYM+QINd+yawJz23tbBM40VIGXOdDw3X344KcclI/gtBbdWF6SlQ4nK/bYhF9d27KYug9WzljHC6B9Ysg== + dependencies: + fill-keys "^1.0.2" + module-not-found-error "^1.0.1" + resolve "^1.11.1" + +pump@^1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/pump/-/pump-1.0.3.tgz#5dfe8311c33bbf6fc18261f9f34702c47c08a954" + integrity sha512-8k0JupWme55+9tCVE+FS5ULT3K6AbgqrGa58lTT49RpyfwwcGedHqaC5LlQNdEAumn/wFsu6aPwkuPMioy8kqw== + dependencies: + end-of-stream "^1.1.0" + once "^1.3.1" + +pump@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64" + integrity sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww== + dependencies: + end-of-stream "^1.1.0" + once "^1.3.1" + +punycode@^2.1.0: + version "2.3.1" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" + integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== + +qs@^6.4.0: + version "6.12.1" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.12.1.tgz#39422111ca7cbdb70425541cba20c7d7b216599a" + integrity sha512-zWmv4RSuB9r2mYQw3zxQuHWeU+42aKi1wWig/j4ele4ygELZ7PEO6MM7rim9oAQH2A5MWfsAVf/jPvTPgCbvUQ== + dependencies: + side-channel "^1.0.6" + +queue-microtask@^1.2.2: + version "1.2.3" + resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" + integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== + +randombytes@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" + integrity sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ== + dependencies: + safe-buffer "^5.1.0" + +raw-body@^2.4.1: + version "2.5.2" + resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.2.tgz#99febd83b90e08975087e8f1f9419a149366b68a" + integrity sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA== + dependencies: + bytes "3.1.2" + http-errors "2.0.0" + iconv-lite "0.4.24" + unpipe "1.0.0" + +readable-stream@^2.2.2, readable-stream@^2.3.0, readable-stream@^2.3.5: + version "2.3.8" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.8.tgz#91125e8042bba1b9887f49345f6277027ce8be9b" + integrity sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA== + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.3" + isarray "~1.0.0" + process-nextick-args "~2.0.0" + safe-buffer "~5.1.1" + string_decoder "~1.1.1" + util-deprecate "~1.0.1" + +readable-stream@^3.1.1, readable-stream@^3.4.0, readable-stream@^3.5.0, readable-stream@^3.6.0: + version "3.6.2" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967" + integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA== + dependencies: + inherits "^2.0.3" + string_decoder "^1.1.1" + util-deprecate "^1.0.1" + +readable-stream@~1.0.26-4: + version "1.0.34" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.0.34.tgz#125820e34bc842d2f2aaafafe4c2916ee32c157c" + integrity sha512-ok1qVCJuRkNmvebYikljxJA/UEsKwLl2nI1OmaqAu4/UE+h0wKCHok4XkL/gvi39OacXvw59RJUOFUkDib2rHg== + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.1" + isarray "0.0.1" + string_decoder "~0.10.x" + +readdirp@~3.6.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" + integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA== + dependencies: + picomatch "^2.2.1" + +rechoir@^0.6.2: + version "0.6.2" + resolved "https://registry.yarnpkg.com/rechoir/-/rechoir-0.6.2.tgz#85204b54dba82d5742e28c96756ef43af50e3384" + integrity sha512-HFM8rkZ+i3zrV+4LQjwQ0W+ez98pApMGM3HUrN04j3CqzPOzl9nmP15Y8YXNm8QHGv/eacOVEjqhmWpkRV0NAw== + dependencies: + resolve "^1.1.6" + +recursive-readdir@^2.2.2: + version "2.2.3" + resolved "https://registry.yarnpkg.com/recursive-readdir/-/recursive-readdir-2.2.3.tgz#e726f328c0d69153bcabd5c322d3195252379372" + integrity sha512-8HrF5ZsXk5FAH9dgsx3BlUer73nIhuj+9OrQwEbLTPOBzGkL1lsFCR01am+v+0m2Cmbs1nP12hLDl5FA7EszKA== + dependencies: + minimatch "^3.0.5" + +reduce-flatten@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/reduce-flatten/-/reduce-flatten-2.0.0.tgz#734fd84e65f375d7ca4465c69798c25c9d10ae27" + integrity sha512-EJ4UNY/U1t2P/2k6oqotuX2Cc3T6nxJwsM0N0asT7dhrtH1ltUxDn4NalSYmPE2rCkVpcf/X6R0wDwcFpzhd4w== + +regexp.prototype.flags@^1.5.2: + version "1.5.2" + resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.5.2.tgz#138f644a3350f981a858c44f6bb1a61ff59be334" + integrity sha512-NcDiDkTLuPR+++OCKB0nWafEmhg/Da8aUPLPMQbK+bxKKCm1/S5he+AqYa4PlMCVBalb4/yxIRub6qkEx5yJbw== + dependencies: + call-bind "^1.0.6" + define-properties "^1.2.1" + es-errors "^1.3.0" + set-function-name "^2.0.1" + +req-cwd@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/req-cwd/-/req-cwd-2.0.0.tgz#d4082b4d44598036640fb73ddea01ed53db49ebc" + integrity sha512-ueoIoLo1OfB6b05COxAA9UpeoscNpYyM+BqYlA7H6LVF4hKGPXQQSSaD2YmvDVJMkk4UDpAHIeU1zG53IqjvlQ== + dependencies: + req-from "^2.0.0" + +req-from@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/req-from/-/req-from-2.0.0.tgz#d74188e47f93796f4aa71df6ee35ae689f3e0e70" + integrity sha512-LzTfEVDVQHBRfjOUMgNBA+V6DWsSnoeKzf42J7l0xa/B4jyPOuuF5MlNSmomLNGemWTnV2TIdjSSLnEn95fOQA== + dependencies: + resolve-from "^3.0.0" + +require-directory@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" + integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q== + +require-from-string@^2.0.0, require-from-string@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909" + integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== + +resolve-from@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-3.0.0.tgz#b22c7af7d9d6881bc8b6e653335eebcb0a188748" + integrity sha512-GnlH6vxLymXJNMBo7XP1fJIzBFbdYt49CuTwmB/6N53t+kMPRMFKz783LlQ4tv28XoQfMWinAJX6WCGf2IlaIw== + +resolve-from@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" + integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== + +resolve-from@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-5.0.0.tgz#c35225843df8f776df21c57557bc087e9dfdfc69" + integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw== + +resolve@1.1.x: + version "1.1.7" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.1.7.tgz#203114d82ad2c5ed9e8e0411b3932875e889e97b" + integrity sha512-9znBF0vBcaSN3W2j7wKvdERPwqTxSpCq+if5C0WoTCyV9n24rua28jeuQ2pL/HOf+yUe/Mef+H/5p60K0Id3bg== + +resolve@1.17.0: + version "1.17.0" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.17.0.tgz#b25941b54968231cc2d1bb76a79cb7f2c0bf8444" + integrity sha512-ic+7JYiV8Vi2yzQGFWOkiZD5Z9z7O2Zhm9XMaTxdJExKasieFCr+yXZ/WmXsckHiKl12ar0y6XiXDx3m4RHn1w== + dependencies: + path-parse "^1.0.6" + +resolve@^1.1.6, resolve@^1.11.1: + version "1.22.8" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.8.tgz#b6c87a9f2aa06dfab52e3d70ac8cde321fa5a48d" + integrity sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw== + dependencies: + is-core-module "^2.13.0" + path-parse "^1.0.7" + supports-preserve-symlinks-flag "^1.0.0" + +retry@^0.12.0: + version "0.12.0" + resolved "https://registry.yarnpkg.com/retry/-/retry-0.12.0.tgz#1b42a6266a21f07421d1b0b54b7dc167b01c013b" + integrity sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow== + +reusify@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" + integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw== + +rimraf@^2.2.8: + version "2.7.1" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec" + integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w== + dependencies: + glob "^7.1.3" + +rimraf@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" + integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== + dependencies: + glob "^7.1.3" + +ripemd160@^2.0.0, ripemd160@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/ripemd160/-/ripemd160-2.0.2.tgz#a1c1a6f624751577ba5d07914cbc92850585890c" + integrity sha512-ii4iagi25WusVoiC4B4lq7pbXfAp3D9v5CwfkY33vffw2+pkDjY1D8GaN7spsxvCSx8dkPqOZCEZyfxcmJG2IA== + dependencies: + hash-base "^3.0.0" + inherits "^2.0.1" + +rlp@^2.2.3, rlp@^2.2.4: + version "2.2.7" + resolved "https://registry.yarnpkg.com/rlp/-/rlp-2.2.7.tgz#33f31c4afac81124ac4b283e2bd4d9720b30beaf" + integrity sha512-d5gdPmgQ0Z+AklL2NVXr/IoSjNZFfTVvQWzL/AM2AOcSzYP2xjlb0AC8YyCLc41MSNf6P6QVtjgPdmVtzb+4lQ== + dependencies: + bn.js "^5.2.0" + +rollup@^2.74.1: + version "2.79.1" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.79.1.tgz#bedee8faef7c9f93a2647ac0108748f497f081c7" + integrity sha512-uKxbd0IhMZOhjAiD5oAFp7BqvkA4Dv47qpOCtaNvng4HBwdbWtdOh8f5nZNuk2rp51PMGk3bzfWu5oayNEuYnw== + optionalDependencies: + fsevents "~2.3.2" + +run-parallel@^1.1.9: + version "1.2.0" + resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee" + integrity sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA== + dependencies: + queue-microtask "^1.2.2" + +safe-array-concat@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/safe-array-concat/-/safe-array-concat-1.1.2.tgz#81d77ee0c4e8b863635227c721278dd524c20edb" + integrity sha512-vj6RsCsWBCf19jIeHEfkRMw8DPiBb+DMXklQ/1SGDHOMlHdPUkZXFQ2YdplS23zESTijAcurb1aSgJA3AgMu1Q== + dependencies: + call-bind "^1.0.7" + get-intrinsic "^1.2.4" + has-symbols "^1.0.3" + isarray "^2.0.5" + +safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@^5.2.0, safe-buffer@~5.2.0: + version "5.2.1" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" + integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== + +safe-buffer@~5.1.0, safe-buffer@~5.1.1: + version "5.1.2" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" + integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== + +safe-regex-test@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/safe-regex-test/-/safe-regex-test-1.0.3.tgz#a5b4c0f06e0ab50ea2c395c14d8371232924c377" + integrity sha512-CdASjNJPvRa7roO6Ra/gLYBTzYzzPyyBXxIMdGW3USQLyjWEls2RgW5UBTXaQVp+OrpeCK3bLem8smtmheoRuw== + dependencies: + call-bind "^1.0.6" + es-errors "^1.3.0" + is-regex "^1.1.4" + +"safer-buffer@>= 2.1.2 < 3", safer-buffer@~2.1.0: + version "2.1.2" + resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" + integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== + +sc-istanbul@^0.4.5: + version "0.4.6" + resolved "https://registry.yarnpkg.com/sc-istanbul/-/sc-istanbul-0.4.6.tgz#cf6784355ff2076f92d70d59047d71c13703e839" + integrity sha512-qJFF/8tW/zJsbyfh/iT/ZM5QNHE3CXxtLJbZsL+CzdJLBsPD7SedJZoUA4d8iAcN2IoMp/Dx80shOOd2x96X/g== + dependencies: + abbrev "1.0.x" + async "1.x" + escodegen "1.8.x" + esprima "2.7.x" + glob "^5.0.15" + handlebars "^4.0.1" + js-yaml "3.x" + mkdirp "0.5.x" + nopt "3.x" + once "1.x" + resolve "1.1.x" + supports-color "^3.1.0" + which "^1.1.1" + wordwrap "^1.0.0" + +scrypt-js@3.0.1, scrypt-js@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/scrypt-js/-/scrypt-js-3.0.1.tgz#d314a57c2aef69d1ad98a138a21fe9eafa9ee312" + integrity sha512-cdwTTnqPu0Hyvf5in5asVdZocVDTNRmR7XEcJuIzMjJeSHybHl7vpB66AzwTaIg6CLSbtjcxc8fqcySfnTkccA== + +secp256k1@^4.0.1: + version "4.0.3" + resolved "https://registry.yarnpkg.com/secp256k1/-/secp256k1-4.0.3.tgz#c4559ecd1b8d3c1827ed2d1b94190d69ce267303" + integrity sha512-NLZVf+ROMxwtEj3Xa562qgv2BK5e2WNmXPiOdVIPLgs6lyTzMvBq0aWTYMI5XCP9jZMVKOcqZLw/Wc4vDkuxhA== + dependencies: + elliptic "^6.5.4" + node-addon-api "^2.0.0" + node-gyp-build "^4.2.0" + +semver@^5.5.0: + version "5.7.2" + resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.2.tgz#48d55db737c3287cd4835e17fa13feace1c41ef8" + integrity sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g== + +semver@^6.3.0: + version "6.3.1" + resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" + integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== + +semver@^7.3.4, semver@^7.5.1: + version "7.6.1" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.1.tgz#60bfe090bf907a25aa8119a72b9f90ef7ca281b2" + integrity sha512-f/vbBsu+fOiYt+lmwZV0rVwJScl46HppnOA1ZvIuBWKOTlllpyJ3bfVax76/OrhCH38dyxoDIA8K7uB963IYgA== + +semver@^7.3.7, semver@^7.5.2, semver@^7.5.4: + version "7.5.4" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e" + integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA== + dependencies: + lru-cache "^6.0.0" + +serialize-javascript@6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.0.tgz#efae5d88f45d7924141da8b5c3a7a7e663fefeb8" + integrity sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag== + dependencies: + randombytes "^2.1.0" + +set-function-length@^1.2.1: + version "1.2.2" + resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449" + integrity sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg== + dependencies: + define-data-property "^1.1.4" + es-errors "^1.3.0" + function-bind "^1.1.2" + get-intrinsic "^1.2.4" + gopd "^1.0.1" + has-property-descriptors "^1.0.2" + +set-function-name@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/set-function-name/-/set-function-name-2.0.2.tgz#16a705c5a0dc2f5e638ca96d8a8cd4e1c2b90985" + integrity sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ== + dependencies: + define-data-property "^1.1.4" + es-errors "^1.3.0" + functions-have-names "^1.2.3" + has-property-descriptors "^1.0.2" + +setimmediate@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" + integrity sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA== + +setprototypeof@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" + integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== + +sha.js@^2.4.0, sha.js@^2.4.8: + version "2.4.11" + resolved "https://registry.yarnpkg.com/sha.js/-/sha.js-2.4.11.tgz#37a5cf0b81ecbc6943de109ba2960d1b26584ae7" + integrity sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ== + dependencies: + inherits "^2.0.1" + safe-buffer "^5.0.1" + +sha1@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/sha1/-/sha1-1.1.1.tgz#addaa7a93168f393f19eb2b15091618e2700f848" + integrity sha512-dZBS6OrMjtgVkopB1Gmo4RQCDKiZsqcpAQpkV/aaj+FCrCg8r4I4qMkDPQjBgLIxlmu9k4nUbWq6ohXahOneYA== + dependencies: + charenc ">= 0.0.1" + crypt ">= 0.0.1" + +shebang-command@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" + integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== + dependencies: + shebang-regex "^3.0.0" + +shebang-regex@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" + integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== + +shelljs@^0.8.3: + version "0.8.5" + resolved "https://registry.yarnpkg.com/shelljs/-/shelljs-0.8.5.tgz#de055408d8361bed66c669d2f000538ced8ee20c" + integrity sha512-TiwcRcrkhHvbrZbnRcFYMLl30Dfov3HKqzp5tO5b4pt6G/SezKcYhmDg15zXVBswHmctSAQKznqNW2LO5tTDow== + dependencies: + glob "^7.0.0" + interpret "^1.0.0" + rechoir "^0.6.2" + +side-channel@^1.0.4, side-channel@^1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.6.tgz#abd25fb7cd24baf45466406b1096b7831c9215f2" + integrity sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA== + dependencies: + call-bind "^1.0.7" + es-errors "^1.3.0" + get-intrinsic "^1.2.4" + object-inspect "^1.13.1" + +signal-exit@^3.0.2, signal-exit@^3.0.3: + version "3.0.7" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" + integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== + +signal-exit@^4.0.1: + version "4.1.0" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04" + integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== + +sinon-chai@^3.7.0: + version "3.7.0" + resolved "https://registry.yarnpkg.com/sinon-chai/-/sinon-chai-3.7.0.tgz#cfb7dec1c50990ed18c153f1840721cf13139783" + integrity sha512-mf5NURdUaSdnatJx3uhoBOrY9dtL19fiOtAdT1Azxg3+lNJFiuN0uzaU3xX1LeAfL17kHQhTAJgpsfhbMJMY2g== + +sinon@^17.0.1: + version "17.0.2" + resolved "https://registry.yarnpkg.com/sinon/-/sinon-17.0.2.tgz#470894bcc2d24b01bad539722ea46da949892405" + integrity sha512-uihLiaB9FhzesElPDFZA7hDcNABzsVHwr3YfmM9sBllVwab3l0ltGlRV1XhpNfIacNDLGD1QRZNLs5nU5+hTuA== + dependencies: + "@sinonjs/commons" "^3.0.1" + "@sinonjs/fake-timers" "^11.2.2" + "@sinonjs/samsam" "^8.0.0" + diff "^5.2.0" + nise "^5.1.9" + supports-color "^7" + +slash@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" + integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q== + +slice-ansi@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-4.0.0.tgz#500e8dd0fd55b05815086255b3195adf2a45fe6b" + integrity sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ== + dependencies: + ansi-styles "^4.0.0" + astral-regex "^2.0.0" + is-fullwidth-code-point "^3.0.0" + +solady@0.0.180: + version "0.0.180" + resolved "https://registry.yarnpkg.com/solady/-/solady-0.0.180.tgz#d806c84a0bf8b6e3d85a8fb0978980de086ff59e" + integrity sha512-9QVCyMph+wk78Aq/GxtDAQg7dvNoVWx2dS2Zwf11XlwFKDZ+YJG2lrQsK9NEIth9NOebwjBXAYk4itdwOOE4aw== + +solc@0.7.3: + version "0.7.3" + resolved "https://registry.yarnpkg.com/solc/-/solc-0.7.3.tgz#04646961bd867a744f63d2b4e3c0701ffdc7d78a" + integrity sha512-GAsWNAjGzIDg7VxzP6mPjdurby3IkGCjQcM8GFYZT6RyaoUZKmMU6Y7YwG+tFGhv7dwZ8rmR4iwFDrrD99JwqA== + dependencies: + command-exists "^1.2.8" + commander "3.0.2" + follow-redirects "^1.12.1" + fs-extra "^0.30.0" + js-sha3 "0.8.0" + memorystream "^0.3.1" + require-from-string "^2.0.0" + semver "^5.5.0" + tmp "0.0.33" + +solhint-plugin-prettier@^0.0.5: + version "0.0.5" + resolved "https://registry.yarnpkg.com/solhint-plugin-prettier/-/solhint-plugin-prettier-0.0.5.tgz#e3b22800ba435cd640a9eca805a7f8bc3e3e6a6b" + integrity sha512-7jmWcnVshIrO2FFinIvDQmhQpfpS2rRRn3RejiYgnjIE68xO2bvrYvjqVNfrio4xH9ghOqn83tKuTzLjEbmGIA== + dependencies: + prettier-linter-helpers "^1.0.0" + +solhint@^3.6.2: + version "3.6.2" + resolved "https://registry.yarnpkg.com/solhint/-/solhint-3.6.2.tgz#2b2acbec8fdc37b2c68206a71ba89c7f519943fe" + integrity sha512-85EeLbmkcPwD+3JR7aEMKsVC9YrRSxd4qkXuMzrlf7+z2Eqdfm1wHWq1ffTuo5aDhoZxp2I9yF3QkxZOxOL7aQ== + dependencies: + "@solidity-parser/parser" "^0.16.0" + ajv "^6.12.6" + antlr4 "^4.11.0" + ast-parents "^0.0.1" + chalk "^4.1.2" + commander "^10.0.0" + cosmiconfig "^8.0.0" + fast-diff "^1.2.0" + glob "^8.0.3" + ignore "^5.2.4" + js-yaml "^4.1.0" + lodash "^4.17.21" + pluralize "^8.0.0" + semver "^7.5.2" + strip-ansi "^6.0.1" + table "^6.8.1" + text-table "^0.2.0" + optionalDependencies: + prettier "^2.8.3" + +solidity-ast@^0.4.15, solidity-ast@^0.4.51: + version "0.4.56" + resolved "https://registry.yarnpkg.com/solidity-ast/-/solidity-ast-0.4.56.tgz#94fe296f12e8de1a3bed319bc06db8d05a113d7a" + integrity sha512-HgmsA/Gfklm/M8GFbCX/J1qkVH0spXHgALCNZ8fA8x5X+MFdn/8CP2gr5OVyXjXw6RZTPC/Sxl2RUDQOXyNMeA== + dependencies: + array.prototype.findlast "^1.2.2" + +"solidity-ast@npm:solidity-ast@0.4.45": + version "0.4.45" + resolved "https://registry.yarnpkg.com/solidity-ast/-/solidity-ast-0.4.45.tgz#37c1c17bd79123106fc69d94b4a8e9237ae8c625" + integrity sha512-N6uqfaDulVZqjpjru+KvMLjV89M3hesyr/1/t8nkjohRagFSDmDxZvb9viKV98pdwpMzs61Nt2JAApgh0fkL0g== + +solidity-comments-extractor@^0.0.8: + version "0.0.8" + resolved "https://registry.yarnpkg.com/solidity-comments-extractor/-/solidity-comments-extractor-0.0.8.tgz#f6e148ab0c49f30c1abcbecb8b8df01ed8e879f8" + integrity sha512-htM7Vn6LhHreR+EglVMd2s+sZhcXAirB1Zlyrv5zBuTxieCvjfnRpd7iZk75m/u6NOlEyQ94C6TWbBn2cY7w8g== + +solidity-coverage@^0.8.1: + version "0.8.12" + resolved "https://registry.yarnpkg.com/solidity-coverage/-/solidity-coverage-0.8.12.tgz#c4fa2f64eff8ada7a1387b235d6b5b0e6c6985ed" + integrity sha512-8cOB1PtjnjFRqOgwFiD8DaUsYJtVJ6+YdXQtSZDrLGf8cdhhh8xzTtGzVTGeBf15kTv0v7lYPJlV/az7zLEPJw== + dependencies: + "@ethersproject/abi" "^5.0.9" + "@solidity-parser/parser" "^0.18.0" + chalk "^2.4.2" + death "^1.1.0" + difflib "^0.2.4" + fs-extra "^8.1.0" + ghost-testrpc "^0.0.2" + global-modules "^2.0.0" + globby "^10.0.1" + jsonschema "^1.2.4" + lodash "^4.17.21" + mocha "^10.2.0" + node-emoji "^1.10.0" + pify "^4.0.1" + recursive-readdir "^2.2.2" + sc-istanbul "^0.4.5" + semver "^7.3.4" + shelljs "^0.8.3" + web3-utils "^1.3.6" + +source-map-support@^0.5.13: + version "0.5.21" + resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f" + integrity sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w== + dependencies: + buffer-from "^1.0.0" + source-map "^0.6.0" + +source-map@0.8.0-beta.0: + version "0.8.0-beta.0" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.8.0-beta.0.tgz#d4c1bb42c3f7ee925f005927ba10709e0d1d1f11" + integrity sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA== + dependencies: + whatwg-url "^7.0.0" + +source-map@^0.6.0, source-map@^0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" + integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== + +source-map@~0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.2.0.tgz#dab73fbcfc2ba819b4de03bd6f6eaa48164b3f9d" + integrity sha512-CBdZ2oa/BHhS4xj5DlhjWNHcan57/5YuvfdLf17iVmIpd9KRm+DFLmC6nBNj+6Ua7Kt3TmOjDpQT1aTYOQtoUA== + dependencies: + amdefine ">=0.0.4" + +split-ca@^1.0.0, split-ca@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/split-ca/-/split-ca-1.0.1.tgz#6c83aff3692fa61256e0cd197e05e9de157691a6" + integrity sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ== + +sprintf-js@~1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" + integrity sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g== + +ssh2@^1.11.0, ssh2@^1.15.0: + version "1.15.0" + resolved "https://registry.yarnpkg.com/ssh2/-/ssh2-1.15.0.tgz#2f998455036a7f89e0df5847efb5421748d9871b" + integrity sha512-C0PHgX4h6lBxYx7hcXwu3QWdh4tg6tZZsTfXcdvc5caW/EMxaB4H9dWsl7qk+F7LAW762hp8VbXOX7x4xUYvEw== + dependencies: + asn1 "^0.2.6" + bcrypt-pbkdf "^1.0.2" + optionalDependencies: + cpu-features "~0.0.9" + nan "^2.18.0" + +stacktrace-parser@^0.1.10: + version "0.1.10" + resolved "https://registry.yarnpkg.com/stacktrace-parser/-/stacktrace-parser-0.1.10.tgz#29fb0cae4e0d0b85155879402857a1639eb6051a" + integrity sha512-KJP1OCML99+8fhOHxwwzyWrlUuVX5GQ0ZpJTd1DFXhdkrvg1szxfHhawXUZ3g9TkXORQd4/WG68jMlQZ2p8wlg== + dependencies: + type-fest "^0.7.1" + +statuses@2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63" + integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== + +string-format@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/string-format/-/string-format-2.0.0.tgz#f2df2e7097440d3b65de31b6d40d54c96eaffb9b" + integrity sha512-bbEs3scLeYNXLecRRuk6uJxdXUSj6le/8rNPHChIJTn2V79aXVTR1EH2OH5zLKKoz0V02fOUKZZcw01pLUShZA== + +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3: + name string-width-cjs + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e" + integrity sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw== + dependencies: + is-fullwidth-code-point "^2.0.0" + strip-ansi "^4.0.0" + +string-width@^5.0.1, string-width@^5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" + integrity sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA== + dependencies: + eastasianwidth "^0.2.0" + emoji-regex "^9.2.2" + strip-ansi "^7.0.1" + +string.prototype.trim@^1.2.9: + version "1.2.9" + resolved "https://registry.yarnpkg.com/string.prototype.trim/-/string.prototype.trim-1.2.9.tgz#b6fa326d72d2c78b6df02f7759c73f8f6274faa4" + integrity sha512-klHuCNxiMZ8MlsOihJhJEBJAiMVqU3Z2nEXWfWnIqjN0gEFS9J9+IxKozWWtQGcgoa1WUZzLjKPTr4ZHNFTFxw== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + es-abstract "^1.23.0" + es-object-atoms "^1.0.0" + +string.prototype.trimend@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.8.tgz#3651b8513719e8a9f48de7f2f77640b26652b229" + integrity sha512-p73uL5VCHCO2BZZ6krwwQE3kCzM7NKmis8S//xEC6fQonchbum4eP6kR4DLEjQFO3Wnj3Fuo8NM0kOSjVdHjZQ== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + es-object-atoms "^1.0.0" + +string.prototype.trimstart@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz#7ee834dda8c7c17eff3118472bb35bfedaa34dde" + integrity sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + es-object-atoms "^1.0.0" + +string_decoder@^1.1.1: + version "1.3.0" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" + integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== + dependencies: + safe-buffer "~5.2.0" + +string_decoder@~0.10.x: + version "0.10.31" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-0.10.31.tgz#62e203bc41766c6c28c9fc84301dab1c5310fa94" + integrity sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ== + +string_decoder@~1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8" + integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== + dependencies: + safe-buffer "~5.1.0" + +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: + name strip-ansi-cjs + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-4.0.0.tgz#a8479022eb1ac368a871389b635262c505ee368f" + integrity sha512-4XaJ2zQdCzROZDivEVIDPkcQn8LMFSa8kj8Gxb/Lnwzv9A8VctNZ+lfivC/sV3ivW8ElJTERXZoPBRrZKkNKow== + dependencies: + ansi-regex "^3.0.0" + +strip-ansi@^7.0.1: + version "7.1.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" + integrity sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ== + dependencies: + ansi-regex "^6.0.1" + +strip-final-newline@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz#89b852fb2fcbe936f6f4b3187afb0a12c1ab58ad" + integrity sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA== + +strip-hex-prefix@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/strip-hex-prefix/-/strip-hex-prefix-1.0.0.tgz#0c5f155fef1151373377de9dbb588da05500e36f" + integrity sha512-q8d4ue7JGEiVcypji1bALTos+0pWtyGlivAWyPuTkHzuTCJqrK9sWxYQZUq6Nq3cuyv3bm734IhHvHtGGURU6A== + dependencies: + is-hex-prefixed "1.0.0" + +strip-json-comments@3.1.1, strip-json-comments@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" + integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== + +sucrase@^3.20.3: + version "3.35.0" + resolved "https://registry.yarnpkg.com/sucrase/-/sucrase-3.35.0.tgz#57f17a3d7e19b36d8995f06679d121be914ae263" + integrity sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA== + dependencies: + "@jridgewell/gen-mapping" "^0.3.2" + commander "^4.0.0" + glob "^10.3.10" + lines-and-columns "^1.1.6" + mz "^2.7.0" + pirates "^4.0.1" + ts-interface-checker "^0.1.9" + +supports-color@8.1.1: + version "8.1.1" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c" + integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== + dependencies: + has-flag "^4.0.0" + +supports-color@^3.1.0: + version "3.2.3" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-3.2.3.tgz#65ac0504b3954171d8a64946b2ae3cbb8a5f54f6" + integrity sha512-Jds2VIYDrlp5ui7t8abHN2bjAu4LV/q4N2KivFPpGH0lrka0BMq/33AmECUXlKPcHigkNaqfXRENFju+rlcy+A== + dependencies: + has-flag "^1.0.0" + +supports-color@^5.3.0: + version "5.5.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" + integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== + dependencies: + has-flag "^3.0.0" + +supports-color@^7, supports-color@^7.1.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" + integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== + dependencies: + has-flag "^4.0.0" + +supports-preserve-symlinks-flag@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" + integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== + +sync-request@^6.0.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/sync-request/-/sync-request-6.1.0.tgz#e96217565b5e50bbffe179868ba75532fb597e68" + integrity sha512-8fjNkrNlNCrVc/av+Jn+xxqfCjYaBoHqCsDz6mt030UMxJGr+GSfCV1dQt2gRtlL63+VPidwDVLr7V2OcTSdRw== + dependencies: + http-response-object "^3.0.1" + sync-rpc "^1.2.1" + then-request "^6.0.0" + +sync-rpc@^1.2.1: + version "1.3.6" + resolved "https://registry.yarnpkg.com/sync-rpc/-/sync-rpc-1.3.6.tgz#b2e8b2550a12ccbc71df8644810529deb68665a7" + integrity sha512-J8jTXuZzRlvU7HemDgHi3pGnh/rkoqR/OZSjhTyyZrEkkYQbk7Z33AXp37mkPfPpfdOuj7Ex3H/TJM1z48uPQw== + dependencies: + get-port "^3.1.0" + +table-layout@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/table-layout/-/table-layout-1.0.2.tgz#c4038a1853b0136d63365a734b6931cf4fad4a04" + integrity sha512-qd/R7n5rQTRFi+Zf2sk5XVVd9UQl6ZkduPFC3S7WEGJAmetDTjY3qPN50eSKzwuzEyQKy5TN2TiZdkIjos2L6A== + dependencies: + array-back "^4.0.1" + deep-extend "~0.6.0" + typical "^5.2.0" + wordwrapjs "^4.0.0" + +table@^6.8.0: + version "6.8.2" + resolved "https://registry.yarnpkg.com/table/-/table-6.8.2.tgz#c5504ccf201213fa227248bdc8c5569716ac6c58" + integrity sha512-w2sfv80nrAh2VCbqR5AK27wswXhqcck2AhfnNW76beQXskGZ1V12GwS//yYVa3d3fcvAip2OUnbDAjW2k3v9fA== + dependencies: + ajv "^8.0.1" + lodash.truncate "^4.4.2" + slice-ansi "^4.0.0" + string-width "^4.2.3" + strip-ansi "^6.0.1" + +table@^6.8.1: + version "6.8.1" + resolved "https://registry.yarnpkg.com/table/-/table-6.8.1.tgz#ea2b71359fe03b017a5fbc296204471158080bdf" + integrity sha512-Y4X9zqrCftUhMeH2EptSSERdVKt/nEdijTOacGD/97EKjhQ/Qs8RTlEGABSJNNN8lac9kheH+af7yAkEWlgneA== + dependencies: + ajv "^8.0.1" + lodash.truncate "^4.4.2" + slice-ansi "^4.0.0" + string-width "^4.2.3" + strip-ansi "^6.0.1" + +tar-fs@~1.16.3: + version "1.16.3" + resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-1.16.3.tgz#966a628841da2c4010406a82167cbd5e0c72d509" + integrity sha512-NvCeXpYx7OsmOh8zIOP/ebG55zZmxLE0etfWRbWok+q2Qo8x/vOR/IJT1taADXPe+jsiu9axDb3X4B+iIgNlKw== + dependencies: + chownr "^1.0.1" + mkdirp "^0.5.1" + pump "^1.0.0" + tar-stream "^1.1.2" + +tar-fs@~2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-2.0.1.tgz#e44086c1c60d31a4f0cf893b1c4e155dabfae9e2" + integrity sha512-6tzWDMeroL87uF/+lin46k+Q+46rAJ0SyPGz7OW7wTgblI273hsBqk2C1j0/xNadNLKDTUL9BukSjB7cwgmlPA== + dependencies: + chownr "^1.1.1" + mkdirp-classic "^0.5.2" + pump "^3.0.0" + tar-stream "^2.0.0" + +tar-stream@^1.1.2: + version "1.6.2" + resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-1.6.2.tgz#8ea55dab37972253d9a9af90fdcd559ae435c555" + integrity sha512-rzS0heiNf8Xn7/mpdSVVSMAWAoy9bfb1WOTYC78Z0UQKeKa/CWS8FOq0lKGNa8DWKAn9gxjCvMLYc5PGXYlK2A== + dependencies: + bl "^1.0.0" + buffer-alloc "^1.2.0" + end-of-stream "^1.0.0" + fs-constants "^1.0.0" + readable-stream "^2.3.0" + to-buffer "^1.1.1" + xtend "^4.0.0" + +tar-stream@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-2.2.0.tgz#acad84c284136b060dc3faa64474aa9aebd77287" + integrity sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ== + dependencies: + bl "^4.0.3" + end-of-stream "^1.4.1" + fs-constants "^1.0.0" + inherits "^2.0.3" + readable-stream "^3.1.1" + +text-table@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" + integrity sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw== + +then-request@^6.0.0: + version "6.0.2" + resolved "https://registry.yarnpkg.com/then-request/-/then-request-6.0.2.tgz#ec18dd8b5ca43aaee5cb92f7e4c1630e950d4f0c" + integrity sha512-3ZBiG7JvP3wbDzA9iNY5zJQcHL4jn/0BWtXIkagfz7QgOL/LqjCEOBQuJNZfu0XYnv5JhKh+cDxCPM4ILrqruA== + dependencies: + "@types/concat-stream" "^1.6.0" + "@types/form-data" "0.0.33" + "@types/node" "^8.0.0" + "@types/qs" "^6.2.31" + caseless "~0.12.0" + concat-stream "^1.6.0" + form-data "^2.2.0" + http-basic "^8.1.1" + http-response-object "^3.0.1" + promise "^8.0.0" + qs "^6.4.0" + +thenify-all@^1.0.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/thenify-all/-/thenify-all-1.6.0.tgz#1a1918d402d8fc3f98fbf234db0bcc8cc10e9726" + integrity sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA== + dependencies: + thenify ">= 3.1.0 < 4" + +"thenify@>= 3.1.0 < 4": + version "3.3.1" + resolved "https://registry.yarnpkg.com/thenify/-/thenify-3.3.1.tgz#8932e686a4066038a016dd9e2ca46add9838a95f" + integrity sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw== + dependencies: + any-promise "^1.0.0" + +"through@>=2.2.7 <3": + version "2.3.8" + resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" + integrity sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg== + +tmp@0.0.33: + version "0.0.33" + resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9" + integrity sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw== + dependencies: + os-tmpdir "~1.0.2" + +to-buffer@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/to-buffer/-/to-buffer-1.1.1.tgz#493bd48f62d7c43fcded313a03dcadb2e1213a80" + integrity sha512-lx9B5iv7msuFYE3dytT+KE5tap+rNYw+K4jVkb9R/asAb+pbBSM17jtunHplhBe6RRJdZx3Pn2Jph24O32mOVg== + +to-regex-range@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" + integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== + dependencies: + is-number "^7.0.0" + +toidentifier@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" + integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== + +tr46@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-1.0.1.tgz#a8b13fd6bfd2489519674ccde55ba3693b706d09" + integrity sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA== + dependencies: + punycode "^2.1.0" + +tr46@~0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" + integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== + +tree-kill@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/tree-kill/-/tree-kill-1.2.2.tgz#4ca09a9092c88b73a7cdc5e8a01b507b0790a0cc" + integrity sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A== + +treeify@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/treeify/-/treeify-1.1.0.tgz#4e31c6a463accd0943879f30667c4fdaff411bb8" + integrity sha512-1m4RA7xVAJrSGrrXGs0L3YTwyvBs2S8PbRHaLZAkFw7JR8oIFwYtysxlBZhYIa7xSyiYJKZ3iGrrk55cGA3i9A== + +ts-command-line-args@^2.2.0: + version "2.5.1" + resolved "https://registry.yarnpkg.com/ts-command-line-args/-/ts-command-line-args-2.5.1.tgz#e64456b580d1d4f6d948824c274cf6fa5f45f7f0" + integrity sha512-H69ZwTw3rFHb5WYpQya40YAX2/w7Ut75uUECbgBIsLmM+BNuYnxsltfyyLMxy6sEeKxgijLTnQtLd0nKd6+IYw== + dependencies: + chalk "^4.1.0" + command-line-args "^5.1.1" + command-line-usage "^6.1.0" + string-format "^2.0.0" + +ts-essentials@^7.0.1: + version "7.0.3" + resolved "https://registry.yarnpkg.com/ts-essentials/-/ts-essentials-7.0.3.tgz#686fd155a02133eedcc5362dc8b5056cde3e5a38" + integrity sha512-8+gr5+lqO3G84KdiTSMRLtuyJ+nTBVRKuCrK4lidMPdVeEp0uqC875uE5NMcaA7YYMN7XsNiFQuMvasF8HT/xQ== + +ts-interface-checker@^0.1.9: + version "0.1.13" + resolved "https://registry.yarnpkg.com/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz#784fd3d679722bc103b1b4b8030bcddb5db2a699" + integrity sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA== + +ts-morph@^21.0.1: + version "21.0.1" + resolved "https://registry.yarnpkg.com/ts-morph/-/ts-morph-21.0.1.tgz#712302a0f6e9dbf1aa8d9cf33a4386c4b18c2006" + integrity sha512-dbDtVdEAncKctzrVZ+Nr7kHpHkv+0JDJb2MjjpBaj8bFeCkePU9rHfMklmhuLFnpeq/EJZk2IhStY6NzqgjOkg== + dependencies: + "@ts-morph/common" "~0.22.0" + code-block-writer "^12.0.0" + +ts-node@^10.9.1: + version "10.9.2" + resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-10.9.2.tgz#70f021c9e185bccdca820e26dc413805c101c71f" + integrity sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ== + dependencies: + "@cspotcode/source-map-support" "^0.8.0" + "@tsconfig/node10" "^1.0.7" + "@tsconfig/node12" "^1.0.7" + "@tsconfig/node14" "^1.0.0" + "@tsconfig/node16" "^1.0.2" + acorn "^8.4.1" + acorn-walk "^8.1.1" + arg "^4.1.0" + create-require "^1.1.0" + diff "^4.0.1" + make-error "^1.1.1" + v8-compile-cache-lib "^3.0.1" + yn "3.1.1" + +tslib@^1.8.1, tslib@^1.9.3: + version "1.14.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" + integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== + +tslib@^2.6.2: + version "2.6.2" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae" + integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q== + +tsort@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/tsort/-/tsort-0.0.1.tgz#e2280f5e817f8bf4275657fd0f9aebd44f5a2786" + integrity sha512-Tyrf5mxF8Ofs1tNoxA13lFeZ2Zrbd6cKbuH3V+MQ5sb6DtBj5FjrXVsRWT8YvNAQTqNoz66dz1WsbigI22aEnw== + +tsup@^5.12.9: + version "5.12.9" + resolved "https://registry.yarnpkg.com/tsup/-/tsup-5.12.9.tgz#8cdd9b4bc6493317cb92edf5f3476920dddcdb18" + integrity sha512-dUpuouWZYe40lLufo64qEhDpIDsWhRbr2expv5dHEMjwqeKJS2aXA/FPqs1dxO4T6mBojo7rvo3jP9NNzaKyDg== + dependencies: + bundle-require "^3.0.2" + cac "^6.7.12" + chokidar "^3.5.1" + debug "^4.3.1" + esbuild "^0.14.25" + execa "^5.0.0" + globby "^11.0.3" + joycon "^3.0.1" + postcss-load-config "^3.0.1" + resolve-from "^5.0.0" + rollup "^2.74.1" + source-map "0.8.0-beta.0" + sucrase "^3.20.3" + tree-kill "^1.2.2" + +tsutils@^3.21.0: + version "3.21.0" + resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623" + integrity sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA== + dependencies: + tslib "^1.8.1" + +tweetnacl-util@^0.15.1: + version "0.15.1" + resolved "https://registry.yarnpkg.com/tweetnacl-util/-/tweetnacl-util-0.15.1.tgz#b80fcdb5c97bcc508be18c44a4be50f022eea00b" + integrity sha512-RKJBIj8lySrShN4w6i/BonWp2Z/uxwC3h4y7xsRrpP59ZboCd0GpEVsOnMDYLMmKBpYhb5TgHzZXy7wTfYFBRw== + +tweetnacl@^0.14.3: + version "0.14.5" + resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64" + integrity sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA== + +tweetnacl@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-1.0.3.tgz#ac0af71680458d8a6378d0d0d050ab1407d35596" + integrity sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw== + +type-check@^0.4.0, type-check@~0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1" + integrity sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew== + dependencies: + prelude-ls "^1.2.1" + +type-check@~0.3.2: + version "0.3.2" + resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.3.2.tgz#5884cab512cf1d355e3fb784f30804b2b520db72" + integrity sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg== + dependencies: + prelude-ls "~1.1.2" + +type-detect@4.0.8, type-detect@^4.0.0, type-detect@^4.0.8: + version "4.0.8" + resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c" + integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g== + +type-fest@^0.20.2: + version "0.20.2" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4" + integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ== + +type-fest@^0.21.3: + version "0.21.3" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.21.3.tgz#d260a24b0198436e133fa26a524a6d65fa3b2e37" + integrity sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w== + +type-fest@^0.7.1: + version "0.7.1" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.7.1.tgz#8dda65feaf03ed78f0a3f9678f1869147f7c5c48" + integrity sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg== + +typechain@^8.3.2: + version "8.3.2" + resolved "https://registry.yarnpkg.com/typechain/-/typechain-8.3.2.tgz#1090dd8d9c57b6ef2aed3640a516bdbf01b00d73" + integrity sha512-x/sQYr5w9K7yv3es7jo4KTX05CLxOf7TRWwoHlrjRh8H82G64g+k7VuWPJlgMo6qrjfCulOdfBjiaDtmhFYD/Q== + dependencies: + "@types/prettier" "^2.1.1" + debug "^4.3.1" + fs-extra "^7.0.0" + glob "7.1.7" + js-sha3 "^0.8.0" + lodash "^4.17.15" + mkdirp "^1.0.4" + prettier "^2.3.1" + ts-command-line-args "^2.2.0" + ts-essentials "^7.0.1" + +typed-array-buffer@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/typed-array-buffer/-/typed-array-buffer-1.0.2.tgz#1867c5d83b20fcb5ccf32649e5e2fc7424474ff3" + integrity sha512-gEymJYKZtKXzzBzM4jqa9w6Q1Jjm7x2d+sh19AdsD4wqnMPDYyvwpsIc2Q/835kHuo3BEQ7CjelGhfTsoBb2MQ== + dependencies: + call-bind "^1.0.7" + es-errors "^1.3.0" + is-typed-array "^1.1.13" + +typed-array-byte-length@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/typed-array-byte-length/-/typed-array-byte-length-1.0.1.tgz#d92972d3cff99a3fa2e765a28fcdc0f1d89dec67" + integrity sha512-3iMJ9q0ao7WE9tWcaYKIptkNBuOIcZCCT0d4MRvuuH88fEoEH62IuQe0OtraD3ebQEoTRk8XCBoknUNc1Y67pw== + dependencies: + call-bind "^1.0.7" + for-each "^0.3.3" + gopd "^1.0.1" + has-proto "^1.0.3" + is-typed-array "^1.1.13" + +typed-array-byte-offset@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/typed-array-byte-offset/-/typed-array-byte-offset-1.0.2.tgz#f9ec1acb9259f395093e4567eb3c28a580d02063" + integrity sha512-Ous0vodHa56FviZucS2E63zkgtgrACj7omjwd/8lTEMEPFFyjfixMZ1ZXenpgCFBBt4EC1J2XsyVS2gkG0eTFA== + dependencies: + available-typed-arrays "^1.0.7" + call-bind "^1.0.7" + for-each "^0.3.3" + gopd "^1.0.1" + has-proto "^1.0.3" + is-typed-array "^1.1.13" + +typed-array-length@^1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/typed-array-length/-/typed-array-length-1.0.6.tgz#57155207c76e64a3457482dfdc1c9d1d3c4c73a3" + integrity sha512-/OxDN6OtAk5KBpGb28T+HZc2M+ADtvRxXrKKbUwtsLgdoxgX13hyy7ek6bFRl5+aBs2yZzB0c4CnQfAtVypW/g== + dependencies: + call-bind "^1.0.7" + for-each "^0.3.3" + gopd "^1.0.1" + has-proto "^1.0.3" + is-typed-array "^1.1.13" + possible-typed-array-names "^1.0.0" + +typedarray@^0.0.6: + version "0.0.6" + resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" + integrity sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA== + +typescript@^4.9.5: + version "4.9.5" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.5.tgz#095979f9bcc0d09da324d58d03ce8f8374cbe65a" + integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g== + +typescript@^5.3.2: + version "5.3.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.3.3.tgz#b3ce6ba258e72e6305ba66f5c9b452aaee3ffe37" + integrity sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw== + +typical@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/typical/-/typical-4.0.0.tgz#cbeaff3b9d7ae1e2bbfaf5a4e6f11eccfde94fc4" + integrity sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw== + +typical@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/typical/-/typical-5.2.0.tgz#4daaac4f2b5315460804f0acf6cb69c52bb93066" + integrity sha512-dvdQgNDNJo+8B2uBQoqdb11eUCE1JQXhvjC/CZtgvZseVd5TYMXnq0+vuUemXbd/Se29cTaUuPX3YIc2xgbvIg== + +uglify-js@^3.1.4: + version "3.17.4" + resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.17.4.tgz#61678cf5fa3f5b7eb789bb345df29afb8257c22c" + integrity sha512-T9q82TJI9e/C1TAxYvfb16xO120tMVFZrGA3f9/P4424DNu6ypK103y0GPFVa17yotwSyZW5iYXgjYHkGrJW/g== + +unbox-primitive@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.2.tgz#29032021057d5e6cdbd08c5129c226dff8ed6f9e" + integrity sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw== + dependencies: + call-bind "^1.0.2" + has-bigints "^1.0.2" + has-symbols "^1.0.3" + which-boxed-primitive "^1.0.2" + +undici-types@~5.26.4: + version "5.26.5" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617" + integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA== + +undici@^5.14.0: + version "5.28.4" + resolved "https://registry.yarnpkg.com/undici/-/undici-5.28.4.tgz#6b280408edb6a1a604a9b20340f45b422e373068" + integrity sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g== + dependencies: + "@fastify/busboy" "^2.0.0" + +universalify@^0.1.0: + version "0.1.2" + resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66" + integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg== + +universalify@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.1.tgz#168efc2180964e6386d061e094df61afe239b18d" + integrity sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw== + +unpipe@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" + integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ== + +uri-js@^4.2.2: + version "4.4.1" + resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e" + integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg== + dependencies: + punycode "^2.1.0" + +utf8@3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/utf8/-/utf8-3.0.0.tgz#f052eed1364d696e769ef058b183df88c87f69d1" + integrity sha512-E8VjFIQ/TyQgp+TZfS6l8yp/xWppSAHzidGiRrqe4bK4XP9pTRyKFgGJpO3SN7zdX4DeomTrwaseCHovfpFcqQ== + +util-deprecate@^1.0.1, util-deprecate@~1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" + integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== + +uuid@^8.3.2: + version "8.3.2" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" + integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== + +v8-compile-cache-lib@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz#6336e8d71965cb3d35a1bbb7868445a7c05264bf" + integrity sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg== + +web3-utils@^1.3.6: + version "1.10.4" + resolved "https://registry.yarnpkg.com/web3-utils/-/web3-utils-1.10.4.tgz#0daee7d6841641655d8b3726baf33b08eda1cbec" + integrity sha512-tsu8FiKJLk2PzhDl9fXbGUWTkkVXYhtTA+SmEFkKft+9BgwLxfCRpU96sWv7ICC8zixBNd3JURVoiR3dUXgP8A== + dependencies: + "@ethereumjs/util" "^8.1.0" + bn.js "^5.2.1" + ethereum-bloom-filters "^1.0.6" + ethereum-cryptography "^2.1.2" + ethjs-unit "0.1.6" + number-to-bn "1.7.0" + randombytes "^2.1.0" + utf8 "3.0.0" + +webidl-conversions@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" + integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== + +webidl-conversions@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-4.0.2.tgz#a855980b1f0b6b359ba1d5d9fb39ae941faa63ad" + integrity sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg== + +whatwg-url@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" + integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw== + dependencies: + tr46 "~0.0.3" + webidl-conversions "^3.0.0" + +whatwg-url@^7.0.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-7.1.0.tgz#c2c492f1eca612988efd3d2266be1b9fc6170d06" + integrity sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg== + dependencies: + lodash.sortby "^4.7.0" + tr46 "^1.0.1" + webidl-conversions "^4.0.2" + +which-boxed-primitive@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz#13757bc89b209b049fe5d86430e21cf40a89a8e6" + integrity sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg== + dependencies: + is-bigint "^1.0.1" + is-boolean-object "^1.1.0" + is-number-object "^1.0.4" + is-string "^1.0.5" + is-symbol "^1.0.3" + +which-typed-array@^1.1.14, which-typed-array@^1.1.15: + version "1.1.15" + resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.15.tgz#264859e9b11a649b388bfaaf4f767df1f779b38d" + integrity sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA== + dependencies: + available-typed-arrays "^1.0.7" + call-bind "^1.0.7" + for-each "^0.3.3" + gopd "^1.0.1" + has-tostringtag "^1.0.2" + +which@2.0.2, which@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" + integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== + dependencies: + isexe "^2.0.0" + +which@^1.1.1, which@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a" + integrity sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ== + dependencies: + isexe "^2.0.0" + +widest-line@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/widest-line/-/widest-line-3.1.0.tgz#8292333bbf66cb45ff0de1603b136b7ae1496eca" + integrity sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg== + dependencies: + string-width "^4.0.0" + +word-wrap@~1.2.3: + version "1.2.5" + resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" + integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== + +wordwrap@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" + integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== + +wordwrapjs@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/wordwrapjs/-/wordwrapjs-4.0.1.tgz#d9790bccfb110a0fc7836b5ebce0937b37a8b98f" + integrity sha512-kKlNACbvHrkpIw6oPeYDSmdCTu2hdMHoyXLTcUKala++lx5Y+wjJ/e474Jqv5abnVmwxw08DiTuHmw69lJGksA== + dependencies: + reduce-flatten "^2.0.0" + typical "^5.2.0" + +workerpool@6.2.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.2.0.tgz#827d93c9ba23ee2019c3ffaff5c27fccea289e8b" + integrity sha512-Rsk5qQHJ9eowMH28Jwhe8HEbmdYDX4lwoMWshiCXugjtHqMD9ZbiqSDLxcsfdqsETPzVUtX5s1Z5kStiIM6l4A== + +workerpool@6.2.1: + version "6.2.1" + resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.2.1.tgz#46fc150c17d826b86a008e5a4508656777e9c343" + integrity sha512-ILEIE97kDZvF9Wb9f6h5aXK4swSlKGUcOEGiIYb2OOu/IrDU9iwj0fD//SsA6E5ibwJxpEvhullJY4Sl4GcpAw== + +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: + name wrap-ansi-cjs + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrap-ansi@^8.1.0: + version "8.1.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" + integrity sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ== + dependencies: + ansi-styles "^6.1.0" + string-width "^5.0.1" + strip-ansi "^7.0.1" + +wrappy@1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" + integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== + +ws@7.4.6: + version "7.4.6" + resolved "https://registry.yarnpkg.com/ws/-/ws-7.4.6.tgz#5654ca8ecdeee47c33a9a4bf6d28e2be2980377c" + integrity sha512-YmhHDO4MzaDLB+M9ym/mDA5z0naX8j7SIlT8f8z+I0VtzsRbekxEutHSme7NPS2qE8StCYQNUnfWdXta/Yu85A== + +ws@^7.4.6: + version "7.5.9" + resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.9.tgz#54fa7db29f4c7cec68b1ddd3a89de099942bb591" + integrity sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q== + +xtend@^4.0.0: + version "4.0.2" + resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" + integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== + +y18n@^5.0.5: + version "5.0.8" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" + integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== + +yallist@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" + integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== + +yaml@^1.10.2: + version "1.10.2" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b" + integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg== + +yargs-parser@20.2.4: + version "20.2.4" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.4.tgz#b42890f14566796f85ae8e3a25290d205f154a54" + integrity sha512-WOkpgNhPTlE73h4VFAFsOnomJVaovO8VqLDzy5saChRBFQFBoMYirowyW+Q9HB4HFF4Z7VZTiG3iSzJJA29yRA== + +yargs-parser@^20.2.2: + version "20.2.9" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.9.tgz#2eb7dc3b0289718fc295f362753845c41a0c94ee" + integrity sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w== + +yargs-unparser@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/yargs-unparser/-/yargs-unparser-2.0.0.tgz#f131f9226911ae5d9ad38c432fe809366c2325eb" + integrity sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA== + dependencies: + camelcase "^6.0.0" + decamelize "^4.0.0" + flat "^5.0.2" + is-plain-obj "^2.1.0" + +yargs@16.2.0: + version "16.2.0" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-16.2.0.tgz#1c82bf0f6b6a66eafce7ef30e376f49a12477f66" + integrity sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw== + dependencies: + cliui "^7.0.2" + escalade "^3.1.1" + get-caller-file "^2.0.5" + require-directory "^2.1.1" + string-width "^4.2.0" + y18n "^5.0.5" + yargs-parser "^20.2.2" + +yn@3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/yn/-/yn-3.1.1.tgz#1e87401a09d767c1d5eab26a6e4c185182d2eb50" + integrity sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q== + +yocto-queue@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" + integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== + +zksync-ethers@^5.0.0, zksync-ethers@^5.7.0: + version "5.7.0" + resolved "https://registry.yarnpkg.com/zksync-ethers/-/zksync-ethers-5.7.0.tgz#edf465eb564ed60d6a1cc3de5978b8bd8481c230" + integrity sha512-X99c5APICTlRzyXXjfwkEjRzOPp3Jwo62+z2DVGaZbe+b9Apbizcd2UGV4NGomoAR2GXPbeiSqi1cf3Hbo3cQw== + dependencies: + ethers "~5.7.0"