├── .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 | [](https://twitter.com/layer3xyz)
11 | [](https://discord.com/invite/layer3)
12 | [](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 |
--------------------------------------------------------------------------------