├── .env.example ├── .github ├── CODEOWNERS └── workflows │ └── test.yml ├── .gitignore ├── .gitmodules ├── .vscode └── settings.json ├── LICENSE ├── Makefile ├── README.md ├── assets ├── cube.png └── layer3.png ├── audit ├── cube │ ├── hallborn_oct_24.pdf │ └── sherlock_december_2023.pdf └── escrow │ └── three_sigma_march_2024.pdf ├── foundry.toml ├── funding.json ├── script ├── DeployEscrow.s.sol ├── DeployProxy.s.sol └── UpgradeCube.s.sol ├── src ├── CUBE.sol └── escrow │ ├── Escrow.sol │ ├── Factory.sol │ ├── TaskEscrow.sol │ └── interfaces │ ├── IEscrow.sol │ ├── IFactory.sol │ └── ITokenType.sol └── test ├── contracts └── CubeV2.sol ├── mock ├── MockERC1155.sol ├── MockERC20.sol └── MockERC721.sol ├── unit ├── Cube.t.sol ├── Escrow.t.sol ├── EscrowFactory.t.sol ├── TaskEscrow.t.sol └── TestProxy.t.sol └── utils └── Helper.t.sol /.env.example: -------------------------------------------------------------------------------- 1 | PRIVATE_KEY= 2 | ETHERSCAN_API_KEY= -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @petersng @NemboKid @larskarbo @dkhojasteh -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: workflow_dispatch 4 | 5 | env: 6 | FOUNDRY_PROFILE: ci 7 | 8 | jobs: 9 | check: 10 | strategy: 11 | fail-fast: true 12 | 13 | name: CUBE 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v3 17 | with: 18 | submodules: recursive 19 | 20 | - name: Install Foundry 21 | uses: foundry-rs/foundry-toolchain@v1 22 | with: 23 | version: nightly 24 | 25 | - name: Run Forge build 26 | run: | 27 | forge --version 28 | forge build --sizes 29 | id: build 30 | 31 | - name: Run Forge tests 32 | run: | 33 | forge test --ffi 34 | id: test 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiler files 2 | cache/ 3 | out/ 4 | 5 | # Ignores development broadcast logs 6 | !/broadcast 7 | /broadcast/*/31337/ 8 | # /broadcast/*/5/ 9 | /broadcast/**/dry-run/ 10 | broadcast/ 11 | 12 | # Docs 13 | docs/ 14 | 15 | # Dotenv file 16 | .env 17 | 18 | # local tests 19 | coverage-report.txt 20 | .gas-snapshot 21 | bin/* 22 | 23 | node_modules 24 | 25 | .DS_Store -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "lib/forge-std"] 2 | path = lib/forge-std 3 | url = https://github.com/foundry-rs/forge-std 4 | [submodule "lib/openzeppelin-contracts"] 5 | path = lib/openzeppelin-contracts 6 | url = https://github.com/openzeppelin/openzeppelin-contracts 7 | [submodule "lib/openzeppelin-contracts-upgradeable"] 8 | path = lib/openzeppelin-contracts-upgradeable 9 | url = https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable 10 | [submodule "lib/foundry-devops"] 11 | path = lib/foundry-devops 12 | url = https://github.com/chainaccelorg/foundry-devops 13 | [submodule "lib/openzeppelin-foundry-upgrades"] 14 | path = lib/openzeppelin-foundry-upgrades 15 | url = https://github.com/OpenZeppelin/openzeppelin-foundry-upgrades 16 | [submodule "lib/solady"] 17 | path = lib/solady 18 | url = https://github.com/vectorized/solady 19 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "solidity.compileUsingRemoteVersion": "v0.8.20+commit.a1b79de6", 3 | "[solidity]": { 4 | "editor.defaultFormatter": "JuanBlanco.solidity" 5 | }, 6 | "solidity.formatter": "forge", 7 | "slither.solcPath": "", 8 | "slither.hiddenDetectors": [], 9 | "security.olympix.project.includePath": "/src", 10 | "wake.compiler.solc.remappings": [ 11 | "@openzeppelin/contracts-upgradeable/=lib/openzeppelin-contracts-upgradeable/contracts/", 12 | "@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/", 13 | "erc4626-tests/=lib/openzeppelin-contracts/lib/erc4626-tests/", 14 | "openzeppelin-foundry-upgrades/=lib/openzeppelin-foundry-upgrades/src/", 15 | "ds-test/=lib/forge-std/lib/ds-test/src/", 16 | "forge-std/=lib/forge-std/src/", 17 | "foundry-devops/=lib/foundry-devops/", 18 | "openzeppelin-contracts-upgradeable/=lib/openzeppelin-contracts-upgradeable/", 19 | "openzeppelin-contracts/=lib/openzeppelin-contracts/", 20 | "solady/=lib/solady/", 21 | "solidity-stringutils/=lib/openzeppelin-foundry-upgrades/lib/solidity-stringutils/" 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | -include .env 2 | 3 | .PHONY: deploy test coverage build deploy_proxy fork_test 4 | 5 | DEFAULT_ANVIL_PRIVATE_KEY := 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 6 | 7 | install:; forge install 8 | build:; forge build 9 | test :; forge clean && forge test --ffi 10 | coverage :; forge coverage --ffi --report debug > coverage-report.txt 11 | snapshot :; forge snapshot --ffi 12 | 13 | NETWORK_ARGS := --rpc-url http://localhost:8545 --private-key $(DEFAULT_ANVIL_PRIVATE_KEY) --broadcast 14 | 15 | # Goerli 16 | ifeq ($(findstring --network goerli,$(ARGS)),--network goerli) 17 | NETWORK_ARGS := --rpc-url $(GOERLI_RPC_ENDPOINT) --private-key $(PRIVATE_KEY) --verify --etherscan-api-key $(ETHERSCAN_API_KEY) --broadcast -vvvv 18 | endif 19 | 20 | # Base 21 | ifeq ($(findstring --network base_sepolia,$(ARGS)),--network base_sepolia) 22 | NETWORK_ARGS := --rpc-url https://sepolia.base.org --account baseSepolia --broadcast -vvvv 23 | endif 24 | 25 | # Base Sepolia 26 | # ifeq ($(findstring --network base_sepolia,$(ARGS)),--network base_sepolia) 27 | # NETWORK_ARGS := --rpc-url $(BASE_SEPOLIA_RPC_ENDPOINT) --private-key $(PRIVATE_KEY) --broadcast -vvvv 28 | # endif 29 | 30 | deploy: 31 | @forge script script/DeployCube.s.sol:DeployCube $(NETWORK_ARGS) 32 | 33 | deploy_proxy: 34 | @forge script script/DeployProxy.s.sol:DeployProxy $(NETWORK_ARGS) --ffi 35 | 36 | upgrade_proxy: 37 | @forge script script/UpgradeCube.s.sol:UpgradeCube $(NETWORK_ARGS) --ffi 38 | 39 | fork_test: 40 | @forge test --rpc-url $(RPC_ENDPOINT) -vvv -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 |
3 | 4 | 5 | 6 |
7 |
8 |

9 | 10 | [![Twitter](https://img.shields.io/twitter/follow/layer3xyz?color=blue&style=flat-square)](https://twitter.com/layer3xyz) 11 | [![Discord](https://img.shields.io/discord/884514862737281024?color=green&style=flat-square&logo=discord)](https://discord.com/invite/layer3) 12 | [![LICENSE](https://img.shields.io/badge/license-Apache--2.0-blue?logo=apache)](https://github.com/layer3xyz/cube-contracts/blob/main/LICENSE) 13 | 14 | ``` 15 | ________ ______ ______ 16 | / ____/ / / / __ )/ ____/ 17 | / / / / / / __ / __/ 18 | / /___/ /_/ / /_/ / /___ 19 | \____/\____/_____/_____/ 20 | ``` 21 | 22 | # Introduction 23 | 24 | CUBEs, or Credentials to Unify Blockchain Events, are ERC-721 credentials that attest and unify multi-chain, multi-transaction, and multi-dapp quests onchain. As new ecosystems appear everyday, a simple way of querying impact and distributing rewards is necessary. CUBE lays the foundation for rewards in a world of 1,000,000 chains. 25 | 26 |

27 |
28 |
29 | 30 | 31 | 32 |
33 |
34 |

35 | 36 | ## Install 37 | 38 | ```bash 39 | make install 40 | make build 41 | ``` 42 | 43 | ### Deployment 44 | 45 | ```bash 46 | make deploy_proxy ARGS="--network base_sepolia" 47 | ``` 48 | 49 | ### Test 50 | 51 | ```bash 52 | make test 53 | ``` 54 | 55 | ## Overview 56 | 57 | Upon completing certain quests, users have the opportunity to mint a CUBE. This unique NFT encapsulates data from the quest, serving as a digital record of achievement. Check out an example of a minted CUBE on [Opensea](https://opensea.io/assets/base/0x1195cf65f83b3a5768f3c496d3a05ad6412c64b7/95). 58 | 59 | ### Minting Process 60 | 61 | The minting process is initiated by a wallet with the _signer role_ creating an EIP-712 signature. This signature encapsulates all requisite data for the `mintCubes` function, ensuring the integrity and accuracy of the minting process. The user then broadcasts the transaction, including this signature and data. Minting transactions emit events in the smart contract that are captured onchain. 62 | 63 | ### Quest Initialization 64 | 65 | When a new quest comes to life in our system, we call `initializeQuest`. This function is key—it broadcasts event data about the quest, such as participating communities (Layer3, Uniswap, 1Inch, etc.), the quest's difficulty level (beginner, intermediate, advanced), title, and more. 66 | 67 | ## CUBE Smart Contract Details 68 | 69 | #### Key Features 70 | 71 | - EIP712 Signatures: Utilizes EIP712 to sign data. 72 | - Analytics: Events emitted by the contract are captured for analytics. 73 | - Referral System: Incorporates a referral mechanism in the minting process. 74 | 75 | #### Deployment Details 76 | 77 | - Contract Name: CUBE 78 | - Compiler Version: 0.8.20 79 | - Optimizations: Yes, 10,000 runs. 80 | 81 | #### Roles and Permissions 82 | 83 | - Default Admin: Full control over the contract and can handle the different roles. 84 | - Signer: Authorized to initialize quests and sign cube data for minting. 85 | - Upgrader: Can upgrade the contract 86 | 87 | ## Token Reward System 88 | 89 | ### Overview 90 | 91 | The Layer3 Token Reward System automates the distribution of token rewards upon the completion of onchain events. This system is empowered by two primary smart contracts, [Factory.sol](./src/escrow/Factory.sol) and [Escrow.sol](./src/escrow/Escrow.sol), which work in tandem with [CUBE.sol](./src/CUBE.sol) to handle the lifecycle of reward distribution. Used with CUBEs, this is the first mechanism that supports token rewards for multichain interactions. 92 | 93 | ### Contracts 94 | 95 | ### Factory 96 | 97 | [Factory.sol](./src/escrow/Factory.sol) serves as a factory hub within the Layer3 Token Reward System, and is responsible of creating individual [Escrow.sol](./src/escrow/Escrow.sol) contracts for each new quest. These escrow contracts are where the token rewards are stored and from which they are distributed to the users upon quest completion. 98 | 99 | #### Key Functions 100 | 101 | - **createEscrow**: Deploys a new [Escrow.sol](./src/escrow/Escrow.sol) instance with specified admin and whitelisted tokens for a given quest ID. 102 | - **updateEscrowAdmin**: Allows changing the admin of an escrow. 103 | - **withdrawFunds**: Withdraws funds from the escrow when a quest is inactive, supporting various token types. 104 | - **distributeRewards**: Sends out rewards from escrow when called by the CUBE contract. 105 | 106 | ### Escrow 107 | 108 | [Escrow.sol](./src/escrow/Escrow.sol) acts as a holding mechanism for tokens until they are rewarded to users upon quest completion. It is designed to support various token standards including ERC20, ERC721, and ERC1155. 109 | 110 | #### Key Functions 111 | 112 | - **addTokenToWhitelist**: Enables a token to be used within the escrow. 113 | - **removeTokenFromWhitelist**: Disables the use of a token within the escrow. 114 | - **withdrawERC20**, **withdrawERC721**, **withdrawERC1155**, **withdrawNative**: Allow the withdrawal of rewards to a specified recipient, applying a rake as defined. 115 | 116 | ### Workflow 117 | 118 | 1. **Quest Initiation**: When creating a new quest that should contain a token reward, the creator additionally creates a unique [Escrow.sol](./src/escrow/Escrow.sol) for the quest by calling [Factory.sol](./src/escrow/Factory.sol). 119 | 2. **Reward Funding**: The created [Escrow.sol](./src/escrow/Escrow.sol) contract is then funded with the appropriate tokens that will be awarded to the users completing the quest. 120 | 3. **Quest Completion**: After users complete the quest and mint their CUBEs, the [CUBE.sol](./src/CUBE.sol) contract calls the **distributeRewards** function inside [Factory.sol](./src/escrow/Factory.sol), which triggers [Escrow.sol](./src/escrow/Escrow.sol) to make a push payment to the user. 121 | 122 | --- 123 | 124 | ### Audits 125 | 126 | In December 2023 [CUBE.sol](./src/CUBE.sol) was audited by Sherlock. Find the report [here](./audit/cube/sherlock_december_2023.pdf). 127 | 128 | In March 2024, both [Factory.sol](./src/escrow/Factory.sol) and [Escrow.sol](./src/escrow/Escrow.sol) were audited by Three Sigma. Find the report [here](./audit/escrow/three_sigma_march_2024.pdf). 129 | 130 | ### Bug Bounty Program 131 | 132 | | Severity Level | Description | Examples | Maximum Bounty | 133 | | -------------- | ------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------- | -------------- | 134 | | **Critical** | Bugs that could lead to substantial theft or loss of tokens, or severe damage to the protocol's integrity. | Exploits allowing unauthorized token transfers or contract manipulations. | Contact Us | 135 | | **High** | Issues that can affect the functionality of the CUBEs contracts but don't directly lead to a loss of funds. | Temporary inability to claim or transfer CUBEs, manipulation of non-critical contract states. | Up to 5 ETH | 136 | | **Medium** | Bugs that cause inconvenience or unexpected behavior, but with limited impact on the overall security and functionality. | Contracts using excessive gas, causing inefficiency or denial of service without direct economic damage. | Up to 2.5 ETH | 137 | | **Low** | Non-critical issues that relate to best practices, code optimization, or failings that have a minor impact on user experience. | Sub-optimal contract performance, failure to meet standards or best practices without economic risk. | Up to 0.5 ETH | 138 | 139 | _Note: All bounties are at the discretion of the Layer3 team and will be awarded based on the severity, impact, and quality of the report. To claim a bounty, a detailed report including proof of concept is required. For submissions or inquiries, please email us at [security@layer3.xyz](mailto:security@layer3.xyz)._ 140 | 141 | --- 142 | 143 | ### License 144 | 145 | This repo is released under the Apache 2 license, see [LICENSE](./LICENSE) for more details. However, some files are licensed under MIT, such as the test and script files. 146 | -------------------------------------------------------------------------------- /assets/cube.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/layer3xyz/cubes/713ad246e79237752f00c1765c7197ca43e5d86f/assets/cube.png -------------------------------------------------------------------------------- /assets/layer3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/layer3xyz/cubes/713ad246e79237752f00c1765c7197ca43e5d86f/assets/layer3.png -------------------------------------------------------------------------------- /audit/cube/hallborn_oct_24.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/layer3xyz/cubes/713ad246e79237752f00c1765c7197ca43e5d86f/audit/cube/hallborn_oct_24.pdf -------------------------------------------------------------------------------- /audit/cube/sherlock_december_2023.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/layer3xyz/cubes/713ad246e79237752f00c1765c7197ca43e5d86f/audit/cube/sherlock_december_2023.pdf -------------------------------------------------------------------------------- /audit/escrow/three_sigma_march_2024.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/layer3xyz/cubes/713ad246e79237752f00c1765c7197ca43e5d86f/audit/escrow/three_sigma_march_2024.pdf -------------------------------------------------------------------------------- /foundry.toml: -------------------------------------------------------------------------------- 1 | [profile.default] 2 | src = "src" 3 | out = "out" 4 | libs = ["lib"] 5 | remappings = [ 6 | "@openzeppelin/contracts-upgradeable/=lib/openzeppelin-contracts-upgradeable/contracts/", 7 | "@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/", 8 | "erc4626-tests/=lib/openzeppelin-contracts/lib/erc4626-tests/", 9 | "openzeppelin-foundry-upgrades/=lib/openzeppelin-foundry-upgrades/src/" 10 | ] 11 | solc_version = "0.8.20" 12 | optimizer = true 13 | optimizer_runs = 10000 14 | # NOTE: viaIR doesn't work with `forge coverage` 15 | #viaIR = true 16 | build_info = true 17 | extra_output = ["storageLayout"] 18 | 19 | [etherscan] 20 | 8453 = { key = "${BASESCAN_API_KEY}", url = "https://api.basescan.org/api" } 21 | 84532 = { key = "${BASESCAN_API_KEY}", url = "https://api.basescan.org/api" } 22 | 23 | # See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options 24 | [fmt] 25 | line_length = 100 26 | 27 | gas_reports = ["*"] 28 | 29 | [fuzz] 30 | runs = 300 31 | seed = "0x1" 32 | 33 | [invariant] 34 | runs = 64 35 | depth = 32 36 | fail_on_revert = true -------------------------------------------------------------------------------- /funding.json: -------------------------------------------------------------------------------- 1 | { 2 | "opRetro": { 3 | "projectId": "0x91a4420e2fcc8311e97dad480f201a8ce221f2cd64c2de77280cbcc6ce193752" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /script/DeployEscrow.s.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity 0.8.20; 3 | 4 | import {Script} from "forge-std/Script.sol"; 5 | 6 | import {Factory} from "../src/escrow/Factory.sol"; 7 | import {IFactory} from "../src/escrow/interfaces/IFactory.sol"; 8 | import {MockERC20} from "../test/mock/MockERC20.sol"; 9 | import {MockERC721} from "../test/mock/MockERC721.sol"; 10 | import {MockERC1155} from "../test/mock/MockERC1155.sol"; 11 | 12 | import {CUBE} from "../src/CUBE.sol"; 13 | 14 | import {IERC20} from "@openzeppelin/contracts/interfaces/IERC20.sol"; 15 | import {IERC721} from "@openzeppelin/contracts/interfaces/IERC721.sol"; 16 | import {IERC1155} from "@openzeppelin/contracts/interfaces/IERC1155.sol"; 17 | import {Upgrades, Options} from "openzeppelin-foundry-upgrades/Upgrades.sol"; 18 | 19 | contract DeployEscrow is Script { 20 | // private key is the same for everyone 21 | uint256 public DEFAULT_ANVIL_PRIVATE_KEY = 22 | 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80; 23 | uint256 public deployerKey; 24 | 25 | uint256 public constant QUEST_ID = 1; 26 | 27 | Factory public factoryContract; 28 | address erc20Mock; 29 | address erc721Mock; 30 | address erc1155Mock; 31 | 32 | function run(address admin, address treasury, address cube) 33 | external 34 | returns (address, address, address, address, address) 35 | { 36 | // deploy nft contract and set factory address 37 | deployTokenContracts(admin); 38 | 39 | address factory = deployFactory(admin, cube); 40 | factoryContract = Factory(factory); 41 | 42 | address[] memory whitelistedTokens = new address[](3); 43 | whitelistedTokens[0] = erc20Mock; 44 | whitelistedTokens[1] = erc721Mock; 45 | whitelistedTokens[2] = erc1155Mock; 46 | address escrow = deployEscrow(admin, QUEST_ID, whitelistedTokens, treasury); 47 | 48 | return (factory, escrow, erc20Mock, erc721Mock, erc1155Mock); 49 | } 50 | 51 | function deployTokenContracts(address admin) public { 52 | address erc20 = deployERC20Mock(admin); 53 | address erc721 = deployERC721Mock(admin); 54 | address erc1155 = deployERC1155Mock(admin); 55 | 56 | erc20Mock = erc20; 57 | erc721Mock = erc721; 58 | erc1155Mock = erc1155; 59 | } 60 | 61 | function deployFactory(address _admin, address cube) public returns (address) { 62 | vm.startBroadcast(_admin); 63 | Options memory opts; 64 | opts.constructorData = abi.encode(CUBE(cube)); 65 | address proxy = Upgrades.deployUUPSProxy( 66 | "Factory.sol", abi.encodeCall(Factory.initialize, (_admin)), opts 67 | ); 68 | 69 | vm.stopBroadcast(); 70 | return proxy; 71 | } 72 | 73 | function deployEscrow( 74 | address _admin, 75 | uint256 questId, 76 | address[] memory tokens, 77 | address treasury 78 | ) public returns (address) { 79 | vm.startBroadcast(_admin); 80 | factoryContract.createEscrow(questId, _admin, tokens, treasury); 81 | 82 | // get the escrow's address 83 | address escrow = factoryContract.s_escrows(questId); 84 | vm.stopBroadcast(); 85 | return escrow; 86 | } 87 | 88 | function depositToFactory(address depositor, uint256 amount) public { 89 | vm.startBroadcast(depositor); 90 | address escrowAddr = factoryContract.s_escrows(QUEST_ID); 91 | IERC20(erc20Mock).transfer(escrowAddr, amount); 92 | vm.stopBroadcast(); 93 | } 94 | 95 | function deployERC20Mock(address _admin) public returns (address) { 96 | vm.startBroadcast(_admin); 97 | MockERC20 erc20 = new MockERC20(); 98 | erc20.mint(_admin, 20e18); 99 | vm.stopBroadcast(); 100 | return address(erc20); 101 | } 102 | 103 | function deployERC721Mock(address _admin) public returns (address) { 104 | vm.startBroadcast(_admin); 105 | MockERC721 erc721 = new MockERC721(); 106 | 107 | erc721.mint(_admin); 108 | erc721.mint(_admin); 109 | erc721.mint(_admin); 110 | 111 | vm.stopBroadcast(); 112 | return address(erc721); 113 | } 114 | 115 | function deployERC1155Mock(address _admin) public returns (address) { 116 | vm.startBroadcast(_admin); 117 | MockERC1155 erc1155 = new MockERC1155(); 118 | erc1155.mint(_admin, 100, 0); 119 | 120 | vm.stopBroadcast(); 121 | return address(erc1155); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /script/DeployProxy.s.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity 0.8.20; 3 | 4 | import {Script} from "forge-std/Script.sol"; 5 | import {CUBE} from "../src/CUBE.sol"; 6 | import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol"; 7 | 8 | contract DeployProxy is Script { 9 | // private key is the same for everyone 10 | uint256 public DEFAULT_ANVIL_PRIVATE_KEY = 11 | 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80; 12 | uint256 public deployerKey; 13 | 14 | string public constant NAME = "Layer3 CUBE"; 15 | string public constant SYMBOL = "CUBE"; 16 | string public constant SIGNATURE_DOMAIN = "LAYER3"; 17 | string public constant SIGNING_VERSION = "1"; 18 | 19 | function run() external returns (address) { 20 | if (block.chainid == 31337) { 21 | deployerKey = DEFAULT_ANVIL_PRIVATE_KEY; 22 | } else { 23 | deployerKey = vm.envUint("PRIVATE_KEY"); 24 | } 25 | 26 | address proxy = deployProxy(vm.addr(deployerKey)); 27 | 28 | return proxy; 29 | } 30 | 31 | function deployProxy(address _admin) public returns (address) { 32 | vm.startBroadcast(_admin); 33 | address proxy = Upgrades.deployUUPSProxy( 34 | "CUBE.sol", 35 | abi.encodeCall( 36 | CUBE.initialize, (NAME, SYMBOL, SIGNATURE_DOMAIN, SIGNING_VERSION, _admin) 37 | ) 38 | ); 39 | vm.stopBroadcast(); 40 | return address(proxy); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /script/UpgradeCube.s.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity 0.8.20; 3 | 4 | import {Script, console} from "forge-std/Script.sol"; 5 | import {CUBE} from "../src/CUBE.sol"; 6 | import {CubeV2} from "../test/contracts/CubeV2.sol"; 7 | import {DevOpsTools} from "lib/foundry-devops/src/DevOpsTools.sol"; 8 | import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol"; 9 | 10 | contract UpgradeCube is Script { 11 | uint256 public DEFAULT_ANVIL_PRIVATE_KEY = 12 | 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80; 13 | uint256 public deployerKey; 14 | 15 | function run() public { 16 | // if (block.chainid == 31337) { 17 | // deployerKey = DEFAULT_ANVIL_PRIVATE_KEY; 18 | // } else { 19 | // deployerKey = vm.envUint("PRIVATE_KEY"); 20 | // } 21 | 22 | address proxyAddr = 0xad4dCAfE9C020CF694FFaa943Be69eC182CA07DC; 23 | address admin = 0x225d5BF80f4164eB8F7CE8408dD2Cfb9e35a8C57; 24 | upgradeCube(admin, proxyAddr); 25 | } 26 | 27 | function upgradeCube(address _admin, address _proxyAddress) public { 28 | console.log("admin ", _admin); 29 | vm.startBroadcast(_admin); 30 | 31 | Upgrades.upgradeProxy(_proxyAddress, "CUBE.sol", new bytes(0)); 32 | vm.stopBroadcast(); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/CUBE.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | /* 3 | .____ ________ 4 | | | _____ ___.__. __________\_____ \ 5 | | | \__ \< | |/ __ \_ __ \_(__ < 6 | | |___ / __ \\___ \ ___/| | \/ \ 7 | |_______ (____ / ____|\___ >__| /______ / 8 | \/ \/\/ \/ \/ 9 | */ 10 | 11 | pragma solidity 0.8.20; 12 | 13 | import {EIP712Upgradeable} from 14 | "@openzeppelin/contracts-upgradeable/utils/cryptography/EIP712Upgradeable.sol"; 15 | import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; 16 | import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; 17 | import {ERC721Upgradeable} from 18 | "@openzeppelin/contracts-upgradeable/token/ERC721/ERC721Upgradeable.sol"; 19 | import {AccessControlUpgradeable} from 20 | "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; 21 | import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; 22 | import {ReentrancyGuardUpgradeable} from 23 | "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardUpgradeable.sol"; 24 | import {IFactory} from "./escrow/interfaces/IFactory.sol"; 25 | import {ITokenType} from "./escrow/interfaces/ITokenType.sol"; 26 | import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; 27 | 28 | /// @title CUBE 29 | /// @dev Implementation of an NFT smart contract with EIP712 signatures. 30 | /// The contract is upgradeable using OpenZeppelin's UUPSUpgradeable pattern. 31 | /// @custom:oz-upgrades-from CUBE 32 | contract CUBE is 33 | Initializable, 34 | ERC721Upgradeable, 35 | AccessControlUpgradeable, 36 | UUPSUpgradeable, 37 | EIP712Upgradeable, 38 | ReentrancyGuardUpgradeable, 39 | ITokenType 40 | { 41 | using ECDSA for bytes32; 42 | 43 | error CUBE__IsNotSigner(); 44 | error CUBE__MintingIsNotActive(); 45 | error CUBE__FeeNotEnough(); 46 | error CUBE__WithdrawFailed(); 47 | error CUBE__NonceAlreadyUsed(); 48 | error CUBE__TransferFailed(); 49 | error CUBE__BPSTooHigh(); 50 | error CUBE__ExcessiveFeePayout(); 51 | error CUBE__ExceedsContractBalance(); 52 | error CUBE__NativePaymentFailed(); 53 | error CUBE__ERC20TransferFailed(); 54 | error CUBE__L3TokenNotSet(); 55 | error CUBE__L3PaymentsDisabled(); 56 | error CUBE__TreasuryNotSet(); 57 | error CUBE__InvalidAdminAddress(); 58 | 59 | uint256 internal s_nextTokenId; 60 | bool public s_isMintingActive; 61 | 62 | bytes32 public constant SIGNER_ROLE = keccak256("SIGNER"); 63 | bytes32 public constant UPGRADER_ROLE = keccak256("UPGRADER"); 64 | 65 | bytes32 internal constant TX_DATA_HASH = 66 | keccak256("TransactionData(string txHash,string networkChainId)"); 67 | bytes32 internal constant RECIPIENT_DATA_HASH = 68 | keccak256("FeeRecipient(address recipient,uint16 BPS,uint8 recipientType)"); 69 | bytes32 internal constant REWARD_DATA_HASH = keccak256( 70 | "RewardData(address tokenAddress,uint256 chainId,uint256 amount,uint256 tokenId,uint8 tokenType,uint256 rakeBps,address factoryAddress,address rewardRecipientAddress)" 71 | ); 72 | bytes32 internal constant CUBE_DATA_HASH = keccak256( 73 | "CubeData(uint256 questId,uint256 nonce,uint256 price,bool isNative,address toAddress,string walletProvider,string tokenURI,string embedOrigin,TransactionData[] transactions,FeeRecipient[] recipients,RewardData reward)FeeRecipient(address recipient,uint16 BPS,uint8 recipientType)RewardData(address tokenAddress,uint256 chainId,uint256 amount,uint256 tokenId,uint8 tokenType,uint256 rakeBps,address factoryAddress,address rewardRecipientAddress)TransactionData(string txHash,string networkChainId)" 74 | ); 75 | 76 | mapping(uint256 => uint256) internal s_questIssueNumbers; 77 | mapping(uint256 => string) internal s_tokenURIs; 78 | mapping(uint256 nonce => bool isConsumed) internal s_nonces; 79 | mapping(uint256 => bool) internal s_quests; 80 | 81 | address public s_treasury; 82 | address public s_l3Token; 83 | bool public s_l3PaymentsEnabled; 84 | bytes4 private constant TRANSFER_ERC20 = 85 | bytes4(keccak256(bytes("transferFrom(address,address,uint256)"))); 86 | 87 | enum QuestType { 88 | QUEST, 89 | STREAK 90 | } 91 | 92 | enum Difficulty { 93 | BEGINNER, 94 | INTERMEDIATE, 95 | ADVANCED 96 | } 97 | 98 | enum FeeRecipientType { 99 | LAYER3, 100 | PUBLISHER, 101 | CREATOR, 102 | REFERRER 103 | } 104 | 105 | /// @notice Emitted when a new quest is initialized 106 | /// @param questId The unique identifier of the quest 107 | /// @param questType The type of the quest (QUEST, STREAK) 108 | /// @param difficulty The difficulty level of the quest (BEGINNER, INTERMEDIATE, ADVANCED) 109 | /// @param title The title of the quest 110 | /// @param tags An array of tags associated with the quest 111 | /// @param communities An array of communities associated with the quest 112 | event QuestMetadata( 113 | uint256 indexed questId, 114 | QuestType questType, 115 | Difficulty difficulty, 116 | string title, 117 | string[] tags, 118 | string[] communities 119 | ); 120 | 121 | /// @notice Emitted when a CUBE is claimed 122 | /// @param questId The quest ID associated with the CUBE 123 | /// @param tokenId The token ID of the minted CUBE 124 | /// @param claimer Address of the CUBE claimer 125 | /// @param isNative If the payment was made in native currency 126 | /// @param price The price paid for the CUBE 127 | /// @param issueNumber The issue number of the CUBE 128 | /// @param walletProvider The name of the wallet provider used for claiming 129 | /// @param embedOrigin The origin of the embed associated with the CUBE 130 | event CubeClaim( 131 | uint256 indexed questId, 132 | uint256 indexed tokenId, 133 | address indexed claimer, 134 | bool isNative, 135 | uint256 price, 136 | uint256 issueNumber, 137 | string walletProvider, 138 | string embedOrigin 139 | ); 140 | 141 | /// @notice Emitted for each transaction associated with a CUBE claim 142 | /// This event is designed to support both EVM and non-EVM blockchains 143 | /// @param cubeTokenId The token ID of the Cube 144 | /// @param txHash The hash of the transaction 145 | /// @param networkChainId The network and chain ID of the transaction in the format : 146 | event CubeTransaction(uint256 indexed cubeTokenId, string txHash, string networkChainId); 147 | 148 | /// @notice Emitted when there is a reward associated with a CUBE 149 | /// @param cubeTokenId The token ID of the CUBE giving the reward 150 | /// @param tokenAddress The token address of the reward 151 | /// @param chainId The blockchain chain ID where the transaction occurred 152 | /// @param amount The amount of the reward 153 | /// @param tokenId Token ID of the reward (only applicable for ERC721 and ERC1155) 154 | /// @param tokenType The type of reward token 155 | /// @param rewardRecipientAddress The address of the reward recipient 156 | event TokenReward( 157 | uint256 indexed cubeTokenId, 158 | address indexed tokenAddress, 159 | uint256 indexed chainId, 160 | uint256 amount, 161 | uint256 tokenId, 162 | TokenType tokenType, 163 | address rewardRecipientAddress 164 | ); 165 | 166 | /// @notice Emitted when a fee payout is made 167 | /// @param recipient The address of the payout recipient 168 | /// @param amount The amount of the payout 169 | /// @param isNative If the payout was made in native currency 170 | /// @param recipientType The type of recipient (LAYER3, PUBLISHER, CREATOR, REFERRER) 171 | event FeePayout(address indexed recipient, uint256 amount, bool isNative, FeeRecipientType recipientType); 172 | 173 | /// @notice Emitted when the minting switch is turned on/off 174 | /// @param isActive The boolean showing if the minting is active or not 175 | event MintingSwitch(bool isActive); 176 | 177 | /// @notice Emitted when the contract balance is withdrawn by an admin 178 | /// @param amount The contract's balance that was withdrawn 179 | event ContractWithdrawal(uint256 amount); 180 | 181 | /// @notice Emitted when a quest is disabled 182 | /// @param questId The ID of the quest that was disabled 183 | event QuestDisabled(uint256 indexed questId); 184 | 185 | /// @notice Emitted when the treasury address is updated 186 | /// @param newTreasury The new treasury address 187 | event UpdatedTreasury(address indexed newTreasury); 188 | 189 | /// @notice Emitted when the L3 token address is updated 190 | /// @param token The L3 token address 191 | event UpdatedL3Address(address indexed token); 192 | 193 | /// @notice Emitted when L3 payments are enabled or disabled 194 | /// @param enabled Boolean indicating whether L3 payments are enabled 195 | event L3PaymentsEnabled(bool enabled); 196 | 197 | /// @dev Represents the data needed for minting a CUBE. 198 | /// @param questId The ID of the quest associated with the CUBE 199 | /// @param nonce A unique number to prevent replay attacks 200 | /// @param price The price paid for minting the CUBE 201 | /// @param isNative If the price is paid in native currency or with L3 202 | /// @param toAddress The address where the CUBE will be minted 203 | /// @param walletProvider The wallet provider used for the transaction 204 | /// @param tokenURI The URI pointing to the CUBE's metadata 205 | /// @param embedOrigin The origin source of the CUBE's embed content 206 | /// @param transactions An array of transactions related to the CUBE 207 | /// @param recipients An array of recipients for fee payouts 208 | /// @param reward Data about the reward associated with the CUBE 209 | struct CubeData { 210 | uint256 questId; 211 | uint256 nonce; 212 | uint256 price; 213 | bool isNative; 214 | address toAddress; 215 | string walletProvider; 216 | string tokenURI; 217 | string embedOrigin; 218 | TransactionData[] transactions; 219 | FeeRecipient[] recipients; 220 | RewardData reward; 221 | } 222 | 223 | /// @dev Represents a recipient for fee distribution. 224 | /// @param recipient The address of the fee recipient 225 | /// @param BPS The basis points representing the fee percentage for the recipient 226 | /// @param recipientType The type of recipient (LAYER3, PUBLISHER, CREATOR, REFERRER) 227 | struct FeeRecipient { 228 | address recipient; 229 | uint16 BPS; 230 | FeeRecipientType recipientType; 231 | } 232 | 233 | /// @dev Contains data about the token rewards associated with a CUBE. 234 | /// @param tokenAddress The token address of the reward 235 | /// @param chainId The blockchain chain ID where the transaction occurred 236 | /// @param amount The amount of the reward 237 | /// @param tokenId The token ID 238 | /// @param tokenType The token type 239 | /// @param rakeBps The rake basis points 240 | /// @param factoryAddress The escrow factory address 241 | /// @param rewardRecipientAddress The address of the reward recipient 242 | struct RewardData { 243 | address tokenAddress; 244 | uint256 chainId; 245 | uint256 amount; 246 | uint256 tokenId; 247 | TokenType tokenType; 248 | uint256 rakeBps; 249 | address factoryAddress; 250 | address rewardRecipientAddress; 251 | } 252 | 253 | /// @dev Contains data about a specific transaction related to a CUBE 254 | /// and is designed to support both EVM and non-EVM data. 255 | /// @param txHash The hash of the transaction 256 | /// @param networkChainId The network and chain ID of the transaction in the format : 257 | struct TransactionData { 258 | string txHash; 259 | string networkChainId; 260 | } 261 | 262 | /// @custom:oz-upgrades-unsafe-allow constructor 263 | constructor() { 264 | _disableInitializers(); 265 | } 266 | 267 | /// @notice Returns the version of the CUBE smart contract 268 | function cubeVersion() external pure returns (string memory) { 269 | return "4"; 270 | } 271 | 272 | /// @notice Initializes the CUBE contract with necessary parameters 273 | /// @dev Sets up the ERC721 token with given name and symbol, and grants initial roles. 274 | /// @param _tokenName Name of the NFT collection 275 | /// @param _tokenSymbol Symbol of the NFT collection 276 | /// @param _signingDomain Domain used for EIP712 signing 277 | /// @param _signatureVersion Version of the EIP712 signature 278 | /// @param _admin Address to be granted the admin roles 279 | function initialize( 280 | string memory _tokenName, 281 | string memory _tokenSymbol, 282 | string memory _signingDomain, 283 | string memory _signatureVersion, 284 | address _admin 285 | ) external initializer { 286 | if (_admin == address(0)) revert CUBE__InvalidAdminAddress(); 287 | __ERC721_init(_tokenName, _tokenSymbol); 288 | __EIP712_init(_signingDomain, _signatureVersion); 289 | __AccessControl_init(); 290 | __UUPSUpgradeable_init(); 291 | __ReentrancyGuard_init(); 292 | s_isMintingActive = true; 293 | 294 | _grantRole(DEFAULT_ADMIN_ROLE, _admin); 295 | } 296 | 297 | /// @notice Authorizes an upgrade to a new contract implementation 298 | /// @dev Overrides the UUPSUpgradeable internal function with access control. 299 | /// @param newImplementation Address of the new contract implementation 300 | function _authorizeUpgrade(address newImplementation) 301 | internal 302 | override 303 | onlyRole(UPGRADER_ROLE) 304 | {} 305 | 306 | /// @notice Checks whether a quest is active or not 307 | /// @param questId Unique identifier for the quest 308 | function isQuestActive(uint256 questId) public view returns (bool) { 309 | return s_quests[questId]; 310 | } 311 | 312 | /// @notice Retrieves the URI for a given token 313 | /// @dev Overrides the ERC721Upgradeable's tokenURI method. 314 | /// @param _tokenId The ID of the token 315 | /// @return _tokenURI The URI of the specified token 316 | function tokenURI(uint256 _tokenId) public view override returns (string memory _tokenURI) { 317 | return s_tokenURIs[_tokenId]; 318 | } 319 | 320 | /// @notice Mints a CUBE based on the provided data 321 | /// @param cubeData CubeData struct containing minting information 322 | /// @param signature Signature of the CubeData struct 323 | function mintCube(CubeData calldata cubeData, bytes calldata signature) 324 | external 325 | payable 326 | nonReentrant 327 | { 328 | // Check if the minting function is currently active. If not, revert the transaction 329 | if (!s_isMintingActive) { 330 | revert CUBE__MintingIsNotActive(); 331 | } 332 | 333 | if (s_treasury == address(0)) { 334 | revert CUBE__TreasuryNotSet(); 335 | } 336 | 337 | // Validate payment method and amount 338 | if (cubeData.isNative) { 339 | // Check if the sent value is at least equal to the price 340 | if (msg.value < cubeData.price) { 341 | revert CUBE__FeeNotEnough(); 342 | } 343 | } else { 344 | // Check if L3 payments are enabled 345 | if (!s_l3PaymentsEnabled) { 346 | revert CUBE__L3PaymentsDisabled(); 347 | } 348 | 349 | // Check if L3 token is set 350 | if (s_l3Token == address(0)) { 351 | revert CUBE__L3TokenNotSet(); 352 | } 353 | } 354 | 355 | _mintCube(cubeData, signature); 356 | } 357 | 358 | /// @notice Internal function to handle the logic of minting a single cube 359 | /// @dev Verifies the signer, handles nonce, transactions, referral payments, and minting. 360 | /// @param data The CubeData containing details of the minting 361 | /// @param signature The signature for verification 362 | function _mintCube(CubeData calldata data, bytes calldata signature) internal { 363 | // Cache the tokenId 364 | uint256 tokenId = s_nextTokenId; 365 | 366 | // Validate the signature to ensure the mint request is authorized 367 | _validateSignature(data, signature); 368 | 369 | // Iterate over all the transactions in the mint request and emit events 370 | for (uint256 i = 0; i < data.transactions.length;) { 371 | emit CubeTransaction( 372 | tokenId, data.transactions[i].txHash, data.transactions[i].networkChainId 373 | ); 374 | unchecked { 375 | ++i; 376 | } 377 | } 378 | 379 | // Set the token URI for the CUBE 380 | s_tokenURIs[tokenId] = data.tokenURI; 381 | 382 | // Increment the counters for quest completion, issue numbers, and token IDs 383 | unchecked { 384 | ++s_questIssueNumbers[data.questId]; 385 | ++s_nextTokenId; 386 | } 387 | 388 | // process payments 389 | data.isNative ? _processNativePayouts(data) : _processL3Payouts(data); 390 | 391 | // Perform the actual minting of the CUBE 392 | _safeMint(data.toAddress, tokenId); 393 | 394 | // Emit an event indicating a CUBE has been claimed 395 | emit CubeClaim( 396 | data.questId, 397 | tokenId, 398 | data.toAddress, 399 | data.isNative, 400 | data.price, 401 | s_questIssueNumbers[data.questId], 402 | data.walletProvider, 403 | data.embedOrigin 404 | ); 405 | 406 | if (data.reward.chainId != 0) { 407 | if (data.reward.factoryAddress != address(0)) { 408 | IFactory(data.reward.factoryAddress).distributeRewards( 409 | data.questId, 410 | data.reward.tokenAddress, 411 | data.reward.rewardRecipientAddress, 412 | data.reward.amount, 413 | data.reward.tokenId, 414 | data.reward.tokenType, 415 | data.reward.rakeBps 416 | ); 417 | } 418 | 419 | emit TokenReward( 420 | tokenId, 421 | data.reward.tokenAddress, 422 | data.reward.chainId, 423 | data.reward.amount, 424 | data.reward.tokenId, 425 | data.reward.tokenType, 426 | data.reward.rewardRecipientAddress 427 | ); 428 | } 429 | } 430 | 431 | /// @notice Validates the signature for a Cube minting request 432 | /// @dev Ensures that the signature is from a valid signer and the nonce hasn't been used before 433 | /// @param data The CubeData struct containing minting details 434 | /// @param signature The signature to be validated 435 | function _validateSignature(CubeData calldata data, bytes calldata signature) internal { 436 | address signer = _getSigner(data, signature); 437 | if (!hasRole(SIGNER_ROLE, signer)) { 438 | revert CUBE__IsNotSigner(); 439 | } 440 | if (s_nonces[data.nonce]) { 441 | revert CUBE__NonceAlreadyUsed(); 442 | } 443 | s_nonces[data.nonce] = true; 444 | } 445 | 446 | /// @notice Processes fee payouts to specified recipients when handling L3 payments 447 | /// @dev Distributes a portion of the minting fee to designated addresses based on their Basis Points (BPS) 448 | /// @param data The CubeData struct containing payout details 449 | function _processL3Payouts(CubeData calldata data) internal { 450 | // validate amounts 451 | (uint256[] memory payoutAmounts, uint256 totalAmount) = _calculatePayouts(data); 452 | 453 | // transfer mint fee from user to contract - using transferFrom() 454 | (bool success, bytes memory returnData) = s_l3Token.call( 455 | abi.encodeWithSelector(TRANSFER_ERC20, msg.sender, address(this), data.price) 456 | ); 457 | if (!success || (returnData.length > 0 && !abi.decode(returnData, (bool)))) { 458 | revert CUBE__ERC20TransferFailed(); 459 | } 460 | 461 | uint256 recipientsLength = data.recipients.length; 462 | 463 | // process payouts to recipients 464 | for (uint256 i = 0; i < recipientsLength;) { 465 | address recipient = data.recipients[i].recipient; 466 | uint256 amount = payoutAmounts[i]; 467 | 468 | if (recipient != address(0) && amount > 0) { 469 | (success, returnData) = s_l3Token.call( 470 | abi.encodeWithSelector(IERC20.transfer.selector, recipient, amount) 471 | ); 472 | if (!success || (returnData.length > 0 && !abi.decode(returnData, (bool)))) { 473 | revert CUBE__ERC20TransferFailed(); 474 | } 475 | emit FeePayout(recipient, amount, data.isNative, data.recipients[i].recipientType); 476 | } 477 | 478 | unchecked { 479 | ++i; 480 | } 481 | } 482 | 483 | // Transfer remaining amount to treasury 484 | uint256 treasuryAmount = data.price - totalAmount; 485 | if (treasuryAmount > 0) { 486 | (success, returnData) = s_l3Token.call( 487 | abi.encodeWithSelector(IERC20.transfer.selector, s_treasury, treasuryAmount) 488 | ); 489 | if (!success || (returnData.length > 0 && !abi.decode(returnData, (bool)))) { 490 | revert CUBE__ERC20TransferFailed(); 491 | } 492 | } 493 | } 494 | 495 | /// @dev Calculates payout amounts for all recipients 496 | /// @param data The CubeData containing recipient information 497 | /// @return payoutAmounts Array of amounts to pay each recipient 498 | /// @return totalAmount Total amount to be paid to recipients 499 | function _calculatePayouts(CubeData calldata data) 500 | internal 501 | pure 502 | returns (uint256[] memory payoutAmounts, uint256 totalAmount) 503 | { 504 | uint256 recipientsLength = data.recipients.length; 505 | uint16 MAX_BPS = 10_000; 506 | payoutAmounts = new uint256[](recipientsLength); 507 | totalAmount = 0; 508 | 509 | for (uint256 i = 0; i < recipientsLength;) { 510 | if (data.recipients[i].BPS > MAX_BPS) { 511 | revert CUBE__BPSTooHigh(); 512 | } 513 | 514 | payoutAmounts[i] = (data.price * data.recipients[i].BPS) / MAX_BPS; 515 | totalAmount += payoutAmounts[i]; 516 | 517 | if (totalAmount > data.price) { 518 | revert CUBE__ExcessiveFeePayout(); 519 | } 520 | 521 | unchecked { 522 | ++i; 523 | } 524 | } 525 | } 526 | 527 | /// @notice Processes fee payouts to specified recipients when handling native payments 528 | /// @dev Distributes a portion of the minting fee to designated addresses based on their Basis Points (BPS) 529 | /// @param data The CubeData struct containing payout details 530 | function _processNativePayouts(CubeData calldata data) internal { 531 | uint256 totalReferrals; 532 | 533 | if (data.recipients.length > 0) { 534 | // max basis points is 10k (100%) 535 | uint16 maxBps = 10_000; 536 | uint256 contractBalance = address(this).balance; 537 | for (uint256 i = 0; i < data.recipients.length;) { 538 | if (data.recipients[i].BPS > maxBps) { 539 | revert CUBE__BPSTooHigh(); 540 | } 541 | 542 | // Calculate the referral amount for each recipient 543 | uint256 referralAmount = (data.price * data.recipients[i].BPS) / maxBps; 544 | totalReferrals = totalReferrals + referralAmount; 545 | 546 | // Ensure the total payout does not exceed the cube price or contract balance 547 | if (totalReferrals > data.price) { 548 | revert CUBE__ExcessiveFeePayout(); 549 | } 550 | if (totalReferrals > contractBalance) { 551 | revert CUBE__ExceedsContractBalance(); 552 | } 553 | 554 | // Transfer the referral amount to the recipient 555 | address recipient = data.recipients[i].recipient; 556 | if (recipient != address(0)) { 557 | (bool payoutSuccess,) = recipient.call{value: referralAmount}(""); 558 | if (!payoutSuccess) { 559 | revert CUBE__TransferFailed(); 560 | } 561 | 562 | emit FeePayout(recipient, referralAmount, data.isNative, data.recipients[i].recipientType); 563 | } 564 | unchecked { 565 | ++i; 566 | } 567 | } 568 | } 569 | 570 | (bool success,) = payable(s_treasury).call{value: data.price - totalReferrals}(""); 571 | if (!success) { 572 | revert CUBE__NativePaymentFailed(); 573 | } 574 | } 575 | 576 | /// @notice Recovers the signer's address from the CubeData and its associated signature 577 | /// @dev Utilizes EIP-712 typed data hashing and ECDSA signature recovery 578 | /// @param data The CubeData struct containing the details of the minting request 579 | /// @param sig The signature associated with the CubeData 580 | /// @return The address of the signer who signed the CubeData 581 | function _getSigner(CubeData calldata data, bytes calldata sig) 582 | internal 583 | view 584 | returns (address) 585 | { 586 | bytes32 digest = _computeDigest(data); 587 | return digest.recover(sig); 588 | } 589 | 590 | /// @notice Internal function to compute the EIP712 digest for CubeData 591 | /// @dev Generates the digest that must be signed by the signer. 592 | /// @param data The CubeData to generate a digest for 593 | /// @return The computed EIP712 digest 594 | function _computeDigest(CubeData calldata data) internal view returns (bytes32) { 595 | return _hashTypedDataV4(keccak256(_getStructHash(data))); 596 | } 597 | 598 | /// @notice Internal function to generate the struct hash for CubeData 599 | /// @dev Encodes the CubeData struct into a hash as per EIP712 standard. 600 | /// @param data The CubeData struct to hash 601 | /// @return A hash representing the encoded CubeData 602 | function _getStructHash(CubeData calldata data) internal pure returns (bytes memory) { 603 | return abi.encode( 604 | CUBE_DATA_HASH, 605 | data.questId, 606 | data.nonce, 607 | data.price, 608 | data.isNative, 609 | data.toAddress, 610 | _encodeString(data.walletProvider), 611 | _encodeString(data.tokenURI), 612 | _encodeString(data.embedOrigin), 613 | _encodeCompletedTxs(data.transactions), 614 | _encodeRecipients(data.recipients), 615 | _encodeReward(data.reward) 616 | ); 617 | } 618 | 619 | /// @notice Encodes a string into a bytes32 hash 620 | /// @dev Used for converting strings into a consistent format for EIP712 encoding 621 | /// @param _string The string to be encoded 622 | /// @return The keccak256 hash of the encoded string 623 | function _encodeString(string calldata _string) internal pure returns (bytes32) { 624 | return keccak256(bytes(_string)); 625 | } 626 | 627 | /// @notice Encodes a transaction data into a byte array 628 | /// @dev Used for converting transaction data into a consistent format for EIP712 encoding 629 | /// @param transaction The TransactionData struct to be encoded 630 | /// @return A byte array representing the encoded transaction data 631 | function _encodeTx(TransactionData calldata transaction) internal pure returns (bytes memory) { 632 | return abi.encode( 633 | TX_DATA_HASH, 634 | _encodeString(transaction.txHash), 635 | _encodeString(transaction.networkChainId) 636 | ); 637 | } 638 | 639 | /// @notice Encodes an array of transaction data into a single bytes32 hash 640 | /// @dev Used to aggregate multiple transactions into a single hash for EIP712 encoding 641 | /// @param txData An array of TransactionData structs to be encoded 642 | /// @return A bytes32 hash representing the aggregated and encoded transaction data 643 | function _encodeCompletedTxs(TransactionData[] calldata txData) 644 | internal 645 | pure 646 | returns (bytes32) 647 | { 648 | bytes32[] memory encodedTxs = new bytes32[](txData.length); 649 | for (uint256 i = 0; i < txData.length;) { 650 | encodedTxs[i] = keccak256(_encodeTx(txData[i])); 651 | unchecked { 652 | ++i; 653 | } 654 | } 655 | 656 | return keccak256(abi.encodePacked(encodedTxs)); 657 | } 658 | 659 | /// @notice Encodes a fee recipient data into a byte array 660 | /// @dev Used for converting fee recipient information into a consistent format for EIP712 encoding 661 | /// @param data The FeeRecipient struct to be encoded 662 | /// @return A byte array representing the encoded fee recipient data 663 | function _encodeRecipient(FeeRecipient calldata data) internal pure returns (bytes memory) { 664 | return abi.encode(RECIPIENT_DATA_HASH, data.recipient, data.BPS, data.recipientType); 665 | } 666 | 667 | /// @notice Encodes an array of fee recipient data into a single bytes32 hash 668 | /// @dev Used to aggregate multiple fee recipient entries into a single hash for EIP712 encoding 669 | /// @param data An array of FeeRecipient structs to be encoded 670 | /// @return A bytes32 hash representing the aggregated and encoded fee recipient data 671 | function _encodeRecipients(FeeRecipient[] calldata data) internal pure returns (bytes32) { 672 | bytes32[] memory encodedRecipients = new bytes32[](data.length); 673 | for (uint256 i = 0; i < data.length;) { 674 | encodedRecipients[i] = keccak256(_encodeRecipient(data[i])); 675 | unchecked { 676 | ++i; 677 | } 678 | } 679 | 680 | return keccak256(abi.encodePacked(encodedRecipients)); 681 | } 682 | 683 | /// @notice Encodes the reward data for a CUBE mint 684 | /// @param data An array of FeeRecipient structs to be encoded 685 | /// @return A bytes32 hash representing the encoded reward data 686 | function _encodeReward(RewardData calldata data) internal pure returns (bytes32) { 687 | return keccak256( 688 | abi.encode( 689 | REWARD_DATA_HASH, 690 | data.tokenAddress, 691 | data.chainId, 692 | data.amount, 693 | data.tokenId, 694 | data.tokenType, 695 | data.rakeBps, 696 | data.factoryAddress, 697 | data.rewardRecipientAddress 698 | ) 699 | ); 700 | } 701 | 702 | /// @notice Enables or disables the minting process 703 | /// @dev Can only be called by an account with the default admin role. 704 | /// @param _isMintingActive Boolean indicating whether minting should be active 705 | function setIsMintingActive(bool _isMintingActive) external onlyRole(DEFAULT_ADMIN_ROLE) { 706 | s_isMintingActive = _isMintingActive; 707 | emit MintingSwitch(_isMintingActive); 708 | } 709 | 710 | /// @notice Sets a new treasury address 711 | /// @dev Can only be called by an account with the default admin role. 712 | /// @param _treasury Address of the new treasury to receive fees 713 | function setTreasury(address _treasury) external onlyRole(DEFAULT_ADMIN_ROLE) { 714 | s_treasury = _treasury; 715 | emit UpdatedTreasury(_treasury); 716 | } 717 | 718 | /// @notice Sets the address of the L3 token 719 | /// @dev Can only be called by an account with the default admin role. 720 | /// @param _l3 L3 token address 721 | function setL3TokenAddress(address _l3) external onlyRole(DEFAULT_ADMIN_ROLE) { 722 | s_l3Token = _l3; 723 | emit UpdatedL3Address(_l3); 724 | } 725 | /// @notice Enables or disables L3 payments 726 | /// @dev Can only be called by an account with the default admin role. 727 | /// @param _l3PaymentsEnabled Boolean indicating whether L3 payments should be enabled 728 | function setL3PaymentsEnabled(bool _l3PaymentsEnabled) external onlyRole(DEFAULT_ADMIN_ROLE) { 729 | s_l3PaymentsEnabled = _l3PaymentsEnabled; 730 | emit L3PaymentsEnabled(_l3PaymentsEnabled); 731 | } 732 | 733 | /// @notice Withdraws the contract's balance to the message sender 734 | /// @dev Can only be called by an account with the default admin role. 735 | function withdraw() external onlyRole(DEFAULT_ADMIN_ROLE) { 736 | uint256 withdrawAmount = address(this).balance; 737 | (bool success,) = msg.sender.call{value: withdrawAmount}(""); 738 | if (!success) { 739 | revert CUBE__WithdrawFailed(); 740 | } 741 | emit ContractWithdrawal(withdrawAmount); 742 | } 743 | 744 | /// @notice Initializes a new quest with given parameters 745 | /// @dev Can only be called by an account with the signer role. 746 | /// @param questId Unique identifier for the quest 747 | /// @param communities Array of community names associated with the quest 748 | /// @param title Title of the quest 749 | /// @param difficulty Difficulty level of the quest 750 | /// @param questType Type of the quest 751 | function initializeQuest( 752 | uint256 questId, 753 | string[] memory communities, 754 | string memory title, 755 | Difficulty difficulty, 756 | QuestType questType, 757 | string[] memory tags 758 | ) external onlyRole(SIGNER_ROLE) { 759 | s_quests[questId] = true; 760 | emit QuestMetadata(questId, questType, difficulty, title, tags, communities); 761 | } 762 | 763 | /// @notice Unpublishes and disables a quest 764 | /// @dev Can only be called by an account with the signer role 765 | /// @param questId Unique identifier for the quest 766 | function unpublishQuest(uint256 questId) external onlyRole(SIGNER_ROLE) { 767 | s_quests[questId] = false; 768 | emit QuestDisabled(questId); 769 | } 770 | 771 | /// @notice Checks if the contract implements an interface 772 | /// @dev Overrides the supportsInterface function of ERC721Upgradeable and AccessControlUpgradeable. 773 | /// @param interfaceId The interface identifier, as specified in ERC-165 774 | /// @return True if the contract implements the interface, false otherwise 775 | function supportsInterface(bytes4 interfaceId) 776 | public 777 | view 778 | override(ERC721Upgradeable, AccessControlUpgradeable) 779 | returns (bool) 780 | { 781 | return super.supportsInterface(interfaceId); 782 | } 783 | } 784 | -------------------------------------------------------------------------------- /src/escrow/Escrow.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | /* 3 | .____ ________ 4 | | | _____ ___.__. __________\_____ \ 5 | | | \__ \< | |/ __ \_ __ \_(__ < 6 | | |___ / __ \\___ \ ___/| | \/ \ 7 | |_______ (____ / ____|\___ >__| /______ / 8 | \/ \/\/ \/ \/ 9 | */ 10 | 11 | pragma solidity 0.8.20; 12 | 13 | import {IERC20} from "@openzeppelin/contracts/interfaces/IERC20.sol"; 14 | import {IERC721} from "@openzeppelin/contracts/interfaces/IERC721.sol"; 15 | import {IERC1155} from "@openzeppelin/contracts/interfaces/IERC1155.sol"; 16 | import {ERC721Holder} from "@openzeppelin/contracts/token/ERC721/utils/ERC721Holder.sol"; 17 | import {ERC1155Holder} from "@openzeppelin/contracts/token/ERC1155/utils/ERC1155Holder.sol"; 18 | import {Ownable2Step} from "@openzeppelin/contracts/access/Ownable2Step.sol"; 19 | import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; 20 | import {IEscrow} from "./interfaces/IEscrow.sol"; 21 | 22 | contract Escrow is IEscrow, ERC721Holder, ERC1155Holder, Ownable2Step { 23 | error Escrow__TokenNotWhitelisted(); 24 | error Escrow__InsufficientEscrowBalance(); 25 | error Escrow__ZeroAddress(); 26 | error Escrow__NativeRakeError(); 27 | error Escrow__NativePayoutError(); 28 | error Escrow__InvalidRakeBps(); 29 | error Escrow__ERC20TransferFailed(); 30 | error Escrow__IsNotAContract(); 31 | 32 | event EscrowERC20Transfer( 33 | address indexed token, 34 | address indexed to, 35 | uint256 amount, 36 | uint256 rake, 37 | address rakePayoutAddress 38 | ); 39 | event EscrowNativeTransfer( 40 | address indexed to, uint256 amount, uint256 rake, address rakePayoutAddress 41 | ); 42 | event EscrowERC1155Transfer( 43 | address indexed token, address indexed to, uint256 amount, uint256 tokenId 44 | ); 45 | event EscrowERC721Transfer(address indexed token, address indexed to, uint256 tokenId); 46 | event TokenWhitelisted(address indexed token); 47 | event TokenRemovedFromWhitelist(address indexed token); 48 | 49 | bytes4 private constant TRANSFER_ERC20 = bytes4(keccak256(bytes("transfer(address,uint256)"))); 50 | 51 | address public immutable i_treasury; 52 | 53 | uint16 constant MAX_BPS = 10_000; 54 | uint16 constant GAS_CAP = 35_000; 55 | 56 | mapping(address => bool) public s_whitelistedTokens; 57 | 58 | /// @notice Initializes the escrow contract with specified whitelisted tokens and treasury address. 59 | /// @param tokenAddr An array of addresses of tokens to whitelist upon initialization. 60 | /// @param treasury The address of the treasury for receiving rake payments. 61 | constructor(address _owner, address[] memory tokenAddr, address treasury) Ownable(_owner) { 62 | i_treasury = treasury; 63 | 64 | uint256 length = tokenAddr.length; 65 | for (uint256 i = 0; i < length;) { 66 | s_whitelistedTokens[tokenAddr[i]] = true; 67 | unchecked { 68 | ++i; 69 | } 70 | } 71 | } 72 | 73 | /// @notice Adds a token to the whitelist, allowing it to be used in the escrow. 74 | /// @param token The address of the token to whitelist. 75 | function addTokenToWhitelist(address token) external override onlyOwner { 76 | if (token == address(0)) { 77 | revert Escrow__ZeroAddress(); 78 | } 79 | s_whitelistedTokens[token] = true; 80 | emit TokenWhitelisted(token); 81 | } 82 | 83 | /// @notice Removes a token from the whitelist. 84 | /// @param token The address of the token to remove from the whitelist. 85 | function removeTokenFromWhitelist(address token) external override onlyOwner { 86 | s_whitelistedTokens[token] = false; 87 | emit TokenRemovedFromWhitelist(token); 88 | } 89 | 90 | /// @notice Returns the ERC20 token balance held in escrow. 91 | /// @param token The address of the token. 92 | /// @return The balance of the specified token held in escrow. 93 | function escrowERC20Reserves(address token) public view override returns (uint256) { 94 | return IERC20(token).balanceOf(address(this)); 95 | } 96 | 97 | /// @notice Returns the ERC1155 token balance held in escrow for a specific tokenId. 98 | /// @param token The address of the token. 99 | /// @param tokenId The ID of the token. 100 | /// @return The balance of the specified token ID held in escrow. 101 | function escrowERC1155Reserves(address token, uint256 tokenId) 102 | external 103 | view 104 | override 105 | returns (uint256) 106 | { 107 | return IERC1155(token).balanceOf(address(this), tokenId); 108 | } 109 | 110 | /// @notice Returns the native balance of the escrow smart contract 111 | function escrowNativeBalance() public view override returns (uint256) { 112 | return address(this).balance; 113 | } 114 | 115 | /// @notice Returns the ERC721 token balance held in escrow. 116 | function escrowERC721BalanceOf(address token) external view override returns (uint256) { 117 | return IERC721(token).balanceOf(address(this)); 118 | } 119 | 120 | /// @notice Withdraws ERC20 tokens from the escrow to a specified address. 121 | /// @dev Can only be called by the owner. Applies a rake before sending to the recipient. 122 | /// @param token The token address. 123 | /// @param to The recipient address. 124 | /// @param amount The amount to withdraw. 125 | /// @param rakeBps The basis points of the total amount to be taken as rake. 126 | function withdrawERC20(address token, address to, uint256 amount, uint256 rakeBps) 127 | external 128 | override 129 | onlyOwner 130 | { 131 | if (!s_whitelistedTokens[token]) { 132 | revert Escrow__TokenNotWhitelisted(); 133 | } 134 | if (amount > escrowERC20Reserves(token)) { 135 | revert Escrow__InsufficientEscrowBalance(); 136 | } 137 | if (rakeBps > MAX_BPS) { 138 | revert Escrow__InvalidRakeBps(); 139 | } 140 | 141 | // rake payment in basis points 142 | uint256 rake = (amount * rakeBps) / MAX_BPS; 143 | if (rake > 0) { 144 | _rakePayoutERC20(token, rake); 145 | } 146 | 147 | _safeTransferERC20(token, to, amount - rake); 148 | emit EscrowERC20Transfer(token, to, amount, rake, i_treasury); 149 | } 150 | 151 | function _rakePayoutERC20(address token, uint256 amount) internal { 152 | _safeTransferERC20(token, i_treasury, amount); 153 | } 154 | 155 | function _safeTransferERC20(address token, address to, uint256 value) internal { 156 | if (token.code.length == 0) { 157 | revert Escrow__IsNotAContract(); 158 | } 159 | (bool success, bytes memory data) = 160 | token.call(abi.encodeWithSelector(TRANSFER_ERC20, to, value)); 161 | if (!success || (data.length > 0 && !abi.decode(data, (bool)))) { 162 | revert Escrow__ERC20TransferFailed(); 163 | } 164 | } 165 | 166 | /// @notice Withdraws ERC721 tokens from the escrow to a specified address. 167 | /// @dev Can only be called by the owner. 168 | /// @param token The token address. 169 | /// @param to The recipient address. 170 | /// @param tokenId The token ID to withdraw. 171 | function withdrawERC721(address token, address to, uint256 tokenId) 172 | external 173 | override 174 | onlyOwner 175 | { 176 | if (!s_whitelistedTokens[token]) { 177 | revert Escrow__TokenNotWhitelisted(); 178 | } 179 | IERC721(token).safeTransferFrom(address(this), to, tokenId); 180 | emit EscrowERC721Transfer(token, to, tokenId); 181 | } 182 | 183 | /// @notice Withdraws ERC1155 tokens from the escrow to a specified address. 184 | /// @dev Can only be called by the owner. 185 | /// @param token The token address. 186 | /// @param to The recipient address. 187 | /// @param amount The amount to withdraw. 188 | /// @param tokenId The token ID to withdraw. 189 | function withdrawERC1155(address token, address to, uint256 amount, uint256 tokenId) 190 | external 191 | override 192 | onlyOwner 193 | { 194 | if (!s_whitelistedTokens[token]) { 195 | revert Escrow__TokenNotWhitelisted(); 196 | } 197 | 198 | IERC1155(token).safeTransferFrom(address(this), to, tokenId, amount, ""); 199 | emit EscrowERC1155Transfer(token, to, amount, tokenId); 200 | } 201 | 202 | /// @notice Withdraws native tokens from the escrow to a specified address. 203 | /// @dev Can only be called by the owner. 204 | /// @param to The recipient address. 205 | /// @param amount The amount to withdraw. 206 | /// @param rakeBps The basis points of the total amount to be taken as rake. 207 | function withdrawNative(address to, uint256 amount, uint256 rakeBps) 208 | external 209 | override 210 | onlyOwner 211 | { 212 | if (amount > escrowNativeBalance()) { 213 | revert Escrow__InsufficientEscrowBalance(); 214 | } 215 | if (to == address(0)) { 216 | revert Escrow__ZeroAddress(); 217 | } 218 | if (rakeBps > MAX_BPS) { 219 | revert Escrow__InvalidRakeBps(); 220 | } 221 | 222 | // rake payment in basis points 223 | uint256 rake = (amount * rakeBps) / MAX_BPS; 224 | if (rake > 0) { 225 | (bool rakeSuccess,) = payable(i_treasury).call{value: rake}(""); 226 | if (!rakeSuccess) { 227 | revert Escrow__NativeRakeError(); 228 | } 229 | } 230 | 231 | (bool rewardSuccess,) = payable(to).call{value: amount - rake, gas: GAS_CAP}(""); 232 | if (!rewardSuccess) { 233 | revert Escrow__NativePayoutError(); 234 | } 235 | 236 | emit EscrowNativeTransfer(to, amount, rake, i_treasury); 237 | } 238 | 239 | function supportsInterface(bytes4 interfaceId) 240 | public 241 | view 242 | override(ERC1155Holder) 243 | returns (bool) 244 | { 245 | return super.supportsInterface(interfaceId); 246 | } 247 | 248 | function renounceOwnership() public override onlyOwner {} 249 | 250 | fallback() external payable {} 251 | receive() external payable {} 252 | } 253 | -------------------------------------------------------------------------------- /src/escrow/Factory.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | /* 3 | .____ ________ 4 | | | _____ ___.__. __________\_____ \ 5 | | | \__ \< | |/ __ \_ __ \_(__ < 6 | | |___ / __ \\___ \ ___/| | \/ \ 7 | |_______ (____ / ____|\___ >__| /______ / 8 | \/ \/\/ \/ \/ 9 | */ 10 | 11 | pragma solidity 0.8.20; 12 | 13 | import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; 14 | import {AccessControlUpgradeable} from 15 | "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; 16 | import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; 17 | import {Escrow} from "./Escrow.sol"; 18 | import {CUBE} from "../CUBE.sol"; 19 | import {IEscrow} from "./interfaces/IEscrow.sol"; 20 | import {IFactory} from "./interfaces/IFactory.sol"; 21 | 22 | contract Factory is IFactory, Initializable, AccessControlUpgradeable, UUPSUpgradeable { 23 | error Factory__OnlyCallableByCUBE(); 24 | error Factory__CUBEQuestIsActive(); 25 | error Factory__NoQuestEscrowFound(); 26 | error Factory__OnlyCallableByAdmin(); 27 | error Factory__EscrowAlreadyExists(); 28 | error Factory__ZeroAddress(); 29 | 30 | /// @custom:oz-upgrades-unsafe-allow state-variable-immutable 31 | CUBE public immutable i_cube; 32 | mapping(uint256 => address) public s_escrows; 33 | mapping(uint256 => address) public s_escrow_admin; 34 | 35 | event EscrowRegistered( 36 | address indexed registror, address indexed escrowAddress, uint256 indexed questId 37 | ); 38 | event TokenPayout( 39 | address indexed receiver, 40 | address indexed tokenAddress, 41 | uint256 indexed tokenId, 42 | uint256 amount, 43 | uint8 tokenType, 44 | uint256 questId 45 | ); 46 | event EscrowWithdrawal( 47 | address indexed caller, 48 | address indexed receiver, 49 | address indexed tokenAddress, 50 | uint256 tokenId, 51 | uint256 amount, 52 | uint8 tokenType, 53 | uint256 questId 54 | ); 55 | event EscrowAdminUpdated( 56 | address indexed updater, uint256 indexed questId, address indexed newAdmin 57 | ); 58 | 59 | modifier onlyAdmin(uint256 questId) { 60 | if (msg.sender != s_escrow_admin[questId] && !hasRole(DEFAULT_ADMIN_ROLE, msg.sender)) { 61 | revert Factory__OnlyCallableByAdmin(); 62 | } 63 | _; 64 | } 65 | 66 | /// @custom:oz-upgrades-unsafe-allow constructor 67 | constructor(CUBE cube) { 68 | i_cube = cube; 69 | _disableInitializers(); 70 | } 71 | 72 | /// @notice Initializes the contract by setting up roles and linking to the CUBE contract. 73 | /// @param admin Address to be granted the default admin role. 74 | function initialize(address admin) external override initializer { 75 | __AccessControl_init(); 76 | __UUPSUpgradeable_init(); 77 | 78 | _grantRole(DEFAULT_ADMIN_ROLE, admin); 79 | } 80 | 81 | /// @notice Updates the admin of a specific escrow. 82 | /// @dev Can only be called by the current escrow admin. 83 | /// @param questId Identifier of the quest associated with the escrow. 84 | /// @param newAdmin Address of the new admin. 85 | function updateEscrowAdmin(uint256 questId, address newAdmin) external override { 86 | if (s_escrow_admin[questId] != msg.sender) { 87 | revert Factory__OnlyCallableByAdmin(); 88 | } 89 | if (newAdmin == address(0)) { 90 | revert Factory__ZeroAddress(); 91 | } 92 | s_escrow_admin[questId] = newAdmin; 93 | emit EscrowAdminUpdated(msg.sender, questId, newAdmin); 94 | } 95 | 96 | /// @notice Creates a new escrow for a quest. 97 | /// @dev Can only be called by an account with the default admin role. 98 | /// @param questId The quest the escrow should be created for. 99 | /// @param admin Admin of the new escrow. 100 | /// @param whitelistedTokens Array of addresses of tokens that are whitelisted for the escrow. 101 | /// @param treasury Address of the treasury where fees are sent. 102 | function createEscrow( 103 | uint256 questId, 104 | address admin, 105 | address[] calldata whitelistedTokens, 106 | address treasury 107 | ) external override onlyRole(DEFAULT_ADMIN_ROLE) { 108 | if (s_escrows[questId] != address(0)) { 109 | revert Factory__EscrowAlreadyExists(); 110 | } 111 | 112 | s_escrow_admin[questId] = admin; 113 | address escrow = 114 | address(new Escrow{salt: bytes32(questId)}(address(this), whitelistedTokens, treasury)); 115 | s_escrows[questId] = escrow; 116 | 117 | emit EscrowRegistered(msg.sender, escrow, questId); 118 | } 119 | 120 | /// @notice Adds a token to the whitelist, allowing it to be used in the escrow. 121 | /// @param token The address of the token to whitelist. 122 | function addTokenToWhitelist(uint256 questId, address token) 123 | external 124 | override 125 | onlyAdmin(questId) 126 | { 127 | address escrow = s_escrows[questId]; 128 | if (escrow == address(0)) { 129 | revert Factory__NoQuestEscrowFound(); 130 | } 131 | 132 | IEscrow(escrow).addTokenToWhitelist(token); 133 | } 134 | 135 | /// @notice Removes a token from the whitelist. 136 | /// @param token The address of the token to remove from the whitelist. 137 | function removeTokenFromWhitelist(uint256 questId, address token) 138 | external 139 | override 140 | onlyAdmin(questId) 141 | { 142 | address escrow = s_escrows[questId]; 143 | if (escrow == address(0)) { 144 | revert Factory__NoQuestEscrowFound(); 145 | } 146 | 147 | IEscrow(escrow).removeTokenFromWhitelist(token); 148 | } 149 | 150 | /// @notice Withdraws funds from the escrow associated with a quest. 151 | /// @dev Withdrawal can only be initiated by the escrow admin or an account with the default admin role. 152 | /// @param questId The quest the escrow is mapped to. 153 | /// @param to Recipient of the funds. 154 | /// @param token Address of the token to withdraw. 155 | /// @param tokenId Identifier of the token (for ERC721 and ERC1155). 156 | /// @param tokenType Type of the token being withdrawn. 157 | function withdrawFunds( 158 | uint256 questId, 159 | address to, 160 | address token, 161 | uint256 tokenId, 162 | TokenType tokenType 163 | ) external override onlyAdmin(questId) { 164 | // only allow withdrawals if quest is inactive 165 | if (i_cube.isQuestActive(questId)) { 166 | revert Factory__CUBEQuestIsActive(); 167 | } 168 | address escrow = s_escrows[questId]; 169 | if (escrow == address(0)) { 170 | revert Factory__NoQuestEscrowFound(); 171 | } 172 | 173 | if (tokenType == TokenType.NATIVE) { 174 | uint256 escrowBalance = escrow.balance; 175 | IEscrow(escrow).withdrawNative(to, escrowBalance, 0); 176 | emit EscrowWithdrawal( 177 | msg.sender, to, address(0), 0, escrowBalance, uint8(tokenType), questId 178 | ); 179 | } else if (tokenType == TokenType.ERC20) { 180 | uint256 erc20Amount = IEscrow(escrow).escrowERC20Reserves(token); 181 | IEscrow(escrow).withdrawERC20(token, to, erc20Amount, 0); 182 | emit EscrowWithdrawal(msg.sender, to, token, 0, erc20Amount, uint8(tokenType), questId); 183 | } else if (tokenType == TokenType.ERC721) { 184 | IEscrow(escrow).withdrawERC721(token, to, tokenId); 185 | emit EscrowWithdrawal(msg.sender, to, token, tokenId, 1, uint8(tokenType), questId); 186 | } else if (tokenType == TokenType.ERC1155) { 187 | uint256 erc1155Amount = IEscrow(escrow).escrowERC1155Reserves(token, tokenId); 188 | IEscrow(escrow).withdrawERC1155(token, to, tokenId, erc1155Amount); 189 | emit EscrowWithdrawal( 190 | msg.sender, to, token, tokenId, erc1155Amount, uint8(tokenType), questId 191 | ); 192 | } 193 | } 194 | 195 | /// @notice Distributes rewards for a quest. 196 | /// @dev Can only be called by the CUBE contract. 197 | /// @param questId The quest the escrow is mapped to. 198 | /// @param token Address of the token for rewards. 199 | /// @param to Recipient of the rewards. 200 | /// @param amount Amount of tokens. 201 | /// @param rewardTokenId Token ID for ERC721 and ERC1155 rewards. 202 | /// @param tokenType Type of the token for rewards. 203 | /// @param rakeBps Basis points for the rake to be taken from the reward. 204 | function distributeRewards( 205 | uint256 questId, 206 | address token, 207 | address to, 208 | uint256 amount, 209 | uint256 rewardTokenId, 210 | TokenType tokenType, 211 | uint256 rakeBps 212 | ) external override { 213 | if (msg.sender != address(i_cube)) { 214 | revert Factory__OnlyCallableByCUBE(); 215 | } 216 | address escrow = s_escrows[questId]; 217 | if (escrow == address(0)) { 218 | revert Factory__NoQuestEscrowFound(); 219 | } 220 | 221 | if (tokenType == TokenType.NATIVE) { 222 | IEscrow(escrow).withdrawNative(to, amount, rakeBps); 223 | emit TokenPayout(to, address(0), 0, amount, uint8(tokenType), questId); 224 | } else if (tokenType == TokenType.ERC20) { 225 | IEscrow(escrow).withdrawERC20(token, to, amount, rakeBps); 226 | emit TokenPayout(to, token, 0, amount, uint8(tokenType), questId); 227 | } else if (tokenType == TokenType.ERC721) { 228 | IEscrow(escrow).withdrawERC721(token, to, rewardTokenId); 229 | emit TokenPayout(to, token, rewardTokenId, 1, uint8(tokenType), questId); 230 | } else if (tokenType == TokenType.ERC1155) { 231 | IEscrow(escrow).withdrawERC1155(token, to, amount, rewardTokenId); 232 | emit TokenPayout(to, token, rewardTokenId, amount, uint8(tokenType), questId); 233 | } 234 | } 235 | 236 | function supportsInterface(bytes4 interfaceId) 237 | public 238 | view 239 | override(AccessControlUpgradeable) 240 | returns (bool) 241 | { 242 | return super.supportsInterface(interfaceId); 243 | } 244 | 245 | function _authorizeUpgrade(address newImplementation) 246 | internal 247 | virtual 248 | override 249 | onlyRole(DEFAULT_ADMIN_ROLE) 250 | {} 251 | } 252 | -------------------------------------------------------------------------------- /src/escrow/TaskEscrow.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | /* 3 | .____ ________ 4 | | | _____ ___.__. __________\_____ \ 5 | | | \__ \< | |/ __ \_ __ \_(__ < 6 | | |___ / __ \\___ \ ___/| | \/ \ 7 | |_______ (____ / ____|\___ >__| /______ / 8 | \/ \/\/ \/ \/ 9 | */ 10 | 11 | pragma solidity 0.8.20; 12 | 13 | import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; 14 | import {IERC20} from "@openzeppelin/contracts/interfaces/IERC20.sol"; 15 | import {IERC721} from "@openzeppelin/contracts/interfaces/IERC721.sol"; 16 | import {IERC1155} from "@openzeppelin/contracts/interfaces/IERC1155.sol"; 17 | import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; 18 | import {Escrow} from "./Escrow.sol"; 19 | import {ITokenType} from "./interfaces/ITokenType.sol"; 20 | 21 | contract TaskEscrow is EIP712, ITokenType, Escrow { 22 | using ECDSA for bytes32; 23 | 24 | error TaskEscrow__SignerIsNotOwner(); 25 | error TaskEscrow__NonceAlreadyUsed(); 26 | error TaskEscrow__InsufficientClaimFee(); 27 | error TaskEscrow__ClaimFeePayoutFailed(); 28 | 29 | bytes32 internal constant CLAIM_HASH = keccak256( 30 | "ClaimData(uint256 taskId,address token,address to,uint8 tokenType,uint256 amount,uint256 tokenId,uint256 rakeBps,uint256 claimFee,uint256 nonce,string txHash,string networkChainId)" 31 | ); 32 | 33 | mapping(uint256 => bool) internal s_sigNonces; 34 | 35 | event ClaimFeePayout(address indexed payer, address indexed treasury, uint256 amount); 36 | event RewardClaimed( 37 | uint256 indexed taskId, 38 | address indexed to, 39 | uint256 indexed nonce, 40 | uint256 amount, 41 | uint256 tokenId, 42 | address tokenAddress, 43 | uint8 tokenType 44 | ); 45 | event TaskTransaction(string txHash, string networkChainId); 46 | 47 | struct ClaimData { 48 | uint256 taskId; 49 | address token; 50 | address to; 51 | TokenType tokenType; 52 | uint256 amount; 53 | uint256 tokenId; 54 | uint256 rakeBps; 55 | uint256 claimFee; 56 | uint256 nonce; 57 | string txHash; 58 | string networkChainId; 59 | } 60 | 61 | constructor(address _owner, address[] memory tokenAddr, address treasury) 62 | Escrow(_owner, tokenAddr, treasury) 63 | EIP712("LAYER3", "1") 64 | {} 65 | 66 | function claimReward(ClaimData calldata data, bytes calldata signature) external payable { 67 | _validateSignature(data, signature); 68 | 69 | if (msg.value < data.claimFee) { 70 | revert TaskEscrow__InsufficientClaimFee(); 71 | } 72 | 73 | (bool success,) = i_treasury.call{value: msg.value}(""); 74 | if (!success) { 75 | revert TaskEscrow__ClaimFeePayoutFailed(); 76 | } 77 | 78 | emit ClaimFeePayout(msg.sender, i_treasury, msg.value); 79 | 80 | // emit transaction event 81 | if (bytes(data.txHash).length > 0) { 82 | emit TaskTransaction(data.txHash, data.networkChainId); 83 | } 84 | 85 | // withdraw reward 86 | if (data.tokenType == TokenType.NATIVE) { 87 | _withdrawNative(data.to, data.amount, data.rakeBps); 88 | } else if (data.tokenType == TokenType.ERC20) { 89 | _withdrawERC20(data.token, data.to, data.amount, data.rakeBps); 90 | } else if (data.tokenType == TokenType.ERC721) { 91 | _withdrawERC721(data.token, data.to, data.tokenId); 92 | } else if (data.tokenType == TokenType.ERC1155) { 93 | _withdrawERC1155(data.token, data.to, data.amount, data.tokenId); 94 | } else { 95 | return; 96 | } 97 | 98 | emit RewardClaimed( 99 | data.taskId, 100 | data.to, 101 | data.nonce, 102 | data.amount, 103 | data.tokenId, 104 | data.token, 105 | uint8(data.tokenType) 106 | ); 107 | } 108 | 109 | function _validateSignature(ClaimData calldata data, bytes calldata signature) internal { 110 | address signer = _getSigner(data, signature); 111 | if (signer != owner()) { 112 | revert TaskEscrow__SignerIsNotOwner(); 113 | } 114 | if (s_sigNonces[data.nonce]) { 115 | revert TaskEscrow__NonceAlreadyUsed(); 116 | } 117 | s_sigNonces[data.nonce] = true; 118 | } 119 | 120 | function _computeDigest(ClaimData calldata data) internal view returns (bytes32) { 121 | return _hashTypedDataV4(keccak256(_getStructHash(data))); 122 | } 123 | 124 | function _getStructHash(ClaimData calldata data) internal pure returns (bytes memory) { 125 | return abi.encode( 126 | CLAIM_HASH, 127 | data.taskId, 128 | data.token, 129 | data.to, 130 | data.tokenType, 131 | data.amount, 132 | data.tokenId, 133 | data.rakeBps, 134 | data.claimFee, 135 | data.nonce, 136 | keccak256(bytes(data.txHash)), 137 | keccak256(bytes(data.networkChainId)) 138 | ); 139 | } 140 | 141 | function _getSigner(ClaimData calldata data, bytes calldata sig) 142 | internal 143 | view 144 | returns (address) 145 | { 146 | bytes32 digest = _computeDigest(data); 147 | return digest.recover(sig); 148 | } 149 | 150 | function _withdrawERC721(address token, address to, uint256 tokenId) internal { 151 | if (!s_whitelistedTokens[token]) { 152 | revert Escrow__TokenNotWhitelisted(); 153 | } 154 | IERC721(token).safeTransferFrom(address(this), to, tokenId); 155 | emit EscrowERC721Transfer(token, to, tokenId); 156 | } 157 | 158 | function _withdrawERC1155(address token, address to, uint256 amount, uint256 tokenId) 159 | internal 160 | { 161 | if (!s_whitelistedTokens[token]) { 162 | revert Escrow__TokenNotWhitelisted(); 163 | } 164 | 165 | IERC1155(token).safeTransferFrom(address(this), to, tokenId, amount, ""); 166 | emit EscrowERC1155Transfer(token, to, amount, tokenId); 167 | } 168 | 169 | function _withdrawNative(address to, uint256 amount, uint256 rakeBps) internal { 170 | if (amount > escrowNativeBalance()) { 171 | revert Escrow__InsufficientEscrowBalance(); 172 | } 173 | if (to == address(0)) { 174 | revert Escrow__ZeroAddress(); 175 | } 176 | if (rakeBps > MAX_BPS) { 177 | revert Escrow__InvalidRakeBps(); 178 | } 179 | 180 | uint256 rake = (amount * rakeBps) / MAX_BPS; 181 | if (rake > 0) { 182 | (bool rakeSuccess,) = payable(i_treasury).call{value: rake}(""); 183 | if (!rakeSuccess) { 184 | revert Escrow__NativeRakeError(); 185 | } 186 | } 187 | 188 | (bool rewardSuccess,) = payable(to).call{value: amount - rake, gas: GAS_CAP}(""); 189 | if (!rewardSuccess) { 190 | revert Escrow__NativePayoutError(); 191 | } 192 | 193 | emit EscrowNativeTransfer(to, amount, rake, i_treasury); 194 | } 195 | 196 | function _withdrawERC20(address token, address to, uint256 amount, uint256 rakeBps) internal { 197 | if (!s_whitelistedTokens[token]) { 198 | revert Escrow__TokenNotWhitelisted(); 199 | } 200 | if (amount > escrowERC20Reserves(token)) { 201 | revert Escrow__InsufficientEscrowBalance(); 202 | } 203 | if (rakeBps > MAX_BPS) { 204 | revert Escrow__InvalidRakeBps(); 205 | } 206 | 207 | uint256 rake = (amount * rakeBps) / MAX_BPS; 208 | if (rake > 0) { 209 | _rakePayoutERC20(token, rake); 210 | } 211 | 212 | _safeTransferERC20(token, to, amount - rake); 213 | emit EscrowERC20Transfer(token, to, amount, rake, i_treasury); 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /src/escrow/interfaces/IEscrow.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity 0.8.20; 3 | 4 | interface IEscrow { 5 | function withdrawERC20(address token, address to, uint256 amount, uint256 rakeBps) external; 6 | function withdrawERC721(address token, address to, uint256 tokenId) external; 7 | function withdrawERC1155(address token, address to, uint256 amount, uint256 tokenId) external; 8 | function withdrawNative(address to, uint256 amount, uint256 rakeBps) external; 9 | 10 | function escrowERC20Reserves(address token) external view returns (uint256); 11 | function escrowERC1155Reserves(address token, uint256 tokenId) 12 | external 13 | view 14 | returns (uint256); 15 | function escrowERC721BalanceOf(address token) external view returns (uint256); 16 | function escrowNativeBalance() external view returns (uint256); 17 | 18 | function addTokenToWhitelist(address token) external; 19 | function removeTokenFromWhitelist(address token) external; 20 | } 21 | -------------------------------------------------------------------------------- /src/escrow/interfaces/IFactory.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity 0.8.20; 3 | 4 | import {ITokenType} from "./ITokenType.sol"; 5 | import {CUBE} from "../../CUBE.sol"; 6 | 7 | interface IFactory is ITokenType { 8 | function distributeRewards( 9 | uint256 questId, 10 | address token, 11 | address to, 12 | uint256 amount, 13 | uint256 rewardTokenId, 14 | TokenType tokenType, 15 | uint256 rakeBps 16 | ) external; 17 | 18 | function withdrawFunds( 19 | uint256 questId, 20 | address to, 21 | address token, 22 | uint256 tokenId, 23 | TokenType tokenType 24 | ) external; 25 | 26 | function createEscrow( 27 | uint256 questId, 28 | address admin, 29 | address[] memory whitelistedTokens, 30 | address treasury 31 | ) external; 32 | 33 | function updateEscrowAdmin(uint256 questId, address newAdmin) external; 34 | 35 | function addTokenToWhitelist(uint256 questId, address token) external; 36 | function removeTokenFromWhitelist(uint256 questId, address token) external; 37 | 38 | function initialize(address admin) external; 39 | } 40 | -------------------------------------------------------------------------------- /src/escrow/interfaces/ITokenType.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity 0.8.20; 3 | 4 | interface ITokenType { 5 | enum TokenType { 6 | ERC20, 7 | ERC721, 8 | ERC1155, 9 | NATIVE 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /test/contracts/CubeV2.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Apache-2.0 2 | /* 3 | .____ ________ 4 | | | _____ ___.__. __________\_____ \ 5 | | | \__ \< | |/ __ \_ __ \_(__ < 6 | | |___ / __ \\___ \ ___/| | \/ \ 7 | |_______ (____ / ____|\___ >__| /______ / 8 | \/ \/\/ \/ \/ 9 | */ 10 | 11 | pragma solidity 0.8.20; 12 | 13 | import {CUBE} from "../../src/CUBE.sol"; 14 | 15 | /// @title CubeV2 16 | /// @dev Proxy upgrade test contract 17 | /// @custom:oz-upgrades-from CUBE 18 | contract CubeV2 is CUBE { 19 | uint256 public newValueV2; 20 | 21 | function initializeV2(uint256 _newVal) external reinitializer(2) { 22 | newValueV2 = _newVal; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /test/mock/MockERC1155.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity 0.8.20; 3 | 4 | import {ERC1155} from "@openzeppelin/contracts/token/ERC1155/ERC1155.sol"; 5 | 6 | contract MockERC1155 is ERC1155 { 7 | constructor() ERC1155("Mock1155") {} 8 | 9 | function mint(address to, uint256 amount, uint256 id) public { 10 | _mint(to, id, amount, "0x00"); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /test/mock/MockERC20.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity 0.8.20; 3 | 4 | import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; 5 | 6 | contract MockERC20 is ERC20 { 7 | constructor() ERC20("Token", "TKN") {} 8 | 9 | function mint(address _to, uint256 _amount) public { 10 | _mint(_to, _amount); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /test/mock/MockERC721.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity 0.8.20; 3 | 4 | import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol"; 5 | 6 | contract MockERC721 is ERC721 { 7 | uint256 internal s_currentTokenId; 8 | 9 | constructor() ERC721("MockToken", "MOCK") {} 10 | 11 | function mint(address to) public { 12 | unchecked { 13 | ++s_currentTokenId; 14 | } 15 | _mint(to, s_currentTokenId); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /test/unit/Cube.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity 0.8.20; 3 | 4 | import {Test, console, Vm} from "forge-std/Test.sol"; 5 | 6 | import {CUBE} from "../../src/CUBE.sol"; 7 | import {Factory} from "../../src/escrow/Factory.sol"; 8 | import {Escrow} from "../../src/escrow/Escrow.sol"; 9 | import {ITokenType} from "../../src/escrow/interfaces/ITokenType.sol"; 10 | 11 | import {DeployProxy} from "../../script/DeployProxy.s.sol"; 12 | import {DeployEscrow} from "../../script/DeployEscrow.s.sol"; 13 | import {Helper} from "../utils/Helper.t.sol"; 14 | 15 | import {MockERC20} from "../mock/MockERC20.sol"; 16 | import {MockERC721} from "../mock/MockERC721.sol"; 17 | import {MockERC1155} from "../mock/MockERC1155.sol"; 18 | 19 | import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; 20 | import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; 21 | import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol"; 22 | import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; 23 | import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; 24 | import {ERC1155} from "@openzeppelin/contracts/token/ERC1155/ERC1155.sol"; 25 | import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; 26 | 27 | contract CubeTest is Test { 28 | using ECDSA for bytes32; 29 | using MessageHashUtils for bytes32; 30 | 31 | /* EVENTS */ 32 | event QuestMetadata( 33 | uint256 indexed questId, 34 | CUBE.QuestType questType, 35 | CUBE.Difficulty difficulty, 36 | string title, 37 | string[] tags, 38 | string[] communities 39 | ); 40 | event CubeClaim( 41 | uint256 indexed questId, 42 | uint256 indexed tokenId, 43 | address indexed claimer, 44 | bool isNative, 45 | uint256 price, 46 | uint256 issueNumber, 47 | string walletProvider, 48 | string embedOrigin 49 | ); 50 | event CubeTransaction(uint256 indexed cubeTokenId, string txHash, string networkChainId); 51 | 52 | event TokenReward( 53 | uint256 indexed cubeTokenId, 54 | address indexed tokenAddress, 55 | uint256 indexed chainId, 56 | uint256 amount, 57 | uint256 tokenId, 58 | ITokenType.TokenType tokenType, 59 | address rewardRecipientAddress 60 | ); 61 | 62 | event FeePayout( 63 | address indexed recipient, 64 | uint256 amount, 65 | bool isNative, 66 | CUBE.FeeRecipientType recipientType 67 | ); 68 | 69 | DeployProxy public deployer; 70 | CUBE public cubeContract; 71 | 72 | string constant SIGNATURE_DOMAIN = "LAYER3"; 73 | string constant SIGNING_VERSION = "1"; 74 | 75 | Helper internal helper; 76 | 77 | uint256 internal ownerPrivateKey; 78 | address internal ownerPubKey; 79 | 80 | address internal realAccount; 81 | uint256 internal realPrivateKey; 82 | 83 | DeployEscrow public deployEscrow; 84 | Factory public factoryContract; 85 | Escrow public mockEscrow; 86 | MockERC20 public erc20Mock; 87 | MockERC721 public erc721Mock; 88 | MockERC1155 public erc1155Mock; 89 | MockERC20 public l3Token; 90 | 91 | // Test Users 92 | address public adminAddress; 93 | address public ADMIN = makeAddr("admin"); 94 | uint256 internal adminPrivateKey; 95 | address public ALICE = makeAddr("alice"); 96 | address public BOB = makeAddr("bob"); 97 | address public TREASURY = makeAddr("treasury"); 98 | 99 | address public notAdminAddress; 100 | uint256 internal notAdminPrivKey; 101 | 102 | address public proxyAddress; 103 | 104 | function getDomainSeparator() internal view virtual returns (bytes32) { 105 | return keccak256( 106 | abi.encode( 107 | keccak256( 108 | "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" 109 | ), 110 | keccak256(bytes(SIGNATURE_DOMAIN)), 111 | keccak256(bytes(SIGNING_VERSION)), 112 | block.chainid, 113 | proxyAddress 114 | ) 115 | ); 116 | } 117 | 118 | function setUp() public { 119 | ownerPrivateKey = 0xA11CE; 120 | ownerPubKey = vm.addr(ownerPrivateKey); 121 | vm.label(ownerPubKey, "Owner"); 122 | 123 | adminPrivateKey = 0x01; 124 | adminAddress = vm.addr(adminPrivateKey); 125 | vm.label(adminAddress, "Admin"); 126 | 127 | notAdminPrivKey = 0x099; 128 | notAdminAddress = vm.addr(notAdminPrivKey); 129 | vm.label(notAdminAddress, "Not Admin"); 130 | 131 | deployer = new DeployProxy(); 132 | proxyAddress = deployer.deployProxy(ownerPubKey); 133 | vm.label(proxyAddress, "CUBE Proxy"); 134 | cubeContract = CUBE(payable(proxyAddress)); 135 | 136 | vm.startBroadcast(ownerPubKey); 137 | cubeContract.grantRole(cubeContract.SIGNER_ROLE(), adminAddress); 138 | vm.stopBroadcast(); 139 | 140 | deployEscrow = new DeployEscrow(); 141 | ( 142 | address factory, 143 | address escrow, 144 | address erc20Addr, 145 | address erc721Addr, 146 | address erc1155Addr 147 | ) = deployEscrow.run(adminAddress, TREASURY, proxyAddress); 148 | 149 | mockEscrow = Escrow(payable(escrow)); 150 | vm.label(escrow, "Escrow"); 151 | 152 | erc20Mock = MockERC20(erc20Addr); 153 | vm.label(erc20Addr, "ERC20 Mock"); 154 | 155 | erc721Mock = MockERC721(erc721Addr); 156 | vm.label(erc721Addr, "ERC721 Mock"); 157 | 158 | erc1155Mock = MockERC1155(erc1155Addr); 159 | vm.label(erc1155Addr, "ERC1155 Mock"); 160 | 161 | factoryContract = Factory(factory); 162 | vm.label(factory, "Factory"); 163 | vm.startPrank(adminAddress); 164 | cubeContract.initializeQuest( 165 | deployEscrow.QUEST_ID(), 166 | new string[](0), 167 | "Quest Title", 168 | CUBE.Difficulty.BEGINNER, 169 | CUBE.QuestType.QUEST, 170 | new string[](0) 171 | ); 172 | 173 | vm.deal(adminAddress, 100 ether); 174 | fundEscrowContract(); 175 | 176 | helper = new Helper(); 177 | 178 | vm.stopPrank(); 179 | 180 | vm.startPrank(ownerPubKey); 181 | l3Token = new MockERC20(); 182 | vm.label(address(l3Token), "L3 Token"); 183 | 184 | cubeContract.setTreasury(TREASURY); 185 | vm.label(TREASURY, "Treasury"); 186 | 187 | cubeContract.setL3TokenAddress(address(l3Token)); 188 | cubeContract.setL3PaymentsEnabled(true); 189 | cubeContract.withdraw(); 190 | vm.stopPrank(); 191 | } 192 | 193 | function _mintCube() internal { 194 | CUBE.CubeData memory _data = helper.getCubeData({ 195 | _feeRecipient: makeAddr("feeRecipient"), 196 | _mintTo: makeAddr("mintTo"), 197 | factoryAddress: address(factoryContract), 198 | tokenAddress: address(erc20Mock), 199 | tokenId: 0, 200 | tokenType: ITokenType.TokenType.ERC20, 201 | rakeBps: 0, 202 | amount: 10, 203 | chainId: 137, 204 | rewardRecipientAddress: makeAddr("rewardRecipientAddress") 205 | }); 206 | _data.nonce = 0; 207 | 208 | bytes memory signature = _signCubeData(_data, adminPrivateKey); 209 | 210 | hoax(adminAddress, 10 ether); 211 | cubeContract.mintCube{value: 10 ether}(_data, signature); 212 | } 213 | 214 | function testPayWithL3() public { 215 | l3Token.mint(BOB, 1000); 216 | 217 | // let bob give some allowance to the contract 218 | vm.prank(BOB); 219 | l3Token.approve(address(cubeContract), 600); 220 | 221 | // test a mint and pay with erc20 222 | CUBE.CubeData memory _data = _getCubeData(20); 223 | _data.nonce = 0; 224 | _data.isNative = false; 225 | 226 | bytes memory signature = _signCubeData(_data, adminPrivateKey); 227 | vm.prank(BOB); 228 | cubeContract.mintCube(_data, signature); 229 | } 230 | 231 | function testPayWithL3RevertsWhenDisabled() public { 232 | vm.prank(ownerPubKey); 233 | cubeContract.setL3PaymentsEnabled(false); 234 | 235 | l3Token.mint(BOB, 1000); 236 | 237 | // let bob give some allowance to the contract 238 | vm.prank(BOB); 239 | l3Token.approve(address(cubeContract), 600); 240 | 241 | // test a mint and pay with erc20 242 | CUBE.CubeData memory _data = _getCubeData(20); 243 | _data.nonce = 0; 244 | _data.isNative = false; 245 | 246 | bytes memory signature = _signCubeData(_data, adminPrivateKey); 247 | vm.prank(BOB); 248 | vm.expectRevert(CUBE.CUBE__L3PaymentsDisabled.selector); 249 | cubeContract.mintCube(_data, signature); 250 | } 251 | 252 | function testPayWithL3LowAllowance() public { 253 | l3Token.mint(BOB, 1000); 254 | 255 | vm.prank(BOB); 256 | l3Token.approve(address(cubeContract), 300); // approve less than the price of 600 257 | 258 | // test a mint and pay with erc20 259 | CUBE.CubeData memory _data = _getCubeData(20); 260 | _data.nonce = 0; 261 | _data.isNative = false; 262 | 263 | bytes memory signature = _signCubeData(_data, adminPrivateKey); 264 | 265 | // hoax(adminAddress, 10 ether); 266 | vm.prank(BOB); 267 | vm.expectRevert(); 268 | cubeContract.mintCube(_data, signature); 269 | } 270 | 271 | function testPayWithL3LowBalance() public { 272 | l3Token.mint(BOB, 200); 273 | 274 | vm.prank(BOB); 275 | l3Token.approve(address(cubeContract), 600); // approve less than the price of 600 276 | 277 | // test a mint and pay with erc20 278 | CUBE.CubeData memory _data = _getCubeData(20); 279 | _data.nonce = 0; 280 | _data.isNative = false; 281 | 282 | bytes memory signature = _signCubeData(_data, adminPrivateKey); 283 | 284 | // hoax(adminAddress, 10 ether); 285 | vm.prank(BOB); 286 | vm.expectRevert(); 287 | cubeContract.mintCube(_data, signature); 288 | } 289 | 290 | function fundEscrowContract() internal { 291 | // native 292 | uint256 amount = 100 ether; 293 | (bool success,) = address(mockEscrow).call{value: amount}(""); 294 | require(success, "native deposit failed"); 295 | 296 | // erc721 297 | erc721Mock.safeTransferFrom(adminAddress, address(mockEscrow), 2); 298 | 299 | // erc20 300 | uint256 erc20Amount = 10e18; 301 | erc20Mock.mint(address(mockEscrow), erc20Amount); 302 | 303 | // erc1155 304 | erc1155Mock.mint(address(mockEscrow), 1e18, 0); 305 | erc1155Mock.mint(address(adminAddress), 1e18, 0); 306 | } 307 | 308 | function testWithdrawFundsWhenQuestInactive() public { 309 | vm.startPrank(adminAddress); 310 | 311 | erc20Mock.transfer(address(mockEscrow), 100); 312 | 313 | // withdrawal should revert if quest is still active 314 | bool isQuestActive = cubeContract.isQuestActive(1); 315 | assert(isQuestActive == true); 316 | 317 | vm.expectRevert(Factory.Factory__CUBEQuestIsActive.selector); 318 | factoryContract.withdrawFunds(1, ALICE, address(erc20Mock), 0, ITokenType.TokenType.ERC20); 319 | 320 | uint256 escrowBalanceBefore = erc20Mock.balanceOf(address(mockEscrow)); 321 | 322 | cubeContract.unpublishQuest(1); 323 | bool isQuestActive2 = cubeContract.isQuestActive(1); 324 | assert(isQuestActive2 == false); 325 | 326 | factoryContract.withdrawFunds(1, BOB, address(erc20Mock), 0, ITokenType.TokenType.ERC20); 327 | vm.stopPrank(); 328 | 329 | assert(erc20Mock.balanceOf(BOB) == escrowBalanceBefore); 330 | } 331 | 332 | function testInitializeQuest() public { 333 | uint256 questId = 1; 334 | string[] memory communities = new string[](2); 335 | communities[0] = "Community1"; 336 | communities[1] = "Community2"; 337 | string memory title = "Quest Title"; 338 | CUBE.Difficulty difficulty = CUBE.Difficulty.BEGINNER; 339 | CUBE.QuestType questType = CUBE.QuestType.QUEST; 340 | string[] memory tags = new string[](1); 341 | tags[0] = "DeFi"; 342 | 343 | // Expecting QuestMetadata events to be emitted 344 | vm.expectEmit(true, true, false, true); 345 | emit QuestMetadata(questId, questType, difficulty, title, tags, communities); 346 | 347 | vm.prank(adminAddress); 348 | cubeContract.initializeQuest(questId, communities, title, difficulty, questType, tags); 349 | } 350 | 351 | function testInitializeQuestNotAsSigner() public { 352 | uint256 questId = 1; 353 | string[] memory communities = new string[](2); 354 | 355 | communities[0] = "Community1"; 356 | communities[1] = "Community2"; 357 | string memory title = "Quest Title"; 358 | CUBE.Difficulty difficulty = CUBE.Difficulty.BEGINNER; 359 | CUBE.QuestType questType = CUBE.QuestType.QUEST; 360 | 361 | string[] memory tags = new string[](1); 362 | tags[0] = "DeFi"; 363 | 364 | bytes4 selector = bytes4(keccak256("AccessControlUnauthorizedAccount(address,bytes32)")); 365 | bytes memory expectedError = abi.encodeWithSelector(selector, ALICE, keccak256("SIGNER")); 366 | vm.expectRevert(expectedError); 367 | vm.prank(ALICE); 368 | cubeContract.initializeQuest(questId, communities, title, difficulty, questType, tags); 369 | } 370 | 371 | function testMintCubeNativeReward() public { 372 | uint256 rake = 300; 373 | (CUBE.CubeData memory cubeData, bytes memory signature) = _getCustomSignedCubeMintData( 374 | address(0), 0, 2 ether, ITokenType.TokenType.NATIVE, rake, 137, BOB 375 | ); 376 | 377 | // Get initial balances 378 | uint256 initialBobBalanceEth = address(BOB).balance; 379 | uint256 initialBobBalanceCube = cubeContract.balanceOf(BOB); 380 | 381 | hoax(adminAddress, 10 ether); 382 | 383 | cubeContract.mintCube{value: 600}(cubeData, signature); 384 | 385 | // Verify bob's balances are correct 386 | assertEq(cubeContract.balanceOf(BOB), initialBobBalanceCube + 1); 387 | assertEq(address(BOB).balance, initialBobBalanceEth + 2 ether - _getRakeAmount(2 ether, rake)); 388 | } 389 | 390 | function testMintCubeNativeRewardWithUniqueRewardRecipientAddress() public { 391 | uint256 rake = 0; 392 | address BOB_SMART_ACCOUNT = makeAddr("bob's smart account"); 393 | (CUBE.CubeData memory cubeData, bytes memory signature) = _getCustomSignedCubeMintData( 394 | address(0), 0, 2 ether, ITokenType.TokenType.NATIVE, rake, 137, BOB_SMART_ACCOUNT 395 | ); 396 | 397 | // Get initial balances 398 | uint256 initialBobBalanceEth = address(BOB).balance; // 0 399 | uint256 initialBobBalanceCube = cubeContract.balanceOf(BOB); // 0 400 | uint256 initialBobSmartAccountBalanceCube = cubeContract.balanceOf(BOB_SMART_ACCOUNT); // 0 401 | uint256 initialBobSmartAccountBalanceEth = address(BOB_SMART_ACCOUNT).balance; // 0 402 | 403 | hoax(adminAddress, 10 ether); 404 | 405 | cubeContract.mintCube{value: 600}(cubeData, signature); 406 | 407 | // Verify bob's balances are correct, bob should have only received a CUBE 408 | assertEq(cubeContract.balanceOf(BOB), initialBobBalanceCube + 1); // 1 409 | assertEq(address(BOB).balance, initialBobBalanceEth); // 0 410 | 411 | // Verify bob's smart account's balances are correct, bob's smart account should have received the ERC20 token 412 | assertEq(cubeContract.balanceOf(BOB_SMART_ACCOUNT), initialBobSmartAccountBalanceCube); // 0 413 | assertEq(address(BOB_SMART_ACCOUNT).balance, initialBobSmartAccountBalanceEth + 2 ether); // 20 414 | } 415 | 416 | function testMintCubeNoReward() public { 417 | uint256 rake = 300; 418 | uint256 amount = 100; 419 | (CUBE.CubeData memory cubeData, bytes memory signature) = _getCustomSignedCubeMintData( 420 | address(erc20Mock), 0, amount, ITokenType.TokenType.ERC20, rake, 0, BOB 421 | ); 422 | 423 | hoax(adminAddress, 10 ether); 424 | cubeContract.mintCube{value: 1 ether}(cubeData, signature); 425 | 426 | uint256 bobBal = erc20Mock.balanceOf(BOB); 427 | assert(bobBal == 0); 428 | assert(erc20Mock.balanceOf(TREASURY) == 0); 429 | } 430 | 431 | function testMintCubeERC20Reward() public { 432 | uint256 rake = 300; // 3% 433 | uint256 amount = 100; 434 | (CUBE.CubeData memory cubeData, bytes memory signature) = _getCustomSignedCubeMintData( 435 | address(erc20Mock), 0, amount, ITokenType.TokenType.ERC20, rake, 137, BOB 436 | ); 437 | 438 | hoax(adminAddress, 10 ether); 439 | cubeContract.mintCube{value: 1 ether}(cubeData, signature); 440 | 441 | uint256 bobBal = erc20Mock.balanceOf(BOB); 442 | uint256 rakePayout = (amount * rake) / 10_000; 443 | assert(bobBal == amount - rakePayout); 444 | assert(erc20Mock.balanceOf(TREASURY) == rakePayout); 445 | } 446 | 447 | function testMintCubeERC721Reward() public { 448 | (CUBE.CubeData memory cubeData, bytes memory signature) = _getCustomSignedCubeMintData( 449 | address(erc721Mock), 2, 1, ITokenType.TokenType.ERC721, 1, 137, BOB 450 | ); 451 | 452 | hoax(adminAddress, 10 ether); 453 | cubeContract.mintCube{value: 1 ether}(cubeData, signature); 454 | 455 | address ownerOf = erc721Mock.ownerOf(2); 456 | assertEq(ownerOf, BOB); 457 | } 458 | 459 | function testMintCubeERC1155Reward() public { 460 | (CUBE.CubeData memory cubeData, bytes memory signature) = _getCustomSignedCubeMintData( 461 | address(erc1155Mock), 0, 2, ITokenType.TokenType.ERC1155, 0, 137, BOB 462 | ); 463 | 464 | bool isSigner = cubeContract.hasRole(keccak256("SIGNER"), adminAddress); 465 | assertEq(isSigner, true); 466 | 467 | hoax(adminAddress, 10 ether); 468 | 469 | cubeContract.mintCube{value: 10 ether}(cubeData, signature); 470 | 471 | assertEq(cubeContract.tokenURI(0), "ipfs://abc"); 472 | assertEq(cubeContract.ownerOf(0), BOB); 473 | 474 | uint256 bobBal = erc1155Mock.balanceOf(address(BOB), 0); 475 | assertEq(bobBal, 2); 476 | } 477 | 478 | function testDepositNativeToEscrow() public { 479 | uint256 preBalance = address(mockEscrow).balance; 480 | 481 | uint256 amount = 100 ether; 482 | hoax(adminAddress, amount); 483 | (bool success,) = address(mockEscrow).call{value: amount}(""); 484 | require(success, "native deposit failed"); 485 | 486 | uint256 postBalance = address(mockEscrow).balance; 487 | assertEq(postBalance, preBalance + amount); 488 | } 489 | 490 | function testDepositERC20ToEscrow() public { 491 | uint256 preBalance = erc20Mock.balanceOf(address(mockEscrow)); 492 | 493 | uint256 amount = 100; 494 | vm.prank(adminAddress); 495 | erc20Mock.transfer(address(mockEscrow), amount); 496 | 497 | uint256 postBalance = erc20Mock.balanceOf(address(mockEscrow)); 498 | 499 | assertEq(postBalance, preBalance + amount); 500 | } 501 | 502 | function testDepositERC1155ToEscrow() public { 503 | uint256 preBalance = erc1155Mock.balanceOf(address(mockEscrow), 0); 504 | 505 | vm.prank(adminAddress); 506 | uint256 amount = 100; 507 | erc1155Mock.safeTransferFrom(address(adminAddress), address(mockEscrow), 0, amount, "0x00"); 508 | 509 | uint256 postBalance = erc1155Mock.balanceOf(address(mockEscrow), 0); 510 | 511 | assertEq(postBalance, preBalance + amount); 512 | } 513 | 514 | function testDepositERC721ToEscrow() public { 515 | uint256 preBalance = erc721Mock.balanceOf(address(mockEscrow)); 516 | 517 | vm.prank(adminAddress); 518 | erc721Mock.safeTransferFrom(adminAddress, address(mockEscrow), 1); 519 | 520 | uint256 postBalance = erc721Mock.balanceOf(address(mockEscrow)); 521 | 522 | assertEq(postBalance, preBalance + 1); 523 | assertEq(erc721Mock.ownerOf(2), address(mockEscrow)); 524 | } 525 | 526 | function testMintCube() public { 527 | (CUBE.CubeData memory cubeData, bytes memory signature) = _getSignedCubeMintData(); 528 | 529 | bool isSigner = cubeContract.hasRole(keccak256("SIGNER"), adminAddress); 530 | assertEq(isSigner, true); 531 | 532 | hoax(adminAddress, 10 ether); 533 | 534 | cubeContract.mintCube{value: 10 ether}(cubeData, signature); 535 | 536 | assertEq(cubeContract.tokenURI(0), "ipfs://abc"); 537 | assertEq(cubeContract.ownerOf(0), BOB); 538 | } 539 | 540 | function testMintCubesRewardEvent() public { 541 | (CUBE.CubeData memory cubeData, bytes memory signature) = _getSignedCubeMintData(); 542 | 543 | hoax(adminAddress, 10 ether); 544 | 545 | // Expecting TokenReward event to be emitted 546 | vm.expectEmit(true, true, true, true); 547 | emit TokenReward(0, address(erc20Mock), 137, 100, 0, ITokenType.TokenType.ERC20, BOB); 548 | 549 | cubeContract.mintCube{value: 10 ether}(cubeData, signature); 550 | } 551 | 552 | function testMintCubesTxEvent() public { 553 | (CUBE.CubeData memory cubeData, bytes memory signature) = _getSignedCubeMintData(); 554 | 555 | hoax(adminAddress, 10 ether); 556 | 557 | // Expecting CubeTransaction event to be emitted 558 | vm.expectEmit(true, true, true, true); 559 | emit CubeTransaction( 560 | 0, "0xe265a54b4f6470f7f52bb1e4b19489b13d4a6d0c87e6e39c5d05c6639ec98002", "evm:137" 561 | ); 562 | 563 | cubeContract.mintCube{value: 10 ether}(cubeData, signature); 564 | } 565 | 566 | function testMintCubesClaimEvent() public { 567 | (CUBE.CubeData memory cubeData, bytes memory signature) = _getSignedCubeMintData(); 568 | 569 | hoax(adminAddress, 10 ether); 570 | 571 | // Expecting TokenReward events to be emitted 572 | vm.expectEmit(true, true, true, true); 573 | emit CubeClaim(1, 0, BOB, cubeData.isNative, cubeData.price, 1, "MetaMask", "test.com"); 574 | 575 | cubeContract.mintCube{value: 10 ether}(cubeData, signature); 576 | } 577 | 578 | function testNonceReuse() public { 579 | CUBE.CubeData memory data = _getCubeData(100); 580 | CUBE.CubeData memory data2 = _getCubeData(100); 581 | 582 | data.nonce = 1; 583 | data2.nonce = 1; 584 | 585 | bytes memory signature = _signCubeData(data, adminPrivateKey); 586 | bytes memory signature2 = _signCubeData(data2, adminPrivateKey); 587 | 588 | bytes[] memory signatures = new bytes[](2); 589 | CUBE.CubeData[] memory cubeData = new CUBE.CubeData[](2); 590 | 591 | signatures[0] = signature; 592 | signatures[1] = signature2; 593 | cubeData[0] = data; 594 | cubeData[1] = data2; 595 | 596 | hoax(adminAddress, 40 ether); 597 | cubeContract.mintCube{value: 20 ether}(cubeData[0], signatures[0]); 598 | vm.expectRevert(CUBE.CUBE__NonceAlreadyUsed.selector); 599 | cubeContract.mintCube{value: 20 ether}(cubeData[1], signatures[1]); 600 | } 601 | 602 | function testCubeMintDifferentSigners() public { 603 | CUBE.CubeData memory data = _getCubeData(100); 604 | CUBE.CubeData memory data2 = _getCubeData(100); 605 | 606 | data.nonce = 1; 607 | data2.nonce = 2; 608 | 609 | bytes memory signature = _signCubeData(data, adminPrivateKey); 610 | bytes memory signature2 = _signCubeData(data2, notAdminPrivKey); 611 | 612 | bytes[] memory signatures = new bytes[](2); 613 | CUBE.CubeData[] memory cubeData = new CUBE.CubeData[](2); 614 | 615 | signatures[0] = signature; 616 | signatures[1] = signature2; 617 | cubeData[0] = data; 618 | cubeData[1] = data2; 619 | 620 | hoax(adminAddress, 20 ether); 621 | cubeContract.mintCube{value: 20 ether}(cubeData[0], signatures[0]); 622 | vm.expectRevert(CUBE.CUBE__IsNotSigner.selector); 623 | cubeContract.mintCube{value: 20 ether}(cubeData[1], signatures[1]); 624 | } 625 | 626 | function testMultipleCubeDataMint() public { 627 | CUBE.CubeData memory data = _getCubeData(100); 628 | CUBE.CubeData memory data2 = _getCubeData(100); 629 | data2.nonce = 32142; 630 | 631 | bytes memory signature = _signCubeData(data, adminPrivateKey); 632 | bytes memory signature2 = _signCubeData(data2, adminPrivateKey); 633 | 634 | bytes[] memory signatures = new bytes[](2); 635 | CUBE.CubeData[] memory cubeData = new CUBE.CubeData[](2); 636 | 637 | signatures[0] = signature; 638 | signatures[1] = signature2; 639 | cubeData[0] = data; 640 | cubeData[1] = data2; 641 | 642 | hoax(adminAddress, 20 ether); 643 | cubeContract.mintCube{value: 10 ether}(cubeData[0], signatures[0]); 644 | cubeContract.mintCube{value: 10 ether}(cubeData[1], signatures[1]); 645 | assertEq(cubeContract.ownerOf(1), BOB); 646 | } 647 | 648 | function testEmptySignatureArray() public { 649 | CUBE.CubeData memory data = _getCubeData(100); 650 | 651 | hoax(adminAddress, 20 ether); 652 | vm.expectRevert(); 653 | cubeContract.mintCube{value: 20 ether}(data, new bytes(0)); 654 | } 655 | 656 | function testInvalidSignature() public { 657 | CUBE.CubeData memory _data = _getCubeData(100); 658 | 659 | // Sign the digest with a non-signer key 660 | bytes memory signature = _signCubeData(_data, notAdminPrivKey); 661 | hoax(adminAddress, 10 ether); 662 | 663 | // Expect the mint to fail due to invalid signature 664 | vm.expectRevert(CUBE.CUBE__IsNotSigner.selector); 665 | cubeContract.mintCube{value: 10 ether}(_data, signature); 666 | } 667 | 668 | function testEmptyCubeDataTxs() public { 669 | CUBE.CubeData memory data = _getCubeData(100); 670 | data.transactions = new CUBE.TransactionData[](1); 671 | 672 | bytes memory signature = _signCubeData(data, adminPrivateKey); 673 | 674 | hoax(adminAddress, 10 ether); 675 | cubeContract.mintCube{value: 10 ether}(data, signature); 676 | } 677 | 678 | function testEmptyReferrals() public { 679 | CUBE.CubeData memory data = _getCubeData(100); 680 | data.recipients = new CUBE.FeeRecipient[](1); 681 | 682 | bytes memory signature = _signCubeData(data, adminPrivateKey); 683 | 684 | hoax(adminAddress, 10 ether); 685 | cubeContract.mintCube{value: 10 ether}(data, signature); 686 | } 687 | 688 | function testMultipleRefPayouts() public { 689 | CUBE.CubeData memory data = _getCubeData(200); 690 | data.recipients = new CUBE.FeeRecipient[](3); 691 | data.recipients[0] = CUBE.FeeRecipient({recipient: ALICE, BPS: 500, recipientType: CUBE.FeeRecipientType.LAYER3}); 692 | data.recipients[1] = CUBE.FeeRecipient({recipient: BOB, BPS: 4000, recipientType: CUBE.FeeRecipientType.LAYER3}); 693 | data.recipients[2] = CUBE.FeeRecipient({recipient: adminAddress, BPS: 4000, recipientType: CUBE.FeeRecipientType.LAYER3}); 694 | 695 | bytes memory signature = _signCubeData(data, adminPrivateKey); 696 | 697 | uint256 amount = 600; 698 | 699 | hoax(adminAddress, amount); 700 | cubeContract.mintCube{value: amount}(data, signature); 701 | 702 | assertEq(ALICE.balance, amount * 500 / 10_000); // 5% 703 | assertEq(BOB.balance, amount * 4000 / 10_000); // 40% 704 | assertEq(adminAddress.balance, amount * 4000 / 10_000); // 40% 705 | } 706 | 707 | function testExceedContractBalance() public { 708 | CUBE.CubeData memory data = _getCubeData(10 ether); 709 | data.recipients = new CUBE.FeeRecipient[](3); 710 | data.recipients[0] = CUBE.FeeRecipient({recipient: ALICE, BPS: 500, recipientType: CUBE.FeeRecipientType.LAYER3}); 711 | data.recipients[1] = CUBE.FeeRecipient({recipient: BOB, BPS: 8000, recipientType: CUBE.FeeRecipientType.LAYER3}); 712 | data.recipients[2] = CUBE.FeeRecipient({recipient: adminAddress, BPS: 8000, recipientType: CUBE.FeeRecipientType.LAYER3}); 713 | 714 | bytes memory signature = _signCubeData(data, adminPrivateKey); 715 | 716 | hoax(adminAddress, 10 ether); 717 | vm.expectRevert(CUBE.CUBE__ExcessiveFeePayout.selector); 718 | cubeContract.mintCube{value: 10 ether}(data, signature); 719 | 720 | // alice's balance should be 0 since contract tx reverted 721 | assertEq(ALICE.balance, 0); 722 | } 723 | 724 | function testTooHighReferrerBPS() public { 725 | CUBE.CubeData memory data = _getCubeData(100); 726 | data.recipients = new CUBE.FeeRecipient[](3); 727 | data.recipients[0] = CUBE.FeeRecipient({recipient: ALICE, BPS: 500, recipientType: CUBE.FeeRecipientType.LAYER3}); 728 | data.recipients[1] = CUBE.FeeRecipient({ 729 | recipient: BOB, 730 | BPS: 10_001, // max is 10k 731 | recipientType: CUBE.FeeRecipientType.LAYER3 732 | }); 733 | data.recipients[2] = CUBE.FeeRecipient({recipient: adminAddress, BPS: 4000, recipientType: CUBE.FeeRecipientType.LAYER3}); 734 | 735 | bytes memory signature = _signCubeData(data, adminPrivateKey); 736 | 737 | hoax(adminAddress, 10 ether); 738 | vm.expectRevert(CUBE.CUBE__BPSTooHigh.selector); 739 | cubeContract.mintCube{value: 10 ether}(data, signature); 740 | } 741 | 742 | function testTooHighReferralAmount() public { 743 | CUBE.CubeData memory data = _getCubeData(100); 744 | data.recipients = new CUBE.FeeRecipient[](3); 745 | data.recipients[0] = CUBE.FeeRecipient({recipient: ALICE, BPS: 500, recipientType: CUBE.FeeRecipientType.LAYER3}); 746 | data.recipients[1] = CUBE.FeeRecipient({recipient: BOB, BPS: 9000, recipientType: CUBE.FeeRecipientType.LAYER3}); 747 | data.recipients[2] = CUBE.FeeRecipient({recipient: adminAddress, BPS: 4000, recipientType: CUBE.FeeRecipientType.LAYER3}); 748 | 749 | bytes memory signature = _signCubeData(data, adminPrivateKey); 750 | 751 | hoax(adminAddress, 10 ether); 752 | vm.expectRevert(CUBE.CUBE__ExcessiveFeePayout.selector); 753 | cubeContract.mintCube{value: 10 ether}(data, signature); 754 | assertEq(ALICE.balance, 0); 755 | } 756 | 757 | function testReuseSignature() public { 758 | CUBE.CubeData memory data = _getCubeData(100); 759 | 760 | bytes memory signature = _signCubeData(data, adminPrivateKey); 761 | 762 | hoax(adminAddress, 20 ether); 763 | cubeContract.mintCube{value: 20 ether}(data, signature); 764 | vm.expectRevert(CUBE.CUBE__NonceAlreadyUsed.selector); 765 | cubeContract.mintCube{value: 20 ether}(data, signature); 766 | } 767 | 768 | function testModifyNonceAfterSignature() public { 769 | CUBE.CubeData memory data = _getCubeData(100); 770 | 771 | bytes memory signature = _signCubeData(data, adminPrivateKey); 772 | 773 | // modify nonce 774 | CUBE.CubeData memory modData = data; 775 | modData.nonce = 324234; 776 | 777 | hoax(adminAddress, 20 ether); 778 | 779 | // expect CUBE__IsNotSigner since we changed the data (nonce) 780 | // and we should not be able to recover the signer address 781 | vm.expectRevert(CUBE.CUBE__IsNotSigner.selector); 782 | cubeContract.mintCube{value: 20 ether}(modData, signature); 783 | } 784 | 785 | function testMultipleReferrers() public { 786 | CUBE.CubeData memory _data = _getCubeData(100); 787 | 788 | uint256 preOwnerBalance = TREASURY.balance; 789 | 790 | _data.recipients = new CUBE.FeeRecipient[](3); 791 | _data.recipients[0] = CUBE.FeeRecipient({recipient: ALICE, BPS: 500, recipientType: CUBE.FeeRecipientType.LAYER3}); 792 | _data.recipients[1] = CUBE.FeeRecipient({recipient: BOB, BPS: 800, recipientType: CUBE.FeeRecipientType.LAYER3}); 793 | _data.recipients[2] = CUBE.FeeRecipient({recipient: adminAddress, BPS: 1000, recipientType: CUBE.FeeRecipientType.LAYER3}); 794 | 795 | bytes memory signature = _signCubeData(_data, adminPrivateKey); 796 | 797 | uint256 amount = 600; 798 | 799 | hoax(adminAddress, amount); 800 | cubeContract.mintCube{value: amount}(_data, signature); 801 | 802 | uint256 expectedBalAlice = amount * 500 / 10_000; 803 | assertEq(ALICE.balance, expectedBalAlice); 804 | uint256 expectedBalBob = amount * 800 / 10_000; 805 | assertEq(BOB.balance, expectedBalBob); 806 | uint256 expectedBalAdmin = amount * 1000 / 10_000; 807 | assertEq(adminAddress.balance, expectedBalAdmin); 808 | // 23% taken by referrers, so 77% should be left 809 | uint256 expectedMintProfit = amount * 7700 / 10_000; 810 | assertEq(TREASURY.balance, expectedMintProfit); 811 | 812 | assertEq(TREASURY.balance - preOwnerBalance, expectedMintProfit); 813 | } 814 | 815 | function testReferralFees() public { 816 | uint256 preTreasuryBalance = TREASURY.balance; 817 | (CUBE.CubeData memory cubeData, bytes memory signatures) = _getSignedCubeMintData(); 818 | 819 | uint256 amount = 600; 820 | 821 | // send from admin 822 | hoax(adminAddress, amount); 823 | cubeContract.mintCube{value: amount}(cubeData, signatures); 824 | 825 | uint256 balanceAlice = ALICE.balance; 826 | uint256 balanceTreasury = TREASURY.balance; 827 | 828 | uint256 expectedBal = amount * 3300 / 10_000; 829 | 830 | assertEq(balanceAlice, expectedBal); 831 | assertEq(balanceTreasury, (amount - expectedBal) + preTreasuryBalance); 832 | } 833 | 834 | function testUnpublishQuest(uint256 questId) public { 835 | vm.startPrank(adminAddress); 836 | cubeContract.initializeQuest( 837 | questId, 838 | new string[](0), 839 | "", 840 | CUBE.Difficulty.BEGINNER, 841 | CUBE.QuestType.QUEST, 842 | new string[](0) 843 | ); 844 | bool isActive = cubeContract.isQuestActive(questId); 845 | assertEq(isActive, true); 846 | 847 | cubeContract.unpublishQuest(questId); 848 | 849 | vm.stopPrank(); 850 | bool isActive2 = cubeContract.isQuestActive(questId); 851 | assert(isActive2 == false); 852 | } 853 | 854 | function testInitalizeQuestEvent() public { 855 | uint256 questId = 123; 856 | string[] memory communities = new string[](1); 857 | communities[0] = "Community1"; 858 | string memory title = "Quest Title"; 859 | string[] memory tags = new string[](1); 860 | tags[0] = "NFTs"; 861 | CUBE.Difficulty difficulty = CUBE.Difficulty.BEGINNER; 862 | CUBE.QuestType questType = CUBE.QuestType.QUEST; 863 | 864 | vm.recordLogs(); 865 | emit QuestMetadata(questId, questType, difficulty, title, tags, communities); 866 | Vm.Log[] memory entries = vm.getRecordedLogs(); 867 | assertEq(entries.length, 1); 868 | assertEq(entries[0].topics[1], bytes32(uint256(questId))); 869 | } 870 | 871 | function testTurnOffMinting() public { 872 | bool isActive = cubeContract.s_isMintingActive(); 873 | 874 | vm.prank(ownerPubKey); 875 | cubeContract.setIsMintingActive(false); 876 | 877 | bool isActiveUpdated = cubeContract.s_isMintingActive(); 878 | 879 | assert(isActiveUpdated != isActive); 880 | } 881 | 882 | function test721Interface() public view { 883 | bool supportsInterface = cubeContract.supportsInterface(type(IERC721).interfaceId); 884 | assert(supportsInterface == true); 885 | } 886 | 887 | function testRevokeSignerRole() public { 888 | bytes32 signerRole = keccak256("SIGNER"); 889 | bool isSigner = cubeContract.hasRole(signerRole, adminAddress); 890 | assertEq(isSigner, true); 891 | 892 | vm.prank(adminAddress); 893 | cubeContract.renounceRole(signerRole, adminAddress); 894 | } 895 | 896 | function testRevokeAdminRole() public { 897 | bool isAdmin = cubeContract.hasRole(cubeContract.DEFAULT_ADMIN_ROLE(), ownerPubKey); 898 | assertEq(isAdmin, true); 899 | 900 | vm.startPrank(ownerPubKey); 901 | cubeContract.grantRole(cubeContract.DEFAULT_ADMIN_ROLE(), adminAddress); 902 | cubeContract.revokeRole(cubeContract.DEFAULT_ADMIN_ROLE(), ownerPubKey); 903 | 904 | bool isAdmin2 = cubeContract.hasRole(cubeContract.DEFAULT_ADMIN_ROLE(), adminAddress); 905 | assertEq(isAdmin2, true); 906 | vm.stopPrank(); 907 | } 908 | 909 | function testRotateAdmin() public { 910 | bool isAdmin = cubeContract.hasRole(cubeContract.DEFAULT_ADMIN_ROLE(), ownerPubKey); 911 | assertEq(isAdmin, true); 912 | 913 | vm.startPrank(ownerPubKey); 914 | cubeContract.grantRole(cubeContract.DEFAULT_ADMIN_ROLE(), ALICE); 915 | 916 | bool isAdmin2 = cubeContract.hasRole(cubeContract.DEFAULT_ADMIN_ROLE(), ALICE); 917 | assertEq(isAdmin2, true); 918 | 919 | cubeContract.renounceRole(cubeContract.DEFAULT_ADMIN_ROLE(), ownerPubKey); 920 | bool isAdmin3 = cubeContract.hasRole(cubeContract.DEFAULT_ADMIN_ROLE(), ownerPubKey); 921 | assertEq(isAdmin3, false); 922 | vm.stopPrank(); 923 | } 924 | 925 | function testGrantDefaultAdminRole() public { 926 | cubeContract.DEFAULT_ADMIN_ROLE(); 927 | 928 | bool isActive = cubeContract.s_isMintingActive(); 929 | assertEq(isActive, true); 930 | 931 | // call admin function with BOB, who's not admin 932 | // expect it to fail 933 | bytes4 selector = bytes4(keccak256("AccessControlUnauthorizedAccount(address,bytes32)")); 934 | bytes memory expectedError = 935 | abi.encodeWithSelector(selector, BOB, cubeContract.DEFAULT_ADMIN_ROLE()); 936 | vm.expectRevert(expectedError); 937 | vm.prank(BOB); 938 | cubeContract.setIsMintingActive(false); 939 | 940 | // still active, since the tx failed 941 | bool isActive2 = cubeContract.s_isMintingActive(); 942 | assertEq(isActive2, true); 943 | 944 | // grant admin role to BOB 945 | vm.startBroadcast(ownerPubKey); 946 | cubeContract.grantRole(cubeContract.DEFAULT_ADMIN_ROLE(), BOB); 947 | vm.stopBroadcast(); 948 | 949 | // let BOB turn minting to false 950 | vm.prank(BOB); 951 | cubeContract.setIsMintingActive(false); 952 | 953 | // should be false 954 | bool isActive3 = cubeContract.s_isMintingActive(); 955 | assertEq(isActive3, false); 956 | } 957 | 958 | function testInitializedNFT() public view { 959 | string memory name = cubeContract.name(); 960 | string memory symbol = cubeContract.symbol(); 961 | 962 | assertEq("Layer3 CUBE", name); 963 | assertEq("CUBE", symbol); 964 | } 965 | 966 | function testSetTrueMintingToTrueAgain() public { 967 | vm.prank(ownerPubKey); 968 | cubeContract.setIsMintingActive(true); 969 | assertEq(cubeContract.s_isMintingActive(), true); 970 | } 971 | 972 | function testSetFalseMintingToFalseAgain() public { 973 | vm.startPrank(ownerPubKey); 974 | cubeContract.setIsMintingActive(false); 975 | cubeContract.setIsMintingActive(false); 976 | vm.stopPrank(); 977 | assertEq(cubeContract.s_isMintingActive(), false); 978 | } 979 | 980 | modifier SetMintingToFalse() { 981 | vm.startBroadcast(ownerPubKey); 982 | cubeContract.setIsMintingActive(false); 983 | vm.stopBroadcast(); 984 | _; 985 | } 986 | 987 | function _getCubeData(uint256 amount) internal view returns (CUBE.CubeData memory) { 988 | return helper.getCubeData({ 989 | _feeRecipient: ALICE, 990 | _mintTo: BOB, 991 | factoryAddress: address(factoryContract), 992 | tokenAddress: address(erc20Mock), 993 | tokenId: 0, 994 | tokenType: ITokenType.TokenType.ERC20, 995 | rakeBps: 0, 996 | chainId: 137, 997 | amount: amount, 998 | rewardRecipientAddress: BOB 999 | }); 1000 | } 1001 | 1002 | function _signCubeData(CUBE.CubeData memory cubeData, uint256 privateKey) internal view returns (bytes memory) { 1003 | bytes32 structHash = helper.getStructHash(cubeData); 1004 | bytes32 digest = helper.getDigest(getDomainSeparator(), structHash); 1005 | (uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, digest); 1006 | return abi.encodePacked(r, s, v); 1007 | } 1008 | 1009 | struct Message { 1010 | bytes data; 1011 | uint256 nonce; 1012 | } 1013 | 1014 | function _getCustomSignedCubeMintData( 1015 | address token, 1016 | uint256 tokenId, 1017 | uint256 amount, 1018 | ITokenType.TokenType tokenType, 1019 | uint256 rakeBps, 1020 | uint256 chainId, 1021 | address rewardRecipientAddress 1022 | ) internal view returns (CUBE.CubeData memory, bytes memory) { 1023 | CUBE.CubeData memory _data = helper.getCubeData({ 1024 | _feeRecipient: ALICE, 1025 | _mintTo: BOB, 1026 | factoryAddress: address(factoryContract), 1027 | tokenAddress: token, 1028 | tokenId: tokenId, 1029 | tokenType: tokenType, 1030 | rakeBps: rakeBps, 1031 | amount: amount, 1032 | chainId: chainId, 1033 | rewardRecipientAddress: rewardRecipientAddress 1034 | }); 1035 | 1036 | bytes memory signature = _signCubeData(_data, adminPrivateKey); 1037 | return (_data, signature); 1038 | } 1039 | 1040 | function _getSignedCubeMintData() internal view returns (CUBE.CubeData memory, bytes memory) { 1041 | CUBE.CubeData memory _data = _getCubeData(100); 1042 | bytes memory signature = _signCubeData(_data, adminPrivateKey); 1043 | return (_data, signature); 1044 | } 1045 | 1046 | function _getRakeAmount(uint256 amount, uint256 rakeBps) internal pure returns (uint256) { 1047 | return (amount * rakeBps) / 10_000; // 10000 is the max BPS 1048 | } 1049 | 1050 | function testReferralPayouts() public { 1051 | CUBE.CubeData memory _data = _getCubeData(100); 1052 | vm.expectRevert(CUBE.CUBE__ExceedsContractBalance.selector); 1053 | 1054 | helper.processPayouts(_data); 1055 | } 1056 | 1057 | function testEmptyWithdrawal() public { 1058 | uint256 preBalanceCube = address(cubeContract).balance; 1059 | uint256 preBalance = ownerPubKey.balance; 1060 | vm.prank(ownerPubKey); 1061 | cubeContract.withdraw(); 1062 | uint256 postBal = ownerPubKey.balance; 1063 | assertEq(postBal, preBalance - preBalanceCube); 1064 | } 1065 | 1066 | function testNonAdminWithdrawal() public { 1067 | bytes4 selector = bytes4(keccak256("AccessControlUnauthorizedAccount(address,bytes32)")); 1068 | bytes memory expectedError = 1069 | abi.encodeWithSelector(selector, BOB, cubeContract.DEFAULT_ADMIN_ROLE()); 1070 | vm.expectRevert(expectedError); 1071 | vm.prank(BOB); 1072 | cubeContract.withdraw(); 1073 | } 1074 | 1075 | function testEmptyTokenURI() public view { 1076 | // get tokenURI for some random non-existing token 1077 | string memory uri = cubeContract.tokenURI(15); 1078 | assertEq(uri, ""); 1079 | } 1080 | 1081 | function testReInitializeContract() public { 1082 | // contract has already been initialized and we expect test to fail 1083 | vm.expectRevert(Initializable.InvalidInitialization.selector); 1084 | cubeContract.initialize("Test", "TEST", "Test", "2", ALICE); 1085 | } 1086 | 1087 | function testMintWithLowFee() public { 1088 | (CUBE.CubeData memory _data, bytes memory _sig) = _getSignedCubeMintData(); 1089 | vm.expectRevert(CUBE.CUBE__FeeNotEnough.selector); 1090 | cubeContract.mintCube{value: 10}(_data, _sig); 1091 | } 1092 | 1093 | function testCubeVersion() public view { 1094 | string memory v = cubeContract.cubeVersion(); 1095 | assertEq(v, "4"); 1096 | } 1097 | 1098 | function testMintWithNoFee() public { 1099 | (CUBE.CubeData memory _data, bytes memory _sig) = _getSignedCubeMintData(); 1100 | vm.expectRevert(CUBE.CUBE__FeeNotEnough.selector); 1101 | cubeContract.mintCube(_data, _sig); 1102 | } 1103 | 1104 | function testPayWithL3ZeroTreasuryAmount() public { 1105 | l3Token.mint(BOB, 1000); 1106 | vm.prank(BOB); 1107 | l3Token.approve(address(cubeContract), 600); 1108 | 1109 | // Create data where recipients BPS adds up to 10_000 (100%) 1110 | CUBE.CubeData memory _data = _getCubeData(20); 1111 | _data.nonce = 0; 1112 | _data.isNative = false; 1113 | _data.recipients = new CUBE.FeeRecipient[](2); 1114 | _data.recipients[0] = CUBE.FeeRecipient({recipient: ALICE, BPS: 5000, recipientType: CUBE.FeeRecipientType.LAYER3}); // 50% 1115 | _data.recipients[1] = CUBE.FeeRecipient({recipient: BOB, BPS: 5000, recipientType: CUBE.FeeRecipientType.LAYER3}); // 50% 1116 | 1117 | bytes memory signature = _signCubeData(_data, adminPrivateKey); 1118 | 1119 | vm.prank(BOB); 1120 | cubeContract.mintCube(_data, signature); 1121 | } 1122 | 1123 | function testPayWithL3ZeroRecipientAmount() public { 1124 | l3Token.mint(BOB, 1000); 1125 | vm.prank(BOB); 1126 | l3Token.approve(address(cubeContract), 600); 1127 | 1128 | // Create data with a recipient with 0 BPS 1129 | CUBE.CubeData memory _data = _getCubeData(20); 1130 | _data.nonce = 0; 1131 | _data.isNative = false; 1132 | _data.recipients = new CUBE.FeeRecipient[](2); 1133 | _data.recipients[0] = CUBE.FeeRecipient({recipient: ALICE, BPS: 0, recipientType: CUBE.FeeRecipientType.LAYER3}); // 0% 1134 | _data.recipients[1] = CUBE.FeeRecipient({recipient: BOB, BPS: 5000, recipientType: CUBE.FeeRecipientType.LAYER3}); // 50% 1135 | 1136 | bytes memory signature = _signCubeData(_data, adminPrivateKey); 1137 | 1138 | vm.prank(BOB); 1139 | cubeContract.mintCube(_data, signature); 1140 | 1141 | // Check that ALICE received nothing 1142 | assertEq(l3Token.balanceOf(ALICE), 0); 1143 | } 1144 | 1145 | function testPayWithL3ZeroAddressRecipient() public { 1146 | l3Token.mint(BOB, 1000); 1147 | vm.prank(BOB); 1148 | l3Token.approve(address(cubeContract), 600); 1149 | 1150 | uint256 initialBobBalance = l3Token.balanceOf(BOB); 1151 | 1152 | // Create data with address(0) recipient 1153 | CUBE.CubeData memory _data = _getCubeData(20); 1154 | _data.nonce = 0; 1155 | _data.isNative = false; 1156 | _data.recipients = new CUBE.FeeRecipient[](2); 1157 | _data.recipients[0] = CUBE.FeeRecipient({recipient: address(0), BPS: 3000, recipientType: CUBE.FeeRecipientType.LAYER3}); // 30% 1158 | _data.recipients[1] = CUBE.FeeRecipient({recipient: BOB, BPS: 5000, recipientType: CUBE.FeeRecipientType.LAYER3}); // 50% 1159 | 1160 | bytes memory signature = _signCubeData(_data, adminPrivateKey); 1161 | 1162 | vm.prank(BOB); 1163 | cubeContract.mintCube(_data, signature); 1164 | 1165 | // Check that BOB received their share (50% of 600 = 300) 1166 | assertEq(l3Token.balanceOf(BOB), initialBobBalance - 600 + 300); // Subtract initial transfer, add share 1167 | } 1168 | 1169 | function testPayWithL3TreasuryBalanceCheck() public { 1170 | l3Token.mint(BOB, 1000); 1171 | vm.prank(BOB); 1172 | l3Token.approve(address(cubeContract), 600); 1173 | 1174 | uint256 initialTreasuryBalance = l3Token.balanceOf(TREASURY); 1175 | 1176 | CUBE.CubeData memory _data = _getCubeData(20); 1177 | _data.nonce = 0; 1178 | _data.isNative = false; 1179 | _data.recipients = new CUBE.FeeRecipient[](1); 1180 | _data.recipients[0] = CUBE.FeeRecipient({recipient: ALICE, BPS: 3000, recipientType: CUBE.FeeRecipientType.LAYER3}); // 30% 1181 | 1182 | bytes memory signature = _signCubeData(_data, adminPrivateKey); 1183 | 1184 | vm.prank(BOB); 1185 | cubeContract.mintCube(_data, signature); 1186 | 1187 | // Check that treasury received remaining 70% 1188 | assertEq(l3Token.balanceOf(TREASURY), initialTreasuryBalance + (7000 * 600 / 10_000)); 1189 | } 1190 | 1191 | function testPayWithL3ZeroPrice() public { 1192 | l3Token.mint(BOB, 1000); 1193 | vm.prank(BOB); 1194 | 1195 | uint256 initialBobBalance = l3Token.balanceOf(BOB); 1196 | uint256 initialTreasuryBalance = l3Token.balanceOf(TREASURY); 1197 | 1198 | CUBE.CubeData memory _data = _getCubeData(20); 1199 | _data.nonce = 0; 1200 | _data.isNative = false; 1201 | _data.price = 0; // Set price to 0 1202 | _data.recipients = new CUBE.FeeRecipient[](3); // Multiple recipients 1203 | _data.recipients[0] = CUBE.FeeRecipient({recipient: ALICE, BPS: 3000, recipientType: CUBE.FeeRecipientType.LAYER3}); 1204 | _data.recipients[1] = CUBE.FeeRecipient({recipient: BOB, BPS: 5000, recipientType: CUBE.FeeRecipientType.LAYER3}); 1205 | _data.recipients[2] = CUBE.FeeRecipient({recipient: address(this), BPS: 1000, recipientType: CUBE.FeeRecipientType.LAYER3}); 1206 | 1207 | bytes memory signature = _signCubeData(_data, adminPrivateKey); 1208 | 1209 | vm.prank(BOB); 1210 | cubeContract.mintCube(_data, signature); 1211 | 1212 | // Verify no balances changed 1213 | assertEq(l3Token.balanceOf(BOB), initialBobBalance); 1214 | assertEq(l3Token.balanceOf(TREASURY), initialTreasuryBalance); 1215 | assertEq(l3Token.balanceOf(ALICE), 0); 1216 | } 1217 | 1218 | function testPayWithL3WithUniqueRewardRecipientAddress() public { 1219 | address BOB_SMART_ACCOUNT = makeAddr("bob's smart account"); 1220 | l3Token.mint(BOB_SMART_ACCOUNT, 1000); 1221 | 1222 | // let bob's smart account give some allowance to the contract 1223 | vm.prank(BOB_SMART_ACCOUNT); 1224 | l3Token.approve(address(cubeContract), 600); 1225 | 1226 | CUBE.CubeData memory _data = helper.getCubeData({ 1227 | _feeRecipient: ALICE, 1228 | _mintTo: BOB, 1229 | factoryAddress: address(factoryContract), 1230 | tokenAddress: address(erc20Mock), 1231 | tokenId: 0, 1232 | tokenType: ITokenType.TokenType.ERC20, 1233 | rakeBps: 0, 1234 | chainId: 137, 1235 | amount: 20, 1236 | rewardRecipientAddress: BOB_SMART_ACCOUNT 1237 | }); 1238 | _data.nonce = 0; 1239 | _data.isNative = false; 1240 | _data.price = 100; 1241 | 1242 | bytes memory signature = _signCubeData(_data, adminPrivateKey); 1243 | 1244 | // Get initial balances 1245 | uint256 initialBobBalanceL3 = l3Token.balanceOf(BOB); // 0 1246 | uint256 initialBobBalanceCube = cubeContract.balanceOf(BOB); // 0 1247 | uint256 initialBobBalanceMockErc20 = erc20Mock.balanceOf(BOB); // 0 1248 | uint256 initialBobSmartAccountBalanceL3 = l3Token.balanceOf(BOB_SMART_ACCOUNT); // 1000 1249 | uint256 initialBobSmartAccountBalanceCube = cubeContract.balanceOf(BOB_SMART_ACCOUNT); // 0 1250 | uint256 initialBobSmartAccountBalanceMockErc20 = erc20Mock.balanceOf(BOB_SMART_ACCOUNT); // 0 1251 | 1252 | vm.prank(BOB_SMART_ACCOUNT); 1253 | cubeContract.mintCube(_data, signature); 1254 | 1255 | // Verify bob's balances are correct, bob should have only received a CUBE 1256 | assertEq(l3Token.balanceOf(BOB), initialBobBalanceL3); // 0 1257 | assertEq(cubeContract.balanceOf(BOB), initialBobBalanceCube + 1); // 1 1258 | assertEq(erc20Mock.balanceOf(BOB), initialBobBalanceMockErc20); // 0 1259 | 1260 | // Verify bob's smart account's balances are correct, bob's smart account should 1261 | // have spent 100 L3 and received the ERC20 token 1262 | assertEq(l3Token.balanceOf(BOB_SMART_ACCOUNT), initialBobSmartAccountBalanceL3 - 100); // 900 1263 | assertEq(cubeContract.balanceOf(BOB_SMART_ACCOUNT), initialBobSmartAccountBalanceCube); // 0 1264 | assertEq(erc20Mock.balanceOf(BOB_SMART_ACCOUNT), initialBobSmartAccountBalanceMockErc20 + 20); // 20 1265 | } 1266 | 1267 | function testSetL3PaymentsEnabled() public { 1268 | // Check initial state 1269 | assertTrue(cubeContract.s_l3PaymentsEnabled()); 1270 | 1271 | // Test disabling 1272 | vm.prank(ownerPubKey); 1273 | cubeContract.setL3PaymentsEnabled(false); 1274 | assertFalse(cubeContract.s_l3PaymentsEnabled()); 1275 | 1276 | // Test enabling 1277 | vm.prank(ownerPubKey); 1278 | cubeContract.setL3PaymentsEnabled(true); 1279 | assertTrue(cubeContract.s_l3PaymentsEnabled()); 1280 | } 1281 | 1282 | function testSetL3PaymentsEnabledRevertsWhenNotAdmin() public { 1283 | bytes4 selector = bytes4(keccak256("AccessControlUnauthorizedAccount(address,bytes32)")); 1284 | bytes memory expectedError = 1285 | abi.encodeWithSelector(selector, BOB, cubeContract.DEFAULT_ADMIN_ROLE()); 1286 | vm.expectRevert(expectedError); 1287 | vm.prank(BOB); 1288 | cubeContract.setL3PaymentsEnabled(false); 1289 | } 1290 | 1291 | function testFeePayoutEventWithRecipientTypes() public { 1292 | l3Token.mint(BOB, 1000); 1293 | vm.prank(BOB); 1294 | l3Token.approve(address(cubeContract), 600); 1295 | 1296 | CUBE.CubeData memory _data = _getCubeData(20); 1297 | _data.nonce = 0; 1298 | _data.isNative = false; 1299 | _data.recipients = new CUBE.FeeRecipient[](4); 1300 | _data.recipients[0] = CUBE.FeeRecipient({ 1301 | recipient: ALICE, 1302 | BPS: 1000, 1303 | recipientType: CUBE.FeeRecipientType.LAYER3 1304 | }); 1305 | _data.recipients[1] = CUBE.FeeRecipient({ 1306 | recipient: BOB, 1307 | BPS: 2000, 1308 | recipientType: CUBE.FeeRecipientType.PUBLISHER 1309 | }); 1310 | _data.recipients[2] = CUBE.FeeRecipient({ 1311 | recipient: address(this), 1312 | BPS: 1000, 1313 | recipientType: CUBE.FeeRecipientType.CREATOR 1314 | }); 1315 | _data.recipients[3] = CUBE.FeeRecipient({ 1316 | recipient: adminAddress, 1317 | BPS: 1000, 1318 | recipientType: CUBE.FeeRecipientType.REFERRER 1319 | }); 1320 | 1321 | bytes memory signature = _signCubeData(_data, adminPrivateKey); 1322 | 1323 | // Expect FeePayout events with correct recipient types 1324 | vm.expectEmit(true, true, true, true); 1325 | emit FeePayout(ALICE, 60, false, CUBE.FeeRecipientType.LAYER3); 1326 | 1327 | vm.expectEmit(true, true, true, true); 1328 | emit FeePayout(BOB, 120, false, CUBE.FeeRecipientType.PUBLISHER); 1329 | 1330 | vm.expectEmit(true, true, true, true); 1331 | emit FeePayout(address(this), 60, false, CUBE.FeeRecipientType.CREATOR); 1332 | 1333 | vm.expectEmit(true, true, true, true); 1334 | emit FeePayout(adminAddress, 60, false, CUBE.FeeRecipientType.REFERRER); 1335 | 1336 | vm.prank(BOB); 1337 | cubeContract.mintCube(_data, signature); 1338 | } 1339 | 1340 | function testTokenRewardEventWithRewardRecipientAddress() public { 1341 | address rewardRecipientAddress = makeAddr("rewardRecipientAddress"); 1342 | CUBE.CubeData memory _data = helper.getCubeData({ 1343 | _feeRecipient: ALICE, 1344 | _mintTo: BOB, 1345 | factoryAddress: address(factoryContract), 1346 | tokenAddress: address(erc20Mock), 1347 | tokenId: 0, 1348 | tokenType: ITokenType.TokenType.ERC20, 1349 | rakeBps: 0, 1350 | chainId: 137, 1351 | amount: 100, 1352 | rewardRecipientAddress: rewardRecipientAddress 1353 | }); 1354 | 1355 | bytes memory signature = _signCubeData(_data, adminPrivateKey); 1356 | 1357 | // Expect TokenReward event with reward recipient 1358 | vm.expectEmit(true, true, true, true); 1359 | emit TokenReward( 1360 | 0, address(erc20Mock), 137, 100, 0, ITokenType.TokenType.ERC20, rewardRecipientAddress 1361 | ); 1362 | 1363 | hoax(adminAddress, 10 ether); 1364 | cubeContract.mintCube{value: 10 ether}(_data, signature); 1365 | } 1366 | 1367 | function testRecipientTypeEnumValues() public pure { 1368 | // Test that enum values are as expected 1369 | assertEq(uint256(CUBE.FeeRecipientType.LAYER3), 0); 1370 | assertEq(uint256(CUBE.FeeRecipientType.PUBLISHER), 1); 1371 | assertEq(uint256(CUBE.FeeRecipientType.CREATOR), 2); 1372 | assertEq(uint256(CUBE.FeeRecipientType.REFERRER), 3); 1373 | } 1374 | } 1375 | -------------------------------------------------------------------------------- /test/unit/Escrow.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity 0.8.20; 3 | 4 | import {Test, console, Vm, stdError} from "forge-std/Test.sol"; 5 | 6 | import {DeployEscrow} from "../../script/DeployEscrow.s.sol"; 7 | import {Escrow} from "../../src/escrow/Escrow.sol"; 8 | 9 | import {MockERC20} from "../mock/MockERC20.sol"; 10 | import {MockERC721} from "../mock/MockERC721.sol"; 11 | import {MockERC1155} from "../mock/MockERC1155.sol"; 12 | 13 | import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; 14 | import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol"; 15 | import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; 16 | import {ERC1155} from "@openzeppelin/contracts/token/ERC1155/ERC1155.sol"; 17 | 18 | contract EscrowTest is Test { 19 | DeployEscrow public deployer; 20 | 21 | string constant SIGNATURE_DOMAIN = "LAYER3"; 22 | string constant SIGNING_VERSION = "1"; 23 | 24 | uint256 internal ownerPrivateKey; 25 | address internal ownerPubKey; 26 | 27 | address internal realAccount; 28 | uint256 internal realPrivateKey; 29 | 30 | uint256 constant MAX_BPS = 10_000; 31 | 32 | // Test Users 33 | address public adminAddress; 34 | address public admin; 35 | uint256 internal adminPrivateKey; 36 | address public alice = makeAddr("alice"); 37 | address public bob = makeAddr("bob"); 38 | address public treasury = makeAddr("treasury"); 39 | 40 | address public notAdminAddress; 41 | uint256 internal notAdminPrivKey; 42 | 43 | address public escrowAddr; 44 | Escrow public escrowMock; 45 | MockERC20 public erc20Mock; 46 | MockERC721 public erc721Mock; 47 | MockERC1155 public erc1155Mock; 48 | 49 | address[] public whitelistedTokens; 50 | 51 | function setUp() public { 52 | ownerPrivateKey = 0xA11CE; 53 | ownerPubKey = vm.addr(ownerPrivateKey); 54 | 55 | adminPrivateKey = 0x01; 56 | adminAddress = vm.addr(adminPrivateKey); 57 | 58 | notAdminPrivKey = 0x099; 59 | notAdminAddress = vm.addr(notAdminPrivKey); 60 | 61 | // deploy all necessary contracts and set up dependencies 62 | deployer = new DeployEscrow(); 63 | (,, address erc20, address erc721, address erc1155) = 64 | deployer.run(adminAddress, treasury, address(0)); 65 | 66 | // tokens to be whitelisted in escrow 67 | whitelistedTokens.push(address(erc20)); 68 | whitelistedTokens.push(address(erc721)); 69 | whitelistedTokens.push(address(erc1155)); 70 | 71 | vm.broadcast(adminAddress); 72 | escrowMock = new Escrow(adminAddress, whitelistedTokens, treasury); 73 | escrowAddr = address(escrowMock); 74 | erc20Mock = MockERC20(erc20); 75 | erc721Mock = MockERC721(erc721); 76 | erc1155Mock = MockERC1155(erc1155); 77 | } 78 | 79 | /////////////////////////////////////////////////////////////////////// 80 | //////////////////////////// DEPOSIT ////////////////////////////////// 81 | /////////////////////////////////////////////////////////////////////// 82 | function testDepositNative(uint256 amount) public { 83 | hoax(adminAddress, amount); 84 | uint256 preBalEscrow = escrowAddr.balance; 85 | uint256 preBalAdmin = adminAddress.balance; 86 | 87 | (bool success,) = address(escrowAddr).call{value: amount}(""); 88 | require(success, "native deposit failed"); 89 | 90 | uint256 postBalEscrow = escrowAddr.balance; 91 | uint256 postBalAdmin = adminAddress.balance; 92 | 93 | assertEq(postBalEscrow, preBalEscrow + amount); 94 | assertEq(postBalAdmin, preBalAdmin - amount); 95 | } 96 | 97 | function testDepositERC20(uint256 amount) public { 98 | uint256 preBalance = erc20Mock.balanceOf(escrowAddr); 99 | 100 | uint256 preBalanceAdmin = erc20Mock.balanceOf(adminAddress); 101 | if (amount > preBalanceAdmin) { 102 | return; 103 | } 104 | 105 | vm.startBroadcast(adminAddress); 106 | 107 | erc20Mock.transfer(escrowAddr, amount); 108 | vm.stopBroadcast(); 109 | 110 | uint256 postBalance = erc20Mock.balanceOf(escrowAddr); 111 | 112 | assertEq(postBalance, preBalance + amount); 113 | } 114 | 115 | function testDepositERC721() public { 116 | uint256 preBalance = erc721Mock.balanceOf(escrowAddr); 117 | vm.startBroadcast(adminAddress); 118 | erc721Mock.safeTransferFrom(adminAddress, escrowAddr, 2); 119 | vm.stopBroadcast(); 120 | 121 | uint256 postBalance = erc721Mock.balanceOf(escrowAddr); 122 | 123 | assertEq(postBalance, preBalance + 1); 124 | assertEq(erc721Mock.ownerOf(2), escrowAddr); 125 | } 126 | 127 | function testDepositERC1155() public { 128 | uint256 preBalance = erc1155Mock.balanceOf(escrowAddr, 0); 129 | vm.startBroadcast(adminAddress); 130 | erc1155Mock.safeTransferFrom(adminAddress, escrowAddr, 0, 1, "0x00"); 131 | vm.stopBroadcast(); 132 | 133 | uint256 postBalance = erc1155Mock.balanceOf(escrowAddr, 0); 134 | 135 | assertEq(postBalance, preBalance + 1); 136 | } 137 | 138 | /////////////////////////////////////////////////////////////////////// 139 | //////////////////////////// WITHDRAW ///////////////////////////////// 140 | /////////////////////////////////////////////////////////////////////// 141 | function testWithdrawNative() public { 142 | uint256 amount = 10 ether; 143 | testDepositNative(amount); 144 | 145 | uint256 rakeBps = 300; 146 | vm.startBroadcast(adminAddress); 147 | escrowMock.withdrawNative(bob, amount, rakeBps); 148 | vm.stopBroadcast(); 149 | 150 | uint256 postBalTreasury = treasury.balance; 151 | uint256 postBalBob = bob.balance; 152 | 153 | uint256 rakeFee = amount * rakeBps / MAX_BPS; 154 | 155 | assertEq(postBalBob, amount - rakeFee); 156 | assertEq(postBalTreasury, rakeFee); 157 | } 158 | 159 | function testWithdrawERC20() public { 160 | testDepositERC20(10e18); 161 | 162 | uint256 amount = 1e18; 163 | uint256 rakeBps = 300; 164 | vm.prank(adminAddress); 165 | escrowMock.withdrawERC20(address(erc20Mock), bob, amount, rakeBps); 166 | 167 | uint256 postBalTreasury = erc20Mock.balanceOf(treasury); 168 | uint256 postBalBob = erc20Mock.balanceOf(bob); 169 | 170 | uint256 rakeFee = amount * rakeBps / MAX_BPS; 171 | assertEq(postBalBob, amount - rakeFee); 172 | assertEq(postBalTreasury, rakeFee); 173 | } 174 | 175 | function testWithdrawERC721() public { 176 | testDepositERC721(); 177 | 178 | address preOwnerOf = erc721Mock.ownerOf(2); 179 | 180 | vm.startBroadcast(adminAddress); 181 | escrowMock.withdrawERC721(address(erc721Mock), bob, 2); 182 | vm.stopBroadcast(); 183 | 184 | address postOwnerOf = erc721Mock.ownerOf(2); 185 | 186 | assertEq(preOwnerOf, escrowAddr); 187 | assertEq(postOwnerOf, bob); 188 | } 189 | 190 | function testWithdrawERC1155() public { 191 | testDepositERC1155(); 192 | 193 | uint256 preBal = erc1155Mock.balanceOf(bob, 0); 194 | 195 | vm.prank(adminAddress); 196 | escrowMock.withdrawERC1155(address(erc1155Mock), bob, 1, 0); 197 | 198 | uint256 postBal = erc1155Mock.balanceOf(bob, 0); 199 | 200 | assertEq(preBal, 0); 201 | assertEq(postBal, 1); 202 | } 203 | 204 | function testWithdrawNotWhitelistedToken() public { 205 | vm.startBroadcast(adminAddress); 206 | 207 | // create and mint new token 208 | address token = _createERC20(adminAddress, 1e18); 209 | 210 | // deposit 211 | uint256 amount = 10; 212 | MockERC20(token).transfer(escrowAddr, amount); 213 | 214 | vm.expectRevert(Escrow.Escrow__TokenNotWhitelisted.selector); 215 | escrowMock.withdrawERC20(token, bob, amount, 300); 216 | 217 | vm.stopBroadcast(); 218 | } 219 | 220 | function testWithdrawZeroTokenAddress() public { 221 | vm.prank(adminAddress); 222 | vm.expectRevert(Escrow.Escrow__TokenNotWhitelisted.selector); 223 | escrowMock.withdrawERC20(address(0), bob, 10, 300); 224 | } 225 | 226 | function testWithdrawZeroToAddress() public { 227 | testDepositERC20(10e18); 228 | vm.prank(adminAddress); 229 | vm.expectRevert(); 230 | escrowMock.withdrawERC20(address(erc20Mock), address(0), 10, 300); 231 | } 232 | 233 | function testWithdrawNativeToZeroAddress(uint256 amount) public { 234 | vm.deal(adminAddress, amount); 235 | 236 | testDepositNative(amount); 237 | 238 | vm.prank(adminAddress); 239 | vm.expectRevert(Escrow.Escrow__ZeroAddress.selector); 240 | escrowMock.withdrawNative(address(0), amount, 300); 241 | } 242 | 243 | function testWhitelistToken() public { 244 | vm.startBroadcast(adminAddress); 245 | 246 | // create and mint new token 247 | address token = _createERC20(adminAddress, 1e18); 248 | 249 | // deposit 250 | uint256 amount = 10; 251 | MockERC20(token).transfer(escrowAddr, amount); 252 | 253 | // it'll revert since token isn't whitelisted 254 | vm.expectRevert(Escrow.Escrow__TokenNotWhitelisted.selector); 255 | escrowMock.withdrawERC20(token, bob, amount, 0); 256 | 257 | // whitelist token 258 | escrowMock.addTokenToWhitelist(token); 259 | 260 | // withdraw to bob 261 | escrowMock.withdrawERC20(token, bob, amount, 0); 262 | vm.stopBroadcast(); 263 | 264 | // verify balance 265 | uint256 balanceBob = MockERC20(token).balanceOf(bob); 266 | assertEq(amount, balanceBob); 267 | } 268 | 269 | /////////////////////////////////////////////////////////////////////// 270 | ////////////////////////// RAKE PAYOUTS /////////////////////////////// 271 | /////////////////////////////////////////////////////////////////////// 272 | function testWithdrawTooHighBPS() public { 273 | testDepositERC20(10e18); 274 | 275 | uint256 amount = 1e18; 276 | uint256 rakeBps = 10_001; // 10k (100% in bps) is max, make it overflow 277 | vm.startBroadcast(adminAddress); 278 | 279 | vm.expectRevert(Escrow.Escrow__InvalidRakeBps.selector); 280 | escrowMock.withdrawERC20(address(erc20Mock), bob, amount, rakeBps); 281 | vm.stopBroadcast(); 282 | } 283 | 284 | function testWithdrawZeroBPS() public { 285 | testDepositERC20(10e18); 286 | 287 | uint256 amount = 1e18; 288 | uint256 rakeBps = 0; 289 | vm.startBroadcast(adminAddress); 290 | escrowMock.withdrawERC20(address(erc20Mock), bob, amount, rakeBps); 291 | vm.stopBroadcast(); 292 | 293 | uint256 postBalTreasury = erc20Mock.balanceOf(treasury); 294 | uint256 postBalBob = erc20Mock.balanceOf(bob); 295 | 296 | uint256 rakeFee = amount * rakeBps / MAX_BPS; 297 | assertEq(postBalBob, amount - rakeFee); 298 | assertEq(postBalTreasury, 0); 299 | } 300 | 301 | function testWithdrawHigherThanBalance() public { 302 | testDepositERC20(1e18); 303 | 304 | uint256 amount = 10e18; 305 | uint256 rakeBps = 300; 306 | vm.startBroadcast(adminAddress); 307 | 308 | vm.expectRevert(Escrow.Escrow__InsufficientEscrowBalance.selector); 309 | escrowMock.withdrawERC20(address(erc20Mock), bob, amount, rakeBps); 310 | vm.stopBroadcast(); 311 | } 312 | 313 | /////////////////////////////////////////////////////////////////////// 314 | ///////////////////////// ACCESS CONTROL ////////////////////////////// 315 | /////////////////////////////////////////////////////////////////////// 316 | function testWithdrawNotAdmin() public { 317 | testDepositERC20(1e18); 318 | 319 | uint256 amount = 100; 320 | uint256 rakeBps = 300; 321 | vm.prank(alice); 322 | bytes4 selector = bytes4(keccak256("OwnableUnauthorizedAccount(address)")); 323 | bytes memory expectedError = abi.encodeWithSelector(selector, alice); 324 | vm.expectRevert(expectedError); 325 | escrowMock.withdrawERC20(address(erc20Mock), bob, amount, rakeBps); 326 | } 327 | 328 | function testChangeOwner() public { 329 | address token = makeAddr("someToken"); 330 | 331 | bytes4 selector = bytes4(keccak256("OwnableUnauthorizedAccount(address)")); 332 | bytes memory expectedError = abi.encodeWithSelector(selector, alice); 333 | vm.expectRevert(expectedError); 334 | vm.prank(alice); 335 | escrowMock.addTokenToWhitelist(token); 336 | 337 | address owner = escrowMock.owner(); 338 | assert(owner == adminAddress); 339 | 340 | vm.prank(adminAddress); 341 | escrowMock.transferOwnership(alice); 342 | 343 | address pendingOwner = escrowMock.pendingOwner(); 344 | assert(pendingOwner == alice); 345 | address stillOwner = escrowMock.owner(); 346 | assert(stillOwner == adminAddress); 347 | 348 | vm.prank(alice); 349 | escrowMock.acceptOwnership(); 350 | address newOwner = escrowMock.owner(); 351 | assert(newOwner == alice); 352 | 353 | vm.prank(alice); 354 | escrowMock.addTokenToWhitelist(token); 355 | 356 | assert(escrowMock.s_whitelistedTokens(token)); 357 | } 358 | 359 | function testRenounceOwnership() public { 360 | vm.prank(adminAddress); 361 | 362 | // we overwrite this function since there'll never be a case where we want to do this 363 | escrowMock.renounceOwnership(); 364 | 365 | // make sure transfership wasn't renounced 366 | address owner = escrowMock.owner(); 367 | assert(owner == adminAddress); 368 | } 369 | 370 | function testEscrowERC165Interface() public view { 371 | // ERC165 - 0x01ffc9a7 372 | assertEq(escrowMock.supportsInterface(0x01ffc9a7), true); 373 | } 374 | 375 | function testERC1155BatchDeposit() public { 376 | uint256[] memory ids = new uint256[](3); 377 | uint256[] memory amounts = new uint256[](3); 378 | address[] memory escrowAddresses = new address[](3); 379 | uint256 amount = 5; 380 | for (uint256 i = 0; i < amounts.length; i++) { 381 | ids[i] = i; 382 | amounts[i] = amount; 383 | escrowAddresses[i] = escrowAddr; 384 | erc1155Mock.mint(adminAddress, 10, i); 385 | } 386 | 387 | vm.startBroadcast(adminAddress); 388 | erc1155Mock.safeBatchTransferFrom(adminAddress, escrowAddr, ids, amounts, "0x00"); 389 | vm.stopBroadcast(); 390 | 391 | uint256[] memory postBalances = erc1155Mock.balanceOfBatch(escrowAddresses, ids); 392 | for (uint256 i = 0; i < 3; i++) { 393 | assertEq(postBalances[i], amount); 394 | } 395 | 396 | // withdraw 397 | vm.prank(adminAddress); 398 | uint256 tokenId = 1; 399 | 400 | escrowMock.withdrawERC1155(address(erc1155Mock), bob, amount, tokenId); 401 | 402 | uint256 escrow1155Balance = escrowMock.escrowERC1155Reserves(address(erc1155Mock), tokenId); 403 | assert(escrow1155Balance == 0); 404 | assert(erc1155Mock.balanceOf(bob, tokenId) == amount); 405 | } 406 | 407 | /////////////////////////////////////////////////////////////////////// 408 | ///////////////////////// HELPER FUNCTIONS //////////////////////////// 409 | /////////////////////////////////////////////////////////////////////// 410 | function _createERC20(address _to, uint256 _amount) internal returns (address) { 411 | address token = address(new MockERC20()); 412 | MockERC20(token).mint(_to, _amount); 413 | 414 | return token; 415 | } 416 | 417 | function depositERC721ToEscrow() public { 418 | IERC721(erc721Mock).transferFrom(adminAddress, escrowAddr, 1); 419 | } 420 | 421 | function testDepositWithoutData(uint256 amount) public { 422 | amount = bound(amount, 0, type(uint256).max); 423 | hoax(alice, amount); 424 | (bool success,) = escrowAddr.call{value: amount}(""); 425 | assert(success); 426 | assert(escrowAddr.balance == amount); 427 | } 428 | 429 | function testDepositWithData(uint256 amount) public { 430 | amount = bound(amount, 0, type(uint256).max); 431 | hoax(alice, amount); 432 | (bool success,) = escrowAddr.call{value: amount}("some data"); 433 | assert(success); 434 | assert(escrowAddr.balance == amount); 435 | } 436 | 437 | function depositNativeToEscrow() public { 438 | hoax(adminAddress, 50 ether); 439 | (bool success,) = payable(escrowAddr).call{value: 10 ether}(""); 440 | assert(success); 441 | } 442 | } 443 | -------------------------------------------------------------------------------- /test/unit/EscrowFactory.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity 0.8.20; 3 | 4 | import {DeployProxy} from "../../script/DeployProxy.s.sol"; 5 | import {DeployEscrow} from "../../script/DeployEscrow.s.sol"; 6 | import {CUBE} from "../../src/CUBE.sol"; 7 | import {CubeV2} from "../contracts/CubeV2.sol"; 8 | 9 | import {MockERC20} from "../mock/MockERC20.sol"; 10 | import {MockERC721} from "../mock/MockERC721.sol"; 11 | import {MockERC1155} from "../mock/MockERC1155.sol"; 12 | import {Test, console, Vm} from "forge-std/Test.sol"; 13 | import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; 14 | 15 | import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol"; 16 | import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; 17 | import {ERC1155} from "@openzeppelin/contracts/token/ERC1155/ERC1155.sol"; 18 | import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol"; 19 | 20 | import {Escrow} from "../../src/escrow/Escrow.sol"; 21 | import {Factory} from "../../src/escrow/Factory.sol"; 22 | import {ITokenType} from "../../src/escrow/interfaces/ITokenType.sol"; 23 | 24 | contract EscrowFactoryTest is Test { 25 | DeployEscrow public deployer; 26 | Factory public factoryContract; 27 | 28 | string constant SIGNATURE_DOMAIN = "LAYER3"; 29 | string constant SIGNING_VERSION = "1"; 30 | 31 | uint256 internal ownerPrivateKey; 32 | address internal ownerPubKey; 33 | 34 | address internal realAccount; 35 | uint256 internal realPrivateKey; 36 | 37 | // Test Users 38 | address public adminAddress; 39 | address public ADMIN = makeAddr("admin"); 40 | uint256 internal adminPrivateKey; 41 | address public ALICE = makeAddr("alice"); 42 | address public BOB = makeAddr("bob"); 43 | 44 | address public notAdminAddress; 45 | uint256 internal notAdminPrivKey; 46 | 47 | address public proxyAddress; 48 | DeployProxy public proxyDeployer; 49 | CUBE public cubeContract; 50 | 51 | address public factoryAddr; 52 | address public escrowAddr; 53 | Escrow public escrowMock; 54 | MockERC20 public erc20Mock; 55 | MockERC721 public erc721Mock; 56 | MockERC1155 public erc1155Mock; 57 | 58 | address[] public whitelistedTokens; 59 | 60 | address public treasury; 61 | 62 | event EscrowRegistered( 63 | address indexed registror, address indexed escrowAddress, uint256 indexed questId 64 | ); 65 | 66 | function setUp() public { 67 | ownerPrivateKey = 0xA11CE; 68 | ownerPubKey = vm.addr(ownerPrivateKey); 69 | 70 | adminPrivateKey = 0x01; 71 | adminAddress = vm.addr(adminPrivateKey); 72 | 73 | notAdminPrivKey = 0x099; 74 | notAdminAddress = vm.addr(notAdminPrivKey); 75 | 76 | treasury = makeAddr("treasury"); 77 | 78 | proxyDeployer = new DeployProxy(); 79 | proxyAddress = proxyDeployer.deployProxy(ownerPubKey); 80 | cubeContract = CUBE(payable(proxyAddress)); 81 | 82 | vm.startBroadcast(ownerPubKey); 83 | cubeContract.grantRole(cubeContract.SIGNER_ROLE(), adminAddress); 84 | vm.stopBroadcast(); 85 | 86 | // deploy all necessary contracts and set up dependencies 87 | deployer = new DeployEscrow(); 88 | (,, address _erc20Mock, address _erc721Mock, address _erc1155Mock) = 89 | deployer.run(adminAddress, treasury, proxyAddress); 90 | 91 | whitelistedTokens.push(address(_erc20Mock)); 92 | whitelistedTokens.push(address(_erc721Mock)); 93 | whitelistedTokens.push(address(_erc1155Mock)); 94 | 95 | factoryAddr = deployer.deployFactory(adminAddress, proxyAddress); 96 | factoryContract = Factory(payable(factoryAddr)); 97 | 98 | bool hasRole = factoryContract.hasRole(factoryContract.DEFAULT_ADMIN_ROLE(), adminAddress); 99 | assert(hasRole); 100 | 101 | uint256 questId = 0; 102 | vm.startPrank(adminAddress); 103 | factoryContract.createEscrow(questId, adminAddress, whitelistedTokens, treasury); 104 | vm.stopPrank(); 105 | 106 | escrowAddr = factoryContract.s_escrows(questId); 107 | escrowMock = Escrow(payable(escrowAddr)); 108 | 109 | assert(escrowMock.s_whitelistedTokens(_erc20Mock)); 110 | 111 | erc20Mock = MockERC20(_erc20Mock); 112 | erc721Mock = MockERC721(_erc721Mock); 113 | erc1155Mock = MockERC1155(_erc1155Mock); 114 | } 115 | 116 | function createEscrow(uint256 questId) public returns (uint256) { 117 | string[] memory communities = new string[](1); 118 | communities[0] = "test"; 119 | 120 | string[] memory tags = new string[](1); 121 | tags[0] = "DeFi"; 122 | 123 | vm.startPrank(adminAddress); 124 | cubeContract.initializeQuest( 125 | questId, communities, "Test Quest", CUBE.Difficulty.BEGINNER, CUBE.QuestType.QUEST, tags 126 | ); 127 | factoryContract.createEscrow(questId, adminAddress, whitelistedTokens, treasury); 128 | vm.stopPrank(); 129 | 130 | return questId; 131 | } 132 | 133 | function testDepositNative(uint256 amount) public { 134 | hoax(adminAddress, amount); 135 | uint256 preBalEscrow = escrowAddr.balance; 136 | uint256 preBalAdmin = adminAddress.balance; 137 | 138 | (bool success,) = address(escrowAddr).call{value: amount}(""); 139 | require(success, "native deposit failed"); 140 | 141 | uint256 postBalEscrow = escrowAddr.balance; 142 | uint256 postBalAdmin = adminAddress.balance; 143 | 144 | assertEq(postBalEscrow, preBalEscrow + amount); 145 | assertEq(postBalAdmin, preBalAdmin - amount); 146 | } 147 | 148 | function testDepositERC20(uint256 amount) public { 149 | uint256 preBalance = erc20Mock.balanceOf(escrowAddr); 150 | 151 | uint256 preBalanceAdmin = erc20Mock.balanceOf(adminAddress); 152 | if (amount > preBalanceAdmin) { 153 | return; 154 | } 155 | 156 | vm.startBroadcast(adminAddress); 157 | 158 | erc20Mock.transfer(escrowAddr, amount); 159 | vm.stopBroadcast(); 160 | 161 | uint256 postBalance = erc20Mock.balanceOf(escrowAddr); 162 | 163 | assertEq(postBalance, preBalance + amount); 164 | } 165 | 166 | function testDepositERC721() public { 167 | uint256 preBalance = erc721Mock.balanceOf(escrowAddr); 168 | vm.startBroadcast(adminAddress); 169 | erc721Mock.safeTransferFrom(adminAddress, escrowAddr, 2); 170 | vm.stopBroadcast(); 171 | 172 | uint256 postBalance = erc721Mock.balanceOf(escrowAddr); 173 | 174 | assertEq(postBalance, preBalance + 1); 175 | assertEq(erc721Mock.ownerOf(2), escrowAddr); 176 | } 177 | 178 | function testDepositERC1155() public { 179 | uint256 preBalance = erc1155Mock.balanceOf(escrowAddr, 0); 180 | vm.startBroadcast(adminAddress); 181 | erc1155Mock.safeTransferFrom(adminAddress, escrowAddr, 0, 1, "0x00"); 182 | vm.stopBroadcast(); 183 | 184 | uint256 postBalance = erc1155Mock.balanceOf(escrowAddr, 0); 185 | 186 | assertEq(postBalance, preBalance + 1); 187 | } 188 | 189 | function testCreateEscrow(uint256 questId, uint256 amount) public { 190 | questId = bound(questId, 1, type(uint256).max); // 0 is already used in setUp() 191 | vm.prank(adminAddress); 192 | factoryContract.createEscrow(questId, adminAddress, whitelistedTokens, treasury); 193 | address newEscrow = factoryContract.s_escrows(questId); 194 | 195 | MockERC20 erc20 = new MockERC20(); 196 | erc20.mint(newEscrow, amount); 197 | 198 | assertEq(Escrow(payable(newEscrow)).escrowERC20Reserves(address(erc20)), amount); 199 | } 200 | 201 | // test withdrawal 202 | function testNativeWithdrawalByAdmin(uint256 questId, uint256 nativeAmount) public { 203 | questId = bound(questId, 1, type(uint256).max); // 0 is already used in setUp() 204 | createEscrow(questId); 205 | 206 | nativeAmount = bound(nativeAmount, 0, type(uint256).max); 207 | testDepositNative(nativeAmount); 208 | 209 | address questEscrow = factoryContract.s_escrows(questId); 210 | hoax(BOB, nativeAmount); 211 | (bool success,) = address(questEscrow).call{value: nativeAmount}(""); 212 | assert(success); 213 | 214 | uint256 balNative = questEscrow.balance; 215 | assertEq(balNative, nativeAmount); 216 | 217 | vm.startPrank(adminAddress); 218 | cubeContract.unpublishQuest(questId); 219 | factoryContract.withdrawFunds(questId, ALICE, address(0), 0, ITokenType.TokenType.NATIVE); 220 | vm.stopPrank(); 221 | 222 | assertEq(questEscrow.balance, 0); 223 | assertEq(ALICE.balance, nativeAmount); 224 | } 225 | 226 | function testErc20WithdrawalByAdmin(uint256 erc20Amount) public { 227 | erc20Amount = bound(erc20Amount, 0, type(uint64).max); 228 | erc20Mock.mint(escrowAddr, erc20Amount); 229 | 230 | uint256 preBalEscrow = erc20Mock.balanceOf(escrowAddr); 231 | uint256 balErc20 = escrowMock.escrowERC20Reserves(address(erc20Mock)); 232 | 233 | assertEq(preBalEscrow, balErc20); 234 | 235 | vm.prank(adminAddress); 236 | factoryContract.withdrawFunds(0, ALICE, address(erc20Mock), 0, ITokenType.TokenType.ERC20); 237 | 238 | uint256 postBalAlice = erc20Mock.balanceOf(ALICE); 239 | 240 | assert(erc20Mock.balanceOf(escrowAddr) == 0); 241 | assert(escrowMock.escrowERC20Reserves(address(erc20Mock)) == 0); 242 | assert(postBalAlice == erc20Amount); 243 | } 244 | 245 | // expect revert 246 | function testErc20WithdrawalByNonAdmin(uint256 erc20Amount) public { 247 | erc20Amount = bound(erc20Amount, 0, type(uint64).max); 248 | erc20Mock.mint(escrowAddr, erc20Amount); 249 | 250 | vm.prank(ALICE); 251 | vm.expectRevert(Factory.Factory__OnlyCallableByAdmin.selector); 252 | factoryContract.withdrawFunds(0, ALICE, address(erc20Mock), 0, ITokenType.TokenType.ERC20); 253 | } 254 | 255 | function testUpdateAdmin(uint256 erc20Amount) public { 256 | erc20Amount = bound(erc20Amount, 0, type(uint64).max); 257 | erc20Mock.mint(escrowAddr, erc20Amount); 258 | 259 | vm.prank(ALICE); 260 | vm.expectRevert(Factory.Factory__OnlyCallableByAdmin.selector); 261 | factoryContract.withdrawFunds(0, ALICE, address(erc20Mock), 0, ITokenType.TokenType.ERC20); 262 | 263 | vm.prank(adminAddress); 264 | factoryContract.updateEscrowAdmin(0, ALICE); 265 | 266 | vm.prank(ALICE); 267 | factoryContract.withdrawFunds(0, ALICE, address(erc20Mock), 0, ITokenType.TokenType.ERC20); 268 | 269 | assert(erc20Mock.balanceOf(ALICE) == erc20Amount); 270 | } 271 | 272 | function testChangeEscrowAdminAndWhitelistToken() public { 273 | vm.prank(ALICE); 274 | address tokenToAdd = makeAddr("tokenToAdd"); 275 | 276 | vm.expectRevert(Factory.Factory__OnlyCallableByAdmin.selector); 277 | factoryContract.addTokenToWhitelist(0, tokenToAdd); 278 | 279 | vm.prank(adminAddress); 280 | factoryContract.updateEscrowAdmin(0, ALICE); 281 | 282 | vm.prank(ALICE); 283 | factoryContract.addTokenToWhitelist(0, tokenToAdd); 284 | 285 | bool isWhitelisted = escrowMock.s_whitelistedTokens(tokenToAdd); 286 | assert(isWhitelisted); 287 | } 288 | 289 | function testRemoveTokenFromWhitelist() public { 290 | bool isWhitelisted = escrowMock.s_whitelistedTokens(address(erc20Mock)); 291 | assert(isWhitelisted); 292 | 293 | vm.prank(adminAddress); 294 | factoryContract.removeTokenFromWhitelist(0, address(erc20Mock)); 295 | 296 | bool isWhitelistedPostRemoval = escrowMock.s_whitelistedTokens(address(erc20Mock)); 297 | assert(!isWhitelistedPostRemoval); 298 | } 299 | 300 | function testUpdateAdminWithdrawByDefaultAdmin(uint256 erc20Amount) public { 301 | erc20Amount = bound(erc20Amount, 0, type(uint64).max); 302 | erc20Mock.mint(escrowAddr, erc20Amount); 303 | 304 | vm.prank(ALICE); 305 | vm.expectRevert(Factory.Factory__OnlyCallableByAdmin.selector); 306 | factoryContract.withdrawFunds(0, ALICE, address(erc20Mock), 0, ITokenType.TokenType.ERC20); 307 | 308 | // update admin but withdraw by default admin, which should still work 309 | vm.startPrank(adminAddress); 310 | factoryContract.updateEscrowAdmin(0, ALICE); 311 | factoryContract.withdrawFunds(0, ALICE, address(erc20Mock), 0, ITokenType.TokenType.ERC20); 312 | vm.stopPrank(); 313 | 314 | assert(erc20Mock.balanceOf(ALICE) == erc20Amount); 315 | } 316 | 317 | function testUpdateAdminByNonAdmin() public { 318 | vm.prank(ALICE); 319 | vm.expectRevert(Factory.Factory__OnlyCallableByAdmin.selector); 320 | factoryContract.updateEscrowAdmin(0, ALICE); 321 | } 322 | 323 | function testCreateEscrowByNonAdmin() public { 324 | vm.startBroadcast(ALICE); 325 | bytes4 selector = bytes4(keccak256("AccessControlUnauthorizedAccount(address,bytes32)")); 326 | bytes memory expectedError = 327 | abi.encodeWithSelector(selector, ALICE, factoryContract.DEFAULT_ADMIN_ROLE()); 328 | vm.expectRevert(expectedError); 329 | factoryContract.createEscrow(2, ALICE, whitelistedTokens, treasury); 330 | vm.stopBroadcast(); 331 | } 332 | 333 | function testCreateDoubleEscrow(uint256 questId) public { 334 | questId = bound(questId, 1, type(uint256).max); // 0 is already used in setUp() 335 | vm.startPrank(adminAddress); 336 | factoryContract.createEscrow(questId, adminAddress, whitelistedTokens, treasury); 337 | vm.expectRevert(Factory.Factory__EscrowAlreadyExists.selector); 338 | factoryContract.createEscrow(questId, adminAddress, whitelistedTokens, treasury); 339 | vm.stopPrank(); 340 | } 341 | 342 | function testDistributeRewardsNotCUBE() public { 343 | uint256 questId = 0; 344 | vm.startPrank(adminAddress); 345 | erc20Mock.mint(escrowAddr, 1e18); 346 | vm.expectRevert(Factory.Factory__OnlyCallableByCUBE.selector); 347 | factoryContract.distributeRewards( 348 | questId, address(erc20Mock), BOB, 1e18, 0, ITokenType.TokenType.ERC20, 300 349 | ); 350 | vm.stopPrank(); 351 | } 352 | 353 | function testRotateAdmin() public { 354 | bool isAdmin = factoryContract.hasRole(factoryContract.DEFAULT_ADMIN_ROLE(), adminAddress); 355 | assertEq(isAdmin, true); 356 | 357 | vm.startPrank(adminAddress); 358 | factoryContract.grantRole(factoryContract.DEFAULT_ADMIN_ROLE(), ALICE); 359 | 360 | bool isAdminAlice = factoryContract.hasRole(factoryContract.DEFAULT_ADMIN_ROLE(), ALICE); 361 | assertEq(isAdminAlice, true); 362 | 363 | factoryContract.renounceRole(factoryContract.DEFAULT_ADMIN_ROLE(), adminAddress); 364 | bool isAdminPostRenounce = 365 | factoryContract.hasRole(factoryContract.DEFAULT_ADMIN_ROLE(), adminAddress); 366 | assertEq(isAdminPostRenounce, false); 367 | vm.stopPrank(); 368 | } 369 | 370 | function testDepositERC721ToFactory() public { 371 | vm.prank(adminAddress); 372 | bytes4 selector = bytes4(keccak256("ERC721InvalidReceiver(address)")); 373 | bytes memory expectedError = abi.encodeWithSelector(selector, factoryAddr); 374 | vm.expectRevert(expectedError); 375 | IERC721(erc721Mock).safeTransferFrom(adminAddress, factoryAddr, 1); 376 | } 377 | 378 | function testDepositERC1155ToFactory() public { 379 | vm.prank(adminAddress); 380 | bytes4 selector = bytes4(keccak256("ERC1155InvalidReceiver(address)")); 381 | bytes memory expectedError = abi.encodeWithSelector(selector, factoryAddr); 382 | vm.expectRevert(expectedError); 383 | ERC1155(erc1155Mock).safeTransferFrom(adminAddress, factoryAddr, 0, 1, "0x00"); 384 | } 385 | 386 | function testDepositToFactoryWithoutData(uint256 amount) public { 387 | amount = bound(amount, 0, type(uint256).max); 388 | hoax(ALICE, amount); 389 | (bool success,) = factoryAddr.call{value: amount}(""); 390 | assert(!success); 391 | } 392 | 393 | function testDepositToFactoryWithData(uint256 amount) public { 394 | amount = bound(amount, 0, type(uint256).max); 395 | hoax(ALICE, amount); 396 | (bool success,) = factoryAddr.call{value: amount}("some data"); 397 | assert(!success); 398 | } 399 | 400 | function testUpgrade() public { 401 | uint256 newValue = 2; 402 | vm.startPrank(adminAddress); 403 | Upgrades.upgradeProxy( 404 | factoryAddr, "CubeV2.sol", abi.encodeCall(CubeV2.initializeV2, (newValue)) 405 | ); 406 | CubeV2 cubeV2 = CubeV2(factoryAddr); 407 | uint256 value = cubeV2.newValueV2(); 408 | assertEq(value, newValue); 409 | vm.stopPrank(); 410 | } 411 | } 412 | -------------------------------------------------------------------------------- /test/unit/TaskEscrow.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity 0.8.20; 3 | 4 | import {Test, console, Vm, stdError} from "forge-std/Test.sol"; 5 | 6 | import {DeployEscrow} from "../../script/DeployEscrow.s.sol"; 7 | import {Escrow} from "../../src/escrow/Escrow.sol"; 8 | import {TaskEscrow} from "../../src/escrow/TaskEscrow.sol"; 9 | 10 | import {MockERC20} from "../mock/MockERC20.sol"; 11 | import {MockERC721} from "../mock/MockERC721.sol"; 12 | import {MockERC1155} from "../mock/MockERC1155.sol"; 13 | import {ITokenType} from "../../src/escrow/interfaces/ITokenType.sol"; 14 | 15 | import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; 16 | import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol"; 17 | import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; 18 | import {ERC1155} from "@openzeppelin/contracts/token/ERC1155/ERC1155.sol"; 19 | import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; 20 | 21 | contract TaskEscrowTest is Test { 22 | using MessageHashUtils for bytes32; 23 | 24 | DeployEscrow public deployer; 25 | 26 | string constant SIGNATURE_DOMAIN = "LAYER3"; 27 | string constant SIGNING_VERSION = "1"; 28 | 29 | uint256 internal ownerPrivateKey; 30 | address internal ownerPubKey; 31 | 32 | address internal realAccount; 33 | uint256 internal realPrivateKey; 34 | 35 | uint256 constant MAX_BPS = 10_000; 36 | 37 | // Test Users 38 | address public adminAddress; 39 | address public admin; 40 | uint256 internal adminPrivateKey; 41 | address public alice = makeAddr("alice"); 42 | address public bob = makeAddr("bob"); 43 | address public treasury = makeAddr("treasury"); 44 | 45 | address public notAdminAddress; 46 | uint256 internal notAdminPrivKey; 47 | 48 | address public taskEscrowAddr; 49 | TaskEscrow public taskEscrowMock; 50 | MockERC20 public erc20Mock; 51 | MockERC721 public erc721Mock; 52 | MockERC1155 public erc1155Mock; 53 | 54 | address[] public whitelistedTokens; 55 | 56 | function setUp() public { 57 | ownerPrivateKey = 0xA11CE; 58 | ownerPubKey = vm.addr(ownerPrivateKey); 59 | 60 | adminPrivateKey = 0x01; 61 | adminAddress = vm.addr(adminPrivateKey); 62 | 63 | notAdminPrivKey = 0x099; 64 | notAdminAddress = vm.addr(notAdminPrivKey); 65 | 66 | // deploy all necessary contracts and set up dependencies 67 | deployer = new DeployEscrow(); 68 | (,, address erc20, address erc721, address erc1155) = 69 | deployer.run(adminAddress, treasury, address(0)); 70 | 71 | // tokens to be whitelisted in escrow 72 | whitelistedTokens.push(address(erc20)); 73 | whitelistedTokens.push(address(erc721)); 74 | whitelistedTokens.push(address(erc1155)); 75 | 76 | vm.broadcast(adminAddress); 77 | erc20Mock = MockERC20(erc20); 78 | erc721Mock = MockERC721(erc721); 79 | erc1155Mock = MockERC1155(erc1155); 80 | 81 | taskEscrowMock = new TaskEscrow(adminAddress, whitelistedTokens, treasury); 82 | taskEscrowAddr = address(taskEscrowMock); 83 | } 84 | 85 | /////////////////////////////////////////////////////////////////////// 86 | //////////////////////////// DEPOSIT ////////////////////////////////// 87 | /////////////////////////////////////////////////////////////////////// 88 | function testDepositNative(uint256 amount) public { 89 | hoax(adminAddress, amount); 90 | uint256 preBalEscrow = taskEscrowAddr.balance; 91 | uint256 preBalAdmin = adminAddress.balance; 92 | 93 | (bool success,) = address(taskEscrowAddr).call{value: amount}(""); 94 | require(success, "native deposit failed"); 95 | 96 | uint256 postBalEscrow = taskEscrowAddr.balance; 97 | uint256 postBalAdmin = adminAddress.balance; 98 | 99 | assertEq(postBalEscrow, preBalEscrow + amount); 100 | assertEq(postBalAdmin, preBalAdmin - amount); 101 | } 102 | 103 | function testDepositERC20(uint256 amount) public { 104 | uint256 preBalance = erc20Mock.balanceOf(taskEscrowAddr); 105 | 106 | uint256 preBalanceAdmin = erc20Mock.balanceOf(adminAddress); 107 | if (amount > preBalanceAdmin) { 108 | return; 109 | } 110 | 111 | vm.startBroadcast(adminAddress); 112 | 113 | erc20Mock.transfer(taskEscrowAddr, amount); 114 | vm.stopBroadcast(); 115 | 116 | uint256 postBalance = erc20Mock.balanceOf(taskEscrowAddr); 117 | 118 | assertEq(postBalance, preBalance + amount); 119 | } 120 | 121 | function testDepositERC721() public { 122 | uint256 preBalance = erc721Mock.balanceOf(taskEscrowAddr); 123 | vm.startBroadcast(adminAddress); 124 | erc721Mock.safeTransferFrom(adminAddress, taskEscrowAddr, 2); 125 | vm.stopBroadcast(); 126 | 127 | uint256 postBalance = erc721Mock.balanceOf(taskEscrowAddr); 128 | 129 | assertEq(postBalance, preBalance + 1); 130 | assertEq(erc721Mock.ownerOf(2), taskEscrowAddr); 131 | } 132 | 133 | function testDepositERC1155() public { 134 | uint256 preBalance = erc1155Mock.balanceOf(taskEscrowAddr, 0); 135 | vm.startBroadcast(adminAddress); 136 | erc1155Mock.safeTransferFrom(adminAddress, taskEscrowAddr, 0, 1, "0x00"); 137 | vm.stopBroadcast(); 138 | 139 | uint256 postBalance = erc1155Mock.balanceOf(taskEscrowAddr, 0); 140 | 141 | assertEq(postBalance, preBalance + 1); 142 | } 143 | 144 | /////////////////////////////////////////////////////////////////////// 145 | //////////////////////////// WITHDRAW ///////////////////////////////// 146 | /////////////////////////////////////////////////////////////////////// 147 | function testWithdrawNative() public { 148 | uint256 amount = 10 ether; 149 | testDepositNative(amount); 150 | 151 | uint256 rakeBps = 300; 152 | vm.startBroadcast(adminAddress); 153 | taskEscrowMock.withdrawNative(bob, amount, rakeBps); 154 | vm.stopBroadcast(); 155 | 156 | uint256 postBalTreasury = treasury.balance; 157 | uint256 postBalBob = bob.balance; 158 | 159 | uint256 rakeFee = amount * rakeBps / MAX_BPS; 160 | 161 | assertEq(postBalBob, amount - rakeFee); 162 | assertEq(postBalTreasury, rakeFee); 163 | } 164 | 165 | function testWithdrawERC20() public { 166 | testDepositERC20(10e18); 167 | 168 | uint256 amount = 1e18; 169 | uint256 rakeBps = 300; 170 | vm.prank(adminAddress); 171 | taskEscrowMock.withdrawERC20(address(erc20Mock), bob, amount, rakeBps); 172 | 173 | uint256 postBalTreasury = erc20Mock.balanceOf(treasury); 174 | uint256 postBalBob = erc20Mock.balanceOf(bob); 175 | 176 | uint256 rakeFee = amount * rakeBps / MAX_BPS; 177 | assertEq(postBalBob, amount - rakeFee); 178 | assertEq(postBalTreasury, rakeFee); 179 | } 180 | 181 | function testWithdrawERC721() public { 182 | testDepositERC721(); 183 | 184 | address preOwnerOf = erc721Mock.ownerOf(2); 185 | 186 | vm.startBroadcast(adminAddress); 187 | taskEscrowMock.withdrawERC721(address(erc721Mock), bob, 2); 188 | vm.stopBroadcast(); 189 | 190 | address postOwnerOf = erc721Mock.ownerOf(2); 191 | 192 | assertEq(preOwnerOf, taskEscrowAddr); 193 | assertEq(postOwnerOf, bob); 194 | } 195 | 196 | function testWithdrawERC1155() public { 197 | testDepositERC1155(); 198 | 199 | uint256 preBal = erc1155Mock.balanceOf(bob, 0); 200 | 201 | vm.prank(adminAddress); 202 | taskEscrowMock.withdrawERC1155(address(erc1155Mock), bob, 1, 0); 203 | 204 | uint256 postBal = erc1155Mock.balanceOf(bob, 0); 205 | 206 | assertEq(preBal, 0); 207 | assertEq(postBal, 1); 208 | } 209 | 210 | function testWithdrawNotWhitelistedToken() public { 211 | vm.startBroadcast(adminAddress); 212 | 213 | // create and mint new token 214 | address token = _createERC20(adminAddress, 1e18); 215 | 216 | // deposit 217 | uint256 amount = 10; 218 | MockERC20(token).transfer(taskEscrowAddr, amount); 219 | 220 | vm.expectRevert(Escrow.Escrow__TokenNotWhitelisted.selector); 221 | taskEscrowMock.withdrawERC20(token, bob, amount, 300); 222 | 223 | vm.stopBroadcast(); 224 | } 225 | 226 | function testWithdrawZeroTokenAddress() public { 227 | vm.prank(adminAddress); 228 | vm.expectRevert(Escrow.Escrow__TokenNotWhitelisted.selector); 229 | taskEscrowMock.withdrawERC20(address(0), bob, 10, 300); 230 | } 231 | 232 | function testWithdrawZeroToAddress() public { 233 | testDepositERC20(10e18); 234 | vm.prank(adminAddress); 235 | vm.expectRevert(); 236 | taskEscrowMock.withdrawERC20(address(erc20Mock), address(0), 10, 300); 237 | } 238 | 239 | function testWithdrawNativeToZeroAddress(uint256 amount) public { 240 | vm.deal(adminAddress, amount); 241 | 242 | testDepositNative(amount); 243 | 244 | vm.prank(adminAddress); 245 | vm.expectRevert(Escrow.Escrow__ZeroAddress.selector); 246 | taskEscrowMock.withdrawNative(address(0), amount, 300); 247 | } 248 | 249 | function testWhitelistToken() public { 250 | vm.startBroadcast(adminAddress); 251 | 252 | // create and mint new token 253 | address token = _createERC20(adminAddress, 1e18); 254 | 255 | // deposit 256 | uint256 amount = 10; 257 | MockERC20(token).transfer(taskEscrowAddr, amount); 258 | 259 | // it'll revert since token isn't whitelisted 260 | vm.expectRevert(Escrow.Escrow__TokenNotWhitelisted.selector); 261 | taskEscrowMock.withdrawERC20(token, bob, amount, 0); 262 | 263 | // whitelist token 264 | taskEscrowMock.addTokenToWhitelist(token); 265 | 266 | // withdraw to bob 267 | taskEscrowMock.withdrawERC20(token, bob, amount, 0); 268 | vm.stopBroadcast(); 269 | 270 | // verify balance 271 | uint256 balanceBob = MockERC20(token).balanceOf(bob); 272 | assertEq(amount, balanceBob); 273 | } 274 | 275 | /////////////////////////////////////////////////////////////////////// 276 | ////////////////////////// RAKE PAYOUTS /////////////////////////////// 277 | /////////////////////////////////////////////////////////////////////// 278 | function testWithdrawTooHighBPS() public { 279 | testDepositERC20(10e18); 280 | 281 | uint256 amount = 1e18; 282 | uint256 rakeBps = 10_001; // 10k (100% in bps) is max, make it overflow 283 | vm.startBroadcast(adminAddress); 284 | 285 | vm.expectRevert(Escrow.Escrow__InvalidRakeBps.selector); 286 | taskEscrowMock.withdrawERC20(address(erc20Mock), bob, amount, rakeBps); 287 | vm.stopBroadcast(); 288 | } 289 | 290 | function testWithdrawZeroBPS() public { 291 | testDepositERC20(10e18); 292 | 293 | uint256 amount = 1e18; 294 | uint256 rakeBps = 0; 295 | vm.startBroadcast(adminAddress); 296 | taskEscrowMock.withdrawERC20(address(erc20Mock), bob, amount, rakeBps); 297 | vm.stopBroadcast(); 298 | 299 | uint256 postBalTreasury = erc20Mock.balanceOf(treasury); 300 | uint256 postBalBob = erc20Mock.balanceOf(bob); 301 | 302 | uint256 rakeFee = amount * rakeBps / MAX_BPS; 303 | assertEq(postBalBob, amount - rakeFee); 304 | assertEq(postBalTreasury, 0); 305 | } 306 | 307 | function testWithdrawHigherThanBalance() public { 308 | testDepositERC20(1e18); 309 | 310 | uint256 amount = 10e18; 311 | uint256 rakeBps = 300; 312 | vm.startBroadcast(adminAddress); 313 | 314 | vm.expectRevert(Escrow.Escrow__InsufficientEscrowBalance.selector); 315 | taskEscrowMock.withdrawERC20(address(erc20Mock), bob, amount, rakeBps); 316 | vm.stopBroadcast(); 317 | } 318 | 319 | /////////////////////////////////////////////////////////////////////// 320 | ///////////////////////// ACCESS CONTROL ////////////////////////////// 321 | /////////////////////////////////////////////////////////////////////// 322 | function testWithdrawNotAdmin() public { 323 | testDepositERC20(1e18); 324 | 325 | uint256 amount = 100; 326 | uint256 rakeBps = 300; 327 | vm.prank(alice); 328 | bytes4 selector = bytes4(keccak256("OwnableUnauthorizedAccount(address)")); 329 | bytes memory expectedError = abi.encodeWithSelector(selector, alice); 330 | vm.expectRevert(expectedError); 331 | taskEscrowMock.withdrawERC20(address(erc20Mock), bob, amount, rakeBps); 332 | } 333 | 334 | function testChangeOwner() public { 335 | address token = makeAddr("someToken"); 336 | 337 | bytes4 selector = bytes4(keccak256("OwnableUnauthorizedAccount(address)")); 338 | bytes memory expectedError = abi.encodeWithSelector(selector, alice); 339 | vm.expectRevert(expectedError); 340 | vm.prank(alice); 341 | taskEscrowMock.addTokenToWhitelist(token); 342 | 343 | address owner = taskEscrowMock.owner(); 344 | assert(owner == adminAddress); 345 | 346 | vm.prank(adminAddress); 347 | taskEscrowMock.transferOwnership(alice); 348 | 349 | address pendingOwner = taskEscrowMock.pendingOwner(); 350 | assert(pendingOwner == alice); 351 | address stillOwner = taskEscrowMock.owner(); 352 | assert(stillOwner == adminAddress); 353 | 354 | vm.prank(alice); 355 | taskEscrowMock.acceptOwnership(); 356 | address newOwner = taskEscrowMock.owner(); 357 | assert(newOwner == alice); 358 | 359 | vm.prank(alice); 360 | taskEscrowMock.addTokenToWhitelist(token); 361 | 362 | assert(taskEscrowMock.s_whitelistedTokens(token)); 363 | } 364 | 365 | function testRenounceOwnership() public { 366 | vm.prank(adminAddress); 367 | 368 | // we overwrite this function since there'll never be a case where we want to do this 369 | taskEscrowMock.renounceOwnership(); 370 | 371 | // make sure transfership wasn't renounced 372 | address owner = taskEscrowMock.owner(); 373 | assert(owner == adminAddress); 374 | } 375 | 376 | function testEscrowERC165Interface() public view { 377 | // ERC165 - 0x01ffc9a7 378 | assertEq(taskEscrowMock.supportsInterface(0x01ffc9a7), true); 379 | } 380 | 381 | function testERC1155BatchDeposit() public { 382 | uint256[] memory ids = new uint256[](3); 383 | uint256[] memory amounts = new uint256[](3); 384 | address[] memory escrowAddresses = new address[](3); 385 | uint256 amount = 5; 386 | for (uint256 i = 0; i < amounts.length; i++) { 387 | ids[i] = i; 388 | amounts[i] = amount; 389 | escrowAddresses[i] = taskEscrowAddr; 390 | erc1155Mock.mint(adminAddress, 10, i); 391 | } 392 | 393 | vm.startBroadcast(adminAddress); 394 | erc1155Mock.safeBatchTransferFrom(adminAddress, taskEscrowAddr, ids, amounts, "0x00"); 395 | vm.stopBroadcast(); 396 | 397 | uint256[] memory postBalances = erc1155Mock.balanceOfBatch(escrowAddresses, ids); 398 | for (uint256 i = 0; i < 3; i++) { 399 | assertEq(postBalances[i], amount); 400 | } 401 | 402 | // withdraw 403 | vm.prank(adminAddress); 404 | uint256 tokenId = 1; 405 | 406 | taskEscrowMock.withdrawERC1155(address(erc1155Mock), bob, amount, tokenId); 407 | 408 | uint256 escrow1155Balance = 409 | taskEscrowMock.escrowERC1155Reserves(address(erc1155Mock), tokenId); 410 | assert(escrow1155Balance == 0); 411 | assert(erc1155Mock.balanceOf(bob, tokenId) == amount); 412 | } 413 | 414 | /////////////////////////////////////////////////////////////////////// 415 | ///////////////////////// HELPER FUNCTIONS //////////////////////////// 416 | /////////////////////////////////////////////////////////////////////// 417 | function _createERC20(address _to, uint256 _amount) internal returns (address) { 418 | address token = address(new MockERC20()); 419 | MockERC20(token).mint(_to, _amount); 420 | 421 | return token; 422 | } 423 | 424 | function depositERC721ToEscrow() public { 425 | IERC721(erc721Mock).transferFrom(adminAddress, taskEscrowAddr, 1); 426 | } 427 | 428 | function testDepositWithoutData(uint256 amount) public { 429 | amount = bound(amount, 0, type(uint256).max); 430 | hoax(alice, amount); 431 | (bool success,) = taskEscrowAddr.call{value: amount}(""); 432 | assert(success); 433 | assert(taskEscrowAddr.balance == amount); 434 | } 435 | 436 | function testDepositWithData(uint256 amount) public { 437 | amount = bound(amount, 0, type(uint256).max); 438 | hoax(alice, amount); 439 | (bool success,) = taskEscrowAddr.call{value: amount}("some data"); 440 | assert(success); 441 | assert(taskEscrowAddr.balance == amount); 442 | } 443 | 444 | function depositNativeToEscrow() public { 445 | hoax(adminAddress, 50 ether); 446 | (bool success,) = payable(taskEscrowAddr).call{value: 10 ether}(""); 447 | assert(success); 448 | } 449 | 450 | function testClaimReward() public { 451 | testDepositERC20(10e18); 452 | 453 | uint256 claimFee = 0.1 ether; 454 | uint256 reward = 100; 455 | 456 | TaskEscrow.ClaimData memory _data = TaskEscrow.ClaimData({ 457 | taskId: 123, 458 | token: address(erc20Mock), 459 | to: bob, 460 | tokenType: ITokenType.TokenType.ERC20, 461 | amount: reward, 462 | tokenId: 0, 463 | rakeBps: 300, 464 | claimFee: claimFee, 465 | nonce: 0, 466 | txHash: "0xaeacb8a0936dc1a06f0b22223249a4638f5f21130a689bed6a865491d3b6b034", 467 | networkChainId: "evm:1" 468 | }); 469 | 470 | bytes32 structHash = getStructHash(_data); 471 | bytes32 digest = getDigest(getDomainSeparator(), structHash); 472 | 473 | (uint8 v, bytes32 r, bytes32 s) = vm.sign(adminPrivateKey, digest); 474 | bytes memory signature = abi.encodePacked(r, s, v); 475 | 476 | vm.deal(bob, claimFee); 477 | vm.startPrank(bob); 478 | uint256 preBalance = bob.balance; 479 | uint256 preERC20Balance = erc20Mock.balanceOf(bob); 480 | 481 | taskEscrowMock.claimReward{value: claimFee}(_data, signature); 482 | 483 | uint256 erc20Amount = erc20Mock.balanceOf(bob); 484 | uint256 postBalance = bob.balance; 485 | uint256 rakeFee = reward * 300 / MAX_BPS; 486 | 487 | vm.stopPrank(); 488 | 489 | assert(erc20Amount == preERC20Balance + (reward - rakeFee)); 490 | assert(postBalance == preBalance - claimFee); 491 | assert(treasury.balance == claimFee); 492 | 493 | uint256 contractERC20Balance = erc20Mock.balanceOf(taskEscrowAddr); 494 | 495 | address withdrawalAddr = makeAddr("withdrawal"); 496 | vm.prank(adminAddress); 497 | taskEscrowMock.withdrawERC20(address(erc20Mock), withdrawalAddr, contractERC20Balance, 0); 498 | 499 | assert(erc20Mock.balanceOf(withdrawalAddr) == contractERC20Balance); 500 | } 501 | 502 | function getStructHash(TaskEscrow.ClaimData memory data) public pure returns (bytes32) { 503 | return keccak256( 504 | abi.encode( 505 | keccak256( 506 | "ClaimData(uint256 taskId,address token,address to,uint8 tokenType,uint256 amount,uint256 tokenId,uint256 rakeBps,uint256 claimFee,uint256 nonce,string txHash,string networkChainId)" 507 | ), 508 | data.taskId, 509 | data.token, 510 | data.to, 511 | data.tokenType, 512 | data.amount, 513 | data.tokenId, 514 | data.rakeBps, 515 | data.claimFee, 516 | data.nonce, 517 | keccak256(bytes(data.txHash)), 518 | keccak256(bytes(data.networkChainId)) 519 | ) 520 | ); 521 | } 522 | 523 | function getDomainSeparator() internal view virtual returns (bytes32) { 524 | return keccak256( 525 | abi.encode( 526 | keccak256( 527 | "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" 528 | ), 529 | keccak256(bytes(SIGNATURE_DOMAIN)), 530 | keccak256(bytes(SIGNING_VERSION)), 531 | block.chainid, 532 | taskEscrowAddr 533 | ) 534 | ); 535 | } 536 | 537 | function getDigest(bytes32 domainSeparator, bytes32 structHash) public pure returns (bytes32) { 538 | return MessageHashUtils.toTypedDataHash(domainSeparator, structHash); 539 | } 540 | } 541 | -------------------------------------------------------------------------------- /test/unit/TestProxy.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity 0.8.20; 3 | 4 | import {Test, console} from "forge-std/Test.sol"; 5 | import {StdCheats} from "forge-std/StdCheats.sol"; 6 | 7 | import {DeployProxy} from "../../script/DeployProxy.s.sol"; 8 | import {UpgradeCube} from "../../script/UpgradeCube.s.sol"; 9 | import {CubeV2} from "../contracts/CubeV2.sol"; 10 | import {CUBE} from "../../src/CUBE.sol"; 11 | 12 | import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; 13 | 14 | contract DeployAndUpgradeTest is StdCheats, Test { 15 | DeployProxy public deployProxy; 16 | UpgradeCube public upgradeCube; 17 | address public OWNER = address(1); 18 | address public ALICE = address(2); 19 | address public BOB = address(3); 20 | 21 | // this address should always remain the same 22 | address public proxyAddress; 23 | 24 | function setUp() public { 25 | deployProxy = new DeployProxy(); 26 | upgradeCube = new UpgradeCube(); 27 | proxyAddress = deployProxy.deployProxy(OWNER); 28 | 29 | // setup necessary roles 30 | vm.startBroadcast(OWNER); 31 | CUBE(payable(proxyAddress)).grantRole(keccak256("UPGRADER"), OWNER); 32 | vm.stopBroadcast(); 33 | } 34 | 35 | function testERC721Name() public { 36 | upgradeCube.upgradeCube(OWNER, proxyAddress); 37 | 38 | string memory expectedValue = deployProxy.NAME(); 39 | assertEq(expectedValue, CubeV2(payable(proxyAddress)).name()); 40 | } 41 | 42 | function testUnauthorizedUpgrade() public { 43 | bytes4 selector = bytes4(keccak256("AccessControlUnauthorizedAccount(address,bytes32)")); 44 | bytes memory expectedError = abi.encodeWithSelector(selector, BOB, keccak256("UPGRADER")); 45 | vm.expectRevert(expectedError); 46 | upgradeCube.upgradeCube(BOB, proxyAddress); 47 | } 48 | 49 | function testV2SignerRoleVariable() public { 50 | upgradeCube.upgradeCube(OWNER, proxyAddress); 51 | 52 | CubeV2 newCube = CubeV2(payable(proxyAddress)); 53 | bytes32 signerRole = newCube.SIGNER_ROLE(); 54 | assertEq(keccak256("SIGNER"), signerRole); 55 | } 56 | 57 | function testV2MigratedName() public { 58 | upgradeCube.upgradeCube(OWNER, proxyAddress); 59 | 60 | CubeV2 newCube = CubeV2(payable(proxyAddress)); 61 | 62 | string memory val = newCube.name(); 63 | assertEq(val, "Layer3 CUBE"); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /test/utils/Helper.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity 0.8.20; 3 | 4 | import {Vm} from "forge-std/Test.sol"; 5 | 6 | import {CUBE} from "../../src/CUBE.sol"; 7 | import {MockERC20} from "../mock/MockERC20.sol"; 8 | import {MockERC721} from "../mock/MockERC721.sol"; 9 | import {MockERC1155} from "../mock/MockERC1155.sol"; 10 | 11 | import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; 12 | import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; 13 | 14 | contract Helper is CUBE { 15 | using ECDSA for bytes32; 16 | using MessageHashUtils for bytes32; 17 | 18 | function getStructHash(CubeData calldata data) public pure returns (bytes32) { 19 | bytes32 encodedTxs = _encodeCompletedTxs(data.transactions); 20 | bytes32 encodedRefs = _encodeRecipients(data.recipients); 21 | bytes32 encodedReward = _encodeReward(data.reward); 22 | 23 | return keccak256( 24 | abi.encode( 25 | CUBE_DATA_HASH, 26 | data.questId, 27 | data.nonce, 28 | data.price, 29 | data.isNative, 30 | data.toAddress, 31 | keccak256(bytes(data.walletProvider)), 32 | keccak256(bytes(data.tokenURI)), 33 | keccak256(bytes(data.embedOrigin)), 34 | encodedTxs, 35 | encodedRefs, 36 | encodedReward 37 | ) 38 | ); 39 | } 40 | 41 | function getSigner(CubeData calldata data, bytes calldata signature) 42 | public 43 | view 44 | returns (address) 45 | { 46 | return _getSigner(data, signature); 47 | } 48 | 49 | function getDigest(bytes32 domainSeparator, bytes32 structHash) public pure returns (bytes32) { 50 | return MessageHashUtils.toTypedDataHash(domainSeparator, structHash); 51 | } 52 | 53 | function getCubeData( 54 | address _feeRecipient, 55 | address _mintTo, 56 | address factoryAddress, 57 | address tokenAddress, 58 | uint256 tokenId, 59 | uint256 amount, 60 | CUBE.TokenType tokenType, 61 | uint256 rakeBps, 62 | uint256 chainId, 63 | address rewardRecipientAddress 64 | ) public pure returns (CUBE.CubeData memory) { 65 | CUBE.TransactionData[] memory transactions = new CUBE.TransactionData[](1); 66 | transactions[0] = CUBE.TransactionData({ 67 | txHash: "0xe265a54b4f6470f7f52bb1e4b19489b13d4a6d0c87e6e39c5d05c6639ec98002", 68 | networkChainId: "evm:137" 69 | }); 70 | 71 | CUBE.RewardData memory reward = CUBE.RewardData({ 72 | tokenAddress: tokenAddress, 73 | chainId: chainId, 74 | amount: amount, 75 | tokenId: tokenId, 76 | tokenType: tokenType, 77 | rakeBps: rakeBps, 78 | factoryAddress: factoryAddress, 79 | rewardRecipientAddress: rewardRecipientAddress 80 | }); 81 | 82 | CUBE.FeeRecipient[] memory recipients = new CUBE.FeeRecipient[](1); 83 | recipients[0] = CUBE.FeeRecipient({ 84 | recipient: _feeRecipient, 85 | BPS: 3300, // 33% 86 | recipientType: CUBE.FeeRecipientType.LAYER3 87 | }); 88 | return CUBE.CubeData({ 89 | questId: 1, 90 | nonce: 1, 91 | price: 600, 92 | isNative: true, 93 | toAddress: _mintTo, 94 | walletProvider: "MetaMask", 95 | tokenURI: "ipfs://abc", 96 | embedOrigin: "test.com", 97 | transactions: transactions, 98 | recipients: recipients, 99 | reward: reward 100 | }); 101 | } 102 | 103 | function depositNativeToEscrow(address escrow, uint256 amount) public { 104 | (bool success,) = address(escrow).call{value: amount}(""); 105 | require(success, "native deposit failed"); 106 | } 107 | 108 | function depositERC20ToEscrow(uint256 amount, address to, MockERC20 erc20) public { 109 | erc20.transfer(to, amount); 110 | } 111 | 112 | function depositERC721ToEscrow(address from, address to, uint256 tokenId, MockERC721 erc721) 113 | public 114 | { 115 | erc721.safeTransferFrom(from, to, tokenId); 116 | } 117 | 118 | function processPayouts(CubeData calldata _data) public { 119 | if (_data.isNative) { 120 | return _processNativePayouts(_data); 121 | } 122 | return _processL3Payouts(_data); 123 | } 124 | } 125 | --------------------------------------------------------------------------------