├── .env.example ├── header.png ├── reliquaryWhitepaper.pdf ├── run_forge_tests.sh ├── contracts ├── interfaces │ ├── ICurves.sol │ ├── INFTDescriptor.sol │ ├── IRollingRewarder.sol │ ├── IParentRollingRewarder.sol │ ├── IRewarder.sol │ └── IReliquary.sol ├── curves │ ├── LinearCurve.sol │ ├── LinearPlateauCurve.sol │ └── PolynomialPlateauCurve.sol ├── libraries │ ├── ReliquaryEvents.sol │ └── ReliquaryLogic.sol ├── helpers │ ├── DepositHelperERC4626.sol │ ├── DepositHelperReaperVault.sol │ └── DepositHelperReaperBPT.sol ├── nft_descriptors │ └── NFTDescriptor.sol └── rewarders │ ├── ParentRollingRewarder.sol │ └── RollingRewarder.sol ├── pre-commit ├── test ├── echidna │ ├── config2_slow.yaml │ ├── config3_inDepth.yaml │ ├── config1_fast.yaml │ ├── mocks │ │ └── ERC20Mock.sol │ ├── README.md │ └── ReliquaryProperties.sol └── foundry │ ├── mocks │ └── ERC20Mock.sol │ ├── DepositHelperERC4626.t.sol │ ├── DepositHelperReaperBPT.t.sol │ ├── DepositHelperReaperVault.t.sol │ ├── Reliquary.t.sol │ └── MultipleRollingRewarder.t.sol ├── .gitignore ├── .gitmodules ├── foundry.toml ├── .github └── workflows │ └── main.yml ├── README.md ├── scripts ├── deploy_conf_example.txt └── Deploy.s.sol └── audit └── ReliquaryV2-audit-Cergyk-report.md /.env.example: -------------------------------------------------------------------------------- 1 | ALCHEMY_API_KEY=abc 2 | ETHERSCAN_API_KEY= -------------------------------------------------------------------------------- /header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cod3x-Labs/Reliquary/HEAD/header.png -------------------------------------------------------------------------------- /reliquaryWhitepaper.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cod3x-Labs/Reliquary/HEAD/reliquaryWhitepaper.pdf -------------------------------------------------------------------------------- /run_forge_tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | forge fmt --check && 3 | export FOUNDRY_PROFILE=test && 4 | forge test 5 | -------------------------------------------------------------------------------- /contracts/interfaces/ICurves.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity 0.8.23; 3 | 4 | interface ICurves { 5 | function getFunction(uint256 _maturity) external view returns (uint256); 6 | } 7 | -------------------------------------------------------------------------------- /contracts/interfaces/INFTDescriptor.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity 0.8.23; 3 | 4 | interface INFTDescriptor { 5 | function constructTokenURI(uint256 _relicId) external view returns (string memory); 6 | } 7 | -------------------------------------------------------------------------------- /contracts/interfaces/IRollingRewarder.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity 0.8.23; 3 | 4 | import "./IRewarder.sol"; 5 | 6 | interface IRollingRewarder is IRewarder { 7 | function fund(uint256 _amount) external; 8 | } 9 | -------------------------------------------------------------------------------- /contracts/interfaces/IParentRollingRewarder.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity 0.8.23; 3 | 4 | import "./IRewarder.sol"; 5 | 6 | interface IParentRollingRewarder is IRewarder { 7 | function initialize(uint8 _poolId) external; 8 | } 9 | -------------------------------------------------------------------------------- /pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | STASH_NAME="pre-commit-$(date +%s)" 3 | git stash save -q --keep-index $STASH_NAME 4 | 5 | ./run_forge_tests.sh 6 | RESULT=$? 7 | 8 | STASHES=$(git stash list) 9 | if [[ $STASHES == "$STASH_NAME" ]]; then git stash pop -q; fi 10 | 11 | [ $RESULT -ne 0 ] && exit 1 12 | exit 0 13 | -------------------------------------------------------------------------------- /test/echidna/config2_slow.yaml: -------------------------------------------------------------------------------- 1 | seqLen: 150 2 | testLimit: 50000 3 | 4 | workers: 10 5 | coverage: true 6 | corpusDir: "corpus" 7 | testMode: assertion 8 | deployer: "0x10000" 9 | sender: ["0x10000", "0x20000", "0x30000"] 10 | coverageFormats: ["txt"] 11 | shrinkLimit: 5000 12 | 13 | # Ignore helpers 14 | filterBlacklist: true 15 | filterFunctions: [] 16 | -------------------------------------------------------------------------------- /test/echidna/config3_inDepth.yaml: -------------------------------------------------------------------------------- 1 | seqLen: 300 2 | testLimit: 600000 3 | 4 | workers: 10 5 | coverage: true 6 | corpusDir: "corpus" 7 | testMode: assertion 8 | deployer: "0x10000" 9 | sender: ["0x10000", "0x20000", "0x30000"] 10 | coverageFormats: ["txt"] 11 | shrinkLimit: 6000 12 | 13 | # Ignore helpers 14 | filterBlacklist: true 15 | filterFunctions: [] 16 | -------------------------------------------------------------------------------- /test/echidna/config1_fast.yaml: -------------------------------------------------------------------------------- 1 | seqLen: 100 2 | testLimit: 10000 3 | 4 | workers: 10 5 | coverage: true 6 | corpusDir: "corpus" 7 | testMode: assertion 8 | deployer: "0x10000" 9 | sender: ["0x10000", "0x20000", "0x30000"] 10 | coverageFormats: ["txt"] 11 | shrinkLimit: 500 12 | codeSize: 0xf000 13 | 14 | # Ignore helpers 15 | filterBlacklist: true 16 | filterFunctions: [] 17 | -------------------------------------------------------------------------------- /test/echidna/mocks/ERC20Mock.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity 0.8.23; 3 | 4 | import "lib/openzeppelin-contracts/contracts/token/ERC20/ERC20.sol"; 5 | 6 | contract ERC20Mock is ERC20 { 7 | constructor(string memory name, string memory symbol) ERC20(name, symbol) {} 8 | 9 | function mint(address to, uint256 amount) public { 10 | _mint(to, amount); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # general 2 | node_modules 3 | .env 4 | secrets.json 5 | scripts/deploy_conf.json 6 | 7 | #Hardhat files 8 | cache 9 | artifacts 10 | 11 | #Solidity coverage 12 | coverage 13 | coverage.json 14 | 15 | #Typechain 16 | typechain 17 | types 18 | 19 | # IDEs 20 | .idea 21 | 22 | #Foundry 23 | forge-cache 24 | out 25 | broadcast 26 | 27 | # Echidna 28 | corpus/ 29 | **/crytic-compile 30 | **/crytic-export 31 | **/corpus 32 | -------------------------------------------------------------------------------- /test/foundry/mocks/ERC20Mock.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity 0.8.23; 3 | 4 | import "openzeppelin-contracts/contracts/mocks/token/ERC20DecimalsMock.sol"; 5 | 6 | contract ERC20Mock is ERC20DecimalsMock { 7 | constructor(uint8 _dec) ERC20("ERC20Mock", "E20M") ERC20DecimalsMock(_dec) {} 8 | 9 | function mint(address account, uint256 amount) external { 10 | _mint(account, amount); 11 | } 12 | 13 | function burn(address account, uint256 amount) external { 14 | _burn(account, amount); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "lib/openzeppelin-contracts"] 2 | path = lib/openzeppelin-contracts 3 | url = https://github.com/openzeppelin/openzeppelin-contracts 4 | branch = v5.0.2 5 | [submodule "lib/v2-core"] 6 | path = lib/v2-core 7 | url = https://github.com/uniswap/v2-core 8 | [submodule "lib/base64"] 9 | path = lib/base64 10 | url = https://github.com/brechtpd/base64 11 | [submodule "lib/forge-std"] 12 | path = lib/forge-std 13 | url = https://github.com/foundry-rs/forge-std 14 | branch = v1 15 | [submodule "lib/solmate"] 16 | path = lib/solmate 17 | url = https://github.com/transmissions11/solmate 18 | -------------------------------------------------------------------------------- /contracts/curves/LinearCurve.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity 0.8.23; 3 | 4 | import "contracts/interfaces/ICurves.sol"; 5 | 6 | contract LinearCurve is ICurves { 7 | uint256 public immutable slope; 8 | uint256 public immutable minMultiplier; // getFunction(0) = minMultiplier 9 | 10 | error LinearFunction__MIN_MULTIPLIER_MUST_GREATER_THAN_ZERO(); 11 | 12 | constructor(uint256 _slope, uint256 _minMultiplier) { 13 | if (_minMultiplier == 0) revert LinearFunction__MIN_MULTIPLIER_MUST_GREATER_THAN_ZERO(); 14 | slope = _slope; // uint256 force the "strictly increasing" rule 15 | minMultiplier = _minMultiplier; 16 | } 17 | 18 | function getFunction(uint256 _level) external view returns (uint256) { 19 | return _level * slope + minMultiplier; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /foundry.toml: -------------------------------------------------------------------------------- 1 | [profile.default] 2 | src = 'contracts' 3 | out = 'out' 4 | libs = ['lib'] 5 | test = 'test/foundry' 6 | cache_path = 'forge-cache' 7 | script = 'scripts' 8 | 9 | solc_version = "0.8.23" 10 | optimizer = true 11 | optimizer_runs = 1000 12 | via_ir = false 13 | verbosity = 1 14 | 15 | #eth-rpc-url = "https://rpcapi.fantom.network" 16 | rpc_endpoints = { fantom = "https://rpcapi-tracing.fantom.network", optimism = "https://opt-mainnet.g.alchemy.com/v2/${ALCHEMY_API_KEY}" } 17 | #sender = '' 18 | #initial_balance = '0xffffffffffffffffffffffff' 19 | 20 | ffi = false 21 | fs_permissions = [{ access = "read", path = "./"}] 22 | #invariant_fail_on_revert = true 23 | 24 | [profile.test.optimizer_details.yulDetails] 25 | # Reduces compile times but produces poorly optimized code 26 | optimizerSteps = '' 27 | 28 | [fmt] 29 | line_length = 100 30 | 31 | # See more config options https://github.com/foundry-rs/foundry/tree/master/config 32 | -------------------------------------------------------------------------------- /contracts/curves/LinearPlateauCurve.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity 0.8.23; 3 | 4 | import "contracts/interfaces/ICurves.sol"; 5 | 6 | contract LinearPlateauCurve is ICurves { 7 | uint256 public immutable slope; 8 | uint256 public immutable minMultiplier; // getFunction(0) = minMultiplier 9 | uint256 public immutable plateauLevel; // getFunction(0) = minMultiplier 10 | 11 | error LinearFunction__MIN_MULTIPLIER_MUST_GREATER_THAN_ZERO(); 12 | 13 | constructor(uint256 _slope, uint256 _minMultiplier, uint256 _plateauLevel) { 14 | if (_minMultiplier == 0) revert LinearFunction__MIN_MULTIPLIER_MUST_GREATER_THAN_ZERO(); 15 | slope = _slope; // uint256 force the "strictly increasing" rule 16 | minMultiplier = _minMultiplier; 17 | plateauLevel = _plateauLevel; 18 | } 19 | 20 | function getFunction(uint256 _level) external view returns (uint256) { 21 | if (_level >= plateauLevel) return plateauLevel * slope + minMultiplier; 22 | return _level * slope + minMultiplier; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /test/echidna/README.md: -------------------------------------------------------------------------------- 1 | ## Echidna 2 | 3 | Echidna is a program designed for fuzzing/property-based testing of Ethereum smart contracts. Please refer to the doc for [installation](https://github.com/crytic/echidna#installation). 4 | 5 | Run with: 6 | 7 | ```sh 8 | echidna test/echidna/ReliquaryProperties.sol --contract ReliquaryProperties --config test/echidna/config1_fast.yaml 9 | ``` 10 | 11 | You can fine in `/echidna` 3 config files to run the fuzzer: 12 | 13 | - 1< min | `config1_fast.yaml` 14 | - 5< min | `config2_slow.yaml` 15 | - 50 min | `config3_inDepth.yaml` 16 | 17 | ## Invariants 18 | 19 | - ✅ A user should never be able to withdraw more than deposited. 20 | - ✅ No `position.entry` should be greater than `block.timestamp`. 21 | - ✅ The sum of all `position.amount` should never be greater than total deposit. 22 | - ✅ The sum of all `allocPoint` should be equal to `totalAllocpoint`. 23 | - ✅ The total reward harvested and pending should never be greater than the total emission rate. 24 | - ✅ `emergencyWithdraw` should burn position rewards. 25 | -------------------------------------------------------------------------------- /contracts/curves/PolynomialPlateauCurve.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity 0.8.23; 3 | 4 | import "contracts/interfaces/ICurves.sol"; 5 | 6 | contract PolynomialPlateauCurve is ICurves { 7 | uint256 private constant WAD = 1e18; 8 | 9 | int256[] public coefficients; 10 | uint256 public immutable plateauLevel = type(uint256).max; 11 | uint256 public immutable plateauMultiplier; 12 | 13 | /// @dev coefficients calculator helper: https://www.desmos.com/calculator/nic7esjsbe 14 | // ex: [100e18, 1e18, 5e15, -1e13, 5e9] 15 | //! We allow int coefficients, but developers must make sure that ∀x > 0 => getFunction(x) > 0 16 | constructor(int256[] memory _coefficients, uint256 _plateauLevel) { 17 | coefficients = _coefficients; // Coefficients must be expressed in WAD. 18 | plateauMultiplier = getFunction(_plateauLevel); 19 | plateauLevel = _plateauLevel; 20 | } 21 | 22 | function getFunction(uint256 _level) public view returns (uint256) { 23 | if (_level >= plateauLevel) return plateauMultiplier; 24 | 25 | int256 result_ = coefficients[0]; 26 | for (uint256 i = 1; i < coefficients.length; i++) { 27 | result_ += coefficients[i] * int256(_level ** i); 28 | } 29 | return uint256(result_) / WAD; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /contracts/libraries/ReliquaryEvents.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity 0.8.23; 3 | 4 | library ReliquaryEvents { 5 | event CreateRelic(uint8 indexed pid, address indexed to, uint256 indexed relicId); 6 | event Deposit(uint8 indexed pid, uint256 amount, address indexed to, uint256 indexed relicId); 7 | event Withdraw(uint8 indexed pid, uint256 amount, address indexed to, uint256 indexed relicId); 8 | event Harvest(uint8 indexed pid, uint256 amount, address indexed to, uint256 indexed relicId); 9 | event Update(uint8 indexed pid, uint256 indexed relicId); 10 | 11 | event EmergencyWithdraw( 12 | uint8 indexed pid, uint256 amount, address indexed to, uint256 indexed relicId 13 | ); 14 | event LogPoolAddition( 15 | uint8 indexed pid, 16 | uint256 allocPoint, 17 | address indexed poolToken, 18 | address indexed rewarder, 19 | address nftDescriptor, 20 | bool allowPartialWithdrawals 21 | ); 22 | event LogPoolModified( 23 | uint8 indexed pid, uint256 allocPoint, address indexed rewarder, address nftDescriptor 24 | ); 25 | event LogSetEmissionRate(uint256 indexed emissionRate); 26 | event Split(uint256 indexed fromId, uint256 indexed toId, uint256 amount); 27 | event Shift(uint256 indexed fromId, uint256 indexed toId, uint256 amount); 28 | event Merge(uint256 indexed fromId, uint256 indexed toId, uint256 amount); 29 | } 30 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | # Controls when the workflow will run 4 | on: 5 | # Triggers the workflow on push or pull request events but only for the "master" branch 6 | push: 7 | branches: [ "master" ] 8 | pull_request: 9 | branches: [ "master" ] 10 | 11 | # Allows you to run this workflow manually from the Actions tab 12 | workflow_dispatch: 13 | 14 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 15 | jobs: 16 | # This workflow contains a single job called "build" 17 | build: 18 | # The type of runner that the job will run on 19 | runs-on: ubuntu-latest 20 | 21 | # Steps represent a sequence of tasks that will be executed as part of the job 22 | steps: 23 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 24 | - uses: actions/checkout@v3 25 | 26 | - name: Install Foundry 27 | uses: foundry-rs/foundry-toolchain@v1 28 | with: 29 | version: nightly-e15e33a07c0920189fc336391f538c3dad53da73 30 | 31 | - name: Install submodules 32 | run: | 33 | git config --global url."https://github.com/".insteadOf "git@github.com:" 34 | git submodule update --init --recursive 35 | 36 | - name: Generate fuzz seed that changes weekly 37 | run: > 38 | echo "FOUNDRY_FUZZ_SEED=$( 39 | echo $(($EPOCHSECONDS - $EPOCHSECONDS % 604800)) 40 | )" >> $GITHUB_ENV 41 | 42 | - name: Run forge tests 43 | run: ./run_forge_tests.sh 44 | -------------------------------------------------------------------------------- /contracts/interfaces/IRewarder.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity 0.8.23; 3 | 4 | import "../interfaces/ICurves.sol"; 5 | 6 | interface IRewarder { 7 | function onReward(uint256 _relicId, address _to) external; 8 | 9 | function onUpdate( 10 | ICurves _curve, 11 | uint256 _relicId, 12 | uint256 _amount, 13 | uint256 _oldLevel, 14 | uint256 _newLevel 15 | ) external; 16 | 17 | function onDeposit( 18 | ICurves _curve, 19 | uint256 _relicId, 20 | uint256 _depositAmount, 21 | uint256 _oldAmount, 22 | uint256 _oldLevel, 23 | uint256 _newLevel 24 | ) external; 25 | 26 | function onWithdraw( 27 | ICurves _curve, 28 | uint256 _relicId, 29 | uint256 _withdrawalAmount, 30 | uint256 _oldAmount, 31 | uint256 _oldLevel, 32 | uint256 _newLevel 33 | ) external; 34 | 35 | function onSplit( 36 | ICurves _curve, 37 | uint256 _fromId, 38 | uint256 _newId, 39 | uint256 _amount, 40 | uint256 _fromAmount, 41 | uint256 _level 42 | ) external; 43 | 44 | function onShift( 45 | ICurves _curve, 46 | uint256 _fromId, 47 | uint256 _toId, 48 | uint256 _amount, 49 | uint256 _oldFromAmount, 50 | uint256 _oldToAmount, 51 | uint256 _fromLevel, 52 | uint256 _oldToLevel, 53 | uint256 _newToLevel 54 | ) external; 55 | 56 | function onMerge( 57 | ICurves _curve, 58 | uint256 _fromId, 59 | uint256 _toId, 60 | uint256 _fromAmount, 61 | uint256 _toAmount, 62 | uint256 _fromLevel, 63 | uint256 _oldToLevel, 64 | uint256 _newToLevel 65 | ) external; 66 | 67 | function pendingTokens(uint256 _relicId) 68 | external 69 | view 70 | returns (address[] memory, uint256[] memory); 71 | } 72 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Reliquary aka Cod3x Stake 2 | 3 | Reliquary is a smart contract system that is designed to improve outcomes of incentive distribution by giving users and developers fine grained control over their investments and rewards. It accomplishes this with the following features: 4 | 5 | 1. Emits tokens based on the maturity of a user's investment, separated in tranches. 6 | 2. Binds variable emission rates to a base emission curve designed by the developer for predictable emissions. 7 | 3. Supports deposits and withdrawals along with these variable rates, which has historically been impossible. 8 | 4. Issues a 'financial NFT' to users which represents their underlying positions, able to be traded and leveraged without removing the underlying liquidity. 9 | 5. Can emit multiple types of rewards for each investment as well as handle complex reward mechanisms based on deposit and withdrawal. 10 | 11 | By binding tokens to a base emission rate you not only gain the advantage of a predictable emission curve, but you're able 12 | to get extremely creative with the Curve contracts you write. Whether this be a sigmoid curve, a square root curve, or a 13 | random curve, you can codify the user behaviors you'd like to promote. 14 | 15 | Please reach out to zokunei@bytemasons.com to report bugs or other funky behavior. We will proceed with various stages of production 16 | testing in the coming weeks. 17 | 18 | ## V2 update notes 19 | 20 | 1. **Maturity Evolution Curves**: We have replaced the previous level evolution mechanism with curves to provide more flexibility and precision. The available curve options are: 21 | - Linear 22 | - Linear Plateau 23 | - Polynomial Plateau 24 | 25 | 2. **Scalable 'Level' Number**: The 'Level' number now scales with an O(1) complexity, ensuring consistent performance as the system grows. 26 | 27 | 3. **Multi-Rewards with Rolling Rewarders**: The V2 update now enables the possibility of multiple rewards with the rolling rewarders. 28 | 29 | 4. **ABI Simplification**: We have simplified ABI to streamline the interaction between the smart contracts and the user interface. 30 | 31 | 5. **Gas Optimization**: The V2 update brings a 20% reduction in gas consumption, resulting in lower transaction fees and improved efficiency. 32 | 33 | 6. **Bug Fixes**: We have addressed bugs identified in the previous version (see audit/ for more details). 34 | 35 | 7. **Code Clean-up, Formatting, and Normalization**: The codebase has undergone a thorough clean-up, formatting, and normalization process to improve readability and maintainability. 36 | 37 | ## Installation 38 | 39 | This is a Foundry project. Get Foundry from [here](https://github.com/foundry-rs/foundry). 40 | 41 | Please run the following command in this project's root directory to enable pre-commit testing: 42 | 43 | ```bash 44 | ln -s ../../pre-commit .git/hooks/pre-commit 45 | ``` 46 | 47 | ## Quick start 48 | 49 | ### Env setup 50 | ```bash 51 | mv .env.example .env 52 | ``` 53 | Fill your `ETHERSCAN_API_KEY` in the `.env`. 54 | 55 | ### Foundry 56 | ```bash 57 | forge install 58 | forge test 59 | ``` 60 | 61 | ### Echidna 62 | ```bash 63 | echidna test/echidna/ReliquaryProperties.sol --contract ReliquaryProperties --config test/echidna/config1_fast.yaml 64 | ``` 65 | 66 | ## Typing conventions 67 | 68 | ### Variables 69 | 70 | - storage: `x` 71 | - memory/stack: `x_` 72 | - function params: `_x` 73 | - contracts/events/structs: `MyContract` 74 | - errors: `MyContract__ERROR_DESCRIPTION` 75 | - public/external functions: `myFunction()` 76 | - internal/private functions: `_myFunction()` 77 | - comments: "This is a comment to describe the variable `amount`." 78 | 79 | ### Nat Specs 80 | 81 | ```js 82 | /** 83 | * @dev Internal function called whenever a position's state needs to be modified. 84 | * @param _amount Amount of poolToken to deposit/withdraw. 85 | * @param _relicId The NFT ID of the position being updated. 86 | * @param _kind Indicates whether tokens are being added to, or removed from, a pool. 87 | * @param _harvestTo Address to send rewards to (zero address if harvest should not be performed). 88 | * @return poolId_ Pool ID of the given position. 89 | * @return received_ Amount of reward token dispensed to `_harvestTo` on harvest. 90 | */ 91 | ``` 92 | 93 | ### Formating 94 | 95 | Please use `forge fmt` before commiting. 96 | 97 | ## TODOs 98 | 99 | - NFT Desccriptor needs to be ajusted to curve 100 | -------------------------------------------------------------------------------- /test/foundry/DepositHelperERC4626.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity 0.8.23; 3 | 4 | import "forge-std/Test.sol"; 5 | import "./mocks/ERC20Mock.sol"; 6 | import "openzeppelin-contracts/contracts/mocks/token/ERC4626Mock.sol"; 7 | import "openzeppelin-contracts/contracts/token/ERC721/utils/ERC721Holder.sol"; 8 | import {WETH} from "solmate/tokens/WETH.sol"; 9 | import "contracts/helpers/DepositHelperERC4626.sol"; 10 | import "contracts/nft_descriptors/NFTDescriptor.sol"; 11 | import "contracts/Reliquary.sol"; 12 | import "contracts/curves/LinearCurve.sol"; 13 | 14 | contract DepositHelperERC4626Test is ERC721Holder, Test { 15 | DepositHelperERC4626 helper; 16 | Reliquary reliquary; 17 | IERC4626 vault; 18 | ERC20Mock oath; 19 | WETH weth; 20 | LinearCurve linearCurve; 21 | uint256 emissionRate = 1e17; 22 | 23 | // Linear function config (to config) 24 | uint256 slope = 100; // Increase of multiplier every second 25 | uint256 minMultiplier = 365 days * 100; // Arbitrary (but should be coherent with slope) 26 | 27 | receive() external payable {} 28 | 29 | function setUp() public { 30 | oath = new ERC20Mock(18); 31 | reliquary = new Reliquary(address(oath), 1e17, "Reliquary Deposit", "RELIC"); 32 | 33 | weth = new WETH(); 34 | vault = new ERC4626Mock(address(weth)); 35 | linearCurve = new LinearCurve(slope, minMultiplier); 36 | 37 | address nftDescriptor = address(new NFTDescriptor(address(reliquary))); 38 | reliquary.grantRole(keccak256("OPERATOR"), address(this)); 39 | 40 | weth.deposit{value: 1_000_000 ether}(); 41 | 42 | helper = new DepositHelperERC4626(reliquary, address(weth)); 43 | 44 | weth.approve(address(helper), type(uint256).max); 45 | helper.reliquary().setApprovalForAll(address(helper), true); 46 | deal(address(vault), address(this), 1); 47 | vault.approve(address(reliquary), 1); // approve 1 wei to bootstrap the pool 48 | reliquary.addPool( 49 | 1000, 50 | address(vault), 51 | address(0), 52 | linearCurve, 53 | "ETH Crypt", 54 | nftDescriptor, 55 | true, 56 | address(this) 57 | ); 58 | } 59 | 60 | function testCreateNew(uint256 amount, bool depositETH) public { 61 | amount = bound(amount, 10, weth.balanceOf(address(this))); 62 | uint256 relicId = helper.createRelicAndDeposit{value: depositETH ? amount : 0}(0, amount); 63 | 64 | assertEq(reliquary.balanceOf(address(this)), 2, "no Relic given"); 65 | assertEq( 66 | reliquary.getPositionForId(relicId).amount, 67 | vault.convertToShares(amount), 68 | "deposited amount not expected amount" 69 | ); 70 | } 71 | 72 | function testDepositExisting(uint256 amountA, uint256 amountB, bool aIsETH, bool bIsETH) 73 | public 74 | { 75 | amountA = bound(amountA, 10, 500_000 ether); 76 | amountB = bound(amountB, 10, 1_000_000 ether - amountA); 77 | 78 | uint256 relicId = helper.createRelicAndDeposit{value: aIsETH ? amountA : 0}(0, amountA); 79 | helper.deposit{value: bIsETH ? amountB : 0}(amountB, relicId, false); 80 | 81 | uint256 relicAmount = reliquary.getPositionForId(relicId).amount; 82 | uint256 expectedAmount = vault.convertToShares(amountA + amountB); 83 | assertApproxEqAbs(expectedAmount, relicAmount, 1); 84 | } 85 | 86 | function testRevertOnDepositUnauthorized() public { 87 | uint256 relicId = helper.createRelicAndDeposit(0, 1); 88 | vm.expectRevert(bytes("not approved or owner")); 89 | vm.prank(address(1)); 90 | helper.deposit(1, relicId, false); 91 | } 92 | 93 | function testWithdraw(uint256 amount, bool harvest, bool depositETH, bool withdrawETH) public { 94 | uint256 ethInitialBalance = address(this).balance; 95 | uint256 wethInitialBalance = weth.balanceOf(address(this)); 96 | amount = bound(amount, 10, wethInitialBalance); 97 | 98 | uint256 relicId = helper.createRelicAndDeposit{value: depositETH ? amount : 0}(0, amount); 99 | helper.withdraw(amount, relicId, harvest, withdrawETH); 100 | 101 | uint256 difference; 102 | if (depositETH && withdrawETH) { 103 | difference = ethInitialBalance - address(this).balance; 104 | } else if (depositETH && !withdrawETH) { 105 | difference = weth.balanceOf(address(this)) - wethInitialBalance; 106 | } else if (!depositETH && withdrawETH) { 107 | difference = address(this).balance - ethInitialBalance; 108 | } else { 109 | difference = wethInitialBalance - weth.balanceOf(address(this)); 110 | } 111 | 112 | uint256 expectedDifference = (depositETH == withdrawETH) ? 0 : amount; 113 | assertApproxEqAbs(difference, expectedDifference, 10); 114 | } 115 | 116 | function testRevertOnWithdrawUnauthorized(bool harvest) public { 117 | uint256 relicId = helper.createRelicAndDeposit(0, 1); 118 | vm.expectRevert(bytes("not approved or owner")); 119 | vm.prank(address(1)); 120 | helper.withdraw(1, relicId, harvest, false); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /contracts/interfaces/IReliquary.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity 0.8.23; 3 | 4 | import "contracts/interfaces/ICurves.sol"; 5 | import "lib/openzeppelin-contracts/contracts/token/ERC721/IERC721.sol"; 6 | 7 | /// @dev Level of precision rewards are calculated to. 8 | uint256 constant ACC_REWARD_PRECISION = 1e41; 9 | /// @dev Max supply allowed for checks purpose. 10 | uint256 constant MAX_SUPPLY_ALLOWED = 100e9 ether; 11 | 12 | /// @dev Indicates whether tokens are being added to, or removed from, a pool. 13 | enum Kind { 14 | DEPOSIT, 15 | WITHDRAW, 16 | UPDATE 17 | } 18 | 19 | /** 20 | * @notice Info for each Reliquary position. 21 | * @dev 3 storage slots 22 | * `rewardDebt` Amount of reward token accumalated before the position's entry or last harvest. 23 | * `rewardCredit` Amount of reward token owed to the user on next harvest. 24 | * `amount` LP token amount the position owner has provided. 25 | * `entry` Used to determine the maturity of the position, position owner's relative entry into the pool. 26 | * `level` Index of this position's level within the pool's array of levels, ensures that a single Relic is only used for one pool. 27 | * `poolId` ID of the pool to which this position belongs. 28 | */ 29 | struct PositionInfo { 30 | uint256 rewardDebt; 31 | uint256 rewardCredit; 32 | uint128 amount; 33 | uint40 entry; 34 | uint40 level; 35 | uint8 poolId; 36 | } 37 | 38 | /** 39 | * @notice Info of each Reliquary pool. 40 | * @dev 7 storage slots 41 | * `name` Name of pool to be displayed in NFT image. 42 | * `accRewardPerShare` Accumulated reward tokens per share of pool (1 / ACC_REWARD_PRECISION). 43 | * `totalLpSupplied` Total number of LPs in the pool. 44 | * `nftDescriptor` The nft descriptor address. 45 | * `rewarder` The nft rewarder address. 46 | * `poolToken` ERC20 token supplied. 47 | * `lastRewardTime` Last timestamp the accumulated reward was updated. 48 | * `allowPartialWithdrawals` Whether users can withdraw less than their entire position. 49 | * `allocPoint` Pool's individual allocation - ratio of the total allocation. 50 | * `curve` Contract that define the function: f(maturity) = multiplier. 51 | * A value of false will also disable shift and split functionality. 52 | */ 53 | struct PoolInfo { 54 | string name; 55 | uint256 accRewardPerShare; 56 | uint256 totalLpSupplied; 57 | address nftDescriptor; 58 | address rewarder; 59 | address poolToken; 60 | uint40 lastRewardTime; 61 | bool allowPartialWithdrawals; 62 | uint96 allocPoint; 63 | ICurves curve; 64 | } 65 | 66 | interface IReliquary is IERC721 { 67 | // Errors 68 | error Reliquary__BURNING_PRINCIPAL(); 69 | error Reliquary__BURNING_REWARDS(); 70 | error Reliquary__REWARD_TOKEN_AS_POOL_TOKEN(); 71 | error Reliquary__TOKEN_NOT_COMPATIBLE(); 72 | error Reliquary__ZERO_TOTAL_ALLOC_POINT(); 73 | error Reliquary__NON_EXISTENT_POOL(); 74 | error Reliquary__ZERO_INPUT(); 75 | error Reliquary__NOT_OWNER(); 76 | error Reliquary__DUPLICATE_RELIC_IDS(); 77 | error Reliquary__RELICS_NOT_OF_SAME_POOL(); 78 | error Reliquary__MERGING_EMPTY_RELICS(); 79 | error Reliquary__NOT_APPROVED_OR_OWNER(); 80 | error Reliquary__PARTIAL_WITHDRAWALS_DISABLED(); 81 | error Reliquary__MULTIPLIER_AT_LEVEL_ZERO_SHOULD_BE_GT_ZERO(); 82 | error Reliquary__REWARD_PRECISION_ISSUE(); 83 | error Reliquary__CURVE_OVERFLOW(); 84 | 85 | function setEmissionRate(uint256 _emissionRate) external; 86 | 87 | function addPool( 88 | uint256 _allocPoint, 89 | address _poolToken, 90 | address _rewarder, 91 | ICurves _curve, 92 | string memory _name, 93 | address _nftDescriptor, 94 | bool _allowPartialWithdrawals, 95 | address _to 96 | ) external; 97 | 98 | function modifyPool( 99 | uint8 _poolId, 100 | uint256 _allocPoint, 101 | address _rewarder, 102 | string calldata _name, 103 | address _nftDescriptor, 104 | bool _overwriteRewarder 105 | ) external; 106 | 107 | function massUpdatePools() external; 108 | 109 | function updatePool(uint8 _poolId) external; 110 | 111 | function deposit(uint256 _amount, uint256 _relicId, address _harvestTo) external; 112 | 113 | function withdraw(uint256 _amount, uint256 _relicId, address _harvestTo) external; 114 | 115 | function update(uint256 _relicId, address _harvestTo) external; 116 | 117 | function emergencyWithdraw(uint256 _relicId) external; 118 | 119 | function poolLength() external view returns (uint256 pools_); 120 | 121 | function getPositionForId(uint256 _posId) external view returns (PositionInfo memory); 122 | 123 | function getPoolInfo(uint8 _poolId) external view returns (PoolInfo memory); 124 | 125 | function getTotalLpSupplied(uint8 _poolId) external view returns (uint256 lp_); 126 | 127 | function isApprovedOrOwner(address, uint256) external view returns (bool); 128 | 129 | function createRelicAndDeposit(address _to, uint8 _poolId, uint256 _amount) 130 | external 131 | returns (uint256 newRelicId_); 132 | 133 | function split(uint256 _relicId, uint256 _amount, address _to) 134 | external 135 | returns (uint256 newRelicId_); 136 | 137 | function shift(uint256 _fromId, uint256 _toId, uint256 _amount) external; 138 | 139 | function merge(uint256 _fromId, uint256 _toId) external; 140 | 141 | function burn(uint256 _tokenId) external; 142 | 143 | function pendingReward(uint256 _relicId) external view returns (uint256 pending_); 144 | 145 | function rewardToken() external view returns (address); 146 | 147 | function emissionRate() external view returns (uint256); 148 | 149 | function totalAllocPoint() external view returns (uint256); 150 | } 151 | -------------------------------------------------------------------------------- /contracts/helpers/DepositHelperERC4626.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity 0.8.23; 3 | 4 | import "openzeppelin-contracts/contracts/access/Ownable.sol"; 5 | import "openzeppelin-contracts/contracts/interfaces/IERC4626.sol"; 6 | import "openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol"; 7 | import {IReliquary, PositionInfo} from "../interfaces/IReliquary.sol"; 8 | 9 | interface IWeth is IERC20 { 10 | function deposit() external payable; 11 | 12 | function withdraw(uint256 _amount) external; 13 | } 14 | 15 | /// @title Helper contract that allows depositing to and withdrawing from Reliquary pools of an ERC4626 vault in a 16 | /// single transaction using the vault's underlying asset. 17 | contract DepositHelperERC4626 is Ownable { 18 | using Address for address payable; 19 | using SafeERC20 for IERC20; 20 | 21 | IReliquary public immutable reliquary; 22 | IWeth public immutable weth; 23 | 24 | constructor(IReliquary _reliquary, address _weth) Ownable(msg.sender) { 25 | reliquary = _reliquary; 26 | weth = IWeth(_weth); 27 | } 28 | 29 | receive() external payable {} 30 | 31 | /// @notice Deposit `_amount` of ERC20 tokens (or native ether for a supported pool) into existing Relic `_relicId`. 32 | function deposit(uint256 _amount, uint256 _relicId, bool _harvest) external payable { 33 | _requireApprovedOrOwner(_relicId); 34 | 35 | uint256 shares_ = _prepareDeposit(reliquary.getPositionForId(_relicId).poolId, _amount); 36 | reliquary.deposit(shares_, _relicId, _harvest ? msg.sender : address(0)); 37 | } 38 | 39 | /// @notice Send `_amount` of ERC20 tokens (or native ether for a supported pool) and create a new Relic in pool `_pid`. 40 | function createRelicAndDeposit(uint8 _pid, uint256 _amount) 41 | external 42 | payable 43 | returns (uint256 relicId_) 44 | { 45 | uint256 shares_ = _prepareDeposit(_pid, _amount); 46 | relicId_ = reliquary.createRelicAndDeposit(msg.sender, _pid, shares_); 47 | } 48 | 49 | /** 50 | * @notice Withdraw underlying tokens from the Relic. 51 | * @param _amount Amount of underlying token to withdraw. 52 | * @param _relicId The NFT ID of the Relic for the position you are withdrawing from. 53 | * @param _harvest Whether to also harvest pending rewards to `msg.sender`. 54 | * @param _giveEther Whether to withdraw the underlying tokens as native ether instead of wrapped. 55 | * Only for supported pools. 56 | */ 57 | function withdraw(uint256 _amount, uint256 _relicId, bool _harvest, bool _giveEther) external { 58 | (, IERC4626 vault_) = _prepareWithdrawal(_relicId); 59 | _withdraw(vault_, vault_.convertToShares(_amount), _relicId, _harvest, _giveEther); 60 | } 61 | 62 | /** 63 | * @notice Withdraw all underlying tokens and rewards from the Relic. 64 | * @param _relicId The NFT ID of the Relic for the position you are withdrawing from. 65 | * @param _giveEther Whether to withdraw the underlying tokens as native ether instead of wrapped. 66 | * @param _burn Whether to burn the empty Relic. 67 | * Only for supported pools. 68 | */ 69 | function withdrawAllAndHarvest(uint256 _relicId, bool _giveEther, bool _burn) external { 70 | (PositionInfo memory position_, IERC4626 vault_) = _prepareWithdrawal(_relicId); 71 | _withdraw(vault_, position_.amount, _relicId, true, _giveEther); 72 | if (_burn) { 73 | reliquary.burn(_relicId); 74 | } 75 | } 76 | 77 | /// @notice Owner may send tokens out of this contract since none should be held here. Do not send tokens manually. 78 | function rescueFunds(address _token, address _to, uint256 _amount) external onlyOwner { 79 | if (_token == address(0)) { 80 | payable(_to).sendValue(_amount); 81 | } else { 82 | IERC20(_token).safeTransfer(_to, _amount); 83 | } 84 | } 85 | 86 | function _prepareDeposit(uint8 _pid, uint256 _amount) internal returns (uint256 shares_) { 87 | IERC4626 vault_ = IERC4626(reliquary.getPoolInfo(_pid).poolToken); 88 | IERC20 token_ = IERC20(vault_.asset()); 89 | if (msg.value != 0) { 90 | require(_amount == msg.value, "ether amount mismatch"); 91 | require(address(token_) == address(weth), "not an ether vault"); 92 | weth.deposit{value: msg.value}(); 93 | } else { 94 | token_.safeTransferFrom(msg.sender, address(this), _amount); 95 | } 96 | 97 | if (token_.allowance(address(this), address(vault_)) == 0) { 98 | token_.approve(address(vault_), type(uint256).max); 99 | } 100 | shares_ = vault_.deposit(_amount, address(this)); 101 | 102 | if (vault_.allowance(address(this), address(reliquary)) == 0) { 103 | vault_.approve(address(reliquary), type(uint256).max); 104 | } 105 | } 106 | 107 | function _prepareWithdrawal(uint256 _relicId) 108 | internal 109 | view 110 | returns (PositionInfo memory position_, IERC4626 vault_) 111 | { 112 | _requireApprovedOrOwner(_relicId); 113 | 114 | position_ = reliquary.getPositionForId(_relicId); 115 | vault_ = IERC4626(reliquary.getPoolInfo(position_.poolId).poolToken); 116 | } 117 | 118 | function _withdraw( 119 | IERC4626 _vault, 120 | uint256 _amount, 121 | uint256 _relicId, 122 | bool _harvest, 123 | bool _giveEther 124 | ) internal { 125 | reliquary.withdraw(_amount, _relicId, _harvest ? msg.sender : address(0)); 126 | 127 | if (_giveEther) { 128 | require(_vault.asset() == address(weth), "not an ether vault"); 129 | uint256 amountETH = _vault.maxWithdraw(address(this)); 130 | _vault.withdraw(amountETH, address(this), address(this)); 131 | weth.withdraw(amountETH); 132 | payable(msg.sender).sendValue(amountETH); 133 | } else { 134 | _vault.withdraw(_vault.maxWithdraw(address(this)), msg.sender, address(this)); 135 | } 136 | } 137 | 138 | function _requireApprovedOrOwner(uint256 _relicId) internal view { 139 | require(reliquary.isApprovedOrOwner(msg.sender, _relicId), "not approved or owner"); 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /contracts/nft_descriptors/NFTDescriptor.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity 0.8.23; 3 | 4 | import "openzeppelin-contracts/contracts/utils/Strings.sol"; 5 | import "openzeppelin-contracts/contracts/token/ERC20/extensions/IERC20Metadata.sol"; 6 | import "base64/base64.sol"; 7 | import "../interfaces/INFTDescriptor.sol"; 8 | import "../interfaces/IReliquary.sol"; 9 | 10 | contract NFTDescriptor is INFTDescriptor { 11 | using Strings for uint256; 12 | 13 | address public immutable reliquary; 14 | 15 | constructor(address _reliquary) { 16 | reliquary = _reliquary; 17 | } 18 | 19 | struct LocalVariables_constructTokenURI { 20 | address underlying; 21 | string amount; 22 | string pendingReward; 23 | uint256 maturity; 24 | string rewardSymbol; 25 | string description; 26 | string attributes; 27 | } 28 | 29 | /// @notice Generate tokenURI as a base64 encoding from live on-chain values. 30 | function constructTokenURI(uint256 relicId) 31 | external 32 | view 33 | override 34 | returns (string memory uri) 35 | { 36 | IReliquary _reliquary = IReliquary(reliquary); 37 | PositionInfo memory position = _reliquary.getPositionForId(relicId); 38 | PoolInfo memory pool = _reliquary.getPoolInfo(position.poolId); 39 | LocalVariables_constructTokenURI memory vars; 40 | vars.underlying = address(_reliquary.getPoolInfo(position.poolId).poolToken); 41 | vars.amount = 42 | generateDecimalString(position.amount, IERC20Metadata(vars.underlying).decimals()); 43 | vars.pendingReward = generateDecimalString(_reliquary.pendingReward(relicId), 18); 44 | vars.maturity = (block.timestamp - position.entry) / 1 days; 45 | vars.rewardSymbol = IERC20Metadata(address(_reliquary.rewardToken())).symbol(); 46 | 47 | vars.description = generateDescription(pool.name); 48 | vars.attributes = generateAttributes( 49 | position, vars.amount, vars.pendingReward, vars.rewardSymbol, vars.maturity 50 | ); 51 | 52 | uri = string.concat( 53 | "data:application/json;base64,", 54 | Base64.encode( 55 | bytes( 56 | abi.encodePacked( 57 | '{"name":"', 58 | string.concat("Relic #", relicId.toString(), ": ", pool.name), 59 | '", "description":"', 60 | vars.description, 61 | '", "attributes": [', 62 | vars.attributes, 63 | "]}" 64 | ) 65 | ) 66 | ) 67 | ); 68 | } 69 | 70 | /// @notice Generate description of the liquidity position for NFT metadata. 71 | /// @param poolName Name of pool as provided by operator. 72 | function generateDescription(string memory poolName) 73 | internal 74 | pure 75 | returns (string memory description) 76 | { 77 | description = string.concat( 78 | "This NFT represents a position in a Reliquary ", 79 | poolName, 80 | " pool. ", 81 | "The owner of this NFT can modify or redeem the position." 82 | ); 83 | } 84 | 85 | /** 86 | * @notice Generate attributes for NFT metadata. 87 | * @param position Position represented by this Relic. 88 | * @param pendingReward Amount of reward token that can currently be harvested from this position. 89 | * @param maturity Weighted average of the maturity deposits into this position. 90 | */ 91 | function generateAttributes( 92 | PositionInfo memory position, 93 | string memory amount, 94 | string memory pendingReward, 95 | string memory rewardSymbol, 96 | uint256 maturity 97 | ) internal pure returns (string memory attributes) { 98 | attributes = string.concat( 99 | '{"trait_type": "Pool ID", "value": ', 100 | uint256(position.poolId).toString(), 101 | '}, {"trait_type": "Amount Deposited", "value": "', 102 | amount, 103 | '"}, {"trait_type": "Pending ', 104 | rewardSymbol, 105 | '", "value": "', 106 | pendingReward, 107 | '"}, {"trait_type": "Maturity", "value": "', 108 | maturity.toString(), 109 | " day", 110 | (maturity == 1) ? "" : "s", 111 | '"}, {"trait_type": "Level", "value": ', 112 | uint256(position.level + 1).toString(), 113 | "}" 114 | ); 115 | } 116 | 117 | /** 118 | * @notice Generate human-readable string from a number with given decimal places. 119 | * Does not work for amounts with more than 18 digits before decimal point. 120 | * @param num A number. 121 | * @param decimals Number of decimal places. 122 | */ 123 | function generateDecimalString(uint256 num, uint256 decimals) 124 | internal 125 | pure 126 | returns (string memory decString) 127 | { 128 | if (num == 0) { 129 | return "0"; 130 | } 131 | 132 | uint256 numLength; 133 | uint256 result; 134 | do { 135 | result = num / 10 ** (++numLength); 136 | } while (result != 0); 137 | 138 | bool lessThanOne = numLength <= decimals; 139 | uint256 bufferLength; 140 | if (lessThanOne) { 141 | bufferLength = decimals + 2; 142 | } else if (numLength > 19) { 143 | uint256 difference = numLength - 19; 144 | decimals -= difference > decimals ? decimals : difference; 145 | num /= 10 ** difference; 146 | bufferLength = 20; 147 | } else { 148 | bufferLength = numLength + 1; 149 | } 150 | bytes memory buffer = new bytes(bufferLength); 151 | 152 | if (lessThanOne) { 153 | buffer[0] = "0"; 154 | buffer[1] = "."; 155 | for (uint256 i = 0; i < decimals - numLength; i++) { 156 | buffer[i + 2] = "0"; 157 | } 158 | } 159 | uint256 index = bufferLength - 1; 160 | while (num != 0) { 161 | if (!lessThanOne && index == bufferLength - decimals - 1) { 162 | buffer[index--] = "."; 163 | } 164 | buffer[index] = bytes1(uint8(48 + (num % 10))); 165 | num /= 10; 166 | unchecked { 167 | index--; 168 | } 169 | } 170 | 171 | decString = string(buffer); 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /scripts/deploy_conf_example.txt: -------------------------------------------------------------------------------- 1 | ######################################################################################################################## 2 | # # 3 | # Below is example content for the config_deploy.json file. It is very important to configure each option properly, as # 4 | # some properties are immutable once on chain. DO NOT INCLUDE THIS COMMENT BLOCK IN THE JSON FILE. # 5 | # # 6 | # `name`: Name to use for the ERC721 token representing a user's position. # 7 | # `symbol`: Symbol to use for the ERC721 token representing a user's position. # 8 | # # 9 | # `weth`: Address of the wrapped native token contract for the chain you are deploying to. # 10 | # # 11 | # `multisig`: Address to transfer admin roles to at end of script. Leaving set to address zero will skip this step, # 12 | # leaving the deployer's address as the admin. # 13 | # `rewardToken`: Address of the token this Reliquary deployment is emitting. # 14 | # `emissionRate`: Emission rate of `rewardToken` from the Reliquary each second. # 15 | # # 16 | # `pools`: May be of any length (or empty). Each member must have each property defined in the correct order shown. # 17 | # `allocPoint`: Amount of allocation points for this pool. The pool will receive emissions proportional to the # 18 | # total number of allocation points for all pools. # 19 | # `allowPartialWithdrawals`: hether users can withdraw less than their entire position. A value of false will also # 20 | # disable shift and split functionality. This is useful for adding pools with decreasing levelMultipliers. # 21 | # `curveIndex`: Index of curve in linearCurves or linearPlateauCurves array. # 22 | # `curveType`: Type of curve for this pool (linearCurve or linearPlateauCurve) # 23 | # `name`: Name of the pool. # 24 | # `poolToken`: Address for the token this pool takes for deposits. # 25 | # `tokenType`: Type of NFTDescriptor to use for this token. Valid values are "normal", "4626", and "pair". Use # 26 | # "4626" if `poolToken` is an ERC4626 contract, and "pair" if it is a UniswapV2Pair. # 27 | # # 28 | # `linearCurves`: May be of any lenght (or empty). Each member must have each property defined in the correct order. # 29 | # `minMultiplier`: # 30 | # `slope`: # 31 | # `linearPlateauCurves`: May be of any lenght (or empty). Each member must have each property defined in the correct # 32 | # order shown. # 33 | # `minMultiplier`: # 34 | # `plateauLevel`: # 35 | # `slope`: # 36 | # # 37 | # `parentRewarders`: May be of any length (or empty). Each member must have each property defined in the correct order.# 38 | # `poolId`: The Reliquary poolId (index of `pools` array) this parent manages rewards for. # 39 | # `childRewarders`: May be of any length (or empty). Each member must have each property defined in the correct order. # 40 | # `parentIndex`: Index of this rewarder's parent in the array of `parentRewarders`. # 41 | # `rewarderToken`: Address of the token this rewarder is emitting. # 42 | # # 43 | ######################################################################################################################## 44 | 45 | { 46 | "name": "Reliquary Deposit", 47 | "symbol": "RELIC", 48 | 49 | "weth": "0x21be370D5312f44cB42ce377BC9b8a0cEF1A4C83", 50 | 51 | "multisig": "0x0000000000000000000000000000000000000000", 52 | "rewardToken": "0x21Ada0D2aC28C3A5Fa3cD2eE30882dA8812279B6", 53 | "emissionRate": "10000000000", 54 | 55 | "pools": [ 56 | { 57 | "allocPoint": 100, 58 | "allowPartialWithdrawals": true, 59 | "curveIndex": 0, 60 | "curveType": "linearPlateauCurve", 61 | "name": "USDC Optimizer", 62 | "poolToken": "0x3d34C680428F05C185ee692A6fA677a494fB787A", 63 | "tokenType": "4626" 64 | }, 65 | { 66 | "allocPoint": 50, 67 | "allowPartialWithdrawals": true, 68 | "curveIndex": 0, 69 | "curveType": "linearPlateauCurve", 70 | "name": "ETH Optimizer", 71 | "poolToken": "0x00764a204165db75CC4f7c50CdC7A409b14F995d", 72 | "tokenType": "4626" 73 | } 74 | ], 75 | 76 | "parentRewarders": [ 77 | { 78 | "poolId": 0 79 | } 80 | ], 81 | 82 | "childRewarders": [ 83 | { 84 | "parentIndex": 0, 85 | "rewarderToken": "0x321162Cd933E2Be498Cd2267a90534A804051b11" 86 | } 87 | ], 88 | 89 | "linearCurves": [ 90 | ], 91 | 92 | "linearPlateauCurves": [ 93 | { 94 | "minMultiplier": 31536000, 95 | "plateauLevel": 31536000, 96 | "slope": 1 97 | } 98 | ] 99 | } 100 | -------------------------------------------------------------------------------- /contracts/helpers/DepositHelperReaperVault.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity 0.8.23; 3 | 4 | import "openzeppelin-contracts/contracts/access/Ownable.sol"; 5 | import "openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol"; 6 | import {IReliquary, PositionInfo} from "../interfaces/IReliquary.sol"; 7 | 8 | interface IReaperVault is IERC20 { 9 | function decimals() external view returns (uint8); 10 | 11 | function deposit(uint256 amount) external; 12 | 13 | function getPricePerFullShare() external view returns (uint256); 14 | 15 | function token() external view returns (IERC20); 16 | 17 | function withdrawAll() external; 18 | } 19 | 20 | interface IWeth is IERC20 { 21 | function deposit() external payable; 22 | 23 | function withdraw(uint256 amount) external; 24 | } 25 | 26 | /// @title Helper contract that allows depositing to and withdrawing from Reliquary pools of a Reaper vault in a 27 | /// single transaction using the vault's underlying asset. 28 | contract DepositHelperReaperVault is Ownable { 29 | using Address for address payable; 30 | using SafeERC20 for IERC20; 31 | 32 | IReliquary public immutable reliquary; 33 | IWeth public immutable weth; 34 | 35 | constructor(IReliquary _reliquary, address _weth) Ownable(msg.sender) { 36 | reliquary = _reliquary; 37 | weth = IWeth(_weth); 38 | } 39 | 40 | receive() external payable {} 41 | 42 | /// @notice Deposit `_amount` of ERC20 tokens (or native ether for a supported pool) into existing Relic `_relicId`. 43 | function deposit(uint256 _amount, uint256 _relicId, bool _harvest) 44 | external 45 | payable 46 | returns (uint256 shares_) 47 | { 48 | _requireApprovedOrOwner(_relicId); 49 | shares_ = _prepareDeposit(reliquary.getPositionForId(_relicId).poolId, _amount); 50 | reliquary.deposit(shares_, _relicId, _harvest ? msg.sender : address(0)); 51 | } 52 | 53 | /// @notice Send `_amount` of ERC20 tokens (or native ether for a supported pool) and create a new Relic in pool `_pid`. 54 | function createRelicAndDeposit(uint8 _pid, uint256 _amount) 55 | external 56 | payable 57 | returns (uint256 relicId_, uint256 shares_) 58 | { 59 | shares_ = _prepareDeposit(_pid, _amount); 60 | relicId_ = reliquary.createRelicAndDeposit(msg.sender, _pid, shares_); 61 | } 62 | 63 | /** 64 | * @notice Withdraw underlying tokens from the Relic. 65 | * @param _amount Amount of underlying token to withdraw. 66 | * @param _relicId The NFT ID of the Relic for the position you are withdrawing from. 67 | * @param _harvest Whether to also harvest pending rewards to `msg.sender`. 68 | * @param _giveEther Whether to withdraw the underlying tokens as native ether instead of wrapped. 69 | * Only for supported pools. 70 | */ 71 | function withdraw(uint256 _amount, uint256 _relicId, bool _harvest, bool _giveEther) external { 72 | _withdraw(_amount, _relicId, _harvest, _giveEther); 73 | } 74 | 75 | /** 76 | * @notice Withdraw all underlying tokens and rewards from the Relic. 77 | * @param _relicId The NFT ID of the Relic for the position you are withdrawing from. 78 | * @param _giveEther Whether to withdraw the underlying tokens as native ether instead of wrapped. 79 | * @param _burn Whether to burn the empty Relic. 80 | * Only for supported pools. 81 | */ 82 | function withdrawAllAndHarvest(uint256 _relicId, bool _giveEther, bool _burn) external { 83 | _withdraw(type(uint256).max, _relicId, true, _giveEther); 84 | if (_burn) { 85 | reliquary.burn(_relicId); 86 | } 87 | } 88 | 89 | /// @notice Owner may send tokens out of this contract since none should be held here. Do not send tokens manually. 90 | function rescueFunds(address _token, address _to, uint256 _amount) external onlyOwner { 91 | if (_token == address(0)) { 92 | payable(_to).sendValue(_amount); 93 | } else { 94 | IERC20(_token).safeTransfer(_to, _amount); 95 | } 96 | } 97 | 98 | function _prepareDeposit(uint8 _pid, uint256 _amount) internal returns (uint256 shares_) { 99 | IReaperVault vault_ = IReaperVault(reliquary.getPoolInfo(_pid).poolToken); 100 | IERC20 token_ = vault_.token(); 101 | 102 | if (msg.value != 0) { 103 | require(_amount == msg.value, "ether amount mismatch"); 104 | require(address(token_) == address(weth), "not an ether vault"); 105 | weth.deposit{value: msg.value}(); 106 | } else { 107 | token_.safeTransferFrom(msg.sender, address(this), _amount); 108 | } 109 | 110 | if (token_.allowance(address(this), address(vault_)) == 0) { 111 | token_.approve(address(vault_), type(uint256).max); 112 | } 113 | 114 | uint256 initialShares_ = vault_.balanceOf(address(this)); 115 | vault_.deposit(_amount); 116 | shares_ = vault_.balanceOf(address(this)) - initialShares_; 117 | 118 | if (vault_.allowance(address(this), address(reliquary)) == 0) { 119 | vault_.approve(address(reliquary), type(uint256).max); 120 | } 121 | } 122 | 123 | function _withdraw(uint256 _amount, uint256 _relicId, bool _harvest, bool _giveEther) 124 | internal 125 | { 126 | _requireApprovedOrOwner(_relicId); 127 | 128 | PositionInfo memory position_ = reliquary.getPositionForId(_relicId); 129 | IReaperVault vault_ = IReaperVault(reliquary.getPoolInfo(position_.poolId).poolToken); 130 | uint256 shares_; 131 | if (_amount == type(uint256).max) { 132 | shares_ = position_.amount; 133 | } else { 134 | shares_ = (_amount * 10 ** vault_.decimals()) / vault_.getPricePerFullShare(); 135 | if (shares_ > position_.amount) { 136 | require( 137 | shares_ < position_.amount + position_.amount / 1000, 138 | "too much imprecision in share price" 139 | ); 140 | shares_ = position_.amount; 141 | } 142 | } 143 | 144 | reliquary.withdraw(shares_, _relicId, _harvest ? msg.sender : address(0)); 145 | 146 | IERC20 token_ = vault_.token(); 147 | uint256 initialBalance_ = token_.balanceOf(address(this)); 148 | vault_.withdrawAll(); 149 | uint256 balance_ = token_.balanceOf(address(this)) - initialBalance_; 150 | 151 | if (_giveEther) { 152 | require(vault_.token() == weth, "not an ether vault"); 153 | weth.withdraw(balance_); 154 | payable(msg.sender).sendValue(balance_); 155 | } else { 156 | vault_.token().safeTransfer(msg.sender, balance_); 157 | } 158 | } 159 | 160 | function _requireApprovedOrOwner(uint256 _relicId) internal view { 161 | require(reliquary.isApprovedOrOwner(msg.sender, _relicId), "not approved or owner"); 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /test/foundry/DepositHelperReaperBPT.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity 0.8.23; 3 | 4 | import "forge-std/Test.sol"; 5 | import "openzeppelin-contracts/contracts/token/ERC721/utils/ERC721Holder.sol"; 6 | import "contracts/helpers/DepositHelperReaperBPT.sol"; 7 | import "contracts/nft_descriptors/NFTDescriptor.sol"; 8 | import "contracts/Reliquary.sol"; 9 | import "contracts/curves/LinearCurve.sol"; 10 | 11 | interface IReaperVaultTest is IReaperVault { 12 | function balance() external view returns (uint256); 13 | } 14 | 15 | interface IReZapTest is IReZap { 16 | function findStepsIn(address zapInToken, address BPT, uint256 tokenInAmount) 17 | external 18 | returns (Step[] memory); 19 | 20 | function findStepsOut(address zapOutToken, address BPT, uint256 bptAmount) 21 | external 22 | returns (Step[] memory); 23 | } 24 | 25 | interface IWftm is IERC20 { 26 | function deposit() external payable returns (uint256); 27 | } 28 | 29 | contract DepositHelperReaperBPTTest is ERC721Holder, Test { 30 | DepositHelperReaperBPT helper; 31 | IReZapTest reZap; 32 | Reliquary reliquary; 33 | IReaperVaultTest vault; 34 | LinearCurve linearCurve; 35 | address bpt; 36 | IERC20 oath; 37 | IWftm wftm; 38 | uint256 emissionRate = 1e17; 39 | 40 | // Linear function config (to config) 41 | uint256 slope = 100; // Increase of multiplier every second 42 | uint256 minMultiplier = 365 days * 100; // Arbitrary (but should be coherent with slope) 43 | 44 | receive() external payable {} 45 | 46 | function setUp() public { 47 | vm.createSelectFork("fantom", 53341452); 48 | 49 | oath = IERC20(0x21Ada0D2aC28C3A5Fa3cD2eE30882dA8812279B6); 50 | reliquary = new Reliquary(address(oath), emissionRate, "Reliquary Deposit", "RELIC"); 51 | linearCurve = new LinearCurve(slope, minMultiplier); 52 | 53 | vault = IReaperVaultTest(0xA817164Cb1BF8bdbd96C502Bbea93A4d2300CBe1); 54 | bpt = address(vault.token()); 55 | 56 | address nftDescriptor = address(new NFTDescriptor(address(reliquary))); 57 | reliquary.grantRole(keccak256("OPERATOR"), address(this)); 58 | deal(address(vault), address(this), 1); 59 | vault.approve(address(reliquary), 1); // approve 1 wei to bootstrap the pool 60 | reliquary.addPool( 61 | 1000, 62 | address(vault), 63 | address(0), 64 | linearCurve, 65 | "A Late Quartet", 66 | nftDescriptor, 67 | true, 68 | address(this) 69 | ); 70 | 71 | reZap = IReZapTest(0x6E87672e547D40285C8FdCE1139DE4bc7CBF2127); 72 | helper = new DepositHelperReaperBPT(reliquary, reZap); 73 | 74 | wftm = IWftm(0x21be370D5312f44cB42ce377BC9b8a0cEF1A4C83); 75 | wftm.deposit{value: 1_000_000 ether}(); 76 | wftm.approve(address(helper), type(uint256).max); 77 | helper.reliquary().setApprovalForAll(address(helper), true); 78 | } 79 | 80 | function testCreateNew(uint256 amount, bool depositFTM) public { 81 | amount = bound(amount, 1 ether, wftm.balanceOf(address(this))); 82 | IReZap.Step[] memory steps = reZap.findStepsIn(address(wftm), bpt, amount); 83 | (uint256 relicId, uint256 shares) = 84 | helper.createRelicAndDeposit{value: depositFTM ? amount : 0}(steps, 0, amount); 85 | 86 | assertEq(wftm.balanceOf(address(helper)), 0); 87 | assertEq(reliquary.balanceOf(address(this)), 2, "no Relic given"); 88 | assertEq( 89 | reliquary.getPositionForId(relicId).amount, 90 | shares, 91 | "deposited amount not expected amount" 92 | ); 93 | } 94 | 95 | function testDepositExisting(uint256 amountA, uint256 amountB, bool aIsFTM, bool bIsFTM) 96 | public 97 | { 98 | amountA = bound(amountA, 1 ether, 500_000 ether); 99 | amountB = bound(amountB, 1 ether, 1_000_000 ether - amountA); 100 | 101 | IReZap.Step[] memory stepsA = reZap.findStepsIn(address(wftm), bpt, amountA); 102 | (uint256 relicId, uint256 sharesA) = 103 | helper.createRelicAndDeposit{value: aIsFTM ? amountA : 0}(stepsA, 0, amountA); 104 | IReZap.Step[] memory stepsB = reZap.findStepsIn(address(wftm), bpt, amountB); 105 | uint256 sharesB = 106 | helper.deposit{value: bIsFTM ? amountB : 0}(stepsB, amountB, relicId, true); 107 | 108 | assertEq(wftm.balanceOf(address(helper)), 0); 109 | uint256 relicAmount = reliquary.getPositionForId(relicId).amount; 110 | assertEq(relicAmount, sharesA + sharesB); 111 | } 112 | 113 | function testRevertOnDepositUnauthorized() public { 114 | IReZap.Step[] memory stepsA = reZap.findStepsIn(address(wftm), bpt, 1 ether); 115 | (uint256 relicId,) = helper.createRelicAndDeposit(stepsA, 0, 1 ether); 116 | IReZap.Step[] memory stepsB = reZap.findStepsIn(address(wftm), bpt, 1 ether); 117 | vm.expectRevert(bytes("not approved or owner")); 118 | vm.prank(address(1)); 119 | helper.deposit(stepsB, 1 ether, relicId, false); 120 | } 121 | 122 | function testWithdraw(uint256 amount, bool harvest, bool depositFTM, bool withdrawFTM) public { 123 | uint256 ftmInitialBalance = address(this).balance; 124 | uint256 wftmInitialBalance = wftm.balanceOf(address(this)); 125 | amount = bound(amount, 1 ether, 1_000_000 ether); 126 | 127 | IReZap.Step[] memory stepsIn = reZap.findStepsIn(address(wftm), bpt, amount); 128 | (uint256 relicId, uint256 shares) = 129 | helper.createRelicAndDeposit{value: depositFTM ? amount : 0}(stepsIn, 0, amount); 130 | IReZap.Step[] memory stepsOut = 131 | reZap.findStepsOut(address(wftm), bpt, (shares * vault.balance()) / vault.totalSupply()); 132 | helper.withdraw(stepsOut, shares, relicId, harvest, withdrawFTM); 133 | 134 | uint256 difference; 135 | if (depositFTM && withdrawFTM) { 136 | difference = ftmInitialBalance - address(this).balance; 137 | } else if (depositFTM && !withdrawFTM) { 138 | difference = wftm.balanceOf(address(this)) - wftmInitialBalance; 139 | } else if (!depositFTM && withdrawFTM) { 140 | difference = address(this).balance - ftmInitialBalance; 141 | } else { 142 | difference = wftmInitialBalance - wftm.balanceOf(address(this)); 143 | } 144 | 145 | // allow for 0.5% slippage after 0.1% security fee 146 | uint256 afterFee = amount - (amount * 10) / 10_000; 147 | if (depositFTM == withdrawFTM) { 148 | assertTrue(difference <= (afterFee * 5) / 1000); 149 | } else { 150 | assertApproxEqRel(difference, afterFee, 5e15); 151 | } 152 | } 153 | 154 | function testRevertOnWithdrawUnauthorized(bool harvest, bool isETH) public { 155 | IReZap.Step[] memory stepsIn = reZap.findStepsIn(address(wftm), bpt, 1 ether); 156 | (uint256 relicId, uint256 shares) = helper.createRelicAndDeposit(stepsIn, 0, 1 ether); 157 | IReZap.Step[] memory stepsOut = 158 | reZap.findStepsOut(address(wftm), bpt, (shares * vault.balance()) / vault.totalSupply()); 159 | vm.expectRevert(bytes("not approved or owner")); 160 | vm.prank(address(1)); 161 | helper.withdraw(stepsOut, shares, relicId, harvest, isETH); 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /scripts/Deploy.s.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.13; 3 | 4 | import "forge-std/Script.sol"; 5 | import {Reliquary} from "contracts/Reliquary.sol"; 6 | import {ICurves, LinearCurve} from "contracts/curves/LinearCurve.sol"; 7 | import {LinearPlateauCurve} from "contracts/curves/LinearPlateauCurve.sol"; 8 | import {DepositHelperERC4626} from "contracts/helpers/DepositHelperERC4626.sol"; 9 | import {NFTDescriptor} from "contracts/nft_descriptors/NFTDescriptor.sol"; 10 | import {ParentRollingRewarder} from "contracts/rewarders/ParentRollingRewarder.sol"; 11 | import "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol"; 12 | 13 | contract Deploy is Script { 14 | using stdJson for string; 15 | 16 | struct Pool { 17 | uint256 allocPoint; 18 | bool allowPartialWithdrawals; 19 | uint256 curveIndex; 20 | string curveType; 21 | string name; 22 | address poolToken; 23 | string tokenType; 24 | } 25 | 26 | struct ParentRewarderParams { 27 | uint256 poolId; 28 | } 29 | 30 | struct ChildRewarderParams { 31 | uint256 parentIndex; 32 | address rewarderToken; 33 | } 34 | 35 | struct LinearCurveParams { 36 | uint256 minMultiplier; 37 | uint256 slope; 38 | } 39 | 40 | struct LinearPlateauCurveParams { 41 | uint256 minMultiplier; 42 | uint256 plateauLevel; 43 | uint256 slope; 44 | } 45 | 46 | bytes32 constant OPERATOR = keccak256("OPERATOR"); 47 | bytes32 constant EMISSION_RATE = keccak256("EMISSION_RATE"); 48 | 49 | string config; 50 | address multisig; 51 | address bootstrapAdd; 52 | Reliquary reliquary; 53 | uint256 poolCount; 54 | address rewardToken; 55 | mapping(uint256 => ParentRollingRewarder) parentForPoolId; 56 | LinearCurve[] linearCurves; 57 | LinearPlateauCurve[] linearPlateauCurves; 58 | address depositHelper4626; 59 | 60 | function run() external { 61 | config = vm.readFile("scripts/deploy_conf.json"); 62 | string memory name = config.readString(".name"); 63 | string memory symbol = config.readString(".symbol"); 64 | multisig = config.readAddress(".multisig"); 65 | bootstrapAdd = config.readAddress(".multisig"); //! bootstrapAdd set to multisig. 66 | rewardToken = config.readAddress(".rewardToken"); 67 | uint256 emissionRate = config.readUint(".emissionRate"); 68 | Pool[] memory pools = abi.decode(config.parseRaw(".pools"), (Pool[])); 69 | poolCount = pools.length; 70 | 71 | vm.startBroadcast(); 72 | 73 | _deployCurves(); 74 | 75 | reliquary = new Reliquary(rewardToken, emissionRate, name, symbol); 76 | 77 | _deployRewarders(); 78 | 79 | reliquary.grantRole(OPERATOR, tx.origin); 80 | for (uint256 i = 0; i < pools.length; ++i) { 81 | Pool memory pool = pools[i]; 82 | 83 | ICurves curve; 84 | bytes32 curveTypeHash = keccak256(bytes(pool.curveType)); 85 | if (curveTypeHash == keccak256("linearCurve")) { 86 | curve = linearCurves[pool.curveIndex]; 87 | } else if (curveTypeHash == keccak256("linearPlateauCurve")) { 88 | curve = linearPlateauCurves[pool.curveIndex]; 89 | } else { 90 | revert(string.concat("invalid curve type ", pool.curveType)); 91 | } 92 | 93 | _deployHelpers(pool.tokenType); 94 | address nftDescriptor = address(new NFTDescriptor(address(reliquary))); 95 | 96 | ERC20(pool.poolToken).approve(address(reliquary), 1); // approve 1 wei to bootstrap the pool 97 | reliquary.addPool( 98 | pool.allocPoint, 99 | pool.poolToken, 100 | address(parentForPoolId[i]), 101 | curve, 102 | pool.name, 103 | nftDescriptor, 104 | pool.allowPartialWithdrawals, 105 | bootstrapAdd 106 | ); 107 | } 108 | 109 | if (multisig != address(0)) { 110 | _renounceRoles(); 111 | } 112 | 113 | vm.stopBroadcast(); 114 | } 115 | 116 | function _deployRewarders() internal { 117 | ParentRewarderParams[] memory parentParams = 118 | abi.decode(config.parseRaw(".parentRewarders"), (ParentRewarderParams[])); 119 | ParentRollingRewarder[] memory parentRewarders = 120 | new ParentRollingRewarder[](parentParams.length); 121 | for (uint256 i; i < parentParams.length; ++i) { 122 | ParentRewarderParams memory params = parentParams[i]; 123 | 124 | ParentRollingRewarder newParent = new ParentRollingRewarder(); 125 | 126 | parentRewarders[i] = newParent; 127 | parentForPoolId[params.poolId] = newParent; 128 | } 129 | 130 | ChildRewarderParams[] memory children = 131 | abi.decode(config.parseRaw(".childRewarders"), (ChildRewarderParams[])); 132 | for (uint256 i; i < children.length; ++i) { 133 | ChildRewarderParams memory child = children[i]; 134 | ParentRollingRewarder parent = ParentRollingRewarder(parentRewarders[child.parentIndex]); 135 | parent.createChild(child.rewarderToken); 136 | } 137 | } 138 | 139 | function _deployCurves() internal { 140 | LinearCurveParams[] memory linearCurveParams = 141 | abi.decode(config.parseRaw(".linearCurves"), (LinearCurveParams[])); 142 | for (uint256 i; i < linearCurveParams.length; ++i) { 143 | LinearCurveParams memory params = linearCurveParams[i]; 144 | linearCurves.push(new LinearCurve(params.slope, params.minMultiplier)); 145 | } 146 | 147 | LinearPlateauCurveParams[] memory linearPlateauCurveParams = 148 | abi.decode(config.parseRaw(".linearPlateauCurves"), (LinearPlateauCurveParams[])); 149 | for (uint256 i; i < linearPlateauCurveParams.length; ++i) { 150 | LinearPlateauCurveParams memory params = linearPlateauCurveParams[i]; 151 | linearPlateauCurves.push( 152 | new LinearPlateauCurve(params.slope, params.minMultiplier, params.plateauLevel) 153 | ); 154 | } 155 | } 156 | 157 | function _deployHelpers(string memory poolTokenType) internal { 158 | bytes32 typeHash = keccak256(bytes(poolTokenType)); 159 | if (typeHash == keccak256("4626")) { 160 | if (depositHelper4626 == address(0)) { 161 | depositHelper4626 = 162 | address(new DepositHelperERC4626(reliquary, config.readAddress(".weth"))); 163 | } 164 | } else if (typeHash != keccak256("normal") && typeHash != keccak256("pair")) { 165 | revert(string.concat("invalid token type ", poolTokenType)); 166 | } 167 | } 168 | 169 | function _renounceRoles() internal { 170 | bytes32 defaultAdminRole = reliquary.DEFAULT_ADMIN_ROLE(); 171 | reliquary.grantRole(defaultAdminRole, multisig); 172 | reliquary.grantRole(OPERATOR, multisig); 173 | reliquary.grantRole(EMISSION_RATE, multisig); 174 | reliquary.renounceRole(OPERATOR, tx.origin); 175 | reliquary.renounceRole(defaultAdminRole, tx.origin); 176 | if (multisig != address(0)) { 177 | for (uint256 i; i < poolCount; ++i) { 178 | if (address(parentForPoolId[i]) != address(0)) { 179 | parentForPoolId[i].transferOwnership(multisig); 180 | } 181 | } 182 | } 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /contracts/rewarders/ParentRollingRewarder.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity 0.8.23; 3 | 4 | import "./RollingRewarder.sol"; 5 | import "../interfaces/IParentRollingRewarder.sol"; 6 | import "openzeppelin-contracts/contracts/access/Ownable.sol"; 7 | import "openzeppelin-contracts/contracts/utils/structs/EnumerableSet.sol"; 8 | 9 | /// @title Extension to the SingleAssetRewarder contract that allows managing multiple reward tokens via access control 10 | /// and enumerable children contracts. 11 | contract ParentRollingRewarder is IParentRollingRewarder, Ownable { 12 | using EnumerableSet for EnumerableSet.AddressSet; 13 | 14 | EnumerableSet.AddressSet private childrenRewarders; 15 | 16 | uint8 public poolId = type(uint8).max; 17 | address public reliquary; 18 | 19 | // Errors 20 | error ParentRollingRewarder__ONLY_RELIQUARY_ACCESS(); 21 | error ParentRollingRewarder__ONLY_CHILD_CAN_BE_REMOVED(); 22 | error ParentRollingRewarder__ALREADY_INITIALIZED(); 23 | 24 | // Events 25 | event ChildCreated(address indexed _child, address indexed _token); 26 | event ChildRemoved(address indexed _child); 27 | 28 | modifier onlyReliquary() { 29 | if (msg.sender != reliquary) revert ParentRollingRewarder__ONLY_RELIQUARY_ACCESS(); 30 | _; 31 | } 32 | 33 | constructor() Ownable(msg.sender) {} 34 | 35 | /** 36 | * @dev initialize called in Reliquary.addPool or Reliquary.modifyPool() 37 | * @param _poolId ID of the pool this rewarder will read state from. 38 | */ 39 | function initialize(uint8 _poolId) external { 40 | if (poolId != type(uint8).max || reliquary != address(0)) { 41 | revert ParentRollingRewarder__ALREADY_INITIALIZED(); 42 | } 43 | poolId = _poolId; 44 | reliquary = msg.sender; 45 | } 46 | 47 | // -------------- Admin -------------- 48 | 49 | /** 50 | * @notice Deploys a ChildRewarder contract and adds it to the childrenRewarders set. 51 | * @param _rewardToken Address of token rewards are distributed in. 52 | * @return child_ Address of the new ChildRewarder. 53 | */ 54 | function createChild(address _rewardToken) external onlyOwner returns (address child_) { 55 | child_ = address(new RollingRewarder(_rewardToken, reliquary, poolId)); 56 | childrenRewarders.add(child_); 57 | emit ChildCreated(child_, address(_rewardToken)); 58 | } 59 | 60 | /** 61 | * @notice Removes a ChildRewarder from the childrenRewarders set. 62 | * @param _childRewarder Address of the ChildRewarder contract to remove. 63 | */ 64 | function removeChild(address _childRewarder) external onlyOwner { 65 | if (!childrenRewarders.remove(_childRewarder)) { 66 | revert ParentRollingRewarder__ONLY_CHILD_CAN_BE_REMOVED(); 67 | } 68 | emit ChildRemoved(_childRewarder); 69 | } 70 | 71 | // -------------- Hooks -------------- 72 | 73 | function onUpdate( 74 | ICurves _curve, 75 | uint256 _relicId, 76 | uint256 _amount, 77 | uint256 _oldLevel, 78 | uint256 _newLevel 79 | ) external override onlyReliquary { 80 | uint256 length_ = childrenRewarders.length(); 81 | 82 | for (uint256 i_; i_ < length_; ++i_) { 83 | IRewarder(childrenRewarders.at(i_)).onUpdate( 84 | _curve, _relicId, _amount, _oldLevel, _newLevel 85 | ); 86 | } 87 | } 88 | 89 | function onReward(uint256 _relicId, address _to) external override onlyReliquary { 90 | uint256 length_ = childrenRewarders.length(); 91 | 92 | for (uint256 i_; i_ < length_; ++i_) { 93 | IRewarder(childrenRewarders.at(i_)).onReward(_relicId, _to); 94 | } 95 | } 96 | 97 | function onDeposit( 98 | ICurves _curve, 99 | uint256 _relicId, 100 | uint256 _depositAmount, 101 | uint256 _oldAmount, 102 | uint256 _oldLevel, 103 | uint256 _newLevel 104 | ) external override onlyReliquary { 105 | uint256 length_ = childrenRewarders.length(); 106 | 107 | for (uint256 i_; i_ < length_; ++i_) { 108 | IRewarder(childrenRewarders.at(i_)).onDeposit( 109 | _curve, _relicId, _depositAmount, _oldAmount, _oldLevel, _newLevel 110 | ); 111 | } 112 | } 113 | 114 | function onWithdraw( 115 | ICurves _curve, 116 | uint256 _relicId, 117 | uint256 _withdrawAmount, 118 | uint256 _oldAmount, 119 | uint256 _oldLevel, 120 | uint256 _newLevel 121 | ) external override onlyReliquary { 122 | uint256 length_ = childrenRewarders.length(); 123 | 124 | for (uint256 i_; i_ < length_; ++i_) { 125 | IRewarder(childrenRewarders.at(i_)).onWithdraw( 126 | _curve, _relicId, _withdrawAmount, _oldAmount, _oldLevel, _newLevel 127 | ); 128 | } 129 | } 130 | 131 | function onSplit( 132 | ICurves _curve, 133 | uint256 _fromId, 134 | uint256 _newId, 135 | uint256 _amount, 136 | uint256 _fromAmount, 137 | uint256 _level 138 | ) external override onlyReliquary { 139 | uint256 length_ = childrenRewarders.length(); 140 | 141 | for (uint256 i_; i_ < length_; ++i_) { 142 | IRewarder(childrenRewarders.at(i_)).onSplit( 143 | _curve, _fromId, _newId, _amount, _fromAmount, _level 144 | ); 145 | } 146 | } 147 | 148 | function onShift( 149 | ICurves _curve, 150 | uint256 _fromId, 151 | uint256 _toId, 152 | uint256 _amount, 153 | uint256 _oldFromAmount, 154 | uint256 _oldToAmount, 155 | uint256 _fromLevel, 156 | uint256 _oldToLevel, 157 | uint256 _newToLevel 158 | ) external override onlyReliquary { 159 | uint256 length_ = childrenRewarders.length(); 160 | 161 | for (uint256 i_; i_ < length_; ++i_) { 162 | IRewarder(childrenRewarders.at(i_)).onShift( 163 | _curve, 164 | _fromId, 165 | _toId, 166 | _amount, 167 | _oldFromAmount, 168 | _oldToAmount, 169 | _fromLevel, 170 | _oldToLevel, 171 | _newToLevel 172 | ); 173 | } 174 | } 175 | 176 | function onMerge( 177 | ICurves _curve, 178 | uint256 _fromId, 179 | uint256 _toId, 180 | uint256 _fromAmount, 181 | uint256 _toAmount, 182 | uint256 _fromLevel, 183 | uint256 _oldToLevel, 184 | uint256 _newToLevel 185 | ) external override onlyReliquary { 186 | uint256 length_ = childrenRewarders.length(); 187 | 188 | for (uint256 i_; i_ < length_; ++i_) { 189 | IRewarder(childrenRewarders.at(i_)).onMerge( 190 | _curve, _fromId, _toId, _fromAmount, _toAmount, _fromLevel, _oldToLevel, _newToLevel 191 | ); 192 | } 193 | } 194 | 195 | // -------------- View -------------- 196 | 197 | /** 198 | * @dev WARNING: This operation will copy the entire childrenRewarders storage to memory, which can be quite 199 | * expensive. This is designed to mostly be used by view accessors that are queried without any gas fees. 200 | * Developers should keep in mind that this function has an unbounded cost, and using it as part of a state- 201 | * changing function may render the function uncallable if the set grows to a point where copying to memory 202 | * consumes too much gas to fit in a block. 203 | */ 204 | function getChildrenRewarders() external view returns (address[] memory) { 205 | return childrenRewarders.values(); 206 | } 207 | 208 | function pendingTokens(uint256 _relicId) 209 | external 210 | view 211 | override 212 | returns (address[] memory rewardTokens_, uint256[] memory rewardAmounts_) 213 | { 214 | uint256 length_ = childrenRewarders.length(); 215 | rewardTokens_ = new address[](length_); 216 | rewardAmounts_ = new uint256[](length_); 217 | 218 | for (uint256 i_ = 0; i_ < length_; ++i_) { 219 | RollingRewarder rewarder_ = RollingRewarder(childrenRewarders.at(i_)); 220 | rewardTokens_[i_] = rewarder_.rewardToken(); 221 | rewardAmounts_[i_] = rewarder_.pendingToken(_relicId); 222 | } 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /contracts/helpers/DepositHelperReaperBPT.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity 0.8.23; 3 | 4 | import "openzeppelin-contracts/contracts/access/Ownable.sol"; 5 | import "openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol"; 6 | import {IReliquary, PositionInfo} from "../interfaces/IReliquary.sol"; 7 | 8 | interface IReaperVault is IERC20 { 9 | function token() external view returns (IERC20); 10 | } 11 | 12 | interface IReZap { 13 | enum JoinType { 14 | Swap, 15 | Weighted 16 | } 17 | 18 | struct Step { 19 | address startToken; 20 | address endToken; 21 | uint8 inIdx; 22 | uint8 outIdx; 23 | JoinType jT; 24 | bytes32 poolId; 25 | uint256 minAmountOut; 26 | } 27 | 28 | function zapIn(Step[] calldata steps, address crypt, uint256 tokenInAmount) external; 29 | 30 | function zapInETH(Step[] calldata steps, address crypt) external payable; 31 | 32 | function zapOut(Step[] calldata steps, address crypt, uint256 cryptAmount) external; 33 | 34 | function WETH() external view returns (address); 35 | } 36 | 37 | interface IWeth is IERC20 { 38 | function deposit() external payable; 39 | } 40 | 41 | /** 42 | * @title Helper contract that allows depositing to and withdrawing from Reliquary pools of a Reaper vault (or possibly 43 | * similar) for a Balancer Pool Token in a single transaction using one of the BPT's underlying assets. 44 | * @notice Due to the complexities and risks associated with inputting the `Step` struct arrays in each function, 45 | * THIS CONTRACT SHOULD NOT BE WRITTEN TO USING A BLOCK EXPLORER. 46 | */ 47 | contract DepositHelperReaperBPT is Ownable { 48 | using Address for address payable; 49 | using SafeERC20 for IERC20; 50 | 51 | IReliquary public immutable reliquary; 52 | IReZap public immutable reZap; 53 | IWeth public immutable weth; 54 | 55 | constructor(IReliquary _reliquary, IReZap _reZap) Ownable(msg.sender) { 56 | reliquary = _reliquary; 57 | reZap = _reZap; 58 | weth = IWeth(_reZap.WETH()); 59 | } 60 | 61 | receive() external payable {} 62 | 63 | function deposit( 64 | IReZap.Step[] calldata _steps, 65 | uint256 _amount, 66 | uint256 _relicId, 67 | bool _harvest 68 | ) external payable returns (uint256 shares_) { 69 | _requireApprovedOrOwner(_relicId); 70 | 71 | shares_ = _prepareDeposit(_steps, reliquary.getPositionForId(_relicId).poolId, _amount); 72 | reliquary.deposit(shares_, _relicId, _harvest ? msg.sender : address(0)); 73 | } 74 | 75 | function createRelicAndDeposit(IReZap.Step[] calldata _steps, uint8 _pid, uint256 _amount) 76 | external 77 | payable 78 | returns (uint256 relicId_, uint256 shares_) 79 | { 80 | shares_ = _prepareDeposit(_steps, _pid, _amount); 81 | relicId_ = reliquary.createRelicAndDeposit(msg.sender, _pid, shares_); 82 | } 83 | 84 | function withdraw( 85 | IReZap.Step[] calldata _steps, 86 | uint256 _shares, 87 | uint256 _relicId, 88 | bool _harvest, 89 | bool _giveEther 90 | ) external { 91 | (, IReaperVault vault) = _prepareWithdrawal(_steps, _relicId, _giveEther); 92 | if (_giveEther) { 93 | _withdrawEther(vault, _steps, _shares, _relicId, _harvest); 94 | } else { 95 | _withdrawERC20(vault, _steps, _shares, _relicId, _harvest); 96 | } 97 | } 98 | 99 | function withdrawAllAndHarvest( 100 | IReZap.Step[] calldata _steps, 101 | uint256 _relicId, 102 | bool _giveEther, 103 | bool _burn 104 | ) external { 105 | (PositionInfo memory position, IReaperVault vault) = 106 | _prepareWithdrawal(_steps, _relicId, _giveEther); 107 | if (_giveEther) { 108 | _withdrawEther(vault, _steps, position.amount, _relicId, true); 109 | } else { 110 | _withdrawERC20(vault, _steps, position.amount, _relicId, true); 111 | } 112 | if (_burn) { 113 | reliquary.burn(_relicId); 114 | } 115 | } 116 | 117 | /// @notice Owner may send tokens out of this contract since none should be held here. Do not send tokens manually. 118 | function rescueFunds(address _token, address _to, uint256 _amount) external onlyOwner { 119 | if (_token == address(0)) { 120 | payable(_to).sendValue(_amount); 121 | } else { 122 | IERC20(_token).safeTransfer(_to, _amount); 123 | } 124 | } 125 | 126 | function _prepareDeposit(IReZap.Step[] calldata _steps, uint8 _pid, uint256 _amount) 127 | internal 128 | returns (uint256 shares_) 129 | { 130 | IReaperVault vault_ = IReaperVault(reliquary.getPoolInfo(_pid).poolToken); 131 | uint256 initialShares_ = vault_.balanceOf(address(this)); 132 | if (msg.value != 0) { 133 | reZap.zapInETH{value: msg.value}(_steps, address(vault_)); 134 | } else { 135 | IERC20 zapInToken = IERC20(_steps[0].startToken); 136 | zapInToken.safeTransferFrom(msg.sender, address(this), _amount); 137 | 138 | if (zapInToken.allowance(address(this), address(reZap)) == 0) { 139 | zapInToken.approve(address(reZap), type(uint256).max); 140 | } 141 | reZap.zapIn(_steps, address(vault_), _amount); 142 | } 143 | 144 | shares_ = vault_.balanceOf(address(this)) - initialShares_; 145 | if (vault_.allowance(address(this), address(reliquary)) == 0) { 146 | vault_.approve(address(reliquary), type(uint256).max); 147 | } 148 | } 149 | 150 | function _prepareWithdrawal(IReZap.Step[] calldata _steps, uint256 _relicId, bool _giveEther) 151 | internal 152 | view 153 | returns (PositionInfo memory position, IReaperVault vault) 154 | { 155 | address zapOutToken_ = _steps[_steps.length - 1].endToken; 156 | if (_giveEther) { 157 | require(zapOutToken_ == address(weth), "invalid steps"); 158 | } 159 | 160 | _requireApprovedOrOwner(_relicId); 161 | 162 | position = reliquary.getPositionForId(_relicId); 163 | vault = IReaperVault(reliquary.getPoolInfo(position.poolId).poolToken); 164 | } 165 | 166 | function _withdrawERC20( 167 | IReaperVault vault, 168 | IReZap.Step[] calldata _steps, 169 | uint256 _shares, 170 | uint256 _relicId, 171 | bool _harvest 172 | ) internal { 173 | _withdrawFromRelicAndApproveVault(vault, _shares, _relicId, _harvest); 174 | 175 | address zapOutToken_ = _steps[_steps.length - 1].endToken; 176 | uint256 initialTokenBalance_ = IERC20(zapOutToken_).balanceOf(address(this)); 177 | uint256 initialEtherBalance_ = address(this).balance; 178 | reZap.zapOut(_steps, address(vault), _shares); 179 | 180 | uint256 amountOut_; 181 | if (zapOutToken_ == address(weth)) { 182 | amountOut_ = address(this).balance - initialEtherBalance_; 183 | IWeth(zapOutToken_).deposit{value: amountOut_}(); 184 | } else { 185 | amountOut_ = IERC20(zapOutToken_).balanceOf(address(this)) - initialTokenBalance_; 186 | } 187 | IERC20(zapOutToken_).safeTransfer(msg.sender, amountOut_); 188 | } 189 | 190 | function _withdrawEther( 191 | IReaperVault _vault, 192 | IReZap.Step[] calldata _steps, 193 | uint256 _shares, 194 | uint256 _relicId, 195 | bool _harvest 196 | ) internal { 197 | _withdrawFromRelicAndApproveVault(_vault, _shares, _relicId, _harvest); 198 | 199 | uint256 initialEtherBalance_ = address(this).balance; 200 | reZap.zapOut(_steps, address(_vault), _shares); 201 | 202 | payable(msg.sender).sendValue(address(this).balance - initialEtherBalance_); 203 | } 204 | 205 | function _withdrawFromRelicAndApproveVault( 206 | IReaperVault _vault, 207 | uint256 _shares, 208 | uint256 _relicId, 209 | bool _harvest 210 | ) internal { 211 | reliquary.withdraw(_shares, _relicId, _harvest ? msg.sender : address(0)); 212 | 213 | if (_vault.allowance(address(this), address(reZap)) == 0) { 214 | _vault.approve(address(reZap), type(uint256).max); 215 | } 216 | } 217 | 218 | function _requireApprovedOrOwner(uint256 _relicId) internal view { 219 | require(reliquary.isApprovedOrOwner(msg.sender, _relicId), "not approved or owner"); 220 | } 221 | } 222 | -------------------------------------------------------------------------------- /test/foundry/DepositHelperReaperVault.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity 0.8.23; 3 | 4 | import "forge-std/Test.sol"; 5 | import "openzeppelin-contracts/contracts/token/ERC721/utils/ERC721Holder.sol"; 6 | import "contracts/helpers/DepositHelperReaperVault.sol"; 7 | import "contracts/nft_descriptors/NFTDescriptor.sol"; 8 | import "contracts/Reliquary.sol"; 9 | import "contracts/curves/LinearCurve.sol"; 10 | 11 | interface IReaperVaultTest is IReaperVault { 12 | function balance() external view returns (uint256); 13 | 14 | function tvlCap() external view returns (uint256); 15 | 16 | function withdrawalQueue(uint256) external view returns (IStrategy); 17 | } 18 | 19 | interface IStrategy is IAccessControlEnumerable { 20 | function harvest() external; 21 | } 22 | 23 | contract DepositHelperReaperVaultTest is ERC721Holder, Test { 24 | DepositHelperReaperVault helper; 25 | Reliquary reliquary; 26 | LinearCurve linearCurve; 27 | IReaperVaultTest wethVault = IReaperVaultTest(0x1bAd45E92DCe078Cf68C2141CD34f54A02c92806); 28 | IReaperVaultTest usdcVault = IReaperVaultTest(0x508734b52BA7e04Ba068A2D4f67720Ac1f63dF47); 29 | IReaperVaultTest sternVault = IReaperVaultTest(0x3eE6107d9C93955acBb3f39871D32B02F82B78AB); 30 | IERC20 oath; 31 | IWeth weth; 32 | uint256 emissionRate = 1e17; 33 | 34 | // Linear function config (to config) 35 | uint256 slope = 100; // Increase of multiplier every second 36 | uint256 minMultiplier = 365 days * 100; // Arbitrary (but should be coherent with slope) 37 | 38 | receive() external payable {} 39 | 40 | function setUp() public { 41 | vm.createSelectFork("optimism", 111980000); 42 | 43 | oath = IERC20(0x00e1724885473B63bCE08a9f0a52F35b0979e35A); 44 | reliquary = new Reliquary(address(oath), emissionRate, "Reliquary Deposit", "RELIC"); 45 | linearCurve = new LinearCurve(slope, minMultiplier); 46 | 47 | address nftDescriptor = address(new NFTDescriptor(address(reliquary))); 48 | deal(address(wethVault), address(this), 1); 49 | wethVault.approve(address(reliquary), 1); // approve 1 wei to bootstrap the pool 50 | reliquary.addPool( 51 | 1000, 52 | address(wethVault), 53 | address(0), 54 | linearCurve, 55 | "WETH", 56 | nftDescriptor, 57 | true, 58 | address(this) 59 | ); 60 | deal(address(usdcVault), address(this), 1); 61 | usdcVault.approve(address(reliquary), 1); // approve 1 wei to bootstrap the pool 62 | reliquary.addPool( 63 | 1000, 64 | address(usdcVault), 65 | address(0), 66 | linearCurve, 67 | "USDC", 68 | nftDescriptor, 69 | true, 70 | address(this) 71 | ); 72 | deal(address(sternVault), address(this), 1); 73 | sternVault.approve(address(reliquary), 1); // approve 1 wei to bootstrap the pool 74 | reliquary.addPool( 75 | 1000, 76 | address(sternVault), 77 | address(0), 78 | linearCurve, 79 | "ERN", 80 | nftDescriptor, 81 | true, 82 | address(this) 83 | ); 84 | 85 | weth = IWeth(address(wethVault.token())); 86 | helper = new DepositHelperReaperVault(reliquary, address(weth)); 87 | 88 | weth.deposit{value: 1_000_000 ether}(); 89 | weth.approve(address(helper), type(uint256).max); 90 | helper.reliquary().setApprovalForAll(address(helper), true); 91 | } 92 | 93 | function testCreateNew(uint256 amount, bool depositETH) public { 94 | amount = bound(amount, 10, weth.balanceOf(address(this))); 95 | (uint256 relicId, uint256 shares) = 96 | helper.createRelicAndDeposit{value: depositETH ? amount : 0}(0, amount); 97 | 98 | assertEq(weth.balanceOf(address(helper)), 0); 99 | assertEq(reliquary.balanceOf(address(this)), 4, "no Relic given"); 100 | assertEq( 101 | reliquary.getPositionForId(relicId).amount, 102 | shares, 103 | "deposited amount not expected amount" 104 | ); 105 | } 106 | 107 | function testDepositExisting(uint256 amountA, uint256 amountB, bool aIsETH, bool bIsETH) 108 | public 109 | { 110 | amountA = bound(amountA, 10, 0.5 ether); 111 | amountB = bound(amountB, 10, 1 ether - amountA); 112 | 113 | (uint256 relicId, uint256 sharesA) = 114 | helper.createRelicAndDeposit{value: aIsETH ? amountA : 0}(0, amountA); 115 | uint256 sharesB = helper.deposit{value: bIsETH ? amountB : 0}(amountB, relicId, false); 116 | 117 | assertEq(weth.balanceOf(address(helper)), 0); 118 | uint256 relicAmount = reliquary.getPositionForId(relicId).amount; 119 | assertEq(relicAmount, sharesA + sharesB); 120 | } 121 | 122 | function testRevertOnDepositUnauthorized() public { 123 | (uint256 relicId,) = helper.createRelicAndDeposit(0, 1 ether); 124 | vm.expectRevert(bytes("not approved or owner")); 125 | vm.prank(address(1)); 126 | helper.deposit(1 ether, relicId, true); 127 | } 128 | 129 | function testWithdraw(uint256 amount, bool harvest, bool depositETH, bool withdrawETH) public { 130 | uint256 ethInitialBalance = address(this).balance; 131 | uint256 wethInitialBalance = weth.balanceOf(address(this)); 132 | amount = bound(amount, 10, 1 ether); 133 | 134 | (uint256 relicId,) = helper.createRelicAndDeposit{value: depositETH ? amount : 0}(0, amount); 135 | if (depositETH) { 136 | assertEq(address(this).balance, ethInitialBalance - amount); 137 | } else { 138 | assertEq(weth.balanceOf(address(this)), wethInitialBalance - amount); 139 | } 140 | 141 | IStrategy strategy = usdcVault.withdrawalQueue(0); 142 | vm.prank(strategy.getRoleMember(keccak256("STRATEGIST"), 0)); 143 | strategy.harvest(); 144 | 145 | helper.withdraw(amount, relicId, harvest, withdrawETH); 146 | 147 | uint256 difference; 148 | if (depositETH && withdrawETH) { 149 | difference = ethInitialBalance - address(this).balance; 150 | } else if (depositETH && !withdrawETH) { 151 | difference = weth.balanceOf(address(this)) - wethInitialBalance; 152 | } else if (!depositETH && withdrawETH) { 153 | difference = address(this).balance - ethInitialBalance; 154 | } else { 155 | difference = wethInitialBalance - weth.balanceOf(address(this)); 156 | } 157 | 158 | uint256 expectedDifference = (depositETH == withdrawETH) ? 0 : amount; 159 | assertApproxEqAbs(difference, expectedDifference, 10); 160 | } 161 | 162 | function testWithdrawUSDC(uint256 amount, bool harvest) public { 163 | amount = bound(amount, 10, 1e15); 164 | IERC20 usdc = usdcVault.token(); 165 | assertEq(usdc.balanceOf(address(this)), 0); 166 | deal(address(usdc), address(this), amount); 167 | usdc.approve(address(helper), amount); 168 | 169 | (uint256 relicId,) = helper.createRelicAndDeposit(1, amount); 170 | assertEq(usdc.balanceOf(address(this)), 0); 171 | 172 | IStrategy strategy = usdcVault.withdrawalQueue(0); 173 | vm.prank(strategy.getRoleMember(keccak256("STRATEGIST"), 0)); 174 | strategy.harvest(); 175 | 176 | helper.withdraw(amount, relicId, harvest, false); 177 | 178 | assertApproxEqAbs(usdc.balanceOf(address(this)), amount, 10); 179 | } 180 | 181 | function testWithdrawStERN(uint256 amount, bool harvest) public { 182 | amount = bound(amount, 10, sternVault.tvlCap() - sternVault.balance()); 183 | IERC20 ern = sternVault.token(); 184 | assertEq(ern.balanceOf(address(this)), 0); 185 | deal(address(ern), address(this), amount); 186 | ern.approve(address(helper), type(uint256).max); 187 | 188 | (uint256 relicId,) = helper.createRelicAndDeposit(2, amount); 189 | assertEq(ern.balanceOf(address(this)), 0); 190 | 191 | IStrategy strategy = sternVault.withdrawalQueue(0); 192 | vm.prank(strategy.getRoleMember(keccak256("STRATEGIST"), 0)); 193 | strategy.harvest(); 194 | 195 | helper.withdraw(amount, relicId, harvest, false); 196 | 197 | assertApproxEqAbs(ern.balanceOf(address(this)), amount, 10); 198 | } 199 | 200 | function testRevertOnWithdrawUnauthorized(bool harvest, bool isETH) public { 201 | (uint256 relicId,) = helper.createRelicAndDeposit(0, 1 ether); 202 | vm.expectRevert(bytes("not approved or owner")); 203 | vm.prank(address(1)); 204 | helper.withdraw(1 ether, relicId, harvest, isETH); 205 | } 206 | } 207 | -------------------------------------------------------------------------------- /contracts/libraries/ReliquaryLogic.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity 0.8.23; 3 | 4 | import "../interfaces/IReliquary.sol"; 5 | import "../interfaces/IRewarder.sol"; 6 | import "./ReliquaryEvents.sol"; 7 | import "openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol"; 8 | import "openzeppelin-contracts/contracts/utils/math/Math.sol"; 9 | import "openzeppelin-contracts/contracts/utils/math/SafeCast.sol"; 10 | 11 | struct LocalVariables_updateRelic { 12 | uint256 received; 13 | uint256 oldAmount; 14 | uint256 newAmount; 15 | uint256 oldLevel; 16 | uint256 newLevel; 17 | } 18 | 19 | library ReliquaryLogic { 20 | using SafeERC20 for IERC20; 21 | using SafeCast for uint256; 22 | 23 | // -------------- Internal -------------- 24 | 25 | /** 26 | * @dev Update the position of a relic in a pool. 27 | * This function updates the position of a relic based on the provided kind (deposit, withdraw, harvest, or update), 28 | * calculates the reward credit and debt, and updates the total liquidity provider (LP) supplied for the pool. 29 | * @param position The PositionInfo structure representing the position of the relic to be updated. 30 | * @param pool The PoolInfo structure representing the pool containing the relic. 31 | * @param _kind The kind of update to be performed (deposit, withdraw, harvest, or update). 32 | * @param _relicId The ID of the relic. 33 | * @param _amount The amount of the relic to be deposited, withdrawn, or harvested. 34 | * @param _harvestTo The address to receive the harvested rewards. 35 | * @param _emissionRate The current emission rate. 36 | * @param _totalAllocPoint The total allocation points. 37 | * @param _rewardToken The address of the reward token. 38 | * @return The amount of rewards harvested. 39 | */ 40 | function _updateRelic( 41 | PositionInfo storage position, 42 | PoolInfo storage pool, 43 | Kind _kind, 44 | uint256 _relicId, 45 | uint256 _amount, 46 | address _harvestTo, 47 | uint256 _emissionRate, 48 | uint256 _totalAllocPoint, 49 | address _rewardToken 50 | ) internal returns (uint256) { 51 | uint256 accRewardPerShare_ = _updatePool(pool, _emissionRate, _totalAllocPoint); 52 | 53 | LocalVariables_updateRelic memory vars_; 54 | vars_.oldAmount = uint256(position.amount); 55 | 56 | if (_kind == Kind.DEPOSIT) { 57 | _updateEntry(position, _amount); 58 | vars_.newAmount = vars_.oldAmount + _amount; 59 | position.amount = vars_.newAmount.toUint128(); 60 | } else if (_kind == Kind.WITHDRAW) { 61 | if (_amount != vars_.oldAmount && !pool.allowPartialWithdrawals) { 62 | revert IReliquary.Reliquary__PARTIAL_WITHDRAWALS_DISABLED(); 63 | } 64 | vars_.newAmount = vars_.oldAmount - _amount; 65 | position.amount = vars_.newAmount.toUint128(); 66 | } else { 67 | /* Kind.HARVEST or Kind.UPDATE */ 68 | vars_.newAmount = vars_.oldAmount; 69 | } 70 | 71 | vars_.oldLevel = uint256(position.level); 72 | vars_.newLevel = _updateLevel(position, vars_.oldLevel); 73 | 74 | position.rewardCredit += Math.mulDiv( 75 | vars_.oldAmount, 76 | pool.curve.getFunction(vars_.oldLevel) * accRewardPerShare_, 77 | ACC_REWARD_PRECISION 78 | ) - position.rewardDebt; 79 | position.rewardDebt = Math.mulDiv( 80 | vars_.newAmount, 81 | pool.curve.getFunction(vars_.newLevel) * accRewardPerShare_, 82 | ACC_REWARD_PRECISION 83 | ); 84 | 85 | if (_harvestTo != address(0)) { 86 | vars_.received = _receivedReward(_rewardToken, position.rewardCredit); 87 | position.rewardCredit -= vars_.received; 88 | if (vars_.received != 0) { 89 | IERC20(_rewardToken).safeTransfer(_harvestTo, vars_.received); 90 | } 91 | } 92 | 93 | address rewarder_ = pool.rewarder; 94 | if (rewarder_ != address(0)) { 95 | _updateRewarder( 96 | IRewarder(rewarder_), 97 | pool.curve, 98 | _kind, 99 | _relicId, 100 | _amount, 101 | _harvestTo, 102 | vars_.oldAmount, 103 | vars_.oldLevel, 104 | vars_.newLevel 105 | ); 106 | } 107 | 108 | _updateTotalLpSuppliedUpdateRelic( 109 | pool, _kind, _amount, vars_.oldAmount, vars_.newAmount, vars_.oldLevel, vars_.newLevel 110 | ); 111 | 112 | return vars_.received; 113 | } 114 | 115 | /** 116 | * @dev Update the accumulated reward per share for a given pool. 117 | * This function calculates the amount of rewards that have been distributed since the last reward update, 118 | * and adds it to the accumulated reward per share. 119 | * @param pool The PoolInfo structure representing the pool to be updated. 120 | * @param _emissionRate The current emission rate. 121 | * @param _totalAllocPoint The total allocation points. 122 | * @return accRewardPerShare_ The updated accumulated reward per share. 123 | */ 124 | function _updatePool(PoolInfo storage pool, uint256 _emissionRate, uint256 _totalAllocPoint) 125 | internal 126 | returns (uint256 accRewardPerShare_) 127 | { 128 | uint256 timestamp_ = block.timestamp; 129 | uint256 lastRewardTime_ = uint256(pool.lastRewardTime); 130 | uint256 secondsSinceReward_ = timestamp_ - lastRewardTime_; 131 | 132 | accRewardPerShare_ = pool.accRewardPerShare; 133 | if (secondsSinceReward_ != 0) { 134 | uint256 lpSupply_ = pool.totalLpSupplied; 135 | 136 | if (lpSupply_ != 0) { 137 | uint256 reward_ = (secondsSinceReward_ * _emissionRate * uint256(pool.allocPoint)) 138 | / _totalAllocPoint; 139 | accRewardPerShare_ += Math.mulDiv(reward_, ACC_REWARD_PRECISION, lpSupply_); 140 | pool.accRewardPerShare = accRewardPerShare_; 141 | } 142 | 143 | pool.lastRewardTime = uint40(timestamp_); 144 | } 145 | } 146 | 147 | /** 148 | * @dev Update reward variables for all pools in the `poolInfo` array. 149 | * This function iterates through the array and calls the `_updatePool` function for each pool. 150 | * Be mindful of gas costs when calling this function, as the gas cost increases with the number of pools. 151 | * @param poolInfo An array of PoolInfo structures representing the pools to be updated. 152 | * @param _emissionRate The current emission rate. 153 | * @param _totalAllocPoint The total allocation points of all pools. 154 | */ 155 | function _massUpdatePools( 156 | PoolInfo[] storage poolInfo, 157 | uint256 _emissionRate, 158 | uint256 _totalAllocPoint 159 | ) internal { 160 | for (uint256 i_; i_ < poolInfo.length; ++i_) { 161 | _updatePool(poolInfo[i_], _emissionRate, _totalAllocPoint); 162 | } 163 | } 164 | 165 | /** 166 | * @dev Updates the total LP for each affected level when shifting or merging. 167 | * @param pool The pool for which the update is being made. 168 | * @param _fromLevel The level from which the transfer is happening. 169 | * @param _oldToLevel The old 'To' level. 170 | * @param _newToLevel The new 'To' level. 171 | * @param _amount The amount being transferred. 172 | * @param _toAmount The old 'To' amount. 173 | * @param _newToAmount The new 'To' amount, which is the sum of the old 'To' amount and the transferred amount. 174 | */ 175 | function _updateTotalLpSuppliedShiftMerge( 176 | PoolInfo storage pool, 177 | uint256 _fromLevel, 178 | uint256 _oldToLevel, 179 | uint256 _newToLevel, 180 | uint256 _amount, 181 | uint256 _toAmount, 182 | uint256 _newToAmount 183 | ) internal { 184 | ICurves curve_ = pool.curve; 185 | 186 | if (_fromLevel != _newToLevel) { 187 | pool.totalLpSupplied -= _amount * curve_.getFunction(_fromLevel); 188 | } 189 | if (_oldToLevel != _newToLevel) { 190 | pool.totalLpSupplied -= _toAmount * curve_.getFunction(_oldToLevel); 191 | } 192 | 193 | if (_fromLevel != _newToLevel && _oldToLevel != _newToLevel) { 194 | pool.totalLpSupplied += _newToAmount * curve_.getFunction(_newToLevel); 195 | } else if (_fromLevel != _newToLevel) { 196 | pool.totalLpSupplied += _amount * curve_.getFunction(_newToLevel); 197 | } else if (_oldToLevel != _newToLevel) { 198 | pool.totalLpSupplied += _toAmount * curve_.getFunction(_newToLevel); 199 | } 200 | } 201 | 202 | /** 203 | * @notice Updates the position's level based on entry time. 204 | * @param position The position being updated. 205 | * @param _oldLevel Level of position before update. 206 | * @return newLevel_ Level of position after update. 207 | */ 208 | function _updateLevel(PositionInfo storage position, uint256 _oldLevel) 209 | internal 210 | returns (uint256 newLevel_) 211 | { 212 | newLevel_ = block.timestamp - uint256(position.entry); 213 | if (_oldLevel != newLevel_) { 214 | position.level = uint40(newLevel_); 215 | } 216 | } 217 | 218 | /** 219 | * @notice Calculate the maturity weighted entry. 220 | * @param _amountBefore The previous relic amount. 221 | * @param _entryBefore Entry representing the past maturity. 222 | * @param _amountAdded Amount to add. 223 | * @param _entryAdded Entry of the amount to be added. 224 | * @return weightedEntry_ The weighted entry. 225 | */ 226 | function _weightEntry( 227 | uint256 _amountBefore, 228 | uint256 _entryBefore, 229 | uint256 _amountAdded, 230 | uint256 _entryAdded 231 | ) internal pure returns (uint40 weightedEntry_) { 232 | weightedEntry_ = uint40( 233 | Math.ceilDiv( 234 | _amountBefore * _entryBefore + _amountAdded * _entryAdded, 235 | _amountBefore + _amountAdded 236 | ) // round up div 237 | ); // unsafe cast ok 238 | } 239 | 240 | // -------------- Private -------------- 241 | 242 | function _updateRewarder( 243 | IRewarder _rewarder, 244 | ICurves _curve, 245 | Kind _kind, 246 | uint256 _relicId, 247 | uint256 _amount, 248 | address _harvestTo, 249 | uint256 _oldAmount, 250 | uint256 _oldLevel, 251 | uint256 _newLevel 252 | ) private { 253 | if (_kind == Kind.DEPOSIT) { 254 | _rewarder.onDeposit(_curve, _relicId, _amount, _oldAmount, _oldLevel, _newLevel); 255 | } else if (_kind == Kind.WITHDRAW) { 256 | _rewarder.onWithdraw(_curve, _relicId, _amount, _oldAmount, _oldLevel, _newLevel); 257 | } /* Kind.UPDATE */ else { 258 | _rewarder.onUpdate(_curve, _relicId, _oldAmount, _oldLevel, _newLevel); 259 | } 260 | 261 | if (_harvestTo != address(0)) { 262 | _rewarder.onReward(_relicId, _harvestTo); 263 | } 264 | } 265 | 266 | function _updateTotalLpSuppliedUpdateRelic( 267 | PoolInfo storage pool, 268 | Kind _kind, 269 | uint256 _amount, 270 | uint256 _oldAmount, 271 | uint256 _newAmount, 272 | uint256 _oldLevel, 273 | uint256 _newLevel 274 | ) private { 275 | ICurves curve_ = pool.curve; 276 | 277 | if (_oldLevel != _newLevel) { 278 | pool.totalLpSupplied -= _oldAmount * curve_.getFunction(_oldLevel); 279 | pool.totalLpSupplied += _newAmount * curve_.getFunction(_newLevel); 280 | } else if (_kind == Kind.DEPOSIT) { 281 | pool.totalLpSupplied += _amount * curve_.getFunction(_oldLevel); 282 | } else if (_kind == Kind.WITHDRAW) { 283 | pool.totalLpSupplied -= _amount * curve_.getFunction(_oldLevel); 284 | } 285 | } 286 | 287 | /** 288 | * @notice Updates the user's entry time based on the weight of their deposit or withdrawal. 289 | * @param position The position being updated. 290 | * @param _amount The amount of the deposit / withdrawal. 291 | */ 292 | function _updateEntry(PositionInfo storage position, uint256 _amount) private { 293 | uint256 amountBefore_ = uint256(position.amount); 294 | if (amountBefore_ == 0) { 295 | position.entry = uint40(block.timestamp); 296 | } else { 297 | position.entry = 298 | _weightEntry(amountBefore_, uint256(position.entry), _amount, block.timestamp); 299 | } 300 | } 301 | 302 | /** 303 | * @notice Calculate how much the owner will actually receive on harvest, given available reward tokens. 304 | * @param _pendingReward Amount of reward token owed. 305 | * @return received_ The minimum between amount owed and amount available. 306 | */ 307 | function _receivedReward(address _rewardToken, uint256 _pendingReward) 308 | private 309 | view 310 | returns (uint256 received_) 311 | { 312 | uint256 available_ = IERC20(_rewardToken).balanceOf(address(this)); 313 | received_ = (available_ > _pendingReward) ? _pendingReward : available_; 314 | } 315 | } 316 | -------------------------------------------------------------------------------- /contracts/rewarders/RollingRewarder.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity 0.8.23; 3 | 4 | import "../interfaces/IRollingRewarder.sol"; 5 | import "../interfaces/IRewarder.sol"; 6 | import "../interfaces/IReliquary.sol"; 7 | import "openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol"; 8 | import "openzeppelin-contracts/contracts/utils/math/Math.sol"; 9 | import "openzeppelin-contracts/contracts/access/Ownable.sol"; 10 | 11 | /// @title Rewarder that can be funded with a set token, distributing it over a period of time. 12 | contract RollingRewarder is IRollingRewarder { 13 | using SafeERC20 for IERC20; 14 | 15 | uint256 private constant REWARD_PER_SECOND_PRECISION = 10_000; 16 | 17 | address public immutable parent; 18 | address public immutable reliquary; 19 | address public immutable rewardToken; 20 | uint8 public immutable poolId; 21 | 22 | uint256 public lastDistributionTime; 23 | uint256 public distributionPeriod; 24 | uint256 public lastIssuanceTimestamp; 25 | 26 | uint256 public rewardPerSecond; 27 | uint256 public accRewardPerShare; 28 | 29 | mapping(uint256 => uint256) private rewardDebt; 30 | mapping(uint256 => uint256) private rewardCredit; 31 | 32 | // Errors 33 | error RollingRewarder__NOT_PARENT(); 34 | error RollingRewarder__NOT_OWNER(); 35 | error RollingRewarder__ZERO_INPUT(); 36 | 37 | // Events 38 | event LogOnReward(uint256 _relicId, uint256 _rewardAmount, address _to); 39 | event UpdateDistributionPeriod(uint256 _newDistributionPeriod); 40 | event Fund(uint256 _newDistributionPeriod); 41 | event Issue(uint256 _newDistributionPeriod); 42 | 43 | /// @dev We define owner of parent owner of the child too. 44 | modifier onlyOwner() { 45 | if (msg.sender != Ownable(parent).owner()) revert RollingRewarder__NOT_OWNER(); 46 | _; 47 | } 48 | 49 | /// @dev Limits function calls to address of parent contract `ParentRollingRewarder` 50 | modifier onlyParent() { 51 | if (msg.sender != parent) revert RollingRewarder__NOT_PARENT(); 52 | _; 53 | } 54 | 55 | /** 56 | * @dev Contructor called on deployment of this contract. 57 | * @param _rewardToken Address of token rewards are distributed in. 58 | * @param _reliquary Address of Reliquary this rewarder will read state from. 59 | */ 60 | constructor(address _rewardToken, address _reliquary, uint8 _poolId) { 61 | poolId = _poolId; 62 | parent = msg.sender; 63 | rewardToken = _rewardToken; 64 | reliquary = _reliquary; 65 | _updateDistributionPeriod(7 days); 66 | } 67 | 68 | // -------------- Admin -------------- 69 | 70 | function fund(uint256 _amount) external onlyOwner { 71 | IERC20(rewardToken).safeTransferFrom(msg.sender, address(this), _amount); 72 | _fund(_amount); 73 | } 74 | 75 | function updateDistributionPeriod(uint256 _newDistributionPeriod) external onlyOwner { 76 | _updateDistributionPeriod(_newDistributionPeriod); 77 | } 78 | 79 | // -------------- Hooks -------------- 80 | 81 | function onUpdate( 82 | ICurves _curve, 83 | uint256 _relicId, 84 | uint256 _amount, 85 | uint256 _oldLevel, 86 | uint256 _newLevel 87 | ) external virtual onlyParent { 88 | uint256 oldAmountMultiplied_ = _amount * _curve.getFunction(_oldLevel); 89 | uint256 newAmountMultiplied_ = _amount * _curve.getFunction(_newLevel); 90 | 91 | _issueTokens(); 92 | 93 | uint256 accRewardPerShare_ = accRewardPerShare; 94 | rewardCredit[_relicId] += Math.mulDiv( 95 | oldAmountMultiplied_, accRewardPerShare_, ACC_REWARD_PRECISION 96 | ) - rewardDebt[_relicId]; 97 | rewardDebt[_relicId] = 98 | Math.mulDiv(newAmountMultiplied_, accRewardPerShare_, ACC_REWARD_PRECISION); 99 | } 100 | 101 | /// @dev Must always be called after `onUpdate`, `onDeposit` or `onWithdraw`. 102 | function onReward(uint256 _relicId, address _to) external virtual onlyParent { 103 | uint256 pending_ = rewardCredit[_relicId]; 104 | 105 | if (pending_ != 0) { 106 | rewardCredit[_relicId] = 0; 107 | IERC20(rewardToken).safeTransfer(_to, pending_); 108 | emit LogOnReward(_relicId, pending_, _to); 109 | } 110 | } 111 | 112 | function onDeposit( 113 | ICurves _curve, 114 | uint256 _relicId, 115 | uint256 _depositAmount, 116 | uint256 _oldAmount, 117 | uint256 _oldLevel, 118 | uint256 _newLevel 119 | ) external virtual onlyParent { 120 | uint256 oldAmountMultiplied_ = _oldAmount * _curve.getFunction(_oldLevel); 121 | uint256 newAmountMultiplied_ = (_oldAmount + _depositAmount) * _curve.getFunction(_newLevel); 122 | 123 | _issueTokens(); 124 | 125 | uint256 accRewardPerShare_ = accRewardPerShare; 126 | rewardCredit[_relicId] += Math.mulDiv( 127 | oldAmountMultiplied_, accRewardPerShare_, ACC_REWARD_PRECISION 128 | ) - rewardDebt[_relicId]; 129 | rewardDebt[_relicId] = 130 | Math.mulDiv(newAmountMultiplied_, accRewardPerShare_, ACC_REWARD_PRECISION); 131 | } 132 | 133 | function onWithdraw( 134 | ICurves _curve, 135 | uint256 _relicId, 136 | uint256 _withdrawalAmount, 137 | uint256 _oldAmount, 138 | uint256 _oldLevel, 139 | uint256 _newLevel 140 | ) external virtual onlyParent { 141 | uint256 oldAmountMultiplied_ = _oldAmount * _curve.getFunction(_oldLevel); 142 | uint256 newAmountMultiplied_ = 143 | (_oldAmount - _withdrawalAmount) * _curve.getFunction(_newLevel); 144 | 145 | _issueTokens(); 146 | 147 | uint256 accRewardPerShare_ = accRewardPerShare; 148 | rewardCredit[_relicId] += Math.mulDiv( 149 | oldAmountMultiplied_, accRewardPerShare_, ACC_REWARD_PRECISION 150 | ) - rewardDebt[_relicId]; 151 | rewardDebt[_relicId] = 152 | Math.mulDiv(newAmountMultiplied_, accRewardPerShare_, ACC_REWARD_PRECISION); 153 | } 154 | 155 | function onSplit( 156 | ICurves _curve, 157 | uint256 _fromId, 158 | uint256 _newId, 159 | uint256 _amount, 160 | uint256 _fromAmount, 161 | uint256 _level 162 | ) external virtual onlyParent { 163 | _issueTokens(); 164 | 165 | uint256 accRewardPerShare_ = accRewardPerShare; 166 | uint256 multiplier_ = _curve.getFunction(_level); 167 | rewardCredit[_fromId] += Math.mulDiv( 168 | _fromAmount, multiplier_ * accRewardPerShare_, ACC_REWARD_PRECISION 169 | ) - rewardDebt[_fromId]; 170 | rewardDebt[_fromId] = Math.mulDiv( 171 | _fromAmount - _amount, multiplier_ * accRewardPerShare_, ACC_REWARD_PRECISION 172 | ); 173 | rewardDebt[_newId] = 174 | Math.mulDiv(_amount, multiplier_ * accRewardPerShare_, ACC_REWARD_PRECISION); 175 | } 176 | 177 | function onShift( 178 | ICurves _curve, 179 | uint256 _fromId, 180 | uint256 _toId, 181 | uint256 _amount, 182 | uint256 _oldFromAmount, 183 | uint256 _oldToAmount, 184 | uint256 _fromLevel, 185 | uint256 _oldToLevel, 186 | uint256 _newToLevel 187 | ) external virtual onlyParent { 188 | uint256 _multiplierFrom = _curve.getFunction(_fromLevel); 189 | 190 | _issueTokens(); 191 | 192 | uint256 accRewardPerShare_ = accRewardPerShare; 193 | rewardCredit[_fromId] += Math.mulDiv( 194 | _oldFromAmount, _multiplierFrom * accRewardPerShare_, ACC_REWARD_PRECISION 195 | ) - rewardDebt[_fromId]; 196 | rewardDebt[_fromId] = Math.mulDiv( 197 | _oldFromAmount - _amount, _multiplierFrom * accRewardPerShare_, ACC_REWARD_PRECISION 198 | ); 199 | rewardCredit[_toId] += Math.mulDiv( 200 | _oldToAmount, _curve.getFunction(_oldToLevel) * accRewardPerShare_, ACC_REWARD_PRECISION 201 | ) - rewardDebt[_toId]; 202 | rewardDebt[_toId] = Math.mulDiv( 203 | _oldToAmount + _amount, 204 | _curve.getFunction(_newToLevel) * accRewardPerShare_, 205 | ACC_REWARD_PRECISION 206 | ); 207 | } 208 | 209 | function onMerge( 210 | ICurves _curve, 211 | uint256 _fromId, 212 | uint256 _toId, 213 | uint256 _fromAmount, 214 | uint256 _toAmount, 215 | uint256 _fromLevel, 216 | uint256 _oldToLevel, 217 | uint256 _newToLevel 218 | ) external virtual onlyParent { 219 | uint256 fromAmountMultiplied_ = _fromAmount * _curve.getFunction(_fromLevel); 220 | uint256 oldToAmountMultiplied_ = _toAmount * _curve.getFunction(_oldToLevel); 221 | uint256 newToAmountMultiplied_ = (_toAmount + _fromAmount) * _curve.getFunction(_newToLevel); 222 | 223 | _issueTokens(); 224 | 225 | uint256 accRewardPerShare_ = accRewardPerShare; 226 | uint256 pendingTo_ = Math.mulDiv( 227 | accRewardPerShare_, fromAmountMultiplied_ + oldToAmountMultiplied_, ACC_REWARD_PRECISION 228 | ) + rewardCredit[_fromId] - rewardDebt[_fromId] - rewardDebt[_toId]; 229 | if (pendingTo_ != 0) { 230 | rewardCredit[_toId] += pendingTo_; 231 | } 232 | 233 | rewardCredit[_fromId] = 0; 234 | 235 | rewardDebt[_toId] = 236 | Math.mulDiv(newToAmountMultiplied_, accRewardPerShare_, ACC_REWARD_PRECISION); 237 | } 238 | 239 | // -------------- Internals -------------- 240 | 241 | function _updateDistributionPeriod(uint256 _newDistributionPeriod) internal { 242 | distributionPeriod = _newDistributionPeriod; 243 | emit UpdateDistributionPeriod(_newDistributionPeriod); 244 | } 245 | 246 | function _fund(uint256 _amount) internal { 247 | if (_amount == 0) revert RollingRewarder__ZERO_INPUT(); 248 | 249 | uint256 lastIssuanceTimestamp_ = lastIssuanceTimestamp; // Last time token was distributed. 250 | uint256 lastDistributionTime_ = lastDistributionTime; // Timestamp of the final distribution of tokens. 251 | uint256 amount_ = _amount; // Amount of tokens to add to the distribution. 252 | 253 | if (lastIssuanceTimestamp_ < lastDistributionTime_) { 254 | amount_ += getRewardAmount(lastDistributionTime_ - lastIssuanceTimestamp_); // Add to the funding amount that hasnt been issued. 255 | } 256 | 257 | uint256 distributionPeriod_ = distributionPeriod; // How many days will we distribute these assets over. 258 | rewardPerSecond = (amount_ * REWARD_PER_SECOND_PRECISION) / distributionPeriod_; // How many tokens per second will be distributed. 259 | lastDistributionTime = block.timestamp + distributionPeriod_; // When will the new final distribution be. 260 | lastIssuanceTimestamp = block.timestamp; // When was the last time tokens were distributed -- now. 261 | emit Fund(_amount); 262 | } 263 | 264 | function _issueTokens() internal returns (uint256 issuance_) { 265 | uint256 poolBalance_ = IReliquary(reliquary).getTotalLpSupplied(poolId); 266 | uint256 lastIssuanceTimestamp_ = lastIssuanceTimestamp; // Last time token was distributed. 267 | uint256 lastDistributionTime_ = lastDistributionTime; // Timestamp of the final distribution of tokens. 268 | 269 | if (lastIssuanceTimestamp_ < lastDistributionTime_) { 270 | uint256 endTimestamp_ = 271 | block.timestamp > lastDistributionTime_ ? lastDistributionTime_ : block.timestamp; 272 | issuance_ = getRewardAmount(endTimestamp_ - lastIssuanceTimestamp_); 273 | if (poolBalance_ != 0) { 274 | accRewardPerShare += Math.mulDiv(issuance_, ACC_REWARD_PRECISION, poolBalance_); 275 | } 276 | } 277 | lastIssuanceTimestamp = block.timestamp; 278 | emit Issue(issuance_); 279 | } 280 | 281 | // -------------- View -------------- 282 | 283 | /// @notice Returns the amount of pending rewardToken for a position from this rewarder. 284 | function pendingToken(uint256 _relicId) public view returns (uint256 amount_) { 285 | uint256 poolBalance_ = IReliquary(reliquary).getTotalLpSupplied(poolId); 286 | uint256 lastIssuanceTimestamp_ = lastIssuanceTimestamp; // Last time token was distributed. 287 | uint256 lastDistributionTime_ = lastDistributionTime; // Timestamp of the final distribution of tokens. 288 | uint256 newAccReward_ = accRewardPerShare; 289 | if (lastIssuanceTimestamp_ < lastDistributionTime_) { 290 | uint256 endTimestamp_ = 291 | block.timestamp > lastDistributionTime_ ? lastDistributionTime_ : block.timestamp; 292 | uint256 issuance_ = getRewardAmount(endTimestamp_ - lastIssuanceTimestamp_); 293 | if (poolBalance_ != 0) { 294 | newAccReward_ += Math.mulDiv(issuance_, ACC_REWARD_PRECISION, poolBalance_); 295 | } 296 | } 297 | 298 | PositionInfo memory position_ = IReliquary(reliquary).getPositionForId(_relicId); 299 | uint256 amountMultiplied_ = uint256(position_.amount) 300 | * IReliquary(reliquary).getPoolInfo(poolId).curve.getFunction(uint256(position_.level)); 301 | 302 | uint256 pending_ = Math.mulDiv(amountMultiplied_, newAccReward_, ACC_REWARD_PRECISION) 303 | - rewardDebt[_relicId]; 304 | pending_ += rewardCredit[_relicId]; 305 | 306 | amount_ = pending_; 307 | } 308 | 309 | function pendingTokens(uint256 _relicId) 310 | external 311 | view 312 | virtual 313 | override 314 | returns (address[] memory rewardTokens_, uint256[] memory rewardAmounts_) 315 | { 316 | rewardTokens_ = new address[](1); 317 | rewardTokens_[0] = rewardToken; 318 | 319 | rewardAmounts_ = new uint256[](1); 320 | rewardAmounts_[0] = pendingToken(_relicId); 321 | } 322 | 323 | function getRewardAmount(uint256 _seconds) public view returns (uint256) { 324 | return ((rewardPerSecond * _seconds) / REWARD_PER_SECOND_PRECISION); 325 | } 326 | } 327 | -------------------------------------------------------------------------------- /test/foundry/Reliquary.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity 0.8.23; 3 | 4 | import "forge-std/Test.sol"; 5 | import "forge-std/console.sol"; 6 | import "contracts/Reliquary.sol"; 7 | import "contracts/interfaces/IReliquary.sol"; 8 | import "contracts/nft_descriptors/NFTDescriptor.sol"; 9 | import "contracts/curves/LinearCurve.sol"; 10 | import "contracts/curves/LinearPlateauCurve.sol"; 11 | import "openzeppelin-contracts/contracts/token/ERC721/utils/ERC721Holder.sol"; 12 | import "contracts/curves/PolynomialPlateauCurve.sol"; 13 | import "./mocks/ERC20Mock.sol"; 14 | 15 | contract ReliquaryTest is ERC721Holder, Test { 16 | using Strings for address; 17 | using Strings for uint256; 18 | 19 | Reliquary reliquary; 20 | LinearCurve linearCurve; 21 | LinearPlateauCurve linearPlateauCurve; 22 | PolynomialPlateauCurve polynomialPlateauCurve; 23 | ERC20Mock oath; 24 | ERC20Mock testToken; 25 | address nftDescriptor; 26 | uint256 emissionRate = 1e17; 27 | 28 | // Linear function config (to config) 29 | uint256 slope = 100; // Increase of multiplier every second 30 | uint256 minMultiplier = 365 days * 100; // Arbitrary (but should be coherent with slope) 31 | uint256 plateau = 10 days; 32 | int256[] public coeff = [int256(100e18), int256(1e18), int256(5e15), int256(-1e13), int256(5e9)]; 33 | 34 | function setUp() public { 35 | int256[] memory coeffDynamic = new int256[](5); 36 | for (uint256 i = 0; i < 5; i++) { 37 | coeffDynamic[i] = coeff[i]; 38 | } 39 | 40 | oath = new ERC20Mock(18); 41 | reliquary = new Reliquary(address(oath), emissionRate, "Reliquary Deposit", "RELIC"); 42 | linearPlateauCurve = new LinearPlateauCurve(slope, minMultiplier, plateau); 43 | linearCurve = new LinearCurve(slope, minMultiplier); 44 | polynomialPlateauCurve = new PolynomialPlateauCurve(coeffDynamic, 850); 45 | 46 | oath.mint(address(reliquary), 100_000_000 ether); 47 | 48 | testToken = new ERC20Mock(6); 49 | nftDescriptor = address(new NFTDescriptor(address(reliquary))); 50 | 51 | reliquary.grantRole(keccak256("OPERATOR"), address(this)); 52 | testToken.mint(address(this), 100_000_000 ether); 53 | testToken.approve(address(reliquary), 1); 54 | reliquary.addPool( 55 | 100, 56 | address(testToken), 57 | address(0), 58 | linearCurve, 59 | "ETH Pool", 60 | nftDescriptor, 61 | true, 62 | address(5) 63 | ); 64 | 65 | testToken.approve(address(reliquary), type(uint256).max); 66 | } 67 | 68 | function testPolynomialCurve() public view { 69 | console.log(polynomialPlateauCurve.getFunction(8500)); 70 | } 71 | 72 | function testModifyPool() public { 73 | vm.expectEmit(true, true, false, true); 74 | emit ReliquaryEvents.LogPoolModified(0, 100, address(0), nftDescriptor); 75 | reliquary.modifyPool(0, 100, address(0), "USDC Pool", nftDescriptor, true); 76 | } 77 | 78 | function testRevertOnModifyInvalidPool() public { 79 | vm.expectRevert(IReliquary.Reliquary__NON_EXISTENT_POOL.selector); 80 | reliquary.modifyPool(1, 100, address(0), "USDC Pool", nftDescriptor, true); 81 | } 82 | 83 | function testRevertOnModifyPoolUnauthorized() public { 84 | vm.expectRevert(); 85 | vm.prank(address(1)); 86 | reliquary.modifyPool(0, 100, address(0), "USDC Pool", nftDescriptor, true); 87 | } 88 | 89 | function testPendingOath(uint256 amount, uint256 time) public { 90 | time = bound(time, 0, 3650 days); 91 | amount = bound(amount, 1, testToken.balanceOf(address(this))); 92 | uint256 relicId = reliquary.createRelicAndDeposit(address(this), 0, amount); 93 | skip(time); 94 | reliquary.update(relicId, address(0)); 95 | // reliquary.pendingReward(1) is the bootstrapped relic. 96 | assertApproxEqAbs( 97 | reliquary.pendingReward(relicId) + reliquary.pendingReward(1), 98 | time * emissionRate, 99 | (time * emissionRate) / 100000 100 | ); // max 0,0001% 101 | } 102 | 103 | function testCreateRelicAndDeposit(uint256 amount) public { 104 | amount = bound(amount, 1, testToken.balanceOf(address(this))); 105 | vm.expectEmit(true, true, true, true); 106 | emit ReliquaryEvents.Deposit(0, amount, address(this), 2); 107 | reliquary.createRelicAndDeposit(address(this), 0, amount); 108 | } 109 | 110 | function testDepositExisting(uint256 amountA, uint256 amountB) public { 111 | amountA = bound(amountA, 1, type(uint256).max / 2); 112 | amountB = bound(amountB, 1, type(uint256).max / 2); 113 | vm.assume(amountA + amountB <= testToken.balanceOf(address(this))); 114 | uint256 relicId = reliquary.createRelicAndDeposit(address(this), 0, amountA); 115 | reliquary.deposit(amountB, relicId, address(0)); 116 | assertEq(reliquary.getPositionForId(relicId).amount, amountA + amountB); 117 | } 118 | 119 | function testRevertOnDepositInvalidPool(uint8 pool) public { 120 | pool = uint8(bound(pool, 1, type(uint8).max)); 121 | vm.expectRevert(IReliquary.Reliquary__NON_EXISTENT_POOL.selector); 122 | reliquary.createRelicAndDeposit(address(this), pool, 1); 123 | } 124 | 125 | function testRevertOnDepositUnauthorized() public { 126 | uint256 relicId = reliquary.createRelicAndDeposit(address(this), 0, 1); 127 | vm.expectRevert(IReliquary.Reliquary__NOT_APPROVED_OR_OWNER.selector); 128 | vm.prank(address(1)); 129 | reliquary.deposit(1, relicId, address(0)); 130 | } 131 | 132 | function testWithdraw(uint256 amount) public { 133 | amount = bound(amount, 1, testToken.balanceOf(address(this))); 134 | uint256 relicId = reliquary.createRelicAndDeposit(address(this), 0, amount); 135 | vm.expectEmit(true, true, true, true); 136 | emit ReliquaryEvents.Withdraw(0, amount, address(this), relicId); 137 | reliquary.withdraw(amount, relicId, address(0)); 138 | } 139 | 140 | function testRevertOnWithdrawUnauthorized() public { 141 | uint256 relicId = reliquary.createRelicAndDeposit(address(this), 0, 1); 142 | vm.expectRevert(IReliquary.Reliquary__NOT_APPROVED_OR_OWNER.selector); 143 | vm.prank(address(1)); 144 | reliquary.withdraw(1, relicId, address(0)); 145 | } 146 | 147 | function testHarvest() public { 148 | testToken.transfer(address(1), 1.25 ether); 149 | 150 | vm.startPrank(address(1)); 151 | testToken.approve(address(reliquary), type(uint256).max); 152 | uint256 relicIdA = reliquary.createRelicAndDeposit(address(1), 0, 1 ether); 153 | skip(180 days); 154 | reliquary.withdraw(0.75 ether, relicIdA, address(0)); 155 | reliquary.deposit(1 ether, relicIdA, address(0)); 156 | 157 | vm.stopPrank(); 158 | uint256 relicIdB = reliquary.createRelicAndDeposit(address(this), 0, 100 ether); 159 | skip(180 days); 160 | reliquary.update(relicIdB, address(this)); 161 | 162 | vm.startPrank(address(1)); 163 | reliquary.update(relicIdA, address(this)); 164 | vm.stopPrank(); 165 | 166 | assertApproxEqAbs(oath.balanceOf(address(this)) / 1e18, 3110400, 1); 167 | } 168 | 169 | function testRevertOnHarvestUnauthorized() public { 170 | uint256 relicId = reliquary.createRelicAndDeposit(address(this), 0, 1); 171 | vm.expectRevert(IReliquary.Reliquary__NOT_APPROVED_OR_OWNER.selector); 172 | vm.prank(address(1)); 173 | reliquary.update(relicId, address(this)); 174 | } 175 | 176 | function testEmergencyWithdraw(uint256 amount) public { 177 | amount = bound(amount, 1, testToken.balanceOf(address(this))); 178 | uint256 relicId = reliquary.createRelicAndDeposit(address(this), 0, amount); 179 | vm.expectEmit(true, true, true, true); 180 | emit ReliquaryEvents.EmergencyWithdraw(0, amount, address(this), relicId); 181 | reliquary.emergencyWithdraw(relicId); 182 | } 183 | 184 | function testRevertOnEmergencyWithdrawNotOwner() public { 185 | uint256 relicId = reliquary.createRelicAndDeposit(address(this), 0, 1); 186 | vm.expectRevert(IReliquary.Reliquary__NOT_OWNER.selector); 187 | vm.prank(address(1)); 188 | reliquary.emergencyWithdraw(relicId); 189 | } 190 | 191 | function testSplit(uint256 depositAmount, uint256 splitAmount) public { 192 | depositAmount = bound(depositAmount, 1, testToken.balanceOf(address(this))); 193 | splitAmount = bound(splitAmount, 1, depositAmount); 194 | 195 | uint256 relicId = reliquary.createRelicAndDeposit(address(this), 0, depositAmount); 196 | uint256 newRelicId = reliquary.split(relicId, splitAmount, address(this)); 197 | 198 | assertEq(reliquary.balanceOf(address(this)), 2); 199 | assertEq(reliquary.getPositionForId(relicId).amount, depositAmount - splitAmount); 200 | assertEq(reliquary.getPositionForId(newRelicId).amount, splitAmount); 201 | } 202 | 203 | function testRevertOnSplitUnderflow(uint256 depositAmount, uint256 splitAmount) public { 204 | depositAmount = bound(depositAmount, 1, testToken.balanceOf(address(this)) / 2 - 1); 205 | splitAmount = bound( 206 | splitAmount, depositAmount + 1, testToken.balanceOf(address(this)) - depositAmount 207 | ); 208 | 209 | uint256 relicId = reliquary.createRelicAndDeposit(address(this), 0, depositAmount); 210 | vm.expectRevert(stdError.arithmeticError); 211 | reliquary.split(relicId, splitAmount, address(this)); 212 | } 213 | 214 | function testShift(uint256 depositAmount1, uint256 depositAmount2, uint256 shiftAmount) 215 | public 216 | { 217 | depositAmount1 = bound(depositAmount1, 1, testToken.balanceOf(address(this)) - 1); 218 | depositAmount2 = 219 | bound(depositAmount2, 1, testToken.balanceOf(address(this)) - depositAmount1); 220 | shiftAmount = bound(shiftAmount, 1, depositAmount1); 221 | 222 | uint256 relicId = reliquary.createRelicAndDeposit(address(this), 0, depositAmount1); 223 | uint256 newRelicId = reliquary.createRelicAndDeposit(address(this), 0, depositAmount2); 224 | reliquary.shift(relicId, newRelicId, shiftAmount); 225 | 226 | assertEq(reliquary.getPositionForId(relicId).amount, depositAmount1 - shiftAmount); 227 | assertEq(reliquary.getPositionForId(newRelicId).amount, depositAmount2 + shiftAmount); 228 | } 229 | 230 | function testRevertOnShiftUnderflow(uint256 depositAmount, uint256 shiftAmount) public { 231 | depositAmount = bound(depositAmount, 1, testToken.balanceOf(address(this)) / 2 - 1); 232 | shiftAmount = bound( 233 | shiftAmount, depositAmount + 1, testToken.balanceOf(address(this)) - depositAmount 234 | ); 235 | 236 | uint256 relicId = reliquary.createRelicAndDeposit(address(this), 0, depositAmount); 237 | uint256 newRelicId = reliquary.createRelicAndDeposit(address(this), 0, 1); 238 | vm.expectRevert(stdError.arithmeticError); 239 | reliquary.shift(relicId, newRelicId, shiftAmount); 240 | } 241 | 242 | function testMerge(uint256 depositAmount1, uint256 depositAmount2) public { 243 | depositAmount1 = bound(depositAmount1, 1, testToken.balanceOf(address(this)) - 1); 244 | depositAmount2 = 245 | bound(depositAmount2, 1, testToken.balanceOf(address(this)) - depositAmount1); 246 | 247 | uint256 relicId = reliquary.createRelicAndDeposit(address(this), 0, depositAmount1); 248 | uint256 newRelicId = reliquary.createRelicAndDeposit(address(this), 0, depositAmount2); 249 | reliquary.merge(relicId, newRelicId); 250 | 251 | assertEq(reliquary.getPositionForId(newRelicId).amount, depositAmount1 + depositAmount2); 252 | } 253 | 254 | function testCompareDepositAndMerge(uint256 amount1, uint256 amount2, uint256 time) public { 255 | amount1 = bound(amount1, 1, testToken.balanceOf(address(this)) - 1); 256 | amount2 = bound(amount2, 1, testToken.balanceOf(address(this)) - amount1); 257 | time = bound(time, 1, 356 days * 1); // 100 years 258 | 259 | console.log(amount1); 260 | console.log(amount2); 261 | console.log(time); 262 | 263 | uint256 relicId = reliquary.createRelicAndDeposit(address(this), 0, amount1); 264 | skip(time); 265 | reliquary.deposit(amount2, relicId, address(0)); 266 | uint256 maturity1 = block.timestamp - reliquary.getPositionForId(relicId).entry; 267 | 268 | //reset maturity 269 | reliquary.withdraw(amount1 + amount2, relicId, address(0)); 270 | reliquary.deposit(amount1, relicId, address(0)); 271 | 272 | skip(time); 273 | uint256 newRelicId = reliquary.createRelicAndDeposit(address(this), 0, amount2); 274 | reliquary.merge(newRelicId, relicId); 275 | uint256 maturity2 = block.timestamp - reliquary.getPositionForId(relicId).entry; 276 | 277 | assertApproxEqAbs(maturity1, maturity2, 1); 278 | } 279 | 280 | function testMergeAfterSplit() public { 281 | uint256 depositAmount1 = 100 ether; 282 | uint256 depositAmount2 = 50 ether; 283 | uint256 relicId = reliquary.createRelicAndDeposit(address(this), 0, depositAmount1); 284 | skip(2 days); 285 | reliquary.update(relicId, address(this)); 286 | reliquary.split(relicId, 50 ether, address(this)); 287 | uint256 newRelicId = reliquary.createRelicAndDeposit(address(this), 0, depositAmount2); 288 | reliquary.merge(relicId, newRelicId); 289 | assertEq(reliquary.getPositionForId(newRelicId).amount, 100 ether); 290 | } 291 | 292 | function testBurn() public { 293 | uint256 relicId = reliquary.createRelicAndDeposit(address(this), 0, 1 ether); 294 | vm.expectRevert(IReliquary.Reliquary__BURNING_PRINCIPAL.selector); 295 | reliquary.burn(relicId); 296 | 297 | reliquary.withdraw(1 ether, relicId, address(this)); 298 | vm.expectRevert(IReliquary.Reliquary__NOT_APPROVED_OR_OWNER.selector); 299 | vm.prank(address(1)); 300 | reliquary.burn(relicId); 301 | assertEq(reliquary.balanceOf(address(this)), 1); 302 | 303 | reliquary.burn(relicId); 304 | assertEq(reliquary.balanceOf(address(this)), 0); 305 | } 306 | 307 | function testPocShiftVulnerability() public { 308 | uint256 idParent = reliquary.createRelicAndDeposit(address(this), 0, 10000 ether); 309 | skip(366 days); 310 | reliquary.update(idParent, address(0)); 311 | 312 | for (uint256 i = 0; i < 10; i++) { 313 | uint256 idChild = reliquary.createRelicAndDeposit(address(this), 0, 10 ether); 314 | reliquary.shift(idParent, idChild, 1); 315 | reliquary.update(idParent, address(0)); 316 | uint256 levelChild = reliquary.getPositionForId(idChild).level; 317 | assertEq(levelChild, 0); // assert max level 318 | } 319 | } 320 | 321 | function testPause() public { 322 | reliquary.grantRole(keccak256("OPERATOR"), address(this)); 323 | vm.expectRevert(); 324 | reliquary.pause(); 325 | 326 | reliquary.createRelicAndDeposit(address(this), 0, 1000); 327 | 328 | reliquary.grantRole(keccak256("GUARDIAN"), address(this)); 329 | reliquary.pause(); 330 | vm.expectRevert(); 331 | reliquary.createRelicAndDeposit(address(this), 0, 1000); 332 | 333 | reliquary.unpause(); 334 | reliquary.createRelicAndDeposit(address(this), 0, 1000); 335 | } 336 | } 337 | -------------------------------------------------------------------------------- /audit/ReliquaryV2-audit-Cergyk-report.md: -------------------------------------------------------------------------------- 1 | # 1. About cergyk 2 | 3 | cergyk is a smart contract security expert, highly ranked accross a variety of audit contest platforms. He has helped multiple protocols in preventing critical exploits since 2022. 4 | 5 | # 2. Introduction 6 | 7 | A time-boxed security review of the `Reliquary V2` protocol was done by cergyk, with a focus on the security aspects of the application's smart contracts implementation. 8 | 9 | # 3. Disclaimer 10 | A smart contract security review can never verify the complete absence of vulnerabilities. This is 11 | a time, resource and expertise bound effort aimed at finding as many vulnerabilities as 12 | possible. We can not guarantee 100% security after the review or even if the review will find any 13 | problems with your smart contracts. Subsequent security reviews, bug bounty programs and on- 14 | chain monitoring are strongly recommended. 15 | 16 | # 4. About Reliquary V2 17 | 18 | Reliquary is an improvement of the famous MasterChef contract. The goal is to distribute a reward token proportional to the amount of a user's deposit. The protocol owner can credit the contract with the reward token and set the desired issuance per second. 19 | 20 | Compared to Masterchef, the `Reliquary V2` contract offers more flexibility and customization: 21 | 22 | 1. Emits tokens based on the maturity of a user's investment. 23 | 2. Binds variable emission rates to a base emission curve designed by the developer for predictable emissions. 24 | 3. Supports deposits and withdrawals along with these variable rates. 25 | 4. Issues a 'financial NFT' to users which represents their underlying positions, able to be traded and leveraged without removing the underlying liquidity. 26 | 5. Can emit multiple types of rewards for each investment, as well as handle complex reward mechanisms based on deposit and withdrawal. 27 | 28 | The novelty implemented in this iteration is the addition of non-linear (polynomial) unlocking curves. 29 | 30 | # 5. Security Assessment Summary 31 | 32 | ***review commit hash* - [a3f686e4](https://github.com/beirao/Reliquary/commit/a3f686e4609f58bc5665c80f3f97eb9fe7c6d668)** 33 | 34 | ***fixes review commit hash* - [f262a1eb](https://github.com/beirao/Reliquary/commit/f262a1ebe9c45d7514028604152a702f8f6470b5)** 35 | 36 | ## Deployment chains 37 | 38 | - All EVM chains 39 | 40 | ## Scope 41 | 42 | The following smart contracts were in scope of the audit: (total : 975 SLoC) 43 | 44 | - [`Reliquary.sol`](https://github.com/beirao/Reliquary/blob/a3f686e4609f58bc5665c80f3f97eb9fe7c6d668/contracts/Reliquary.sol) 45 | - [`ParentRollingRewarder.sol`](https://github.com/beirao/Reliquary/blob/a3f686e4609f58bc5665c80f3f97eb9fe7c6d668/contracts/rewarders/ParentRollingRewarder.sol) 46 | - [`RollingRewarder.sol`](https://github.com/beirao/Reliquary/blob/a3f686e4609f58bc5665c80f3f97eb9fe7c6d668/contracts/rewarders/RollingRewarder.sol) 47 | - [`ReliquaryLogic.sol`](https://github.com/beirao/Reliquary/blob/a3f686e4609f58bc5665c80f3f97eb9fe7c6d668/contracts/libraries/ReliquaryLogic.sol) 48 | - [`LinearCurve.sol`](https://github.com/beirao/Reliquary/blob/a3f686e4609f58bc5665c80f3f97eb9fe7c6d668/contracts/curves/LinearCurve.sol) 49 | - [`LinearPlateauCurve.sol`](https://github.com/beirao/Reliquary/blob/a3f686e4609f58bc5665c80f3f97eb9fe7c6d668/contracts/curves/LinearPlateauCurve.sol) 50 | - [`PolynomialPlateauCurve.sol`](https://github.com/beirao/Reliquary/blob/a3f686e4609f58bc5665c80f3f97eb9fe7c6d668/contracts/curves/PolynomialPlateauCurve.sol) 51 | 52 | # 6. Executive Summary 53 | 54 | A security review of the contracts of Reliquary has been conducted during **5 days**. 55 | A total of **14 findings** have been identified and can be classified as below: 56 | 57 | ### Protocol 58 | | | Details| 59 | |---------------|--------------------| 60 | | **Protocol Name** | Reliquary V2 | 61 | | **Repository** | [Reliquary V2](https://github.com/beirao/Reliquary/blob/a3f686e4609f58bc5665c80f3f97eb9fe7c6d668/contracts) | 62 | | **Date** | April 2nd 2024 - April 8th 2024 | 63 | | **Type** | Rewards distributor | 64 | 65 | ### Findings Count 66 | | Severity | Findings Count | 67 | |-----------|----------------| 68 | | Critical | 0 | 69 | | High | 1 | 70 | | Medium | 5 | 71 | | Low | 3 | 72 | | Info/Gas | 4 | 73 | | **Total findings**| 13 | 74 | 75 | 76 | # 7. Findings summary 77 | | Findings | 78 | |-----------| 79 | |H-1 Wrong entry calculation in Reliquary::shift()| 80 | |M-1 Rounding in _updateEntry during deposit enables to increase positions without changing entry 81 | |M-2 Reliquary::addPool remove part of the existing rewards| 82 | |M-3 Reliquary::setEmissionRate does not update pools| 83 | |M-4 Reliquary::shift unfavorable rounding can be used to increase position level when shifting from mature position| 84 | |M-5 Linear average for entry can be gamed in case of non-linear curve functions| 85 | |L-1 Rewarder initialization Dos by front-run| 86 | |L-2 deposit and shift/merge have a slightly different average formula| 87 | |L-3 When a pool is updated and totalLpSupply == 0 the rewards are lost for the pool| 88 | | INFO-1 Change condition to require for readability | 89 | | INFO-2 Reentrancy available in burn() | 90 | | INFO-3 Reliquary::burn() doesn't clean positionForId mapping | 91 | | GAS-1 Loading the whole PoolInfo struct when using a specific parameter | 92 | 93 | # 8. Findings 94 | 95 | ## H-1 Wrong entry calculation in Reliquary::shift() 96 | 97 | ### Vulnerability detail 98 | 99 | The average entry point is wrongly weighted using the amount in the original `from` position, whereas only the portion `_amount` is transferred to the new `to` position and should be used to weight: 100 | 101 | [Reliquary::shift()](https://github.com/beirao/Reliquary/blob/a3f686e4609f58bc5665c80f3f97eb9fe7c6d668/contracts/Reliquary.sol#L421-L426): 102 | ```solidity 103 | toPosition.entry = uint40( 104 | ( 105 | vars_.fromAmount * uint256(fromPosition.entry) 106 | + vars_.toAmount * uint256(toPosition.entry) 107 | ) / (vars_.fromAmount + vars_.toAmount) 108 | ); // unsafe cast ok 109 | ``` 110 | 111 | As a result a malicious user can use a mature well funded position A to bump the level of a freshly created position B, by simply shifting 1 wei of A to B, and then repeat with other new positions. As a result the staking mechanism incentive is completely bypassed and rewards are unjustly overallocated to the malicious user. 112 | 113 | ### Recommendation 114 | 115 | use `_amount` to weight the average: 116 | 117 | ```diff 118 | toPosition.entry = uint40( 119 | ( 120 | - vars_.fromAmount * uint256(fromPosition.entry) 121 | + _amount * uint256(fromPosition.entry) 122 | + vars_.toAmount * uint256(toPosition.entry) 123 | - ) / (vars_.fromAmount + vars_.toAmount) 124 | + ) / (_amount + vars_.toAmount) 125 | ) / (vars_.fromAmount + vars_.toAmount) 126 | ); // unsafe cast ok 127 | ``` 128 | 129 | ### Fix review 130 | 131 | Fixed by: [bdbcc133](https://github.com/beirao/Reliquary/commit/bdbcc133abae4f5b1bca8adf5fb34fc806f1af70) 132 | 133 | ## M-1 Rounding in _updateEntry during deposit enables to increase positions without changing entry 134 | 135 | ### Vulnerability detail 136 | During a deposit on an existing position, the new entry is adjusted, but the use of rounding down can enable a user to add funds to an existing position without changing its entry: 137 | 138 | [ReliquaryLogic::_updateEntry](https://github.com/beirao/Reliquary/blob/a3f686e4609f58bc5665c80f3f97eb9fe7c6d668/contracts/libraries/ReliquaryLogic.sol#L297-L299): 139 | ```solidity 140 | position.entry = uint40( 141 | >> entryBefore_ + (maturity_ * _findWeight(_amount, amountBefore_)) / WEIGHT_PRECISION 142 | ); // unsafe cast ok 143 | ``` 144 | [ReliquaryLogic::_findWeight](https://github.com/beirao/Reliquary/blob/a3f686e4609f58bc5665c80f3f97eb9fe7c6d668/contracts/libraries/ReliquaryLogic.sol#L270-L283): 145 | ```solidity 146 | function _findWeight(uint256 _addedValue, uint256 _oldValue) 147 | private 148 | pure 149 | returns (uint256 weightNew_) 150 | { 151 | if (_oldValue < _addedValue) { 152 | weightNew_ = 153 | WEIGHT_PRECISION - (_oldValue * WEIGHT_PRECISION) / (_addedValue + _oldValue); 154 | } else if (_addedValue < _oldValue) { 155 | weightNew_ = (_addedValue * WEIGHT_PRECISION) / (_addedValue + _oldValue); 156 | } else { 157 | weightNew_ = WEIGHT_PRECISION / 2; 158 | } 159 | } 160 | ``` 161 | 162 | As can be seen in the formulas above, when `_addedValue` is small compared to `_oldValue`: 163 | ``` 164 | let _oldValue = X * _addedValue 165 | > Please note that the formula used in shift also rounds the entry down, which is not favorable to the protocol 166 | 167 | weight = WEIGHT_PRECISION/(X+1) 168 | 169 | so if X = _oldValue/_addedValue > _maturity, 170 | 171 | the following product will be rounded down to zero: 172 | (maturity_ * _findWeight(_amount, amountBefore_)) / WEIGHT_PRECISION = (maturity * WEIGHT_PRECISION / X) / WEIGHT_PRECISION 173 | 174 | as a result the new computed entry is entryBefore_ 175 | ``` 176 | 177 | This enables an attacker to grow a position by repeatedly adding small amounts (such as `oldAmount/amount > maturity`). Each time the operation is repeated, the attacker can add slightly more, since `oldAmount` is increased. 178 | 179 | ### Recommendation 180 | Rounding should generally be done in favor of the protocol, in this case up: 181 | 182 | ```diff 183 | position.entry = uint40( 184 | - entryBefore_ + (maturity_ * _findWeight(_amount, amountBefore_)) / WEIGHT_PRECISION 185 | + entryBefore_ + divUp((maturity_, _findWeight(_amount, amountBefore_), WEIGHT_PRECISION) 186 | ); // unsafe cast ok 187 | ``` 188 | 189 | ### Fix review 190 | 191 | Fixed by: [45fff8b3](https://github.com/beirao/Reliquary/commit/45fff8b362df8c29fedc1eaaafd106f15e5b9cf5) 192 | 193 | ## M-2 Reliquary::addPool remove part of the existing rewards 194 | 195 | ### Vulnerability detail 196 | 197 | Adding a new pool modifies the allocation distribution, which impacts the rewards. To distribute existing rewards fairly, Reliquary must update all of the pools before modifying the `totalAllocPoint`. 198 | 199 | In the current version `ReliquaryLogic::_massUpdatePools` is called, but after the `totalAllocPoint` has been updated with the allocation dedicated to the new pool. This means the portion of existing rewards proportional to the allocation of the new pool will be lost. 200 | 201 | [Reliquary::addPool](https://github.com/beirao/Reliquary/blob/a3f686e4609f58bc5665c80f3f97eb9fe7c6d668/contracts/Reliquary.sol#L124-L134): 202 | ```solidity 203 | // totalAllocPoint must never be zero. 204 | uint256 totalAlloc_ = totalAllocPoint + _allocPoint; 205 | if (totalAlloc_ == 0) revert Reliquary__ZERO_TOTAL_ALLOC_POINT(); 206 | totalAllocPoint = totalAlloc_; 207 | 208 | //! if _curve is not increasing, allowPartialWithdrawals must be set to false. 209 | //! We can't check this rule since curve are defined in [0, +infinity]. 210 | } 211 | // ----------------- 212 | 213 | ReliquaryLogic._massUpdatePools(poolInfo, emissionRate, totalAllocPoint); 214 | ``` 215 | 216 | ### Recommendation 217 | Call `_massUpdatePools` before modifying `totalAllocPoint`: 218 | 219 | ```solidity 220 | function addPool( 221 | uint256 _allocPoint, 222 | address _poolToken, 223 | address _rewarder, 224 | ICurves _curve, 225 | string memory _name, 226 | address _nftDescriptor, 227 | bool _allowPartialWithdrawals 228 | ) external onlyRole(DEFAULT_ADMIN_ROLE) { 229 | ReliquaryLogic._massUpdatePools(poolInfo, emissionRate, totalAllocPoint); 230 | ``` 231 | 232 | ### Fix review 233 | 234 | Fixed by: [f8767e5d](https://github.com/beirao/Reliquary/commit/f8767e5d23d9d72f82ab49f6038c9b314f2b2c9f) 235 | 236 | ## M-3 Reliquary::setEmissionRate does not update pools 237 | 238 | ### Vulnerability details 239 | Setting a new emissionRate impacts current rewards, because pools which have not been updated will distribute more for the stale period. This will create an unequal distribution of rewards between pools which have been updated at the point when rate is modified versus the ones which have not been updated. 240 | 241 | ### Recommendation 242 | Call `ReliquaryLogic::massUpdatePools` inside `setEmissionRate` to ensure the new emission rate does not impact pending reward distribution 243 | 244 | ```diff 245 | function setEmissionRate(uint256 _emissionRate) external onlyRole(EMISSION_RATE) { 246 | + ReliquaryLogic._massUpdatePools(poolInfo, emissionRate, totalAllocPoint); 247 | emissionRate = _emissionRate; 248 | emit ReliquaryEvents.LogSetEmissionRate(_emissionRate); 249 | } 250 | ``` 251 | 252 | ### Fix review 253 | 254 | Fixed by: [f2de3f7c](https://github.com/beirao/Reliquary/commit/f2de3f7cfb6a705f90ad3f63f6135e77a971aab0) 255 | 256 | ## M-4 Reliquary::shift unfavorable rounding can be used to increase position level when shifting from mature position 257 | 258 | ### Vulnerability details 259 | When shifting from an existing position A to another position B, the following weighted average is applied in order to compute the new entry of B: 260 | 261 | ``` 262 | toPosition.entry = uint40( 263 | ( 264 | _amount * uint256(fromPosition.entry) 265 | + vars_.toAmount * uint256(toPosition.entry) 266 | ) / (_amount + vars_.toAmount) 267 | ); // unsafe cast ok 268 | ``` 269 | 270 | The issue with this calculation is that rounding is not in favor of the protocol, which enables for manipulation. A malicious user can simply repeatedly shift 1 wei from a very mature position to a recent one, decreasing the entry of the target position by 1 second repeatedly. 271 | 272 | ### Recommendation 273 | `position.entry` should be rounded up in this calculation, please consider using Math.ceilDiv: 274 | 275 | ```diff 276 | toPosition.entry = uint40( 277 | - ( 278 | - _amount * uint256(fromPosition.entry) 279 | - + vars_.toAmount * uint256(toPosition.entry) 280 | - ) / (_amount + vars_.toAmount) 281 | + Math.ceilDiv( 282 | + _amount * uint256(fromPosition.entry) 283 | + + vars_.toAmount * uint256(toPosition.entry), 284 | + _amount + vars_.toAmount 285 | + ) 286 | ); // unsafe cast ok 287 | ``` 288 | 289 | ### Fix review 290 | 291 | Fixed by: [45fff8b3](https://github.com/beirao/Reliquary/commit/45fff8b362df8c29fedc1eaaafd106f15e5b9cf5) 292 | 293 | ## M-5 Linear average for entry can be gamed in case of non-linear curve functions 294 | 295 | ### Vulnerability details 296 | 297 | A linear amount-weighted average is used to determine the new entry of a position during `deposit`/`shift`/`merge` operations. This enables a user to increase positions while keeping the same level for some non-linear configurations (in this case `LinearPlateauCurve`): 298 | 299 | 300 | ``` 301 | level | t currentMaturity 302 | | | | 303 | | v________v________ 304 | | / 305 | | / 306 | | / 307 | | / 308 | | / 309 | | / 310 | |/_______________________ 311 | 0 t currentMaturity time 312 | ``` 313 | 314 | We can see that in the case of a `curve` with the shape depicted above, adding an amount of `((currentMaturity-t)/currentMaturity)*oldAmount` will not modify the level of the position (the position will stay on the ceiling part of the curve). 315 | 316 | As a result users can slightly game the non-linearity of the curve. 317 | 318 | ### Recommendation 319 | 320 | To have optimal fairness when using non-linear curves, one should keep all entries separate to be able to recompute the correct balance of a position. An alternative, albeit not simple solution would be to recompute a new curve for the merged position. 321 | 322 | ### Fix review 323 | 324 | Acknowledged 325 | 326 | ## L-1 Rewarder initialization Dos 327 | 328 | ### Vulnerability detail 329 | 330 | A rewarder is initialized when added to a pool, this means that if the rewarder is not added in the same transaction as it is registered to the pool, the initialization can be front-run by anyone. 331 | 332 | This only has the impact of making the `addPool` call revert which is low. 333 | 334 | ### Recommendation 335 | Separate the logic of initialization which sets `reliquary`, and registering the rewarder for a pool in a separate function `registerPool`. 336 | 337 | ParentRollingRewarder.sol: 338 | ```solidity 339 | function initialize(address _reliquary) external { 340 | if (reliquary != address(0)) { 341 | revert ParentRollingRewarder__ALREADY_INITIALIZED(); 342 | } 343 | reliquary = _reliquary; 344 | } 345 | ``` 346 | 347 | ParentRollingRewarder.sol: 348 | ```solidity 349 | function registerPool(uint8 poolId) external { 350 | require(msg.sender == reliquary, "Not reliquary"); 351 | if (poolId != type(uint8).max) { 352 | revert ParentRollingRewarder__ALREADY_INITIALIZED(); 353 | } 354 | poolId = _poolId; 355 | } 356 | ``` 357 | 358 | ### Fix review 359 | 360 | Acknowledged 361 | 362 | ## L-2 deposit and shift/merge have a slightly different average formula 363 | 364 | ### Vulnerability detail 365 | Functions which add some amount to an existing position must compute a weighted average to get the `entry` time of the new position. 366 | 367 | The problem lies in the fact `shift` and `merge` have a slightly different formula than `deposit`. As discussed in M-1 the rounding used in `deposit` can be used to increase the amount of positions without increasing `entry`. The capacity to do so is greatly reduced by the formula used in `shift`: 368 | [Reliquary::shift#L421-L426](https://github.com/beirao/Reliquary/blob/a3f686e4609f58bc5665c80f3f97eb9fe7c6d668/contracts/Reliquary.sol#L421-L426): 369 | ``` 370 | toPosition.entry = uint40( 371 | ( 372 | vars_.fromAmount * uint256(fromPosition.entry) 373 | + vars_.toAmount * uint256(toPosition.entry) 374 | ) / (vars_.fromAmount + vars_.toAmount) 375 | ); // unsafe cast ok 376 | ``` 377 | 378 | > Please note that the formula used in shift also rounds the entry down, which is not favorable to the protocol 379 | 380 | ### Recommendation 381 | Use the same function to compute the weighted average for `entry` (preferably the one currently used in shift). 382 | 383 | ### Fix review 384 | 385 | Fixed by: [45fff8b3](https://github.com/beirao/Reliquary/commit/45fff8b362df8c29fedc1eaaafd106f15e5b9cf5) 386 | 387 | 388 | ## L-3 When a pool is updated and totalLpSupply == 0 the rewards are lost for the pool 389 | 390 | ### Vulnerability detail 391 | 392 | When totalLpSupply == 0, the logic of distributing rewards is skipped, to avoid a division by zero: 393 | [ReliquaryLogic.sol#L136-L141](https://github.com/beirao/Reliquary/blob/a3f686e4609f58bc5665c80f3f97eb9fe7c6d668/contracts/libraries/ReliquaryLogic.sol#L136-L141) 394 | ```solidity 395 | if (lpSupply_ != 0) { 396 | uint256 reward_ = (secondsSinceReward_ * _emissionRate * uint256(pool.allocPoint)) 397 | / _totalAllocPoint; 398 | accRewardPerShare_ += Math.mulDiv(reward_, ACC_REWARD_PRECISION, lpSupply_); 399 | pool.accRewardPerShare = accRewardPerShare_; 400 | } 401 | 402 | pool.lastRewardTime = uint40(timestamp_); 403 | ``` 404 | 405 | Unless the protocol team supplies some amount of tokens when creating a pool, some amount of rewards can be lost if updating the pool when it is empty. 406 | 407 | > A variant of this is also present in the `RollingRewarder`, where the funded can be lost if the rewarder was already funded, which seems less likely than in `ReliquaryLogic`. 408 | > [RollingRewarder.sol#L276-L281](https://github.com/beirao/Reliquary/blob/a3f686e4609f58bc5665c80f3f97eb9fe7c6d668/contracts/rewarders/RollingRewarder.sol#L276-L281) 409 | 410 | ### Recommendation 411 | 412 | Since the `emissionRate` is shared accross pools, one can first remove `pool.allocPoint` from `_totalAllocPoint` when pools are empty before actually distribute the rewards, which would share the pending rewards among pools which are not empty. 413 | 414 | ### Fix review 415 | 416 | Fixed by: [f262a1eb](https://github.com/beirao/Reliquary/commit/f262a1ebe9c45d7514028604152a702f8f6470b5) 417 | 418 | 419 | ## Informational/Gas 420 | ### INFO-1 Change condition to require for readability 421 | The following formula to check for an overflow would be more readable if expressed as an explicit require: 422 | https://github.com/beirao/Reliquary/blob/a3f686e4609f58bc5665c80f3f97eb9fe7c6d668/contracts/Reliquary.sol#L122 423 | 424 | ### INFO-2 Reentrancy available in burn() 425 | `Reliquary::burn()` lacks a `nonReentrant` modifier, the reentrancy can be used in `_safeMint()` during a call to `split()` to create an orphan position (actual position without an nft). 426 | 427 | ### INFO-3 Reliquary::burn() doesn't clean positionForId mapping 428 | As a result `pendingReward()` view function can return non-zero values for a burnt relic 429 | 430 | ### GAS-1 Better avoid returning the whole PoolInfo struct when using a specific parameter 431 | [RollingRewarder.sol#L268](https://github.com/beirao/Reliquary/blob/a3f686e4609f58bc5665c80f3f97eb9fe7c6d668/contracts/rewarders/RollingRewarder.sol#L268) 432 | 433 | [RollingRewarder.sol#L289](https://github.com/beirao/Reliquary/blob/a3f686e4609f58bc5665c80f3f97eb9fe7c6d668/contracts/rewarders/RollingRewarder.sol#L289) 434 | 435 | [RollingRewarder.sol#L304](https://github.com/beirao/Reliquary/blob/a3f686e4609f58bc5665c80f3f97eb9fe7c6d668/contracts/rewarders/RollingRewarder.sol#L304) 436 | 437 | Some considerable amount of gas can be saved by implementing the specific accessors: 438 | `getTotalLpSupplied` and `getPoolCurve`, instead of loading the whole `PoolInfo` struct from storage into memory as done currently. 439 | 440 | ### Fix review 441 | Informational and gas issues (excepted INFO-1 which is acknowledged), have been fixed by: [5477f2a9](https://github.com/beirao/Reliquary/commit/5477f2a9cb348bed675dff9aad8216d3f157a0c6) and [158ee9ef](https://github.com/beirao/Reliquary/commit/158ee9efed0902ee963be9b2729e5d303920cf38) -------------------------------------------------------------------------------- /test/echidna/ReliquaryProperties.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity 0.8.23; 3 | 4 | import "contracts/Reliquary.sol"; 5 | import "contracts/interfaces/IReliquary.sol"; 6 | import "./mocks/ERC20Mock.sol"; 7 | import "contracts/nft_descriptors/NFTDescriptor.sol"; 8 | import "lib/openzeppelin-contracts/contracts/token/ERC20/ERC20.sol"; 9 | import "contracts/interfaces/ICurves.sol"; 10 | import "contracts/curves/LinearCurve.sol"; 11 | import "contracts/curves/LinearPlateauCurve.sol"; 12 | import "contracts/curves/PolynomialPlateauCurve.sol"; 13 | 14 | // The only unfuzzed method is reliquary.setEmissionRate() 15 | contract User { 16 | function proxy( 17 | address target, 18 | bytes memory data 19 | ) public returns (bool success, bytes memory err) { 20 | return target.call(data); 21 | } 22 | 23 | function approveERC20(ERC20 target, address spender) public { 24 | target.approve(spender, type(uint256).max); 25 | } 26 | 27 | function onERC721Received( 28 | address, 29 | address, 30 | uint256, 31 | bytes calldata 32 | ) external pure returns (bytes4) { 33 | return this.onERC721Received.selector; 34 | } 35 | } 36 | 37 | struct DepositData { 38 | uint relicId; 39 | uint amount; 40 | bool isInit; 41 | } 42 | 43 | contract ReliquaryProperties { 44 | // Linear function config (to config) 45 | uint256 public slope = 1; // Increase of multiplier every second 46 | uint256 public minMultiplier = 365 days * 100; // Arbitrary (but should be coherent with slope) 47 | uint256 public plateauLinear = 10 days; 48 | uint256 public plateauPoly = 850; 49 | int256[] public coeff = [int256(100e18), int256(1e18), int256(5e15), int256(-1e13), int256(5e9)]; 50 | 51 | uint public emissionRate = 1e18; 52 | uint public initialMint = 100 ether; 53 | uint public immutable startTimestamp; 54 | 55 | uint8 public totalNbPools; 56 | uint public totalNbUsers; 57 | mapping(uint => bool) public isInit; 58 | 59 | uint[] public relicIds; 60 | uint8[] public poolIds; 61 | User[] public users; 62 | ICurves[] public curves; 63 | ERC20Mock[] public tokenPoolIds; 64 | uint public rewardLostByEmergencyWithdraw; 65 | 66 | ERC20Mock public rewardToken; 67 | Reliquary public reliquary; 68 | NFTDescriptor public nftDescriptor; 69 | LinearCurve linearCurve; 70 | LinearPlateauCurve linearPlateauCurve; 71 | PolynomialPlateauCurve polynomialPlateauCurve; 72 | 73 | event LogUint(uint256 a); 74 | 75 | constructor() payable { 76 | // config ----------- 77 | totalNbUsers = 10; // fix 78 | totalNbPools = 2; // the fuzzer can add new pools 79 | // ------------------ 80 | 81 | startTimestamp = block.timestamp; 82 | /// setup reliquary 83 | rewardToken = new ERC20Mock("OATH Token", "OATH"); 84 | reliquary = new Reliquary(address(rewardToken), emissionRate, "Relic", "NFT"); 85 | nftDescriptor = new NFTDescriptor(address(reliquary)); 86 | 87 | int256[] memory coeffDynamic = new int256[](5); 88 | for (uint256 i = 0; i < 5; i++) { 89 | coeffDynamic[i] = coeff[i]; 90 | } 91 | linearCurve = new LinearCurve(slope, minMultiplier); 92 | linearPlateauCurve = new LinearPlateauCurve(slope, minMultiplier, plateauLinear); 93 | polynomialPlateauCurve = new PolynomialPlateauCurve(coeffDynamic, plateauPoly); 94 | 95 | curves.push(linearCurve); 96 | curves.push(linearPlateauCurve); 97 | curves.push(polynomialPlateauCurve); 98 | 99 | rewardToken.mint(address(reliquary), 100 ether); // provide rewards to reliquary contract 100 | 101 | /// setup token pool 102 | for (uint8 i = 0; i < totalNbPools; i++) { 103 | ERC20Mock token = new ERC20Mock("Pool Token", "PT"); 104 | tokenPoolIds.push(token); 105 | 106 | token.mint(address(this), 1); 107 | token.approve(address(reliquary), 1); // approve 1 wei to bootstrap the pool 108 | 109 | // no rewarder for now 110 | reliquary.addPool( 111 | 100, 112 | address(token), 113 | address(0), 114 | polynomialPlateauCurve, 115 | "reaper", 116 | address(nftDescriptor), 117 | true, address(this) 118 | ); 119 | poolIds.push(i); 120 | } 121 | 122 | /// setup users 123 | // admin is this contract 124 | reliquary.grantRole(keccak256("DEFAULT_ADMIN_ROLE"), address(this)); 125 | reliquary.grantRole(keccak256("OPERATOR"), address(this)); 126 | reliquary.grantRole(keccak256("EMISSION_RATE"), address(this)); 127 | 128 | for (uint i = 0; i < totalNbUsers; i++) { 129 | User user = new User(); 130 | users.push(user); 131 | for (uint8 j = 0; j < tokenPoolIds.length; j++) { 132 | tokenPoolIds[j].mint(address(user), initialMint); 133 | user.approveERC20(tokenPoolIds[j], address(reliquary)); 134 | } 135 | } 136 | } 137 | 138 | // --------------------- state updates --------------------- 139 | 140 | /// random add pool 141 | function randAddPools( 142 | uint allocPoint, 143 | uint randSpacingMul, 144 | uint randSizeMul, 145 | uint randSpacingMat, 146 | uint randSizeMat, 147 | uint randCurves 148 | ) public { 149 | uint maxSize = 10; 150 | require(allocPoint > 0); 151 | uint8 startPoolIdsLen = uint8(poolIds.length); 152 | ERC20Mock token = new ERC20Mock("Pool Token", "PT"); 153 | tokenPoolIds.push(token); 154 | ICurves curve = curves[randCurves % curves.length]; 155 | 156 | token.mint(address(this), 1); 157 | token.approve(address(reliquary), 1); // approve 1 wei to bootstrap the pool 158 | 159 | // no rewarder for now 160 | reliquary.addPool( 161 | allocPoint % 10000 ether, // to avoid overflow on totalAllocPoint [0, 10000e18] 162 | address(token), 163 | address(0), 164 | curve, 165 | "reaper", 166 | address(nftDescriptor), 167 | true, address(this) 168 | ); 169 | poolIds.push(startPoolIdsLen); 170 | totalNbPools++; 171 | 172 | // mint new token and setup allowance for users 173 | for (uint i = 0; i < totalNbUsers; i++) { 174 | User user = users[i]; 175 | for (uint8 j = startPoolIdsLen; j < tokenPoolIds.length; j++) { 176 | tokenPoolIds[j].mint(address(user), initialMint); 177 | user.approveERC20(tokenPoolIds[j], address(reliquary)); 178 | } 179 | } 180 | } 181 | 182 | /// random modify pool 183 | function randModifyPools(uint8 randPoolId, uint allocPoint) public { 184 | reliquary.modifyPool( 185 | randPoolId % totalNbPools, 186 | allocPoint % 10000 ether, // to avoid overflow on totalAllocPoint [0, 10000e18] 187 | address(0), 188 | "reaper", 189 | address(nftDescriptor), 190 | true 191 | ); 192 | } 193 | 194 | /// random user create relic and deposit 195 | function randCreateRelicAndDeposit(uint randUser, uint8 randPool, uint randAmt) public { 196 | User user = users[randUser % users.length]; 197 | uint amount = (randAmt % initialMint) / 100 + 1; // with seqLen: 100 we should not have supply issues 198 | uint8 poolId = randPool % totalNbPools; 199 | ERC20 poolToken = ERC20(reliquary.getPoolInfo(poolId).poolToken); 200 | uint balanceReliquaryBefore = poolToken.balanceOf(address(reliquary)); 201 | uint balanceUserBefore = poolToken.balanceOf(address(user)); 202 | 203 | // if the user already has a relic, use deposit() 204 | (bool success, bytes memory data) = user.proxy( 205 | address(reliquary), 206 | abi.encodeWithSelector( 207 | reliquary.createRelicAndDeposit.selector, 208 | address(user), 209 | poolId, 210 | amount 211 | ) 212 | ); 213 | assert(success); 214 | uint relicId = abi.decode(data, (uint)); 215 | isInit[relicId] = true; 216 | relicIds.push(relicId); 217 | 218 | // reliquary balance must have increased by amount 219 | assert(poolToken.balanceOf(address(reliquary)) == balanceReliquaryBefore + amount); 220 | // user balance must have decreased by amount 221 | assert(poolToken.balanceOf(address(user)) == balanceUserBefore - amount); 222 | } 223 | 224 | /// random user deposit 225 | function randDeposit(uint randRelic, uint randAmt) public { 226 | uint relicId = relicIds[randRelic % relicIds.length]; 227 | User user = User(reliquary.ownerOf(relicId)); 228 | uint amount = (randAmt % initialMint) / 100 + 1; // with seqLen: 100 we should not have supply issues 229 | ERC20 poolToken = ERC20( 230 | reliquary.getPoolInfo(reliquary.getPositionForId(relicId).poolId).poolToken 231 | ); 232 | uint balanceReliquaryBefore = poolToken.balanceOf(address(reliquary)); 233 | uint balanceUserBefore = poolToken.balanceOf(address(user)); 234 | 235 | // if the user already has a relic, use deposit() 236 | if (isInit[relicId]) { 237 | (bool success, ) = user.proxy( 238 | address(reliquary), 239 | abi.encodeWithSelector(reliquary.deposit.selector, amount, relicId, address(0)) 240 | ); 241 | assert(success); 242 | } 243 | 244 | // reliquary balance must have increased by amount 245 | assert(poolToken.balanceOf(address(reliquary)) == balanceReliquaryBefore + amount); 246 | // user balance must have decreased by amount 247 | assert(poolToken.balanceOf(address(user)) == balanceUserBefore - amount); 248 | } 249 | 250 | /// random user deposit + harvest 251 | function randDepositAndHarvest(uint randRelic, uint randAmt) public { 252 | uint relicId = relicIds[randRelic % relicIds.length]; 253 | User user = User(reliquary.ownerOf(relicId)); 254 | uint amount = (randAmt % initialMint) / 100 + 1; // with seqLen: 100 we should not have supply issues 255 | ERC20 poolToken = ERC20( 256 | reliquary.getPoolInfo(reliquary.getPositionForId(relicId).poolId).poolToken 257 | ); 258 | uint balanceReliquaryBefore = poolToken.balanceOf(address(reliquary)); 259 | uint balanceUserBefore = poolToken.balanceOf(address(user)); 260 | 261 | // if the user already has a relic, use deposit() 262 | if (isInit[relicId]) { 263 | (bool success, ) = user.proxy( 264 | address(reliquary), 265 | abi.encodeWithSelector(reliquary.deposit.selector, amount, relicId, address(user)) 266 | ); 267 | assert(success); 268 | } 269 | 270 | // reliquary balance must have increased by amount 271 | assert(poolToken.balanceOf(address(reliquary)) == balanceReliquaryBefore + amount); 272 | // user balance must have decreased by amount 273 | assert(poolToken.balanceOf(address(user)) == balanceUserBefore - amount); 274 | } 275 | 276 | /// random withdraw 277 | function randWithdraw(uint randRelic, uint randAmt) public { 278 | uint relicId = relicIds[randRelic % relicIds.length]; 279 | User user = User(reliquary.ownerOf(relicId)); 280 | uint amount = reliquary.getPositionForId(relicId).amount; 281 | 282 | if (amount > 0) { 283 | uint amountToWithdraw = randAmt % (amount + 1); 284 | require(amountToWithdraw > 0); 285 | 286 | uint8 poolId = reliquary.getPositionForId(relicId).poolId; 287 | ERC20 poolToken = ERC20(reliquary.getPoolInfo(poolId).poolToken); 288 | 289 | uint balanceReliquaryBefore = poolToken.balanceOf(address(reliquary)); 290 | uint balanceUserBefore = poolToken.balanceOf(address(user)); 291 | 292 | // if the user already have a relic use deposit() 293 | (bool success, ) = user.proxy( 294 | address(reliquary), 295 | abi.encodeWithSelector( 296 | reliquary.withdraw.selector, 297 | amountToWithdraw, // withdraw more than amount deposited ]0, amount] 298 | relicId, 299 | address(0) 300 | ) 301 | ); 302 | assert(success); 303 | 304 | // reliquary balance must have decreased by amountToWithdraw 305 | assert( 306 | poolToken.balanceOf(address(reliquary)) == balanceReliquaryBefore - amountToWithdraw 307 | ); 308 | // user balance must have increased by amountToWithdraw 309 | assert(poolToken.balanceOf(address(user)) == balanceUserBefore + amountToWithdraw); 310 | } 311 | } 312 | 313 | /// random withdraw + harvest 314 | function randWithdrawAndHarvest(uint randRelic, uint randAmt) public { 315 | uint relicId = relicIds[randRelic % relicIds.length]; 316 | User user = User(reliquary.ownerOf(relicId)); 317 | uint amount = reliquary.getPositionForId(relicId).amount; 318 | 319 | if (amount > 0) { 320 | uint amountToWithdraw = randAmt % (amount + 1); 321 | 322 | uint8 poolId = reliquary.getPositionForId(relicId).poolId; 323 | ERC20 poolToken = ERC20(reliquary.getPoolInfo(poolId).poolToken); 324 | 325 | uint balanceReliquaryBefore = poolToken.balanceOf(address(reliquary)); 326 | uint balanceUserBefore = poolToken.balanceOf(address(user)); 327 | 328 | // if the user already have a relic use deposit() 329 | (bool success, ) = user.proxy( 330 | address(reliquary), 331 | abi.encodeWithSelector( 332 | reliquary.withdraw.selector, 333 | amountToWithdraw, // withdraw more than amount deposited ]0, amount] 334 | relicId, 335 | address(user) 336 | ) 337 | ); 338 | require(success); 339 | 340 | // reliquary balance must have decreased by amountToWithdraw 341 | assert( 342 | poolToken.balanceOf(address(reliquary)) == balanceReliquaryBefore - amountToWithdraw 343 | ); 344 | // user balance must have increased by amountToWithdraw 345 | assert(poolToken.balanceOf(address(user)) == balanceUserBefore + amountToWithdraw); 346 | } 347 | } 348 | 349 | /// random emergency withdraw 350 | function randEmergencyWithdraw(uint rand) public { 351 | uint relicId = relicIds[rand % relicIds.length]; 352 | 353 | PositionInfo memory pi = reliquary.getPositionForId(relicId); 354 | address owner = reliquary.ownerOf(relicId); 355 | ERC20 poolToken = ERC20(reliquary.getPoolInfo(pi.poolId).poolToken); 356 | uint amount = pi.amount; 357 | 358 | uint balanceReliquaryBefore = poolToken.balanceOf(address(reliquary)); 359 | uint balanceOwnerBefore = poolToken.balanceOf(owner); 360 | 361 | rewardLostByEmergencyWithdraw += reliquary.pendingReward(relicId); 362 | 363 | (bool success, ) = User(owner).proxy( 364 | address(reliquary), 365 | abi.encodeWithSelector(reliquary.emergencyWithdraw.selector, relicId) 366 | ); 367 | require(success); 368 | 369 | isInit[relicId] = false; 370 | 371 | // reliquary balance must have decreased by amount 372 | assert(poolToken.balanceOf(address(reliquary)) == balanceReliquaryBefore - amount); 373 | // user balance must have increased by amount 374 | assert(poolToken.balanceOf(address(owner)) == balanceOwnerBefore + amount); 375 | } 376 | 377 | /// harvest a position randomly 378 | function randHarvestPosition(uint rand) public { 379 | uint idToHasvest = rand % relicIds.length; 380 | address owner = reliquary.ownerOf(idToHasvest); 381 | 382 | uint balanceReliquaryBefore = rewardToken.balanceOf(address(reliquary)); 383 | uint balanceOwnerBefore = rewardToken.balanceOf(owner); 384 | uint amount = reliquary.pendingReward(idToHasvest); 385 | 386 | (bool success, ) = User(owner).proxy( 387 | address(reliquary), 388 | abi.encodeWithSelector(reliquary.update.selector, idToHasvest, owner) 389 | ); 390 | require(success); 391 | 392 | // reliquary balance must have increased by amount 393 | assert(rewardToken.balanceOf(address(reliquary)) == balanceReliquaryBefore - amount); 394 | // user balance must have decreased by amount 395 | assert(rewardToken.balanceOf(address(owner)) == balanceOwnerBefore + amount); 396 | } 397 | 398 | /// random split 399 | function randSplit(uint randRelic, uint randAmt, uint randUserTo) public { 400 | uint relicIdFrom = relicIds[randRelic % relicIds.length]; 401 | PositionInfo memory piFrom = reliquary.getPositionForId(relicIdFrom); 402 | uint amount = (randAmt % piFrom.amount); 403 | User owner = User(reliquary.ownerOf(relicIdFrom)); 404 | User to = User(users[randUserTo % users.length]); 405 | 406 | uint amountFromBefore = piFrom.amount; 407 | 408 | (bool success, bytes memory data) = owner.proxy( 409 | address(reliquary), 410 | abi.encodeWithSelector(reliquary.split.selector, relicIdFrom, amount, address(to)) 411 | ); 412 | require(success); 413 | uint relicIdTo = abi.decode(data, (uint)); 414 | isInit[relicIdTo] = true; 415 | relicIds.push(relicIdTo); 416 | 417 | assert(reliquary.getPositionForId(relicIdFrom).amount == amountFromBefore - amount); 418 | assert(reliquary.getPositionForId(relicIdTo).amount == amount); 419 | } 420 | 421 | /// random shift 422 | function randShift(uint randRelicFrom, uint randRelicTo, uint randAmt) public { 423 | uint relicIdFrom = relicIds[randRelicFrom % relicIds.length]; 424 | User user = User(reliquary.ownerOf(relicIdFrom)); // same user for from and to 425 | require(reliquary.balanceOf(address(user)) >= 2); 426 | uint relicIdTo = relicIds[randRelicTo % relicIds.length]; 427 | require(reliquary.ownerOf(relicIdTo) == address(user)); 428 | 429 | uint amountFromBefore = reliquary.getPositionForId(relicIdFrom).amount; 430 | uint amountToBefore = reliquary.getPositionForId(relicIdTo).amount; 431 | uint amount = (randAmt % amountFromBefore); 432 | 433 | (bool success, ) = user.proxy( 434 | address(reliquary), 435 | abi.encodeWithSelector(reliquary.shift.selector, relicIdFrom, relicIdTo, amount) 436 | ); 437 | require(success); 438 | 439 | assert(reliquary.getPositionForId(relicIdFrom).amount == amountFromBefore - amount); 440 | assert(reliquary.getPositionForId(relicIdTo).amount == amountToBefore + amount); 441 | } 442 | 443 | /// random merge 444 | function randMerge(uint randRelicFrom, uint randRelicTo) public { 445 | uint relicIdFrom = relicIds[randRelicFrom % relicIds.length]; 446 | User user = User(reliquary.ownerOf(relicIdFrom)); // same user for from and to 447 | require(reliquary.balanceOf(address(user)) >= 2); 448 | uint relicIdTo = relicIds[randRelicTo % relicIds.length]; 449 | // require(reliquary.ownerOf(relicIdTo) == address(user)); 450 | 451 | uint amountFromBefore = reliquary.getPositionForId(relicIdFrom).amount; 452 | uint amountToBefore = reliquary.getPositionForId(relicIdTo).amount; 453 | uint amount = amountFromBefore; 454 | 455 | (bool success, ) = user.proxy( 456 | address(reliquary), 457 | abi.encodeWithSelector(reliquary.merge.selector, relicIdFrom, relicIdTo) 458 | ); 459 | require(success); 460 | 461 | isInit[relicIdFrom] = false; 462 | 463 | assert(reliquary.getPositionForId(relicIdFrom).amount == 0); 464 | assert(reliquary.getPositionForId(relicIdTo).amount == amountToBefore + amount); 465 | } 466 | 467 | /// update a position randomly 468 | function randUpdatePosition(uint rand) public { 469 | reliquary.update(rand % relicIds.length, address(0)); 470 | } 471 | 472 | /// update a pool randomly 473 | function randUpdatePools(uint8 rand) public { 474 | reliquary.updatePool(rand % totalNbPools); 475 | } 476 | 477 | /// random burn 478 | function randBurn(uint rand) public { 479 | uint idToBurn = relicIds[rand % relicIds.length]; 480 | 481 | try reliquary.burn(idToBurn) { 482 | assert(isInit[idToBurn]); 483 | isInit[idToBurn] = false; 484 | } catch { 485 | assert(true); 486 | } 487 | } 488 | 489 | // ---------------------- Invariants ---------------------- 490 | 491 | /// @custom:invariant - A user should never be able to withdraw more than deposited. 492 | function tryTowithdrawMoreThanDeposit(uint randRelic, uint randAmt) public { 493 | uint relicId = relicIds[randRelic % relicIds.length]; 494 | User user = User(reliquary.ownerOf(relicId)); 495 | uint amount = reliquary.getPositionForId(relicId).amount; 496 | 497 | require(randAmt > amount); 498 | 499 | // if the user already have a relic use deposit() 500 | (bool success, ) = user.proxy( 501 | address(reliquary), 502 | abi.encodeWithSelector( 503 | reliquary.withdraw.selector, 504 | randAmt, // withdraw more than amount deposited ]amount, uint256.max] 505 | relicId, 506 | address(0) 507 | ) 508 | ); 509 | assert(!success); 510 | } 511 | 512 | /// @custom:invariant - No `position.entry` should be greater than `block.timestamp`. 513 | /// @custom:invariant - The sum of all `position.amount` should never be greater than total deposit. 514 | function positionParamsIntegrity() public view { 515 | uint[] memory totalAmtInPositions; 516 | PositionInfo memory pi; 517 | for (uint i; i < relicIds.length; i++) { 518 | pi = reliquary.getPositionForId(relicIds[i]); 519 | assert(pi.entry <= block.timestamp); 520 | totalAmtInPositions[pi.poolId] += pi.amount; 521 | } 522 | 523 | // this works if there are no pools with twice the same token 524 | for (uint8 pid; pid < totalNbPools; pid++) { 525 | uint totalBalance = ERC20(reliquary.getPoolInfo(pid).poolToken).balanceOf( 526 | address(reliquary) 527 | ); 528 | // check balances integrity 529 | assert(totalAmtInPositions[pid] == totalBalance); 530 | } 531 | } 532 | 533 | /// @custom:invariant - The sum of all `allocPoint` should be equal to `totalAllocpoint`. 534 | function poolallocPointIntegrity() public view { 535 | uint sum; 536 | for (uint8 i = 0; i < poolIds.length; i++) { 537 | sum += reliquary.getPoolInfo(i).allocPoint; 538 | } 539 | assert(sum == reliquary.totalAllocPoint()); 540 | } 541 | 542 | /// @custom:invariant - The total reward harvested and pending should never be greater than the total emission rate. 543 | /// @custom:invariant - `emergencyWithdraw` should burn position rewards. 544 | function poolEmissionIntegrity() public { 545 | // require(block.timestamp > startTimestamp + 12); 546 | uint totalReward = rewardLostByEmergencyWithdraw; 547 | 548 | for (uint i = 0; i < totalNbUsers; i++) { 549 | // account for tokenReward harvested 550 | totalReward += rewardToken.balanceOf(address(users[i])); 551 | } 552 | 553 | for (uint i = 0; i < relicIds.length; i++) { 554 | uint relicId = relicIds[i]; 555 | // account for tokenReward pending 556 | // check if position was burned 557 | if (isInit[relicId]) { 558 | totalReward += reliquary.pendingReward(relicId); 559 | } 560 | } 561 | 562 | // only works for constant emission rate 563 | uint maxEmission = (block.timestamp - startTimestamp) * reliquary.emissionRate(); 564 | 565 | assert(totalReward <= maxEmission); 566 | } 567 | 568 | // ---------------------- Helpers ---------------------- 569 | } 570 | -------------------------------------------------------------------------------- /test/foundry/MultipleRollingRewarder.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity 0.8.23; 3 | 4 | import "forge-std/Test.sol"; 5 | import "forge-std/console.sol"; 6 | import "contracts/Reliquary.sol"; 7 | import "contracts/nft_descriptors/NFTDescriptor.sol"; 8 | import "contracts/curves/LinearCurve.sol"; 9 | import "contracts/curves/LinearPlateauCurve.sol"; 10 | import "contracts/rewarders/RollingRewarder.sol"; 11 | import "contracts/rewarders/ParentRollingRewarder.sol"; 12 | import "openzeppelin-contracts/contracts/token/ERC721/utils/ERC721Holder.sol"; 13 | import "./mocks/ERC20Mock.sol"; 14 | 15 | contract MultipleRollingRewarder is ERC721Holder, Test { 16 | using Strings for address; 17 | using Strings for uint256; 18 | 19 | Reliquary public reliquary; 20 | LinearCurve public linearCurve; 21 | LinearPlateauCurve public linearPlateauCurve; 22 | ERC20Mock public oath; 23 | ERC20Mock public suppliedToken; 24 | ParentRollingRewarder public parentRewarder; 25 | 26 | uint256 public nbChildRewarder = 3; 27 | RollingRewarder[] public childRewarders; 28 | ERC20Mock[] public rewardTokens; 29 | 30 | address public nftDescriptor; 31 | 32 | //! here we set emission rate at 0 to simulate a pure collateral Ethos reward without any oath incentives. 33 | uint256 public emissionRate = 0; 34 | uint256 public initialMint = 100_000_000 ether; 35 | uint256 public initialDistributionPeriod = 7 days; 36 | 37 | // Linear function config (to config) 38 | uint256 public slope = 100; // Increase of multiplier every second 39 | uint256 public minMultiplier = 365 days * 100; // Arbitrary (but should be coherent with slope) 40 | uint256 public plateau = 100 days; 41 | 42 | address public alice = address(0xA99); 43 | address public bob = address(0xB99); 44 | address public carlos = address(0xC99); 45 | 46 | address[] public users = [alice, bob, carlos]; 47 | 48 | function setUp() public { 49 | oath = new ERC20Mock(18); 50 | 51 | reliquary = new Reliquary(address(oath), emissionRate, "Reliquary Deposit", "RELIC"); 52 | linearPlateauCurve = new LinearPlateauCurve(slope, minMultiplier, plateau); 53 | linearCurve = new LinearCurve(slope, minMultiplier); 54 | 55 | oath.mint(address(reliquary), initialMint); 56 | 57 | suppliedToken = new ERC20Mock(6); 58 | 59 | nftDescriptor = address(new NFTDescriptor(address(reliquary))); 60 | 61 | parentRewarder = new ParentRollingRewarder(); 62 | 63 | reliquary.grantRole(keccak256("OPERATOR"), address(this)); 64 | 65 | deal(address(suppliedToken), address(this), 1); 66 | suppliedToken.approve(address(reliquary), 1); // approve 1 wei to bootstrap the pool 67 | reliquary.addPool( 68 | 100, 69 | address(suppliedToken), 70 | address(parentRewarder), 71 | linearPlateauCurve, 72 | "ETH Pool", 73 | nftDescriptor, 74 | true, 75 | address(this) 76 | ); 77 | 78 | for (uint256 i = 0; i < nbChildRewarder; i++) { 79 | address rewardTokenTemp = address(new ERC20Mock(18)); 80 | address rewarderTemp = parentRewarder.createChild(rewardTokenTemp); 81 | rewardTokens.push(ERC20Mock(rewardTokenTemp)); 82 | childRewarders.push(RollingRewarder(rewarderTemp)); 83 | ERC20Mock(rewardTokenTemp).mint(address(this), initialMint); 84 | ERC20Mock(rewardTokenTemp).approve(address(reliquary), type(uint256).max); 85 | ERC20Mock(rewardTokenTemp).approve(address(rewarderTemp), type(uint256).max); 86 | } 87 | 88 | suppliedToken.mint(address(this), initialMint); 89 | suppliedToken.approve(address(reliquary), type(uint256).max); 90 | 91 | // fund user 92 | for (uint256 u = 0; u < users.length; u++) { 93 | vm.startPrank(users[u]); 94 | suppliedToken.mint(users[u], initialMint); 95 | suppliedToken.approve(address(reliquary), type(uint256).max); 96 | } 97 | } 98 | 99 | function testMultiRewards1( /*uint256 seedInitialFunding*/ ) public { 100 | uint256 seedInitialFunding = 100000000000000000; 101 | uint256[] memory initialFunding = new uint256[](nbChildRewarder); 102 | for (uint256 i = 0; i < nbChildRewarder; i++) { 103 | initialFunding[i] = bound(seedInitialFunding / (i + 1), 100000, initialMint); 104 | } 105 | 106 | uint256 initialInvest = 100 ether; 107 | uint256[] memory relics = new uint256[](users.length); 108 | for (uint256 u = 0; u < users.length; u++) { 109 | vm.startPrank(users[u]); 110 | relics[u] = reliquary.createRelicAndDeposit(users[u], 0, initialInvest); 111 | } 112 | vm.stopPrank(); 113 | 114 | for (uint256 i = 0; i < nbChildRewarder; i++) { 115 | childRewarders[i].fund(initialFunding[i]); 116 | } 117 | 118 | skip(initialDistributionPeriod); 119 | 120 | for (uint256 i = 0; i < nbChildRewarder; i++) { 121 | for (uint256 u = 0; u < users.length; u++) { 122 | (address[] memory rewardTokens_, uint256[] memory rewardAmounts_) = 123 | parentRewarder.pendingTokens(relics[u]); 124 | assertApproxEqRel(rewardAmounts_[i], initialFunding[i] / 3, 0.001e18); // 0,001% 125 | assertEq(address(rewardTokens_[i]), address(rewardTokens[i])); 126 | } 127 | } 128 | 129 | // withdraw 130 | for (uint256 u = 0; u < users.length; u++) { 131 | vm.startPrank(users[u]); 132 | reliquary.update(relics[u], users[u]); 133 | reliquary.withdraw(initialInvest, relics[u], address(0)); 134 | } 135 | 136 | for (uint256 i = 0; i < nbChildRewarder; i++) { 137 | for (uint256 u = 0; u < users.length; u++) { 138 | (, uint256[] memory rewardAmounts_) = parentRewarder.pendingTokens(relics[u]); 139 | assertEq(rewardAmounts_[i], 0); // 0,001% 140 | assertApproxEqRel( 141 | rewardTokens[i].balanceOf(users[u]), initialFunding[i] / 3, 0.001e18 142 | ); // 0,001% 143 | } 144 | } 145 | } 146 | 147 | function testMultiRewards2(uint256 seedInitialFunding) public { 148 | uint256[] memory initialFunding = new uint256[](nbChildRewarder); 149 | for (uint256 i = 0; i < nbChildRewarder; i++) { 150 | initialFunding[i] = bound(seedInitialFunding / (i + 1), 100000, initialMint / 2); 151 | } 152 | 153 | uint256 initialInvest = 100 ether; 154 | uint256[] memory relics = new uint256[](users.length); 155 | for (uint256 u = 0; u < users.length; u++) { 156 | vm.startPrank(users[u]); 157 | relics[u] = reliquary.createRelicAndDeposit(users[u], 0, initialInvest); 158 | } 159 | vm.stopPrank(); 160 | 161 | for (uint256 i = 0; i < nbChildRewarder; i++) { 162 | childRewarders[i].fund(initialFunding[i]); 163 | } 164 | 165 | skip(initialDistributionPeriod / 2); 166 | 167 | for (uint256 i = 0; i < nbChildRewarder; i++) { 168 | childRewarders[i].fund(initialFunding[i]); 169 | } 170 | 171 | skip(initialDistributionPeriod); 172 | 173 | for (uint256 i = 0; i < nbChildRewarder; i++) { 174 | for (uint256 u = 0; u < users.length; u++) { 175 | (address[] memory rewardTokens_, uint256[] memory rewardAmounts_) = 176 | parentRewarder.pendingTokens(relics[u]); 177 | assertApproxEqRel(rewardAmounts_[i], initialFunding[i] * 2 / 3, 0.001e18); // 0,001% 178 | assertEq(address(rewardTokens_[i]), address(rewardTokens[i])); 179 | } 180 | } 181 | 182 | // withdraw 183 | for (uint256 u = 0; u < users.length; u++) { 184 | vm.startPrank(users[u]); 185 | reliquary.withdraw(initialInvest, relics[u], users[u]); 186 | } 187 | 188 | for (uint256 i = 0; i < nbChildRewarder; i++) { 189 | for (uint256 u = 0; u < users.length; u++) { 190 | (, uint256[] memory rewardAmounts_) = parentRewarder.pendingTokens(relics[u]); 191 | assertEq(rewardAmounts_[i], 0); // 0,001% 192 | assertApproxEqRel( 193 | rewardTokens[i].balanceOf(users[u]), initialFunding[i] * 2 / 3, 0.001e18 194 | ); // 0,001% 195 | } 196 | } 197 | } 198 | 199 | function testMultiRewards3(uint256 seedInitialFunding) public { 200 | uint256[] memory initialFunding = new uint256[](nbChildRewarder); 201 | for (uint256 i = 0; i < nbChildRewarder; i++) { 202 | initialFunding[i] = bound(seedInitialFunding / (i + 1), 100000, initialMint / 2); 203 | } 204 | 205 | uint256 initialInvest = 100 ether; 206 | uint256[] memory relics = new uint256[](users.length); 207 | for (uint256 u = 0; u < users.length; u++) { 208 | vm.startPrank(users[u]); 209 | relics[u] = reliquary.createRelicAndDeposit(users[u], 0, initialInvest); 210 | } 211 | vm.stopPrank(); 212 | 213 | for (uint256 i = 0; i < nbChildRewarder; i++) { 214 | childRewarders[i].fund(initialFunding[i]); 215 | } 216 | 217 | skip(initialDistributionPeriod / 2); 218 | 219 | for (uint256 i = 0; i < nbChildRewarder; i++) { 220 | childRewarders[i].fund(initialFunding[i]); 221 | } 222 | 223 | skip(initialDistributionPeriod / 2); 224 | 225 | for (uint256 i = 0; i < nbChildRewarder; i++) { 226 | for (uint256 u = 0; u < users.length; u++) { 227 | (address[] memory rewardTokens_, uint256[] memory rewardAmounts_) = 228 | parentRewarder.pendingTokens(relics[u]); 229 | assertApproxEqRel(rewardAmounts_[i], initialFunding[i] / 3, 0.001e18); // 0,001% 230 | assertEq(address(rewardTokens_[i]), address(rewardTokens[i])); 231 | } 232 | } 233 | 234 | // withdraw 235 | for (uint256 u = 0; u < users.length; u++) { 236 | vm.startPrank(users[u]); 237 | reliquary.withdraw(initialInvest, relics[u], address(0)); 238 | reliquary.update(relics[u], users[u]); 239 | } 240 | 241 | for (uint256 i = 0; i < nbChildRewarder; i++) { 242 | for (uint256 u = 0; u < users.length; u++) { 243 | (, uint256[] memory rewardAmounts_) = parentRewarder.pendingTokens(relics[u]); 244 | assertEq(rewardAmounts_[i], 0); // 0,001% 245 | assertApproxEqRel( 246 | rewardTokens[i].balanceOf(users[u]), initialFunding[i] / 3, 0.001e18 247 | ); // 0,001% 248 | } 249 | } 250 | } 251 | 252 | function testMultiRewardsUpdate(uint256 seedInitialFunding) public { 253 | uint256[] memory initialFunding = new uint256[](nbChildRewarder); 254 | for (uint256 i = 0; i < nbChildRewarder; i++) { 255 | initialFunding[i] = bound(seedInitialFunding / (i + 1), 100000, initialMint / 2); 256 | } 257 | 258 | uint256 initialInvest = 100 ether; 259 | uint256[] memory relics = new uint256[](users.length); 260 | for (uint256 u = 0; u < users.length; u++) { 261 | vm.startPrank(users[u]); 262 | relics[u] = reliquary.createRelicAndDeposit(users[u], 0, initialInvest); 263 | } 264 | vm.stopPrank(); 265 | 266 | for (uint256 i = 0; i < nbChildRewarder; i++) { 267 | childRewarders[i].fund(initialFunding[i]); 268 | } 269 | 270 | skip(initialDistributionPeriod / 2); 271 | 272 | for (uint256 i = 0; i < nbChildRewarder; i++) { 273 | childRewarders[i].fund(initialFunding[i]); 274 | } 275 | 276 | skip(initialDistributionPeriod / 7); 277 | 278 | for (uint256 u = 0; u < users.length; u++) { 279 | vm.startPrank(users[u]); 280 | reliquary.update(relics[u], address(0)); 281 | } 282 | 283 | skip(initialDistributionPeriod); 284 | 285 | for (uint256 i = 0; i < nbChildRewarder; i++) { 286 | for (uint256 u = 0; u < users.length; u++) { 287 | (address[] memory rewardTokens_, uint256[] memory rewardAmounts_) = 288 | parentRewarder.pendingTokens(relics[u]); 289 | assertApproxEqRel(rewardAmounts_[i], initialFunding[i] * 2 / 3, 0.001e18); // 0,001% 290 | assertEq(address(rewardTokens_[i]), address(rewardTokens[i])); 291 | } 292 | } 293 | 294 | // withdraw 295 | for (uint256 u = 0; u < users.length; u++) { 296 | vm.startPrank(users[u]); 297 | reliquary.withdraw(initialInvest, relics[u], users[u]); 298 | } 299 | 300 | for (uint256 i = 0; i < nbChildRewarder; i++) { 301 | for (uint256 u = 0; u < users.length; u++) { 302 | (, uint256[] memory rewardAmounts_) = parentRewarder.pendingTokens(relics[u]); 303 | assertEq(rewardAmounts_[i], 0); // 0,001% 304 | assertApproxEqRel( 305 | rewardTokens[i].balanceOf(users[u]), initialFunding[i] * 2 / 3, 0.001e18 306 | ); // 0,001% 307 | } 308 | } 309 | } 310 | 311 | function testMultiRewardsSplit(uint256 seedInitialFunding) public { 312 | uint256[] memory initialFunding = new uint256[](nbChildRewarder); 313 | for (uint256 i = 0; i < nbChildRewarder; i++) { 314 | initialFunding[i] = bound(seedInitialFunding / (i + 1), 100000, initialMint); 315 | } 316 | 317 | uint256 initialInvest = 100 ether; 318 | uint256[] memory relics = new uint256[](users.length); 319 | for (uint256 u = 0; u < users.length; u++) { 320 | vm.startPrank(users[u]); 321 | relics[u] = reliquary.createRelicAndDeposit(users[u], 0, initialInvest); 322 | } 323 | vm.stopPrank(); 324 | 325 | for (uint256 i = 0; i < nbChildRewarder; i++) { 326 | childRewarders[i].fund(initialFunding[i]); 327 | } 328 | 329 | skip(initialDistributionPeriod / 2); 330 | 331 | // split first relic 332 | vm.prank(users[0]); 333 | uint256 u0SlittedRelic = reliquary.split(relics[0], initialInvest / 2, users[0]); 334 | 335 | skip(initialDistributionPeriod / 2); 336 | 337 | for (uint256 i = 0; i < nbChildRewarder; i++) { 338 | for (uint256 u = 1; u < users.length; u++) { 339 | (address[] memory rewardTokens_, uint256[] memory rewardAmounts_) = 340 | parentRewarder.pendingTokens(relics[u]); 341 | assertApproxEqRel(rewardAmounts_[i], initialFunding[i] / 3, 0.001e18); // 0,001% 342 | assertEq(address(rewardTokens_[i]), address(rewardTokens[i])); 343 | } 344 | } 345 | 346 | for (uint256 i = 0; i < nbChildRewarder; i++) { 347 | (, uint256[] memory rewardAmounts1_) = parentRewarder.pendingTokens(relics[0]); 348 | assertApproxEqRel( 349 | rewardAmounts1_[i], 350 | ( 351 | ((initialFunding[i] / 3) * (initialDistributionPeriod / 2)) 352 | + ((initialFunding[i] / 6) * (initialDistributionPeriod / 2)) 353 | ) / initialDistributionPeriod, 354 | 0.001e18 355 | ); // 0,001% 356 | 357 | (, uint256[] memory rewardAmounts2_) = parentRewarder.pendingTokens(u0SlittedRelic); 358 | assertApproxEqRel( 359 | rewardAmounts2_[i], 360 | ( 361 | (initialFunding[i] / 6) * (initialDistributionPeriod / 2) 362 | / initialDistributionPeriod 363 | ), 364 | 0.001e18 365 | ); // 0,001% 366 | } 367 | // withdraw 368 | for (uint256 u = 1; u < users.length; u++) { 369 | vm.startPrank(users[u]); 370 | reliquary.update(relics[u], users[u]); 371 | reliquary.withdraw(initialInvest, relics[u], address(0)); 372 | } 373 | 374 | vm.startPrank(users[0]); 375 | 376 | reliquary.update(relics[0], users[0]); 377 | reliquary.withdraw(initialInvest / 2, relics[0], address(0)); 378 | 379 | reliquary.update(u0SlittedRelic, users[0]); 380 | reliquary.withdraw(initialInvest / 2, u0SlittedRelic, address(0)); 381 | 382 | for (uint256 i = 0; i < nbChildRewarder; i++) { 383 | for (uint256 u = 0; u < users.length; u++) { 384 | (, uint256[] memory rewardAmounts_) = parentRewarder.pendingTokens(relics[u]); 385 | assertEq(rewardAmounts_[i], 0); // 0,001% 386 | assertApproxEqRel( 387 | rewardTokens[i].balanceOf(users[u]), initialFunding[i] / 3, 0.001e18 388 | ); // 0,001% 389 | } 390 | } 391 | } 392 | 393 | function testMultiRewardsShift(uint256 seedInitialFunding) public { 394 | uint256[] memory initialFunding = new uint256[](nbChildRewarder); 395 | for (uint256 i = 0; i < nbChildRewarder; i++) { 396 | initialFunding[i] = bound(seedInitialFunding / (i + 1), 100000, initialMint); 397 | } 398 | 399 | uint256 initialInvest = 100 ether; 400 | uint256[] memory relics = new uint256[](users.length); 401 | for (uint256 u = 0; u < users.length; u++) { 402 | vm.startPrank(users[u]); 403 | relics[u] = reliquary.createRelicAndDeposit(users[u], 0, initialInvest); 404 | } 405 | vm.stopPrank(); 406 | 407 | for (uint256 i = 0; i < nbChildRewarder; i++) { 408 | childRewarders[i].fund(initialFunding[i]); 409 | } 410 | 411 | skip(initialDistributionPeriod / 2); 412 | 413 | // shift first relic into the second one 414 | vm.prank(users[1]); 415 | reliquary.approve(users[0], relics[1]); 416 | vm.prank(users[0]); 417 | reliquary.shift(relics[0], relics[1], initialInvest / 2); 418 | 419 | skip(initialDistributionPeriod / 2); 420 | 421 | for (uint256 i = 0; i < nbChildRewarder; i++) { 422 | for (uint256 u = 2; u < users.length; u++) { 423 | (address[] memory rewardTokens_, uint256[] memory rewardAmounts_) = 424 | parentRewarder.pendingTokens(relics[u]); 425 | assertApproxEqRel(rewardAmounts_[i], initialFunding[i] / 3, 0.005e18); // 0,001% 426 | assertEq(address(rewardTokens_[i]), address(rewardTokens[i])); 427 | } 428 | } 429 | 430 | for (uint256 i = 0; i < nbChildRewarder; i++) { 431 | (, uint256[] memory rewardAmounts1_) = parentRewarder.pendingTokens(relics[0]); 432 | assertApproxEqRel( 433 | rewardAmounts1_[i], 434 | ( 435 | ((initialFunding[i] / 3) * (initialDistributionPeriod / 2)) 436 | + ((initialFunding[i] / 6) * (initialDistributionPeriod / 2)) 437 | ) / initialDistributionPeriod, 438 | 0.005e18 439 | ); // 0,005% 440 | 441 | // (, uint256[] memory rewardAmounts2_) = parentRewarder.pendingTokens(relics[1]); 442 | // assertApproxEqRel( 443 | // rewardAmounts2_[i], 444 | // ( 445 | // ((initialFunding[i] / 3) * (initialDistributionPeriod / 2)) 446 | // + ((initialFunding[i] * 2/ 3) * (initialDistributionPeriod / 2)) 447 | // ) / initialDistributionPeriod, 448 | // 0.001e18 449 | // ); // 0,001% 450 | } 451 | // withdraw 452 | for (uint256 u = 2; u < users.length; u++) { 453 | vm.startPrank(users[u]); 454 | reliquary.update(relics[u], users[u]); 455 | reliquary.withdraw(initialInvest, relics[u], address(0)); 456 | } 457 | 458 | vm.startPrank(users[0]); 459 | reliquary.update(relics[0], users[0]); 460 | reliquary.withdraw(initialInvest / 2, relics[0], address(0)); 461 | 462 | vm.startPrank(users[1]); 463 | reliquary.update(relics[1], users[1]); 464 | reliquary.withdraw(initialInvest + initialInvest / 2, relics[1], address(0)); 465 | 466 | for (uint256 i = 0; i < nbChildRewarder; i++) { 467 | for (uint256 u = 2; u < users.length; u++) { 468 | (, uint256[] memory rewardAmounts_) = parentRewarder.pendingTokens(relics[u]); 469 | assertEq(rewardAmounts_[i], 0); 470 | assertApproxEqRel( 471 | rewardTokens[i].balanceOf(users[u]), initialFunding[i] / 3, 0.005e18 472 | ); // 0,005% 473 | } 474 | } 475 | for (uint256 i = 0; i < nbChildRewarder; i++) { 476 | (, uint256[] memory rewardAmounts1_) = parentRewarder.pendingTokens(relics[0]); 477 | assertEq(rewardAmounts1_[i], 0); 478 | assertApproxEqRel( 479 | rewardTokens[i].balanceOf(users[0]), 480 | ( 481 | ((initialFunding[i] / 3) * (initialDistributionPeriod / 2)) 482 | + ((initialFunding[i] / 6) * (initialDistributionPeriod / 2)) 483 | ) / initialDistributionPeriod, 484 | 0.005e18 485 | ); // 0,005% 486 | 487 | (, uint256[] memory rewardAmounts2_) = parentRewarder.pendingTokens(relics[1]); 488 | assertEq(rewardAmounts2_[i], 0); // 0,001% 489 | // assertApproxEqRel( 490 | // rewardTokens[i].balanceOf(users[1]), 491 | // ( 492 | // ((initialFunding[i] / 3) * (initialDistributionPeriod / 2)) 493 | // + ((initialFunding[i] * 2/ 3) * (initialDistributionPeriod / 2)) 494 | // ) / initialDistributionPeriod, 495 | // 0.005e18 496 | // ); // 0,005% 497 | } 498 | } 499 | 500 | function testMultiRewardsMerge(uint256 seedInitialFunding) public { 501 | // uint256 seedInitialFunding = 100000000000000000; 502 | 503 | uint256[] memory initialFunding = new uint256[](nbChildRewarder); 504 | for (uint256 i = 0; i < nbChildRewarder; i++) { 505 | initialFunding[i] = bound(seedInitialFunding / (i + 1), 100000, initialMint); 506 | } 507 | 508 | uint256 initialInvest = 100 ether; 509 | uint256[] memory relics = new uint256[](users.length); 510 | for (uint256 u = 0; u < users.length; u++) { 511 | vm.startPrank(users[u]); 512 | relics[u] = reliquary.createRelicAndDeposit(users[u], 0, initialInvest); 513 | } 514 | vm.stopPrank(); 515 | 516 | for (uint256 i = 0; i < nbChildRewarder; i++) { 517 | childRewarders[i].fund(initialFunding[i]); 518 | } 519 | 520 | skip(initialDistributionPeriod / 2); 521 | 522 | // shift first relic into the second one 523 | vm.prank(users[1]); 524 | reliquary.approve(users[0], relics[1]); 525 | vm.prank(users[0]); 526 | reliquary.merge(relics[0], relics[1]); 527 | 528 | skip(initialDistributionPeriod / 2); 529 | 530 | for (uint256 i = 0; i < nbChildRewarder; i++) { 531 | for (uint256 u = 2; u < users.length; u++) { 532 | (address[] memory rewardTokens_, uint256[] memory rewardAmounts_) = 533 | parentRewarder.pendingTokens(relics[u]); 534 | assertApproxEqRel(rewardAmounts_[i], initialFunding[i] / 3, 0.005e18); // 0,001% 535 | assertEq(address(rewardTokens_[i]), address(rewardTokens[i])); 536 | } 537 | } 538 | 539 | for (uint256 i = 0; i < nbChildRewarder; i++) { 540 | (, uint256[] memory rewardAmounts1_) = parentRewarder.pendingTokens(relics[0]); 541 | assertEq(rewardAmounts1_[i], 0); 542 | 543 | (, uint256[] memory rewardAmounts2_) = parentRewarder.pendingTokens(relics[1]); 544 | assertApproxEqRel(rewardAmounts2_[i], initialFunding[i] * 2 / 3, 0.005e18); // 0,005% 545 | } 546 | // withdraw 547 | for (uint256 u = 2; u < users.length; u++) { 548 | vm.startPrank(users[u]); 549 | reliquary.update(relics[u], users[u]); 550 | reliquary.withdraw(initialInvest, relics[u], address(0)); 551 | } 552 | 553 | vm.startPrank(users[1]); 554 | reliquary.update(relics[1], users[1]); 555 | reliquary.withdraw(initialInvest * 2, relics[1], address(0)); 556 | 557 | for (uint256 i = 0; i < nbChildRewarder; i++) { 558 | for (uint256 u = 2; u < users.length; u++) { 559 | (, uint256[] memory rewardAmounts_) = parentRewarder.pendingTokens(relics[u]); 560 | assertEq(rewardAmounts_[i], 0); 561 | assertApproxEqRel( 562 | rewardTokens[i].balanceOf(users[u]), initialFunding[i] / 3, 0.005e18 563 | ); // 0,005% 564 | } 565 | } 566 | 567 | for (uint256 i = 0; i < nbChildRewarder; i++) { 568 | (, uint256[] memory rewardAmounts1_) = parentRewarder.pendingTokens(relics[0]); 569 | assertEq(rewardAmounts1_[i], 0); 570 | 571 | assertEq(rewardTokens[i].balanceOf(users[0]), 0); 572 | 573 | (, uint256[] memory rewardAmounts2_) = parentRewarder.pendingTokens(relics[1]); 574 | assertEq(rewardAmounts2_[i], 0); // 0,001% 575 | assertApproxEqRel( 576 | rewardTokens[i].balanceOf(users[1]), initialFunding[i] * 2 / 3, 0.005e18 577 | ); // 0,005% 578 | } 579 | } 580 | } 581 | --------------------------------------------------------------------------------