diff --git a/contracts/staking/contracts/test/TestMixinStakingPoolRewards.sol b/contracts/staking/contracts/test/TestMixinStakingPoolRewards.sol new file mode 100644 index 0000000000..3bda161aac --- /dev/null +++ b/contracts/staking/contracts/test/TestMixinStakingPoolRewards.sol @@ -0,0 +1,281 @@ +/* + + Copyright 2019 ZeroEx Intl. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +*/ + +pragma solidity ^0.5.9; +pragma experimental ABIEncoderV2; + +import "../src/interfaces/IStructs.sol"; +import "./TestStakingNoWETH.sol"; + + +contract TestMixinStakingPoolRewards is + TestStakingNoWETH +{ + // solhint-disable no-simple-event-func-name + event UpdateCumulativeReward( + bytes32 poolId + ); + + event WithdrawAndSyncDelegatorRewards( + bytes32 poolId, + address delegator + ); + + struct UnfinalizedPoolReward { + uint256 reward; + uint256 membersStake; + } + + constructor() public { + _addAuthorizedAddress(msg.sender); + init(); + _removeAuthorizedAddressAtIndex(msg.sender, 0); + } + + // Rewards returned by `_computeMemberRewardOverInterval()`, indexed + // by `_getMemberRewardOverIntervalHash()`. + mapping (bytes32 => uint256) private _memberRewardsOverInterval; + // Rewards returned by `_getUnfinalizedPoolRewards()`, indexed by pool ID. + mapping (bytes32 => UnfinalizedPoolReward) private _unfinalizedPoolRewards; + + // Set pool `rewardsByPoolId`. + function setPoolRewards( + bytes32 poolId, + uint256 _rewardsByPoolId + ) + external + { + rewardsByPoolId[poolId] = _rewardsByPoolId; + } + + // Set `wethReservedForPoolRewards`. + function setWethReservedForPoolRewards( + uint256 _wethReservedForPoolRewards + ) + external + { + wethReservedForPoolRewards = _wethReservedForPoolRewards; + } + + // Set the rewards returned by a call to `_computeMemberRewardOverInterval()`. + function setMemberRewardsOverInterval( + bytes32 poolId, + uint256 memberStakeOverInterval, + uint256 beginEpoch, + uint256 endEpoch, + uint256 reward + ) + external + { + bytes32 rewardHash = _getMemberRewardOverIntervalHash( + poolId, + memberStakeOverInterval, + beginEpoch, + endEpoch + ); + _memberRewardsOverInterval[rewardHash] = reward; + } + + // Set the rewards returned by `_getUnfinalizedPoolRewards()`. + function setUnfinalizedPoolRewards( + bytes32 poolId, + uint256 reward, + uint256 membersStake + ) + external + { + _unfinalizedPoolRewards[poolId] = UnfinalizedPoolReward( + reward, + membersStake + ); + } + + // Set `currentEpoch`. + function setCurrentEpoch(uint256 epoch) external { + currentEpoch = epoch; + } + + // Expose `_syncPoolRewards()` for testing. + function syncPoolRewards( + bytes32 poolId, + uint256 reward, + uint256 membersStake + ) + external + returns (uint256 operatorReward, uint256 membersReward) + { + return _syncPoolRewards(poolId, reward, membersStake); + } + + // Expose `_withdrawAndSyncDelegatorRewards()` for testing. + function withdrawAndSyncDelegatorRewards( + bytes32 poolId, + address member + ) + external + { + return _withdrawAndSyncDelegatorRewards( + poolId, + member + ); + } + + // Expose `_computePoolRewardsSplit()` for testing. + function computePoolRewardsSplit( + uint32 operatorShare, + uint256 totalReward, + uint256 membersStake + ) + external + pure + returns (uint256 operatorReward, uint256 membersReward) + { + return _computePoolRewardsSplit( + operatorShare, + totalReward, + membersStake + ); + } + + // Access `_delegatedStakeToPoolByOwner` + function delegatedStakeToPoolByOwner(address member, bytes32 poolId) + external + view + returns (IStructs.StoredBalance memory balance) + { + return _delegatedStakeToPoolByOwner[member][poolId]; + } + + // Set `_delegatedStakeToPoolByOwner` + function setDelegatedStakeToPoolByOwner( + address member, + bytes32 poolId, + IStructs.StoredBalance memory balance + ) + public + { + _delegatedStakeToPoolByOwner[member][poolId] = balance; + } + + // Set `_poolById`. + function setPool( + bytes32 poolId, + IStructs.Pool memory pool + ) + public + { + _poolById[poolId] = pool; + } + + // Overridden to emit an event. + function _withdrawAndSyncDelegatorRewards( + bytes32 poolId, + address member + ) + internal + { + emit WithdrawAndSyncDelegatorRewards(poolId, member); + return MixinStakingPoolRewards._withdrawAndSyncDelegatorRewards( + poolId, + member + ); + } + + // Overridden to use `_memberRewardsOverInterval` + function _computeMemberRewardOverInterval( + bytes32 poolId, + uint256 memberStakeOverInterval, + uint256 beginEpoch, + uint256 endEpoch + ) + internal + view + returns (uint256 reward) + { + bytes32 rewardHash = _getMemberRewardOverIntervalHash( + poolId, + memberStakeOverInterval, + beginEpoch, + endEpoch + ); + return _memberRewardsOverInterval[rewardHash]; + } + + // Overridden to use `_unfinalizedPoolRewards` + function _getUnfinalizedPoolRewards( + bytes32 poolId + ) + internal + view + returns (uint256 reward, uint256 membersStake) + { + (reward, membersStake) = ( + _unfinalizedPoolRewards[poolId].reward, + _unfinalizedPoolRewards[poolId].membersStake + ); + } + + // Overridden to just increase `currentEpoch`. + function _loadCurrentBalance(IStructs.StoredBalance storage balancePtr) + internal + view + returns (IStructs.StoredBalance memory balance) + { + balance = balancePtr; + balance.currentEpoch += 1; + } + + // Overridden to revert if a pool has unfinalized rewards. + function _assertPoolFinalizedLastEpoch(bytes32 poolId) + internal + view + { + require( + _unfinalizedPoolRewards[poolId].membersStake == 0, + "POOL_NOT_FINALIZED" + ); + } + + // Overridden to just emit an event. + function _updateCumulativeReward(bytes32 poolId) + internal + { + emit UpdateCumulativeReward(poolId); + } + + // Compute a hash to index `_memberRewardsOverInterval` + function _getMemberRewardOverIntervalHash( + bytes32 poolId, + uint256 memberStakeOverInterval, + uint256 beginEpoch, + uint256 endEpoch + ) + private + pure + returns (bytes32 rewardHash) + { + return keccak256( + abi.encode( + poolId, + memberStakeOverInterval, + beginEpoch, + endEpoch + ) + ); + } +} diff --git a/contracts/staking/package.json b/contracts/staking/package.json index b15f0786a0..dc92524bfe 100644 --- a/contracts/staking/package.json +++ b/contracts/staking/package.json @@ -37,7 +37,7 @@ }, "config": { "abis:comment": "This list is auto-generated by contracts-gen. Don't edit manually.", - "abis": "./generated-artifacts/@(IStaking|IStakingEvents|IStakingProxy|IStorage|IStorageInit|IStructs|IZrxVault|LibCobbDouglas|LibFixedMath|LibFixedMathRichErrors|LibSafeDowncast|LibStakingRichErrors|MixinAbstract|MixinConstants|MixinCumulativeRewards|MixinDeploymentConstants|MixinExchangeFees|MixinExchangeManager|MixinFinalizer|MixinParams|MixinScheduler|MixinStake|MixinStakeBalances|MixinStakeStorage|MixinStakingPool|MixinStakingPoolRewards|MixinStorage|Staking|StakingProxy|TestAssertStorageParams|TestCobbDouglas|TestCumulativeRewardTracking|TestDelegatorRewards|TestExchangeManager|TestFinalizer|TestInitTarget|TestLibFixedMath|TestLibSafeDowncast|TestMixinParams|TestMixinStake|TestMixinStakeBalances|TestMixinStakeStorage|TestMixinStakingPool|TestProtocolFees|TestProxyDestination|TestStaking|TestStakingNoWETH|TestStakingProxy|TestStakingProxyUnit|TestStorageLayoutAndConstants|ZrxVault).json" + "abis": "./generated-artifacts/@(IStaking|IStakingEvents|IStakingProxy|IStorage|IStorageInit|IStructs|IZrxVault|LibCobbDouglas|LibFixedMath|LibFixedMathRichErrors|LibSafeDowncast|LibStakingRichErrors|MixinAbstract|MixinConstants|MixinCumulativeRewards|MixinDeploymentConstants|MixinExchangeFees|MixinExchangeManager|MixinFinalizer|MixinParams|MixinScheduler|MixinStake|MixinStakeBalances|MixinStakeStorage|MixinStakingPool|MixinStakingPoolRewards|MixinStorage|Staking|StakingProxy|TestAssertStorageParams|TestCobbDouglas|TestCumulativeRewardTracking|TestDelegatorRewards|TestExchangeManager|TestFinalizer|TestInitTarget|TestLibFixedMath|TestLibSafeDowncast|TestMixinParams|TestMixinStake|TestMixinStakeBalances|TestMixinStakeStorage|TestMixinStakingPool|TestMixinStakingPoolRewards|TestProtocolFees|TestProxyDestination|TestStaking|TestStakingNoWETH|TestStakingProxy|TestStakingProxyUnit|TestStorageLayoutAndConstants|ZrxVault).json" }, "repository": { "type": "git", @@ -50,6 +50,7 @@ "homepage": "https://github.com/0xProject/0x-monorepo/contracts/tokens/README.md", "devDependencies": { "@0x/abi-gen": "^4.3.0-beta.0", + "@0x/contracts-exchange-libs": "^3.1.0-beta.0", "@0x/contracts-gen": "^1.1.0-beta.0", "@0x/contracts-test-utils": "^3.2.0-beta.0", "@0x/dev-utils": "^2.4.0-beta.0", diff --git a/contracts/staking/src/artifacts.ts b/contracts/staking/src/artifacts.ts index db17417685..b9bbf14441 100644 --- a/contracts/staking/src/artifacts.ts +++ b/contracts/staking/src/artifacts.ts @@ -48,6 +48,7 @@ import * as TestMixinStake from '../generated-artifacts/TestMixinStake.json'; import * as TestMixinStakeBalances from '../generated-artifacts/TestMixinStakeBalances.json'; import * as TestMixinStakeStorage from '../generated-artifacts/TestMixinStakeStorage.json'; import * as TestMixinStakingPool from '../generated-artifacts/TestMixinStakingPool.json'; +import * as TestMixinStakingPoolRewards from '../generated-artifacts/TestMixinStakingPoolRewards.json'; import * as TestProtocolFees from '../generated-artifacts/TestProtocolFees.json'; import * as TestProxyDestination from '../generated-artifacts/TestProxyDestination.json'; import * as TestStaking from '../generated-artifacts/TestStaking.json'; @@ -101,6 +102,7 @@ export const artifacts = { TestMixinStakeBalances: TestMixinStakeBalances as ContractArtifact, TestMixinStakeStorage: TestMixinStakeStorage as ContractArtifact, TestMixinStakingPool: TestMixinStakingPool as ContractArtifact, + TestMixinStakingPoolRewards: TestMixinStakingPoolRewards as ContractArtifact, TestProtocolFees: TestProtocolFees as ContractArtifact, TestProxyDestination: TestProxyDestination as ContractArtifact, TestStaking: TestStaking as ContractArtifact, diff --git a/contracts/staking/src/wrappers.ts b/contracts/staking/src/wrappers.ts index df1b36a8ab..f532e731c3 100644 --- a/contracts/staking/src/wrappers.ts +++ b/contracts/staking/src/wrappers.ts @@ -46,6 +46,7 @@ export * from '../generated-wrappers/test_mixin_stake'; export * from '../generated-wrappers/test_mixin_stake_balances'; export * from '../generated-wrappers/test_mixin_stake_storage'; export * from '../generated-wrappers/test_mixin_staking_pool'; +export * from '../generated-wrappers/test_mixin_staking_pool_rewards'; export * from '../generated-wrappers/test_protocol_fees'; export * from '../generated-wrappers/test_proxy_destination'; export * from '../generated-wrappers/test_staking'; diff --git a/contracts/staking/test/unit_tests/mixin_staking_pool_rewards.ts b/contracts/staking/test/unit_tests/mixin_staking_pool_rewards.ts new file mode 100644 index 0000000000..88e344f3be --- /dev/null +++ b/contracts/staking/test/unit_tests/mixin_staking_pool_rewards.ts @@ -0,0 +1,496 @@ +import { ReferenceFunctions } from '@0x/contracts-exchange-libs'; +import { + blockchainTests, + constants, + expect, + getRandomInteger, + getRandomPortion, + hexRandom, + Numberish, + randomAddress, + TransactionHelper, + verifyEventsFromLogs, +} from '@0x/contracts-test-utils'; +import { BigNumber } from '@0x/utils'; +import { LogEntry, TransactionReceiptWithDecodedLogs } from 'ethereum-types'; + +import { StoredBalance } from '../utils/types'; + +import { artifacts, TestMixinStakingPoolRewardsContract, TestMixinStakingPoolRewardsEvents as Events } from '../../src'; + +blockchainTests.resets('MixinStakingPoolRewards unit tests', env => { + let testContract: TestMixinStakingPoolRewardsContract; + let txHelper: TransactionHelper; + + const POOL_ID = hexRandom(); + const OPERATOR = randomAddress(); + const OPERATOR_SHARE = getRandomInteger(1, constants.PPM_100_PERCENT); + let caller: string; + + before(async () => { + testContract = await TestMixinStakingPoolRewardsContract.deployFrom0xArtifactAsync( + artifacts.TestMixinStakingPoolRewards, + env.provider, + env.txDefaults, + artifacts, + ); + await testContract.setPool.awaitTransactionSuccessAsync(POOL_ID, { + operator: OPERATOR, + operatorShare: OPERATOR_SHARE, + }); + [caller] = await env.getAccountAddressesAsync(); + txHelper = new TransactionHelper(env.web3Wrapper, artifacts); + }); + + async function setUnfinalizedPoolRewardsAsync( + poolId: string, + reward: Numberish, + membersStake: Numberish, + ): Promise { + await testContract.setUnfinalizedPoolRewards.awaitTransactionSuccessAsync( + poolId, + new BigNumber(reward), + new BigNumber(membersStake), + ); + } + + // Set the delegated stake of a delegator in a pool. + // Omitted fields will be randomly generated. + async function setStakeAsync( + poolId: string, + delegator: string, + stake?: Partial, + ): Promise { + const _stake = { + currentEpoch: getRandomInteger(1, 4e9), + currentEpochBalance: getRandomInteger(1, 1e18), + nextEpochBalance: getRandomInteger(1, 1e18), + ...stake, + }; + await testContract.setDelegatedStakeToPoolByOwner.awaitTransactionSuccessAsync(delegator, poolId, { + currentEpoch: _stake.currentEpoch, + currentEpochBalance: _stake.currentEpochBalance, + nextEpochBalance: _stake.nextEpochBalance, + }); + return _stake; + } + + // Sets up state for a call to `_computeDelegatorReward()` and return the + // finalized rewards it will compute. + async function setComputeDelegatorRewardStateAsync( + poolId: string, + delegator: string, + finalizedReward?: Numberish, + ): Promise { + const stake = await testContract.delegatedStakeToPoolByOwner.callAsync(delegator, poolId); + // Split the rewards up across the two calls to `_computeMemberRewardOverInterval()` + const reward = finalizedReward === undefined ? getRandomInteger(1, 1e18) : new BigNumber(finalizedReward); + const oldRewards = getRandomPortion(reward); + await testContract.setMemberRewardsOverInterval.awaitTransactionSuccessAsync( + poolId, + stake.currentEpochBalance, + stake.currentEpoch, + stake.currentEpoch.plus(1), + oldRewards, + ); + const newRewards = reward.minus(oldRewards); + await testContract.setMemberRewardsOverInterval.awaitTransactionSuccessAsync( + poolId, + stake.nextEpochBalance, + stake.currentEpoch.plus(1), + await testContract.currentEpoch.callAsync(), + newRewards, + ); + return reward; + } + + function toOperatorPortion(operatorShare: Numberish, reward: Numberish): BigNumber { + return ReferenceFunctions.getPartialAmountCeil( + new BigNumber(operatorShare), + new BigNumber(constants.PPM_DENOMINATOR), + new BigNumber(reward), + ); + } + + function toMembersPortion(operatorShare: Numberish, reward: Numberish): BigNumber { + return new BigNumber(reward).minus(toOperatorPortion(operatorShare, reward)); + } + + describe('withdrawDelegatorRewards()', () => { + it('calls `_withdrawAndSyncDelegatorRewards()` with the sender as the member', async () => { + const { logs } = await testContract.withdrawDelegatorRewards.awaitTransactionSuccessAsync(POOL_ID); + verifyEventsFromLogs( + logs, + [{ poolId: POOL_ID, delegator: caller }], + Events.WithdrawAndSyncDelegatorRewards, + ); + }); + }); + + describe('_withdrawAndSyncDelegatorRewards()', () => { + const POOL_REWARD = getRandomInteger(1, 100e18); + const WETH_RESERVED_FOR_POOL_REWARDS = POOL_REWARD.plus(getRandomInteger(1, 100e18)); + const DELEGATOR = randomAddress(); + let stake: StoredBalance; + + before(async () => { + stake = await setStakeAsync(POOL_ID, DELEGATOR); + await testContract.setPoolRewards.awaitTransactionSuccessAsync(POOL_ID, POOL_REWARD); + await testContract.setWethReservedForPoolRewards.awaitTransactionSuccessAsync( + WETH_RESERVED_FOR_POOL_REWARDS, + ); + }); + + async function withdrawAndSyncDelegatorRewardsAsync(): Promise { + return testContract.withdrawAndSyncDelegatorRewards.awaitTransactionSuccessAsync(POOL_ID, DELEGATOR); + } + + it('reverts if the pool is not finalized', async () => { + await setUnfinalizedPoolRewardsAsync(POOL_ID, 0, 1); + const tx = withdrawAndSyncDelegatorRewardsAsync(); + return expect(tx).to.revertWith('POOL_NOT_FINALIZED'); + }); + it('calls `_updateCumulativeReward()`', async () => { + const { logs } = await withdrawAndSyncDelegatorRewardsAsync(); + verifyEventsFromLogs(logs, [{ poolId: POOL_ID }], Events.UpdateCumulativeReward); + }); + it('transfers finalized rewards to the sender', async () => { + const finalizedReward = getRandomPortion(POOL_REWARD); + await setComputeDelegatorRewardStateAsync(POOL_ID, DELEGATOR, finalizedReward); + const { logs } = await withdrawAndSyncDelegatorRewardsAsync(); + verifyEventsFromLogs( + logs, + [{ _from: testContract.address, _to: DELEGATOR, _value: finalizedReward }], + Events.Transfer, + ); + }); + it('reduces `rewardsByPoolId` for the pool', async () => { + const finalizedReward = getRandomPortion(POOL_REWARD); + await setComputeDelegatorRewardStateAsync(POOL_ID, DELEGATOR, finalizedReward); + await withdrawAndSyncDelegatorRewardsAsync(); + const poolReward = await testContract.rewardsByPoolId.callAsync(POOL_ID); + expect(poolReward).to.bignumber.eq(POOL_REWARD.minus(finalizedReward)); + }); + it('reduces `wethReservedForPoolRewards` for the pool', async () => { + const finalizedReward = getRandomPortion(POOL_REWARD); + await setComputeDelegatorRewardStateAsync(POOL_ID, DELEGATOR, finalizedReward); + await withdrawAndSyncDelegatorRewardsAsync(); + const wethReserved = await testContract.wethReservedForPoolRewards.callAsync(); + expect(wethReserved).to.bignumber.eq(WETH_RESERVED_FOR_POOL_REWARDS.minus(finalizedReward)); + }); + it('syncs `_delegatedStakeToPoolByOwner`', async () => { + await setComputeDelegatorRewardStateAsync(POOL_ID, DELEGATOR, getRandomPortion(POOL_REWARD)); + await withdrawAndSyncDelegatorRewardsAsync(); + const stakeAfter = await testContract.delegatedStakeToPoolByOwner.callAsync(DELEGATOR, POOL_ID); + // `_loadCurrentBalance` is overridden to just increment `currentEpoch`. + expect(stakeAfter).to.deep.eq({ + currentEpoch: stake.currentEpoch.plus(1), + currentEpochBalance: stake.currentEpochBalance, + nextEpochBalance: stake.nextEpochBalance, + }); + }); + it('does not transfer zero rewards', async () => { + await setComputeDelegatorRewardStateAsync(POOL_ID, DELEGATOR, 0); + const { logs } = await withdrawAndSyncDelegatorRewardsAsync(); + verifyEventsFromLogs(logs, [], Events.Transfer); + }); + it('no rewards if the delegated stake epoch == current epoch', async () => { + // Set some finalized rewards that should be ignored. + await setComputeDelegatorRewardStateAsync(POOL_ID, DELEGATOR, getRandomInteger(1, POOL_REWARD)); + await testContract.setCurrentEpoch.awaitTransactionSuccessAsync(stake.currentEpoch); + const { logs } = await withdrawAndSyncDelegatorRewardsAsync(); + // There will be no Transfer events if computed rewards are zero. + verifyEventsFromLogs(logs, [], Events.Transfer); + }); + }); + + describe('computeRewardBalanceOfOperator()', () => { + async function computeRewardBalanceOfOperatorAsync(): Promise { + return testContract.computeRewardBalanceOfOperator.callAsync(POOL_ID); + } + + it('returns only unfinalized rewards', async () => { + const unfinalizedReward = getRandomInteger(1, 1e18); + await setUnfinalizedPoolRewardsAsync(POOL_ID, unfinalizedReward, getRandomInteger(1, 1e18)); + // Set some unfinalized state for a call to `_computeDelegatorReward()`, + // which should not be called. + await setComputeDelegatorRewardStateAsync(POOL_ID, OPERATOR, getRandomInteger(1, 1e18)); + const reward = await computeRewardBalanceOfOperatorAsync(); + const expectedReward = toOperatorPortion(OPERATOR_SHARE, unfinalizedReward); + expect(reward).to.bignumber.eq(expectedReward); + }); + it('returns operator portion of unfinalized rewards', async () => { + const unfinalizedReward = getRandomInteger(1, 1e18); + await setUnfinalizedPoolRewardsAsync(POOL_ID, unfinalizedReward, getRandomInteger(1, 1e18)); + const reward = await computeRewardBalanceOfOperatorAsync(); + const expectedReward = toOperatorPortion(OPERATOR_SHARE, unfinalizedReward); + expect(reward).to.bignumber.eq(expectedReward); + }); + it('returns zero if no unfinalized rewards', async () => { + await setUnfinalizedPoolRewardsAsync(POOL_ID, 0, getRandomInteger(1, 1e18)); + const reward = await computeRewardBalanceOfOperatorAsync(); + expect(reward).to.bignumber.eq(0); + }); + it('returns all unfinalized reward if member stake is zero', async () => { + const unfinalizedReward = getRandomInteger(1, 1e18); + await setUnfinalizedPoolRewardsAsync(POOL_ID, unfinalizedReward, 0); + const reward = await computeRewardBalanceOfOperatorAsync(); + expect(reward).to.bignumber.eq(unfinalizedReward); + }); + it('returns no reward if operator share is zero', async () => { + await testContract.setPool.awaitTransactionSuccessAsync(POOL_ID, { + operator: OPERATOR, + operatorShare: constants.ZERO_AMOUNT, + }); + await setUnfinalizedPoolRewardsAsync(POOL_ID, getRandomInteger(1, 1e18), getRandomInteger(1, 1e18)); + const reward = await computeRewardBalanceOfOperatorAsync(); + expect(reward).to.bignumber.eq(0); + }); + it('returns all unfinalized reward if operator share is 100%', async () => { + await testContract.setPool.awaitTransactionSuccessAsync(POOL_ID, { + operator: OPERATOR, + operatorShare: constants.PPM_100_PERCENT, + }); + const unfinalizedReward = getRandomInteger(1, 1e18); + await setUnfinalizedPoolRewardsAsync(POOL_ID, unfinalizedReward, getRandomInteger(1, 1e18)); + const reward = await computeRewardBalanceOfOperatorAsync(); + expect(reward).to.bignumber.eq(unfinalizedReward); + }); + }); + + describe('computeRewardBalanceOfDelegator()', () => { + const DELEGATOR = randomAddress(); + let currentEpoch: BigNumber; + let stake: StoredBalance; + + before(async () => { + currentEpoch = await testContract.currentEpoch.callAsync(); + stake = await setStakeAsync(POOL_ID, DELEGATOR); + }); + + async function computeRewardBalanceOfDelegatorAsync(): Promise { + return testContract.computeRewardBalanceOfDelegator.callAsync(POOL_ID, DELEGATOR); + } + + function getDelegatorPortionOfUnfinalizedReward( + unfinalizedReward: Numberish, + unfinalizedMembersStake: Numberish, + ): BigNumber { + const unfinalizedStakeBalance = stake.currentEpoch.gte(currentEpoch) + ? stake.currentEpochBalance + : stake.nextEpochBalance; + return ReferenceFunctions.getPartialAmountFloor( + unfinalizedStakeBalance, + new BigNumber(unfinalizedMembersStake), + toMembersPortion(OPERATOR_SHARE, unfinalizedReward), + ); + } + + it('returns zero when no finalized or unfinalized rewards', async () => { + const reward = await computeRewardBalanceOfDelegatorAsync(); + expect(reward).to.bignumber.eq(0); + }); + it('returns only unfinalized rewards when no finalized rewards', async () => { + const unfinalizedReward = getRandomInteger(1, 1e18); + const unfinalizedMembersStake = getRandomInteger(1, 1e18); + await setUnfinalizedPoolRewardsAsync(POOL_ID, unfinalizedReward, unfinalizedMembersStake); + const expectedReward = getDelegatorPortionOfUnfinalizedReward(unfinalizedReward, unfinalizedMembersStake); + const reward = await computeRewardBalanceOfDelegatorAsync(); + expect(reward).to.bignumber.eq(expectedReward); + }); + it("returns zero when delegator's synced stake was zero in the last epoch and no finalized rewards", async () => { + await setStakeAsync(POOL_ID, DELEGATOR, { + ...stake, + currentEpoch: currentEpoch.minus(1), + currentEpochBalance: constants.ZERO_AMOUNT, + }); + await setUnfinalizedPoolRewardsAsync(POOL_ID, getRandomInteger(1, 1e18), getRandomInteger(1, 1e18)); + const reward = await computeRewardBalanceOfDelegatorAsync(); + expect(reward).to.bignumber.eq(0); + }); + it("returns zero when delegator's unsynced stake was zero in the last epoch and no finalized rewards", async () => { + const epoch = 2; + await setStakeAsync(POOL_ID, DELEGATOR, { + ...stake, + currentEpoch: new BigNumber(epoch - 2), + nextEpochBalance: constants.ZERO_AMOUNT, + }); + await testContract.setCurrentEpoch.awaitTransactionSuccessAsync(new BigNumber(epoch)); + await setUnfinalizedPoolRewardsAsync(POOL_ID, getRandomInteger(1, 1e18), getRandomInteger(1, 1e18)); + const reward = await computeRewardBalanceOfDelegatorAsync(); + expect(reward).to.bignumber.eq(0); + }); + it('returns only finalized rewards when no unfinalized rewards', async () => { + const finalizedReward = getRandomInteger(1, 1e18); + await setComputeDelegatorRewardStateAsync(POOL_ID, DELEGATOR, finalizedReward); + const reward = await computeRewardBalanceOfDelegatorAsync(); + expect(reward).to.bignumber.eq(finalizedReward); + }); + it('returns both unfinalized and finalized rewards', async () => { + const unfinalizedReward = getRandomInteger(1, 1e18); + const unfinalizedMembersStake = getRandomInteger(1, 1e18); + await setUnfinalizedPoolRewardsAsync(POOL_ID, unfinalizedReward, unfinalizedMembersStake); + const finalizedReward = getRandomInteger(1, 1e18); + await setComputeDelegatorRewardStateAsync(POOL_ID, DELEGATOR, finalizedReward); + const delegatorUnfinalizedReward = getDelegatorPortionOfUnfinalizedReward( + unfinalizedReward, + unfinalizedMembersStake, + ); + const expectedReward = delegatorUnfinalizedReward.plus(finalizedReward); + const reward = await computeRewardBalanceOfDelegatorAsync(); + expect(reward).to.bignumber.eq(expectedReward); + }); + }); + + describe('_syncPoolRewards()', async () => { + const POOL_REWARD = getRandomInteger(1, 100e18); + const WETH_RESERVED_FOR_POOL_REWARDS = POOL_REWARD.plus(getRandomInteger(1, 100e18)); + + before(async () => { + await testContract.setPoolRewards.awaitTransactionSuccessAsync(POOL_ID, POOL_REWARD); + await testContract.setWethReservedForPoolRewards.awaitTransactionSuccessAsync( + WETH_RESERVED_FOR_POOL_REWARDS, + ); + }); + + async function syncPoolRewardsAsync( + reward: Numberish, + membersStake: Numberish, + ): Promise<[[BigNumber, BigNumber], LogEntry[]]> { + const [result, receipt] = await txHelper.getResultAndReceiptAsync( + testContract.syncPoolRewards, + POOL_ID, + new BigNumber(reward), + new BigNumber(membersStake), + ); + return [result, receipt.logs]; + } + + it("transfers operator's portion of the reward to the operator", async () => { + const totalReward = getRandomInteger(1, 1e18); + const membersStake = getRandomInteger(1, 1e18); + const [, logs] = await syncPoolRewardsAsync(totalReward, membersStake); + const expectedOperatorReward = toOperatorPortion(OPERATOR_SHARE, totalReward); + verifyEventsFromLogs( + logs, + [{ _from: testContract.address, _to: OPERATOR, _value: expectedOperatorReward }], + Events.Transfer, + ); + }); + it("increases `rewardsByPoolId` with members' portion of rewards", async () => { + const totalReward = getRandomInteger(1, 1e18); + const membersStake = getRandomInteger(1, 1e18); + await syncPoolRewardsAsync(totalReward, membersStake); + const expectedMembersReward = toMembersPortion(OPERATOR_SHARE, totalReward); + const poolReward = await testContract.rewardsByPoolId.callAsync(POOL_ID); + expect(poolReward).to.bignumber.eq(POOL_REWARD.plus(expectedMembersReward)); + }); + it("increases `wethReservedForPoolRewards` with members' portion of rewards", async () => { + const totalReward = getRandomInteger(1, 1e18); + const membersStake = getRandomInteger(1, 1e18); + await syncPoolRewardsAsync(totalReward, membersStake); + const expectedMembersReward = toMembersPortion(OPERATOR_SHARE, totalReward); + const wethReserved = await testContract.wethReservedForPoolRewards.callAsync(); + expect(wethReserved).to.bignumber.eq(WETH_RESERVED_FOR_POOL_REWARDS.plus(expectedMembersReward)); + }); + it("returns operator and members' portion of the reward", async () => { + const totalReward = getRandomInteger(1, 1e18); + const membersStake = getRandomInteger(1, 1e18); + const [[operatorReward, membersReward]] = await syncPoolRewardsAsync(totalReward, membersStake); + const expectedOperatorReward = toOperatorPortion(OPERATOR_SHARE, totalReward); + const expectedMembersReward = toMembersPortion(OPERATOR_SHARE, totalReward); + expect(operatorReward).to.bignumber.eq(expectedOperatorReward); + expect(membersReward).to.bignumber.eq(expectedMembersReward); + }); + it("gives all rewards to operator if members' stake is zero", async () => { + const totalReward = getRandomInteger(1, 1e18); + const [[operatorReward, membersReward], logs] = await syncPoolRewardsAsync(totalReward, 0); + expect(operatorReward).to.bignumber.eq(totalReward); + expect(membersReward).to.bignumber.eq(0); + verifyEventsFromLogs( + logs, + [{ _from: testContract.address, _to: OPERATOR, _value: totalReward }], + Events.Transfer, + ); + }); + it("gives all rewards to members if operator's share is zero", async () => { + const totalReward = getRandomInteger(1, 1e18); + await testContract.setPool.awaitTransactionSuccessAsync(POOL_ID, { + operator: OPERATOR, + operatorShare: constants.ZERO_AMOUNT, + }); + const [[operatorReward, membersReward], logs] = await syncPoolRewardsAsync( + totalReward, + getRandomInteger(1, 1e18), + ); + expect(operatorReward).to.bignumber.eq(0); + expect(membersReward).to.bignumber.eq(totalReward); + // Should be no transfer to the operator. + verifyEventsFromLogs(logs, [], Events.Transfer); + }); + }); + + describe('_computePoolRewardsSplit', () => { + it("gives all rewards to operator if members' stake is zero", async () => { + const operatorShare = getRandomPortion(constants.PPM_100_PERCENT); + const totalReward = getRandomInteger(1, 1e18); + const membersStake = constants.ZERO_AMOUNT; + const [operatorReward, membersReward] = await testContract.computePoolRewardsSplit.callAsync( + operatorShare, + totalReward, + membersStake, + ); + expect(operatorReward).to.bignumber.eq(totalReward); + expect(membersReward).to.bignumber.eq(0); + }); + it("gives all rewards to operator if members' stake is zero and operator share is zero", async () => { + const operatorShare = constants.ZERO_AMOUNT; + const totalReward = getRandomInteger(1, 1e18); + const membersStake = constants.ZERO_AMOUNT; + const [operatorReward, membersReward] = await testContract.computePoolRewardsSplit.callAsync( + operatorShare, + totalReward, + membersStake, + ); + expect(operatorReward).to.bignumber.eq(totalReward); + expect(membersReward).to.bignumber.eq(0); + }); + it('gives all rewards to operator if operator share is 100%', async () => { + const operatorShare = constants.PPM_100_PERCENT; + const totalReward = getRandomInteger(1, 1e18); + const membersStake = getRandomInteger(1, 1e18); + const [operatorReward, membersReward] = await testContract.computePoolRewardsSplit.callAsync( + operatorShare, + totalReward, + membersStake, + ); + expect(operatorReward).to.bignumber.eq(totalReward); + expect(membersReward).to.bignumber.eq(0); + }); + it('gives all rewards to members if operator share is 0%', async () => { + const operatorShare = constants.ZERO_AMOUNT; + const totalReward = getRandomInteger(1, 1e18); + const membersStake = getRandomInteger(1, 1e18); + const [operatorReward, membersReward] = await testContract.computePoolRewardsSplit.callAsync( + operatorShare, + totalReward, + membersStake, + ); + expect(operatorReward).to.bignumber.eq(0); + expect(membersReward).to.bignumber.eq(totalReward); + }); + it('splits rewards between operator and members based on operator share', async () => { + const operatorShare = getRandomPortion(constants.PPM_100_PERCENT); + const totalReward = getRandomInteger(1, 1e18); + const membersStake = getRandomInteger(1, 1e18); + const [operatorReward, membersReward] = await testContract.computePoolRewardsSplit.callAsync( + operatorShare, + totalReward, + membersStake, + ); + expect(operatorReward).to.bignumber.eq(toOperatorPortion(operatorShare, totalReward)); + expect(membersReward).to.bignumber.eq(toMembersPortion(operatorShare, totalReward)); + }); + }); +}); +// tslint:disable: max-file-line-count diff --git a/contracts/staking/tsconfig.json b/contracts/staking/tsconfig.json index 3383d877ae..0905fc08d5 100644 --- a/contracts/staking/tsconfig.json +++ b/contracts/staking/tsconfig.json @@ -46,6 +46,7 @@ "generated-artifacts/TestMixinStakeBalances.json", "generated-artifacts/TestMixinStakeStorage.json", "generated-artifacts/TestMixinStakingPool.json", + "generated-artifacts/TestMixinStakingPoolRewards.json", "generated-artifacts/TestProtocolFees.json", "generated-artifacts/TestProxyDestination.json", "generated-artifacts/TestStaking.json",