diff --git a/abi/IBalancerRateProvider.json b/abi/IBalancerRateProvider.json new file mode 100644 index 00000000..6ef7bf65 --- /dev/null +++ b/abi/IBalancerRateProvider.json @@ -0,0 +1,15 @@ +[ + { + "inputs": [], + "name": "getRate", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + } +] diff --git a/abi/ICumulativeMerkleDrop.json b/abi/ICumulativeMerkleDrop.json new file mode 100644 index 00000000..ae369fb5 --- /dev/null +++ b/abi/ICumulativeMerkleDrop.json @@ -0,0 +1,117 @@ +[ + { + "inputs": [], + "name": "AlreadyClaimed", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidProof", + "type": "error" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "account", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "cumulativeAmount", + "type": "uint256" + } + ], + "name": "Claimed", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "merkleRoot", + "type": "bytes32" + }, + { + "indexed": false, + "internalType": "string", + "name": "proofsIpfsHash", + "type": "string" + } + ], + "name": "MerkleRootUpdated", + "type": "event" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + }, + { + "internalType": "uint256", + "name": "cumulativeAmount", + "type": "uint256" + }, + { + "internalType": "bytes32[]", + "name": "merkleProof", + "type": "bytes32[]" + } + ], + "name": "claim", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "merkleRoot", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "_merkleRoot", + "type": "bytes32" + }, + { + "internalType": "string", + "name": "proofsIpfsHash", + "type": "string" + } + ], + "name": "setMerkleRoot", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "token", + "outputs": [ + { + "internalType": "contract IERC20", + "name": "", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "function" + } +] diff --git a/abi/IRewardSplitter.json b/abi/IRewardSplitter.json new file mode 100644 index 00000000..9d994392 --- /dev/null +++ b/abi/IRewardSplitter.json @@ -0,0 +1,351 @@ +[ + { + "inputs": [], + "name": "InvalidAccount", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidAmount", + "type": "error" + }, + { + "inputs": [], + "name": "NotHarvested", + "type": "error" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "totalRewards", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "rewardPerShare", + "type": "uint256" + } + ], + "name": "RewardsSynced", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "account", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "RewardsWithdrawn", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "account", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "SharesDecreased", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "account", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "SharesIncreased", + "type": "event" + }, + { + "inputs": [], + "name": "canSyncRewards", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "rewards", + "type": "uint256" + }, + { + "internalType": "address", + "name": "receiver", + "type": "address" + } + ], + "name": "claimVaultTokens", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + }, + { + "internalType": "uint128", + "name": "amount", + "type": "uint128" + } + ], + "name": "decreaseShares", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "rewards", + "type": "uint256" + }, + { + "internalType": "address", + "name": "receiver", + "type": "address" + } + ], + "name": "enterExitQueue", + "outputs": [ + { + "internalType": "uint256", + "name": "positionTicket", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + }, + { + "internalType": "uint128", + "name": "amount", + "type": "uint128" + } + ], + "name": "increaseShares", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "internalType": "address", + "name": "_vault", + "type": "address" + } + ], + "name": "initialize", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes[]", + "name": "data", + "type": "bytes[]" + } + ], + "name": "multicall", + "outputs": [ + { + "internalType": "bytes[]", + "name": "results", + "type": "bytes[]" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "rewards", + "type": "uint256" + }, + { + "internalType": "address", + "name": "receiver", + "type": "address" + } + ], + "name": "redeem", + "outputs": [ + { + "internalType": "uint256", + "name": "assets", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "rewardsOf", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "sharesOf", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "syncRewards", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "totalShares", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "components": [ + { + "internalType": "bytes32", + "name": "rewardsRoot", + "type": "bytes32" + }, + { + "internalType": "int160", + "name": "reward", + "type": "int160" + }, + { + "internalType": "uint160", + "name": "unlockedMevReward", + "type": "uint160" + }, + { + "internalType": "bytes32[]", + "name": "proof", + "type": "bytes32[]" + } + ], + "internalType": "struct IKeeperRewards.HarvestParams", + "name": "harvestParams", + "type": "tuple" + } + ], + "name": "updateVaultState", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "vault", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "function" + } +] diff --git a/abi/IRewardSplitterFactory.json b/abi/IRewardSplitterFactory.json new file mode 100644 index 00000000..5cf62d15 --- /dev/null +++ b/abi/IRewardSplitterFactory.json @@ -0,0 +1,59 @@ +[ + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": false, + "internalType": "address", + "name": "vault", + "type": "address" + }, + { + "indexed": false, + "internalType": "address", + "name": "rewardSplitter", + "type": "address" + } + ], + "name": "RewardSplitterCreated", + "type": "event" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "vault", + "type": "address" + } + ], + "name": "createRewardSplitter", + "outputs": [ + { + "internalType": "address", + "name": "rewardSplitter", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "implementation", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + } +] diff --git a/audits/StakeWise_Protocol_V3_Smart_Contract_Security_Audit_Report_Halborn_Final.pdf b/audits/05-2023-Halborn.pdf similarity index 100% rename from audits/StakeWise_Protocol_V3_Smart_Contract_Security_Audit_Report_Halborn_Final.pdf rename to audits/05-2023-Halborn.pdf diff --git a/audits/08-2023-Halborn.pdf b/audits/08-2023-Halborn.pdf new file mode 100644 index 00000000..f753db2a Binary files /dev/null and b/audits/08-2023-Halborn.pdf differ diff --git a/audits/08-2023-Sigma-Prime.pdf b/audits/08-2023-Sigma-Prime.pdf new file mode 100644 index 00000000..08430ca0 Binary files /dev/null and b/audits/08-2023-Sigma-Prime.pdf differ diff --git a/contracts/interfaces/IBalancerRateProvider.sol b/contracts/interfaces/IBalancerRateProvider.sol new file mode 100644 index 00000000..b3ecc4ec --- /dev/null +++ b/contracts/interfaces/IBalancerRateProvider.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +pragma solidity =0.8.20; + +interface IBalancerRateProvider { + /** + * @notice Returns the price of a unit of osToken (e.g price of osETH in ETH) + * @return The price of a unit of osToken (with 18 decimals) + */ + function getRate() external view returns (uint256); +} diff --git a/contracts/interfaces/ICumulativeMerkleDrop.sol b/contracts/interfaces/ICumulativeMerkleDrop.sol new file mode 100644 index 00000000..dea8f476 --- /dev/null +++ b/contracts/interfaces/ICumulativeMerkleDrop.sol @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: BUSL-1.1 + +pragma solidity =0.8.20; + +import {IERC20} from '@openzeppelin/contracts/token/ERC20/IERC20.sol'; + +/** + * @title ICumulativeMerkleDrop + * @author StakeWise + * @notice Defines the interface for the CumulativeMerkleDrop contract + */ +interface ICumulativeMerkleDrop { + // Custom errors + error InvalidProof(); + error AlreadyClaimed(); + + /** + * @notice Event emitted when the Merkle root is updated + * @param merkleRoot The new Merkle root hash + * @param proofsIpfsHash The IPFS hash with the Merkle tree proofs + */ + event MerkleRootUpdated(bytes32 indexed merkleRoot, string proofsIpfsHash); + + /** + * @notice Event emitted when tokens are claimed + * @param account The address of the account that claimed tokens + * @param cumulativeAmount The cumulative amount of tokens claimed so far + */ + event Claimed(address indexed account, uint256 cumulativeAmount); + + /** + * @notice The address of the distribution token + * @return The address of the token contract + */ + function token() external returns (IERC20); + + /** + * @notice The current Merkle root + * @return The Merkle root hash + */ + function merkleRoot() external returns (bytes32); + + /** + * @notice Function for updating the Merkle root of the distribution. Can only be called by the owner. + * @param _merkleRoot The new Merkle root hash + * @param proofsIpfsHash The IPFS hash with the Merkle tree proofs + */ + function setMerkleRoot(bytes32 _merkleRoot, string calldata proofsIpfsHash) external; + + /** + * @notice Function for claiming tokens from the distribution + * @param account The address of the account to claim tokens for + * @param cumulativeAmount The cumulative amount of tokens to claim + * @param merkleProof The Merkle proof for the distribution + */ + function claim( + address account, + uint256 cumulativeAmount, + bytes32[] calldata merkleProof + ) external; +} diff --git a/contracts/interfaces/IRewardSplitter.sol b/contracts/interfaces/IRewardSplitter.sol new file mode 100644 index 00000000..ee98fd33 --- /dev/null +++ b/contracts/interfaces/IRewardSplitter.sol @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: BUSL-1.1 + +pragma solidity =0.8.20; + +import {IMulticall} from './IMulticall.sol'; +import {IKeeperRewards} from './IKeeperRewards.sol'; + +/** + * @title IRewardSplitter + * @author StakeWise + * @notice Defines the interface for the RewardSplitter contract + */ +interface IRewardSplitter is IMulticall { + // Custom errors + error NotHarvested(); + error InvalidAccount(); + error InvalidAmount(); + + /** + * @notice Structure for storing information about share holder + * @param shares The amount of shares the account has + * @param rewardPerShare The last synced reward per share + */ + struct ShareHolder { + uint128 shares; + uint128 rewardPerShare; + } + + /** + * @notice Event emitted when the number of shares is increased for an account + * @param account The address of the account for which the shares were increased + * @param amount The amount of shares that were added + */ + event SharesIncreased(address indexed account, uint256 amount); + + /** + * @notice Event emitted when the number of shares is decreased for an account + * @param account The address of the account for which the shares were decreased + * @param amount The amount of shares that were deducted + */ + event SharesDecreased(address indexed account, uint256 amount); + + /** + * @notice Event emitted when the rewards are synced from the vault. + * @param totalRewards The new total amount of rewards + * @param rewardPerShare The new reward per share + */ + event RewardsSynced(uint256 totalRewards, uint256 rewardPerShare); + + /** + * @notice Event emitted when the rewards are withdrawn from the splitter + * @param account The address of the account for which the rewards were withdrawn + * @param amount The amount of rewards that were withdrawn + */ + event RewardsWithdrawn(address indexed account, uint256 amount); + + /** + * @notice The vault to which the RewardSplitter is connected + * @return The address of the vault + */ + function vault() external returns (address); + + /** + * @notice The total number of shares in the splitter + * @return The total number of shares + */ + function totalShares() external returns (uint256); + + /** + * @notice Initializes the RewardSplitter contract + * @param owner The address of the owner of the RewardSplitter contract + * @param _vault The address of the vault to which the RewardSplitter will be connected + */ + function initialize(address owner, address _vault) external; + + /** + * @notice Retrieves the amount of splitter shares for the given account. + The shares are used to calculate the amount of rewards the account is entitled to. + * @param account The address of the account to get shares for + */ + function sharesOf(address account) external view returns (uint256); + + /** + * @notice Retrieves the amount of rewards the account is entitled to. + The rewards are calculated based on the amount of shares the account has. + Note, rewards must be synced using the `syncRewards` function. + * @param account The address of the account to get rewards for + */ + function rewardsOf(address account) external view returns (uint256); + + /** + * @notice Checks whether new rewards can be synced from the vault. + * @return True if new rewards can be synced, false otherwise + */ + function canSyncRewards() external view returns (bool); + + /** + * @notice Increases the amount of shares for the given account. Can only be called by the owner. + * @param account The address of the account to increase shares for + * @param amount The amount of shares to add + */ + function increaseShares(address account, uint128 amount) external; + + /** + * @notice Decreases the amount of shares for the given account. Can only be called by the owner. + * @param account The address of the account to decrease shares for + * @param amount The amount of shares to deduct + */ + function decreaseShares(address account, uint128 amount) external; + + /** + * @notice Updates the vault state. Can be used in multicall to update state, sync rewards and withdraw them. + * @param harvestParams The harvest params to use for updating the vault state + */ + function updateVaultState(IKeeperRewards.HarvestParams calldata harvestParams) external; + + /** + * @notice Transfers the vault tokens to the given account. Can only be called for the vault with ERC-20 token. + * @param rewards The amount of vault tokens to transfer + * @param receiver The address of the account to transfer tokens to + */ + function claimVaultTokens(uint256 rewards, address receiver) external; + + /** + * @notice Sends the rewards to the exit queue + * @param rewards The amount of rewards to send to the exit queue + * @param receiver The address that will claim exited assets + * @return positionTicket The position ticket of the exit queue + */ + function enterExitQueue( + uint256 rewards, + address receiver + ) external returns (uint256 positionTicket); + + /** + * @notice Redeems available assets from the vault + * @param rewards The amount of rewards to redeem + * @param receiver The address that will receive the redeemed assets + * @return assets The amount of assets that were redeemed + */ + function redeem(uint256 rewards, address receiver) external returns (uint256 assets); + + /** + * @notice Syncs the rewards from the vault to the splitter. The vault state must be up-to-date. + */ + function syncRewards() external; +} diff --git a/contracts/interfaces/IRewardSplitterFactory.sol b/contracts/interfaces/IRewardSplitterFactory.sol new file mode 100644 index 00000000..1cb9ee25 --- /dev/null +++ b/contracts/interfaces/IRewardSplitterFactory.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: BUSL-1.1 + +pragma solidity =0.8.20; + +/** + * @title IRewardSplitterFactory + * @author StakeWise + * @notice Defines the interface for the RewardSplitterFactory contract + */ +interface IRewardSplitterFactory { + /** + * @notice Event emitted on a RewardSplitter creation + * @param owner The address of the RewardSplitter owner + * @param vault The address of the connected vault + * @param rewardSplitter The address of the created RewardSplitter + */ + event RewardSplitterCreated(address owner, address vault, address rewardSplitter); + + /** + * @notice The address of the RewardSplitter implementation contract used for proxy creation + * @return The address of the RewardSplitter proxy contract + */ + function implementation() external view returns (address); + + /** + * @notice Creates RewardSplitter contract proxy + * @param vault The address of the vault to which the RewardSplitter will be connected + * @return rewardSplitter The address of the created RewardSplitter contract + */ + function createRewardSplitter(address vault) external returns (address rewardSplitter); +} diff --git a/contracts/misc/CumulativeMerkleDrop.sol b/contracts/misc/CumulativeMerkleDrop.sol new file mode 100644 index 00000000..a905ddeb --- /dev/null +++ b/contracts/misc/CumulativeMerkleDrop.sol @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: BUSL-1.1 + +pragma solidity 0.8.20; + +import {IERC20} from '@openzeppelin/contracts/token/ERC20/IERC20.sol'; +import {Ownable2Step} from '@openzeppelin/contracts/access/Ownable2Step.sol'; +import {SafeERC20} from '@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol'; +import {MerkleProof} from '@openzeppelin/contracts/utils/cryptography/MerkleProof.sol'; +import {ICumulativeMerkleDrop} from '../interfaces/ICumulativeMerkleDrop.sol'; + +contract CumulativeMerkleDrop is Ownable2Step, ICumulativeMerkleDrop { + /// @inheritdoc ICumulativeMerkleDrop + IERC20 public immutable override token; + + /// @inheritdoc ICumulativeMerkleDrop + bytes32 public override merkleRoot; + + mapping(address => uint256) private _cumulativeClaimed; + + /** + * @dev Constructor + * @param _owner The address of the owner of the contract + * @param _token The address of the token contract + */ + constructor(address _owner, address _token) { + _transferOwnership(_owner); + token = IERC20(_token); + } + + /// @inheritdoc ICumulativeMerkleDrop + function setMerkleRoot( + bytes32 _merkleRoot, + string calldata proofsIpfsHash + ) external override onlyOwner { + merkleRoot = _merkleRoot; + emit MerkleRootUpdated(_merkleRoot, proofsIpfsHash); + } + + /// @inheritdoc ICumulativeMerkleDrop + function claim( + address account, + uint256 cumulativeAmount, + bytes32[] calldata merkleProof + ) external override { + // verify the merkle proof + if ( + !MerkleProof.verifyCalldata( + merkleProof, + merkleRoot, + keccak256(bytes.concat(keccak256(abi.encode(account, cumulativeAmount)))) + ) + ) { + revert InvalidProof(); + } + + // SLOAD to memory + uint256 amountBefore = _cumulativeClaimed[account]; + + // reverts if less than before + uint256 periodAmount = cumulativeAmount - amountBefore; + if (periodAmount == 0) revert AlreadyClaimed(); + + // update state + _cumulativeClaimed[account] = cumulativeAmount; + + // transfer amount + SafeERC20.safeTransfer(token, account, periodAmount); + emit Claimed(account, cumulativeAmount); + } +} diff --git a/contracts/misc/RewardSplitter.sol b/contracts/misc/RewardSplitter.sol new file mode 100644 index 00000000..ee6a140f --- /dev/null +++ b/contracts/misc/RewardSplitter.sol @@ -0,0 +1,186 @@ +// SPDX-License-Identifier: BUSL-1.1 + +pragma solidity =0.8.20; + +import {SafeCast} from '@openzeppelin/contracts/utils/math/SafeCast.sol'; +import {Math} from '@openzeppelin/contracts/utils/math/Math.sol'; +import {SafeERC20} from '@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol'; +import {IERC20} from '@openzeppelin/contracts/token/ERC20/IERC20.sol'; +import {Initializable} from '@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol'; +import {OwnableUpgradeable} from '@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol'; +import {IKeeperRewards} from '../interfaces/IKeeperRewards.sol'; +import {IRewardSplitter} from '../interfaces/IRewardSplitter.sol'; +import {IVaultState} from '../interfaces/IVaultState.sol'; +import {IVaultEnterExit} from '../interfaces/IVaultEnterExit.sol'; +import {Multicall} from '../base/Multicall.sol'; + +/** + * @title RewardSplitter + * @author StakeWise + * @notice The RewardSplitter can be used to split the rewards of the fee recipient of the vault based on configures shares + */ +contract RewardSplitter is IRewardSplitter, Initializable, OwnableUpgradeable, Multicall { + uint256 private constant _wad = 1e18; + + /// @inheritdoc IRewardSplitter + address public override vault; + + /// @inheritdoc IRewardSplitter + uint256 public override totalShares; + + mapping(address => ShareHolder) private _shareHolders; + mapping(address => uint256) private _unclaimedRewards; + + uint128 private _totalRewards; + uint128 private _rewardPerShare; + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + /// @inheritdoc IRewardSplitter + function initialize(address owner, address _vault) external override initializer { + _transferOwnership(owner); + vault = _vault; + } + + /// @inheritdoc IRewardSplitter + function sharesOf(address account) external view override returns (uint256) { + return _shareHolders[account].shares; + } + + /// @inheritdoc IRewardSplitter + function rewardsOf(address account) public view override returns (uint256) { + // SLOAD to memory + ShareHolder memory shareHolder = _shareHolders[account]; + // calculate period rewards based on current reward per share + uint256 periodRewards = Math.mulDiv( + shareHolder.shares, + _rewardPerShare - shareHolder.rewardPerShare, + _wad + ); + return _unclaimedRewards[account] + periodRewards; + } + + /// @inheritdoc IRewardSplitter + function canSyncRewards() external view override returns (bool) { + return totalShares > 0 && _totalRewards != IERC20(vault).balanceOf(address(this)); + } + + /// @inheritdoc IRewardSplitter + function increaseShares(address account, uint128 amount) external override onlyOwner { + if (account == address(0)) revert InvalidAccount(); + if (amount == 0) revert InvalidAmount(); + + // update rewards state + syncRewards(); + + // update unclaimed rewards + _unclaimedRewards[account] = rewardsOf(account); + + // increase shares for the account + _shareHolders[account] = ShareHolder({ + shares: _shareHolders[account].shares + amount, + rewardPerShare: _rewardPerShare + }); + totalShares += amount; + + // emit event + emit SharesIncreased(account, amount); + } + + /// @inheritdoc IRewardSplitter + function decreaseShares(address account, uint128 amount) external override onlyOwner { + if (account == address(0)) revert InvalidAccount(); + if (amount == 0) revert InvalidAmount(); + + // update rewards state + syncRewards(); + + // update unclaimed rewards + _unclaimedRewards[account] = rewardsOf(account); + + // decrease shares for the account + _shareHolders[account] = ShareHolder({ + shares: _shareHolders[account].shares - amount, + rewardPerShare: _rewardPerShare + }); + totalShares -= amount; + + // emit event + emit SharesDecreased(account, amount); + } + + /// @inheritdoc IRewardSplitter + function updateVaultState(IKeeperRewards.HarvestParams calldata harvestParams) external override { + IVaultState(vault).updateState(harvestParams); + } + + /// @inheritdoc IRewardSplitter + function claimVaultTokens(uint256 rewards, address receiver) external override { + _withdrawRewards(msg.sender, rewards); + // NB! will revert if vault is not ERC-20 + SafeERC20.safeTransfer(IERC20(vault), receiver, rewards); + } + + /// @inheritdoc IRewardSplitter + function enterExitQueue( + uint256 rewards, + address receiver + ) external override returns (uint256 positionTicket) { + _withdrawRewards(msg.sender, rewards); + return IVaultEnterExit(vault).enterExitQueue(rewards, receiver); + } + + /// @inheritdoc IRewardSplitter + function redeem(uint256 rewards, address receiver) external override returns (uint256 assets) { + _withdrawRewards(msg.sender, rewards); + return IVaultEnterExit(vault).redeem(rewards, receiver); + } + + /// @inheritdoc IRewardSplitter + function syncRewards() public override { + // SLOAD to memory + uint256 _totalShares = totalShares; + if (_totalShares == 0) return; + + address _vault = vault; + // vault state must be up-to-date + if (IVaultState(_vault).isStateUpdateRequired()) revert NotHarvested(); + + // SLOAD to memory + uint256 prevTotalRewards = _totalRewards; + + // retrieve new total rewards + // NB! make sure vault has balanceOf function to retrieve number of shares assigned + uint256 newTotalRewards = IERC20(_vault).balanceOf(address(this)); + if (newTotalRewards == prevTotalRewards) return; + + // calculate new cumulative reward per share + // reverts when total shares is zero + uint256 newRewardPerShare = _rewardPerShare + + Math.mulDiv(newTotalRewards - prevTotalRewards, _wad, _totalShares); + + // update state + _totalRewards = SafeCast.toUint128(newTotalRewards); + _rewardPerShare = SafeCast.toUint128(newRewardPerShare); + + // emit event + emit RewardsSynced(newTotalRewards, newRewardPerShare); + } + + function _withdrawRewards(address account, uint256 rewards) private { + // get user total number of rewards + uint256 accountRewards = rewardsOf(account); + + // update state + _totalRewards -= SafeCast.toUint128(rewards); + // reverts if withdrawn rewards exceed total + _unclaimedRewards[account] = accountRewards - rewards; + _shareHolders[account].rewardPerShare = _rewardPerShare; + + // emit event + emit RewardsWithdrawn(account, rewards); + } +} diff --git a/contracts/misc/RewardSplitterFactory.sol b/contracts/misc/RewardSplitterFactory.sol new file mode 100644 index 00000000..98291e92 --- /dev/null +++ b/contracts/misc/RewardSplitterFactory.sol @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: BUSL-1.1 + +pragma solidity =0.8.20; + +import {Clones} from '@openzeppelin/contracts/proxy/Clones.sol'; +import {IRewardSplitter} from '../interfaces/IRewardSplitter.sol'; +import {IRewardSplitterFactory} from '../interfaces/IRewardSplitterFactory.sol'; + +/** + * @title RewardSplitterFactory + * @author StakeWise + * @notice Factory for deploying the RewardSplitter contract + */ +contract RewardSplitterFactory is IRewardSplitterFactory { + /// @inheritdoc IRewardSplitterFactory + address public immutable override implementation; + + /** + * @dev Constructor + * @param _implementation The implementation address of RewardSplitter + */ + constructor(address _implementation) { + implementation = _implementation; + } + + /// @inheritdoc IRewardSplitterFactory + function createRewardSplitter(address vault) external override returns (address rewardSplitter) { + // deploy and initialize reward splitter + rewardSplitter = Clones.clone(implementation); + IRewardSplitter(rewardSplitter).initialize(msg.sender, vault); + + // emit event + emit RewardSplitterCreated(msg.sender, vault, rewardSplitter); + } +} diff --git a/contracts/mocks/ERC20Mock.sol b/contracts/mocks/ERC20Mock.sol new file mode 100644 index 00000000..121b8640 --- /dev/null +++ b/contracts/mocks/ERC20Mock.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MIT + +pragma solidity =0.8.20; + +import {ERC20} from '@openzeppelin/contracts/token/ERC20/ERC20.sol'; + +contract ERC20Mock is ERC20 { + constructor() ERC20('ERC20Mock', 'E20M') {} + + function mint(address account, uint256 amount) external { + _mint(account, amount); + } + + function burn(address account, uint256 amount) external { + _burn(account, amount); + } +} diff --git a/contracts/osToken/PriceFeed.sol b/contracts/osToken/PriceFeed.sol index 20784caf..fcfcc41d 100644 --- a/contracts/osToken/PriceFeed.sol +++ b/contracts/osToken/PriceFeed.sol @@ -4,6 +4,7 @@ pragma solidity =0.8.20; import {IChainlinkAggregator} from '../interfaces/IChainlinkAggregator.sol'; import {IChainlinkV3Aggregator} from '../interfaces/IChainlinkV3Aggregator.sol'; +import {IBalancerRateProvider} from '../interfaces/IBalancerRateProvider.sol'; import {IOsToken} from '../interfaces/IOsToken.sol'; /** @@ -11,7 +12,7 @@ import {IOsToken} from '../interfaces/IOsToken.sol'; * @author StakeWise * @notice Price feed for osToken (e.g osETH price in ETH) */ -contract PriceFeed is IChainlinkAggregator, IChainlinkV3Aggregator { +contract PriceFeed is IBalancerRateProvider, IChainlinkAggregator, IChainlinkV3Aggregator { error NotImplemented(); /// @inheritdoc IChainlinkV3Aggregator @@ -32,9 +33,14 @@ contract PriceFeed is IChainlinkAggregator, IChainlinkV3Aggregator { description = _description; } + /// @inheritdoc IBalancerRateProvider + function getRate() public view override returns (uint256) { + return IOsToken(osToken).convertToAssets(10 ** decimals()); + } + /// @inheritdoc IChainlinkAggregator function latestAnswer() public view override returns (int256) { - uint256 value = IOsToken(osToken).convertToAssets(10 ** decimals()); + uint256 value = getRate(); // cannot realistically overflow, but better to check return (value > uint256(type(int256).max)) ? type(int256).max : int256(value); } diff --git a/helpers/constants.ts b/helpers/constants.ts index 7d651c39..4c5b341c 100644 --- a/helpers/constants.ts +++ b/helpers/constants.ts @@ -54,6 +54,10 @@ export const NETWORKS: { feePercent: 500, }, priceFeedDescription: 'osETH/ETH', + + // Cumulative MerkleDrop + liquidityCommittee: '0x1867c96601bc5fE24F685d112314B8F3Fe228D5A', + swiseToken: '0x0e2497aACec2755d831E4AFDEA25B4ef1B823855', }, [Networks.mainnet]: { url: process.env.NETWORK_RPC_URL || '', @@ -94,6 +98,10 @@ export const NETWORKS: { feePercent: 500, }, priceFeedDescription: 'osETH/ETH', + + // Cumulative MerkleDrop + liquidityCommittee: '', + swiseToken: '', }, } diff --git a/helpers/types.ts b/helpers/types.ts index db8a4a94..a17525bd 100644 --- a/helpers/types.ts +++ b/helpers/types.ts @@ -47,4 +47,8 @@ export type NetworkConfig = { // PriceFeed priceFeedDescription: string + + // Cumulative MerkleDrop + liquidityCommittee: string + swiseToken: string } diff --git a/tasks/eth-full-deploy-local.ts b/tasks/eth-full-deploy-local.ts index 6668ce19..95842283 100644 --- a/tasks/eth-full-deploy-local.ts +++ b/tasks/eth-full-deploy-local.ts @@ -10,8 +10,11 @@ import { PriceFeed__factory, SharedMevEscrow__factory, VaultsRegistry__factory, + RewardSplitter__factory, + RewardSplitterFactory__factory, + CumulativeMerkleDrop__factory, } from '../typechain-types' -import { deployContract } from '../helpers/utils' +import { deployContract, verify } from '../helpers/utils' import { getContractAddress } from 'ethers/lib/utils' import { NetworkConfig, Networks } from '../helpers/types' import { ethValidatorsRegistry, NETWORKS } from '../helpers/constants' @@ -137,6 +140,22 @@ task('eth-full-deploy-local', 'deploys StakeWise V3 for Ethereum to local networ ) console.log('PriceFeed deployed at', priceFeed.address) + const rewardSplitterImpl = await deployContract(new RewardSplitter__factory(deployer).deploy()) + console.log('RewardSplitter implementation deployed at', rewardSplitterImpl.address) + + const rewardSplitterFactory = await deployContract( + new RewardSplitterFactory__factory(deployer).deploy(rewardSplitterImpl.address) + ) + console.log('RewardSplitterFactory deployed at', rewardSplitterFactory.address) + + const cumulativeMerkleDrop = await deployContract( + new CumulativeMerkleDrop__factory(deployer).deploy( + goerliConfig.liquidityCommittee, + goerliConfig.swiseToken + ) + ) + console.log('CumulativeMerkleDrop deployed at', cumulativeMerkleDrop.address) + // pass ownership to governor await vaultsRegistry.transferOwnership(governor.address) await keeper.transferOwnership(governor.address) @@ -161,6 +180,7 @@ task('eth-full-deploy-local', 'deploys StakeWise V3 for Ethereum to local networ OsToken: osToken.address, OsTokenConfig: osTokenConfig.address, PriceFeed: priceFeed.address, + RewardSplitterFactory: rewardSplitterFactory.address, } const json = JSON.stringify(addresses, null, 2) const fileName = `${DEPLOYMENTS_DIR}/${networkName}.json` diff --git a/tasks/eth-full-deploy.ts b/tasks/eth-full-deploy.ts index 72d078e7..87794fd4 100644 --- a/tasks/eth-full-deploy.ts +++ b/tasks/eth-full-deploy.ts @@ -10,6 +10,9 @@ import { PriceFeed__factory, SharedMevEscrow__factory, VaultsRegistry__factory, + RewardSplitter__factory, + RewardSplitterFactory__factory, + CumulativeMerkleDrop__factory, } from '../typechain-types' import { deployContract, verify } from '../helpers/utils' import { NETWORKS } from '../helpers/constants' @@ -257,6 +260,40 @@ task('eth-full-deploy', 'deploys StakeWise V3 for Ethereum').setAction(async (ta 'contracts/osToken/PriceFeed.sol:PriceFeed' ) + const rewardSplitterImpl = await deployContract(new RewardSplitter__factory(deployer).deploy()) + console.log('RewardSplitter implementation deployed at', rewardSplitterImpl.address) + await verify( + hre, + rewardSplitterImpl.address, + [], + 'contracts/misc/RewardSplitter.sol:RewardSplitter' + ) + + const rewardSplitterFactory = await deployContract( + new RewardSplitterFactory__factory(deployer).deploy(rewardSplitterImpl.address) + ) + console.log('RewardSplitterFactory deployed at', rewardSplitterFactory.address) + await verify( + hre, + rewardSplitterFactory.address, + [rewardSplitterImpl], + 'contracts/misc/RewardSplitterFactory.sol:RewardSplitterFactory' + ) + + const cumulativeMerkleDrop = await deployContract( + new CumulativeMerkleDrop__factory(deployer).deploy( + networkConfig.liquidityCommittee, + networkConfig.swiseToken + ) + ) + console.log('CumulativeMerkleDrop deployed at', cumulativeMerkleDrop.address) + await verify( + hre, + cumulativeMerkleDrop.address, + [networkConfig.liquidityCommittee, networkConfig.swiseToken], + 'contracts/misc/CumulativeMerkleDrop.sol:CumulativeMerkleDrop' + ) + // pass ownership to governor await vaultsRegistry.transferOwnership(networkConfig.governor) await keeper.transferOwnership(networkConfig.governor) @@ -275,6 +312,7 @@ task('eth-full-deploy', 'deploys StakeWise V3 for Ethereum').setAction(async (ta OsToken: osToken.address, OsTokenConfig: osTokenConfig.address, PriceFeed: priceFeed.address, + RewardSplitterFactory: rewardSplitterFactory.address, } const json = JSON.stringify(addresses, null, 2) const fileName = `${DEPLOYMENTS_DIR}/${networkName}.json` diff --git a/test/CumulativeMerkleDrop.spec.ts b/test/CumulativeMerkleDrop.spec.ts new file mode 100644 index 00000000..33051b9b --- /dev/null +++ b/test/CumulativeMerkleDrop.spec.ts @@ -0,0 +1,126 @@ +import { ethers } from 'hardhat' +import { BigNumber, BigNumberish, Wallet } from 'ethers' +import { CumulativeMerkleDrop, ERC20Mock } from '../typechain-types' +import { createCumulativeMerkleDrop } from './shared/fixtures' +import { StandardMerkleTree } from '@openzeppelin/merkle-tree' +import { expect } from './shared/expect' +import snapshotGasCost from './shared/snapshotGasCost' +import { PANIC_CODES } from './shared/constants' + +type RewardsTree = StandardMerkleTree<[string, BigNumberish]> + +describe('CumulativeMerkleDrop', () => { + const proofsIpfsHash = 'bafkreidivzimqfqtoqxkrpge6bjyhlvxqs3rhe73owtmdulaxr5do5in7u' + const rewards: { address: string; reward: number }[] = [ + { address: '0x5E0375cFD64e036b37f74EbD213B061a0fFd6CC0', reward: 283 }, + { address: '0x3D238CccC9839f012ee613D1076F747093A25F16', reward: 649 }, + { address: '0xb12F7f27A07AB869761FF2bD11943db317a06466', reward: 779 }, + { address: '0x267a0909ea6043550D7054957061dC01eDd2915F', reward: 573 }, + { address: '0x03079134787b4570952Eacb53Bef82a7AF773fED', reward: 959 }, + { address: '0x704e14dFf77cdA4155BBC7b6AA8d3B39810aAE91', reward: 563 }, + { address: '0xC1016a99a8b37fDE1cFddf638f3d8Ec5B14c7d78', reward: 444 }, + { address: '0x6aBb7fFd8ad5770A90640ce7ca7647fA98a48702', reward: 172 }, + { address: '0x6c9A2c104D10fcA6510eF4B2c1E778aA94b50A5a', reward: 969 }, + { address: '0x408bdc9EF95A89F3B200eCb02dffEFEb87650da4', reward: 327 }, + ] + const tree: RewardsTree = StandardMerkleTree.of( + rewards.map((r) => [r.address, r.reward]), + ['address', 'uint256'] + ) as RewardsTree + let dao: Wallet, sender: Wallet + let merkleDrop: CumulativeMerkleDrop, token: ERC20Mock + + before('create fixture loader', async () => { + ;[dao, sender] = await (ethers as any).getSigners() + }) + + beforeEach('deploy fixtures', async () => { + const factory = await ethers.getContractFactory('ERC20Mock') + token = (await factory.deploy()) as ERC20Mock + merkleDrop = await createCumulativeMerkleDrop(token.address, dao) + + let totalReward = BigNumber.from(0) + for (let i = 0; i < 10; i++) { + totalReward = totalReward.add(rewards[i].reward) + } + await token.mint(merkleDrop.address, totalReward) + }) + + describe('set merkle root', () => { + it('fails for not owner', async () => { + await expect( + merkleDrop.connect(sender).setMerkleRoot(tree.root, proofsIpfsHash) + ).revertedWith('Ownable: caller is not the owner') + }) + + it('works for owner', async () => { + const receipt = await merkleDrop.connect(dao).setMerkleRoot(tree.root, proofsIpfsHash) + expect(await merkleDrop.merkleRoot()).to.eq(tree.root) + await expect(receipt) + .to.emit(merkleDrop, 'MerkleRootUpdated') + .withArgs(tree.root, proofsIpfsHash) + await snapshotGasCost(receipt) + }) + }) + + describe('claim', () => { + beforeEach('set merkle root', async () => { + await merkleDrop.connect(dao).setMerkleRoot(tree.root, proofsIpfsHash) + }) + + it('fails with invalid proof', async () => { + const reward = rewards[0] + await expect( + merkleDrop.claim( + reward.address, + reward.reward, + tree.getProof([rewards[1].address, rewards[1].reward]) + ) + ).revertedWith('InvalidProof') + }) + + it('reverts with cumulative amount less than previous', async () => { + let reward = rewards[0] + await merkleDrop.claim( + reward.address, + reward.reward, + tree.getProof([reward.address, reward.reward]) + ) + const newRewards = [{ address: reward.address, reward: reward.reward - 1 }] + const newTree = StandardMerkleTree.of( + newRewards.map((r) => [r.address, r.reward]), + ['address', 'uint256'] + ) as RewardsTree + await merkleDrop.connect(dao).setMerkleRoot(newTree.root, proofsIpfsHash) + + reward = newRewards[0] + await expect( + merkleDrop.claim( + reward.address, + reward.reward, + newTree.getProof([reward.address, reward.reward]) + ) + ).revertedWith(PANIC_CODES.ARITHMETIC_UNDER_OR_OVERFLOW) + }) + + it('works with valid proof', async () => { + const reward = rewards[0] + const receipt = await merkleDrop.claim( + reward.address, + reward.reward, + tree.getProof([reward.address, reward.reward]) + ) + await expect(receipt).to.emit(merkleDrop, 'Claimed').withArgs(reward.address, reward.reward) + await snapshotGasCost(receipt) + + // fails to claim second time + await expect( + merkleDrop.claim( + reward.address, + reward.reward, + tree.getProof([reward.address, reward.reward]) + ) + ).revertedWith('AlreadyClaimed') + }) + }) +}) diff --git a/test/KeeperValidators.spec.ts b/test/KeeperValidators.spec.ts index 86b56459..9de29401 100644 --- a/test/KeeperValidators.spec.ts +++ b/test/KeeperValidators.spec.ts @@ -477,7 +477,6 @@ describe('KeeperValidators', () => { .to.emit(keeper, 'ExitSignaturesUpdated') .withArgs(sender.address, vault.address, nonce, exitSignaturesIpfsHash) expect(await keeper.exitSignaturesNonces(vault.address)).to.eq(nonce.add(1)) - await snapshotGasCost(receipt) }) }) diff --git a/test/RewardSplitter.spec.ts b/test/RewardSplitter.spec.ts new file mode 100644 index 00000000..c5be2b24 --- /dev/null +++ b/test/RewardSplitter.spec.ts @@ -0,0 +1,429 @@ +import { ethers, waffle } from 'hardhat' +import { BigNumber, Wallet } from 'ethers' +import { parseEther } from 'ethers/lib/utils' +import { EthErc20Vault, EthVault, Keeper, RewardSplitter } from '../typechain-types' +import { createRewardSplitterFactory, ethVaultFixture } from './shared/fixtures' +import { expect } from './shared/expect' +import { PANIC_CODES, SECURITY_DEPOSIT, ZERO_ADDRESS } from './shared/constants' +import { collateralizeEthVault, getRewardsRootProof, updateRewards } from './shared/rewards' +import snapshotGasCost from './shared/snapshotGasCost' + +const createFixtureLoader = waffle.createFixtureLoader + +describe('RewardSplitter', () => { + let admin: Wallet, owner: Wallet, other: Wallet + let vault: EthVault, keeper: Keeper, rewardSplitter: RewardSplitter, erc20Vault: EthErc20Vault + + let loadFixture: ReturnType + + before('create fixture loader', async () => { + ;[owner, admin, other] = await (ethers as any).getSigners() + loadFixture = createFixtureLoader([owner]) + }) + + beforeEach(async () => { + const fixture = await loadFixture(ethVaultFixture) + vault = await fixture.createEthVault(admin, { + capacity: parseEther('1000'), + feePercent: 1000, + metadataIpfsHash: 'bafkreidivzimqfqtoqxkrpge6bjyhlvxqs3rhe73owtmdulaxr5do5in7u', + }) + erc20Vault = await fixture.createEthErc20Vault(admin, { + capacity: parseEther('1000'), + feePercent: 1000, + metadataIpfsHash: 'bafkreidivzimqfqtoqxkrpge6bjyhlvxqs3rhe73owtmdulaxr5do5in7u', + name: 'SW ETH Vault', + symbol: 'SW-ETH-1', + }) + keeper = fixture.keeper + await collateralizeEthVault(vault, keeper, fixture.validatorsRegistry, admin) + await collateralizeEthVault(erc20Vault, keeper, fixture.validatorsRegistry, admin) + + const rewardSplitterFactory = await createRewardSplitterFactory() + const rewardSplitterAddress = await rewardSplitterFactory + .connect(admin) + .callStatic.createRewardSplitter(vault.address) + await rewardSplitterFactory.connect(admin).createRewardSplitter(vault.address) + const factory = await ethers.getContractFactory('RewardSplitter') + rewardSplitter = factory.attach(rewardSplitterAddress) as RewardSplitter + await vault.connect(admin).setFeeRecipient(rewardSplitter.address) + }) + + describe('increase shares', () => { + it('fails with zero shares', async () => { + await expect( + rewardSplitter.connect(admin).increaseShares(other.address, 0) + ).to.be.revertedWith('InvalidAmount') + }) + + it('fails with zero account', async () => { + await expect( + rewardSplitter.connect(admin).increaseShares(ZERO_ADDRESS, 1) + ).to.be.revertedWith('InvalidAccount') + }) + + it('fails by not owner', async () => { + await expect( + rewardSplitter.connect(other).increaseShares(other.address, 1) + ).to.be.revertedWith('Ownable: caller is not the owner') + }) + + it('fails when vault not harvested', async () => { + await rewardSplitter.connect(admin).increaseShares(other.address, 1) + await updateRewards( + keeper, + [{ vault: vault.address, reward: parseEther('1'), unlockedMevReward: 0 }], + 0 + ) + await updateRewards( + keeper, + [{ vault: vault.address, reward: parseEther('2'), unlockedMevReward: 0 }], + 0 + ) + await expect( + rewardSplitter.connect(admin).increaseShares(other.address, 1) + ).to.be.revertedWith('NotHarvested') + }) + + it('increasing shares does not affect others rewards', async () => { + await rewardSplitter.connect(admin).increaseShares(other.address, 100) + await vault.deposit(other.address, ZERO_ADDRESS, { + value: parseEther('10').sub(SECURITY_DEPOSIT), + }) + const totalReward = parseEther('1') + const fee = parseEther('0.1') + const tree = await updateRewards( + keeper, + [{ vault: vault.address, reward: totalReward, unlockedMevReward: 0 }], + 0 + ) + await vault.updateState({ + rewardsRoot: tree.root, + reward: totalReward, + unlockedMevReward: 0, + proof: getRewardsRootProof(tree, { + vault: vault.address, + unlockedMevReward: 0, + reward: totalReward, + }), + }) + const feeShares = await vault.convertToShares(fee) + expect(await vault.feeRecipient()).to.eq(rewardSplitter.address) + expect(await vault.balanceOf(rewardSplitter.address)).to.eq(feeShares) + + await rewardSplitter.connect(admin).increaseShares(admin.address, 100) + expect(await rewardSplitter.rewardsOf(other.address)).to.eq(feeShares) + expect(await rewardSplitter.rewardsOf(admin.address)).to.eq(0) + }) + + it('owner can increase shares', async () => { + const shares = 100 + const receipt = await rewardSplitter.connect(admin).increaseShares(other.address, shares) + expect(await rewardSplitter.sharesOf(other.address)).to.eq(shares) + expect(await rewardSplitter.totalShares()).to.eq(shares) + await expect(receipt) + .to.emit(rewardSplitter, 'SharesIncreased') + .withArgs(other.address, shares) + await snapshotGasCost(receipt) + }) + }) + + describe('decrease shares', () => { + const shares = 100 + beforeEach(async () => { + await rewardSplitter.connect(admin).increaseShares(other.address, shares) + }) + + it('fails with zero shares', async () => { + await expect( + rewardSplitter.connect(admin).decreaseShares(other.address, 0) + ).to.be.revertedWith('InvalidAmount') + }) + + it('fails with zero account', async () => { + await expect( + rewardSplitter.connect(admin).decreaseShares(ZERO_ADDRESS, 1) + ).to.be.revertedWith('InvalidAccount') + }) + + it('fails by not owner', async () => { + await expect( + rewardSplitter.connect(other).decreaseShares(other.address, 1) + ).to.be.revertedWith('Ownable: caller is not the owner') + }) + + it('fails with amount larger than balance', async () => { + await expect( + rewardSplitter.connect(admin).decreaseShares(other.address, shares + 1) + ).to.be.revertedWith(PANIC_CODES.ARITHMETIC_UNDER_OR_OVERFLOW) + }) + + it('fails when vault not harvested', async () => { + await updateRewards( + keeper, + [{ vault: vault.address, reward: parseEther('1'), unlockedMevReward: 0 }], + 0 + ) + await updateRewards( + keeper, + [{ vault: vault.address, reward: parseEther('2'), unlockedMevReward: 0 }], + 0 + ) + await expect( + rewardSplitter.connect(admin).decreaseShares(other.address, 1) + ).to.be.revertedWith('NotHarvested') + }) + + it('decreasing shares does not affect rewards', async () => { + await rewardSplitter.connect(admin).increaseShares(admin.address, shares) + await vault.deposit(other.address, ZERO_ADDRESS, { + value: parseEther('10').sub(SECURITY_DEPOSIT), + }) + const totalReward = parseEther('1') + const fee = parseEther('0.1') + const tree = await updateRewards( + keeper, + [{ vault: vault.address, reward: totalReward, unlockedMevReward: 0 }], + 0 + ) + await vault.updateState({ + rewardsRoot: tree.root, + reward: totalReward, + unlockedMevReward: 0, + proof: getRewardsRootProof(tree, { + vault: vault.address, + unlockedMevReward: 0, + reward: totalReward, + }), + }) + const feeShares = await vault.convertToShares(fee) + expect(await vault.feeRecipient()).to.eq(rewardSplitter.address) + expect(await vault.balanceOf(rewardSplitter.address)).to.eq(feeShares) + + await rewardSplitter.connect(admin).decreaseShares(admin.address, 1) + expect(await rewardSplitter.rewardsOf(other.address)).to.eq(feeShares.div(2)) + expect(await rewardSplitter.rewardsOf(admin.address)).to.eq(feeShares.div(2)) + }) + + it('owner can decrease shares', async () => { + const receipt = await rewardSplitter.connect(admin).decreaseShares(other.address, 1) + const newShares = shares - 1 + + expect(await rewardSplitter.sharesOf(other.address)).to.eq(newShares) + expect(await rewardSplitter.totalShares()).to.eq(newShares) + await expect(receipt).to.emit(rewardSplitter, 'SharesDecreased').withArgs(other.address, 1) + await snapshotGasCost(receipt) + }) + }) + + describe('sync rewards', () => { + const shares = 100 + beforeEach(async () => { + await rewardSplitter.connect(admin).increaseShares(other.address, shares) + }) + + it('does not sync rewards when up to date', async () => { + const totalReward = parseEther('1') + const tree = await updateRewards( + keeper, + [{ vault: vault.address, reward: totalReward, unlockedMevReward: 0 }], + 0 + ) + await vault.updateState({ + rewardsRoot: tree.root, + reward: totalReward, + unlockedMevReward: 0, + proof: getRewardsRootProof(tree, { + vault: vault.address, + unlockedMevReward: 0, + reward: totalReward, + }), + }) + expect(await rewardSplitter.canSyncRewards()).to.eq(true) + await rewardSplitter.syncRewards() + expect(await rewardSplitter.canSyncRewards()).to.eq(false) + await expect(rewardSplitter.syncRewards()).to.not.emit(rewardSplitter, 'RewardsSynced') + }) + + it('does not sync rewards with zero total shares', async () => { + await rewardSplitter.connect(admin).decreaseShares(other.address, shares) + const totalReward = parseEther('1') + const tree = await updateRewards( + keeper, + [{ vault: vault.address, reward: totalReward, unlockedMevReward: 0 }], + 0 + ) + await vault.updateState({ + rewardsRoot: tree.root, + reward: totalReward, + unlockedMevReward: 0, + proof: getRewardsRootProof(tree, { + vault: vault.address, + unlockedMevReward: 0, + reward: totalReward, + }), + }) + expect(await rewardSplitter.canSyncRewards()).to.eq(false) + await expect(rewardSplitter.syncRewards()).to.not.emit(rewardSplitter, 'RewardsSynced') + }) + + it('anyone can sync rewards', async () => { + await vault.deposit(other.address, ZERO_ADDRESS, { + value: parseEther('10').sub(SECURITY_DEPOSIT), + }) + const totalReward = parseEther('1') + const fee = parseEther('0.1') + const tree = await updateRewards( + keeper, + [{ vault: vault.address, reward: totalReward, unlockedMevReward: 0 }], + 0 + ) + await vault.updateState({ + rewardsRoot: tree.root, + reward: totalReward, + unlockedMevReward: 0, + proof: getRewardsRootProof(tree, { + vault: vault.address, + unlockedMevReward: 0, + reward: totalReward, + }), + }) + const feeShares = await vault.convertToShares(fee) + expect(await rewardSplitter.canSyncRewards()).to.eq(true) + const receipt = await rewardSplitter.syncRewards() + await expect(receipt) + .to.emit(rewardSplitter, 'RewardsSynced') + .withArgs(feeShares, feeShares.mul(parseEther('1')).div(shares)) + await snapshotGasCost(receipt) + }) + }) + + describe('withdraw rewards', () => { + const shares = 100 + let rewards: BigNumber + + beforeEach(async () => { + await rewardSplitter.connect(admin).increaseShares(other.address, shares) + const totalReward = parseEther('1') + const tree = await updateRewards( + keeper, + [{ vault: vault.address, reward: totalReward, unlockedMevReward: 0 }], + 0 + ) + await vault.updateState({ + rewardsRoot: tree.root, + reward: totalReward, + unlockedMevReward: 0, + proof: getRewardsRootProof(tree, { + vault: vault.address, + unlockedMevReward: 0, + reward: totalReward, + }), + }) + await rewardSplitter.syncRewards() + rewards = await rewardSplitter.rewardsOf(other.address) + }) + + it('fails to claim vault tokens for not ERC-20 vault', async () => { + await expect(rewardSplitter.connect(other).claimVaultTokens(rewards, other.address)).to.be + .reverted + }) + + it('can claim vault tokens for ERC-20 vault', async () => { + // create rewards splitter + const rewardSplitterFactory = await createRewardSplitterFactory() + const rewardSplitterAddress = await rewardSplitterFactory + .connect(admin) + .callStatic.createRewardSplitter(erc20Vault.address) + await rewardSplitterFactory.connect(admin).createRewardSplitter(erc20Vault.address) + + // collateralize rewards splitter + const factory = await ethers.getContractFactory('RewardSplitter') + const rewardSplitter = factory.attach(rewardSplitterAddress) as RewardSplitter + await erc20Vault.connect(admin).setFeeRecipient(rewardSplitter.address) + await rewardSplitter.connect(admin).increaseShares(other.address, shares) + const totalReward = parseEther('1') + const tree = await updateRewards( + keeper, + [{ vault: erc20Vault.address, reward: totalReward, unlockedMevReward: 0 }], + 0 + ) + await erc20Vault.updateState({ + rewardsRoot: tree.root, + reward: totalReward, + unlockedMevReward: 0, + proof: getRewardsRootProof(tree, { + vault: erc20Vault.address, + unlockedMevReward: 0, + reward: totalReward, + }), + }) + await rewardSplitter.syncRewards() + const rewards = await rewardSplitter.rewardsOf(other.address) + + const receipt = await rewardSplitter.connect(other).claimVaultTokens(rewards, other.address) + await expect(receipt) + .to.emit(rewardSplitter, 'RewardsWithdrawn') + .withArgs(other.address, rewards) + expect(await rewardSplitter.rewardsOf(other.address)).to.eq(0) + + // second claim should fail + await expect( + rewardSplitter.connect(other).claimVaultTokens(rewards, other.address) + ).to.be.revertedWith(PANIC_CODES.ARITHMETIC_UNDER_OR_OVERFLOW) + await snapshotGasCost(receipt) + }) + + it('can redeem, enter exit queue with multicall', async () => { + await vault.deposit(other.address, ZERO_ADDRESS, { + value: parseEther('10').sub(SECURITY_DEPOSIT), + }) + let totalReward = parseEther('2') + await updateRewards( + keeper, + [{ vault: vault.address, reward: totalReward, unlockedMevReward: 0 }], + 0 + ) + totalReward = parseEther('3') + const tree = await updateRewards( + keeper, + [{ vault: vault.address, reward: totalReward, unlockedMevReward: 0 }], + 0 + ) + + const calls: string[] = [ + rewardSplitter.interface.encodeFunctionData('updateVaultState', [ + { + rewardsRoot: tree.root, + reward: totalReward, + unlockedMevReward: 0, + proof: getRewardsRootProof(tree, { + vault: vault.address, + unlockedMevReward: 0, + reward: totalReward, + }), + }, + ]), + rewardSplitter.interface.encodeFunctionData('syncRewards'), + ] + const result = await rewardSplitter.callStatic.multicall([ + ...calls, + rewardSplitter.interface.encodeFunctionData('rewardsOf', [other.address]), + ]) + const rewards = rewardSplitter.interface.decodeFunctionResult('rewardsOf', result[2])[0] + + const receipt = await rewardSplitter + .connect(other) + .multicall([ + ...calls, + rewardSplitter.interface.encodeFunctionData('redeem', [rewards.div(2), other.address]), + rewardSplitter.interface.encodeFunctionData('enterExitQueue', [ + rewards.div(2), + other.address, + ]), + ]) + expect(await rewardSplitter.rewardsOf(other.address)).to.eq(1) // rounding error + await snapshotGasCost(receipt) + }) + }) +}) diff --git a/test/RewardSplitterFactory.spec.ts b/test/RewardSplitterFactory.spec.ts new file mode 100644 index 00000000..2327d838 --- /dev/null +++ b/test/RewardSplitterFactory.spec.ts @@ -0,0 +1,63 @@ +import { ethers, waffle } from 'hardhat' +import { Wallet } from 'ethers' +import { parseEther } from 'ethers/lib/utils' +import { EthVault, RewardSplitter, RewardSplitterFactory } from '../typechain-types' +import snapshotGasCost from './shared/snapshotGasCost' +import { createRewardSplitterFactory, ethVaultFixture } from './shared/fixtures' +import { expect } from './shared/expect' + +const createFixtureLoader = waffle.createFixtureLoader + +describe('RewardSplitterFactory', () => { + let admin: Wallet, owner: Wallet + let vault: EthVault, rewardSplitterFactory: RewardSplitterFactory + + let loadFixture: ReturnType + + before('create fixture loader', async () => { + ;[owner, admin] = await (ethers as any).getSigners() + loadFixture = createFixtureLoader([owner]) + }) + + beforeEach(async () => { + const fixture = await loadFixture(ethVaultFixture) + vault = await fixture.createEthVault(admin, { + capacity: parseEther('1000'), + feePercent: 1000, + metadataIpfsHash: 'bafkreidivzimqfqtoqxkrpge6bjyhlvxqs3rhe73owtmdulaxr5do5in7u', + }) + rewardSplitterFactory = await createRewardSplitterFactory() + }) + + it('splitter deployment gas', async () => { + const receipt = await rewardSplitterFactory.connect(admin).createRewardSplitter(vault.address) + await snapshotGasCost(receipt) + }) + + it('factory deploys correctly', async () => { + let factory = await ethers.getContractFactory('RewardSplitter') + const rewardSplitterImpl = await factory.deploy() + + factory = await ethers.getContractFactory('RewardSplitterFactory') + const rewardsFactory = (await factory.deploy( + rewardSplitterImpl.address + )) as RewardSplitterFactory + expect(await rewardsFactory.implementation()).to.eq(rewardSplitterImpl.address) + }) + + it('splitter deploys correctly', async () => { + const rewardSplitterAddress = await rewardSplitterFactory + .connect(admin) + .callStatic.createRewardSplitter(vault.address) + const receipt = await rewardSplitterFactory.connect(admin).createRewardSplitter(vault.address) + await expect(receipt) + .to.emit(rewardSplitterFactory, 'RewardSplitterCreated') + .withArgs(admin.address, vault.address, rewardSplitterAddress) + + const factory = await ethers.getContractFactory('RewardSplitter') + const rewardSplitter = (await factory.attach(rewardSplitterAddress)) as RewardSplitter + expect(await rewardSplitter.vault()).to.eq(vault.address) + expect(await rewardSplitter.owner()).to.eq(admin.address) + expect(await rewardSplitter.totalShares()).to.eq(0) + }) +}) diff --git a/test/__snapshots__/CumulativeMerkleDrop.spec.ts.snap b/test/__snapshots__/CumulativeMerkleDrop.spec.ts.snap new file mode 100644 index 00000000..d5e1497d --- /dev/null +++ b/test/__snapshots__/CumulativeMerkleDrop.spec.ts.snap @@ -0,0 +1,15 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CumulativeMerkleDrop claim works with valid proof 1`] = ` +Object { + "calldataByteLength": 228, + "gasUsed": 83962, +} +`; + +exports[`CumulativeMerkleDrop set merkle root works for owner 1`] = ` +Object { + "calldataByteLength": 164, + "gasUsed": 49768, +} +`; diff --git a/test/__snapshots__/EthGenesisVault.spec.ts.snap b/test/__snapshots__/EthGenesisVault.spec.ts.snap index 50ee327a..29dfe7a8 100644 --- a/test/__snapshots__/EthGenesisVault.spec.ts.snap +++ b/test/__snapshots__/EthGenesisVault.spec.ts.snap @@ -24,14 +24,14 @@ Object { exports[`EthGenesisVault pulls assets on multiple validators registration 1`] = ` Object { "calldataByteLength": 3684, - "gasUsed": 738387, + "gasUsed": 738611, } `; exports[`EthGenesisVault pulls assets on single validator registration 1`] = ` Object { "calldataByteLength": 1412, - "gasUsed": 374236, + "gasUsed": 374228, } `; @@ -45,7 +45,7 @@ Object { exports[`EthGenesisVault update state splits penalty between rewardEthToken and vault 1`] = ` Object { "calldataByteLength": 196, - "gasUsed": 135771, + "gasUsed": 135783, } `; diff --git a/test/__snapshots__/EthVault.register.spec.ts.snap b/test/__snapshots__/EthVault.register.spec.ts.snap index 6b3dc762..93e907cb 100644 --- a/test/__snapshots__/EthVault.register.spec.ts.snap +++ b/test/__snapshots__/EthVault.register.spec.ts.snap @@ -3,13 +3,13 @@ exports[`EthVault - register multiple validators succeeds 1`] = ` Object { "calldataByteLength": 3620, - "gasUsed": 722403, + "gasUsed": 722339, } `; exports[`EthVault - register single validator succeeds 1`] = ` Object { - "calldataByteLength": 1444, - "gasUsed": 360135, + "calldataByteLength": 1412, + "gasUsed": 359242, } `; diff --git a/test/__snapshots__/KeeperRewards.spec.ts.snap b/test/__snapshots__/KeeperRewards.spec.ts.snap index cea7bd8d..93c20d48 100644 --- a/test/__snapshots__/KeeperRewards.spec.ts.snap +++ b/test/__snapshots__/KeeperRewards.spec.ts.snap @@ -3,21 +3,21 @@ exports[`KeeperRewards harvest (own escrow) succeeds for latest rewards root 1`] = ` Object { "calldataByteLength": 324, - "gasUsed": 111616, + "gasUsed": 111619, } `; exports[`KeeperRewards harvest (own escrow) succeeds for latest rewards root 2`] = ` Object { "calldataByteLength": 324, - "gasUsed": 55273, + "gasUsed": 55276, } `; exports[`KeeperRewards harvest (own escrow) succeeds for previous rewards root 1`] = ` Object { "calldataByteLength": 324, - "gasUsed": 113785, + "gasUsed": 113788, } `; @@ -31,28 +31,28 @@ Object { exports[`KeeperRewards harvest (own escrow) succeeds for previous rewards root 3`] = ` Object { "calldataByteLength": 324, - "gasUsed": 57442, + "gasUsed": 57445, } `; exports[`KeeperRewards harvest (shared escrow) succeeds for latest rewards root 1`] = ` Object { "calldataByteLength": 324, - "gasUsed": 145049, + "gasUsed": 145019, } `; exports[`KeeperRewards harvest (shared escrow) succeeds for latest rewards root 2`] = ` Object { "calldataByteLength": 324, - "gasUsed": 52153, + "gasUsed": 52123, } `; exports[`KeeperRewards harvest (shared escrow) succeeds for previous rewards root 1`] = ` Object { "calldataByteLength": 324, - "gasUsed": 147218, + "gasUsed": 147188, } `; @@ -66,7 +66,7 @@ Object { exports[`KeeperRewards harvest (shared escrow) succeeds for previous rewards root 3`] = ` Object { "calldataByteLength": 324, - "gasUsed": 54322, + "gasUsed": 54292, } `; @@ -80,20 +80,20 @@ Object { exports[`KeeperRewards update rewards succeeds 1`] = ` Object { "calldataByteLength": 772, - "gasUsed": 140757, + "gasUsed": 140829, } `; exports[`KeeperRewards update rewards succeeds 2`] = ` Object { "calldataByteLength": 772, - "gasUsed": 123717, + "gasUsed": 123705, } `; exports[`KeeperRewards update rewards succeeds with all signatures 1`] = ` Object { "calldataByteLength": 1156, - "gasUsed": 146949, + "gasUsed": 147033, } `; diff --git a/test/__snapshots__/KeeperValidators.spec.ts.snap b/test/__snapshots__/KeeperValidators.spec.ts.snap index 25ed13f2..5f9674f9 100644 --- a/test/__snapshots__/KeeperValidators.spec.ts.snap +++ b/test/__snapshots__/KeeperValidators.spec.ts.snap @@ -3,28 +3,28 @@ exports[`KeeperValidators register multiple validators succeeds 1`] = ` Object { "calldataByteLength": 3684, - "gasUsed": 723406, + "gasUsed": 723482, } `; exports[`KeeperValidators register multiple validators succeeds 2`] = ` Object { "calldataByteLength": 3684, - "gasUsed": 631860, + "gasUsed": 631918, } `; exports[`KeeperValidators register single validator succeeds 1`] = ` Object { - "calldataByteLength": 1476, - "gasUsed": 360372, + "calldataByteLength": 1508, + "gasUsed": 361176, } `; exports[`KeeperValidators register single validator succeeds 2`] = ` Object { "calldataByteLength": 1508, - "gasUsed": 307653, + "gasUsed": 307701, } `; @@ -34,10 +34,3 @@ Object { "gasUsed": 32144, } `; - -exports[`KeeperValidators update exit signatures succeeds 1`] = ` -Object { - "calldataByteLength": 1060, - "gasUsed": 139299, -} -`; diff --git a/test/__snapshots__/RewardSplitter.spec.ts.snap b/test/__snapshots__/RewardSplitter.spec.ts.snap new file mode 100644 index 00000000..fbed113c --- /dev/null +++ b/test/__snapshots__/RewardSplitter.spec.ts.snap @@ -0,0 +1,36 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`RewardSplitter decrease shares owner can decrease shares 1`] = ` +Object { + "calldataByteLength": 68, + "gasUsed": 66844, +} +`; + +exports[`RewardSplitter increase shares owner can increase shares 1`] = ` +Object { + "calldataByteLength": 68, + "gasUsed": 78756, +} +`; + +exports[`RewardSplitter sync rewards anyone can sync rewards 1`] = ` +Object { + "calldataByteLength": 4, + "gasUsed": 72880, +} +`; + +exports[`RewardSplitter withdraw rewards can claim vault tokens for ERC-20 vault 1`] = ` +Object { + "calldataByteLength": 68, + "gasUsed": 78421, +} +`; + +exports[`RewardSplitter withdraw rewards can redeem, enter exit queue with multicall 1`] = ` +Object { + "calldataByteLength": 772, + "gasUsed": 200245, +} +`; diff --git a/test/__snapshots__/RewardSplitterFactory.spec.ts.snap b/test/__snapshots__/RewardSplitterFactory.spec.ts.snap new file mode 100644 index 00000000..9254b2c9 --- /dev/null +++ b/test/__snapshots__/RewardSplitterFactory.spec.ts.snap @@ -0,0 +1,8 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`RewardSplitterFactory splitter deployment gas 1`] = ` +Object { + "calldataByteLength": 36, + "gasUsed": 137542, +} +`; diff --git a/test/shared/fixtures.ts b/test/shared/fixtures.ts index 020d00fd..f3e09a9e 100644 --- a/test/shared/fixtures.ts +++ b/test/shared/fixtures.ts @@ -17,7 +17,9 @@ import { PriceFeed, SharedMevEscrow, VaultsRegistry, + RewardSplitterFactory, PoolEscrowMock, + CumulativeMerkleDrop, } from '../../typechain-types' import { getValidatorsRegistryFactory } from './contracts' import { @@ -73,6 +75,14 @@ export const createPriceFeed = async function ( return (await factory.deploy(osToken.address, description)) as PriceFeed } +export const createRewardSplitterFactory = async function (): Promise { + let factory = await ethers.getContractFactory('RewardSplitter') + const rewardSplitterImpl = await factory.deploy() + + factory = await ethers.getContractFactory('RewardSplitterFactory') + return (await factory.deploy(rewardSplitterImpl.address)) as RewardSplitterFactory +} + export const createOsToken = async function ( keeperAddress: string, vaultsRegistry: VaultsRegistry, @@ -112,6 +122,14 @@ export const createOsTokenConfig = async function ( })) as OsTokenConfig } +export const createCumulativeMerkleDrop = async function ( + token: string, + owner: Wallet +): Promise { + const factory = await ethers.getContractFactory('CumulativeMerkleDrop') + return (await factory.deploy(owner.address, token)) as CumulativeMerkleDrop +} + export const createKeeper = async function ( initialOracles: string[], configIpfsHash: string,