├── .editorconfig ├── .env.example ├── .forge-snapshots ├── ClaimMultiple.snap ├── ClaimSingle.snap ├── CreateBoost.snap ├── CreateBoostWithProtocolFee.snap ├── Deposit.snap ├── DepositWithProtocolFees.snap ├── Transfer.snap └── Withdraw.snap ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .gitmodules ├── LICENSE ├── README.md ├── deployments ├── base.json ├── goerli.json ├── mainnet.json └── sepolia.json ├── foundry.toml ├── remappings.txt ├── script └── Deployer.s.sol ├── src ├── Boost.sol └── IBoost.sol └── test ├── Boost.t.sol ├── Burn.t.sol ├── Claim.t.sol ├── Create.t.sol ├── Deposit.t.sol ├── ProtocolFees.t.sol ├── Transfer.t.sol └── mocks └── MockERC20.sol /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig http://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # All files 7 | [*] 8 | charset = utf-8 9 | end_of_line = lf 10 | indent_size = 2 11 | indent_style = space 12 | insert_final_newline = true 13 | trim_trailing_whitespace = true 14 | 15 | [*.sol] 16 | indent_size = 4 17 | 18 | [*.tree] 19 | indent_size = 1 20 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | PROTOCOL_OWNER= 2 | NETWORK= 3 | RPC_URL= 4 | PRIVATE_KEY= 5 | ETHERSCAN_API_KEY= 6 | -------------------------------------------------------------------------------- /.forge-snapshots/ClaimMultiple.snap: -------------------------------------------------------------------------------- 1 | 290490 -------------------------------------------------------------------------------- /.forge-snapshots/ClaimSingle.snap: -------------------------------------------------------------------------------- 1 | 59969 -------------------------------------------------------------------------------- /.forge-snapshots/CreateBoost.snap: -------------------------------------------------------------------------------- 1 | 131465 -------------------------------------------------------------------------------- /.forge-snapshots/CreateBoostWithProtocolFee.snap: -------------------------------------------------------------------------------- 1 | 136851 -------------------------------------------------------------------------------- /.forge-snapshots/Deposit.snap: -------------------------------------------------------------------------------- 1 | 11657 -------------------------------------------------------------------------------- /.forge-snapshots/DepositWithProtocolFees.snap: -------------------------------------------------------------------------------- 1 | 11669 -------------------------------------------------------------------------------- /.forge-snapshots/Transfer.snap: -------------------------------------------------------------------------------- 1 | 32060 -------------------------------------------------------------------------------- /.forge-snapshots/Withdraw.snap: -------------------------------------------------------------------------------- 1 | 34316 -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: "CI" 2 | 3 | env: 4 | FOUNDRY_PROFILE: "ci" 5 | 6 | on: 7 | pull_request: 8 | branches: 9 | - "main" 10 | push: 11 | branches: 12 | - "main" 13 | 14 | jobs: 15 | ci: 16 | runs-on: "ubuntu-latest" 17 | steps: 18 | - name: "Check out the repo" 19 | uses: "actions/checkout@v3" 20 | with: 21 | submodules: "recursive" 22 | 23 | - name: "Install Foundry" 24 | uses: "onbjerg/foundry-toolchain@v1" 25 | with: 26 | version: "nightly" 27 | 28 | - name: "Format the contracts" 29 | run: "forge fmt" 30 | 31 | - name: "Add Format summary" 32 | run: | 33 | echo "## Lint" >> $GITHUB_STEP_SUMMARY 34 | echo "✅ Passed" >> $GITHUB_STEP_SUMMARY 35 | 36 | - name: "Show the Foundry config" 37 | run: "forge config" 38 | 39 | - name: "Build the contracts" 40 | run: | 41 | forge --version 42 | forge build --sizes 43 | 44 | - name: "Run the tests" 45 | run: "forge test" 46 | 47 | - name: "Add test summary" 48 | run: | 49 | echo "## Tests" >> $GITHUB_STEP_SUMMARY 50 | echo "✅ Passed" >> $GITHUB_STEP_SUMMARY 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # directories 2 | .yarn/* 3 | !.yarn/patches 4 | !.yarn/releases 5 | !.yarn/plugins 6 | !.yarn/sdks 7 | !.yarn/versions 8 | **/cache 9 | **/node_modules 10 | **/out 11 | 12 | # files 13 | *.env 14 | *.log 15 | .DS_Store 16 | .pnp.* 17 | yarn-debug.log* 18 | yarn-error.log* 19 | 20 | # broadcasts 21 | !/broadcast 22 | /broadcast/* 23 | /broadcast/*/31337/ 24 | 25 | .env 26 | .yarn -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "lib/openzeppelin-contracts"] 2 | path = lib/openzeppelin-contracts 3 | url = https://github.com/OpenZeppelin/openzeppelin-contracts 4 | [submodule "lib/forge-std"] 5 | path = lib/forge-std 6 | url = https://github.com/brockelmore/forge-std 7 | [submodule "lib/forge-gas-snapshot"] 8 | path = lib/forge-gas-snapshot 9 | url = https://github.com/marktoda/forge-gas-snapshot 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Snapshot Labs 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](https://raw.githubusercontent.com/snapshot-labs/boost/master/LICENSE) 2 | 3 | # Boost 4 | 5 | Programmable token distribution. 6 | 7 | **[Documentation](https://docs.boost.limo)** 8 | 9 | ### Deployment 10 | 11 | To deploy the protocol to an EVM chain, first set the following environment variables: 12 | 13 | ```sh 14 | # The address of the account that will be set as the owner of the protocol. 15 | PROTOCOL_OWNER= 16 | # The name of the chain you want to deploy on. The addresses of the deployed contracts will be stored at /deployments/[network].json. 17 | NETWORK= 18 | # An RPC URL for the chain. 19 | RPC_URL= 20 | # Private Key for the deployer address. 21 | PRIVATE_KEY= 22 | # An API key for a block explorer on the chain. 23 | (Optional). 24 | ETHERSCAN_API_KEY= 25 | ``` 26 | 27 | Following this, a [Foundry Script](https://book.getfoundry.sh/tutorials/solidity-scripting) can be run to deploy the 28 | entire protocol. Example usage to deploy from a Ledger Hardware Wallet and verify on a block explorer: 29 | 30 | ```sh 31 | forge script script/Deployer.s.sol:Deployer --rpc-url $RPC_URL --optimize --broadcast --verify -vvvv 32 | ``` 33 | 34 | The script uses the [CREATE3 Factory](https://github.com/lifinance/create3-factory) for the deployments which ensures 35 | that the addresses of the contracts are the same on all chains even if the constructor arguments for the contract are 36 | different. 37 | -------------------------------------------------------------------------------- /deployments/base.json: -------------------------------------------------------------------------------- 1 | { 2 | "Boost": "0x8E8913197114c911F13cfBfCBBD138C1DC74B964" 3 | } -------------------------------------------------------------------------------- /deployments/goerli.json: -------------------------------------------------------------------------------- 1 | { 2 | "Boost": "0x60CADCcFd9BE4512546E068C569d7da628117E28" 3 | } -------------------------------------------------------------------------------- /deployments/mainnet.json: -------------------------------------------------------------------------------- 1 | { 2 | "Boost": "0x8E8913197114c911F13cfBfCBBD138C1DC74B964" 3 | } -------------------------------------------------------------------------------- /deployments/sepolia.json: -------------------------------------------------------------------------------- 1 | { 2 | "Boost": "0x8E8913197114c911F13cfBfCBBD138C1DC74B964" 3 | } -------------------------------------------------------------------------------- /foundry.toml: -------------------------------------------------------------------------------- 1 | # Full reference https://github.com/foundry-rs/foundry/tree/master/config 2 | 3 | [profile.default] 4 | auto_detect_solc = false 5 | bytecode_hash = "none" 6 | fuzz = { runs = 256 } 7 | gas_reports = ["*"] 8 | libs = ["lib"] 9 | optimizer = true 10 | optimizer_runs = 10_000 11 | out = "out" 12 | solc = "0.8.23" 13 | src = "src" 14 | test = "test" 15 | ffi = true 16 | fs_permissions = [{ access = "read-write", path = ".forge-snapshots/"}, { access = "read-write", path = "./deployments/"}] 17 | 18 | 19 | [profile.ci] 20 | fuzz = { runs = 1_000 } 21 | verbosity = 4 22 | 23 | -------------------------------------------------------------------------------- /remappings.txt: -------------------------------------------------------------------------------- 1 | forge-std/=lib/forge-std/src/ 2 | forge-gas-snapshot/=lib/forge-gas-snapshot/src 3 | openzeppelin-contracts/=lib/openzeppelin-contracts/contracts/ 4 | -------------------------------------------------------------------------------- /script/Deployer.s.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | 3 | pragma solidity ^0.8.23; 4 | 5 | import "forge-std/Script.sol"; 6 | import "../src/Boost.sol"; 7 | 8 | interface ICREATE3Factory { 9 | function deploy( 10 | bytes32 salt, 11 | bytes memory bytecode 12 | ) external returns (address deployedAddress); 13 | } 14 | 15 | contract Deployer is Script { 16 | using stdJson for string; 17 | 18 | string internal deployments; 19 | string internal deploymentsPath; 20 | 21 | string constant boostName = "boost"; 22 | string constant boostSymbol = "BOOST"; 23 | string constant boostVersion = "0.1.0"; 24 | uint256 constant ethFee = 0; // 0.00 ETH 25 | uint256 constant tokenFee = 0; 26 | uint256 constant nonce = 10; 27 | 28 | function run() external { 29 | uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); 30 | string memory network = vm.envString("NETWORK"); 31 | address owner = vm.envAddress("PROTOCOL_OWNER"); 32 | 33 | deploymentsPath = string.concat( 34 | string.concat("./deployments/", network), 35 | ".json" 36 | ); 37 | 38 | vm.startBroadcast(deployerPrivateKey); 39 | 40 | // Using the CREATE3 factory maintained by lififinance: https://github.com/lifinance/create3-factory 41 | address deployed = ICREATE3Factory( 42 | 0x93FEC2C00BfE902F733B57c5a6CeeD7CD1384AE1 43 | ).deploy( 44 | bytes32(nonce), 45 | abi.encodePacked( 46 | type(Boost).creationCode, 47 | abi.encode( 48 | owner, 49 | boostName, 50 | boostSymbol, 51 | boostVersion, 52 | ethFee, 53 | tokenFee 54 | ) 55 | ) 56 | ); 57 | 58 | deployments = deployments.serialize("Boost", deployed); 59 | 60 | deployments.write(deploymentsPath); 61 | 62 | vm.stopBroadcast(); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Boost.sol: -------------------------------------------------------------------------------- 1 | /** 2 | * SPDX-License-Identifier: MIT 3 | * 4 | * ____ _ 5 | * | _ \ | | 6 | * | |_) | ___ ___ ___ | |_ 7 | * | _ < / _ \ / _ \ / __|| __| 8 | * | |_) || (_) || (_) |\__ \| |_ 9 | * |____/ \___/ \___/ |___/ \__| 10 | */ 11 | 12 | pragma solidity ^0.8.23; 13 | 14 | import "openzeppelin-contracts/access/Ownable.sol"; 15 | import "openzeppelin-contracts/utils/cryptography/SignatureChecker.sol"; 16 | import "openzeppelin-contracts/utils/cryptography/EIP712.sol"; 17 | import "openzeppelin-contracts/token/ERC721/extensions/ERC721URIStorage.sol"; 18 | import "openzeppelin-contracts/token/ERC20/utils/SafeERC20.sol"; 19 | 20 | import "./IBoost.sol"; 21 | 22 | /** 23 | * @title Boost 24 | * @author @SnapshotLabs - admin@snapshot.org 25 | * @notice Incentivize actions with ERC20 token disbursals 26 | */ 27 | contract Boost is IBoost, EIP712, Ownable, ERC721URIStorage { 28 | using SafeERC20 for IERC20; 29 | 30 | /// @dev The divisor used to calculate the per-myriad fee 31 | uint256 private constant MYRIAD = 10000; 32 | 33 | /// @dev The EIP712 typehash for the claim struct 34 | bytes32 private constant CLAIM_TYPE_HASH = 35 | keccak256("Claim(uint256 boostId,address recipient,uint256 amount)"); 36 | 37 | /// @inheritdoc IBoost 38 | mapping(uint256 => BoostConfig) public override boosts; 39 | 40 | /// @inheritdoc IBoost 41 | mapping(uint256 => mapping(address => bool)) public override claimed; 42 | 43 | /// @inheritdoc IBoost 44 | mapping(address => uint256) public override tokenFeeBalances; 45 | 46 | /// @inheritdoc IBoost 47 | uint256 public override nextBoostId; 48 | 49 | /// @inheritdoc IBoost 50 | uint256 public override ethFee; 51 | 52 | /// @inheritdoc IBoost 53 | uint256 public override tokenFee; 54 | 55 | /// @notice Initializes the boost contract 56 | /// @param _protocolOwner The address of the owner of the protocol 57 | /// @param _ethFee The eth protocol fee 58 | /// @param _tokenFee The token protocol fee 59 | constructor( 60 | address _protocolOwner, 61 | string memory name, 62 | string memory symbol, 63 | string memory version, 64 | uint256 _ethFee, 65 | uint256 _tokenFee 66 | ) ERC721(name, symbol) EIP712(name, version) { 67 | setEthFee(_ethFee); 68 | setTokenFee(_tokenFee); 69 | transferOwnership(_protocolOwner); 70 | } 71 | 72 | /// @inheritdoc IBoost 73 | function setEthFee(uint256 _ethFee) public override onlyOwner { 74 | ethFee = _ethFee; 75 | emit EthFeeSet(_ethFee); 76 | } 77 | 78 | /// @inheritdoc IBoost 79 | function setTokenFee(uint256 _tokenFee) public override onlyOwner { 80 | tokenFee = _tokenFee; 81 | emit TokenFeeSet(_tokenFee); 82 | } 83 | 84 | /// @inheritdoc IBoost 85 | function collectEthFees(address _recipient) external override onlyOwner { 86 | payable(_recipient).transfer(address(this).balance); 87 | emit EthFeesCollected(_recipient); 88 | } 89 | 90 | /// @inheritdoc IBoost 91 | function collectTokenFees( 92 | IERC20 _token, 93 | address _recipient 94 | ) external override onlyOwner { 95 | uint256 fees = tokenFeeBalances[address(_token)]; 96 | tokenFeeBalances[address(_token)] = 0; 97 | _token.safeTransfer(_recipient, fees); 98 | emit TokenFeesCollected(_token, _recipient); 99 | } 100 | 101 | /// @inheritdoc IBoost 102 | function mint( 103 | string calldata _strategyURI, 104 | IERC20 _token, 105 | uint256 _amount, 106 | address _owner, 107 | address _guard, 108 | uint48 _start, 109 | uint48 _end 110 | ) external payable override { 111 | if (_amount == 0) revert BoostDepositRequired(); 112 | if (_end <= block.timestamp) revert BoostEndDateInPast(); 113 | if (_start >= _end) revert BoostEndDateBeforeStart(); 114 | if (_guard == address(0)) revert InvalidGuard(); 115 | if (msg.value < ethFee) revert InsufficientEthFee(); 116 | 117 | (uint256 balanceIncrease, uint256 tokenFeeAmount) = calculateFee( 118 | _amount 119 | ); 120 | 121 | tokenFeeBalances[address(_token)] += tokenFeeAmount; 122 | 123 | uint256 boostId = nextBoostId; 124 | unchecked { 125 | // Overflows if 2**128 boosts are minted 126 | nextBoostId++; 127 | } 128 | 129 | // Minting the boost as an ERC721 and storing the config data 130 | _safeMint(_owner, boostId); 131 | _setTokenURI(boostId, _strategyURI); 132 | boosts[boostId] = BoostConfig({ 133 | token: _token, 134 | balance: balanceIncrease, 135 | guard: _guard, 136 | start: _start, 137 | end: _end 138 | }); 139 | 140 | // Transferring the deposit amount of the ERC20 token to the contract 141 | _token.safeTransferFrom(msg.sender, address(this), _amount); 142 | 143 | emit Mint(boostId, _owner, boosts[boostId], _strategyURI); 144 | } 145 | 146 | /// @inheritdoc IBoost 147 | function deposit(uint256 _boostId, uint256 _amount) external override { 148 | BoostConfig storage boost = boosts[_boostId]; 149 | if (_amount == 0) revert BoostDepositRequired(); 150 | if (!_exists(_boostId)) revert BoostDoesNotExist(); 151 | if (boost.end <= block.timestamp) revert BoostEnded(); 152 | if (block.timestamp >= boost.start) revert ClaimingPeriodStarted(); 153 | 154 | (uint256 balanceIncrease, uint256 tokenFeeAmount) = calculateFee( 155 | _amount 156 | ); 157 | 158 | tokenFeeBalances[address(boost.token)] += tokenFeeAmount; 159 | 160 | boost.balance += balanceIncrease; 161 | boost.token.safeTransferFrom(msg.sender, address(this), _amount); 162 | 163 | emit Deposit(_boostId, msg.sender, balanceIncrease); 164 | } 165 | 166 | /// @inheritdoc IBoost 167 | function withdrawAndBurn(uint256 _boostId, address _to) external override { 168 | BoostConfig storage boost = boosts[_boostId]; 169 | if (!_exists(_boostId)) revert BoostDoesNotExist(); 170 | if (boost.balance == 0) revert InsufficientBoostBalance(); 171 | if (boost.end > block.timestamp) revert BoostNotEnded(boost.end); 172 | if (ownerOf(_boostId) != msg.sender) revert OnlyBoostOwner(); 173 | if (_to == address(0)) revert InvalidRecipient(); 174 | 175 | uint256 amount = boost.balance; 176 | 177 | // Transferring remaining ERC20 token balance to the designated address 178 | boost.token.safeTransfer(_to, amount); 179 | 180 | // Deleting the boost data 181 | _burn(_boostId); 182 | delete boosts[_boostId]; 183 | 184 | emit Burn(_boostId); 185 | } 186 | 187 | /// @inheritdoc IBoost 188 | function claim( 189 | ClaimConfig calldata _claimConfig, 190 | bytes calldata _signature 191 | ) external override { 192 | _claim(_claimConfig, _signature); 193 | } 194 | 195 | /// @inheritdoc IBoost 196 | function claimMultiple( 197 | ClaimConfig[] calldata _claimConfigs, 198 | bytes[] calldata _signatures 199 | ) external override { 200 | for (uint256 i = 0; i < _signatures.length; i++) { 201 | _claim(_claimConfigs[i], _signatures[i]); 202 | } 203 | } 204 | 205 | /// @notice Claims a boost 206 | /// @param _claimConfig The claim 207 | /// @param _signature The signature of the claim, signed by the boost guard 208 | function _claim( 209 | ClaimConfig memory _claimConfig, 210 | bytes memory _signature 211 | ) internal { 212 | BoostConfig storage boost = boosts[_claimConfig.boostId]; 213 | if (boost.start > block.timestamp) revert BoostNotStarted(boost.start); 214 | if (boost.balance < _claimConfig.amount) { 215 | revert InsufficientBoostBalance(); 216 | } 217 | if (boost.end <= block.timestamp) revert BoostEnded(); 218 | if (claimed[_claimConfig.boostId][_claimConfig.recipient]) { 219 | revert RecipientAlreadyClaimed(); 220 | } 221 | if (_claimConfig.recipient == address(0)) revert InvalidRecipient(); 222 | 223 | bytes32 digest = _hashTypedDataV4( 224 | keccak256( 225 | abi.encode( 226 | CLAIM_TYPE_HASH, 227 | _claimConfig.boostId, 228 | _claimConfig.recipient, 229 | _claimConfig.amount 230 | ) 231 | ) 232 | ); 233 | 234 | if ( 235 | !SignatureChecker.isValidSignatureNow( 236 | boost.guard, 237 | digest, 238 | _signature 239 | ) 240 | ) revert InvalidSignature(); 241 | 242 | // Storing recipients that claimed to prevent reusing signatures 243 | claimed[_claimConfig.boostId][_claimConfig.recipient] = true; 244 | 245 | // Calculating the boost balance after the claim, will not underflow as we have already checked 246 | // that the claim amount is less than the balance 247 | boost.balance -= _claimConfig.amount; 248 | 249 | // Transferring claim amount to recipient address 250 | boost.token.safeTransfer(_claimConfig.recipient, _claimConfig.amount); 251 | 252 | emit Claim(_claimConfig); 253 | } 254 | 255 | /// @dev Calculates the boost balance increase and token fee amount for a given deposit amount 256 | function calculateFee( 257 | uint256 _amount 258 | ) internal view returns (uint256, uint256) { 259 | // Using this non-intuitive computation to make it easier for the depositor to calculate the fee. 260 | // This way, depositing 110 tokens with a tokenFee of 10% will result in a balance increase of 100 tokens 261 | // and a fee of 10 tokens. 262 | uint256 balanceIncrease = (_amount * MYRIAD) / (MYRIAD + tokenFee); 263 | uint256 tokenFeeAmount = _amount - balanceIncrease; 264 | return (balanceIncrease, tokenFeeAmount); 265 | } 266 | } 267 | -------------------------------------------------------------------------------- /src/IBoost.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity ^0.8.23; 4 | 5 | import "openzeppelin-contracts/token/ERC20/IERC20.sol"; 6 | 7 | interface IBoost { 8 | struct BoostConfig { 9 | // The token that is being distributed as a boost 10 | IERC20 token; 11 | // The current balance of the boost 12 | uint256 balance; 13 | // The boost guard, which is the address of the account that should sign claims 14 | address guard; 15 | // The start timestamp of the boost, after which claims can be made 16 | uint48 start; 17 | // The end timestamp of the boost, after which no more claims can be made 18 | uint48 end; 19 | } 20 | 21 | struct ClaimConfig { 22 | // The boost id where the claim is being made 23 | uint256 boostId; 24 | // The address of the recipient for the claim 25 | address recipient; 26 | // The amount of boost token in the claim 27 | uint256 amount; 28 | } 29 | 30 | error BoostDoesNotExist(); 31 | error BoostDepositRequired(); 32 | error BoostEndDateInPast(); 33 | error BoostEndDateBeforeStart(); 34 | error BoostEnded(); 35 | error BoostNotEnded(uint256 end); 36 | error BoostNotStarted(uint256 start); 37 | error ClaimingPeriodStarted(); 38 | error OnlyBoostOwner(); 39 | error InvalidRecipient(); 40 | error InvalidGuard(); 41 | error InvalidTokenFee(); 42 | error RecipientAlreadyClaimed(); 43 | error InvalidSignature(); 44 | error InsufficientBoostBalance(); 45 | error InsufficientEthFee(); 46 | 47 | /// @notice Emitted when a boost is minted 48 | /// @param boostId The boost id 49 | /// @param owner The boost owner 50 | /// @param boost The boost config 51 | /// @param strategyURI The URI of the boost strategy 52 | event Mint( 53 | uint256 boostId, 54 | address owner, 55 | BoostConfig boost, 56 | string strategyURI 57 | ); 58 | 59 | /// @notice Emitted when a claim is made 60 | /// @param claim The claim config 61 | event Claim(ClaimConfig claim); 62 | 63 | /// @notice Emitted when a boost is deposited into 64 | /// @param boostId The boost id 65 | /// @param sender The address of the depositor sender 66 | /// @param amount The amount of the boost token deposited 67 | event Deposit(uint256 boostId, address sender, uint256 amount); 68 | 69 | /// @notice Emitted when a boost is burned 70 | /// @param boostId The boost id 71 | event Burn(uint256 boostId); 72 | 73 | /// @notice Emitted when the ETH fee is set 74 | /// @param ethFee The ETH fee 75 | event EthFeeSet(uint256 ethFee); 76 | 77 | /// @notice Emitted when the token fee is set 78 | /// @param tokenFee The token fee 79 | event TokenFeeSet(uint256 tokenFee); 80 | 81 | /// @notice Emitted when ETH fees are collected 82 | /// @param recipient The recipient of the ETH fees 83 | event EthFeesCollected(address recipient); 84 | 85 | /// @notice Emitted when token fees are collected 86 | /// @param token The token of the fees 87 | /// @param recipient The recipient of the token fees 88 | event TokenFeesCollected(IERC20 token, address recipient); 89 | 90 | /// @notice Returns the boost config for a given boost id 91 | /// @param boostId The boost id 92 | function boosts( 93 | uint256 boostId 94 | ) 95 | external 96 | view 97 | returns ( 98 | IERC20 token, 99 | uint256 balance, 100 | address guard, 101 | uint48 start, 102 | uint48 end 103 | ); 104 | 105 | /// @notice Returns whether a recipient has claimed a boost 106 | /// @param boostId The boost id 107 | /// @param recipient The recipient address 108 | function claimed( 109 | uint256 boostId, 110 | address recipient 111 | ) external view returns (bool); 112 | 113 | /// @notice Returns the accumulated protocol fees for a given token 114 | /// @param token The token to get the fee balance for 115 | function tokenFeeBalances(address token) external view returns (uint256); 116 | 117 | /// @notice Returns the id of the next boost to be minted 118 | function nextBoostId() external view returns (uint256); 119 | 120 | /// @notice Returns the constant eth protocol fee (in wei) that must be paid by all boost creators 121 | function ethFee() external view returns (uint256); 122 | 123 | /// @notice Returns the per-myriad (parts per ten-thousand) proportion of the boost size that is taken as a fee. 124 | /// Eg with a token fee of 200, 2% of the boost size is taken as a fee. So a 102 token deposit would result in a 125 | /// 100 token boost and 2 token fee. 126 | function tokenFee() external view returns (uint256); 127 | 128 | /// @notice Updates the eth protocol fee 129 | /// @param ethFee The new eth fee in wei 130 | function setEthFee(uint256 ethFee) external; 131 | 132 | /// @notice Updates the token protocol fee 133 | /// @param tokenFee The new token fee, represented as an integer denominator (100/x)% 134 | function setTokenFee(uint256 tokenFee) external; 135 | 136 | /// @notice Collects the accumulated Eth protocol fees 137 | /// @param recipient The address to send the fees to 138 | function collectEthFees(address recipient) external; 139 | 140 | /// @notice Collects the accumulated token protocol fees 141 | /// @param token The token to collect fees for 142 | /// @param recipient The address to send the fees to 143 | function collectTokenFees(IERC20 token, address recipient) external; 144 | 145 | /// @notice Mints a new boost 146 | /// @param strategyURI The URI of the boost strategy 147 | /// @param token The token that is being distributed as a boost 148 | /// @param amount The amount of the boost token that will be distributed 149 | /// @param owner The owner of the boost 150 | /// @param guard The address of the account that should sign claims 151 | /// @param start The start timestamp of the boost, after which claims can be made 152 | /// @param end The end timestamp of the boost, after which no more claims can be made 153 | function mint( 154 | string calldata strategyURI, 155 | IERC20 token, 156 | uint256 amount, 157 | address owner, 158 | address guard, 159 | uint48 start, 160 | uint48 end 161 | ) external payable; 162 | 163 | /// @notice Deposits more tokens into a boost 164 | /// @param boostId The boost id 165 | /// @param amount The amount of the token to deposit 166 | function deposit(uint256 boostId, uint256 amount) external; 167 | 168 | /// @notice Withdraws the remaining funds and burns the boost 169 | /// @param boostId The boost id 170 | /// @param to The address to send the remaining boost balance to 171 | function withdrawAndBurn(uint256 boostId, address to) external; 172 | 173 | /// @notice Claims a boost 174 | /// @param claimConfig The claim 175 | /// @param signature The signature of the claim, signed by the boost guard 176 | function claim( 177 | ClaimConfig calldata claimConfig, 178 | bytes calldata signature 179 | ) external; 180 | 181 | /// @notice Wrapper function to claim multiple boosts in a single transaction 182 | /// @param claimConfigs Array of claims 183 | /// @param signatures Array of signatures, that correspond to the claims array 184 | function claimMultiple( 185 | ClaimConfig[] calldata claimConfigs, 186 | bytes[] calldata signatures 187 | ) external; 188 | } 189 | -------------------------------------------------------------------------------- /test/Boost.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.23; 3 | 4 | import "forge-std/Test.sol"; 5 | import "./mocks/MockERC20.sol"; 6 | import "../src/Boost.sol"; 7 | import {GasSnapshot} from "forge-gas-snapshot/GasSnapshot.sol"; 8 | 9 | abstract contract BoostTest is Test, GasSnapshot { 10 | uint256 constant MYRIAD = 10000; 11 | event Mint( 12 | uint256 boostId, 13 | address owner, 14 | IBoost.BoostConfig boost, 15 | string strategyURI 16 | ); 17 | event Claim(IBoost.ClaimConfig claim); 18 | event Deposit(uint256 boostId, address sender, uint256 amount); 19 | event Burn(uint256 boostId); 20 | event EthFeeSet(uint256 ethFee); 21 | event TokenFeeSet(uint256 tokenFee); 22 | event EthFeesCollected(address recipient); 23 | event TokenFeesCollected(IERC20 token, address recipient); 24 | 25 | event OwnershipTransferred( 26 | address indexed previousOwner, 27 | address indexed newOwner 28 | ); 29 | 30 | error BoostDoesNotExist(); 31 | error BoostDepositRequired(); 32 | error BoostEndDateInPast(); 33 | error BoostEndDateBeforeStart(); 34 | error BoostEnded(); 35 | error BoostNotEnded(uint256 end); 36 | error BoostNotStarted(uint256 start); 37 | error OnlyBoostOwner(); 38 | error InvalidRecipient(); 39 | error InvalidGuard(); 40 | error RecipientAlreadyClaimed(); 41 | error InvalidSignature(); 42 | error InsufficientBoostBalance(); 43 | 44 | address protocolOwner = address(0xFFFF); 45 | 46 | string constant boostName = "Boost"; 47 | string constant boostSymbol = "BOOST"; 48 | string constant boostVersion = "0.1.0"; 49 | 50 | Boost public boost; 51 | MockERC20 public token; 52 | 53 | uint256 public constant ownerKey = 1234; 54 | uint256 public constant guardKey = 5678; 55 | 56 | address public owner = vm.addr(ownerKey); 57 | address public guard = vm.addr(guardKey); 58 | 59 | uint256 public constant depositAmount = 10200; 60 | string public constant strategyURI = "abc123"; 61 | 62 | function setUp() public virtual { 63 | boost = new Boost( 64 | protocolOwner, 65 | boostName, 66 | boostSymbol, 67 | boostVersion, 68 | 0, 69 | 0 70 | ); 71 | token = new MockERC20("Test Token", "TEST"); 72 | } 73 | 74 | /// @notice Creates a default boost 75 | function _createBoost() internal returns (uint256) { 76 | uint256 boostID = boost.nextBoostId(); 77 | vm.prank(owner); 78 | boost.mint( 79 | strategyURI, 80 | IERC20(token), 81 | depositAmount, 82 | owner, 83 | guard, 84 | uint48(block.timestamp), 85 | uint48(block.timestamp + 60) 86 | ); 87 | return boostID; 88 | } 89 | 90 | /// @notice Creates a customizable boost 91 | function _createBoost( 92 | string memory _strategyURI, 93 | address _token, 94 | uint256 _amount, 95 | address _owner, 96 | address _guard, 97 | uint256 _start, 98 | uint256 _end, 99 | uint256 _ethFee 100 | ) internal returns (uint256) { 101 | uint256 boostID = boost.nextBoostId(); 102 | vm.prank(_owner); 103 | vm.deal(_owner, _ethFee); 104 | boost.mint{value: _ethFee}( 105 | _strategyURI, 106 | IERC20(_token), 107 | _amount, 108 | _owner, 109 | _guard, 110 | uint48(_start), 111 | uint48(_end) 112 | ); 113 | return boostID; 114 | } 115 | 116 | /// @notice Mint and approve token utility function 117 | function _mintAndApprove( 118 | address user, 119 | uint256 mintAmount, 120 | uint256 approveAmount 121 | ) internal { 122 | token.mint(user, mintAmount); 123 | vm.prank(user); 124 | token.approve(address(boost), approveAmount); 125 | } 126 | 127 | /// @notice Generate claim eip712 signature 128 | function _generateClaimSignature( 129 | IBoost.ClaimConfig memory claim 130 | ) internal view returns (bytes memory) { 131 | bytes32 digest = keccak256( 132 | abi.encodePacked( 133 | "\x19\x01", 134 | keccak256( 135 | abi.encode( 136 | keccak256( 137 | "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" 138 | ), 139 | keccak256(bytes(boostName)), 140 | keccak256(bytes(boostVersion)), 141 | block.chainid, 142 | address(boost) 143 | ) 144 | ), 145 | keccak256( 146 | abi.encode( 147 | keccak256( 148 | "Claim(uint256 boostId,address recipient,uint256 amount)" 149 | ), 150 | claim.boostId, 151 | claim.recipient, 152 | claim.amount 153 | ) 154 | ) 155 | ) 156 | ); 157 | (uint8 v, bytes32 r, bytes32 s) = vm.sign(guardKey, digest); 158 | return abi.encodePacked(r, s, v); 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /test/Burn.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.23; 3 | 4 | import "./Boost.t.sol"; 5 | 6 | contract BoostWithdrawTest is BoostTest { 7 | address public constant claimer = address(0x1111); 8 | address public constant claimer2 = address(0x2222); 9 | address public constant claimer3 = address(0x3333); 10 | 11 | function testWithdrawAfterBoostExpired() public { 12 | _mintAndApprove(owner, depositAmount, depositAmount); 13 | uint256 boostId = _createBoost(); 14 | 15 | assertEq(boost.balanceOf(owner), 1); // sanity check 16 | // Increasing timestamp to after boost has ended 17 | vm.warp(block.timestamp + 60); 18 | vm.prank(owner); 19 | snapStart("Withdraw"); 20 | boost.withdrawAndBurn(boostId, owner); 21 | snapEnd(); 22 | 23 | // Checking balances after withdrawal 24 | assertEq(token.balanceOf(address(boost)), 0); 25 | assertEq(token.balanceOf(owner), depositAmount); 26 | assertEq(boost.balanceOf(owner), 0); 27 | } 28 | 29 | function testWithdrawBoostNotOwner() public { 30 | _mintAndApprove(owner, depositAmount, depositAmount); 31 | uint256 boostId = _createBoost(); 32 | 33 | vm.warp(block.timestamp + 60); 34 | // Not boost owner 35 | vm.prank(claimer); 36 | vm.expectRevert(IBoost.OnlyBoostOwner.selector); 37 | boost.withdrawAndBurn(boostId, claimer); 38 | } 39 | 40 | function testWithdrawBoostNotExpired() public { 41 | _mintAndApprove(owner, depositAmount, depositAmount); 42 | uint256 boostId = _createBoost(); 43 | 44 | vm.prank(owner); 45 | vm.expectRevert( 46 | abi.encodeWithSelector( 47 | IBoost.BoostNotEnded.selector, 48 | block.timestamp + 60 49 | ) 50 | ); 51 | // Boost still active 52 | boost.withdrawAndBurn(boostId, owner); 53 | } 54 | 55 | function testWithdrawZeroBalance() public { 56 | // todo: investigate if this is not a duplicate? wrong name at least... 57 | _mintAndApprove(owner, depositAmount, depositAmount); 58 | uint256 boostId = _createBoost(); 59 | 60 | // Claiming the entire deposit amount so that the boost balance will be zero 61 | IBoost.ClaimConfig memory claim = IBoost.ClaimConfig({ 62 | boostId: boostId, 63 | recipient: claimer, 64 | amount: depositAmount 65 | }); 66 | boost.claim(claim, _generateClaimSignature(claim)); 67 | vm.warp(block.timestamp + 60); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /test/Claim.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.23; 3 | 4 | import "./Boost.t.sol"; 5 | 6 | contract BoostClaimTest is BoostTest { 7 | address public constant claimer = address(0x1111); 8 | address public constant claimer2 = address(0x2222); 9 | address public constant claimer3 = address(0x3333); 10 | address public constant claimer4 = address(0x4444); 11 | address public constant claimer5 = address(0x5555); 12 | 13 | function testClaimSingleRecipient() public { 14 | _mintAndApprove(owner, depositAmount, depositAmount); 15 | uint256 boostId = _createBoost(); 16 | 17 | IBoost.ClaimConfig memory claim = IBoost.ClaimConfig({ 18 | boostId: boostId, 19 | recipient: claimer, 20 | amount: 1 21 | }); 22 | vm.expectEmit(true, false, false, true); 23 | emit Claim(claim); 24 | snapStart("ClaimSingle"); 25 | boost.claim(claim, _generateClaimSignature(claim)); 26 | snapEnd(); 27 | 28 | // Checking balances are correct after claim 29 | assertEq(token.balanceOf(address(boost)), depositAmount - 1); 30 | assertEq(token.balanceOf(claimer), 1); 31 | } 32 | 33 | function testClaimMultipleSeparately() public { 34 | _mintAndApprove(owner, depositAmount, depositAmount); 35 | uint256 boostId = _createBoost(); 36 | 37 | IBoost.ClaimConfig memory claim = IBoost.ClaimConfig({ 38 | boostId: boostId, 39 | recipient: claimer, 40 | amount: 1 41 | }); 42 | IBoost.ClaimConfig memory claim2 = IBoost.ClaimConfig({ 43 | boostId: boostId, 44 | recipient: claimer2, 45 | amount: 1 46 | }); 47 | IBoost.ClaimConfig memory claim3 = IBoost.ClaimConfig({ 48 | boostId: boostId, 49 | recipient: claimer3, 50 | amount: 1 51 | }); 52 | boost.claim(claim, _generateClaimSignature(claim)); 53 | boost.claim(claim2, _generateClaimSignature(claim2)); 54 | boost.claim(claim3, _generateClaimSignature(claim3)); 55 | 56 | // Checking balances are correct after claim 57 | assertEq(token.balanceOf(address(boost)), depositAmount - 3); 58 | assertEq(token.balanceOf(claimer), 1); 59 | assertEq(token.balanceOf(claimer2), 1); 60 | assertEq(token.balanceOf(claimer3), 1); 61 | } 62 | 63 | function testClaimMultiple() public { 64 | _mintAndApprove(owner, depositAmount, depositAmount); 65 | uint256 boostId = _createBoost(); 66 | 67 | IBoost.ClaimConfig memory claim = IBoost.ClaimConfig({ 68 | boostId: boostId, 69 | recipient: claimer, 70 | amount: 1 71 | }); 72 | IBoost.ClaimConfig memory claim2 = IBoost.ClaimConfig({ 73 | boostId: boostId, 74 | recipient: claimer2, 75 | amount: 1 76 | }); 77 | IBoost.ClaimConfig memory claim3 = IBoost.ClaimConfig({ 78 | boostId: boostId, 79 | recipient: claimer3, 80 | amount: 1 81 | }); 82 | IBoost.ClaimConfig memory claim4 = IBoost.ClaimConfig({ 83 | boostId: boostId, 84 | recipient: claimer4, 85 | amount: 1 86 | }); 87 | IBoost.ClaimConfig memory claim5 = IBoost.ClaimConfig({ 88 | boostId: boostId, 89 | recipient: claimer5, 90 | amount: 1 91 | }); 92 | 93 | // Generating Claim array 94 | IBoost.ClaimConfig[] memory claims = new IBoost.ClaimConfig[](5); 95 | claims[0] = claim; 96 | claims[1] = claim2; 97 | claims[2] = claim3; 98 | claims[3] = claim4; 99 | claims[4] = claim5; 100 | 101 | // Generating signature array from the claims 102 | bytes[] memory signatures = new bytes[](5); 103 | signatures[0] = _generateClaimSignature(claim); 104 | signatures[1] = _generateClaimSignature(claim2); 105 | signatures[2] = _generateClaimSignature(claim3); 106 | signatures[3] = _generateClaimSignature(claim4); 107 | signatures[4] = _generateClaimSignature(claim5); 108 | 109 | snapStart("ClaimMultiple"); 110 | boost.claimMultiple(claims, signatures); 111 | snapEnd(); 112 | 113 | // Checking balances are correct after claims made 114 | assertEq(token.balanceOf(address(boost)), depositAmount - 5); 115 | assertEq(token.balanceOf(claimer), 1); 116 | assertEq(token.balanceOf(claimer2), 1); 117 | assertEq(token.balanceOf(claimer3), 1); 118 | assertEq(token.balanceOf(claimer4), 1); 119 | assertEq(token.balanceOf(claimer5), 1); 120 | } 121 | 122 | function testClaimReusedSignature() public { 123 | _mintAndApprove(owner, depositAmount, depositAmount); 124 | uint256 boostId = _createBoost(); 125 | 126 | IBoost.ClaimConfig memory claim = IBoost.ClaimConfig({ 127 | boostId: boostId, 128 | recipient: claimer, 129 | amount: 1 130 | }); 131 | bytes memory sig = _generateClaimSignature(claim); 132 | boost.claim(claim, sig); 133 | 134 | vm.expectRevert(IBoost.RecipientAlreadyClaimed.selector); 135 | // Reusing signature 136 | boost.claim(claim, sig); 137 | } 138 | 139 | function testClaimInvalidSignature() public { 140 | _mintAndApprove(owner, depositAmount, depositAmount); 141 | uint256 boostId = _createBoost(); 142 | 143 | IBoost.ClaimConfig memory claim = IBoost.ClaimConfig({ 144 | boostId: boostId, 145 | recipient: claimer, 146 | amount: 1 147 | }); 148 | // Creating signature with different claim data 149 | bytes memory sig = _generateClaimSignature( 150 | IBoost.ClaimConfig({ 151 | boostId: boostId, 152 | recipient: claimer, 153 | amount: 2 154 | }) 155 | ); 156 | vm.expectRevert(IBoost.InvalidSignature.selector); 157 | boost.claim(claim, sig); 158 | } 159 | 160 | function testClaimBoostEnded() public { 161 | _mintAndApprove(owner, depositAmount, depositAmount); 162 | uint256 boostId = _createBoost(); 163 | 164 | IBoost.ClaimConfig memory claim = IBoost.ClaimConfig({ 165 | boostId: boostId, 166 | recipient: claimer, 167 | amount: 1 168 | }); 169 | bytes memory sig = _generateClaimSignature(claim); 170 | // skipped ahead to after boost has ended 171 | vm.warp(block.timestamp + 60); 172 | vm.expectRevert(IBoost.BoostEnded.selector); 173 | boost.claim(claim, sig); 174 | } 175 | 176 | function testClaimBoostNotStarted() public { 177 | _mintAndApprove(owner, depositAmount, depositAmount); 178 | 179 | // Start timestamp is in future 180 | uint256 boostId = _createBoost( 181 | strategyURI, 182 | address(token), 183 | depositAmount, 184 | owner, 185 | guard, 186 | block.timestamp + 60, 187 | block.timestamp + 120, 188 | 0 189 | ); 190 | 191 | IBoost.ClaimConfig memory claim = IBoost.ClaimConfig({ 192 | boostId: boostId, 193 | recipient: claimer, 194 | amount: 1 195 | }); 196 | bytes memory sig = _generateClaimSignature(claim); 197 | vm.expectRevert( 198 | abi.encodeWithSelector( 199 | IBoost.BoostNotStarted.selector, 200 | block.timestamp + 60 201 | ) 202 | ); 203 | boost.claim(claim, sig); 204 | } 205 | 206 | function testClaimBoostDoesntExist() public { 207 | IBoost.ClaimConfig memory claim = IBoost.ClaimConfig({ 208 | boostId: 1, 209 | recipient: claimer, 210 | amount: 1 211 | }); 212 | bytes memory sig = _generateClaimSignature(claim); 213 | // If the boost does not exist, then the balance of the boost will be zero 214 | vm.expectRevert(IBoost.InsufficientBoostBalance.selector); 215 | boost.claim(claim, sig); 216 | } 217 | 218 | function testClaimExceedsBalance() public { 219 | _mintAndApprove(owner, depositAmount, depositAmount); 220 | uint256 boostId = _createBoost(); 221 | 222 | // Claim larger than boost balance 223 | IBoost.ClaimConfig memory claim = IBoost.ClaimConfig({ 224 | boostId: boostId, 225 | recipient: claimer, 226 | amount: depositAmount + 1 227 | }); 228 | bytes memory sig = _generateClaimSignature(claim); 229 | vm.expectRevert(IBoost.InsufficientBoostBalance.selector); 230 | boost.claim(claim, sig); 231 | } 232 | } 233 | -------------------------------------------------------------------------------- /test/Create.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.23; 3 | 4 | import "./Boost.t.sol"; 5 | 6 | contract BoostCreateTest is BoostTest { 7 | function testCreateBoost() public { 8 | token.mint(owner, depositAmount); 9 | vm.prank(owner); 10 | token.approve(address(boost), depositAmount); 11 | // Id of the next boost that will be created 12 | uint256 boostId = boost.nextBoostId(); 13 | assertEq(boostId, 0); // The first boost created should have an id of 0 14 | vm.expectEmit(true, true, false, true); 15 | emit Mint( 16 | boostId, 17 | owner, 18 | IBoost.BoostConfig({ 19 | token: IERC20(address(token)), 20 | balance: depositAmount, 21 | guard: guard, 22 | start: uint48(block.timestamp), 23 | end: uint48(block.timestamp + 60) 24 | }), 25 | strategyURI 26 | ); 27 | vm.prank(owner); 28 | 29 | boost.mint( 30 | strategyURI, 31 | IERC20(address(token)), 32 | depositAmount, 33 | owner, 34 | guard, 35 | uint48(block.timestamp), 36 | uint48(block.timestamp + 60) 37 | ); 38 | 39 | // Checking BoostConfig object and other data that we store separately to obey the ERC721 standard 40 | ( 41 | IERC20 _token, 42 | uint256 _balance, 43 | address _guard, 44 | uint48 _start, 45 | uint48 _end 46 | ) = boost.boosts(boostId); 47 | assertEq(address(token), address(_token)); 48 | assertEq(depositAmount, _balance); 49 | assertEq(guard, _guard); 50 | assertEq(uint48(block.timestamp), _start); 51 | assertEq(uint48(block.timestamp + 60), _end); 52 | assertEq(boost.ownerOf(boostId), owner); 53 | assertEq(boost.tokenURI(boostId), strategyURI); 54 | assertEq(boost.balanceOf(owner), 1); // The owner minted a single boost 55 | 56 | // Checking boost balance is equal to the deposit amount 57 | assertEq(token.balanceOf(address(boost)), depositAmount); 58 | } 59 | 60 | function testCreateMultipleBoosts() public { 61 | _mintAndApprove(owner, 2 * depositAmount, 2 * depositAmount); 62 | 63 | // Creating 2 boosts, gas snapshot on second 64 | uint256 boostId1 = _createBoost(); 65 | snapStart("CreateBoost"); 66 | uint256 boostId2 = _createBoost(); 67 | snapEnd(); 68 | 69 | assertEq(boostId1, 0); 70 | assertEq(boostId2, 1); 71 | 72 | // After creating 2 boosts, the owner's balance should be 2 73 | assertEq(boost.balanceOf(owner), 2); 74 | assertEq(token.balanceOf(address(boost)), 2 * depositAmount); 75 | } 76 | 77 | function testCreateBoostInsufficientAllowance() public { 78 | token.mint(owner, depositAmount); 79 | vm.prank(owner); 80 | token.approve(address(boost), depositAmount - 1); 81 | vm.expectRevert("ERC20: insufficient allowance"); 82 | vm.prank(owner); 83 | // Attempting to deposit more than what the contract is approved for 84 | boost.mint( 85 | strategyURI, 86 | IERC20(address(token)), 87 | depositAmount, 88 | owner, 89 | guard, 90 | uint48(block.timestamp), 91 | uint48(block.timestamp + 60) 92 | ); 93 | } 94 | 95 | function testCreateBoostInsufficientBalance() public { 96 | token.mint(owner, depositAmount - 1); 97 | vm.prank(owner); 98 | token.approve(address(boost), depositAmount); 99 | vm.expectRevert("ERC20: transfer amount exceeds balance"); 100 | vm.prank(owner); 101 | // Attempting to deposit more than the owner's balance 102 | boost.mint( 103 | strategyURI, 104 | IERC20(address(token)), 105 | depositAmount, 106 | owner, 107 | guard, 108 | uint48(block.timestamp), 109 | uint48(block.timestamp + 60) 110 | ); 111 | } 112 | 113 | function testCreateBoostZeroDeposit() public { 114 | vm.prank(owner); 115 | vm.expectRevert(IBoost.BoostDepositRequired.selector); 116 | // Deposit of zero 117 | boost.mint( 118 | strategyURI, 119 | IERC20(address(token)), 120 | 0, 121 | owner, 122 | guard, 123 | uint48(block.timestamp), 124 | uint48(block.timestamp + 60) 125 | ); 126 | } 127 | 128 | function testCreateBoostEndNotGreaterThanStart() public { 129 | token.mint(owner, depositAmount); 130 | vm.prank(owner); 131 | token.approve(address(boost), depositAmount); 132 | vm.prank(owner); 133 | vm.expectRevert(IBoost.BoostEndDateInPast.selector); 134 | // Start and end timestamps are equal 135 | boost.mint( 136 | strategyURI, 137 | IERC20(address(token)), 138 | depositAmount, 139 | owner, 140 | guard, 141 | uint48(block.timestamp), 142 | uint48(block.timestamp) 143 | ); 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /test/Deposit.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.23; 3 | 4 | import "./Boost.t.sol"; 5 | 6 | contract BoostDepositTest is BoostTest { 7 | address public constant depositee = address(0x1111); 8 | 9 | function testDepositToExistingBoost() public { 10 | _mintAndApprove(owner, depositAmount * 2, depositAmount * 2); 11 | 12 | vm.warp(block.timestamp + 1); // Increase here so we can decrease later 13 | uint256 boostId = _createBoost(); 14 | vm.warp(block.timestamp - 1); // Decrease here so claim period hasn't started 15 | 16 | vm.prank(owner); 17 | vm.expectEmit(true, true, false, true); 18 | emit Deposit(boostId, owner, depositAmount); 19 | snapStart("Deposit"); 20 | boost.deposit(boostId, depositAmount); 21 | snapEnd(); 22 | 23 | // Checking state after deposit 24 | (, uint256 _balance, , , ) = boost.boosts(boostId); 25 | assertEq(token.balanceOf(address(boost)), 2 * depositAmount); 26 | assertEq(_balance, 2 * depositAmount); 27 | } 28 | 29 | function testDepositClaimingPeriodStarted() public { 30 | _mintAndApprove(owner, depositAmount * 2, depositAmount * 2); 31 | uint256 boostId = _createBoost(); 32 | 33 | ( 34 | IERC20 token, 35 | uint256 balance, 36 | address guard, 37 | uint48 start, 38 | uint48 end 39 | ) = boost.boosts(boostId); 40 | vm.warp(start); 41 | vm.prank(owner); 42 | vm.expectRevert(IBoost.ClaimingPeriodStarted.selector); 43 | boost.deposit(boostId, depositAmount); 44 | } 45 | 46 | function testDepositFromDifferentAccount() public { 47 | _mintAndApprove(owner, depositAmount, depositAmount); 48 | _mintAndApprove(depositee, 50, 50); 49 | 50 | vm.prank(owner); 51 | vm.warp(block.timestamp + 1); // Increase here so we can decrease later 52 | uint256 boostId = _createBoost(); 53 | vm.warp(block.timestamp - 1); // Decrease here so claim period hasn't started 54 | 55 | // Depositing from a different account 56 | vm.prank(depositee); 57 | boost.deposit(boostId, 50); 58 | } 59 | 60 | function testDepositToBoostThatDoesntExist() public { 61 | _mintAndApprove(owner, depositAmount, depositAmount); 62 | 63 | vm.prank(owner); 64 | vm.expectRevert(IBoost.BoostDoesNotExist.selector); 65 | // Boost with id 1 has not been created yet 66 | boost.deposit(1, depositAmount); 67 | } 68 | 69 | function testDepositToExpiredBoost() public { 70 | _mintAndApprove(owner, depositAmount, depositAmount); 71 | uint256 boostId = _createBoost(); 72 | 73 | // Increasing timestamp to after boost has ended 74 | vm.warp(block.timestamp + 60); 75 | vm.prank(owner); 76 | vm.expectRevert(IBoost.BoostEnded.selector); 77 | boost.deposit(boostId, depositAmount); 78 | } 79 | 80 | function testDepositExceedsAllowance() public { 81 | _mintAndApprove(owner, depositAmount * 2, depositAmount); 82 | vm.warp(block.timestamp + 1); // Increase here so we can decrease later 83 | uint256 boostId = _createBoost(); 84 | vm.warp(block.timestamp - 1); // Decrease here so claim period hasn't started 85 | 86 | vm.prank(owner); 87 | vm.expectRevert("ERC20: insufficient allowance"); 88 | // Full allowance of depositAmount has been used to create the boost 89 | boost.deposit(boostId, 1); 90 | } 91 | 92 | function testDepositExceedsBalance() public { 93 | _mintAndApprove(owner, depositAmount, 2 * depositAmount); 94 | vm.warp(block.timestamp + 1); // Increase here so we can decrease later 95 | uint256 boostId = _createBoost(); 96 | vm.warp(block.timestamp - 1); // Decrease here so claim period hasn't started 97 | 98 | vm.prank(owner); 99 | vm.expectRevert("ERC20: transfer amount exceeds balance"); 100 | // Attempting to deposit more than the owner's balance 101 | boost.deposit(boostId, 1); 102 | } 103 | 104 | function testDepositZero() public { 105 | _mintAndApprove(owner, depositAmount, depositAmount); 106 | uint256 boostId = _createBoost(); 107 | vm.prank(owner); 108 | vm.expectRevert(IBoost.BoostDepositRequired.selector); 109 | boost.deposit(boostId, 0); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /test/ProtocolFees.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.23; 3 | 4 | import "./Boost.t.sol"; 5 | 6 | contract ProtocolFeesTest is BoostTest { 7 | uint256 ethFee = 1000; 8 | uint256 tokenFee = 100; // 1% 9 | 10 | function setUp() public override { 11 | token = new MockERC20("Test Token", "TEST"); 12 | boost = new Boost( 13 | protocolOwner, 14 | boostName, 15 | boostSymbol, 16 | boostVersion, 17 | ethFee, 18 | tokenFee 19 | ); 20 | } 21 | 22 | function testCreateBoostWithProtocolFees() public { 23 | _mintAndApprove(owner, depositAmount, depositAmount); 24 | uint256 boostBalance = (depositAmount * MYRIAD) / (MYRIAD + tokenFee); 25 | uint256 tokenFeeAmount = depositAmount - boostBalance; 26 | uint256 boostId = boost.nextBoostId(); 27 | vm.expectEmit(true, true, false, true); 28 | emit Mint( 29 | boostId, 30 | owner, 31 | IBoost.BoostConfig({ 32 | token: IERC20(address(token)), 33 | balance: boostBalance, 34 | guard: guard, 35 | start: uint48(block.timestamp), 36 | end: uint48(block.timestamp + 60) 37 | }), 38 | strategyURI 39 | ); 40 | 41 | vm.deal(owner, ethFee); 42 | vm.prank(owner); 43 | 44 | boost.mint{value: ethFee}( 45 | strategyURI, 46 | IERC20(address(token)), 47 | depositAmount, 48 | owner, 49 | guard, 50 | uint48(block.timestamp), 51 | uint48(block.timestamp + 60) 52 | ); 53 | 54 | // Checking BoostConfig object is correct 55 | ( 56 | IERC20 _token, 57 | uint256 _balance, 58 | address _guard, 59 | uint48 _start, 60 | uint48 _end 61 | ) = boost.boosts(boostId); 62 | assertEq(address(token), address(_token)); 63 | assertEq(boostBalance, _balance); 64 | assertEq(guard, _guard); 65 | assertEq(uint48(block.timestamp), _start); 66 | assertEq(uint48(block.timestamp + 60), _end); 67 | 68 | // Checking balances of eth and the token are correct 69 | assertEq(address(boost).balance, ethFee); 70 | assertEq(owner.balance, 0); 71 | assertEq(token.balanceOf(address(boost)), depositAmount); 72 | assertEq(boost.tokenFeeBalances(address(token)), tokenFeeAmount); 73 | } 74 | 75 | function testCreateMultipleBoostsWithProtocolFee() public { 76 | _mintAndApprove(owner, 2 * depositAmount, 2 * depositAmount); 77 | uint256 boostBalanceIncrease = (depositAmount * MYRIAD) / 78 | (MYRIAD + tokenFee); 79 | uint256 tokenFeeAmount = depositAmount - boostBalanceIncrease; 80 | 81 | uint256 boostId1 = boost.nextBoostId(); 82 | vm.deal(owner, 2 * ethFee); 83 | vm.prank(owner); 84 | boost.mint{value: ethFee}( 85 | strategyURI, 86 | IERC20(address(token)), 87 | depositAmount, 88 | owner, 89 | guard, 90 | uint48(block.timestamp), 91 | uint48(block.timestamp + 60) 92 | ); 93 | uint256 boostId2 = boost.nextBoostId(); 94 | vm.prank(owner); 95 | snapStart("CreateBoostWithProtocolFee"); 96 | boost.mint{value: ethFee}( 97 | strategyURI, 98 | IERC20(address(token)), 99 | depositAmount, 100 | owner, 101 | guard, 102 | uint48(block.timestamp), 103 | uint48(block.timestamp + 60) 104 | ); 105 | snapEnd(); 106 | 107 | (, uint256 _balance1, , , ) = boost.boosts(boostId1); 108 | (, uint256 _balance2, , , ) = boost.boosts(boostId2); 109 | 110 | assertEq(boostId1, 0); 111 | assertEq(boostId2, 1); 112 | 113 | // After creating 2 boosts, the owner's balance should be 2 114 | assertEq(boost.balanceOf(owner), 2); 115 | 116 | assertEq(_balance1, boostBalanceIncrease); 117 | assertEq(_balance2, boostBalanceIncrease); 118 | 119 | assertEq(token.balanceOf(address(boost)), 2 * depositAmount); 120 | assertEq(boost.tokenFeeBalances(address(token)), 2 * tokenFeeAmount); 121 | assertEq(address(boost).balance, 2 * ethFee); 122 | } 123 | 124 | function testUpdateProtocolFees() public { 125 | uint256 newEthFee = 2000; 126 | uint256 newTokenFee = 20; // 0.2% 127 | 128 | vm.expectEmit(true, true, false, true); 129 | emit EthFeeSet(newEthFee); 130 | vm.prank(protocolOwner); 131 | boost.setEthFee(newEthFee); 132 | 133 | vm.expectEmit(true, true, false, true); 134 | emit TokenFeeSet(newTokenFee); 135 | vm.prank(protocolOwner); 136 | boost.setTokenFee(newTokenFee); 137 | 138 | _mintAndApprove(owner, depositAmount, depositAmount); 139 | uint256 boostBalanceIncrease = (depositAmount * MYRIAD) / 140 | (MYRIAD + newTokenFee); 141 | uint256 tokenFeeAmount = depositAmount - boostBalanceIncrease; 142 | 143 | _createBoost( 144 | strategyURI, 145 | address(token), 146 | depositAmount, 147 | owner, 148 | guard, 149 | block.timestamp, 150 | block.timestamp + 60, 151 | newEthFee 152 | ); 153 | 154 | // Checking balances of eth and the token are correct 155 | assertEq(address(boost).balance, newEthFee); 156 | assertEq(owner.balance, 0); 157 | assertEq(token.balanceOf(address(boost)), depositAmount); 158 | assertEq(boost.tokenFeeBalances(address(token)), tokenFeeAmount); 159 | } 160 | 161 | function testSetEthFeeNotProtocolOwner() public { 162 | uint256 newEthFee = 2000; 163 | vm.expectRevert("Ownable: caller is not the owner"); 164 | boost.setEthFee(newEthFee); 165 | } 166 | 167 | function testSetTokenFeeNotProtocolOwner() public { 168 | uint256 newTokenFee = 20; 169 | vm.expectRevert("Ownable: caller is not the owner"); 170 | boost.setTokenFee(newTokenFee); 171 | } 172 | 173 | function testDepositWithProtocolFees() public { 174 | _mintAndApprove(owner, depositAmount * 2, depositAmount * 2); 175 | uint256 boostId = _createBoost( 176 | strategyURI, 177 | address(token), 178 | depositAmount, 179 | owner, 180 | guard, 181 | block.timestamp + 1, 182 | block.timestamp + 60, 183 | ethFee 184 | ); 185 | 186 | uint256 boostBalanceIncrease = (depositAmount * MYRIAD) / 187 | (MYRIAD + tokenFee); 188 | uint256 tokenFeeAmount = depositAmount - boostBalanceIncrease; 189 | 190 | vm.prank(owner); 191 | vm.expectEmit(true, true, false, true); 192 | emit Deposit(boostId, owner, boostBalanceIncrease); 193 | snapStart("DepositWithProtocolFees"); 194 | boost.deposit(boostId, depositAmount); 195 | snapEnd(); 196 | assertEq(address(boost).balance, ethFee); 197 | assertEq(owner.balance, 0); 198 | // The deposit amount when the boost was created and when a deposit was added was the same therefore 199 | // we multiply the balance increase and token fee amount by 2 to get the aggregate values. 200 | assertEq(token.balanceOf(address(boost)), 2 * depositAmount); 201 | assertEq(boost.tokenFeeBalances(address(token)), 2 * tokenFeeAmount); 202 | } 203 | 204 | function testCollectFees() public { 205 | _mintAndApprove(owner, depositAmount, depositAmount); 206 | uint256 boostBalanceIncrease = (depositAmount * MYRIAD) / 207 | (MYRIAD + tokenFee); 208 | uint256 tokenFeeAmount = depositAmount - boostBalanceIncrease; 209 | 210 | _createBoost( 211 | strategyURI, 212 | address(token), 213 | depositAmount, 214 | owner, 215 | guard, 216 | block.timestamp, 217 | block.timestamp + 60, 218 | ethFee 219 | ); 220 | 221 | assertEq(address(boost).balance, ethFee); 222 | assertEq(protocolOwner.balance, 0); 223 | assertEq(token.balanceOf(address(boost)), depositAmount); 224 | assertEq(boost.tokenFeeBalances(address(token)), tokenFeeAmount); 225 | 226 | vm.prank(protocolOwner); 227 | vm.expectEmit(true, true, false, true); 228 | emit EthFeesCollected(protocolOwner); 229 | boost.collectEthFees(protocolOwner); 230 | 231 | vm.prank(protocolOwner); 232 | vm.expectEmit(true, true, false, true); 233 | emit TokenFeesCollected(IERC20(token), protocolOwner); 234 | boost.collectTokenFees(IERC20(token), protocolOwner); 235 | 236 | // Checking balances are correct after fees are collected 237 | assertEq(address(boost).balance, 0); 238 | assertEq(protocolOwner.balance, ethFee); 239 | assertEq( 240 | token.balanceOf(address(boost)), 241 | depositAmount - tokenFeeAmount 242 | ); 243 | assertEq(boost.tokenFeeBalances(address(token)), 0); 244 | } 245 | 246 | function testInsufficientEthFee() public { 247 | _mintAndApprove(owner, depositAmount * 2, depositAmount * 2); 248 | vm.expectRevert(IBoost.InsufficientEthFee.selector); 249 | vm.prank(owner); 250 | vm.deal(owner, ethFee - 1); 251 | boost.mint{value: ethFee - 1}( 252 | strategyURI, 253 | IERC20(token), 254 | depositAmount, 255 | owner, 256 | guard, 257 | uint48(block.timestamp), 258 | uint48(block.timestamp + 60) 259 | ); 260 | } 261 | 262 | function testMaxTokenFee() public { 263 | uint256 newEthFee = 0; 264 | uint256 newTokenFee = MYRIAD * MYRIAD * MYRIAD; // This will round up the tokenFee to 0 265 | 266 | vm.prank(protocolOwner); 267 | boost.setEthFee(newEthFee); 268 | vm.prank(protocolOwner); 269 | boost.setTokenFee(newTokenFee); 270 | _mintAndApprove(owner, depositAmount, depositAmount); 271 | _createBoost(); 272 | 273 | assertEq(token.balanceOf(address(boost)), depositAmount); 274 | assertEq(boost.tokenFeeBalances(address(token)), depositAmount); 275 | assertEq(token.balanceOf(protocolOwner), 0); 276 | } 277 | 278 | function testUpdateProtocolOwner() public { 279 | address newProtocolOwner = address(0xBEEF); 280 | vm.prank(protocolOwner); 281 | vm.expectEmit(true, true, false, true); 282 | emit OwnershipTransferred(protocolOwner, newProtocolOwner); 283 | boost.transferOwnership(newProtocolOwner); 284 | } 285 | 286 | function testUpdateProtocolOwnerNotProtocolOwner() public { 287 | address newProtocolOwner = address(0xBEEF); 288 | vm.expectRevert("Ownable: caller is not the owner"); 289 | boost.transferOwnership(newProtocolOwner); 290 | } 291 | 292 | function testProtocolOwnerRenounceOwnership() public { 293 | vm.prank(protocolOwner); 294 | vm.expectEmit(true, true, false, true); 295 | emit OwnershipTransferred(protocolOwner, address(0)); 296 | boost.renounceOwnership(); 297 | } 298 | } 299 | -------------------------------------------------------------------------------- /test/Transfer.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.23; 3 | 4 | import "./Boost.t.sol"; 5 | 6 | contract BoostERC721Test is BoostTest { 7 | address public constant exchange = address(0x1111); 8 | address public constant owner2 = address(0x2222); 9 | 10 | function testTransferFrom() public { 11 | _mintAndApprove(owner, depositAmount, depositAmount); 12 | uint256 boostId = _createBoost(); 13 | 14 | vm.prank(owner); 15 | boost.approve(exchange, boostId); 16 | assertEq(boost.ownerOf(boostId), owner); // sanity check 17 | vm.prank(exchange); 18 | boost.safeTransferFrom(owner, owner2, boostId); 19 | 20 | // Checking that the ownership has been transferred 21 | assertEq(boost.ownerOf(boostId), owner2); 22 | } 23 | 24 | function testTransfer() public { 25 | _mintAndApprove(owner, depositAmount, depositAmount); 26 | uint256 boostId = _createBoost(); 27 | 28 | vm.prank(owner); 29 | // No approval needed as the current owner is performing the transfer 30 | snapStart("Transfer"); 31 | boost.safeTransferFrom(owner, owner2, boostId); 32 | snapEnd(); 33 | assertEq(boost.ownerOf(boostId), owner2); 34 | } 35 | 36 | function testBoostTransferNoApproval() public { 37 | _mintAndApprove(owner, depositAmount, depositAmount); 38 | uint256 boostId = _createBoost(); 39 | 40 | // exchange was not approved to transfer the token 41 | vm.prank(exchange); 42 | vm.expectRevert("ERC721: caller is not token owner or approved"); 43 | boost.safeTransferFrom(owner, owner2, boostId); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /test/mocks/MockERC20.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-only 2 | pragma solidity ^0.8.23; 3 | 4 | import "openzeppelin-contracts/token/ERC20/ERC20.sol"; 5 | 6 | contract MockERC20 is ERC20 { 7 | constructor( 8 | string memory _name, 9 | string memory _symbol 10 | ) ERC20(_name, _symbol) {} 11 | 12 | function mint(address to, uint256 value) public virtual { 13 | _mint(to, value); 14 | } 15 | 16 | function burn(address from, uint256 value) public virtual { 17 | _burn(from, value); 18 | } 19 | } 20 | --------------------------------------------------------------------------------