├── .commitlintrc ├── .env.template ├── .eslintignore ├── .eslintrc ├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── checks.yaml │ └── tests.yaml ├── .gitignore ├── .gitmodules ├── .husky ├── commit-msg └── pre-commit ├── .npmignore ├── .prettierignore ├── .prettierrc ├── .release-it.js ├── .solcover.js ├── .solhint.json ├── .solhintignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── SECURITY.md ├── contracts ├── AggregatorFeeSharingWithUniswapV3.sol ├── FeeSharingSetter.sol ├── FeeSharingSystem.sol ├── LooksRareToken.sol ├── MultiRewardsDistributor.sol ├── OperatorControllerForRewardsV2.sol ├── PrivateSaleWithFeeSharing.sol ├── ProtocolFeesDistributor.sol ├── SeasonRewardsDistributor.sol ├── StakingPoolForUniswapV2Tokens.sol ├── TokenDistributor.sol ├── TokenSplitter.sol ├── TradingRewardsDistributor.sol ├── VestingContractWithFeeSharing.sol ├── interfaces │ ├── IBlast.sol │ ├── IBlastPoints.sol │ ├── ILooksRareToken.sol │ └── IRewardConvertor.sol ├── test │ ├── AggregatorFeeSharingWithUniswapV3.t.sol │ ├── ICheatCodes.sol │ ├── TestHelpers.sol │ └── utils │ │ ├── MockERC20.sol │ │ ├── MockFaultyUniswapV3Router.sol │ │ ├── MockRewardConvertor.sol │ │ └── MockUniswapV3Router.sol └── uniswap-interfaces │ ├── ISwapRouter.sol │ └── IUniswapV3SwapCallback.sol ├── foundry.toml ├── hardhat.config.ts ├── package.json ├── remappings.txt ├── scripts ├── SeasonRewardsDistributorUpdateRewards.s.sol └── deployment │ └── SeasonRewardsDistributorDeployment.s.sol ├── test ├── aggregatorFeeSharingWithUniswapV3.test.ts ├── feeSharingSystem.test.ts ├── helpers │ ├── block-traveller.ts │ └── cryptography.ts ├── looksRareToken.test.ts ├── multiRewardsDistributor.test.ts ├── privateSaleWithFeeSharing.test.ts ├── protocolFeesDistributor.test.ts ├── seasonRewardsDistributor.test.ts ├── stakingPoolForUniswapV2Tokens.test.ts ├── tokenDistributor.test.ts ├── tokenSplitter.test.ts ├── tradingRewardsDistributor.test.ts └── vestingContractWithFeeSharing.test.ts ├── tsconfig.json └── yarn.lock /.commitlintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "@commitlint/config-conventional" 4 | ], 5 | "rules": { 6 | "subject-case": [ 7 | 2, 8 | "always", 9 | "sentence-case" 10 | ], 11 | "type-enum": [ 12 | 2, 13 | "always", 14 | [ 15 | "build", 16 | "ci", 17 | "chore", 18 | "docs", 19 | "feat", 20 | "fix", 21 | "perf", 22 | "refactor", 23 | "revert", 24 | "style", 25 | "test" 26 | ] 27 | ] 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /.env.template: -------------------------------------------------------------------------------- 1 | GITHUB_TOKEN= 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | artifacts 3 | cache 4 | coverage* 5 | gasReporterOutput.json 6 | typechain 7 | lib/ds-test -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": false, 4 | "es2021": true, 5 | "mocha": true, 6 | "node": true 7 | }, 8 | "plugins": ["@typescript-eslint"], 9 | "extends": [ 10 | "standard", 11 | "plugin:@typescript-eslint/recommended", 12 | "plugin:prettier/recommended", 13 | "plugin:node/recommended" 14 | ], 15 | "parser": "@typescript-eslint/parser", 16 | "parserOptions": { 17 | "ecmaVersion": 12 18 | }, 19 | "rules": { 20 | "node/no-unsupported-features/es-syntax": ["error", { "ignores": ["modules"] }] 21 | }, 22 | "settings": { 23 | "node": { 24 | "tryExtensions": [".js", ".json", ".ts", ".d.ts"] 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners 2 | 3 | @0xShisui @0xJurassicPunk 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "" 5 | labels: "" 6 | assignees: "" 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **To Reproduce** 13 | Steps to reproduce the behavior: 14 | 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Additional context** 24 | Add any other context about the problem here. 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "" 5 | labels: "" 6 | assignees: "" 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | -------------------------------------------------------------------------------- /.github/workflows/checks.yaml: -------------------------------------------------------------------------------- 1 | name: Format and lint checks 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - master 8 | 9 | jobs: 10 | format: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v2 15 | with: 16 | submodules: recursive 17 | - name: Setup Node 18 | uses: actions/setup-node@v3 19 | with: 20 | node-version: 14.x 21 | - name: Get yarn cache directory path 22 | id: yarn-cache-dir-path 23 | run: echo "::set-output name=dir::$(yarn cache dir)" 24 | - uses: actions/cache@v2 25 | id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`) 26 | with: 27 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 28 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 29 | restore-keys: | 30 | ${{ runner.os }}-yarn- 31 | - name: Install dependencies 32 | run: yarn install --frozen-lockfile 33 | - name: Run format checks 34 | run: yarn format:check 35 | - name: Run lint 36 | run: yarn lint 37 | -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - master 8 | 9 | jobs: 10 | tests: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v2 15 | with: 16 | submodules: recursive 17 | - name: Setup Node 18 | uses: actions/setup-node@v3 19 | with: 20 | node-version: 14.x 21 | - name: Get yarn cache directory path 22 | id: yarn-cache-dir-path 23 | run: echo "::set-output name=dir::$(yarn cache dir)" 24 | - uses: actions/cache@v2 25 | id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`) 26 | with: 27 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 28 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 29 | restore-keys: | 30 | ${{ runner.os }}-yarn- 31 | - name: Install dev dependencies 32 | run: yarn install --frozen-lockfile 33 | - name: Install Foundry 34 | uses: onbjerg/foundry-toolchain@v1 35 | with: 36 | version: nightly 37 | - name: Compile code (Hardhat) 38 | run: yarn compile:force 39 | - name: Run TypeScript/Waffle tests 40 | run: yarn test 41 | - name: Run Solidity/Forge tests 42 | run: forge test 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env 3 | .env* 4 | !.env.template 5 | coverage 6 | coverage.json 7 | typechain 8 | .DS_Store 9 | 10 | # Hardhat files 11 | cache 12 | artifacts 13 | abis/ 14 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "lib/ds-test"] 2 | path = lib/ds-test 3 | url = https://github.com/dapphub/ds-test 4 | [submodule "lib/forge-std"] 5 | path = lib/forge-std 6 | url = https://github.com/foundry-rs/forge-std 7 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn commitlint --edit $1 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn lint 5 | yarn format:check 6 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env 3 | .env* 4 | coverage 5 | coverage.json 6 | typechain 7 | 8 | # Hardhat files 9 | cache 10 | artifacts 11 | 12 | hardhat.config.ts 13 | contracts/test 14 | test/ 15 | 16 | # Others 17 | lib 18 | .DS_Store -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | artifacts 3 | cache 4 | coverage* 5 | gasReporterOutput.json 6 | typechain 7 | lib/ds-test 8 | lib/forge-std 9 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120 3 | } 4 | -------------------------------------------------------------------------------- /.release-it.js: -------------------------------------------------------------------------------- 1 | require("dotenv").config(); 2 | 3 | // Doc: https://github.com/release-it/release-it 4 | module.exports = { 5 | git: { 6 | commitMessage: "build: Release v${version}", 7 | requireUpstream: false, 8 | pushRepo: "upstream", // Push tags and commit to the remote `upstream` (fails if doesn't exist) 9 | requireBranch: "master", // Push commit to the branch `master` (fail if on other branch) 10 | requireCommits: true, // Require new commits since latest tag 11 | }, 12 | github: { 13 | release: true, 14 | }, 15 | hooks: { 16 | "after:bump": "yarn compile:force", 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /.solcover.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | silent: true, 3 | measureStatementCoverage: true, 4 | measureFunctionCoverage: true, 5 | skipFiles: [ 6 | "interfaces", 7 | "uniswap-interfaces", 8 | "test", 9 | "OperatorControllerForRewards.sol", 10 | "OperatorControllerForRewardsV2.sol", 11 | ], 12 | configureYulOptimizer: true, 13 | }; 14 | -------------------------------------------------------------------------------- /.solhint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "solhint:recommended", 3 | "rules": { 4 | "compiler-version": ["error", "^0.8.0"], 5 | "func-visibility": [{ "ignoreConstructors": true }], 6 | "func-name-mixedcase": "off", 7 | "reason-string": "off", 8 | "var-name-mixedcase": "off" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.solhintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | test 3 | demo.sol 4 | *.t.sol -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true 3 | } 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 LooksRare 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @looksrare/contracts-token-staking 2 | 3 | ## Description 4 | 5 | This project contains all smart contracts used for staking and other token-related contracts (excluding the airdrop contract!). 6 | 7 | ## Installation 8 | 9 | ```shell 10 | # Yarn 11 | yarn add @looksrare/contracts-token-staking 12 | 13 | # NPM 14 | npm install @looksrare/contracts-token-staking 15 | ``` 16 | 17 | ## NPM package 18 | 19 | The NPM package contains the following: 20 | 21 | - Solidity smart contracts (_".sol"_) 22 | - ABI files (_".json"_) 23 | 24 | ## Documentation 25 | 26 | The documentation for token & staking smart contracts is available [here](https://docs.looksrare.org/developers/category/rewards-contracts). 27 | 28 | ## About this repo 29 | 30 | ### Structure 31 | 32 | It is a hybrid [Hardhat](https://hardhat.org/) repo that also requires [Foundry](https://book.getfoundry.sh/index.html) to run Solidity tests powered by the [ds-test library](https://github.com/dapphub/ds-test/). 33 | 34 | > To install Foundry, please follow the instructions [here](https://book.getfoundry.sh/getting-started/installation.html). 35 | 36 | ### Run tests 37 | 38 | - TypeScript tests are included in the `test` folder at the root of this repo. 39 | - Solidity tests are included in the `test` folder in the `contracts` folder. 40 | 41 | ### Example of Foundry/Forge commands 42 | 43 | ```shell 44 | forge build 45 | forge test 46 | forge test -vv 47 | forge tree 48 | ``` 49 | 50 | ### Example of Hardhat commands 51 | 52 | ```shell 53 | npx hardhat accounts 54 | npx hardhat compile 55 | npx hardhat clean 56 | npx hardhat test 57 | npx hardhat node 58 | npx hardhat help 59 | REPORT_GAS=true npx hardhat test 60 | npx hardhat coverage 61 | npx hardhat run scripts/deploy.ts 62 | TS_NODE_FILES=true npx ts-node scripts/deploy.ts 63 | npx eslint '**/*.{js,ts}' 64 | npx eslint '**/*.{js,ts}' --fix 65 | npx prettier '**/*.{json,sol,md}' --check 66 | npx prettier '**/*.{json,sol,md}' --write 67 | npx solhint 'contracts/**/*.sol' 68 | npx solhint 'contracts/**/*.sol' --fix 69 | ``` 70 | 71 | ## Release 72 | 73 | - Create a [Personal access token](https://github.com/settings/tokens/new?scopes=repo&description=release-it) (Don't change the default scope) 74 | - Create an `.env` (copy `.env.template`) and set you GitHub personal access token. 75 | - `yarn release` will run all the checks, build, and release the package, and publish the GitHub release note. 76 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security policy 2 | 3 | ## Supported versions 4 | 5 | | Version | Supported | 6 | | ------- | ------------------ | 7 | | 1.0.x | :white_check_mark: | 8 | 9 | ## Reporting a vulnerability 10 | 11 | Vulnerabilities can be reported using [Immunefi](https://immunefi.com/). The process is secure and vulnerabilities can be disclosed anonymously. 12 | 13 | To learn more, [please visit the Immunefi bug bounty page](https://immunefi.com/bounty/looksrare/). 14 | 15 | ## Current audits 16 | 17 | Third-party audits are available [here](https://docs.looksrare.org/about/audits). 18 | -------------------------------------------------------------------------------- /contracts/AggregatorFeeSharingWithUniswapV3.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.0; 3 | 4 | import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; 5 | import {Pausable} from "@openzeppelin/contracts/security/Pausable.sol"; 6 | import {ReentrancyGuard} from "@openzeppelin/contracts/security/ReentrancyGuard.sol"; 7 | import {IERC20, SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; 8 | 9 | import {ISwapRouter} from "./uniswap-interfaces/ISwapRouter.sol"; 10 | import {FeeSharingSystem} from "./FeeSharingSystem.sol"; 11 | 12 | /** 13 | * @title AggregatorFeeSharingWithUniswapV3 14 | * @notice It sells WETH to LOOKS using Uniswap V3. 15 | * @dev Prime shares represent the number of shares in the FeeSharingSystem. When not specified, shares represent secondary shares in this contract. 16 | */ 17 | contract AggregatorFeeSharingWithUniswapV3 is Ownable, Pausable, ReentrancyGuard { 18 | using SafeERC20 for IERC20; 19 | 20 | // Maximum buffer between 2 harvests (in blocks) 21 | uint256 public constant MAXIMUM_HARVEST_BUFFER_BLOCKS = 6500; 22 | 23 | // FeeSharingSystem (handles the distribution of WETH for LOOKS stakers) 24 | FeeSharingSystem public immutable feeSharingSystem; 25 | 26 | // Router of Uniswap v3 27 | ISwapRouter public immutable uniswapRouter; 28 | 29 | // Minimum deposit in LOOKS (it is derived from the FeeSharingSystem) 30 | uint256 public immutable MINIMUM_DEPOSIT_LOOKS; 31 | 32 | // LooksRare Token (LOOKS) 33 | IERC20 public immutable looksRareToken; 34 | 35 | // Reward token (WETH) 36 | IERC20 public immutable rewardToken; 37 | 38 | // Whether harvest and WETH selling is triggered automatically at user action 39 | bool public canHarvest; 40 | 41 | // Trading fee on Uniswap v3 (e.g., 3000 ---> 0.3%) 42 | uint24 public tradingFeeUniswapV3; 43 | 44 | // Buffer between two harvests (in blocks) 45 | uint256 public harvestBufferBlocks; 46 | 47 | // Last user action block 48 | uint256 public lastHarvestBlock; 49 | 50 | // Maximum price of LOOKS (in WETH) multiplied 1e18 (e.g., 0.0004 ETH --> 4e14) 51 | uint256 public maxPriceLOOKSInWETH; 52 | 53 | // Threshold amount (in rewardToken) 54 | uint256 public thresholdAmount; 55 | 56 | // Total number of shares outstanding 57 | uint256 public totalShares; 58 | 59 | // Keeps track of number of user shares 60 | mapping(address => uint256) public userInfo; 61 | 62 | event ConversionToLOOKS(uint256 amountSold, uint256 amountReceived); 63 | event Deposit(address indexed user, uint256 amount); 64 | event FailedConversion(); 65 | event HarvestStart(); 66 | event HarvestStop(); 67 | event NewHarvestBufferBlocks(uint256 harvestBufferBlocks); 68 | event NewMaximumPriceLOOKSInWETH(uint256 maxPriceLOOKSInWETH); 69 | event NewThresholdAmount(uint256 thresholdAmount); 70 | event NewTradingFeeUniswapV3(uint24 tradingFeeUniswapV3); 71 | event Withdraw(address indexed user, uint256 amount); 72 | 73 | /** 74 | * @notice Constructor 75 | * @param _feeSharingSystem address of the fee sharing system contract 76 | * @param _uniswapRouter address of the Uniswap v3 router 77 | */ 78 | constructor(address _feeSharingSystem, address _uniswapRouter) { 79 | address looksRareTokenAddress = address(FeeSharingSystem(_feeSharingSystem).looksRareToken()); 80 | address rewardTokenAddress = address(FeeSharingSystem(_feeSharingSystem).rewardToken()); 81 | 82 | looksRareToken = IERC20(looksRareTokenAddress); 83 | rewardToken = IERC20(rewardTokenAddress); 84 | 85 | feeSharingSystem = FeeSharingSystem(_feeSharingSystem); 86 | uniswapRouter = ISwapRouter(_uniswapRouter); 87 | 88 | IERC20(looksRareTokenAddress).approve(_feeSharingSystem, type(uint256).max); 89 | IERC20(rewardTokenAddress).approve(_uniswapRouter, type(uint256).max); 90 | 91 | tradingFeeUniswapV3 = 3000; 92 | MINIMUM_DEPOSIT_LOOKS = FeeSharingSystem(_feeSharingSystem).PRECISION_FACTOR(); 93 | } 94 | 95 | /** 96 | * @notice Deposit LOOKS tokens 97 | * @param amount amount to deposit (in LOOKS) 98 | * @dev There is a limit of 1 LOOKS per deposit to prevent potential manipulation of the shares 99 | */ 100 | function deposit(uint256 amount) external nonReentrant whenNotPaused { 101 | require(amount >= MINIMUM_DEPOSIT_LOOKS, "Deposit: Amount must be >= 1 LOOKS"); 102 | 103 | if (block.number > (lastHarvestBlock + harvestBufferBlocks) && canHarvest && totalShares != 0) { 104 | _harvestAndSellAndCompound(); 105 | } 106 | 107 | // Transfer LOOKS tokens to this address 108 | looksRareToken.safeTransferFrom(msg.sender, address(this), amount); 109 | 110 | // Fetch the total number of LOOKS staked by this contract 111 | uint256 totalAmountStaked = feeSharingSystem.calculateSharesValueInLOOKS(address(this)); 112 | 113 | uint256 currentShares = totalShares == 0 ? amount : (amount * totalShares) / totalAmountStaked; 114 | require(currentShares != 0, "Deposit: Fail"); 115 | 116 | // Adjust number of shares for user/total 117 | userInfo[msg.sender] += currentShares; 118 | totalShares += currentShares; 119 | 120 | // Deposit to FeeSharingSystem contract 121 | feeSharingSystem.deposit(amount, false); 122 | 123 | emit Deposit(msg.sender, amount); 124 | } 125 | 126 | /** 127 | * @notice Redeem shares for LOOKS tokens 128 | * @param shares number of shares to redeem 129 | */ 130 | function withdraw(uint256 shares) external nonReentrant { 131 | require( 132 | (shares > 0) && (shares <= userInfo[msg.sender]), 133 | "Withdraw: Shares equal to 0 or larger than user shares" 134 | ); 135 | 136 | _withdraw(shares); 137 | } 138 | 139 | /** 140 | * @notice Withdraw all shares of sender 141 | */ 142 | function withdrawAll() external nonReentrant { 143 | require(userInfo[msg.sender] > 0, "Withdraw: Shares equal to 0"); 144 | 145 | _withdraw(userInfo[msg.sender]); 146 | } 147 | 148 | /** 149 | * @notice Harvest pending WETH, sell them to LOOKS, and deposit LOOKS (if possible) 150 | * @dev Only callable by owner. 151 | */ 152 | function harvestAndSellAndCompound() external nonReentrant onlyOwner { 153 | require(totalShares != 0, "Harvest: No share"); 154 | require(block.number != lastHarvestBlock, "Harvest: Already done"); 155 | 156 | _harvestAndSellAndCompound(); 157 | } 158 | 159 | /** 160 | * @notice Adjust allowance if necessary 161 | * @dev Only callable by owner. 162 | */ 163 | function checkAndAdjustLOOKSTokenAllowanceIfRequired() external onlyOwner { 164 | looksRareToken.approve(address(feeSharingSystem), type(uint256).max); 165 | } 166 | 167 | /** 168 | * @notice Adjust allowance if necessary 169 | * @dev Only callable by owner. 170 | */ 171 | function checkAndAdjustRewardTokenAllowanceIfRequired() external onlyOwner { 172 | rewardToken.approve(address(uniswapRouter), type(uint256).max); 173 | } 174 | 175 | /** 176 | * @notice Update harvest buffer block 177 | * @param _newHarvestBufferBlocks buffer in blocks between two harvest operations 178 | * @dev Only callable by owner. 179 | */ 180 | function updateHarvestBufferBlocks(uint256 _newHarvestBufferBlocks) external onlyOwner { 181 | require( 182 | _newHarvestBufferBlocks <= MAXIMUM_HARVEST_BUFFER_BLOCKS, 183 | "Owner: Must be below MAXIMUM_HARVEST_BUFFER_BLOCKS" 184 | ); 185 | harvestBufferBlocks = _newHarvestBufferBlocks; 186 | 187 | emit NewHarvestBufferBlocks(_newHarvestBufferBlocks); 188 | } 189 | 190 | /** 191 | * @notice Start automatic harvest/selling transactions 192 | * @dev Only callable by owner 193 | */ 194 | function startHarvest() external onlyOwner { 195 | canHarvest = true; 196 | 197 | emit HarvestStart(); 198 | } 199 | 200 | /** 201 | * @notice Stop automatic harvest transactions 202 | * @dev Only callable by owner 203 | */ 204 | function stopHarvest() external onlyOwner { 205 | canHarvest = false; 206 | 207 | emit HarvestStop(); 208 | } 209 | 210 | /** 211 | * @notice Update maximum price of LOOKS in WETH 212 | * @param _newMaxPriceLOOKSInWETH new maximum price of LOOKS in WETH times 1e18 213 | * @dev Only callable by owner 214 | */ 215 | function updateMaxPriceOfLOOKSInWETH(uint256 _newMaxPriceLOOKSInWETH) external onlyOwner { 216 | maxPriceLOOKSInWETH = _newMaxPriceLOOKSInWETH; 217 | 218 | emit NewMaximumPriceLOOKSInWETH(_newMaxPriceLOOKSInWETH); 219 | } 220 | 221 | /** 222 | * @notice Adjust trading fee for Uniswap v3 223 | * @param _newTradingFeeUniswapV3 new tradingFeeUniswapV3 224 | * @dev Only callable by owner. Can only be 10,000 (1%), 3000 (0.3%), or 500 (0.05%). 225 | */ 226 | function updateTradingFeeUniswapV3(uint24 _newTradingFeeUniswapV3) external onlyOwner { 227 | require( 228 | _newTradingFeeUniswapV3 == 10000 || _newTradingFeeUniswapV3 == 3000 || _newTradingFeeUniswapV3 == 500, 229 | "Owner: Fee invalid" 230 | ); 231 | 232 | tradingFeeUniswapV3 = _newTradingFeeUniswapV3; 233 | 234 | emit NewTradingFeeUniswapV3(_newTradingFeeUniswapV3); 235 | } 236 | 237 | /** 238 | * @notice Adjust threshold amount for periodic Uniswap v3 WETH --> LOOKS conversion 239 | * @param _newThresholdAmount new threshold amount (in WETH) 240 | * @dev Only callable by owner 241 | */ 242 | function updateThresholdAmount(uint256 _newThresholdAmount) external onlyOwner { 243 | thresholdAmount = _newThresholdAmount; 244 | 245 | emit NewThresholdAmount(_newThresholdAmount); 246 | } 247 | 248 | /** 249 | * @notice Pause 250 | * @dev Only callable by owner 251 | */ 252 | function pause() external onlyOwner whenNotPaused { 253 | _pause(); 254 | } 255 | 256 | /** 257 | * @notice Unpause 258 | * @dev Only callable by owner 259 | */ 260 | function unpause() external onlyOwner whenPaused { 261 | _unpause(); 262 | } 263 | 264 | /** 265 | * @notice Calculate price of one share (in LOOKS token) 266 | * Share price is expressed times 1e18 267 | */ 268 | function calculateSharePriceInLOOKS() external view returns (uint256) { 269 | uint256 totalAmountStakedWithAggregator = feeSharingSystem.calculateSharesValueInLOOKS(address(this)); 270 | 271 | return 272 | totalShares == 0 273 | ? MINIMUM_DEPOSIT_LOOKS 274 | : (totalAmountStakedWithAggregator * MINIMUM_DEPOSIT_LOOKS) / (totalShares); 275 | } 276 | 277 | /** 278 | * @notice Calculate price of one share (in prime share) 279 | * Share price is expressed times 1e18 280 | */ 281 | function calculateSharePriceInPrimeShare() external view returns (uint256) { 282 | (uint256 totalNumberPrimeShares, , ) = feeSharingSystem.userInfo(address(this)); 283 | 284 | return 285 | totalShares == 0 ? MINIMUM_DEPOSIT_LOOKS : (totalNumberPrimeShares * MINIMUM_DEPOSIT_LOOKS) / totalShares; 286 | } 287 | 288 | /** 289 | * @notice Calculate shares value of a user (in LOOKS) 290 | * @param user address of the user 291 | */ 292 | function calculateSharesValueInLOOKS(address user) external view returns (uint256) { 293 | uint256 totalAmountStakedWithAggregator = feeSharingSystem.calculateSharesValueInLOOKS(address(this)); 294 | 295 | return totalShares == 0 ? 0 : (totalAmountStakedWithAggregator * userInfo[user]) / totalShares; 296 | } 297 | 298 | /** 299 | * @notice Harvest pending WETH, sell them to LOOKS, and deposit LOOKS (if possible) 300 | */ 301 | function _harvestAndSellAndCompound() internal { 302 | // Try/catch to prevent revertions if nothing to harvest 303 | try feeSharingSystem.harvest() {} catch {} 304 | 305 | uint256 amountToSell = rewardToken.balanceOf(address(this)); 306 | 307 | if (amountToSell >= thresholdAmount) { 308 | bool isExecuted = _sellRewardTokenToLOOKS(amountToSell); 309 | 310 | if (isExecuted) { 311 | uint256 adjustedAmount = looksRareToken.balanceOf(address(this)); 312 | 313 | if (adjustedAmount >= MINIMUM_DEPOSIT_LOOKS) { 314 | feeSharingSystem.deposit(adjustedAmount, false); 315 | } 316 | } 317 | } 318 | 319 | // Adjust last harvest block 320 | lastHarvestBlock = block.number; 321 | } 322 | 323 | /** 324 | * @notice Sell WETH to LOOKS 325 | * @param _amount amount of rewardToken to convert (WETH) 326 | * @return whether the transaction went through 327 | */ 328 | function _sellRewardTokenToLOOKS(uint256 _amount) internal returns (bool) { 329 | uint256 amountOutMinimum = maxPriceLOOKSInWETH != 0 ? (_amount * 1e18) / maxPriceLOOKSInWETH : 0; 330 | 331 | // Set the order parameters 332 | ISwapRouter.ExactInputSingleParams memory params = ISwapRouter.ExactInputSingleParams( 333 | address(rewardToken), // tokenIn 334 | address(looksRareToken), // tokenOut 335 | tradingFeeUniswapV3, // fee 336 | address(this), // recipient 337 | block.timestamp, // deadline 338 | _amount, // amountIn 339 | amountOutMinimum, // amountOutMinimum 340 | 0 // sqrtPriceLimitX96 341 | ); 342 | 343 | // Swap on Uniswap V3 344 | try uniswapRouter.exactInputSingle(params) returns (uint256 amountOut) { 345 | emit ConversionToLOOKS(_amount, amountOut); 346 | return true; 347 | } catch { 348 | emit FailedConversion(); 349 | return false; 350 | } 351 | } 352 | 353 | /** 354 | * @notice Withdraw shares 355 | * @param _shares number of shares to redeem 356 | * @dev The difference between the two snapshots of LOOKS balances is used to know how many tokens to transfer to user. 357 | */ 358 | function _withdraw(uint256 _shares) internal { 359 | if (block.number > (lastHarvestBlock + harvestBufferBlocks) && canHarvest) { 360 | _harvestAndSellAndCompound(); 361 | } 362 | 363 | // Take snapshot of current LOOKS balance 364 | uint256 previousBalanceLOOKS = looksRareToken.balanceOf(address(this)); 365 | 366 | // Fetch total number of prime shares 367 | (uint256 totalNumberPrimeShares, , ) = feeSharingSystem.userInfo(address(this)); 368 | 369 | // Calculate number of prime shares to redeem based on existing shares (from this contract) 370 | uint256 currentNumberPrimeShares = (totalNumberPrimeShares * _shares) / totalShares; 371 | 372 | // Adjust number of shares for user/total 373 | userInfo[msg.sender] -= _shares; 374 | totalShares -= _shares; 375 | 376 | // Withdraw amount equivalent in prime shares 377 | feeSharingSystem.withdraw(currentNumberPrimeShares, false); 378 | 379 | // Calculate the difference between the current balance of LOOKS with the previous snapshot 380 | uint256 amountToTransfer = looksRareToken.balanceOf(address(this)) - previousBalanceLOOKS; 381 | 382 | // Transfer the LOOKS amount back to user 383 | looksRareToken.safeTransfer(msg.sender, amountToTransfer); 384 | 385 | emit Withdraw(msg.sender, amountToTransfer); 386 | } 387 | } 388 | -------------------------------------------------------------------------------- /contracts/FeeSharingSetter.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.0; 3 | 4 | import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol"; 5 | import {ReentrancyGuard} from "@openzeppelin/contracts/security/ReentrancyGuard.sol"; 6 | import {IERC20, SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; 7 | import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; 8 | 9 | import {FeeSharingSystem} from "./FeeSharingSystem.sol"; 10 | import {TokenDistributor} from "./TokenDistributor.sol"; 11 | 12 | import {IRewardConvertor} from "./interfaces/IRewardConvertor.sol"; 13 | 14 | /** 15 | * @title FeeSharingSetter 16 | * @notice It receives LooksRare protocol fees and owns the FeeSharingSystem contract. 17 | * It can plug to AMMs for converting all received currencies to WETH. 18 | */ 19 | contract FeeSharingSetter is ReentrancyGuard, AccessControl { 20 | using EnumerableSet for EnumerableSet.AddressSet; 21 | using SafeERC20 for IERC20; 22 | 23 | // Operator role 24 | bytes32 public constant OPERATOR_ROLE = keccak256("OPERATOR_ROLE"); 25 | 26 | // Min duration for each fee-sharing period (in blocks) 27 | uint256 public immutable MIN_REWARD_DURATION_IN_BLOCKS; 28 | 29 | // Max duration for each fee-sharing period (in blocks) 30 | uint256 public immutable MAX_REWARD_DURATION_IN_BLOCKS; 31 | 32 | IERC20 public immutable looksRareToken; 33 | 34 | IERC20 public immutable rewardToken; 35 | 36 | FeeSharingSystem public feeSharingSystem; 37 | 38 | TokenDistributor public immutable tokenDistributor; 39 | 40 | // Reward convertor (tool to convert other currencies to rewardToken) 41 | IRewardConvertor public rewardConvertor; 42 | 43 | // Last reward block of distribution 44 | uint256 public lastRewardDistributionBlock; 45 | 46 | // Next reward duration in blocks 47 | uint256 public nextRewardDurationInBlocks; 48 | 49 | // Reward duration in blocks 50 | uint256 public rewardDurationInBlocks; 51 | 52 | // Set of addresses that are staking only the fee sharing 53 | EnumerableSet.AddressSet private _feeStakingAddresses; 54 | 55 | event ConversionToRewardToken(address indexed token, uint256 amountConverted, uint256 amountReceived); 56 | event FeeStakingAddressesAdded(address[] feeStakingAddresses); 57 | event FeeStakingAddressesRemoved(address[] feeStakingAddresses); 58 | event NewFeeSharingSystemOwner(address newOwner); 59 | event NewRewardDurationInBlocks(uint256 rewardDurationInBlocks); 60 | event NewRewardConvertor(address rewardConvertor); 61 | 62 | /** 63 | * @notice Constructor 64 | * @param _feeSharingSystem address of the fee sharing system 65 | * @param _minRewardDurationInBlocks minimum reward duration in blocks 66 | * @param _maxRewardDurationInBlocks maximum reward duration in blocks 67 | * @param _rewardDurationInBlocks reward duration between two updates in blocks 68 | */ 69 | constructor( 70 | address _feeSharingSystem, 71 | uint256 _minRewardDurationInBlocks, 72 | uint256 _maxRewardDurationInBlocks, 73 | uint256 _rewardDurationInBlocks 74 | ) { 75 | require( 76 | (_rewardDurationInBlocks <= _maxRewardDurationInBlocks) && 77 | (_rewardDurationInBlocks >= _minRewardDurationInBlocks), 78 | "Owner: Reward duration in blocks outside of range" 79 | ); 80 | 81 | MIN_REWARD_DURATION_IN_BLOCKS = _minRewardDurationInBlocks; 82 | MAX_REWARD_DURATION_IN_BLOCKS = _maxRewardDurationInBlocks; 83 | 84 | feeSharingSystem = FeeSharingSystem(_feeSharingSystem); 85 | 86 | rewardToken = feeSharingSystem.rewardToken(); 87 | looksRareToken = feeSharingSystem.looksRareToken(); 88 | tokenDistributor = feeSharingSystem.tokenDistributor(); 89 | 90 | rewardDurationInBlocks = _rewardDurationInBlocks; 91 | nextRewardDurationInBlocks = _rewardDurationInBlocks; 92 | 93 | _setupRole(DEFAULT_ADMIN_ROLE, msg.sender); 94 | } 95 | 96 | /** 97 | * @notice Update the reward per block (in rewardToken) 98 | * @dev It automatically retrieves the number of pending WETH and adjusts 99 | * based on the balance of LOOKS in fee-staking addresses that exist in the set. 100 | */ 101 | function updateRewards() external onlyRole(OPERATOR_ROLE) { 102 | if (lastRewardDistributionBlock > 0) { 103 | require(block.number > (rewardDurationInBlocks + lastRewardDistributionBlock), "Reward: Too early to add"); 104 | } 105 | 106 | // Adjust for this period 107 | if (rewardDurationInBlocks != nextRewardDurationInBlocks) { 108 | rewardDurationInBlocks = nextRewardDurationInBlocks; 109 | } 110 | 111 | lastRewardDistributionBlock = block.number; 112 | 113 | // Calculate the reward to distribute as the balance held by this address 114 | uint256 reward = rewardToken.balanceOf(address(this)); 115 | 116 | require(reward != 0, "Reward: Nothing to distribute"); 117 | 118 | // Check if there is any address eligible for fee-sharing only 119 | uint256 numberAddressesForFeeStaking = _feeStakingAddresses.length(); 120 | 121 | // If there are eligible addresses for fee-sharing only, calculate their shares 122 | if (numberAddressesForFeeStaking > 0) { 123 | uint256[] memory looksBalances = new uint256[](numberAddressesForFeeStaking); 124 | (uint256 totalAmountStaked, ) = tokenDistributor.userInfo(address(feeSharingSystem)); 125 | 126 | for (uint256 i = 0; i < numberAddressesForFeeStaking; i++) { 127 | uint256 looksBalance = looksRareToken.balanceOf(_feeStakingAddresses.at(i)); 128 | totalAmountStaked += looksBalance; 129 | looksBalances[i] = looksBalance; 130 | } 131 | 132 | // Only apply the logic if the totalAmountStaked > 0 (to prevent division by 0) 133 | if (totalAmountStaked > 0) { 134 | uint256 adjustedReward = reward; 135 | 136 | for (uint256 i = 0; i < numberAddressesForFeeStaking; i++) { 137 | uint256 amountToTransfer = (looksBalances[i] * reward) / totalAmountStaked; 138 | if (amountToTransfer > 0) { 139 | adjustedReward -= amountToTransfer; 140 | rewardToken.safeTransfer(_feeStakingAddresses.at(i), amountToTransfer); 141 | } 142 | } 143 | 144 | // Adjust reward accordingly 145 | reward = adjustedReward; 146 | } 147 | } 148 | 149 | // Transfer tokens to fee sharing system 150 | rewardToken.safeTransfer(address(feeSharingSystem), reward); 151 | 152 | // Update rewards 153 | feeSharingSystem.updateRewards(reward, rewardDurationInBlocks); 154 | } 155 | 156 | /** 157 | * @notice Convert currencies to reward token 158 | * @dev Function only usable only for whitelisted currencies (where no potential side effect) 159 | * @param token address of the token to sell 160 | * @param additionalData additional data (e.g., slippage) 161 | */ 162 | function convertCurrencyToRewardToken(address token, bytes calldata additionalData) 163 | external 164 | nonReentrant 165 | onlyRole(OPERATOR_ROLE) 166 | { 167 | require(address(rewardConvertor) != address(0), "Convert: RewardConvertor not set"); 168 | require(token != address(rewardToken), "Convert: Cannot be reward token"); 169 | 170 | uint256 amountToConvert = IERC20(token).balanceOf(address(this)); 171 | require(amountToConvert != 0, "Convert: Amount to convert must be > 0"); 172 | 173 | // Adjust allowance for this transaction only 174 | IERC20(token).safeIncreaseAllowance(address(rewardConvertor), amountToConvert); 175 | 176 | // Exchange token to reward token 177 | uint256 amountReceived = rewardConvertor.convert(token, address(rewardToken), amountToConvert, additionalData); 178 | 179 | emit ConversionToRewardToken(token, amountToConvert, amountReceived); 180 | } 181 | 182 | /** 183 | * @notice Add staking addresses 184 | * @param _stakingAddresses array of addresses eligible for fee-sharing only 185 | */ 186 | function addFeeStakingAddresses(address[] calldata _stakingAddresses) external onlyRole(DEFAULT_ADMIN_ROLE) { 187 | for (uint256 i = 0; i < _stakingAddresses.length; i++) { 188 | require(!_feeStakingAddresses.contains(_stakingAddresses[i]), "Owner: Address already registered"); 189 | _feeStakingAddresses.add(_stakingAddresses[i]); 190 | } 191 | 192 | emit FeeStakingAddressesAdded(_stakingAddresses); 193 | } 194 | 195 | /** 196 | * @notice Remove staking addresses 197 | * @param _stakingAddresses array of addresses eligible for fee-sharing only 198 | */ 199 | function removeFeeStakingAddresses(address[] calldata _stakingAddresses) external onlyRole(DEFAULT_ADMIN_ROLE) { 200 | for (uint256 i = 0; i < _stakingAddresses.length; i++) { 201 | require(_feeStakingAddresses.contains(_stakingAddresses[i]), "Owner: Address not registered"); 202 | _feeStakingAddresses.remove(_stakingAddresses[i]); 203 | } 204 | 205 | emit FeeStakingAddressesRemoved(_stakingAddresses); 206 | } 207 | 208 | /** 209 | * @notice Set new reward duration in blocks for next update 210 | * @param _newRewardDurationInBlocks number of blocks for new reward period 211 | */ 212 | function setNewRewardDurationInBlocks(uint256 _newRewardDurationInBlocks) external onlyRole(DEFAULT_ADMIN_ROLE) { 213 | require( 214 | (_newRewardDurationInBlocks <= MAX_REWARD_DURATION_IN_BLOCKS) && 215 | (_newRewardDurationInBlocks >= MIN_REWARD_DURATION_IN_BLOCKS), 216 | "Owner: New reward duration in blocks outside of range" 217 | ); 218 | 219 | nextRewardDurationInBlocks = _newRewardDurationInBlocks; 220 | 221 | emit NewRewardDurationInBlocks(_newRewardDurationInBlocks); 222 | } 223 | 224 | /** 225 | * @notice Set reward convertor contract 226 | * @param _rewardConvertor address of the reward convertor (set to null to deactivate) 227 | */ 228 | function setRewardConvertor(address _rewardConvertor) external onlyRole(DEFAULT_ADMIN_ROLE) { 229 | rewardConvertor = IRewardConvertor(_rewardConvertor); 230 | 231 | emit NewRewardConvertor(_rewardConvertor); 232 | } 233 | 234 | /** 235 | * @notice Transfer ownership of fee sharing system 236 | * @param _newOwner address of the new owner 237 | */ 238 | function transferOwnershipOfFeeSharingSystem(address _newOwner) external onlyRole(DEFAULT_ADMIN_ROLE) { 239 | require(_newOwner != address(0), "Owner: New owner cannot be null address"); 240 | feeSharingSystem.transferOwnership(_newOwner); 241 | 242 | emit NewFeeSharingSystemOwner(_newOwner); 243 | } 244 | 245 | /** 246 | * @notice See addresses eligible for fee-staking 247 | */ 248 | function viewFeeStakingAddresses() external view returns (address[] memory) { 249 | uint256 length = _feeStakingAddresses.length(); 250 | 251 | address[] memory feeStakingAddresses = new address[](length); 252 | 253 | for (uint256 i = 0; i < length; i++) { 254 | feeStakingAddresses[i] = _feeStakingAddresses.at(i); 255 | } 256 | 257 | return (feeStakingAddresses); 258 | } 259 | } 260 | -------------------------------------------------------------------------------- /contracts/FeeSharingSystem.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.0; 3 | 4 | import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; 5 | import {ReentrancyGuard} from "@openzeppelin/contracts/security/ReentrancyGuard.sol"; 6 | import {IERC20, SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; 7 | 8 | import {TokenDistributor} from "./TokenDistributor.sol"; 9 | 10 | /** 11 | * @title FeeSharingSystem 12 | * @notice It handles the distribution of fees using 13 | * WETH along with the auto-compounding of LOOKS. 14 | */ 15 | contract FeeSharingSystem is ReentrancyGuard, Ownable { 16 | using SafeERC20 for IERC20; 17 | 18 | struct UserInfo { 19 | uint256 shares; // shares of token staked 20 | uint256 userRewardPerTokenPaid; // user reward per token paid 21 | uint256 rewards; // pending rewards 22 | } 23 | 24 | // Precision factor for calculating rewards and exchange rate 25 | uint256 public constant PRECISION_FACTOR = 10**18; 26 | 27 | IERC20 public immutable looksRareToken; 28 | 29 | IERC20 public immutable rewardToken; 30 | 31 | TokenDistributor public immutable tokenDistributor; 32 | 33 | // Reward rate (block) 34 | uint256 public currentRewardPerBlock; 35 | 36 | // Last reward adjustment block number 37 | uint256 public lastRewardAdjustment; 38 | 39 | // Last update block for rewards 40 | uint256 public lastUpdateBlock; 41 | 42 | // Current end block for the current reward period 43 | uint256 public periodEndBlock; 44 | 45 | // Reward per token stored 46 | uint256 public rewardPerTokenStored; 47 | 48 | // Total existing shares 49 | uint256 public totalShares; 50 | 51 | mapping(address => UserInfo) public userInfo; 52 | 53 | event Deposit(address indexed user, uint256 amount, uint256 harvestedAmount); 54 | event Harvest(address indexed user, uint256 harvestedAmount); 55 | event NewRewardPeriod(uint256 numberBlocks, uint256 rewardPerBlock, uint256 reward); 56 | event Withdraw(address indexed user, uint256 amount, uint256 harvestedAmount); 57 | 58 | /** 59 | * @notice Constructor 60 | * @param _looksRareToken address of the token staked (LOOKS) 61 | * @param _rewardToken address of the reward token 62 | * @param _tokenDistributor address of the token distributor contract 63 | */ 64 | constructor( 65 | address _looksRareToken, 66 | address _rewardToken, 67 | address _tokenDistributor 68 | ) { 69 | rewardToken = IERC20(_rewardToken); 70 | looksRareToken = IERC20(_looksRareToken); 71 | tokenDistributor = TokenDistributor(_tokenDistributor); 72 | } 73 | 74 | /** 75 | * @notice Deposit staked tokens (and collect reward tokens if requested) 76 | * @param amount amount to deposit (in LOOKS) 77 | * @param claimRewardToken whether to claim reward tokens 78 | * @dev There is a limit of 1 LOOKS per deposit to prevent potential manipulation of current shares 79 | */ 80 | function deposit(uint256 amount, bool claimRewardToken) external nonReentrant { 81 | require(amount >= PRECISION_FACTOR, "Deposit: Amount must be >= 1 LOOKS"); 82 | 83 | // Auto compounds for everyone 84 | tokenDistributor.harvestAndCompound(); 85 | 86 | // Update reward for user 87 | _updateReward(msg.sender); 88 | 89 | // Retrieve total amount staked by this contract 90 | (uint256 totalAmountStaked, ) = tokenDistributor.userInfo(address(this)); 91 | 92 | // Transfer LOOKS tokens to this address 93 | looksRareToken.safeTransferFrom(msg.sender, address(this), amount); 94 | 95 | uint256 currentShares; 96 | 97 | // Calculate the number of shares to issue for the user 98 | if (totalShares != 0) { 99 | currentShares = (amount * totalShares) / totalAmountStaked; 100 | // This is a sanity check to prevent deposit for 0 shares 101 | require(currentShares != 0, "Deposit: Fail"); 102 | } else { 103 | currentShares = amount; 104 | } 105 | 106 | // Adjust internal shares 107 | userInfo[msg.sender].shares += currentShares; 108 | totalShares += currentShares; 109 | 110 | uint256 pendingRewards; 111 | 112 | if (claimRewardToken) { 113 | // Fetch pending rewards 114 | pendingRewards = userInfo[msg.sender].rewards; 115 | 116 | if (pendingRewards > 0) { 117 | userInfo[msg.sender].rewards = 0; 118 | rewardToken.safeTransfer(msg.sender, pendingRewards); 119 | } 120 | } 121 | 122 | // Verify LOOKS token allowance and adjust if necessary 123 | _checkAndAdjustLOOKSTokenAllowanceIfRequired(amount, address(tokenDistributor)); 124 | 125 | // Deposit user amount in the token distributor contract 126 | tokenDistributor.deposit(amount); 127 | 128 | emit Deposit(msg.sender, amount, pendingRewards); 129 | } 130 | 131 | /** 132 | * @notice Harvest reward tokens that are pending 133 | */ 134 | function harvest() external nonReentrant { 135 | // Auto compounds for everyone 136 | tokenDistributor.harvestAndCompound(); 137 | 138 | // Update reward for user 139 | _updateReward(msg.sender); 140 | 141 | // Retrieve pending rewards 142 | uint256 pendingRewards = userInfo[msg.sender].rewards; 143 | 144 | // If pending rewards are null, revert 145 | require(pendingRewards > 0, "Harvest: Pending rewards must be > 0"); 146 | 147 | // Adjust user rewards and transfer 148 | userInfo[msg.sender].rewards = 0; 149 | 150 | // Transfer reward token to sender 151 | rewardToken.safeTransfer(msg.sender, pendingRewards); 152 | 153 | emit Harvest(msg.sender, pendingRewards); 154 | } 155 | 156 | /** 157 | * @notice Withdraw staked tokens (and collect reward tokens if requested) 158 | * @param shares shares to withdraw 159 | * @param claimRewardToken whether to claim reward tokens 160 | */ 161 | function withdraw(uint256 shares, bool claimRewardToken) external nonReentrant { 162 | require( 163 | (shares > 0) && (shares <= userInfo[msg.sender].shares), 164 | "Withdraw: Shares equal to 0 or larger than user shares" 165 | ); 166 | 167 | _withdraw(shares, claimRewardToken); 168 | } 169 | 170 | /** 171 | * @notice Withdraw all staked tokens (and collect reward tokens if requested) 172 | * @param claimRewardToken whether to claim reward tokens 173 | */ 174 | function withdrawAll(bool claimRewardToken) external nonReentrant { 175 | _withdraw(userInfo[msg.sender].shares, claimRewardToken); 176 | } 177 | 178 | /** 179 | * @notice Update the reward per block (in rewardToken) 180 | * @dev Only callable by owner. Owner is meant to be another smart contract. 181 | */ 182 | function updateRewards(uint256 reward, uint256 rewardDurationInBlocks) external onlyOwner { 183 | // Adjust the current reward per block 184 | if (block.number >= periodEndBlock) { 185 | currentRewardPerBlock = reward / rewardDurationInBlocks; 186 | } else { 187 | currentRewardPerBlock = 188 | (reward + ((periodEndBlock - block.number) * currentRewardPerBlock)) / 189 | rewardDurationInBlocks; 190 | } 191 | 192 | lastUpdateBlock = block.number; 193 | periodEndBlock = block.number + rewardDurationInBlocks; 194 | 195 | emit NewRewardPeriod(rewardDurationInBlocks, currentRewardPerBlock, reward); 196 | } 197 | 198 | /** 199 | * @notice Calculate pending rewards (WETH) for a user 200 | * @param user address of the user 201 | */ 202 | function calculatePendingRewards(address user) external view returns (uint256) { 203 | return _calculatePendingRewards(user); 204 | } 205 | 206 | /** 207 | * @notice Calculate value of LOOKS for a user given a number of shares owned 208 | * @param user address of the user 209 | */ 210 | function calculateSharesValueInLOOKS(address user) external view returns (uint256) { 211 | // Retrieve amount staked 212 | (uint256 totalAmountStaked, ) = tokenDistributor.userInfo(address(this)); 213 | 214 | // Adjust for pending rewards 215 | totalAmountStaked += tokenDistributor.calculatePendingRewards(address(this)); 216 | 217 | // Return user pro-rata of total shares 218 | return userInfo[user].shares == 0 ? 0 : (totalAmountStaked * userInfo[user].shares) / totalShares; 219 | } 220 | 221 | /** 222 | * @notice Calculate price of one share (in LOOKS token) 223 | * Share price is expressed times 1e18 224 | */ 225 | function calculateSharePriceInLOOKS() external view returns (uint256) { 226 | (uint256 totalAmountStaked, ) = tokenDistributor.userInfo(address(this)); 227 | 228 | // Adjust for pending rewards 229 | totalAmountStaked += tokenDistributor.calculatePendingRewards(address(this)); 230 | 231 | return totalShares == 0 ? PRECISION_FACTOR : (totalAmountStaked * PRECISION_FACTOR) / (totalShares); 232 | } 233 | 234 | /** 235 | * @notice Return last block where trading rewards were distributed 236 | */ 237 | function lastRewardBlock() external view returns (uint256) { 238 | return _lastRewardBlock(); 239 | } 240 | 241 | /** 242 | * @notice Calculate pending rewards for a user 243 | * @param user address of the user 244 | */ 245 | function _calculatePendingRewards(address user) internal view returns (uint256) { 246 | return 247 | ((userInfo[user].shares * (_rewardPerToken() - (userInfo[user].userRewardPerTokenPaid))) / 248 | PRECISION_FACTOR) + userInfo[user].rewards; 249 | } 250 | 251 | /** 252 | * @notice Check current allowance and adjust if necessary 253 | * @param _amount amount to transfer 254 | * @param _to token to transfer 255 | */ 256 | function _checkAndAdjustLOOKSTokenAllowanceIfRequired(uint256 _amount, address _to) internal { 257 | if (looksRareToken.allowance(address(this), _to) < _amount) { 258 | looksRareToken.approve(_to, type(uint256).max); 259 | } 260 | } 261 | 262 | /** 263 | * @notice Return last block where rewards must be distributed 264 | */ 265 | function _lastRewardBlock() internal view returns (uint256) { 266 | return block.number < periodEndBlock ? block.number : periodEndBlock; 267 | } 268 | 269 | /** 270 | * @notice Return reward per token 271 | */ 272 | function _rewardPerToken() internal view returns (uint256) { 273 | if (totalShares == 0) { 274 | return rewardPerTokenStored; 275 | } 276 | 277 | return 278 | rewardPerTokenStored + 279 | ((_lastRewardBlock() - lastUpdateBlock) * (currentRewardPerBlock * PRECISION_FACTOR)) / 280 | totalShares; 281 | } 282 | 283 | /** 284 | * @notice Update reward for a user account 285 | * @param _user address of the user 286 | */ 287 | function _updateReward(address _user) internal { 288 | if (block.number != lastUpdateBlock) { 289 | rewardPerTokenStored = _rewardPerToken(); 290 | lastUpdateBlock = _lastRewardBlock(); 291 | } 292 | 293 | userInfo[_user].rewards = _calculatePendingRewards(_user); 294 | userInfo[_user].userRewardPerTokenPaid = rewardPerTokenStored; 295 | } 296 | 297 | /** 298 | * @notice Withdraw staked tokens (and collect reward tokens if requested) 299 | * @param shares shares to withdraw 300 | * @param claimRewardToken whether to claim reward tokens 301 | */ 302 | function _withdraw(uint256 shares, bool claimRewardToken) internal { 303 | // Auto compounds for everyone 304 | tokenDistributor.harvestAndCompound(); 305 | 306 | // Update reward for user 307 | _updateReward(msg.sender); 308 | 309 | // Retrieve total amount staked and calculated current amount (in LOOKS) 310 | (uint256 totalAmountStaked, ) = tokenDistributor.userInfo(address(this)); 311 | uint256 currentAmount = (totalAmountStaked * shares) / totalShares; 312 | 313 | userInfo[msg.sender].shares -= shares; 314 | totalShares -= shares; 315 | 316 | // Withdraw amount equivalent in shares 317 | tokenDistributor.withdraw(currentAmount); 318 | 319 | uint256 pendingRewards; 320 | 321 | if (claimRewardToken) { 322 | // Fetch pending rewards 323 | pendingRewards = userInfo[msg.sender].rewards; 324 | 325 | if (pendingRewards > 0) { 326 | userInfo[msg.sender].rewards = 0; 327 | rewardToken.safeTransfer(msg.sender, pendingRewards); 328 | } 329 | } 330 | 331 | // Transfer LOOKS tokens to sender 332 | looksRareToken.safeTransfer(msg.sender, currentAmount); 333 | 334 | emit Withdraw(msg.sender, currentAmount, pendingRewards); 335 | } 336 | } 337 | -------------------------------------------------------------------------------- /contracts/LooksRareToken.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.0; 3 | 4 | import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; 5 | import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; 6 | 7 | import {ILooksRareToken} from "./interfaces/ILooksRareToken.sol"; 8 | 9 | /** 10 | * @title LooksRareToken (LOOKS) 11 | * @notice 12 | LOOKSRARELOOKSRARELOOKSRLOOKSRARELOOKSRARELOOKSRARELOOKSRARELOOKSRLOOKSRARELOOKSRARELOOKSR 13 | LOOKSRARELOOKSRARELOOKSRAR'''''''''''''''''''''''''''''''''''OOKSRLOOKSRARELOOKSRARELOOKSR 14 | LOOKSRARELOOKSRARELOOKS:. .;OOKSRARELOOKSRARELOOKSR 15 | LOOKSRARELOOKSRARELOO,. .,KSRARELOOKSRARELOOKSR 16 | LOOKSRARELOOKSRAREL' ..',;:LOOKS::;,'.. 'RARELOOKSRARELOOKSR 17 | LOOKSRARELOOKSRAR. .,:LOOKSRARELOOKSRARELO:,. .RELOOKSRARELOOKSR 18 | LOOKSRARELOOKS:. .;RARELOOKSRARELOOKSRARELOOKSl;. .:OOKSRARELOOKSR 19 | LOOKSRARELOO;. .'OKSRARELOOKSRARELOOKSRARELOOKSRARE'. .;KSRARELOOKSR 20 | LOOKSRAREL,. .,LOOKSRARELOOK:;;:"""":;;;lELOOKSRARELO,. .,RARELOOKSR 21 | LOOKSRAR. .;okLOOKSRAREx:. .;OOKSRARELOOK;. .RELOOKSR 22 | LOOKS:. .:dOOOLOOKSRARE' .''''.. .OKSRARELOOKSR:. .LOOKSR 23 | LOx;. .cKSRARELOOKSRAR' 'LOOKSRAR' .KSRARELOOKSRARc.. .OKSR 24 | L;. .cxOKSRARELOOKSRAR. .LOOKS.RARE' ;kRARELOOKSRARExc. .;R 25 | LO' .;oOKSRARELOOKSRAl. .LOOKS.RARE. :kRARELOOKSRAREo;. 'SR 26 | LOOK;. .,KSRARELOOKSRAx, .;LOOKSR;. .oSRARELOOKSRAo,. .;OKSR 27 | LOOKSk:. .'RARELOOKSRARd;. .... 'oOOOOOOOOOOxc'. .:LOOKSR 28 | LOOKSRARc. .:dLOOKSRAREko;. .,lxOOOOOOOOOd:. .ARELOOKSR 29 | LOOKSRARELo' .;oOKSRARELOOxoc;,....,;:ldkOOOOOOOOkd;. 'SRARELOOKSR 30 | LOOKSRARELOOd,. .,lSRARELOOKSRARELOOKSRARELOOKSRkl,. .,OKSRARELOOKSR 31 | LOOKSRARELOOKSx;. ..;oxELOOKSRARELOOKSRARELOkxl:.. .:LOOKSRARELOOKSR 32 | LOOKSRARELOOKSRARc. .':cOKSRARELOOKSRALOc;'. .ARELOOKSRARELOOKSR 33 | LOOKSRARELOOKSRARELl' ...'',,,,''... 'SRARELOOKSRARELOOKSR 34 | LOOKSRARELOOKSRARELOOo,. .,OKSRARELOOKSRARELOOKSR 35 | LOOKSRARELOOKSRARELOOKSx;. .;xOOKSRARELOOKSRARELOOKSR 36 | LOOKSRARELOOKSRARELOOKSRLO:. .:SRLOOKSRARELOOKSRARELOOKSR 37 | LOOKSRARELOOKSRARELOOKSRLOOKl. .lOKSRLOOKSRARELOOKSRARELOOKSR 38 | LOOKSRARELOOKSRARELOOKSRLOOKSRo'. .'oLOOKSRLOOKSRARELOOKSRARELOOKSR 39 | LOOKSRARELOOKSRARELOOKSRLOOKSRARd;. .;xRELOOKSRLOOKSRARELOOKSRARELOOKSR 40 | LOOKSRARELOOKSRARELOOKSRLOOKSRARELO:. .:kRARELOOKSRLOOKSRARELOOKSRARELOOKSR 41 | LOOKSRARELOOKSRARELOOKSRLOOKSRARELOOKl. .cOKSRARELOOKSRLOOKSRARELOOKSRARELOOKSR 42 | LOOKSRARELOOKSRARELOOKSRLOOKSRARELOOKSRo' 'oLOOKSRARELOOKSRLOOKSRARELOOKSRARELOOKSR 43 | LOOKSRARELOOKSRARELOOKSRLOOKSRARELOOKSRARE,. .,dRELOOKSRARELOOKSRLOOKSRARELOOKSRARELOOKSR 44 | LOOKSRARELOOKSRARELOOKSRLOOKSRARELOOKSRARELOOKSRARELOOKSRARELOOKSRLOOKSRARELOOKSRARELOOKSR 45 | */ 46 | contract LooksRareToken is ERC20, Ownable, ILooksRareToken { 47 | uint256 private immutable _SUPPLY_CAP; 48 | 49 | /** 50 | * @notice Constructor 51 | * @param _premintReceiver address that receives the premint 52 | * @param _premintAmount amount to premint 53 | * @param _cap supply cap (to prevent abusive mint) 54 | */ 55 | constructor( 56 | address _premintReceiver, 57 | uint256 _premintAmount, 58 | uint256 _cap 59 | ) ERC20("LooksRare Token", "LOOKS") { 60 | require(_cap > _premintAmount, "LOOKS: Premint amount is greater than cap"); 61 | // Transfer the sum of the premint to address 62 | _mint(_premintReceiver, _premintAmount); 63 | _SUPPLY_CAP = _cap; 64 | } 65 | 66 | /** 67 | * @notice Mint LOOKS tokens 68 | * @param account address to receive tokens 69 | * @param amount amount to mint 70 | * @return status true if mint is successful, false if not 71 | */ 72 | function mint(address account, uint256 amount) external override onlyOwner returns (bool status) { 73 | if (totalSupply() + amount <= _SUPPLY_CAP) { 74 | _mint(account, amount); 75 | return true; 76 | } 77 | return false; 78 | } 79 | 80 | /** 81 | * @notice View supply cap 82 | */ 83 | function SUPPLY_CAP() external view override returns (uint256) { 84 | return _SUPPLY_CAP; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /contracts/MultiRewardsDistributor.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.0; 3 | 4 | import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; 5 | import {Pausable} from "@openzeppelin/contracts/security/Pausable.sol"; 6 | import {ReentrancyGuard} from "@openzeppelin/contracts/security/ReentrancyGuard.sol"; 7 | import {MerkleProof} from "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol"; 8 | import {IERC20, SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; 9 | 10 | /** 11 | * @title MultiRewardsDistributor 12 | * @notice It distributes LOOKS tokens with parallel rolling Merkle airdrops. 13 | * @dev It uses safe guard addresses (e.g., address(0), address(1)) to add a protection layer against operational errors when the operator sets up the merkle roots for each of the existing trees. 14 | */ 15 | contract MultiRewardsDistributor is Pausable, ReentrancyGuard, Ownable { 16 | using SafeERC20 for IERC20; 17 | 18 | struct TreeParameter { 19 | address safeGuard; // address of the safe guard (e.g., address(0)) 20 | bytes32 merkleRoot; // current merkle root 21 | uint256 maxAmountPerUserInCurrentTree; // max amount per user in the current tree 22 | } 23 | 24 | // Time buffer for the admin to withdraw LOOKS tokens if the contract becomes paused 25 | uint256 public constant BUFFER_ADMIN_WITHDRAW = 3 days; 26 | 27 | // Standard safe guard amount (set at 1 LOOKS) 28 | uint256 public constant SAFE_GUARD_AMOUNT = 1e18; 29 | 30 | // LooksRare token 31 | IERC20 public immutable looksRareToken; 32 | 33 | // Keeps track of number of trees existing in parallel 34 | uint8 public numberTrees; 35 | 36 | // Current reward round 37 | uint256 public currentRewardRound; 38 | 39 | // Last paused timestamp 40 | uint256 public lastPausedTimestamp; 41 | 42 | // Keeps track of current parameters of a tree 43 | mapping(uint8 => TreeParameter) public treeParameters; 44 | 45 | // Total amount claimed by user (in LOOKS) 46 | mapping(address => mapping(uint8 => uint256)) public amountClaimedByUserPerTreeId; 47 | 48 | // Check whether safe guard address was used 49 | mapping(address => bool) public safeGuardUsed; 50 | 51 | // Checks whether a merkle root was used 52 | mapping(bytes32 => bool) public merkleRootUsed; 53 | 54 | event Claim(address user, uint256 rewardRound, uint256 totalAmount, uint8[] treeIds, uint256[] amounts); 55 | event NewTree(uint8 treeId); 56 | event UpdateTradingRewards(uint256 indexed rewardRound); 57 | event TokenWithdrawnOwner(uint256 amount); 58 | 59 | /** 60 | * @notice Constructor 61 | * @param _looksRareToken address of the LooksRare token 62 | */ 63 | constructor(address _looksRareToken) { 64 | looksRareToken = IERC20(_looksRareToken); 65 | _pause(); 66 | } 67 | 68 | /** 69 | * @notice Claim pending rewards 70 | * @param treeIds array of treeIds 71 | * @param amounts array of amounts to claim 72 | * @param merkleProofs array of arrays containing the merkle proof 73 | */ 74 | function claim( 75 | uint8[] calldata treeIds, 76 | uint256[] calldata amounts, 77 | bytes32[][] calldata merkleProofs 78 | ) external whenNotPaused nonReentrant { 79 | require( 80 | treeIds.length > 0 && treeIds.length == amounts.length && merkleProofs.length == treeIds.length, 81 | "Rewards: Wrong lengths" 82 | ); 83 | 84 | uint256 amountToTransfer; 85 | uint256[] memory adjustedAmounts = new uint256[](amounts.length); 86 | 87 | for (uint256 i = 0; i < treeIds.length; i++) { 88 | require(treeIds[i] < numberTrees, "Rewards: Tree nonexistent"); 89 | (bool claimStatus, uint256 adjustedAmount) = _canClaim(msg.sender, treeIds[i], amounts[i], merkleProofs[i]); 90 | require(claimStatus, "Rewards: Invalid proof"); 91 | require(adjustedAmount > 0, "Rewards: Already claimed"); 92 | require( 93 | amounts[i] <= treeParameters[treeIds[i]].maxAmountPerUserInCurrentTree, 94 | "Rewards: Amount higher than max" 95 | ); 96 | amountToTransfer += adjustedAmount; 97 | amountClaimedByUserPerTreeId[msg.sender][treeIds[i]] += adjustedAmount; 98 | adjustedAmounts[i] = adjustedAmount; 99 | } 100 | 101 | // Transfer total amount 102 | looksRareToken.safeTransfer(msg.sender, amountToTransfer); 103 | 104 | emit Claim(msg.sender, currentRewardRound, amountToTransfer, treeIds, adjustedAmounts); 105 | } 106 | 107 | /** 108 | * @notice Update trading rewards with a new merkle root 109 | * @dev It automatically increments the currentRewardRound 110 | * @param treeIds array of treeIds 111 | * @param merkleRoots array of merkle roots (for each treeId) 112 | * @param maxAmountsPerUser array of maximum amounts per user (for each treeId) 113 | * @param merkleProofsSafeGuards array of merkle proof for the safe guard addresses 114 | */ 115 | function updateTradingRewards( 116 | uint8[] calldata treeIds, 117 | bytes32[] calldata merkleRoots, 118 | uint256[] calldata maxAmountsPerUser, 119 | bytes32[][] calldata merkleProofsSafeGuards 120 | ) external onlyOwner { 121 | require( 122 | treeIds.length > 0 && 123 | treeIds.length == merkleRoots.length && 124 | treeIds.length == maxAmountsPerUser.length && 125 | treeIds.length == merkleProofsSafeGuards.length, 126 | "Owner: Wrong lengths" 127 | ); 128 | 129 | for (uint256 i = 0; i < merkleRoots.length; i++) { 130 | require(treeIds[i] < numberTrees, "Owner: Tree nonexistent"); 131 | require(!merkleRootUsed[merkleRoots[i]], "Owner: Merkle root already used"); 132 | treeParameters[treeIds[i]].merkleRoot = merkleRoots[i]; 133 | treeParameters[treeIds[i]].maxAmountPerUserInCurrentTree = maxAmountsPerUser[i]; 134 | merkleRootUsed[merkleRoots[i]] = true; 135 | (bool canSafeGuardClaim, ) = _canClaim( 136 | treeParameters[treeIds[i]].safeGuard, 137 | treeIds[i], 138 | SAFE_GUARD_AMOUNT, 139 | merkleProofsSafeGuards[i] 140 | ); 141 | require(canSafeGuardClaim, "Owner: Wrong safe guard proofs"); 142 | } 143 | 144 | // Emit event and increment reward round 145 | emit UpdateTradingRewards(++currentRewardRound); 146 | } 147 | 148 | /** 149 | * @notice Add a new tree 150 | * @param safeGuard address of a safe guard user (e.g., address(0), address(1)) 151 | * @dev Only for owner. 152 | */ 153 | function addNewTree(address safeGuard) external onlyOwner { 154 | require(!safeGuardUsed[safeGuard], "Owner: Safe guard already used"); 155 | safeGuardUsed[safeGuard] = true; 156 | treeParameters[numberTrees].safeGuard = safeGuard; 157 | 158 | // Emit event and increment number trees 159 | emit NewTree(numberTrees++); 160 | } 161 | 162 | /** 163 | * @notice Pause distribution 164 | * @dev Only for owner. 165 | */ 166 | function pauseDistribution() external onlyOwner whenNotPaused { 167 | lastPausedTimestamp = block.timestamp; 168 | _pause(); 169 | } 170 | 171 | /** 172 | * @notice Unpause distribution 173 | * @dev Only for owner. 174 | */ 175 | function unpauseDistribution() external onlyOwner whenPaused { 176 | _unpause(); 177 | } 178 | 179 | /** 180 | * @notice Transfer LOOKS tokens back to owner 181 | * @dev It is for emergency purposes. Only for owner. 182 | * @param amount amount to withdraw 183 | */ 184 | function withdrawTokenRewards(uint256 amount) external onlyOwner whenPaused { 185 | require(block.timestamp > (lastPausedTimestamp + BUFFER_ADMIN_WITHDRAW), "Owner: Too early to withdraw"); 186 | looksRareToken.safeTransfer(msg.sender, amount); 187 | 188 | emit TokenWithdrawnOwner(amount); 189 | } 190 | 191 | /** 192 | * @notice Check whether it is possible to claim and how much based on previous distribution 193 | * @param user address of the user 194 | * @param treeIds array of treeIds 195 | * @param amounts array of amounts to claim 196 | * @param merkleProofs array of arrays containing the merkle proof 197 | */ 198 | function canClaim( 199 | address user, 200 | uint8[] calldata treeIds, 201 | uint256[] calldata amounts, 202 | bytes32[][] calldata merkleProofs 203 | ) external view returns (bool[] memory, uint256[] memory) { 204 | bool[] memory statuses = new bool[](amounts.length); 205 | uint256[] memory adjustedAmounts = new uint256[](amounts.length); 206 | 207 | if (treeIds.length != amounts.length || treeIds.length != merkleProofs.length || treeIds.length == 0) { 208 | return (statuses, adjustedAmounts); 209 | } else { 210 | for (uint256 i = 0; i < treeIds.length; i++) { 211 | if (treeIds[i] < numberTrees) { 212 | (statuses[i], adjustedAmounts[i]) = _canClaim(user, treeIds[i], amounts[i], merkleProofs[i]); 213 | } 214 | } 215 | return (statuses, adjustedAmounts); 216 | } 217 | } 218 | 219 | /** 220 | * @notice Check whether it is possible to claim and how much based on previous distribution 221 | * @param user address of the user 222 | * @param treeId id of the merkle tree 223 | * @param amount amount to claim 224 | * @param merkleProof array with the merkle proof 225 | */ 226 | function _canClaim( 227 | address user, 228 | uint8 treeId, 229 | uint256 amount, 230 | bytes32[] calldata merkleProof 231 | ) internal view returns (bool, uint256) { 232 | // Compute the node and verify the merkle proof 233 | bytes32 node = keccak256(abi.encodePacked(user, amount)); 234 | bool canUserClaim = MerkleProof.verify(merkleProof, treeParameters[treeId].merkleRoot, node); 235 | 236 | if (!canUserClaim) { 237 | return (false, 0); 238 | } else { 239 | uint256 adjustedAmount = amount - amountClaimedByUserPerTreeId[user][treeId]; 240 | return (true, adjustedAmount); 241 | } 242 | } 243 | } 244 | -------------------------------------------------------------------------------- /contracts/OperatorControllerForRewardsV2.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.0; 3 | 4 | import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; 5 | 6 | import {FeeSharingSystem} from "./FeeSharingSystem.sol"; 7 | import {FeeSharingSetter} from "./FeeSharingSetter.sol"; 8 | import {TokenSplitter} from "./TokenSplitter.sol"; 9 | 10 | /** 11 | * @title OperatorControllerForRewardsV2 12 | * @notice It splits pending LOOKS and updates trading rewards. 13 | */ 14 | contract OperatorControllerForRewardsV2 is Ownable { 15 | FeeSharingSystem public immutable feeSharingSystem; 16 | 17 | FeeSharingSetter public immutable feeSharingSetter; 18 | TokenSplitter public immutable tokenSplitter; 19 | 20 | address public immutable teamVesting; 21 | address public immutable treasuryVesting; 22 | address public immutable tradingRewardsDistributor; 23 | 24 | /** 25 | * @notice Constructor 26 | * @param _feeSharingSystem address of the fee sharing system contract 27 | * @param _feeSharingSetter address of the fee sharing setter contract 28 | * @param _tokenSplitter address of the token splitter contract 29 | * @param _teamVesting address of the team vesting contract 30 | * @param _treasuryVesting address of the treasury vesting contract 31 | * @param _tradingRewardsDistributor address of the trading rewards distributor contract 32 | */ 33 | constructor( 34 | address _feeSharingSystem, 35 | address _feeSharingSetter, 36 | address _tokenSplitter, 37 | address _teamVesting, 38 | address _treasuryVesting, 39 | address _tradingRewardsDistributor 40 | ) { 41 | feeSharingSystem = FeeSharingSystem(_feeSharingSystem); 42 | feeSharingSetter = FeeSharingSetter(_feeSharingSetter); 43 | tokenSplitter = TokenSplitter(_tokenSplitter); 44 | teamVesting = _teamVesting; 45 | treasuryVesting = _treasuryVesting; 46 | tradingRewardsDistributor = _tradingRewardsDistributor; 47 | } 48 | 49 | /** 50 | * @notice Release LOOKS tokens from the TokenSplitter and update fee-sharing rewards 51 | */ 52 | function releaseTokensAndUpdateRewards() external onlyOwner { 53 | require(canRelease(), "Owner: Too early"); 54 | 55 | try tokenSplitter.releaseTokens(teamVesting) {} catch {} 56 | try tokenSplitter.releaseTokens(treasuryVesting) {} catch {} 57 | try tokenSplitter.releaseTokens(tradingRewardsDistributor) {} catch {} 58 | 59 | feeSharingSetter.updateRewards(); 60 | } 61 | 62 | /** 63 | * @notice It verifies that the lastUpdateBlock is greater than endBlock 64 | */ 65 | function canRelease() public view returns (bool) { 66 | uint256 endBlock = feeSharingSystem.periodEndBlock(); 67 | uint256 lastUpdateBlock = feeSharingSystem.lastUpdateBlock(); 68 | return lastUpdateBlock == endBlock; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /contracts/PrivateSaleWithFeeSharing.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.0; 3 | 4 | import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; 5 | import {ReentrancyGuard} from "@openzeppelin/contracts/security/ReentrancyGuard.sol"; 6 | import {IERC20, SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; 7 | 8 | /** 9 | * @title PrivateSaleWithFeeSharing 10 | * @notice It handles the private sale for LOOKS tokens (against ETH) and the fee-sharing 11 | * mechanism for sale participants. It uses a 3-tier system with different 12 | * costs (in ETH) to participate. The exchange rate is expressed as the price of 1 ETH in LOOKS token. 13 | * It is the same for all three tiers. 14 | */ 15 | contract PrivateSaleWithFeeSharing is Ownable, ReentrancyGuard { 16 | using SafeERC20 for IERC20; 17 | 18 | enum SalePhase { 19 | Pending, // Pending (owner sets up parameters) 20 | Deposit, // Deposit (sale is in progress) 21 | Over, // Sale is over, prior to staking 22 | Staking, // Staking starts 23 | Withdraw // Withdraw opens 24 | } 25 | 26 | struct UserInfo { 27 | uint256 rewardsDistributedToAccount; // reward claimed by the sale participant 28 | uint8 tier; // sale tier (e.g., 1/2/3) 29 | bool hasDeposited; // whether the user has participated 30 | bool hasWithdrawn; // whether the user has withdrawn (after the end of the fee-sharing period) 31 | } 32 | 33 | // Number of eligible tiers in the private sale 34 | uint8 public constant NUMBER_TIERS = 3; 35 | 36 | IERC20 public immutable looksRareToken; 37 | 38 | IERC20 public immutable rewardToken; 39 | 40 | // Maximum blocks for withdrawal 41 | uint256 public immutable MAX_BLOCK_FOR_WITHDRAWAL; 42 | 43 | // Total LOOKS expected to be distributed 44 | uint256 public immutable TOTAL_LOOKS_DISTRIBUTED; 45 | 46 | // Current sale phase (uint8) 47 | SalePhase public currentPhase; 48 | 49 | // Block where participants can withdraw the LOOKS tokens 50 | uint256 public blockForWithdrawal; 51 | 52 | // Price of WETH in LOOKS for the sale 53 | uint256 public priceOfETHInLOOKS; 54 | 55 | // Total amount committed in the sale (in ETH) 56 | uint256 public totalAmountCommitted; 57 | 58 | // Total reward tokens (i.e., WETH) distributed across stakers 59 | uint256 public totalRewardTokensDistributedToStakers; 60 | 61 | // Keeps track of the cost to join the sale for a given tier 62 | mapping(uint8 => uint256) public allocationCostPerTier; 63 | 64 | // Keeps track of the number of whitelisted participants for each tier 65 | mapping(uint8 => uint256) public numberOfParticipantsForATier; 66 | 67 | // Keeps track of user information (e.g., tier, amount collected, participation) 68 | mapping(address => UserInfo) public userInfo; 69 | 70 | event Deposit(address indexed user, uint8 tier); 71 | event Harvest(address indexed user, uint256 amount); 72 | event NewSalePhase(SalePhase newSalePhase); 73 | event NewAllocationCostPerTier(uint8 tier, uint256 allocationCostInETH); 74 | event NewBlockForWithdrawal(uint256 blockForWithdrawal); 75 | event NewPriceOfETHInLOOKS(uint256 price); 76 | event UsersWhitelisted(address[] users, uint8 tier); 77 | event UserRemoved(address user); 78 | event Withdraw(address indexed user, uint8 tier, uint256 amount); 79 | 80 | /** 81 | * @notice Constructor 82 | * @param _looksRareToken address of the LOOKS token 83 | * @param _rewardToken address of the reward token 84 | * @param _maxBlockForWithdrawal maximum block for withdrawal 85 | * @param _totalLooksDistributed total number of LOOKS tokens to distribute 86 | */ 87 | constructor( 88 | address _looksRareToken, 89 | address _rewardToken, 90 | uint256 _maxBlockForWithdrawal, 91 | uint256 _totalLooksDistributed 92 | ) { 93 | require(_maxBlockForWithdrawal > block.number, "Owner: MaxBlockForWithdrawal must be after block number"); 94 | 95 | looksRareToken = IERC20(_looksRareToken); 96 | rewardToken = IERC20(_rewardToken); 97 | blockForWithdrawal = _maxBlockForWithdrawal; 98 | 99 | MAX_BLOCK_FOR_WITHDRAWAL = _maxBlockForWithdrawal; 100 | TOTAL_LOOKS_DISTRIBUTED = _totalLooksDistributed; 101 | } 102 | 103 | /** 104 | * @notice Deposit ETH to this contract 105 | */ 106 | function deposit() external payable nonReentrant { 107 | require(currentPhase == SalePhase.Deposit, "Deposit: Phase must be Deposit"); 108 | require(userInfo[msg.sender].tier != 0, "Deposit: Not whitelisted"); 109 | require(!userInfo[msg.sender].hasDeposited, "Deposit: Has deposited"); 110 | require(msg.value == allocationCostPerTier[userInfo[msg.sender].tier], "Deposit: Wrong amount"); 111 | 112 | userInfo[msg.sender].hasDeposited = true; 113 | totalAmountCommitted += msg.value; 114 | 115 | emit Deposit(msg.sender, userInfo[msg.sender].tier); 116 | } 117 | 118 | /** 119 | * @notice Harvest WETH 120 | */ 121 | function harvest() external nonReentrant { 122 | require(currentPhase == SalePhase.Staking, "Harvest: Phase must be Staking"); 123 | require(userInfo[msg.sender].hasDeposited, "Harvest: User not eligible"); 124 | 125 | uint256 totalTokensReceived = rewardToken.balanceOf(address(this)) + totalRewardTokensDistributedToStakers; 126 | 127 | uint256 pendingRewardsInWETH = ((totalTokensReceived * allocationCostPerTier[userInfo[msg.sender].tier]) / 128 | totalAmountCommitted) - userInfo[msg.sender].rewardsDistributedToAccount; 129 | 130 | // Revert if amount to transfer is equal to 0 131 | require(pendingRewardsInWETH != 0, "Harvest: Nothing to transfer"); 132 | 133 | userInfo[msg.sender].rewardsDistributedToAccount += pendingRewardsInWETH; 134 | totalRewardTokensDistributedToStakers += pendingRewardsInWETH; 135 | 136 | // Transfer funds to account 137 | rewardToken.safeTransfer(msg.sender, pendingRewardsInWETH); 138 | 139 | emit Harvest(msg.sender, pendingRewardsInWETH); 140 | } 141 | 142 | /** 143 | * @notice Withdraw LOOKS + pending WETH 144 | */ 145 | function withdraw() external nonReentrant { 146 | require(currentPhase == SalePhase.Withdraw, "Withdraw: Phase must be Withdraw"); 147 | require(userInfo[msg.sender].hasDeposited, "Withdraw: User not eligible"); 148 | require(!userInfo[msg.sender].hasWithdrawn, "Withdraw: Has already withdrawn"); 149 | 150 | // Final harvest logic 151 | { 152 | uint256 totalTokensReceived = rewardToken.balanceOf(address(this)) + totalRewardTokensDistributedToStakers; 153 | uint256 pendingRewardsInWETH = ((totalTokensReceived * allocationCostPerTier[userInfo[msg.sender].tier]) / 154 | totalAmountCommitted) - userInfo[msg.sender].rewardsDistributedToAccount; 155 | 156 | // Skip if equal to 0 157 | if (pendingRewardsInWETH > 0) { 158 | userInfo[msg.sender].rewardsDistributedToAccount += pendingRewardsInWETH; 159 | totalRewardTokensDistributedToStakers += pendingRewardsInWETH; 160 | 161 | // Transfer funds to sender 162 | rewardToken.safeTransfer(msg.sender, pendingRewardsInWETH); 163 | 164 | emit Harvest(msg.sender, pendingRewardsInWETH); 165 | } 166 | } 167 | 168 | // Update status to withdrawn 169 | userInfo[msg.sender].hasWithdrawn = true; 170 | 171 | // Calculate amount of LOOKS to transfer based on the tier 172 | uint256 looksAmountToTransfer = allocationCostPerTier[userInfo[msg.sender].tier] * priceOfETHInLOOKS; 173 | 174 | // Transfer LOOKS token to sender 175 | looksRareToken.safeTransfer(msg.sender, looksAmountToTransfer); 176 | 177 | emit Withdraw(msg.sender, userInfo[msg.sender].tier, looksAmountToTransfer); 178 | } 179 | 180 | /** 181 | * @notice Update sale phase to withdraw after the sale lock has passed. 182 | * It can called by anyone. 183 | */ 184 | function updateSalePhaseToWithdraw() external { 185 | require(currentPhase == SalePhase.Staking, "Phase: Must be Staking"); 186 | require(block.number >= blockForWithdrawal, "Phase: Too early to update sale status"); 187 | 188 | // Update phase to Withdraw 189 | currentPhase = SalePhase.Withdraw; 190 | 191 | emit NewSalePhase(SalePhase.Withdraw); 192 | } 193 | 194 | /** 195 | * @notice Remove a user from the whitelist 196 | * @param _user address of the user 197 | */ 198 | function removeUserFromWhitelist(address _user) external onlyOwner { 199 | require(currentPhase == SalePhase.Pending, "Owner: Phase must be Pending"); 200 | require(userInfo[_user].tier != 0, "Owner: Tier not set for user"); 201 | 202 | numberOfParticipantsForATier[userInfo[_user].tier]--; 203 | userInfo[_user].tier = 0; 204 | 205 | emit UserRemoved(_user); 206 | } 207 | 208 | /** 209 | * @notice Set allocation per tier 210 | * @param _tier tier of sale 211 | * @param _allocationCostInETH allocation in ETH for the tier 212 | */ 213 | function setAllocationCostPerTier(uint8 _tier, uint256 _allocationCostInETH) external onlyOwner { 214 | require(currentPhase == SalePhase.Pending, "Owner: Phase must be Pending"); 215 | require(_tier > 0 && _tier <= NUMBER_TIERS, "Owner: Tier outside of range"); 216 | 217 | allocationCostPerTier[_tier] = _allocationCostInETH; 218 | 219 | emit NewAllocationCostPerTier(_tier, _allocationCostInETH); 220 | } 221 | 222 | /** 223 | * @notice Update block deadline for withdrawal of LOOKS 224 | * @param _blockForWithdrawal block for withdrawing LOOKS for sale participants 225 | */ 226 | function setBlockForWithdrawal(uint256 _blockForWithdrawal) external onlyOwner { 227 | require( 228 | _blockForWithdrawal <= MAX_BLOCK_FOR_WITHDRAWAL, 229 | "Owner: Block for withdrawal must be lower than max block for withdrawal" 230 | ); 231 | 232 | blockForWithdrawal = _blockForWithdrawal; 233 | 234 | emit NewBlockForWithdrawal(_blockForWithdrawal); 235 | } 236 | 237 | /** 238 | * @notice Set price of 1 ETH in LOOKS 239 | * @param _priceOfETHinLOOKS price of 1 ETH in LOOKS 240 | */ 241 | function setPriceOfETHInLOOKS(uint256 _priceOfETHinLOOKS) external onlyOwner { 242 | require(currentPhase == SalePhase.Pending, "Owner: Phase must be Pending"); 243 | priceOfETHInLOOKS = _priceOfETHinLOOKS; 244 | 245 | emit NewPriceOfETHInLOOKS(_priceOfETHinLOOKS); 246 | } 247 | 248 | /** 249 | * @notice Update sale phase for the first two phases 250 | * @param _newSalePhase SalePhase (uint8) 251 | */ 252 | function updateSalePhase(SalePhase _newSalePhase) external onlyOwner { 253 | if (_newSalePhase == SalePhase.Deposit) { 254 | require(currentPhase == SalePhase.Pending, "Owner: Phase must be Pending"); 255 | 256 | // Risk checks 257 | require(priceOfETHInLOOKS > 0, "Owner: Exchange rate must be > 0"); 258 | require(getMaxAmountLOOKSToDistribute() == TOTAL_LOOKS_DISTRIBUTED, "Owner: Wrong amount of LOOKS"); 259 | require( 260 | looksRareToken.balanceOf(address(this)) >= TOTAL_LOOKS_DISTRIBUTED, 261 | "Owner: Not enough LOOKS in the contract" 262 | ); 263 | require(blockForWithdrawal > block.number, "Owner: Block for withdrawal wrongly set"); 264 | } else if (_newSalePhase == SalePhase.Over) { 265 | require(currentPhase == SalePhase.Deposit, "Owner: Phase must be Deposit"); 266 | } else { 267 | revert("Owner: Cannot update to this phase"); 268 | } 269 | 270 | // Update phase to the new sale phase 271 | currentPhase = _newSalePhase; 272 | 273 | emit NewSalePhase(_newSalePhase); 274 | } 275 | 276 | /** 277 | * @notice Withdraw the total commited amount (in ETH) and any LOOKS surplus. 278 | * It also updates the sale phase to Staking phase. 279 | */ 280 | function withdrawCommittedAmount() external onlyOwner nonReentrant { 281 | require(currentPhase == SalePhase.Over, "Owner: Phase must be Over"); 282 | 283 | // Transfer ETH to the owner 284 | (bool success, ) = msg.sender.call{value: totalAmountCommitted}(""); 285 | require(success, "Owner: Transfer fail"); 286 | 287 | // If some tiered users did not participate, transfer the LOOKS surplus to contract owner 288 | if (totalAmountCommitted * priceOfETHInLOOKS < (TOTAL_LOOKS_DISTRIBUTED)) { 289 | uint256 tokenAmountToReturnInLOOKS = TOTAL_LOOKS_DISTRIBUTED - (totalAmountCommitted * priceOfETHInLOOKS); 290 | looksRareToken.safeTransfer(msg.sender, tokenAmountToReturnInLOOKS); 291 | } 292 | 293 | // Update phase status to Staking 294 | currentPhase = SalePhase.Staking; 295 | 296 | emit NewSalePhase(SalePhase.Staking); 297 | } 298 | 299 | /** 300 | * @notice Whitelist a list of user addresses for a given tier 301 | * It updates the sale phase to staking phase. 302 | * @param _users array of user addresses 303 | * @param _tier tier for the array of users 304 | */ 305 | function whitelistUsers(address[] calldata _users, uint8 _tier) external onlyOwner { 306 | require(currentPhase == SalePhase.Pending, "Owner: Phase must be Pending"); 307 | require(_tier > 0 && _tier <= NUMBER_TIERS, "Owner: Tier outside of range"); 308 | 309 | for (uint256 i = 0; i < _users.length; i++) { 310 | require(userInfo[_users[i]].tier == 0, "Owner: Tier already set"); 311 | userInfo[_users[i]].tier = _tier; 312 | } 313 | 314 | // Adjust count of participants for the given tier 315 | numberOfParticipantsForATier[_tier] += _users.length; 316 | 317 | emit UsersWhitelisted(_users, _tier); 318 | } 319 | 320 | /** 321 | * @notice Retrieve amount of reward token (WETH) a user can collect 322 | * @param user address of the user who participated in the private sale 323 | */ 324 | function calculatePendingRewards(address user) external view returns (uint256) { 325 | if (userInfo[user].hasDeposited == false || userInfo[user].hasWithdrawn) { 326 | return 0; 327 | } 328 | 329 | uint256 totalTokensReceived = rewardToken.balanceOf(address(this)) + totalRewardTokensDistributedToStakers; 330 | uint256 pendingRewardsInWETH = ((totalTokensReceived * allocationCostPerTier[userInfo[user].tier]) / 331 | totalAmountCommitted) - userInfo[user].rewardsDistributedToAccount; 332 | 333 | return pendingRewardsInWETH; 334 | } 335 | 336 | /** 337 | * @notice Retrieve max amount to distribute (in LOOKS) for sale 338 | */ 339 | function getMaxAmountLOOKSToDistribute() public view returns (uint256 maxAmountCollected) { 340 | for (uint8 i = 1; i <= NUMBER_TIERS; i++) { 341 | maxAmountCollected += (allocationCostPerTier[i] * numberOfParticipantsForATier[i]); 342 | } 343 | 344 | return maxAmountCollected * priceOfETHInLOOKS; 345 | } 346 | } 347 | -------------------------------------------------------------------------------- /contracts/ProtocolFeesDistributor.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity 0.8.23; 3 | 4 | import {LowLevelWETH} from "@looksrare/contracts-libs/contracts/lowLevelCallers/LowLevelWETH.sol"; 5 | import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol"; 6 | import {Pausable} from "@looksrare/contracts-libs/contracts/Pausable.sol"; 7 | import {ReentrancyGuard} from "@looksrare/contracts-libs/contracts/ReentrancyGuard.sol"; 8 | 9 | import {MerkleProof} from "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol"; 10 | 11 | import {IBlast, GasMode, YieldMode} from "./interfaces/IBlast.sol"; 12 | import {IBlastPoints} from "./interfaces/IBlastPoints.sol"; 13 | 14 | /** 15 | * @title ProtocolFeesDistributor 16 | * @notice It distributes protocol fees with rolling Merkle airdrops. 17 | * @author YOLO Games Team 18 | */ 19 | contract ProtocolFeesDistributor is Pausable, ReentrancyGuard, AccessControl, LowLevelWETH { 20 | bytes32 public constant OPERATOR_ROLE = keccak256("OPERATOR_ROLE"); 21 | 22 | address private immutable WETH; 23 | 24 | // Current round (users can only claim pending protocol fees for the current round) 25 | uint256 public currentRound; 26 | 27 | // Users can claim until this timestamp 28 | uint256 public canClaimUntil; 29 | 30 | // Max amount per user in current tree 31 | uint256 public maximumAmountPerUserInCurrentTree; 32 | 33 | // Total amount claimed by user (in ETH) 34 | mapping(address => uint256) public amountClaimedByUser; 35 | 36 | // Merkle root for a round 37 | mapping(uint256 => bytes32) public merkleRootOfRound; 38 | 39 | // Keeps track on whether user has claimed at a given round 40 | mapping(uint256 => mapping(address => bool)) public hasUserClaimedForRound; 41 | 42 | event ProtocolFeesClaimed(address indexed user, uint256 indexed round, uint256 amount); 43 | event ProtocolFeesDistributionUpdated(uint256 indexed round); 44 | event EthWithdrawn(uint256 amount); 45 | event CanClaimUntilUpdated(uint256 timestamp); 46 | 47 | error AlreadyClaimed(); 48 | error AmountHigherThanMax(); 49 | error ClaimPeriodEnded(); 50 | error InvalidProof(); 51 | 52 | /** 53 | * @notice Constructor 54 | * @param _weth address of the WETH token 55 | * @param _owner address of the owner 56 | * @param _operator address of the operator 57 | * @param _blast address of the BLAST precompile 58 | * @param _blastPoints The Blast points configuration. 59 | * @param _blastPointsOperator The Blast points operator. 60 | */ 61 | constructor( 62 | address _weth, 63 | address _owner, 64 | address _operator, 65 | address _blast, 66 | address _blastPoints, 67 | address _blastPointsOperator 68 | ) { 69 | WETH = _weth; 70 | 71 | _grantRole(DEFAULT_ADMIN_ROLE, _owner); 72 | _grantRole(OPERATOR_ROLE, _owner); 73 | _grantRole(OPERATOR_ROLE, _operator); 74 | 75 | IBlast(_blast).configure(YieldMode.CLAIMABLE, GasMode.CLAIMABLE, _owner); 76 | IBlastPoints(_blastPoints).configurePointsOperator(_blastPointsOperator); 77 | } 78 | 79 | /** 80 | * @notice Claim pending protocol fees 81 | * @param amount amount to claim 82 | * @param merkleProof array containing the merkle proof 83 | */ 84 | function claim(uint256 amount, bytes32[] calldata merkleProof) external whenNotPaused nonReentrant { 85 | // Verify the round is not claimed already 86 | if (hasUserClaimedForRound[currentRound][msg.sender]) { 87 | revert AlreadyClaimed(); 88 | } 89 | 90 | if (block.timestamp >= canClaimUntil) { 91 | revert ClaimPeriodEnded(); 92 | } 93 | 94 | (bool claimStatus, uint256 adjustedAmount) = _canClaim(msg.sender, amount, merkleProof); 95 | 96 | if (!claimStatus) { 97 | revert InvalidProof(); 98 | } 99 | if (amount > maximumAmountPerUserInCurrentTree) { 100 | revert AmountHigherThanMax(); 101 | } 102 | 103 | // Set mapping for user and round as true 104 | hasUserClaimedForRound[currentRound][msg.sender] = true; 105 | 106 | // Adjust amount claimed 107 | amountClaimedByUser[msg.sender] += adjustedAmount; 108 | 109 | // Transfer adjusted amount 110 | _transferETHAndWrapIfFailWithGasLimit({ 111 | _WETH: WETH, 112 | _to: msg.sender, 113 | _amount: adjustedAmount, 114 | _gasLimit: gasleft() 115 | }); 116 | 117 | emit ProtocolFeesClaimed(msg.sender, currentRound, adjustedAmount); 118 | } 119 | 120 | /** 121 | * @notice Update protocol fees distribution with a new merkle root 122 | * @dev It automatically increments the currentRound 123 | * @param merkleRoot root of the computed merkle tree 124 | */ 125 | function updateProtocolFeesDistribution(bytes32 merkleRoot, uint256 newMaximumAmountPerUser) 126 | external 127 | payable 128 | whenPaused 129 | onlyRole(OPERATOR_ROLE) 130 | { 131 | currentRound++; 132 | merkleRootOfRound[currentRound] = merkleRoot; 133 | maximumAmountPerUserInCurrentTree = newMaximumAmountPerUser; 134 | 135 | emit ProtocolFeesDistributionUpdated(currentRound); 136 | } 137 | 138 | function updateCanClaimUntil(uint256 timestamp) external onlyRole(OPERATOR_ROLE) { 139 | canClaimUntil = timestamp; 140 | emit CanClaimUntilUpdated(timestamp); 141 | } 142 | 143 | /** 144 | * @notice Pause claim 145 | */ 146 | function pause() external onlyRole(OPERATOR_ROLE) whenNotPaused { 147 | _pause(); 148 | } 149 | 150 | /** 151 | * @notice Unpause claim 152 | */ 153 | function unpause() external onlyRole(OPERATOR_ROLE) whenPaused { 154 | _unpause(); 155 | } 156 | 157 | /** 158 | * @notice Transfer ETH back to owner 159 | * @dev It is for emergency purposes 160 | * @param amount amount to withdraw 161 | */ 162 | function withdrawETH(uint256 amount) external onlyRole(DEFAULT_ADMIN_ROLE) { 163 | _transferETHAndWrapIfFailWithGasLimit({_WETH: WETH, _to: msg.sender, _amount: amount, _gasLimit: gasleft()}); 164 | emit EthWithdrawn(amount); 165 | } 166 | 167 | /** 168 | * @notice Check whether it is possible to claim and how much based on previous distribution 169 | * @param user address of the user 170 | * @param amount amount to claim 171 | * @param merkleProof array with the merkle proof 172 | */ 173 | function canClaim( 174 | address user, 175 | uint256 amount, 176 | bytes32[] calldata merkleProof 177 | ) external view returns (bool, uint256) { 178 | if (block.timestamp >= canClaimUntil) { 179 | return (false, 0); 180 | } 181 | 182 | return _canClaim(user, amount, merkleProof); 183 | } 184 | 185 | /** 186 | * @notice Check whether it is possible to claim and how much based on previous distribution 187 | * @param user address of the user 188 | * @param amount amount to claim 189 | * @param merkleProof array with the merkle proof 190 | */ 191 | function _canClaim( 192 | address user, 193 | uint256 amount, 194 | bytes32[] calldata merkleProof 195 | ) internal view returns (bool, uint256) { 196 | // Compute the node and verify the merkle proof 197 | bytes32 node = keccak256(bytes.concat(keccak256(abi.encode(user, amount)))); 198 | 199 | bool canUserClaim = MerkleProof.verify(merkleProof, merkleRootOfRound[currentRound], node); 200 | 201 | if ((!canUserClaim) || (hasUserClaimedForRound[currentRound][user])) { 202 | return (false, 0); 203 | } else { 204 | return (true, amount - amountClaimedByUser[user]); 205 | } 206 | } 207 | 208 | receive() external payable {} 209 | } 210 | -------------------------------------------------------------------------------- /contracts/SeasonRewardsDistributor.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.0; 3 | 4 | import {LowLevelERC20Transfer} from "@looksrare/contracts-libs/contracts/lowLevelCallers/LowLevelERC20Transfer.sol"; 5 | import {OwnableTwoSteps} from "@looksrare/contracts-libs/contracts/OwnableTwoSteps.sol"; 6 | import {Pausable} from "@looksrare/contracts-libs/contracts/Pausable.sol"; 7 | import {ReentrancyGuard} from "@looksrare/contracts-libs/contracts/ReentrancyGuard.sol"; 8 | 9 | import {MerkleProof} from "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol"; 10 | 11 | /** 12 | * @title SeasonRewardsDistributor 13 | * @notice It distributes LOOKS tokens with rolling Merkle airdrops. 14 | */ 15 | contract SeasonRewardsDistributor is Pausable, ReentrancyGuard, OwnableTwoSteps, LowLevelERC20Transfer { 16 | uint256 public constant BUFFER_ADMIN_WITHDRAW = 3 days; 17 | 18 | address public immutable looksRareToken; 19 | 20 | // Current reward round (users can only claim pending rewards for the current round) 21 | uint256 public currentRewardRound; 22 | 23 | // Last paused timestamp 24 | uint256 public lastPausedTimestamp; 25 | 26 | // Max amount per user in current tree 27 | uint256 public maximumAmountPerUserInCurrentTree; 28 | 29 | // Total amount claimed by user (in LOOKS) 30 | mapping(address => uint256) public amountClaimedByUser; 31 | 32 | // Merkle root for a reward round 33 | mapping(uint256 => bytes32) public merkleRootOfRewardRound; 34 | 35 | // Checks whether a merkle root was used 36 | mapping(bytes32 => bool) public merkleRootUsed; 37 | 38 | // Keeps track on whether user has claimed at a given reward round 39 | mapping(uint256 => mapping(address => bool)) public hasUserClaimedForRewardRound; 40 | 41 | event RewardsClaim(address indexed user, uint256 indexed rewardRound, uint256 amount); 42 | event UpdateSeasonRewards(uint256 indexed rewardRound); 43 | event TokenWithdrawnOwner(uint256 amount); 44 | 45 | error AlreadyClaimed(); 46 | error AmountHigherThanMax(); 47 | error InvalidProof(); 48 | error MerkleRootAlreadyUsed(); 49 | error TooEarlyToWithdraw(); 50 | 51 | /** 52 | * @notice Constructor 53 | * @param _looksRareToken address of the LooksRare token 54 | * @param _owner address of the owner 55 | */ 56 | constructor(address _looksRareToken, address _owner) OwnableTwoSteps(_owner) { 57 | looksRareToken = _looksRareToken; 58 | merkleRootUsed[bytes32(0)] = true; 59 | } 60 | 61 | /** 62 | * @notice Claim pending rewards 63 | * @param amount amount to claim 64 | * @param merkleProof array containing the merkle proof 65 | */ 66 | function claim(uint256 amount, bytes32[] calldata merkleProof) external whenNotPaused nonReentrant { 67 | // Verify the reward round is not claimed already 68 | if (hasUserClaimedForRewardRound[currentRewardRound][msg.sender]) { 69 | revert AlreadyClaimed(); 70 | } 71 | 72 | (bool claimStatus, uint256 adjustedAmount) = _canClaim(msg.sender, amount, merkleProof); 73 | 74 | if (!claimStatus) { 75 | revert InvalidProof(); 76 | } 77 | if (amount > maximumAmountPerUserInCurrentTree) { 78 | revert AmountHigherThanMax(); 79 | } 80 | 81 | // Set mapping for user and round as true 82 | hasUserClaimedForRewardRound[currentRewardRound][msg.sender] = true; 83 | 84 | // Adjust amount claimed 85 | amountClaimedByUser[msg.sender] += adjustedAmount; 86 | 87 | // Transfer adjusted amount 88 | _executeERC20DirectTransfer(looksRareToken, msg.sender, adjustedAmount); 89 | 90 | emit RewardsClaim(msg.sender, currentRewardRound, adjustedAmount); 91 | } 92 | 93 | /** 94 | * @notice Update season rewards with a new merkle root 95 | * @dev It automatically increments the currentRewardRound 96 | * @param merkleRoot root of the computed merkle tree 97 | */ 98 | function updateSeasonRewards(bytes32 merkleRoot, uint256 newMaximumAmountPerUser) external onlyOwner { 99 | if (merkleRootUsed[merkleRoot]) { 100 | revert MerkleRootAlreadyUsed(); 101 | } 102 | 103 | currentRewardRound++; 104 | merkleRootOfRewardRound[currentRewardRound] = merkleRoot; 105 | merkleRootUsed[merkleRoot] = true; 106 | maximumAmountPerUserInCurrentTree = newMaximumAmountPerUser; 107 | 108 | emit UpdateSeasonRewards(currentRewardRound); 109 | } 110 | 111 | /** 112 | * @notice Pause distribution 113 | */ 114 | function pauseDistribution() external onlyOwner whenNotPaused { 115 | lastPausedTimestamp = block.timestamp; 116 | _pause(); 117 | } 118 | 119 | /** 120 | * @notice Unpause distribution 121 | */ 122 | function unpauseDistribution() external onlyOwner whenPaused { 123 | _unpause(); 124 | } 125 | 126 | /** 127 | * @notice Transfer LOOKS tokens back to owner 128 | * @dev It is for emergency purposes 129 | * @param amount amount to withdraw 130 | */ 131 | function withdrawTokenRewards(uint256 amount) external onlyOwner whenPaused { 132 | if (block.timestamp <= (lastPausedTimestamp + BUFFER_ADMIN_WITHDRAW)) { 133 | revert TooEarlyToWithdraw(); 134 | } 135 | _executeERC20DirectTransfer(looksRareToken, msg.sender, amount); 136 | 137 | emit TokenWithdrawnOwner(amount); 138 | } 139 | 140 | /** 141 | * @notice Check whether it is possible to claim and how much based on previous distribution 142 | * @param user address of the user 143 | * @param amount amount to claim 144 | * @param merkleProof array with the merkle proof 145 | */ 146 | function canClaim( 147 | address user, 148 | uint256 amount, 149 | bytes32[] calldata merkleProof 150 | ) external view returns (bool, uint256) { 151 | return _canClaim(user, amount, merkleProof); 152 | } 153 | 154 | /** 155 | * @notice Check whether it is possible to claim and how much based on previous distribution 156 | * @param user address of the user 157 | * @param amount amount to claim 158 | * @param merkleProof array with the merkle proof 159 | */ 160 | function _canClaim( 161 | address user, 162 | uint256 amount, 163 | bytes32[] calldata merkleProof 164 | ) internal view returns (bool, uint256) { 165 | // Compute the node and verify the merkle proof 166 | bytes32 node = keccak256(bytes.concat(keccak256(abi.encodePacked(user, amount)))); 167 | 168 | bool canUserClaim = MerkleProof.verify(merkleProof, merkleRootOfRewardRound[currentRewardRound], node); 169 | 170 | if ((!canUserClaim) || (hasUserClaimedForRewardRound[currentRewardRound][user])) { 171 | return (false, 0); 172 | } else { 173 | return (true, amount - amountClaimedByUser[user]); 174 | } 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /contracts/StakingPoolForUniswapV2Tokens.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.0; 3 | 4 | import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; 5 | import {Pausable} from "@openzeppelin/contracts/security/Pausable.sol"; 6 | import {ReentrancyGuard} from "@openzeppelin/contracts/security/ReentrancyGuard.sol"; 7 | import {IERC20, SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; 8 | 9 | import {TokenDistributor} from "./TokenDistributor.sol"; 10 | import {TokenSplitter} from "./TokenSplitter.sol"; 11 | 12 | /** 13 | * @title StakingPoolForUniswapV2Tokens 14 | * @notice It is a staking pool for Uniswap V2 LP tokens (stake Uniswap V2 LP tokens -> get LOOKS). 15 | */ 16 | contract StakingPoolForUniswapV2Tokens is Ownable, Pausable, ReentrancyGuard { 17 | using SafeERC20 for IERC20; 18 | 19 | struct UserInfo { 20 | uint256 amount; // Amount of staked tokens provided by user 21 | uint256 rewardDebt; // Reward debt 22 | } 23 | 24 | // Precision factor for reward calculation 25 | uint256 public constant PRECISION_FACTOR = 10**12; 26 | 27 | // LOOKS token (token distributed) 28 | IERC20 public immutable looksRareToken; 29 | 30 | // The staked token (i.e., Uniswap V2 WETH/LOOKS LP token) 31 | IERC20 public immutable stakedToken; 32 | 33 | // Block number when rewards start 34 | uint256 public immutable START_BLOCK; 35 | 36 | // Accumulated tokens per share 37 | uint256 public accTokenPerShare; 38 | 39 | // Block number when rewards end 40 | uint256 public endBlock; 41 | 42 | // Block number of the last update 43 | uint256 public lastRewardBlock; 44 | 45 | // Tokens distributed per block (in looksRareToken) 46 | uint256 public rewardPerBlock; 47 | 48 | // UserInfo for users that stake tokens (stakedToken) 49 | mapping(address => UserInfo) public userInfo; 50 | 51 | event AdminRewardWithdraw(uint256 amount); 52 | event Deposit(address indexed user, uint256 amount, uint256 harvestedAmount); 53 | event EmergencyWithdraw(address indexed user, uint256 amount); 54 | event Harvest(address indexed user, uint256 harvestedAmount); 55 | event NewRewardPerBlockAndEndBlock(uint256 rewardPerBlock, uint256 endBlock); 56 | event Withdraw(address indexed user, uint256 amount, uint256 harvestedAmount); 57 | 58 | /** 59 | * @notice Constructor 60 | * @param _stakedToken staked token address 61 | * @param _looksRareToken reward token address 62 | * @param _rewardPerBlock reward per block (in LOOKS) 63 | * @param _startBlock start block 64 | * @param _endBlock end block 65 | */ 66 | constructor( 67 | address _stakedToken, 68 | address _looksRareToken, 69 | uint256 _rewardPerBlock, 70 | uint256 _startBlock, 71 | uint256 _endBlock 72 | ) { 73 | stakedToken = IERC20(_stakedToken); 74 | looksRareToken = IERC20(_looksRareToken); 75 | rewardPerBlock = _rewardPerBlock; 76 | START_BLOCK = _startBlock; 77 | endBlock = _endBlock; 78 | 79 | // Set the lastRewardBlock as the start block 80 | lastRewardBlock = _startBlock; 81 | } 82 | 83 | /** 84 | * @notice Deposit staked tokens and collect reward tokens (if any) 85 | * @param amount amount to deposit (in stakedToken) 86 | */ 87 | function deposit(uint256 amount) external nonReentrant { 88 | require(amount > 0, "Deposit: Amount must be > 0"); 89 | 90 | _updatePool(); 91 | 92 | uint256 pendingRewards; 93 | 94 | if (userInfo[msg.sender].amount > 0) { 95 | pendingRewards = 96 | ((userInfo[msg.sender].amount * accTokenPerShare) / PRECISION_FACTOR) - 97 | userInfo[msg.sender].rewardDebt; 98 | 99 | if (pendingRewards > 0) { 100 | looksRareToken.safeTransfer(msg.sender, pendingRewards); 101 | } 102 | } 103 | 104 | stakedToken.safeTransferFrom(msg.sender, address(this), amount); 105 | 106 | userInfo[msg.sender].amount += amount; 107 | userInfo[msg.sender].rewardDebt = (userInfo[msg.sender].amount * accTokenPerShare) / PRECISION_FACTOR; 108 | 109 | emit Deposit(msg.sender, amount, pendingRewards); 110 | } 111 | 112 | /** 113 | * @notice Harvest tokens that are pending 114 | */ 115 | function harvest() external nonReentrant { 116 | _updatePool(); 117 | 118 | uint256 pendingRewards = ((userInfo[msg.sender].amount * accTokenPerShare) / PRECISION_FACTOR) - 119 | userInfo[msg.sender].rewardDebt; 120 | 121 | require(pendingRewards > 0, "Harvest: Pending rewards must be > 0"); 122 | 123 | userInfo[msg.sender].rewardDebt = (userInfo[msg.sender].amount * accTokenPerShare) / PRECISION_FACTOR; 124 | looksRareToken.safeTransfer(msg.sender, pendingRewards); 125 | 126 | emit Harvest(msg.sender, pendingRewards); 127 | } 128 | 129 | /** 130 | * @notice Withdraw staked tokens and give up rewards 131 | * @dev Only for emergency. It does not update the pool. 132 | */ 133 | function emergencyWithdraw() external nonReentrant whenPaused { 134 | uint256 userBalance = userInfo[msg.sender].amount; 135 | 136 | require(userBalance != 0, "Withdraw: Amount must be > 0"); 137 | 138 | // Reset internal value for user 139 | userInfo[msg.sender].amount = 0; 140 | userInfo[msg.sender].rewardDebt = 0; 141 | 142 | stakedToken.safeTransfer(msg.sender, userBalance); 143 | 144 | emit EmergencyWithdraw(msg.sender, userBalance); 145 | } 146 | 147 | /** 148 | * @notice Withdraw staked tokens and collect reward tokens 149 | * @param amount amount to withdraw (in stakedToken) 150 | */ 151 | function withdraw(uint256 amount) external nonReentrant { 152 | require( 153 | (userInfo[msg.sender].amount >= amount) && (amount > 0), 154 | "Withdraw: Amount must be > 0 or lower than user balance" 155 | ); 156 | 157 | _updatePool(); 158 | 159 | uint256 pendingRewards = ((userInfo[msg.sender].amount * accTokenPerShare) / PRECISION_FACTOR) - 160 | userInfo[msg.sender].rewardDebt; 161 | 162 | userInfo[msg.sender].amount -= amount; 163 | userInfo[msg.sender].rewardDebt = (userInfo[msg.sender].amount * accTokenPerShare) / PRECISION_FACTOR; 164 | 165 | stakedToken.safeTransfer(msg.sender, amount); 166 | 167 | if (pendingRewards > 0) { 168 | looksRareToken.safeTransfer(msg.sender, pendingRewards); 169 | } 170 | 171 | emit Withdraw(msg.sender, amount, pendingRewards); 172 | } 173 | 174 | /** 175 | * @notice Withdraw rewards (for admin) 176 | * @param amount amount to withdraw (in looksRareToken) 177 | * @dev Only callable by owner. 178 | */ 179 | function adminRewardWithdraw(uint256 amount) external onlyOwner { 180 | looksRareToken.safeTransfer(msg.sender, amount); 181 | 182 | emit AdminRewardWithdraw(amount); 183 | } 184 | 185 | /** 186 | * @notice Pause 187 | * It allows calling emergencyWithdraw 188 | */ 189 | function pause() external onlyOwner whenNotPaused { 190 | _pause(); 191 | } 192 | 193 | /** 194 | * @notice Unpause 195 | */ 196 | function unpause() external onlyOwner whenPaused { 197 | _unpause(); 198 | } 199 | 200 | /** 201 | * @notice Update reward per block and the end block 202 | * @param newRewardPerBlock the new reward per block 203 | * @param newEndBlock the new end block 204 | */ 205 | function updateRewardPerBlockAndEndBlock(uint256 newRewardPerBlock, uint256 newEndBlock) external onlyOwner { 206 | if (block.number >= START_BLOCK) { 207 | _updatePool(); 208 | } 209 | require(newEndBlock > block.number, "Owner: New endBlock must be after current block"); 210 | require(newEndBlock > START_BLOCK, "Owner: New endBlock must be after start block"); 211 | 212 | endBlock = newEndBlock; 213 | rewardPerBlock = newRewardPerBlock; 214 | 215 | emit NewRewardPerBlockAndEndBlock(newRewardPerBlock, newEndBlock); 216 | } 217 | 218 | /** 219 | * @notice View function to see pending reward on frontend. 220 | * @param user address of the user 221 | * @return Pending reward 222 | */ 223 | function calculatePendingRewards(address user) external view returns (uint256) { 224 | uint256 stakedTokenSupply = stakedToken.balanceOf(address(this)); 225 | 226 | if ((block.number > lastRewardBlock) && (stakedTokenSupply != 0)) { 227 | uint256 multiplier = _getMultiplier(lastRewardBlock, block.number); 228 | uint256 tokenReward = multiplier * rewardPerBlock; 229 | uint256 adjustedTokenPerShare = accTokenPerShare + (tokenReward * PRECISION_FACTOR) / stakedTokenSupply; 230 | 231 | return (userInfo[user].amount * adjustedTokenPerShare) / PRECISION_FACTOR - userInfo[user].rewardDebt; 232 | } else { 233 | return (userInfo[user].amount * accTokenPerShare) / PRECISION_FACTOR - userInfo[user].rewardDebt; 234 | } 235 | } 236 | 237 | /** 238 | * @notice Update reward variables of the pool to be up-to-date. 239 | */ 240 | function _updatePool() internal { 241 | if (block.number <= lastRewardBlock) { 242 | return; 243 | } 244 | 245 | uint256 stakedTokenSupply = stakedToken.balanceOf(address(this)); 246 | 247 | if (stakedTokenSupply == 0) { 248 | lastRewardBlock = block.number; 249 | return; 250 | } 251 | 252 | uint256 multiplier = _getMultiplier(lastRewardBlock, block.number); 253 | uint256 tokenReward = multiplier * rewardPerBlock; 254 | 255 | // Update only if token reward for staking is not null 256 | if (tokenReward > 0) { 257 | accTokenPerShare = accTokenPerShare + ((tokenReward * PRECISION_FACTOR) / stakedTokenSupply); 258 | } 259 | 260 | // Update last reward block only if it wasn't updated after or at the end block 261 | if (lastRewardBlock <= endBlock) { 262 | lastRewardBlock = block.number; 263 | } 264 | } 265 | 266 | /** 267 | * @notice Return reward multiplier over the given "from" to "to" block. 268 | * @param from block to start calculating reward 269 | * @param to block to finish calculating reward 270 | * @return the multiplier for the period 271 | */ 272 | function _getMultiplier(uint256 from, uint256 to) internal view returns (uint256) { 273 | if (to <= endBlock) { 274 | return to - from; 275 | } else if (from >= endBlock) { 276 | return 0; 277 | } else { 278 | return endBlock - from; 279 | } 280 | } 281 | } 282 | -------------------------------------------------------------------------------- /contracts/TokenDistributor.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.0; 3 | 4 | import {ReentrancyGuard} from "@openzeppelin/contracts/security/ReentrancyGuard.sol"; 5 | import {IERC20, SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; 6 | 7 | import {ILooksRareToken} from "./interfaces/ILooksRareToken.sol"; 8 | 9 | /** 10 | * @title TokenDistributor 11 | * @notice It handles the distribution of LOOKS token. 12 | * It auto-adjusts block rewards over a set number of periods. 13 | */ 14 | contract TokenDistributor is ReentrancyGuard { 15 | using SafeERC20 for IERC20; 16 | using SafeERC20 for ILooksRareToken; 17 | 18 | struct StakingPeriod { 19 | uint256 rewardPerBlockForStaking; 20 | uint256 rewardPerBlockForOthers; 21 | uint256 periodLengthInBlock; 22 | } 23 | 24 | struct UserInfo { 25 | uint256 amount; // Amount of staked tokens provided by user 26 | uint256 rewardDebt; // Reward debt 27 | } 28 | 29 | // Precision factor for calculating rewards 30 | uint256 public constant PRECISION_FACTOR = 10**12; 31 | 32 | ILooksRareToken public immutable looksRareToken; 33 | 34 | address public immutable tokenSplitter; 35 | 36 | // Number of reward periods 37 | uint256 public immutable NUMBER_PERIODS; 38 | 39 | // Block number when rewards start 40 | uint256 public immutable START_BLOCK; 41 | 42 | // Accumulated tokens per share 43 | uint256 public accTokenPerShare; 44 | 45 | // Current phase for rewards 46 | uint256 public currentPhase; 47 | 48 | // Block number when rewards end 49 | uint256 public endBlock; 50 | 51 | // Block number of the last update 52 | uint256 public lastRewardBlock; 53 | 54 | // Tokens distributed per block for other purposes (team + treasury + trading rewards) 55 | uint256 public rewardPerBlockForOthers; 56 | 57 | // Tokens distributed per block for staking 58 | uint256 public rewardPerBlockForStaking; 59 | 60 | // Total amount staked 61 | uint256 public totalAmountStaked; 62 | 63 | mapping(uint256 => StakingPeriod) public stakingPeriod; 64 | 65 | mapping(address => UserInfo) public userInfo; 66 | 67 | event Compound(address indexed user, uint256 harvestedAmount); 68 | event Deposit(address indexed user, uint256 amount, uint256 harvestedAmount); 69 | event NewRewardsPerBlock( 70 | uint256 indexed currentPhase, 71 | uint256 startBlock, 72 | uint256 rewardPerBlockForStaking, 73 | uint256 rewardPerBlockForOthers 74 | ); 75 | event Withdraw(address indexed user, uint256 amount, uint256 harvestedAmount); 76 | 77 | /** 78 | * @notice Constructor 79 | * @param _looksRareToken LOOKS token address 80 | * @param _tokenSplitter token splitter contract address (for team and trading rewards) 81 | * @param _startBlock start block for reward program 82 | * @param _rewardsPerBlockForStaking array of rewards per block for staking 83 | * @param _rewardsPerBlockForOthers array of rewards per block for other purposes (team + treasury + trading rewards) 84 | * @param _periodLengthesInBlocks array of period lengthes 85 | * @param _numberPeriods number of periods with different rewards/lengthes (e.g., if 3 changes --> 4 periods) 86 | */ 87 | constructor( 88 | address _looksRareToken, 89 | address _tokenSplitter, 90 | uint256 _startBlock, 91 | uint256[] memory _rewardsPerBlockForStaking, 92 | uint256[] memory _rewardsPerBlockForOthers, 93 | uint256[] memory _periodLengthesInBlocks, 94 | uint256 _numberPeriods 95 | ) { 96 | require( 97 | (_periodLengthesInBlocks.length == _numberPeriods) && 98 | (_rewardsPerBlockForStaking.length == _numberPeriods) && 99 | (_rewardsPerBlockForStaking.length == _numberPeriods), 100 | "Distributor: Lengthes must match numberPeriods" 101 | ); 102 | 103 | // 1. Operational checks for supply 104 | uint256 nonCirculatingSupply = ILooksRareToken(_looksRareToken).SUPPLY_CAP() - 105 | ILooksRareToken(_looksRareToken).totalSupply(); 106 | 107 | uint256 amountTokensToBeMinted; 108 | 109 | for (uint256 i = 0; i < _numberPeriods; i++) { 110 | amountTokensToBeMinted += 111 | (_rewardsPerBlockForStaking[i] * _periodLengthesInBlocks[i]) + 112 | (_rewardsPerBlockForOthers[i] * _periodLengthesInBlocks[i]); 113 | 114 | stakingPeriod[i] = StakingPeriod({ 115 | rewardPerBlockForStaking: _rewardsPerBlockForStaking[i], 116 | rewardPerBlockForOthers: _rewardsPerBlockForOthers[i], 117 | periodLengthInBlock: _periodLengthesInBlocks[i] 118 | }); 119 | } 120 | 121 | require(amountTokensToBeMinted == nonCirculatingSupply, "Distributor: Wrong reward parameters"); 122 | 123 | // 2. Store values 124 | looksRareToken = ILooksRareToken(_looksRareToken); 125 | tokenSplitter = _tokenSplitter; 126 | rewardPerBlockForStaking = _rewardsPerBlockForStaking[0]; 127 | rewardPerBlockForOthers = _rewardsPerBlockForOthers[0]; 128 | 129 | START_BLOCK = _startBlock; 130 | endBlock = _startBlock + _periodLengthesInBlocks[0]; 131 | 132 | NUMBER_PERIODS = _numberPeriods; 133 | 134 | // Set the lastRewardBlock as the startBlock 135 | lastRewardBlock = _startBlock; 136 | } 137 | 138 | /** 139 | * @notice Deposit staked tokens and compounds pending rewards 140 | * @param amount amount to deposit (in LOOKS) 141 | */ 142 | function deposit(uint256 amount) external nonReentrant { 143 | require(amount > 0, "Deposit: Amount must be > 0"); 144 | 145 | // Update pool information 146 | _updatePool(); 147 | 148 | // Transfer LOOKS tokens to this contract 149 | looksRareToken.safeTransferFrom(msg.sender, address(this), amount); 150 | 151 | uint256 pendingRewards; 152 | 153 | // If not new deposit, calculate pending rewards (for auto-compounding) 154 | if (userInfo[msg.sender].amount > 0) { 155 | pendingRewards = 156 | ((userInfo[msg.sender].amount * accTokenPerShare) / PRECISION_FACTOR) - 157 | userInfo[msg.sender].rewardDebt; 158 | } 159 | 160 | // Adjust user information 161 | userInfo[msg.sender].amount += (amount + pendingRewards); 162 | userInfo[msg.sender].rewardDebt = (userInfo[msg.sender].amount * accTokenPerShare) / PRECISION_FACTOR; 163 | 164 | // Increase totalAmountStaked 165 | totalAmountStaked += (amount + pendingRewards); 166 | 167 | emit Deposit(msg.sender, amount, pendingRewards); 168 | } 169 | 170 | /** 171 | * @notice Compound based on pending rewards 172 | */ 173 | function harvestAndCompound() external nonReentrant { 174 | // Update pool information 175 | _updatePool(); 176 | 177 | // Calculate pending rewards 178 | uint256 pendingRewards = ((userInfo[msg.sender].amount * accTokenPerShare) / PRECISION_FACTOR) - 179 | userInfo[msg.sender].rewardDebt; 180 | 181 | // Return if no pending rewards 182 | if (pendingRewards == 0) { 183 | // It doesn't throw revertion (to help with the fee-sharing auto-compounding contract) 184 | return; 185 | } 186 | 187 | // Adjust user amount for pending rewards 188 | userInfo[msg.sender].amount += pendingRewards; 189 | 190 | // Adjust totalAmountStaked 191 | totalAmountStaked += pendingRewards; 192 | 193 | // Recalculate reward debt based on new user amount 194 | userInfo[msg.sender].rewardDebt = (userInfo[msg.sender].amount * accTokenPerShare) / PRECISION_FACTOR; 195 | 196 | emit Compound(msg.sender, pendingRewards); 197 | } 198 | 199 | /** 200 | * @notice Update pool rewards 201 | */ 202 | function updatePool() external nonReentrant { 203 | _updatePool(); 204 | } 205 | 206 | /** 207 | * @notice Withdraw staked tokens and compound pending rewards 208 | * @param amount amount to withdraw 209 | */ 210 | function withdraw(uint256 amount) external nonReentrant { 211 | require( 212 | (userInfo[msg.sender].amount >= amount) && (amount > 0), 213 | "Withdraw: Amount must be > 0 or lower than user balance" 214 | ); 215 | 216 | // Update pool 217 | _updatePool(); 218 | 219 | // Calculate pending rewards 220 | uint256 pendingRewards = ((userInfo[msg.sender].amount * accTokenPerShare) / PRECISION_FACTOR) - 221 | userInfo[msg.sender].rewardDebt; 222 | 223 | // Adjust user information 224 | userInfo[msg.sender].amount = userInfo[msg.sender].amount + pendingRewards - amount; 225 | userInfo[msg.sender].rewardDebt = (userInfo[msg.sender].amount * accTokenPerShare) / PRECISION_FACTOR; 226 | 227 | // Adjust total amount staked 228 | totalAmountStaked = totalAmountStaked + pendingRewards - amount; 229 | 230 | // Transfer LOOKS tokens to the sender 231 | looksRareToken.safeTransfer(msg.sender, amount); 232 | 233 | emit Withdraw(msg.sender, amount, pendingRewards); 234 | } 235 | 236 | /** 237 | * @notice Withdraw all staked tokens and collect tokens 238 | */ 239 | function withdrawAll() external nonReentrant { 240 | require(userInfo[msg.sender].amount > 0, "Withdraw: Amount must be > 0"); 241 | 242 | // Update pool 243 | _updatePool(); 244 | 245 | // Calculate pending rewards and amount to transfer (to the sender) 246 | uint256 pendingRewards = ((userInfo[msg.sender].amount * accTokenPerShare) / PRECISION_FACTOR) - 247 | userInfo[msg.sender].rewardDebt; 248 | 249 | uint256 amountToTransfer = userInfo[msg.sender].amount + pendingRewards; 250 | 251 | // Adjust total amount staked 252 | totalAmountStaked = totalAmountStaked - userInfo[msg.sender].amount; 253 | 254 | // Adjust user information 255 | userInfo[msg.sender].amount = 0; 256 | userInfo[msg.sender].rewardDebt = 0; 257 | 258 | // Transfer LOOKS tokens to the sender 259 | looksRareToken.safeTransfer(msg.sender, amountToTransfer); 260 | 261 | emit Withdraw(msg.sender, amountToTransfer, pendingRewards); 262 | } 263 | 264 | /** 265 | * @notice Calculate pending rewards for a user 266 | * @param user address of the user 267 | * @return Pending rewards 268 | */ 269 | function calculatePendingRewards(address user) external view returns (uint256) { 270 | if ((block.number > lastRewardBlock) && (totalAmountStaked != 0)) { 271 | uint256 multiplier = _getMultiplier(lastRewardBlock, block.number); 272 | 273 | uint256 tokenRewardForStaking = multiplier * rewardPerBlockForStaking; 274 | 275 | uint256 adjustedEndBlock = endBlock; 276 | uint256 adjustedCurrentPhase = currentPhase; 277 | 278 | // Check whether to adjust multipliers and reward per block 279 | while ((block.number > adjustedEndBlock) && (adjustedCurrentPhase < (NUMBER_PERIODS - 1))) { 280 | // Update current phase 281 | adjustedCurrentPhase++; 282 | 283 | // Update rewards per block 284 | uint256 adjustedRewardPerBlockForStaking = stakingPeriod[adjustedCurrentPhase].rewardPerBlockForStaking; 285 | 286 | // Calculate adjusted block number 287 | uint256 previousEndBlock = adjustedEndBlock; 288 | 289 | // Update end block 290 | adjustedEndBlock = previousEndBlock + stakingPeriod[adjustedCurrentPhase].periodLengthInBlock; 291 | 292 | // Calculate new multiplier 293 | uint256 newMultiplier = (block.number <= adjustedEndBlock) 294 | ? (block.number - previousEndBlock) 295 | : stakingPeriod[adjustedCurrentPhase].periodLengthInBlock; 296 | 297 | // Adjust token rewards for staking 298 | tokenRewardForStaking += (newMultiplier * adjustedRewardPerBlockForStaking); 299 | } 300 | 301 | uint256 adjustedTokenPerShare = accTokenPerShare + 302 | (tokenRewardForStaking * PRECISION_FACTOR) / 303 | totalAmountStaked; 304 | 305 | return (userInfo[user].amount * adjustedTokenPerShare) / PRECISION_FACTOR - userInfo[user].rewardDebt; 306 | } else { 307 | return (userInfo[user].amount * accTokenPerShare) / PRECISION_FACTOR - userInfo[user].rewardDebt; 308 | } 309 | } 310 | 311 | /** 312 | * @notice Update reward variables of the pool 313 | */ 314 | function _updatePool() internal { 315 | if (block.number <= lastRewardBlock) { 316 | return; 317 | } 318 | 319 | if (totalAmountStaked == 0) { 320 | lastRewardBlock = block.number; 321 | return; 322 | } 323 | 324 | // Calculate multiplier 325 | uint256 multiplier = _getMultiplier(lastRewardBlock, block.number); 326 | 327 | // Calculate rewards for staking and others 328 | uint256 tokenRewardForStaking = multiplier * rewardPerBlockForStaking; 329 | uint256 tokenRewardForOthers = multiplier * rewardPerBlockForOthers; 330 | 331 | // Check whether to adjust multipliers and reward per block 332 | while ((block.number > endBlock) && (currentPhase < (NUMBER_PERIODS - 1))) { 333 | // Update rewards per block 334 | _updateRewardsPerBlock(endBlock); 335 | 336 | uint256 previousEndBlock = endBlock; 337 | 338 | // Adjust the end block 339 | endBlock += stakingPeriod[currentPhase].periodLengthInBlock; 340 | 341 | // Adjust multiplier to cover the missing periods with other lower inflation schedule 342 | uint256 newMultiplier = _getMultiplier(previousEndBlock, block.number); 343 | 344 | // Adjust token rewards 345 | tokenRewardForStaking += (newMultiplier * rewardPerBlockForStaking); 346 | tokenRewardForOthers += (newMultiplier * rewardPerBlockForOthers); 347 | } 348 | 349 | // Mint tokens only if token rewards for staking are not null 350 | if (tokenRewardForStaking > 0) { 351 | // It allows protection against potential issues to prevent funds from being locked 352 | bool mintStatus = looksRareToken.mint(address(this), tokenRewardForStaking); 353 | if (mintStatus) { 354 | accTokenPerShare = accTokenPerShare + ((tokenRewardForStaking * PRECISION_FACTOR) / totalAmountStaked); 355 | } 356 | 357 | looksRareToken.mint(tokenSplitter, tokenRewardForOthers); 358 | } 359 | 360 | // Update last reward block only if it wasn't updated after or at the end block 361 | if (lastRewardBlock <= endBlock) { 362 | lastRewardBlock = block.number; 363 | } 364 | } 365 | 366 | /** 367 | * @notice Update rewards per block 368 | * @dev Rewards are halved by 2 (for staking + others) 369 | */ 370 | function _updateRewardsPerBlock(uint256 _newStartBlock) internal { 371 | // Update current phase 372 | currentPhase++; 373 | 374 | // Update rewards per block 375 | rewardPerBlockForStaking = stakingPeriod[currentPhase].rewardPerBlockForStaking; 376 | rewardPerBlockForOthers = stakingPeriod[currentPhase].rewardPerBlockForOthers; 377 | 378 | emit NewRewardsPerBlock(currentPhase, _newStartBlock, rewardPerBlockForStaking, rewardPerBlockForOthers); 379 | } 380 | 381 | /** 382 | * @notice Return reward multiplier over the given "from" to "to" block. 383 | * @param from block to start calculating reward 384 | * @param to block to finish calculating reward 385 | * @return the multiplier for the period 386 | */ 387 | function _getMultiplier(uint256 from, uint256 to) internal view returns (uint256) { 388 | if (to <= endBlock) { 389 | return to - from; 390 | } else if (from >= endBlock) { 391 | return 0; 392 | } else { 393 | return endBlock - from; 394 | } 395 | } 396 | } 397 | -------------------------------------------------------------------------------- /contracts/TokenSplitter.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.0; 3 | 4 | import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; 5 | import {ReentrancyGuard} from "@openzeppelin/contracts/security/ReentrancyGuard.sol"; 6 | import {IERC20, SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; 7 | 8 | /** 9 | * @title TokenSplitter 10 | * @notice It splits LOOKS to team/treasury/trading volume reward accounts based on shares. 11 | */ 12 | contract TokenSplitter is Ownable, ReentrancyGuard { 13 | using SafeERC20 for IERC20; 14 | 15 | struct AccountInfo { 16 | uint256 shares; 17 | uint256 tokensDistributedToAccount; 18 | } 19 | 20 | uint256 public immutable TOTAL_SHARES; 21 | 22 | IERC20 public immutable looksRareToken; 23 | 24 | // Total LOOKS tokens distributed across all accounts 25 | uint256 public totalTokensDistributed; 26 | 27 | mapping(address => AccountInfo) public accountInfo; 28 | 29 | event NewSharesOwner(address indexed oldRecipient, address indexed newRecipient); 30 | event TokensTransferred(address indexed account, uint256 amount); 31 | 32 | /** 33 | * @notice Constructor 34 | * @param _accounts array of accounts addresses 35 | * @param _shares array of shares per account 36 | * @param _looksRareToken address of the LOOKS token 37 | */ 38 | constructor( 39 | address[] memory _accounts, 40 | uint256[] memory _shares, 41 | address _looksRareToken 42 | ) { 43 | require(_accounts.length == _shares.length, "Splitter: Length differ"); 44 | require(_accounts.length > 0, "Splitter: Length must be > 0"); 45 | 46 | uint256 currentShares; 47 | 48 | for (uint256 i = 0; i < _accounts.length; i++) { 49 | require(_shares[i] > 0, "Splitter: Shares are 0"); 50 | 51 | currentShares += _shares[i]; 52 | accountInfo[_accounts[i]].shares = _shares[i]; 53 | } 54 | 55 | TOTAL_SHARES = currentShares; 56 | looksRareToken = IERC20(_looksRareToken); 57 | } 58 | 59 | /** 60 | * @notice Release LOOKS tokens to the account 61 | * @param account address of the account 62 | */ 63 | function releaseTokens(address account) external nonReentrant { 64 | require(accountInfo[account].shares > 0, "Splitter: Account has no share"); 65 | 66 | // Calculate amount to transfer to the account 67 | uint256 totalTokensReceived = looksRareToken.balanceOf(address(this)) + totalTokensDistributed; 68 | uint256 pendingRewards = ((totalTokensReceived * accountInfo[account].shares) / TOTAL_SHARES) - 69 | accountInfo[account].tokensDistributedToAccount; 70 | 71 | // Revert if equal to 0 72 | require(pendingRewards != 0, "Splitter: Nothing to transfer"); 73 | 74 | accountInfo[account].tokensDistributedToAccount += pendingRewards; 75 | totalTokensDistributed += pendingRewards; 76 | 77 | // Transfer funds to account 78 | looksRareToken.safeTransfer(account, pendingRewards); 79 | 80 | emit TokensTransferred(account, pendingRewards); 81 | } 82 | 83 | /** 84 | * @notice Update share recipient 85 | * @param _newRecipient address of the new recipient 86 | * @param _currentRecipient address of the current recipient 87 | */ 88 | function updateSharesOwner(address _newRecipient, address _currentRecipient) external onlyOwner { 89 | require(accountInfo[_currentRecipient].shares > 0, "Owner: Current recipient has no shares"); 90 | require(accountInfo[_newRecipient].shares == 0, "Owner: New recipient has existing shares"); 91 | 92 | // Copy shares to new recipient 93 | accountInfo[_newRecipient].shares = accountInfo[_currentRecipient].shares; 94 | accountInfo[_newRecipient].tokensDistributedToAccount = accountInfo[_currentRecipient] 95 | .tokensDistributedToAccount; 96 | 97 | // Reset existing shares 98 | accountInfo[_currentRecipient].shares = 0; 99 | accountInfo[_currentRecipient].tokensDistributedToAccount = 0; 100 | 101 | emit NewSharesOwner(_currentRecipient, _newRecipient); 102 | } 103 | 104 | /** 105 | * @notice Retrieve amount of LOOKS tokens that can be transferred 106 | * @param account address of the account 107 | */ 108 | function calculatePendingRewards(address account) external view returns (uint256) { 109 | if (accountInfo[account].shares == 0) { 110 | return 0; 111 | } 112 | 113 | uint256 totalTokensReceived = looksRareToken.balanceOf(address(this)) + totalTokensDistributed; 114 | uint256 pendingRewards = ((totalTokensReceived * accountInfo[account].shares) / TOTAL_SHARES) - 115 | accountInfo[account].tokensDistributedToAccount; 116 | 117 | return pendingRewards; 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /contracts/TradingRewardsDistributor.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.0; 3 | 4 | import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; 5 | import {Pausable} from "@openzeppelin/contracts/security/Pausable.sol"; 6 | import {ReentrancyGuard} from "@openzeppelin/contracts/security/ReentrancyGuard.sol"; 7 | import {MerkleProof} from "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol"; 8 | import {IERC20, SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; 9 | 10 | /** 11 | * @title TradingRewardsDistributor 12 | * @notice It distributes LOOKS tokens with rolling Merkle airdrops. 13 | */ 14 | contract TradingRewardsDistributor is Pausable, ReentrancyGuard, Ownable { 15 | using SafeERC20 for IERC20; 16 | 17 | uint256 public constant BUFFER_ADMIN_WITHDRAW = 3 days; 18 | 19 | IERC20 public immutable looksRareToken; 20 | 21 | // Current reward round (users can only claim pending rewards for the current round) 22 | uint256 public currentRewardRound; 23 | 24 | // Last paused timestamp 25 | uint256 public lastPausedTimestamp; 26 | 27 | // Max amount per user in current tree 28 | uint256 public maximumAmountPerUserInCurrentTree; 29 | 30 | // Total amount claimed by user (in LOOKS) 31 | mapping(address => uint256) public amountClaimedByUser; 32 | 33 | // Merkle root for a reward round 34 | mapping(uint256 => bytes32) public merkleRootOfRewardRound; 35 | 36 | // Checks whether a merkle root was used 37 | mapping(bytes32 => bool) public merkleRootUsed; 38 | 39 | // Keeps track on whether user has claimed at a given reward round 40 | mapping(uint256 => mapping(address => bool)) public hasUserClaimedForRewardRound; 41 | 42 | event RewardsClaim(address indexed user, uint256 indexed rewardRound, uint256 amount); 43 | event UpdateTradingRewards(uint256 indexed rewardRound); 44 | event TokenWithdrawnOwner(uint256 amount); 45 | 46 | /** 47 | * @notice Constructor 48 | * @param _looksRareToken address of the LooksRare token 49 | */ 50 | constructor(address _looksRareToken) { 51 | looksRareToken = IERC20(_looksRareToken); 52 | _pause(); 53 | } 54 | 55 | /** 56 | * @notice Claim pending rewards 57 | * @param amount amount to claim 58 | * @param merkleProof array containing the merkle proof 59 | */ 60 | function claim(uint256 amount, bytes32[] calldata merkleProof) external whenNotPaused nonReentrant { 61 | // Verify the reward round is not claimed already 62 | require(!hasUserClaimedForRewardRound[currentRewardRound][msg.sender], "Rewards: Already claimed"); 63 | 64 | (bool claimStatus, uint256 adjustedAmount) = _canClaim(msg.sender, amount, merkleProof); 65 | 66 | require(claimStatus, "Rewards: Invalid proof"); 67 | require(maximumAmountPerUserInCurrentTree >= amount, "Rewards: Amount higher than max"); 68 | 69 | // Set mapping for user and round as true 70 | hasUserClaimedForRewardRound[currentRewardRound][msg.sender] = true; 71 | 72 | // Adjust amount claimed 73 | amountClaimedByUser[msg.sender] += adjustedAmount; 74 | 75 | // Transfer adjusted amount 76 | looksRareToken.safeTransfer(msg.sender, adjustedAmount); 77 | 78 | emit RewardsClaim(msg.sender, currentRewardRound, adjustedAmount); 79 | } 80 | 81 | /** 82 | * @notice Update trading rewards with a new merkle root 83 | * @dev It automatically increments the currentRewardRound 84 | * @param merkleRoot root of the computed merkle tree 85 | */ 86 | function updateTradingRewards(bytes32 merkleRoot, uint256 newMaximumAmountPerUser) external onlyOwner { 87 | require(!merkleRootUsed[merkleRoot], "Owner: Merkle root already used"); 88 | 89 | currentRewardRound++; 90 | merkleRootOfRewardRound[currentRewardRound] = merkleRoot; 91 | merkleRootUsed[merkleRoot] = true; 92 | maximumAmountPerUserInCurrentTree = newMaximumAmountPerUser; 93 | 94 | emit UpdateTradingRewards(currentRewardRound); 95 | } 96 | 97 | /** 98 | * @notice Pause distribution 99 | */ 100 | function pauseDistribution() external onlyOwner whenNotPaused { 101 | lastPausedTimestamp = block.timestamp; 102 | _pause(); 103 | } 104 | 105 | /** 106 | * @notice Unpause distribution 107 | */ 108 | function unpauseDistribution() external onlyOwner whenPaused { 109 | _unpause(); 110 | } 111 | 112 | /** 113 | * @notice Transfer LOOKS tokens back to owner 114 | * @dev It is for emergency purposes 115 | * @param amount amount to withdraw 116 | */ 117 | function withdrawTokenRewards(uint256 amount) external onlyOwner whenPaused { 118 | require(block.timestamp > (lastPausedTimestamp + BUFFER_ADMIN_WITHDRAW), "Owner: Too early to withdraw"); 119 | looksRareToken.safeTransfer(msg.sender, amount); 120 | 121 | emit TokenWithdrawnOwner(amount); 122 | } 123 | 124 | /** 125 | * @notice Check whether it is possible to claim and how much based on previous distribution 126 | * @param user address of the user 127 | * @param amount amount to claim 128 | * @param merkleProof array with the merkle proof 129 | */ 130 | function canClaim( 131 | address user, 132 | uint256 amount, 133 | bytes32[] calldata merkleProof 134 | ) external view returns (bool, uint256) { 135 | return _canClaim(user, amount, merkleProof); 136 | } 137 | 138 | /** 139 | * @notice Check whether it is possible to claim and how much based on previous distribution 140 | * @param user address of the user 141 | * @param amount amount to claim 142 | * @param merkleProof array with the merkle proof 143 | */ 144 | function _canClaim( 145 | address user, 146 | uint256 amount, 147 | bytes32[] calldata merkleProof 148 | ) internal view returns (bool, uint256) { 149 | // Compute the node and verify the merkle proof 150 | bytes32 node = keccak256(abi.encodePacked(user, amount)); 151 | bool canUserClaim = MerkleProof.verify(merkleProof, merkleRootOfRewardRound[currentRewardRound], node); 152 | 153 | if ((!canUserClaim) || (hasUserClaimedForRewardRound[currentRewardRound][user])) { 154 | return (false, 0); 155 | } else { 156 | return (true, amount - amountClaimedByUser[user]); 157 | } 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /contracts/VestingContractWithFeeSharing.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.0; 3 | 4 | import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; 5 | import {ReentrancyGuard} from "@openzeppelin/contracts/security/ReentrancyGuard.sol"; 6 | import {IERC20, SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; 7 | 8 | /** 9 | * @title VestingContractWithFeeSharing 10 | * @notice It vests the LOOKS tokens to an owner over a linear schedule. 11 | * Other tokens can be withdrawn at any time. 12 | */ 13 | contract VestingContractWithFeeSharing is Ownable, ReentrancyGuard { 14 | using SafeERC20 for IERC20; 15 | 16 | IERC20 public immutable looksRareToken; 17 | 18 | // Number of unlock periods 19 | uint256 public immutable NUMBER_UNLOCK_PERIODS; 20 | 21 | // Standard amount unlocked at each unlock 22 | uint256 public immutable STANDARD_AMOUNT_UNLOCKED_AT_EACH_UNLOCK; 23 | 24 | // Start block for the linear vesting 25 | uint256 public immutable START_BLOCK; 26 | 27 | // Vesting period in blocks 28 | uint256 public immutable VESTING_BETWEEN_PERIODS_IN_BLOCKS; 29 | 30 | // Keeps track of maximum amount to withdraw for next unlock period 31 | uint256 public maxAmountToWithdrawForNextPeriod; 32 | 33 | // Next block number for unlock 34 | uint256 public nextBlockForUnlock; 35 | 36 | // Keep track of number of past unlocks 37 | uint256 public numberPastUnlocks; 38 | 39 | event OtherTokensWithdrawn(address indexed currency, uint256 amount); 40 | event TokensUnlocked(uint256 amount); 41 | 42 | /** 43 | * @notice Constructor 44 | * @param _vestingBetweenPeriodsInBlocks period length between each halving in blocks 45 | * @param _startBlock block number for start (must be same as TokenDistributor) 46 | * @param _numberUnlockPeriods number of unlock periods (e.g., 4) 47 | * @param _maxAmountToWithdraw maximum amount in LOOKS to withdraw per period 48 | * @param _looksRareToken address of the LOOKS token 49 | */ 50 | constructor( 51 | uint256 _vestingBetweenPeriodsInBlocks, 52 | uint256 _startBlock, 53 | uint256 _numberUnlockPeriods, 54 | uint256 _maxAmountToWithdraw, 55 | address _looksRareToken 56 | ) { 57 | VESTING_BETWEEN_PERIODS_IN_BLOCKS = _vestingBetweenPeriodsInBlocks; 58 | START_BLOCK = _startBlock; 59 | NUMBER_UNLOCK_PERIODS = _numberUnlockPeriods; 60 | STANDARD_AMOUNT_UNLOCKED_AT_EACH_UNLOCK = _maxAmountToWithdraw; 61 | 62 | maxAmountToWithdrawForNextPeriod = _maxAmountToWithdraw; 63 | 64 | nextBlockForUnlock = _startBlock + _vestingBetweenPeriodsInBlocks; 65 | looksRareToken = IERC20(_looksRareToken); 66 | } 67 | 68 | /** 69 | * @notice Unlock LOOKS tokens 70 | * @dev It includes protection for overstaking 71 | */ 72 | function unlockLooksRareToken() external nonReentrant onlyOwner { 73 | require( 74 | (numberPastUnlocks == NUMBER_UNLOCK_PERIODS) || (block.number >= nextBlockForUnlock), 75 | "Unlock: Too early" 76 | ); 77 | 78 | uint256 balanceToWithdraw = looksRareToken.balanceOf(address(this)); 79 | 80 | if (numberPastUnlocks < NUMBER_UNLOCK_PERIODS) { 81 | // Adjust next block for unlock 82 | nextBlockForUnlock += VESTING_BETWEEN_PERIODS_IN_BLOCKS; 83 | // Adjust number of past unlocks 84 | numberPastUnlocks++; 85 | 86 | if (balanceToWithdraw >= maxAmountToWithdrawForNextPeriod) { 87 | // Adjust balance to withdraw to match linear schedule 88 | balanceToWithdraw = maxAmountToWithdrawForNextPeriod; 89 | maxAmountToWithdrawForNextPeriod = STANDARD_AMOUNT_UNLOCKED_AT_EACH_UNLOCK; 90 | } else { 91 | // Adjust next period maximum based on the missing amount for this period 92 | maxAmountToWithdrawForNextPeriod = 93 | maxAmountToWithdrawForNextPeriod + 94 | (maxAmountToWithdrawForNextPeriod - balanceToWithdraw); 95 | } 96 | } 97 | 98 | // Transfer LOOKS to owner 99 | looksRareToken.safeTransfer(msg.sender, balanceToWithdraw); 100 | 101 | emit TokensUnlocked(balanceToWithdraw); 102 | } 103 | 104 | /** 105 | * @notice Withdraw any currency to the owner (e.g., WETH for fee sharing) 106 | * @param _currency address of the currency to withdraw 107 | */ 108 | function withdrawOtherCurrency(address _currency) external nonReentrant onlyOwner { 109 | require(_currency != address(looksRareToken), "Owner: Cannot withdraw LOOKS"); 110 | 111 | uint256 balanceToWithdraw = IERC20(_currency).balanceOf(address(this)); 112 | 113 | // Transfer token to owner if not null 114 | require(balanceToWithdraw != 0, "Owner: Nothing to withdraw"); 115 | IERC20(_currency).safeTransfer(msg.sender, balanceToWithdraw); 116 | 117 | emit OtherTokensWithdrawn(_currency, balanceToWithdraw); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /contracts/interfaces/IBlast.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.0; 3 | 4 | enum YieldMode { 5 | AUTOMATIC, 6 | VOID, 7 | CLAIMABLE 8 | } 9 | 10 | enum GasMode { 11 | VOID, 12 | CLAIMABLE 13 | } 14 | 15 | interface IBlast { 16 | // configure 17 | function configureContract( 18 | address contractAddress, 19 | YieldMode _yield, 20 | GasMode gasMode, 21 | address governor 22 | ) external; 23 | 24 | function configure( 25 | YieldMode _yield, 26 | GasMode gasMode, 27 | address governor 28 | ) external; 29 | 30 | // base configuration options 31 | function configureClaimableYield() external; 32 | 33 | function configureClaimableYieldOnBehalf(address contractAddress) external; 34 | 35 | function configureAutomaticYield() external; 36 | 37 | function configureAutomaticYieldOnBehalf(address contractAddress) external; 38 | 39 | function configureVoidYield() external; 40 | 41 | function configureVoidYieldOnBehalf(address contractAddress) external; 42 | 43 | function configureClaimableGas() external; 44 | 45 | function configureClaimableGasOnBehalf(address contractAddress) external; 46 | 47 | function configureVoidGas() external; 48 | 49 | function configureVoidGasOnBehalf(address contractAddress) external; 50 | 51 | function configureGovernor(address _governor) external; 52 | 53 | function configureGovernorOnBehalf(address _newGovernor, address contractAddress) external; 54 | 55 | // claim yield 56 | function claimYield( 57 | address contractAddress, 58 | address recipientOfYield, 59 | uint256 amount 60 | ) external returns (uint256); 61 | 62 | function claimAllYield(address contractAddress, address recipientOfYield) external returns (uint256); 63 | 64 | // claim gas 65 | function claimAllGas(address contractAddress, address recipientOfGas) external returns (uint256); 66 | 67 | function claimGasAtMinClaimRate( 68 | address contractAddress, 69 | address recipientOfGas, 70 | uint256 minClaimRateBips 71 | ) external returns (uint256); 72 | 73 | function claimMaxGas(address contractAddress, address recipientOfGas) external returns (uint256); 74 | 75 | function claimGas( 76 | address contractAddress, 77 | address recipientOfGas, 78 | uint256 gasToClaim, 79 | uint256 gasSecondsToConsume 80 | ) external returns (uint256); 81 | 82 | // read functions 83 | function readClaimableYield(address contractAddress) external view returns (uint256); 84 | 85 | function readYieldConfiguration(address contractAddress) external view returns (uint8); 86 | 87 | function readGasParams(address contractAddress) 88 | external 89 | view 90 | returns ( 91 | uint256 etherSeconds, 92 | uint256 etherBalance, 93 | uint256 lastUpdated, 94 | GasMode 95 | ); 96 | } 97 | -------------------------------------------------------------------------------- /contracts/interfaces/IBlastPoints.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.0; 3 | 4 | interface IBlastPoints { 5 | function configurePointsOperator(address operator) external; 6 | } 7 | -------------------------------------------------------------------------------- /contracts/interfaces/ILooksRareToken.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.0; 3 | 4 | import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; 5 | 6 | interface ILooksRareToken is IERC20 { 7 | function SUPPLY_CAP() external view returns (uint256); 8 | 9 | function mint(address account, uint256 amount) external returns (bool); 10 | } 11 | -------------------------------------------------------------------------------- /contracts/interfaces/IRewardConvertor.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.0; 3 | 4 | interface IRewardConvertor { 5 | function convert( 6 | address tokenToSell, 7 | address tokenToBuy, 8 | uint256 amount, 9 | bytes calldata additionalData 10 | ) external returns (uint256); 11 | } 12 | -------------------------------------------------------------------------------- /contracts/test/ICheatCodes.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.0; 3 | 4 | interface ICheatCodes { 5 | // Set block.timestamp (newTimestamp) 6 | function warp(uint256) external; 7 | 8 | // Set block.height (newHeight) 9 | function roll(uint256) external; 10 | 11 | // Set block.basefee (newBasefee) 12 | function fee(uint256) external; 13 | 14 | // Loads a storage slot from an address (who, slot) 15 | function load(address, bytes32) external returns (bytes32); 16 | 17 | // Stores a value to an address' storage slot, (who, slot, value) 18 | function store( 19 | address, 20 | bytes32, 21 | bytes32 22 | ) external; 23 | 24 | // Signs data, (privateKey, digest) => (v, r, s) 25 | function sign(uint256, bytes32) 26 | external 27 | returns ( 28 | uint8, 29 | bytes32, 30 | bytes32 31 | ); 32 | 33 | // Gets address for a given private key, (privateKey) => (address) 34 | function addr(uint256) external returns (address); 35 | 36 | // Performs a foreign function call via terminal, (stringInputs) => (result) 37 | function ffi(string[] calldata) external returns (bytes memory); 38 | 39 | // Sets the *next* call's msg.sender to be the input address 40 | function prank(address) external; 41 | 42 | // Sets all subsequent calls' msg.sender to be the input address until `stopPrank` is called 43 | function startPrank(address) external; 44 | 45 | // Sets the *next* call's msg.sender to be the input address, and the tx.origin to be the second input 46 | function prank(address, address) external; 47 | 48 | // Sets all subsequent calls' msg.sender to be the input address until `stopPrank` is called, and the tx.origin to be the second input 49 | function startPrank(address, address) external; 50 | 51 | // Resets subsequent calls' msg.sender to be `address(this)` 52 | function stopPrank() external; 53 | 54 | // Sets an address' balance, (who, newBalance) 55 | function deal(address, uint256) external; 56 | 57 | // Sets an address' code, (who, newCode) 58 | function etch(address, bytes calldata) external; 59 | 60 | // Expects an error on next call 61 | function expectRevert(bytes calldata) external; 62 | 63 | function expectRevert(bytes4) external; 64 | 65 | // Record all storage reads and writes 66 | function record() external; 67 | 68 | // Gets all accessed reads and write slot from a recording session, for a given address 69 | function accesses(address) external returns (bytes32[] memory reads, bytes32[] memory writes); 70 | 71 | // Prepare an expected log with (bool checkTopic1, bool checkTopic2, bool checkTopic3, bool checkData). 72 | // Call this function, then emit an event, then call a function. Internally after the call, we check if 73 | // logs were emitted in the expected order with the expected topics and data (as specified by the booleans) 74 | function expectEmit( 75 | bool, 76 | bool, 77 | bool, 78 | bool 79 | ) external; 80 | 81 | // Mocks a call to an address, returning specified data. 82 | // Calldata can either be strict or a partial match, e.g. if you only 83 | // pass a Solidity selector to the expected calldata, then the entire Solidity 84 | // function will be mocked. 85 | function mockCall( 86 | address, 87 | bytes calldata, 88 | bytes calldata 89 | ) external; 90 | 91 | // Clears all mocked calls 92 | function clearMockedCalls() external; 93 | 94 | // Expect a call to an address with the specified calldata. 95 | // Calldata can either be strict or a partial match 96 | function expectCall(address, bytes calldata) external; 97 | 98 | // Fetches the contract bytecode from its artifact file 99 | function getCode(string calldata) external returns (bytes memory); 100 | 101 | // Label an address in test traces 102 | function label(address addr, string calldata label) external; 103 | 104 | // When fuzzing, generate new inputs if conditional not met 105 | function assume(bool) external; 106 | } 107 | -------------------------------------------------------------------------------- /contracts/test/TestHelpers.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.0; 3 | 4 | import {ICheatCodes} from "./ICheatCodes.sol"; 5 | import {DSTest} from "../../lib/ds-test/src/test.sol"; 6 | 7 | abstract contract TestHelpers is DSTest { 8 | ICheatCodes public cheats = ICheatCodes(0x7109709ECfa91a80626fF3989D68f67F5b1DD12D); 9 | 10 | address public user1 = address(1); 11 | address public user2 = address(2); 12 | address public user3 = address(3); 13 | address public user4 = address(4); 14 | address public user5 = address(5); 15 | address public user6 = address(6); 16 | address public user7 = address(7); 17 | address public user8 = address(8); 18 | address public user9 = address(9); 19 | 20 | modifier asPrankedUser(address _user) { 21 | cheats.startPrank(_user); 22 | _; 23 | cheats.stopPrank(); 24 | } 25 | 26 | function assertQuasiEq(uint256 a, uint256 b) public { 27 | require(a >= 1e18 || b >= 1e18, "Error: a & b must be > 1e18"); 28 | 29 | // 0.000001 % precision tolerance 30 | uint256 PRECISION_LOSS = 1e9; 31 | 32 | if (a == b) { 33 | assertEq(a, b); 34 | } else if (a > b) { 35 | assertGt(a, b); 36 | assertLt(a - PRECISION_LOSS, b); 37 | } else if (a < b) { 38 | assertGt(a, b - PRECISION_LOSS); 39 | assertLt(a, b); 40 | } 41 | } 42 | 43 | function _parseEther(uint256 value) internal pure returns (uint256) { 44 | return value * 1e18; 45 | } 46 | 47 | function _parseEtherWithFloating(uint256 value, uint8 floatingDigits) internal pure returns (uint256) { 48 | assert(floatingDigits <= 18); 49 | return value * (10**(18 - floatingDigits)); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /contracts/test/utils/MockERC20.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.0; 3 | 4 | import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; 5 | 6 | contract MockERC20 is ERC20 { 7 | constructor(string memory _name, string memory _symbol) ERC20(_name, _symbol) { 8 | // 9 | } 10 | 11 | function mint(address to, uint256 amount) external { 12 | _mint(to, amount); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /contracts/test/utils/MockFaultyUniswapV3Router.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.0; 3 | 4 | import {IERC20, SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; 5 | 6 | import {ISwapRouter} from "../../uniswap-interfaces/ISwapRouter.sol"; 7 | 8 | contract MockFaultyUniswapV3Router is ISwapRouter { 9 | using SafeERC20 for IERC20; 10 | 11 | address public immutable DEPLOYER; 12 | 13 | constructor() { 14 | // Useless logic not to use an abstract contract 15 | DEPLOYER = msg.sender; 16 | } 17 | 18 | function exactInputSingle(ExactInputSingleParams calldata) external payable override returns (uint256) { 19 | revert(); 20 | } 21 | 22 | function exactInput(ExactInputParams calldata) external payable override returns (uint256 amountOut) { 23 | return 0; 24 | } 25 | 26 | function exactOutputSingle(ExactOutputSingleParams calldata) external payable override returns (uint256 amountIn) { 27 | return 0; 28 | } 29 | 30 | function exactOutput(ExactOutputParams calldata) external payable override returns (uint256 amountIn) { 31 | return 0; 32 | } 33 | 34 | function uniswapV3SwapCallback( 35 | int256, 36 | int256, 37 | bytes calldata 38 | ) external pure override { 39 | return; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /contracts/test/utils/MockRewardConvertor.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.0; 3 | 4 | import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; 5 | 6 | import "../../interfaces/IRewardConvertor.sol"; 7 | 8 | contract MockRewardConvertor is IRewardConvertor { 9 | address public immutable FEE_SHARING_ADDRESS; 10 | 11 | constructor(address _feeSharingAddress) { 12 | FEE_SHARING_ADDRESS = _feeSharingAddress; 13 | } 14 | 15 | function convert( 16 | address tokenToSell, 17 | address tokenToBuy, 18 | uint256 amount, 19 | bytes calldata 20 | ) external override returns (uint256) { 21 | require(msg.sender == FEE_SHARING_ADDRESS, "Convert: Not the fee sharing"); 22 | 23 | uint256 amountToTransfer = IERC20(tokenToBuy).balanceOf(address(this)); 24 | 25 | // Transfer from 26 | IERC20(tokenToSell).transferFrom(msg.sender, address(this), amount); 27 | 28 | // Transfer to 29 | IERC20(tokenToBuy).transfer(FEE_SHARING_ADDRESS, amountToTransfer); 30 | 31 | return amountToTransfer; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /contracts/test/utils/MockUniswapV3Router.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.0; 3 | 4 | import {IERC20, SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; 5 | 6 | import {ISwapRouter} from "../../uniswap-interfaces/ISwapRouter.sol"; 7 | 8 | contract MockUniswapV3Router is ISwapRouter { 9 | using SafeERC20 for IERC20; 10 | 11 | uint256 public constant PRECISION_MULTIPLIER = 10000; 12 | 13 | address public immutable DEPLOYER; 14 | 15 | uint256 public multiplier; 16 | 17 | event SlippageError(); 18 | 19 | constructor() { 20 | // Useless logic not to use an abstract contract 21 | DEPLOYER = msg.sender; 22 | } 23 | 24 | function setMultiplier(uint256 _multiplier) external { 25 | multiplier = _multiplier; 26 | } 27 | 28 | function exactInputSingle(ExactInputSingleParams calldata params) 29 | external 30 | payable 31 | override 32 | returns (uint256 amountOut) 33 | { 34 | amountOut = (params.amountIn * multiplier) / PRECISION_MULTIPLIER; 35 | 36 | if (amountOut < params.amountOutMinimum) { 37 | emit SlippageError(); 38 | amountOut = 0; 39 | } else { 40 | IERC20(params.tokenIn).safeTransferFrom(msg.sender, address(this), params.amountIn); 41 | IERC20(params.tokenOut).transfer(msg.sender, amountOut); 42 | } 43 | 44 | return amountOut; 45 | } 46 | 47 | function exactInput(ExactInputParams calldata) external payable override returns (uint256 amountOut) { 48 | return 0; 49 | } 50 | 51 | function exactOutputSingle(ExactOutputSingleParams calldata) external payable override returns (uint256 amountIn) { 52 | return 0; 53 | } 54 | 55 | function exactOutput(ExactOutputParams calldata) external payable override returns (uint256 amountIn) { 56 | return 0; 57 | } 58 | 59 | function uniswapV3SwapCallback( 60 | int256, 61 | int256, 62 | bytes calldata 63 | ) external pure override { 64 | return; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /contracts/uniswap-interfaces/ISwapRouter.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-2.0-or-later 2 | pragma solidity >=0.7.5; 3 | pragma abicoder v2; 4 | 5 | import "./IUniswapV3SwapCallback.sol"; 6 | 7 | /// @title Router token swapping functionality 8 | /// @notice Functions for swapping tokens via Uniswap V3 9 | interface ISwapRouter is IUniswapV3SwapCallback { 10 | struct ExactInputSingleParams { 11 | address tokenIn; 12 | address tokenOut; 13 | uint24 fee; 14 | address recipient; 15 | uint256 deadline; 16 | uint256 amountIn; 17 | uint256 amountOutMinimum; 18 | uint160 sqrtPriceLimitX96; 19 | } 20 | 21 | /// @notice Swaps `amountIn` of one token for as much as possible of another token 22 | /// @param params The parameters necessary for the swap, encoded as `ExactInputSingleParams` in calldata 23 | /// @return amountOut The amount of the received token 24 | function exactInputSingle(ExactInputSingleParams calldata params) external payable returns (uint256 amountOut); 25 | 26 | struct ExactInputParams { 27 | bytes path; 28 | address recipient; 29 | uint256 deadline; 30 | uint256 amountIn; 31 | uint256 amountOutMinimum; 32 | } 33 | 34 | /// @notice Swaps `amountIn` of one token for as much as possible of another along the specified path 35 | /// @param params The parameters necessary for the multi-hop swap, encoded as `ExactInputParams` in calldata 36 | /// @return amountOut The amount of the received token 37 | function exactInput(ExactInputParams calldata params) external payable returns (uint256 amountOut); 38 | 39 | struct ExactOutputSingleParams { 40 | address tokenIn; 41 | address tokenOut; 42 | uint24 fee; 43 | address recipient; 44 | uint256 deadline; 45 | uint256 amountOut; 46 | uint256 amountInMaximum; 47 | uint160 sqrtPriceLimitX96; 48 | } 49 | 50 | /// @notice Swaps as little as possible of one token for `amountOut` of another token 51 | /// @param params The parameters necessary for the swap, encoded as `ExactOutputSingleParams` in calldata 52 | /// @return amountIn The amount of the input token 53 | function exactOutputSingle(ExactOutputSingleParams calldata params) external payable returns (uint256 amountIn); 54 | 55 | struct ExactOutputParams { 56 | bytes path; 57 | address recipient; 58 | uint256 deadline; 59 | uint256 amountOut; 60 | uint256 amountInMaximum; 61 | } 62 | 63 | /// @notice Swaps as little as possible of one token for `amountOut` of another along the specified path (reversed) 64 | /// @param params The parameters necessary for the multi-hop swap, encoded as `ExactOutputParams` in calldata 65 | /// @return amountIn The amount of the input token 66 | function exactOutput(ExactOutputParams calldata params) external payable returns (uint256 amountIn); 67 | } 68 | -------------------------------------------------------------------------------- /contracts/uniswap-interfaces/IUniswapV3SwapCallback.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-2.0-or-later 2 | pragma solidity >=0.5.0; 3 | 4 | /// @title Callback for IUniswapV3PoolActions#swap 5 | /// @notice Any contract that calls IUniswapV3PoolActions#swap must implement this interface 6 | interface IUniswapV3SwapCallback { 7 | /// @notice Called to `msg.sender` after executing a swap via IUniswapV3Pool#swap. 8 | /// @dev In the implementation you must pay the pool tokens owed for the swap. 9 | /// The caller of this method must be checked to be a UniswapV3Pool deployed by the canonical UniswapV3Factory. 10 | /// amount0Delta and amount1Delta can both be 0 if no tokens were swapped. 11 | /// @param amount0Delta The amount of token0 that was sent (negative) or must be received (positive) by the pool by 12 | /// the end of the swap. If positive, the callback must send that amount of token0 to the pool. 13 | /// @param amount1Delta The amount of token1 that was sent (negative) or must be received (positive) by the pool by 14 | /// the end of the swap. If positive, the callback must send that amount of token1 to the pool. 15 | /// @param data Any data passed through by the caller via the IUniswapV3PoolActions#swap call 16 | function uniswapV3SwapCallback( 17 | int256 amount0Delta, 18 | int256 amount1Delta, 19 | bytes calldata data 20 | ) external; 21 | } 22 | -------------------------------------------------------------------------------- /foundry.toml: -------------------------------------------------------------------------------- 1 | [profile.default] 2 | auto_detect_solc = true 3 | block_base_fee_per_gas = 0 4 | block_coinbase = '0x0000000000000000000000000000000000000000' 5 | block_difficulty = 0 6 | block_number = 0 7 | block_timestamp = 0 8 | cache = true 9 | cache_path = 'cache' 10 | evm_version = 'london' 11 | extra_output = [] 12 | extra_output_files = [] 13 | ffi = false 14 | force = false 15 | fuzz_max_global_rejects = 65536 16 | fuzz_max_local_rejects = 1024 17 | fuzz_runs = 1000 18 | gas_limit = 9223372036854775807 19 | gas_price = 0 20 | gas_reports = ['*'] 21 | ignored_error_codes = [1878] 22 | initial_balance = '0xffffffffffffffffffffffff' 23 | libraries = [] 24 | libs = ["node_modules", "lib"] 25 | names = false 26 | offline = false 27 | optimizer = true 28 | optimizer_runs = 888888 29 | out = 'artifacts' 30 | sender = '0x00a329c0648769a73afac7f9381e08fb43dbea72' 31 | sizes = false 32 | src = 'contracts' 33 | test = 'test' 34 | tx_origin = '0x00a329c0648769a73afac7f9381e08fb43dbea72' 35 | verbosity = 0 36 | via_ir = false 37 | -------------------------------------------------------------------------------- /hardhat.config.ts: -------------------------------------------------------------------------------- 1 | import type { HardhatUserConfig } from "hardhat/types"; 2 | import { task } from "hardhat/config"; 3 | 4 | import "@nomiclabs/hardhat-etherscan"; 5 | import "@nomiclabs/hardhat-waffle"; 6 | import "@typechain/hardhat"; 7 | import "hardhat-abi-exporter"; 8 | import "hardhat-gas-reporter"; 9 | import "solidity-coverage"; 10 | import "dotenv/config"; 11 | 12 | task("accounts", "Prints the list of accounts", async (_args, hre) => { 13 | const accounts = await hre.ethers.getSigners(); 14 | accounts.forEach(async (account) => console.info(account.address)); 15 | }); 16 | 17 | const config: HardhatUserConfig = { 18 | defaultNetwork: "hardhat", 19 | networks: { 20 | hardhat: { 21 | allowUnlimitedContractSize: false, 22 | hardfork: "berlin", // Berlin is used (temporarily) to avoid issues with coverage 23 | mining: { 24 | auto: true, 25 | interval: 50000, 26 | }, 27 | gasPrice: "auto", 28 | }, 29 | }, 30 | etherscan: { 31 | apiKey: process.env.ETHERSCAN_KEY, 32 | }, 33 | solidity: { 34 | compilers: [ 35 | { 36 | version: "0.8.23", 37 | settings: { optimizer: { enabled: true, runs: 888888 } }, 38 | }, 39 | { 40 | version: "0.8.19", 41 | settings: { optimizer: { enabled: true, runs: 888888 } }, 42 | }, 43 | { 44 | version: "0.8.7", 45 | settings: { optimizer: { enabled: true, runs: 888888 } }, 46 | }, 47 | { 48 | version: "0.4.18", 49 | settings: { optimizer: { enabled: true, runs: 999 } }, 50 | }, 51 | ], 52 | }, 53 | paths: { 54 | sources: "./contracts/", 55 | tests: "./test", 56 | cache: "./cache", 57 | artifacts: "./artifacts", 58 | }, 59 | abiExporter: { 60 | path: "./abis", 61 | runOnCompile: true, 62 | clear: true, 63 | flat: true, 64 | pretty: false, 65 | except: ["test*", "@openzeppelin*", "uniswap*"], 66 | }, 67 | gasReporter: { 68 | enabled: !!process.env.REPORT_GAS, 69 | excludeContracts: ["test*", "@openzeppelin*", "uniswap*"], 70 | }, 71 | }; 72 | 73 | export default config; 74 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@looksrare/contracts-token-staking", 3 | "version": "1.1.1", 4 | "description": "LooksRare staking and other token-related smart contracts", 5 | "author": "LooksRare", 6 | "license": "MIT", 7 | "private": false, 8 | "files": [ 9 | "/abis/*.json", 10 | "/contracts/*.sol", 11 | "/contracts/interfaces/*.sol", 12 | "/contracts/uniswap-interfaces/*.sol" 13 | ], 14 | "keywords": [ 15 | "looksrare" 16 | ], 17 | "homepage": "https://looksrare.org/", 18 | "bugs": "https://github.com/LooksRare/contracts-token-staking/issues", 19 | "repository": { 20 | "type": "git", 21 | "url": "https://github.com/LooksRare/contracts-token-staking.git" 22 | }, 23 | "publishConfig": { 24 | "access": "public", 25 | "registry": "https://registry.npmjs.org" 26 | }, 27 | "scripts": { 28 | "compile": "hardhat compile", 29 | "compile:force": "hardhat compile --force", 30 | "format:check": "prettier --check '**/*.{js,jsx,ts,tsx,sol,json,yaml,md}'", 31 | "format:write": "prettier --write '**/*.{js,jsx,ts,tsx,sol,json,yaml,md}'", 32 | "lint": "eslint '**/*.{js,jsx,ts,tsx}'", 33 | "prepare": "husky install", 34 | "test": "hardhat test", 35 | "test:gas": "REPORT_GAS=true hardhat test", 36 | "test:forge": "forge test", 37 | "test:coverage": "hardhat coverage && hardhat compile --force", 38 | "release": "release-it" 39 | }, 40 | "dependencies": { 41 | "@looksrare/contracts-libs": "^3.1.0", 42 | "@openzeppelin/contracts": "4.4.2" 43 | }, 44 | "devDependencies": { 45 | "@commitlint/cli": "^16.2.3", 46 | "@commitlint/config-conventional": "^16.2.1", 47 | "@nomiclabs/hardhat-ethers": "^2.0.5", 48 | "@nomiclabs/hardhat-etherscan": "^3.0.3", 49 | "@nomiclabs/hardhat-waffle": "^2.0.3", 50 | "@openzeppelin/merkle-tree": "^1.0.6", 51 | "@typechain/ethers-v5": "^7.0.1", 52 | "@typechain/hardhat": "^2.3.0", 53 | "@types/chai": "^4.2.21", 54 | "@types/mocha": "^9.0.0", 55 | "@types/node": "^12.0.0", 56 | "@typescript-eslint/eslint-plugin": "^4.29.1", 57 | "@typescript-eslint/parser": "^4.29.1", 58 | "chai": "^4.2.0", 59 | "dotenv": "^10.0.0", 60 | "eslint": "^7.29.0", 61 | "eslint-config-prettier": "^8.3.0", 62 | "eslint-config-standard": "^16.0.3", 63 | "eslint-plugin-import": "^2.23.4", 64 | "eslint-plugin-node": "^11.1.0", 65 | "eslint-plugin-prettier": "^3.4.0", 66 | "eslint-plugin-promise": "^5.1.0", 67 | "ethereum-waffle": "^3.4.4", 68 | "ethers": "^5.6.4", 69 | "hardhat": "^2.9.3", 70 | "hardhat-abi-exporter": "^2.8.0", 71 | "hardhat-gas-reporter": "^1.0.8", 72 | "husky": "^7.0.4", 73 | "merkletreejs": "^0.2.31", 74 | "prettier": "^2.3.2", 75 | "prettier-plugin-solidity": "^1.0.0-beta.13", 76 | "release-it": "^14.14.2", 77 | "solhint": "^3.3.7", 78 | "solidity-coverage": "^0.7.21", 79 | "ts-node": "^10.1.0", 80 | "typechain": "^5.1.2", 81 | "typescript": "^4.5.2" 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /remappings.txt: -------------------------------------------------------------------------------- 1 | @openzeppelin/=node_modules/@openzeppelin/ 2 | hardhat/=node_modules/hardhat/ -------------------------------------------------------------------------------- /scripts/SeasonRewardsDistributorUpdateRewards.s.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity 0.8.19; 3 | 4 | // Scripting tool 5 | import {Script} from "../lib/forge-std/src/Script.sol"; 6 | 7 | // Core contracts 8 | import {SeasonRewardsDistributor} from "../contracts/SeasonRewardsDistributor.sol"; 9 | 10 | contract SeasonRewardsDistributorUpdateRewards is Script { 11 | error ChainIdInvalid(uint256 chainId); 12 | 13 | address public looksRareToken; 14 | address private owner; 15 | 16 | function run() external { 17 | uint256 chainId = block.chainid; 18 | uint256 ownerPrivateKey; 19 | 20 | if (chainId == 1) { 21 | ownerPrivateKey = vm.envUint("MAINNET_KEY"); 22 | } else if (chainId == 5) { 23 | ownerPrivateKey = vm.envUint("TESTNET_KEY"); 24 | } else { 25 | revert ChainIdInvalid(chainId); 26 | } 27 | 28 | vm.startBroadcast(ownerPrivateKey); 29 | 30 | SeasonRewardsDistributor distributor = SeasonRewardsDistributor(0x5C073CeCaFC56EE9f4335230A09933965C8ed472); 31 | distributor.initiateOwnershipTransfer(0xBfb6669Ef4C4c71ae6E722526B1B8d7d9ff9a019); 32 | // distributor.updateSeasonRewards(hex"164971bfe1b8d8c576321511317e6c25e2de27fac02001a5c1df7e6344d33652", 1e18); 33 | 34 | vm.stopBroadcast(); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /scripts/deployment/SeasonRewardsDistributorDeployment.s.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity 0.8.19; 3 | 4 | // Scripting tool 5 | import {Script} from "../../lib/forge-std/src/Script.sol"; 6 | 7 | // Core contracts 8 | import {SeasonRewardsDistributor} from "../../contracts/SeasonRewardsDistributor.sol"; 9 | 10 | contract SeasonRewardsDistributorDeployment is Script { 11 | error ChainIdInvalid(uint256 chainId); 12 | 13 | address public looksRareToken; 14 | address private owner; 15 | 16 | function run() external { 17 | uint256 chainId = block.chainid; 18 | uint256 deployerPrivateKey; 19 | 20 | if (chainId == 1) { 21 | looksRareToken = 0xf4d2888d29D722226FafA5d9B24F9164c092421E; 22 | owner = 0xBfb6669Ef4C4c71ae6E722526B1B8d7d9ff9a019; 23 | deployerPrivateKey = vm.envUint("MAINNET_KEY"); 24 | } else if (chainId == 5) { 25 | looksRareToken = 0x20A5A36ded0E4101C3688CBC405bBAAE58fE9eeC; 26 | owner = 0xF332533bF5d0aC462DC8511067A8122b4DcE2B57; 27 | deployerPrivateKey = vm.envUint("TESTNET_KEY"); 28 | } else if (chainId == 11155111) { 29 | looksRareToken = 0xa68c2CaA3D45fa6EBB95aA706c70f49D3356824E; 30 | owner = 0xF332533bF5d0aC462DC8511067A8122b4DcE2B57; 31 | deployerPrivateKey = vm.envUint("TESTNET_KEY"); 32 | } else { 33 | revert ChainIdInvalid(chainId); 34 | } 35 | 36 | vm.startBroadcast(deployerPrivateKey); 37 | 38 | new SeasonRewardsDistributor(looksRareToken, owner); 39 | 40 | vm.stopBroadcast(); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /test/helpers/block-traveller.ts: -------------------------------------------------------------------------------- 1 | import { BigNumber } from "ethers"; 2 | import { ethers, network } from "hardhat"; 3 | 4 | /** 5 | * Advance the state by one block 6 | */ 7 | export async function advanceBlock(): Promise { 8 | await network.provider.send("evm_mine"); 9 | } 10 | 11 | /** 12 | * Advance the block to the passed target block 13 | * @param targetBlock target block number 14 | * @dev If target block is lower/equal to current block, it throws an error 15 | */ 16 | export async function advanceBlockTo(targetBlock: BigNumber): Promise { 17 | const currentBlock = await ethers.provider.getBlockNumber(); 18 | if (targetBlock.lt(currentBlock)) { 19 | throw Error(`Target·block·#(${targetBlock})·is·lower·than·current·block·#(${currentBlock})`); 20 | } 21 | 22 | let numberBlocks = targetBlock.sub(currentBlock); 23 | 24 | // hardhat_mine only can move by 256 blocks (256 in hex is 0x100) 25 | while (numberBlocks.gte(BigNumber.from("256"))) { 26 | await network.provider.send("hardhat_mine", ["0x100"]); 27 | numberBlocks = numberBlocks.sub(BigNumber.from("256")); 28 | } 29 | 30 | if (numberBlocks.eq("1")) { 31 | await network.provider.send("evm_mine"); 32 | } else if (numberBlocks.eq("15")) { 33 | // Issue with conversion from hexString of 15 (0x0f instead of 0xF) 34 | await network.provider.send("hardhat_mine", ["0xF"]); 35 | } else { 36 | await network.provider.send("hardhat_mine", [numberBlocks.toHexString()]); 37 | } 38 | } 39 | 40 | /** 41 | * Advance the block time to target time 42 | * @param targetTime target time (epoch) 43 | * @dev If target time is lower/equal to current time, it throws an error 44 | */ 45 | export async function increaseTo(targetTime: BigNumber): Promise { 46 | const currentTime = BigNumber.from(await latest()); 47 | if (targetTime.lt(currentTime)) { 48 | throw Error(`Target·time·(${targetTime})·is·lower·than·current·time·#(${currentTime})`); 49 | } 50 | 51 | await network.provider.send("evm_setNextBlockTimestamp", [targetTime.toHexString()]); 52 | } 53 | 54 | /** 55 | * Fetch the current block number 56 | */ 57 | export async function latest(): Promise { 58 | return (await ethers.provider.getBlock(await ethers.provider.getBlockNumber())).timestamp; 59 | } 60 | 61 | /** 62 | * Start automine 63 | */ 64 | export async function pauseAutomine(): Promise { 65 | await network.provider.send("evm_setAutomine", [false]); 66 | } 67 | 68 | /** 69 | * Resume automine 70 | */ 71 | export async function resumeAutomine(): Promise { 72 | await network.provider.send("evm_setAutomine", [true]); 73 | } 74 | -------------------------------------------------------------------------------- /test/helpers/cryptography.ts: -------------------------------------------------------------------------------- 1 | import { MerkleTree } from "merkletreejs"; 2 | import { utils } from "ethers/lib/ethers"; 3 | 4 | const { keccak256, solidityKeccak256 } = utils; 5 | 6 | /** 7 | * Compute the cryptographic hash using keccak256 8 | * @param user address of the user 9 | * @param amount amount for a user 10 | * @dev Do not forget to multiply by 10e18 for decimals 11 | */ 12 | export function computeHash(user: string, amount: string): Buffer { 13 | return Buffer.from(solidityKeccak256(["address", "uint256"], [user, amount]).slice(2), "hex"); 14 | } 15 | 16 | /** 17 | * Compute a merkle tree and return the tree with its root 18 | * @param tree merkle tree 19 | * @returns 2-tuple with merkle tree object and hexRoot 20 | */ 21 | export function createMerkleTree(tree: Record): [MerkleTree, string] { 22 | const merkleTree = new MerkleTree( 23 | Object.entries(tree).map((data) => computeHash(...data)), 24 | keccak256, 25 | { sortPairs: true } 26 | ); 27 | 28 | const hexRoot = merkleTree.getHexRoot(); 29 | return [merkleTree, hexRoot]; 30 | } 31 | 32 | /** 33 | * Compute the cryptographic hash using keccak256 34 | * @param user address of the user 35 | * @param amount amount for a user 36 | * @dev Do not forget to multiply by 10e18 for decimals 37 | */ 38 | export function computeDoubleHash(user: string, amount: string): Buffer { 39 | return Buffer.from(keccak256(computeHash(user, amount)).slice(2), "hex"); 40 | } 41 | 42 | /** 43 | * Compute a merkle tree and return the tree with its root 44 | * @param tree merkle tree 45 | * @returns 2-tuple with merkle tree object and hexRoot 46 | */ 47 | export function createDoubleHashMerkleTree(tree: Record): [MerkleTree, string] { 48 | const merkleTree = new MerkleTree( 49 | Object.entries(tree).map((data) => computeDoubleHash(...data)), 50 | keccak256, 51 | { sortPairs: true } 52 | ); 53 | 54 | const hexRoot = merkleTree.getHexRoot(); 55 | return [merkleTree, hexRoot]; 56 | } 57 | -------------------------------------------------------------------------------- /test/looksRareToken.test.ts: -------------------------------------------------------------------------------- 1 | import { assert, expect } from "chai"; 2 | import { BigNumber, constants, Contract, utils } from "ethers"; 3 | import { ethers } from "hardhat"; 4 | import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; 5 | 6 | const { parseEther } = utils; 7 | 8 | describe("LooksRareToken", () => { 9 | let accounts: SignerWithAddress[]; 10 | let admin: SignerWithAddress; 11 | let looksRareToken: Contract; 12 | let premintAmount: BigNumber; 13 | let cap: BigNumber; 14 | 15 | beforeEach(async () => { 16 | accounts = await ethers.getSigners(); 17 | admin = accounts[0]; 18 | premintAmount = parseEther("200000000"); // 20% 19 | cap = parseEther("1000000000"); 20 | 21 | const LooksRareToken = await ethers.getContractFactory("LooksRareToken"); 22 | looksRareToken = await LooksRareToken.deploy(admin.address, premintAmount, cap); 23 | await looksRareToken.deployed(); 24 | }); 25 | 26 | describe("#1 - Regular user/owner interactions", async () => { 27 | it("Post-deployment values are correct", async () => { 28 | assert.deepEqual(await looksRareToken.SUPPLY_CAP(), cap); 29 | assert.deepEqual(await looksRareToken.totalSupply(), premintAmount); 30 | }); 31 | 32 | it("Owner can mint", async () => { 33 | const valueToMint = parseEther("100000"); 34 | await expect(looksRareToken.connect(admin).mint(admin.address, valueToMint)) 35 | .to.emit(looksRareToken, "Transfer") 36 | .withArgs(constants.AddressZero, admin.address, valueToMint); 37 | }); 38 | 39 | it("Owner cannot mint more than cap", async () => { 40 | let valueToMint = cap.sub(premintAmount); 41 | await expect(looksRareToken.connect(admin).mint(admin.address, valueToMint)) 42 | .to.emit(looksRareToken, "Transfer") 43 | .withArgs(constants.AddressZero, admin.address, valueToMint); 44 | 45 | assert.deepEqual(await looksRareToken.totalSupply(), cap); 46 | 47 | valueToMint = BigNumber.from("1"); 48 | await expect(looksRareToken.connect(admin).mint(admin.address, valueToMint)).not.to.emit( 49 | looksRareToken, 50 | "Transfer" 51 | ); 52 | assert.deepEqual(await looksRareToken.totalSupply(), cap); 53 | }); 54 | }); 55 | 56 | describe("#2 - Unusual cases", async () => { 57 | it("Only owner can mint", async () => { 58 | await expect(looksRareToken.connect(accounts[1]).mint(admin.address, "0")).to.be.revertedWith( 59 | "Ownable: caller is not the owner" 60 | ); 61 | }); 62 | 63 | it("Cannot deploy if cap is greater than premint amount", async () => { 64 | const wrongCap = BigNumber.from("0"); 65 | const LooksRareToken = await ethers.getContractFactory("LooksRareToken"); 66 | await expect(LooksRareToken.deploy(admin.address, premintAmount, wrongCap)).to.be.revertedWith( 67 | "LOOKS: Premint amount is greater than cap" 68 | ); 69 | }); 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /test/tokenSplitter.test.ts: -------------------------------------------------------------------------------- 1 | import { assert, expect } from "chai"; 2 | import { ethers } from "hardhat"; 3 | import { constants, Contract, utils } from "ethers"; 4 | import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; 5 | 6 | const { parseEther } = utils; 7 | 8 | describe("TokenSplitter", () => { 9 | let looksRareToken: Contract; 10 | let tokenSplitter: Contract; 11 | 12 | let admin: SignerWithAddress; 13 | let team: SignerWithAddress; 14 | let treasury: SignerWithAddress; 15 | let tradingRewards: SignerWithAddress; 16 | let newTreasury: SignerWithAddress; 17 | let randomUser: SignerWithAddress; 18 | 19 | beforeEach(async () => { 20 | const accounts = await ethers.getSigners(); 21 | admin = accounts[0]; 22 | team = accounts[1]; 23 | treasury = accounts[2]; 24 | tradingRewards = accounts[3]; 25 | newTreasury = accounts[4]; 26 | randomUser = accounts[10]; 27 | 28 | const MockERC20 = await ethers.getContractFactory("MockERC20"); 29 | looksRareToken = await MockERC20.deploy("LOOKS", "Mock LOOKS"); 30 | await looksRareToken.deployed(); 31 | await looksRareToken.connect(admin).mint(admin.address, parseEther("1000000")); 32 | const TokenSplitter = await ethers.getContractFactory("TokenSplitter"); 33 | tokenSplitter = await TokenSplitter.deploy( 34 | [team.address, treasury.address, tradingRewards.address], 35 | ["20", "10", "70"], 36 | looksRareToken.address 37 | ); 38 | await tokenSplitter.deployed(); 39 | }); 40 | 41 | describe("#1 - System works as expected", async () => { 42 | it("Release tokens work", async () => { 43 | assert.deepEqual(await tokenSplitter.calculatePendingRewards(team.address), constants.Zero); 44 | 45 | // Admin adds 1000 LOOKS 46 | await looksRareToken.connect(admin).transfer(tokenSplitter.address, parseEther("1000")); 47 | assert.deepEqual(await tokenSplitter.calculatePendingRewards(team.address), parseEther("200")); 48 | 49 | let tx = await tokenSplitter.connect(team).releaseTokens(team.address); 50 | await expect(tx).to.emit(tokenSplitter, "TokensTransferred").withArgs(team.address, parseEther("200")); 51 | assert.deepEqual((await tokenSplitter.accountInfo(team.address))[1], parseEther("200")); 52 | 53 | // Admin adds 3000 LOOKS 54 | await looksRareToken.connect(admin).transfer(tokenSplitter.address, parseEther("3000")); 55 | 56 | tx = await tokenSplitter.connect(team).releaseTokens(team.address); 57 | await expect(tx).to.emit(tokenSplitter, "TokensTransferred").withArgs(team.address, parseEther("600")); 58 | assert.deepEqual((await tokenSplitter.accountInfo(team.address))[1], parseEther("800")); 59 | 60 | tx = await tokenSplitter.connect(treasury).releaseTokens(treasury.address); 61 | await expect(tx).to.emit(tokenSplitter, "TokensTransferred").withArgs(treasury.address, parseEther("400")); 62 | 63 | tx = await tokenSplitter.connect(tradingRewards).releaseTokens(tradingRewards.address); 64 | await expect(tx).to.emit(tokenSplitter, "TokensTransferred").withArgs(tradingRewards.address, parseEther("2800")); 65 | 66 | assert.deepEqual(await looksRareToken.balanceOf(tokenSplitter.address), constants.Zero); 67 | }); 68 | 69 | it("Cannot claim if no share, nothing to claim, or already claimed everything", async () => { 70 | assert.deepEqual(await tokenSplitter.calculatePendingRewards(randomUser.address), constants.Zero); 71 | 72 | await expect(tokenSplitter.connect(randomUser).releaseTokens(randomUser.address)).to.be.revertedWith( 73 | "Splitter: Account has no share" 74 | ); 75 | 76 | await expect(tokenSplitter.connect(treasury).releaseTokens(treasury.address)).to.be.revertedWith( 77 | "Splitter: Nothing to transfer" 78 | ); 79 | 80 | // Admin adds 3000 tokens 81 | await looksRareToken.connect(admin).transfer(tokenSplitter.address, parseEther("3000")); 82 | 83 | const tx = await tokenSplitter.connect(team).releaseTokens(team.address); 84 | await expect(tx).to.emit(tokenSplitter, "TokensTransferred").withArgs(team.address, parseEther("600")); 85 | 86 | // Cannot transfer again (if no more rewards) 87 | await expect(tokenSplitter.connect(team).releaseTokens(team.address)).to.be.revertedWith( 88 | "Splitter: Nothing to transfer" 89 | ); 90 | }); 91 | 92 | it("Random user with no shares can release tokens on someone's behalf and receives nothing", async () => { 93 | // Admin adds 3000 tokens 94 | await looksRareToken.connect(admin).transfer(tokenSplitter.address, parseEther("3000")); 95 | 96 | const tx = await tokenSplitter.connect(randomUser).releaseTokens(team.address); 97 | await expect(tx).to.emit(tokenSplitter, "TokensTransferred").withArgs(team.address, parseEther("600")); 98 | assert.deepEqual(await looksRareToken.balanceOf(randomUser.address), constants.Zero); 99 | }); 100 | 101 | it("Admin can port over the shares of someone", async () => { 102 | // Admin adds 3000 tokens 103 | await looksRareToken.connect(admin).transfer(tokenSplitter.address, parseEther("3000")); 104 | 105 | let tx = await tokenSplitter.connect(admin).updateSharesOwner(newTreasury.address, treasury.address); 106 | await expect(tx).to.emit(tokenSplitter, "NewSharesOwner").withArgs(treasury.address, newTreasury.address); 107 | 108 | tx = await tokenSplitter.connect(newTreasury).releaseTokens(newTreasury.address); 109 | await expect(tx).to.emit(tokenSplitter, "TokensTransferred").withArgs(newTreasury.address, parseEther("300")); 110 | assert.deepEqual(await looksRareToken.balanceOf(newTreasury.address), parseEther("300")); 111 | 112 | await expect(tokenSplitter.connect(treasury).releaseTokens(treasury.address)).to.be.revertedWith( 113 | "Splitter: Account has no share" 114 | ); 115 | }); 116 | }); 117 | 118 | describe("#2 - Revertions and admin functions", async () => { 119 | it("Cannot deploy with wrong parameters", async () => { 120 | const TokenSplitter = await ethers.getContractFactory("TokenSplitter"); 121 | 122 | await expect( 123 | TokenSplitter.deploy([team.address, treasury.address], ["20", "10", "70"], looksRareToken.address) 124 | ).to.be.revertedWith("Splitter: Length differ"); 125 | 126 | await expect(TokenSplitter.deploy([], [], looksRareToken.address)).to.be.revertedWith( 127 | "Splitter: Length must be > 0" 128 | ); 129 | 130 | await expect(TokenSplitter.deploy([team.address], ["0"], looksRareToken.address)).to.be.revertedWith( 131 | "Splitter: Shares are 0" 132 | ); 133 | }); 134 | 135 | it("Reversions for shares transfer work as expected", async () => { 136 | await expect( 137 | tokenSplitter.connect(admin).updateSharesOwner(treasury.address, treasury.address) 138 | ).to.be.revertedWith("Owner: New recipient has existing shares"); 139 | 140 | await expect( 141 | tokenSplitter.connect(admin).updateSharesOwner(newTreasury.address, randomUser.address) 142 | ).to.be.revertedWith("Owner: Current recipient has no shares"); 143 | }); 144 | 145 | it("Owner functions are only callable by owner", async () => { 146 | await expect( 147 | tokenSplitter.connect(randomUser).updateSharesOwner(randomUser.address, team.address) 148 | ).to.be.revertedWith("Ownable: caller is not the owner"); 149 | }); 150 | }); 151 | }); 152 | -------------------------------------------------------------------------------- /test/tradingRewardsDistributor.test.ts: -------------------------------------------------------------------------------- 1 | import { assert, expect } from "chai"; 2 | import { ethers } from "hardhat"; 3 | import { BigNumber, constants, Contract, utils } from "ethers"; 4 | import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; 5 | 6 | import { increaseTo } from "./helpers/block-traveller"; 7 | import { computeHash, createMerkleTree } from "./helpers/cryptography"; 8 | 9 | const { parseEther } = utils; 10 | 11 | describe("TradingRewardsDistributor", () => { 12 | let mockLooksRareToken: Contract; 13 | let tradingRewardsDistributor: Contract; 14 | 15 | let admin: SignerWithAddress; 16 | let accounts: SignerWithAddress[]; 17 | 18 | beforeEach(async () => { 19 | accounts = await ethers.getSigners(); 20 | admin = accounts[0]; 21 | 22 | const MockERC20 = await ethers.getContractFactory("MockERC20"); 23 | mockLooksRareToken = await MockERC20.deploy("LooksRare Token", "LOOKS"); 24 | await mockLooksRareToken.deployed(); 25 | await mockLooksRareToken.connect(admin).mint(admin.address, parseEther("1000000").toString()); 26 | 27 | const TradingRewardsDistributor = await ethers.getContractFactory("TradingRewardsDistributor"); 28 | tradingRewardsDistributor = await TradingRewardsDistributor.deploy(mockLooksRareToken.address); 29 | await tradingRewardsDistributor.deployed(); 30 | 31 | // Transfer funds to the mockLooksRareToken 32 | await mockLooksRareToken.connect(admin).transfer(tradingRewardsDistributor.address, parseEther("10000")); 33 | }); 34 | 35 | describe("#1 - Regular claims work as expected", async () => { 36 | it("Claim - Users can claim", async () => { 37 | // Users 1 to 4 38 | const json = { 39 | "0x70997970C51812dc3A010C7d01b50e0d17dc79C8": parseEther("5000").toString(), 40 | "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC": parseEther("3000").toString(), 41 | "0x90F79bf6EB2c4f870365E785982E1f101E93b906": parseEther("1000").toString(), 42 | "0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65": parseEther("1000").toString(), 43 | }; 44 | 45 | let [tree, hexRoot] = createMerkleTree(json); 46 | 47 | let tx = await tradingRewardsDistributor.connect(admin).updateTradingRewards(hexRoot, parseEther("5000")); 48 | await expect(tx).to.emit(tradingRewardsDistributor, "UpdateTradingRewards").withArgs("1"); 49 | 50 | await tradingRewardsDistributor.connect(admin).unpauseDistribution(); 51 | 52 | // All users except the 4th one claims 53 | for (const [index, [user, value]] of Object.entries(Object.entries(json))) { 54 | const signedUser = accounts[Number(index) + 1]; 55 | 56 | if (signedUser === accounts[3]) { 57 | break; 58 | } 59 | // Compute the proof for the user 60 | const hexProof = tree.getHexProof(computeHash(user, value), Number(index)); 61 | 62 | // Verify leaf is matched in the tree with the computed root 63 | assert.isTrue(tree.verify(hexProof, computeHash(user, value), hexRoot)); 64 | 65 | // Check user status 66 | let claimStatus = await tradingRewardsDistributor.canClaim(user, value, hexProof); 67 | assert.isTrue(claimStatus[0]); 68 | assert.equal(claimStatus[1].toString(), value); 69 | 70 | tx = await tradingRewardsDistributor.connect(signedUser).claim(value, hexProof); 71 | await expect(tx).to.emit(tradingRewardsDistributor, "RewardsClaim").withArgs(user, "1", value); 72 | 73 | claimStatus = await tradingRewardsDistributor.canClaim(user, value, hexProof); 74 | assert.isFalse(claimStatus[0]); 75 | assert.deepEqual(claimStatus[1], constants.Zero); 76 | 77 | assert.equal((await tradingRewardsDistributor.amountClaimedByUser(user)).toString(), value); 78 | 79 | // Cannot double claim 80 | await expect(tradingRewardsDistributor.connect(signedUser).claim(value, hexProof)).to.be.revertedWith( 81 | "Rewards: Already claimed" 82 | ); 83 | } 84 | 85 | // Transfer funds to the mockLooksRareToken 86 | await mockLooksRareToken.connect(admin).transfer(tradingRewardsDistributor.address, parseEther("10000")); 87 | 88 | // Users 1 to 4 (10k rewards added) 89 | const jsonRound2 = { 90 | "0x70997970C51812dc3A010C7d01b50e0d17dc79C8": parseEther("8000").toString(), 91 | "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC": parseEther("6000").toString(), 92 | "0x90F79bf6EB2c4f870365E785982E1f101E93b906": parseEther("3000").toString(), 93 | "0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65": parseEther("3000").toString(), 94 | }; 95 | 96 | [tree, hexRoot] = createMerkleTree(jsonRound2); 97 | 98 | tx = await tradingRewardsDistributor.connect(admin).updateTradingRewards(hexRoot, parseEther("8000")); 99 | await expect(tx).to.emit(tradingRewardsDistributor, "UpdateTradingRewards").withArgs("2"); 100 | 101 | // All users except the 4th one claims 102 | for (const [index, [user, value]] of Object.entries(Object.entries(jsonRound2))) { 103 | const signedUser = accounts[Number(index) + 1]; 104 | 105 | if (user === accounts[3].address) { 106 | break; 107 | } 108 | 109 | // Compute the proof for the user 110 | const hexProof = tree.getHexProof(computeHash(user, value), Number(index)); 111 | 112 | // Verify leaf is matched in the tree with the computed root 113 | assert.isTrue(tree.verify(hexProof, computeHash(user, value), hexRoot)); 114 | 115 | // Fetch the amount previous claimed by the user and deduct the amount they will receive 116 | const amountPreviouslyClaimed = await tradingRewardsDistributor.amountClaimedByUser(user); 117 | const expectedAmountToReceive = BigNumber.from(value).sub(BigNumber.from(amountPreviouslyClaimed.toString())); 118 | 119 | // Check user status 120 | let claimStatus = await tradingRewardsDistributor.canClaim(user, value, hexProof); 121 | assert.isTrue(claimStatus[0]); 122 | assert.deepEqual(claimStatus[1], expectedAmountToReceive); 123 | 124 | tx = await tradingRewardsDistributor.connect(signedUser).claim(value, hexProof); 125 | await expect(tx) 126 | .to.emit(tradingRewardsDistributor, "RewardsClaim") 127 | .withArgs(user, "2", expectedAmountToReceive); 128 | 129 | claimStatus = await tradingRewardsDistributor.canClaim(user, value, hexProof); 130 | assert.isFalse(claimStatus[0]); 131 | assert.deepEqual(claimStatus[1], constants.Zero); 132 | 133 | assert.equal((await tradingRewardsDistributor.amountClaimedByUser(user)).toString(), value); 134 | 135 | // Cannot double claim 136 | await expect(tradingRewardsDistributor.connect(signedUser).claim(value, hexProof)).to.be.revertedWith( 137 | "Rewards: Already claimed" 138 | ); 139 | } 140 | 141 | // User (accounts[3]) claims for two periods 142 | const lateClaimer = accounts[3]; 143 | const expectedAmountToReceive = parseEther("3000"); 144 | 145 | // Compute the proof for the user4 146 | const hexProof = tree.getHexProof(computeHash(lateClaimer.address, expectedAmountToReceive.toString()), 2); 147 | 148 | // Verify leaf is matched in the tree with the computed root 149 | 150 | assert.isTrue( 151 | tree.verify(hexProof, computeHash(lateClaimer.address, expectedAmountToReceive.toString()), hexRoot) 152 | ); 153 | 154 | tx = await tradingRewardsDistributor.connect(lateClaimer).claim(expectedAmountToReceive, hexProof); 155 | await expect(tx) 156 | .to.emit(tradingRewardsDistributor, "RewardsClaim") 157 | .withArgs(lateClaimer.address, "2", expectedAmountToReceive); 158 | }); 159 | 160 | it("Claim - Users cannot claim with wrong proofs", async () => { 161 | // Users 1 to 4 162 | const json = { 163 | "0x70997970C51812dc3A010C7d01b50e0d17dc79C8": parseEther("5000").toString(), 164 | "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC": parseEther("3000").toString(), 165 | "0x90F79bf6EB2c4f870365E785982E1f101E93b906": parseEther("1000").toString(), 166 | "0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65": parseEther("1000").toString(), 167 | }; 168 | 169 | // Compute tree 170 | const [tree, hexRoot] = createMerkleTree(json); 171 | 172 | const user1 = accounts[1]; 173 | const user2 = accounts[2]; 174 | const notEligibleUser = accounts[10]; 175 | 176 | const expectedAmountToReceiveForUser1 = parseEther("5000"); 177 | const expectedAmountToReceiveForUser2 = parseEther("3000"); 178 | 179 | // Compute the proof for user1/user2 180 | const hexProof1 = tree.getHexProof(computeHash(user1.address, expectedAmountToReceiveForUser1.toString()), 0); 181 | const hexProof2 = tree.getHexProof(computeHash(user2.address, expectedAmountToReceiveForUser2.toString()), 1); 182 | 183 | // Owner adds trading rewards and unpause distribution 184 | await tradingRewardsDistributor.connect(admin).updateTradingRewards(hexRoot, parseEther("5000")); 185 | await tradingRewardsDistributor.connect(admin).unpauseDistribution(); 186 | 187 | // 1. Verify leafs for user1/user2 are matched in the tree with the computed root 188 | assert.isTrue( 189 | tree.verify(hexProof1, computeHash(user1.address, expectedAmountToReceiveForUser1.toString()), hexRoot) 190 | ); 191 | 192 | assert.isTrue( 193 | tree.verify(hexProof2, computeHash(user2.address, expectedAmountToReceiveForUser2.toString()), hexRoot) 194 | ); 195 | 196 | // 2. User2 cannot claim with proof of user1 197 | assert.isFalse( 198 | tree.verify(hexProof1, computeHash(user2.address, expectedAmountToReceiveForUser1.toString()), hexRoot) 199 | ); 200 | 201 | assert.isFalse( 202 | (await tradingRewardsDistributor.canClaim(user2.address, expectedAmountToReceiveForUser2, hexProof1))[0] 203 | ); 204 | 205 | await expect( 206 | tradingRewardsDistributor.connect(user2).claim(expectedAmountToReceiveForUser2, hexProof1) 207 | ).to.be.revertedWith("Rewards: Invalid proof"); 208 | 209 | // 3. User1 cannot claim with proof of user2 210 | assert.isFalse( 211 | tree.verify(hexProof2, computeHash(user1.address, expectedAmountToReceiveForUser2.toString()), hexRoot) 212 | ); 213 | 214 | assert.isFalse( 215 | (await tradingRewardsDistributor.canClaim(user1.address, expectedAmountToReceiveForUser2, hexProof2))[0] 216 | ); 217 | 218 | await expect( 219 | tradingRewardsDistributor.connect(user1).claim(expectedAmountToReceiveForUser1, hexProof2) 220 | ).to.be.revertedWith("Rewards: Invalid proof"); 221 | 222 | // 4. User1 cannot claim with amount of user2 223 | assert.isFalse( 224 | tree.verify(hexProof1, computeHash(user1.address, expectedAmountToReceiveForUser2.toString()), hexRoot) 225 | ); 226 | 227 | assert.isFalse( 228 | (await tradingRewardsDistributor.canClaim(user1.address, expectedAmountToReceiveForUser2, hexProof1))[0] 229 | ); 230 | 231 | await expect( 232 | tradingRewardsDistributor.connect(user1).claim(expectedAmountToReceiveForUser2, hexProof1) 233 | ).to.be.revertedWith("Rewards: Invalid proof"); 234 | 235 | // 5. User2 cannot claim with amount of user1 236 | assert.isFalse( 237 | tree.verify(hexProof2, computeHash(user2.address, expectedAmountToReceiveForUser1.toString()), hexRoot) 238 | ); 239 | 240 | assert.isFalse( 241 | (await tradingRewardsDistributor.canClaim(user2.address, expectedAmountToReceiveForUser1, hexProof2))[0] 242 | ); 243 | 244 | await expect( 245 | tradingRewardsDistributor.connect(user2).claim(expectedAmountToReceiveForUser1, hexProof2) 246 | ).to.be.revertedWith("Rewards: Invalid proof"); 247 | 248 | // 6. Non-eligible user cannot claim with proof/amount of user1 249 | assert.isFalse( 250 | tree.verify( 251 | hexProof1, 252 | computeHash(notEligibleUser.address, expectedAmountToReceiveForUser1.toString()), 253 | hexRoot 254 | ) 255 | ); 256 | 257 | assert.isFalse( 258 | ( 259 | await tradingRewardsDistributor.canClaim(notEligibleUser.address, expectedAmountToReceiveForUser1, hexProof1) 260 | )[0] 261 | ); 262 | 263 | await expect( 264 | tradingRewardsDistributor.connect(notEligibleUser).claim(expectedAmountToReceiveForUser1, hexProof1) 265 | ).to.be.revertedWith("Rewards: Invalid proof"); 266 | 267 | // 7. Non-eligible user cannot claim with proof/amount of user1 268 | assert.isFalse( 269 | tree.verify( 270 | hexProof2, 271 | computeHash(notEligibleUser.address, expectedAmountToReceiveForUser2.toString()), 272 | hexRoot 273 | ) 274 | ); 275 | 276 | assert.isFalse( 277 | ( 278 | await tradingRewardsDistributor.canClaim(notEligibleUser.address, expectedAmountToReceiveForUser2, hexProof2) 279 | )[0] 280 | ); 281 | 282 | await expect( 283 | tradingRewardsDistributor.connect(notEligibleUser).claim(expectedAmountToReceiveForUser2, hexProof2) 284 | ).to.be.revertedWith("Rewards: Invalid proof"); 285 | }); 286 | 287 | it("Claim - User cannot claim if error in tree computation due to amount too high", async () => { 288 | // Users 1 to 4 289 | const json = { 290 | "0x70997970C51812dc3A010C7d01b50e0d17dc79C8": parseEther("5000").toString(), 291 | "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC": parseEther("3000").toString(), 292 | "0x90F79bf6EB2c4f870365E785982E1f101E93b906": parseEther("1000").toString(), 293 | "0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65": parseEther("1000").toString(), 294 | }; 295 | 296 | // Compute tree 297 | const [tree, hexRoot] = createMerkleTree(json); 298 | 299 | const user1 = accounts[1]; 300 | const expectedAmountToReceiveForUser1 = parseEther("5000"); 301 | 302 | // Compute the proof for user1/user2 303 | const hexProof1 = tree.getHexProof(computeHash(user1.address, expectedAmountToReceiveForUser1.toString()), 0); 304 | 305 | // Owner adds trading rewards and unpause distribution 306 | await tradingRewardsDistributor.connect(admin).updateTradingRewards(hexRoot, parseEther("4999.9999")); 307 | await tradingRewardsDistributor.connect(admin).unpauseDistribution(); 308 | 309 | await expect( 310 | tradingRewardsDistributor.connect(user1).claim(expectedAmountToReceiveForUser1, hexProof1) 311 | ).to.be.revertedWith("Rewards: Amount higher than max"); 312 | }); 313 | }); 314 | 315 | describe("#2 - Owner functions", async () => { 316 | it("Owner - Owner cannot withdraw immediately after pausing", async () => { 317 | const depositAmount = parseEther("10000"); 318 | 319 | // Transfer funds to the mockLooksRareToken 320 | await mockLooksRareToken.connect(admin).transfer(tradingRewardsDistributor.address, depositAmount); 321 | 322 | let tx = await tradingRewardsDistributor.connect(admin).unpauseDistribution(); 323 | await expect(tx).to.emit(tradingRewardsDistributor, "Unpaused"); 324 | 325 | tx = await tradingRewardsDistributor.connect(admin).pauseDistribution(); 326 | await expect(tx).to.emit(tradingRewardsDistributor, "Paused"); 327 | 328 | await expect(tradingRewardsDistributor.connect(admin).withdrawTokenRewards(depositAmount)).to.be.revertedWith( 329 | "Owner: Too early to withdraw" 330 | ); 331 | 332 | const lastPausedTimestamp = await tradingRewardsDistributor.lastPausedTimestamp(); 333 | const BUFFER_ADMIN_WITHDRAW = await tradingRewardsDistributor.BUFFER_ADMIN_WITHDRAW(); 334 | 335 | // Jump in time to the period where it becomes possible to claim 336 | await increaseTo(lastPausedTimestamp.add(BUFFER_ADMIN_WITHDRAW).add(BigNumber.from("1"))); 337 | 338 | tx = await tradingRewardsDistributor.connect(admin).withdrawTokenRewards(depositAmount); 339 | await expect(tx).to.emit(tradingRewardsDistributor, "TokenWithdrawnOwner").withArgs(depositAmount); 340 | }); 341 | 342 | it("Owner - Owner cannot set twice the same Merkle Root", async () => { 343 | // Users 1 to 4 344 | const json = { 345 | "0x70997970C51812dc3A010C7d01b50e0d17dc79C8": parseEther("5000").toString(), 346 | "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC": parseEther("3000").toString(), 347 | "0x90F79bf6EB2c4f870365E785982E1f101E93b906": parseEther("1000").toString(), 348 | "0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65": parseEther("1000").toString(), 349 | }; 350 | 351 | const [, hexRoot] = createMerkleTree(json); 352 | 353 | await tradingRewardsDistributor.connect(admin).updateTradingRewards(hexRoot, parseEther("5000")); 354 | await tradingRewardsDistributor.connect(admin).unpauseDistribution(); 355 | 356 | await expect( 357 | tradingRewardsDistributor.connect(admin).updateTradingRewards(hexRoot, parseEther("5000")) 358 | ).to.be.revertedWith("Owner: Merkle root already used"); 359 | }); 360 | }); 361 | }); 362 | -------------------------------------------------------------------------------- /test/vestingContractWithFeeSharing.test.ts: -------------------------------------------------------------------------------- 1 | import { assert, expect } from "chai"; 2 | import { ethers } from "hardhat"; 3 | import { BigNumber, Contract, utils } from "ethers"; 4 | import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; 5 | import { advanceBlockTo } from "./helpers/block-traveller"; 6 | 7 | const { parseEther } = utils; 8 | 9 | describe("VestingContractWithFeeSharing", () => { 10 | let looksRareToken: Contract; 11 | let weth: Contract; 12 | let vestingContract: Contract; 13 | 14 | let admin: SignerWithAddress; 15 | let randomUser: SignerWithAddress; 16 | 17 | let maxAmountToWithdraw: BigNumber; 18 | let numberUnlockPeriods: BigNumber; 19 | let startBlock: BigNumber; 20 | let vestingBetweenPeriodsInBlocks: BigNumber; 21 | 22 | beforeEach(async () => { 23 | const accounts = await ethers.getSigners(); 24 | admin = accounts[0]; 25 | randomUser = accounts[1]; 26 | 27 | const MockERC20 = await ethers.getContractFactory("MockERC20"); 28 | looksRareToken = await MockERC20.deploy("LOOKS", "Mock LOOKS"); 29 | await looksRareToken.deployed(); 30 | weth = await MockERC20.deploy("WETH", "Wrapped ETH"); 31 | await weth.deployed(); 32 | 33 | await looksRareToken.connect(admin).mint(admin.address, parseEther("100000000")); 34 | await weth.connect(admin).mint(admin.address, parseEther("100000000")); 35 | 36 | maxAmountToWithdraw = parseEther("500000"); // 500,000 37 | 38 | startBlock = BigNumber.from(await ethers.provider.getBlockNumber()).add("100"); 39 | vestingBetweenPeriodsInBlocks = BigNumber.from("50"); 40 | numberUnlockPeriods = BigNumber.from("4"); 41 | 42 | const VestingContractWithFeeSharing = await ethers.getContractFactory("VestingContractWithFeeSharing"); 43 | vestingContract = await VestingContractWithFeeSharing.deploy( 44 | vestingBetweenPeriodsInBlocks, 45 | startBlock, 46 | numberUnlockPeriods, 47 | maxAmountToWithdraw, 48 | looksRareToken.address 49 | ); 50 | await vestingContract.deployed(); 51 | }); 52 | 53 | describe("#1 - System works as expected", async () => { 54 | it("Release tokens work", async () => { 55 | await weth.connect(admin).transfer(vestingContract.address, parseEther("20")); 56 | 57 | let tx = await vestingContract.connect(admin).withdrawOtherCurrency(weth.address); 58 | await expect(tx).to.emit(vestingContract, "OtherTokensWithdrawn").withArgs(weth.address, parseEther("20")); 59 | 60 | // Transfer 500k LOOKS to Vesting Contract 61 | await looksRareToken.connect(admin).transfer(vestingContract.address, maxAmountToWithdraw); 62 | 63 | // Cannot unlock since it is too early 64 | await expect(vestingContract.unlockLooksRareToken()).to.be.revertedWith("Unlock: Too early"); 65 | 66 | // Time travel 67 | await advanceBlockTo(BigNumber.from(startBlock.add(vestingBetweenPeriodsInBlocks))); 68 | 69 | // Admin unlocks tokens 70 | tx = await vestingContract.connect(admin).unlockLooksRareToken(); 71 | await expect(tx).to.emit(vestingContract, "TokensUnlocked").withArgs(maxAmountToWithdraw); 72 | 73 | // Verify next period, maxAmountToWithdrawForNextPeriod are adjusted accordingly 74 | assert.equal((await vestingContract.numberPastUnlocks()).toString(), "1"); 75 | assert.deepEqual(await vestingContract.maxAmountToWithdrawForNextPeriod(), maxAmountToWithdraw); 76 | assert.deepEqual( 77 | await vestingContract.nextBlockForUnlock(), 78 | startBlock.add(vestingBetweenPeriodsInBlocks).add(vestingBetweenPeriodsInBlocks) 79 | ); 80 | 81 | // PERIOD 2 // Transfer less than expected (400k LOOKS) to Vesting Contract 82 | let nextBlockForUnlock = await vestingContract.nextBlockForUnlock(); 83 | await advanceBlockTo(nextBlockForUnlock); 84 | await looksRareToken.connect(admin).transfer(vestingContract.address, parseEther("400000")); 85 | 86 | // Admin unlocks tokens 87 | tx = await vestingContract.connect(admin).unlockLooksRareToken(); 88 | await expect(tx).to.emit(vestingContract, "TokensUnlocked").withArgs(parseEther("400000")); 89 | 90 | // Verify next period, maxAmountToWithdrawForNextPeriod are adjusted accordingly 91 | assert.equal((await vestingContract.numberPastUnlocks()).toString(), "2"); 92 | assert.deepEqual(await vestingContract.maxAmountToWithdrawForNextPeriod(), parseEther("600000")); 93 | 94 | // PERIOD 3 - Transfer more than expected (1M LOOKS) to Vesting Contract 95 | nextBlockForUnlock = await vestingContract.nextBlockForUnlock(); 96 | await advanceBlockTo(nextBlockForUnlock); 97 | await looksRareToken.connect(admin).transfer(vestingContract.address, parseEther("1000000")); 98 | 99 | // Admin unlocks tokens 100 | tx = await vestingContract.connect(admin).unlockLooksRareToken(); 101 | await expect(tx).to.emit(vestingContract, "TokensUnlocked").withArgs(parseEther("600000")); 102 | 103 | // Verify next period, maxAmountToWithdrawForNextPeriod are adjusted accordingly 104 | assert.equal((await vestingContract.numberPastUnlocks()).toString(), "3"); 105 | assert.deepEqual(await vestingContract.maxAmountToWithdrawForNextPeriod(), parseEther("500000")); 106 | 107 | // PERIOD 4 - Transfer what is missing for final period (100k LOOKS) to Vesting Contract 108 | nextBlockForUnlock = await vestingContract.nextBlockForUnlock(); 109 | await advanceBlockTo(nextBlockForUnlock); 110 | await looksRareToken.transfer(vestingContract.address, parseEther("100000")); 111 | 112 | // Admin unlocks tokens 113 | tx = await vestingContract.connect(admin).unlockLooksRareToken(); 114 | await expect(tx).to.emit(vestingContract, "TokensUnlocked").withArgs(maxAmountToWithdraw); 115 | 116 | // Verify number of past unlocks period is adjusted accordingly 117 | assert.equal((await vestingContract.numberPastUnlocks()).toString(), "4"); 118 | 119 | // AFTER - Can claim anything that comes in 120 | await looksRareToken.connect(admin).transfer(vestingContract.address, "100"); 121 | tx = await vestingContract.connect(admin).unlockLooksRareToken(); 122 | await expect(tx).to.emit(vestingContract, "TokensUnlocked").withArgs("100"); 123 | }); 124 | 125 | it("Cannot withdraw LOOKS using standard withdraw functions", async () => { 126 | await expect(vestingContract.connect(admin).withdrawOtherCurrency(looksRareToken.address)).to.be.revertedWith( 127 | "Owner: Cannot withdraw LOOKS" 128 | ); 129 | 130 | await expect(vestingContract.connect(admin).withdrawOtherCurrency(weth.address)).to.be.revertedWith( 131 | "Owner: Nothing to withdraw" 132 | ); 133 | }); 134 | }); 135 | 136 | describe("#2 - Owner functions can only be called by owner", async () => { 137 | it("Owner functions are only callable by owner", async () => { 138 | await expect( 139 | vestingContract.connect(randomUser).withdrawOtherCurrency(looksRareToken.address) 140 | ).to.be.revertedWith("Ownable: caller is not the owner"); 141 | 142 | await expect(vestingContract.connect(randomUser).unlockLooksRareToken()).to.be.revertedWith( 143 | "Ownable: caller is not the owner" 144 | ); 145 | }); 146 | }); 147 | }); 148 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2018", 4 | "module": "commonjs", 5 | "strict": true, 6 | "esModuleInterop": true, 7 | "outDir": "dist", 8 | "declaration": true 9 | }, 10 | "include": ["./scripts", "./test", "./typechain"], 11 | "files": ["./hardhat.config.ts"] 12 | } 13 | --------------------------------------------------------------------------------