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,