From 7ef3c1272211ef4114d4e539fdce3669466c90db Mon Sep 17 00:00:00 2001 From: Lawrence Forman Date: Mon, 16 Sep 2019 10:55:50 -0400 Subject: [PATCH] `@0x/contracts-staking`: Well, it almost worked. --- .../src/libs/LibStakingRichErrors.sol | 1 + .../staking_pools/MixinStakingPoolRewards.sol | 12 +- .../contracts/test/TestDelegatorRewards.sol | 168 +++++- .../staking/contracts/test/TestFinalizer.sol | 4 +- .../staking/test/actors/finalizer_actor.ts | 243 +++++--- contracts/staking/test/params.ts | 2 +- contracts/staking/test/rewards_test.ts | 36 +- .../delegator_reward_balance_test.ts | 560 +++++++++++++++++- .../staking/test/unit_tests/finalizer_test.ts | 6 +- contracts/staking/test/utils/types.ts | 4 +- .../utils/contracts/src/LibFractions.sol | 3 + 11 files changed, 912 insertions(+), 127 deletions(-) diff --git a/contracts/staking/contracts/src/libs/LibStakingRichErrors.sol b/contracts/staking/contracts/src/libs/LibStakingRichErrors.sol index fcb5972d18..4ff7f2a0ef 100644 --- a/contracts/staking/contracts/src/libs/LibStakingRichErrors.sol +++ b/contracts/staking/contracts/src/libs/LibStakingRichErrors.sol @@ -42,6 +42,7 @@ library LibStakingRichErrors { InvalidCobbDouglasAlpha, InvalidRewardDelegatedStakeWeight, InvalidMaximumMakersInPool, + InvalidMinimumPoolStake, InvalidWethProxyAddress, InvalidEthVaultAddress, InvalidRewardVaultAddress, diff --git a/contracts/staking/contracts/src/staking_pools/MixinStakingPoolRewards.sol b/contracts/staking/contracts/src/staking_pools/MixinStakingPoolRewards.sol index a81005a0af..0625018666 100644 --- a/contracts/staking/contracts/src/staking_pools/MixinStakingPoolRewards.sol +++ b/contracts/staking/contracts/src/staking_pools/MixinStakingPoolRewards.sol @@ -187,11 +187,15 @@ contract MixinStakingPoolRewards is amountOfDelegatedStake ); - // Normalize fraction components by dividing by the min token value - // (10^18) + // Normalize fraction components by dividing by the minimum denominator. + uint256 minDenominator = + mostRecentCumulativeRewards.denominator <= amountOfDelegatedStake ? + mostRecentCumulativeRewards.denominator : + amountOfDelegatedStake; + minDenominator = minDenominator == 0 ? 1 : minDenominator; (uint256 numeratorNormalized, uint256 denominatorNormalized) = ( - numerator.safeDiv(MIN_TOKEN_VALUE), - denominator.safeDiv(MIN_TOKEN_VALUE) + numerator.safeDiv(minDenominator), + denominator.safeDiv(minDenominator) ); // store cumulative rewards and set most recent diff --git a/contracts/staking/contracts/test/TestDelegatorRewards.sol b/contracts/staking/contracts/test/TestDelegatorRewards.sol index 5184b2a341..962a4effea 100644 --- a/contracts/staking/contracts/test/TestDelegatorRewards.sol +++ b/contracts/staking/contracts/test/TestDelegatorRewards.sol @@ -19,11 +19,173 @@ pragma solidity ^0.5.9; pragma experimental ABIEncoderV2; -import "../src/Staking.sol"; +import "../src/interfaces/IStructs.sol"; +import "./TestStaking.sol"; contract TestDelegatorRewards is - Staking + TestStaking { - // TODO + event Deposit( + bytes32 poolId, + address member, + uint256 balance + ); + + event FinalizePool( + bytes32 poolId, + uint256 reward, + uint256 stake + ); + + struct UnfinalizedMembersReward { + uint256 reward; + uint256 stake; + } + + constructor() public { + init(); + } + + mapping (uint256 => mapping (bytes32 => UnfinalizedMembersReward)) private + unfinalizedMembersRewardByPoolByEpoch; + + /// @dev Expose _finalizePool + function internalFinalizePool(bytes32 poolId) external { + _finalizePool(poolId); + } + + /// @dev Set unfinalized members reward for a pool in the current epoch. + function setUnfinalizedMembersRewards( + bytes32 poolId, + uint256 membersReward, + uint256 membersStake + ) + external + { + unfinalizedMembersRewardByPoolByEpoch[currentEpoch][poolId] = + UnfinalizedMembersReward({ + reward: membersReward, + stake: membersStake + }); + } + + /// @dev Advance the epoch. + function advanceEpoch() external { + currentEpoch += 1; + } + + /// @dev Create and delegate stake that is active in the current epoch. + /// Only used to test purportedly unreachable states. + /// Also withdraws pending rewards to the eth vault. + function delegateStakeNow( + address delegator, + bytes32 poolId, + uint256 stake + ) + external + { + _transferDelegatorsAccumulatedRewardsToEthVault(poolId, delegator); + _syncCumulativeRewardsNeededByDelegator(poolId, currentEpoch); + IStructs.StoredBalance storage _stake = + delegatedStakeToPoolByOwner[delegator][poolId]; + _stake.currentEpochBalance += uint96(stake); + _stake.nextEpochBalance += uint96(stake); + _stake.currentEpoch = uint64(currentEpoch); + } + + /// @dev Create and delegate stake that will occur in the next epoch + /// (normal behavior). + /// Also withdraws pending rewards to the eth vault. + function delegateStake( + address delegator, + bytes32 poolId, + uint256 stake + ) + external + { + _transferDelegatorsAccumulatedRewardsToEthVault(poolId, delegator); + _syncCumulativeRewardsNeededByDelegator(poolId, currentEpoch); + IStructs.StoredBalance storage _stake = + delegatedStakeToPoolByOwner[delegator][poolId]; + if (_stake.currentEpoch < currentEpoch) { + _stake.currentEpochBalance = _stake.nextEpochBalance; + } + _stake.nextEpochBalance += uint96(stake); + _stake.currentEpoch = uint64(currentEpoch); + } + + /// @dev Clear stake that will occur in the next epoch + /// (normal behavior). + /// Also withdraws pending rewards to the eth vault. + function undelegateStake( + address delegator, + bytes32 poolId, + uint256 stake + ) + external + { + _transferDelegatorsAccumulatedRewardsToEthVault(poolId, delegator); + _syncCumulativeRewardsNeededByDelegator(poolId, currentEpoch); + IStructs.StoredBalance storage _stake = + delegatedStakeToPoolByOwner[delegator][poolId]; + if (_stake.currentEpoch < currentEpoch) { + _stake.currentEpochBalance = _stake.nextEpochBalance; + } + _stake.nextEpochBalance -= uint96(stake); + _stake.currentEpoch = uint64(currentEpoch); + } + + /// @dev Expose `_recordDepositInRewardVaultFor`. + function recordRewardForDelegators( + bytes32 poolId, + uint256 reward, + uint256 amountOfDelegatedStake + ) + external + { + _recordRewardForDelegators(poolId, reward, amountOfDelegatedStake); + } + + /// @dev Overridden to just emit events. + function _transferMemberBalanceToEthVault( + bytes32 poolId, + address member, + uint256 balance + ) + internal + { + emit Deposit( + poolId, + member, + balance + ); + } + + /// @dev Overridden to realize unfinalizedMembersRewardByPoolByEpoch in + /// the current epoch and eit a event, + function _finalizePool(bytes32 poolId) + internal + returns (IStructs.PoolRewards memory rewards) + { + UnfinalizedMembersReward memory reward = + unfinalizedMembersRewardByPoolByEpoch[currentEpoch][poolId]; + delete unfinalizedMembersRewardByPoolByEpoch[currentEpoch][poolId]; + rewards.membersReward = reward.reward; + rewards.membersStake = reward.stake; + _recordRewardForDelegators(poolId, reward.reward, reward.stake); + emit FinalizePool(poolId, reward.reward, reward.stake); + } + + /// @dev Overridden to use unfinalizedMembersRewardByPoolByEpoch. + function _getUnfinalizedPoolRewards(bytes32 poolId) + internal + view + returns (IStructs.PoolRewards memory rewards) + { + UnfinalizedMembersReward storage reward = + unfinalizedMembersRewardByPoolByEpoch[currentEpoch][poolId]; + rewards.membersReward = reward.reward; + rewards.membersStake = reward.stake; + } } diff --git a/contracts/staking/contracts/test/TestFinalizer.sol b/contracts/staking/contracts/test/TestFinalizer.sol index 68b7d9b971..ebd530fdd3 100644 --- a/contracts/staking/contracts/test/TestFinalizer.sol +++ b/contracts/staking/contracts/test/TestFinalizer.sol @@ -21,11 +21,11 @@ pragma experimental ABIEncoderV2; import "../src/interfaces/IStructs.sol"; import "../src/libs/LibCobbDouglas.sol"; -import "../src/Staking.sol"; +import "./TestStaking.sol"; contract TestFinalizer is - Staking + TestStaking { event RecordRewardForDelegatorsCall( bytes32 poolId, diff --git a/contracts/staking/test/actors/finalizer_actor.ts b/contracts/staking/test/actors/finalizer_actor.ts index d424861915..6ae34f7b2d 100644 --- a/contracts/staking/test/actors/finalizer_actor.ts +++ b/contracts/staking/test/actors/finalizer_actor.ts @@ -1,14 +1,15 @@ -import { expect } from '@0x/contracts-test-utils'; +import { constants, expect } from '@0x/contracts-test-utils'; import { BigNumber } from '@0x/utils'; import * as _ from 'lodash'; import { StakingApiWrapper } from '../utils/api_wrapper'; import { - MemberBalancesByPoolId, - MembersByPoolId, - OperatorBalanceByPoolId, + DelegatorBalancesByPoolId, + DelegatorsByPoolId, OperatorByPoolId, OperatorShareByPoolId, + RewardByPoolId, + RewardVaultBalance, RewardVaultBalanceByPoolId, } from '../utils/types'; @@ -19,59 +20,67 @@ interface Reward { poolId: string; } +const { PPM_100_PERCENT } = constants; + +// tslint:disable: prefer-conditional-expression export class FinalizerActor extends BaseActor { private readonly _poolIds: string[]; private readonly _operatorByPoolId: OperatorByPoolId; - private readonly _membersByPoolId: MembersByPoolId; + private readonly _delegatorsByPoolId: DelegatorsByPoolId; constructor( owner: string, stakingApiWrapper: StakingApiWrapper, poolIds: string[], operatorByPoolId: OperatorByPoolId, - membersByPoolId: MembersByPoolId, + delegatorsByPoolId: DelegatorsByPoolId, ) { super(owner, stakingApiWrapper); this._poolIds = _.cloneDeep(poolIds); this._operatorByPoolId = _.cloneDeep(operatorByPoolId); - this._membersByPoolId = _.cloneDeep(membersByPoolId); + this._delegatorsByPoolId = _.cloneDeep(delegatorsByPoolId); } public async finalizeAsync(rewards: Reward[] = []): Promise { // cache initial info and balances - const operatorShareByPoolId = await this._getOperatorShareByPoolIdAsync(this._poolIds); - const rewardVaultBalanceByPoolId = await this._getRewardVaultBalanceByPoolIdAsync(this._poolIds); - const memberBalancesByPoolId = await this._getMemberBalancesByPoolIdAsync(this._membersByPoolId); - const operatorBalanceByPoolId = await this._getOperatorBalanceByPoolIdAsync(this._operatorByPoolId); + const operatorShareByPoolId = + await this._getOperatorShareByPoolIdAsync(this._poolIds); + const rewardVaultBalanceByPoolId = + await this._getRewardVaultBalanceByPoolIdAsync(this._poolIds); + const delegatorBalancesByPoolId = + await this._getDelegatorBalancesByPoolIdAsync(this._delegatorsByPoolId); + const delegatorStakesByPoolId = + await this._getDelegatorStakesByPoolIdAsync(this._delegatorsByPoolId); // compute expected changes - const [ - expectedOperatorBalanceByPoolId, - expectedRewardVaultBalanceByPoolId, - ] = await this._computeExpectedRewardVaultBalanceAsyncByPoolIdAsync( - rewards, - operatorBalanceByPoolId, - rewardVaultBalanceByPoolId, - operatorShareByPoolId, - ); - const memberRewardByPoolId = _.mapValues(_.keyBy(rewards, 'poolId'), r => { - return r.reward.minus(r.reward.times(operatorShareByPoolId[r.poolId]).dividedToIntegerBy(100)); - }); - const expectedMemberBalancesByPoolId = await this._computeExpectedMemberBalancesByPoolIdAsync( - this._membersByPoolId, - memberBalancesByPoolId, - memberRewardByPoolId, - ); + const expectedRewardVaultBalanceByPoolId = + await this._computeExpectedRewardVaultBalanceAsyncByPoolIdAsync( + rewards, + rewardVaultBalanceByPoolId, + operatorShareByPoolId, + ); + const totalRewardsByPoolId = + _.zipObject(_.map(rewards, 'poolId'), _.map(rewards, 'reward')); + const expectedDelegatorBalancesByPoolId = + await this._computeExpectedDelegatorBalancesByPoolIdAsync( + this._delegatorsByPoolId, + delegatorBalancesByPoolId, + delegatorStakesByPoolId, + operatorShareByPoolId, + totalRewardsByPoolId, + ); // finalize await this._stakingApiWrapper.utils.skipToNextEpochAndFinalizeAsync(); // assert reward vault changes - const finalRewardVaultBalanceByPoolId = await this._getRewardVaultBalanceByPoolIdAsync(this._poolIds); + const finalRewardVaultBalanceByPoolId = + await this._getRewardVaultBalanceByPoolIdAsync(this._poolIds); expect(finalRewardVaultBalanceByPoolId, 'final pool balances in reward vault').to.be.deep.equal( expectedRewardVaultBalanceByPoolId, ); - // assert member balances - const finalMemberBalancesByPoolId = await this._getMemberBalancesByPoolIdAsync(this._membersByPoolId); - expect(finalMemberBalancesByPoolId, 'final delegator balances in reward vault').to.be.deep.equal( - expectedMemberBalancesByPoolId, + // assert delegator balances + const finalDelegatorBalancesByPoolId = + await this._getDelegatorBalancesByPoolIdAsync(this._delegatorsByPoolId); + expect(finalDelegatorBalancesByPoolId, 'final delegator balances in reward vault').to.be.deep.equal( + expectedDelegatorBalancesByPoolId, ); // assert operator balances const finalOperatorBalanceByPoolId = await this._getOperatorBalanceByPoolIdAsync(this._operatorByPoolId); @@ -80,55 +89,100 @@ export class FinalizerActor extends BaseActor { ); } - private async _computeExpectedMemberBalancesByPoolIdAsync( - membersByPoolId: MembersByPoolId, - memberBalancesByPoolId: MemberBalancesByPoolId, - rewardByPoolId: { [key: string]: BigNumber }, - ): Promise { - const expectedMemberBalancesByPoolId = _.cloneDeep(memberBalancesByPoolId); - for (const poolId of Object.keys(membersByPoolId)) { - if (rewardByPoolId[poolId] === undefined) { + private async _computeExpectedDelegatorBalancesByPoolIdAsync( + delegatorsByPoolId: DelegatorsByPoolId, + delegatorBalancesByPoolId: DelegatorBalancesByPoolId, + delegatorStakesByPoolId: DelegatorBalancesByPoolId, + operatorShareByPoolId: OperatorShareByPoolId, + totalRewardByPoolId: RewardByPoolId, + ): Promise { + const expectedDelegatorBalancesByPoolId = _.cloneDeep(delegatorBalancesByPoolId); + for (const poolId of Object.keys(delegatorsByPoolId)) { + if (totalRewardByPoolId[poolId] === undefined) { continue; } - const totalStakeDelegatedToPool = (await this._stakingApiWrapper.stakingContract.getTotalStakeDelegatedToPool.callAsync( - poolId, - )).currentEpochBalance; - for (const member of membersByPoolId[poolId]) { - if (totalStakeDelegatedToPool.eq(0)) { - expectedMemberBalancesByPoolId[poolId][member] = new BigNumber(0); - } else { - const stakeDelegatedToPoolByMember = (await this._stakingApiWrapper.stakingContract.getStakeDelegatedToPoolByOwner.callAsync( - member, - poolId, - )).currentEpochBalance; - const rewardThisEpoch = rewardByPoolId[poolId] - .times(stakeDelegatedToPoolByMember) - .dividedToIntegerBy(totalStakeDelegatedToPool); - expectedMemberBalancesByPoolId[poolId][member] = - memberBalancesByPoolId[poolId][member] === undefined - ? rewardThisEpoch - : memberBalancesByPoolId[poolId][member].plus(rewardThisEpoch); + + const operator = this._operatorByPoolId[poolId]; + const [, membersStakeInPool] = + await this._getOperatorAndDelegatorsStakeInPoolAsync(poolId); + const operatorShare = operatorShareByPoolId[poolId].dividedBy(PPM_100_PERCENT); + const totalReward = totalRewardByPoolId[poolId]; + const operatorReward = membersStakeInPool.eq(0) ? + totalReward : + totalReward.times(operatorShare).integerValue(BigNumber.ROUND_DOWN); + const membersTotalReward = totalReward.minus(operatorReward); + + for (const delegator of delegatorsByPoolId[poolId]) { + let delegatorReward = new BigNumber(0); + if (delegator === operator) { + delegatorReward = operatorReward; + } else if (membersStakeInPool.gt(0)) { + const delegatorStake = delegatorStakesByPoolId[poolId][delegator]; + delegatorReward = delegatorStake + .times(membersTotalReward) + .dividedBy(membersStakeInPool) + .integerValue(BigNumber.ROUND_DOWN); } + const currentBalance = expectedDelegatorBalancesByPoolId[poolId][delegator] || 0; + expectedDelegatorBalancesByPoolId[poolId][delegator] = delegatorReward.plus(currentBalance); } } - return expectedMemberBalancesByPoolId; + return expectedDelegatorBalancesByPoolId; } - private async _getMemberBalancesByPoolIdAsync(membersByPoolId: MembersByPoolId): Promise { - const memberBalancesByPoolId: MemberBalancesByPoolId = {}; - for (const poolId of Object.keys(membersByPoolId)) { - const members = membersByPoolId[poolId]; - memberBalancesByPoolId[poolId] = {}; - for (const member of members) { - memberBalancesByPoolId[poolId][ - member - ] = await this._stakingApiWrapper.stakingContract.computeRewardBalanceOfDelegator.callAsync( - poolId, - member, - ); + private async _getDelegatorBalancesByPoolIdAsync( + delegatorsByPoolId: DelegatorsByPoolId, + ): Promise { + const computeRewardBalanceOfDelegator = + this._stakingApiWrapper.stakingContract.computeRewardBalanceOfDelegator; + const rewardVaultBalanceOfOperator = + this._stakingApiWrapper.rewardVaultContract.balanceOfOperator; + const delegatorBalancesByPoolId: DelegatorBalancesByPoolId = {}; + + for (const poolId of Object.keys(delegatorsByPoolId)) { + const operator = this._operatorByPoolId[poolId]; + const delegators = delegatorsByPoolId[poolId]; + delegatorBalancesByPoolId[poolId] = {}; + for (const delegator of delegators) { + let balance = + new BigNumber(delegatorBalancesByPoolId[poolId][delegator] || 0); + if (delegator === operator) { + balance = balance.plus( + await rewardVaultBalanceOfOperator.callAsync(poolId), + ); + } else { + balance = balance.plus( + await computeRewardBalanceOfDelegator.callAsync( + poolId, + delegator, + ), + ); + } + delegatorBalancesByPoolId[poolId][delegator] = balance; } } - return memberBalancesByPoolId; + return delegatorBalancesByPoolId; + } + + private async _getDelegatorStakesByPoolIdAsync( + delegatorsByPoolId: DelegatorsByPoolId, + ): Promise { + const getStakeDelegatedToPoolByOwner = + this._stakingApiWrapper.stakingContract.getStakeDelegatedToPoolByOwner; + const delegatorBalancesByPoolId: DelegatorBalancesByPoolId = {}; + for (const poolId of Object.keys(delegatorsByPoolId)) { + const delegators = delegatorsByPoolId[poolId]; + delegatorBalancesByPoolId[poolId] = {}; + for (const delegator of delegators) { + delegatorBalancesByPoolId[poolId][ + delegator + ] = (await getStakeDelegatedToPoolByOwner.callAsync( + delegator, + poolId, + )).currentEpochBalance; + } + } + return delegatorBalancesByPoolId; } private async _computeExpectedRewardVaultBalanceAsyncByPoolIdAsync( @@ -141,16 +195,13 @@ export class FinalizerActor extends BaseActor { const expectedRewardVaultBalanceByPoolId = _.cloneDeep(rewardVaultBalanceByPoolId); for (const reward of rewards) { const operatorShare = operatorShareByPoolId[reward.poolId]; - [ - expectedOperatorBalanceByPoolId[reward.poolId], - expectedRewardVaultBalanceByPoolId[reward.poolId], - ] = await this._computeExpectedRewardVaultBalanceAsync( - reward.poolId, - reward.reward, - expectedOperatorBalanceByPoolId[reward.poolId], - expectedRewardVaultBalanceByPoolId[reward.poolId], - operatorShare, - ); + expectedRewardVaultBalanceByPoolId[reward.poolId] = + await this._computeExpectedRewardVaultBalanceAsync( + reward.poolId, + reward.reward, + expectedRewardVaultBalanceByPoolId[reward.poolId], + operatorShare, + ); } return [expectedOperatorBalanceByPoolId, expectedRewardVaultBalanceByPoolId]; } @@ -161,13 +212,11 @@ export class FinalizerActor extends BaseActor { operatorBalance: BigNumber, rewardVaultBalance: BigNumber, operatorShare: BigNumber, - ): Promise<[BigNumber, BigNumber]> { - const totalStakeDelegatedToPool = (await this._stakingApiWrapper.stakingContract.getTotalStakeDelegatedToPool.callAsync( - poolId, - )).currentEpochBalance; - const operatorPortion = totalStakeDelegatedToPool.eq(0) + ): Promise { + const [, membersStakeInPool] = await this._getOperatorAndDelegatorsStakeInPoolAsync(poolId); + const operatorPortion = membersStakeInPool.eq(0) ? reward - : reward.times(operatorShare).dividedToIntegerBy(100); + : reward.times(operatorShare).dividedToIntegerBy(PPM_100_PERCENT); const membersPortion = reward.minus(operatorPortion); return [operatorBalance.plus(operatorPortion), rewardVaultBalance.plus(membersPortion)]; } @@ -184,6 +233,22 @@ export class FinalizerActor extends BaseActor { return operatorBalanceByPoolId; } + private async _getOperatorAndDelegatorsStakeInPoolAsync( + poolId: string, + ): Promise<[BigNumber, BigNumber]> { + const stakingContract = this._stakingApiWrapper.stakingContract; + const operator = await stakingContract.getPoolOperator.callAsync(poolId); + const totalStakeInPool = (await stakingContract.getTotalStakeDelegatedToPool.callAsync( + poolId, + )).currentEpochBalance; + const operatorStakeInPool = (await stakingContract.getStakeDelegatedToPoolByOwner.callAsync( + operator, + poolId, + )).currentEpochBalance; + const membersStakeInPool = totalStakeInPool.minus(operatorStakeInPool); + return [operatorStakeInPool, membersStakeInPool]; + } + private async _getOperatorShareByPoolIdAsync(poolIds: string[]): Promise { const operatorShareByPoolId: OperatorShareByPoolId = {}; for (const poolId of poolIds) { diff --git a/contracts/staking/test/params.ts b/contracts/staking/test/params.ts index 7b22c61e8f..9e76ab9d6a 100644 --- a/contracts/staking/test/params.ts +++ b/contracts/staking/test/params.ts @@ -6,7 +6,7 @@ import { artifacts, IStakingEventsParamsSetEventArgs, MixinParamsContract } from import { constants as stakingConstants } from './utils/constants'; import { StakingParams } from './utils/types'; -blockchainTests('Configurable Parameters', env => { +blockchainTests('Configurable Parameters unit tests', env => { let testContract: MixinParamsContract; let authorizedAddress: string; let notAuthorizedAddress: string; diff --git a/contracts/staking/test/rewards_test.ts b/contracts/staking/test/rewards_test.ts index 739db688a4..75f6fa8daf 100644 --- a/contracts/staking/test/rewards_test.ts +++ b/contracts/staking/test/rewards_test.ts @@ -10,7 +10,7 @@ import { PoolOperatorActor } from './actors/pool_operator_actor'; import { StakerActor } from './actors/staker_actor'; import { deployAndConfigureContractsAsync, StakingApiWrapper } from './utils/api_wrapper'; import { toBaseUnitAmount } from './utils/number_utils'; -import { MembersByPoolId, OperatorByPoolId, StakeInfo, StakeStatus } from './utils/types'; +import { DelegatorsByPoolId, OperatorByPoolId, StakeInfo, StakeStatus } from './utils/types'; // tslint:disable:no-unnecessary-type-assertion // tslint:disable:max-file-line-count @@ -67,14 +67,14 @@ blockchainTests.resets('Testing Rewards', env => { const operatorByPoolId: OperatorByPoolId = {}; operatorByPoolId[poolId] = poolOperator.getOwner(); // associate actors with pools for tracking in Finalizer - const membersByPoolId: MembersByPoolId = {}; - membersByPoolId[poolId] = [actors[0], actors[1]]; + const stakersByPoolId: DelegatorsByPoolId = {}; + stakersByPoolId[poolId] = actors.slice(0, 3); // create Finalizer actor - finalizer = new FinalizerActor(actors[3], stakingApiWrapper, [poolId], operatorByPoolId, membersByPoolId); + finalizer = new FinalizerActor(actors[3], stakingApiWrapper, [poolId], operatorByPoolId, stakersByPoolId); // Skip to next epoch so operator stake is realized. await stakingApiWrapper.utils.skipToNextEpochAndFinalizeAsync(); }); - describe('Reward Simulation', () => { + describe.skip('Reward Simulation', () => { interface EndBalances { // staker 1 stakerRewardVaultBalance_1?: BigNumber; @@ -399,12 +399,9 @@ blockchainTests.resets('Testing Rewards', env => { toBaseUnitAmount(0), toBaseUnitAmount(17), ]; - const totalRewardsAfterAddingMoreStake = new BigNumber( - _.sumBy(rewardsAfterAddingMoreStake, v => { - return v.toNumber(); - }), - ); + const totalRewardsAfterAddingMoreStake = BigNumber.sum(...rewardsAfterAddingMoreStake); const stakeAmounts = [toBaseUnitAmount(4), toBaseUnitAmount(6)]; + const totalStake = BigNumber.sum(...stakeAmounts); // first staker delegates (epoch 0) await stakers[0].stakeWithPoolAsync(poolId, stakeAmounts[0]); // skip epoch, so first staker can start earning rewards @@ -419,7 +416,16 @@ blockchainTests.resets('Testing Rewards', env => { } // sanity check final balances await validateEndBalances({ - stakerRewardVaultBalance_1: rewardBeforeAddingMoreStake.plus(totalRewardsAfterAddingMoreStake), + stakerRewardVaultBalance_1: rewardBeforeAddingMoreStake.plus( + totalRewardsAfterAddingMoreStake + .times(stakeAmounts[0]) + .dividedBy(totalStake) + .integerValue(BigNumber.ROUND_DOWN), + ), + stakerRewardVaultBalance_2: totalRewardsAfterAddingMoreStake + .times(stakeAmounts[1]) + .dividedBy(totalStake) + .integerValue(BigNumber.ROUND_DOWN), poolRewardVaultBalance: rewardBeforeAddingMoreStake.plus(totalRewardsAfterAddingMoreStake), membersRewardVaultBalance: rewardBeforeAddingMoreStake.plus(totalRewardsAfterAddingMoreStake), }); @@ -464,11 +470,7 @@ blockchainTests.resets('Testing Rewards', env => { toBaseUnitAmount(0), toBaseUnitAmount(17), ]; - const totalRewardsNotForDelegator = new BigNumber( - _.sumBy(rewardsNotForDelegator, v => { - return v.toNumber(); - }), - ); + const totalRewardsNotForDelegator = BigNumber.sum(...rewardsNotForDelegator); const stakeAmount = toBaseUnitAmount(4); await stakers[0].stakeWithPoolAsync(poolId, stakeAmount); // skip epoch, so first staker can start earning rewards @@ -492,7 +494,7 @@ blockchainTests.resets('Testing Rewards', env => { operatorEthVaultBalance: totalRewardsNotForDelegator, }); }); - it('Should collect fees correctly when leaving and returning to a pool', async () => { + it.only('Should collect fees correctly when leaving and returning to a pool', async () => { // first staker delegates (epoch 0) const rewardsForDelegator = [toBaseUnitAmount(10), toBaseUnitAmount(15)]; const rewardNotForDelegator = toBaseUnitAmount(7); diff --git a/contracts/staking/test/unit_tests/delegator_reward_balance_test.ts b/contracts/staking/test/unit_tests/delegator_reward_balance_test.ts index 42564bab3f..43956f8dde 100644 --- a/contracts/staking/test/unit_tests/delegator_reward_balance_test.ts +++ b/contracts/staking/test/unit_tests/delegator_reward_balance_test.ts @@ -1,8 +1,24 @@ -import { blockchainTests, expect, Numberish } from '@0x/contracts-test-utils'; +import { + blockchainTests, + constants, + expect, + filterLogsToArguments, + hexRandom, + Numberish, +} from '@0x/contracts-test-utils'; +import { BigNumber } from '@0x/utils'; +import { LogEntry } from 'ethereum-types'; -import { artifacts, TestDelegatorRewardsContract } from '../../src'; +import { + artifacts, + TestDelegatorRewardsContract, + TestDelegatorRewardsDepositEventArgs, + TestDelegatorRewardsEvents, +} from '../../src'; -blockchainTests('delegator rewards', env => { +import { assertRoughlyEquals, getRandomInteger, toBaseUnitAmount } from '../utils/number_utils'; + +blockchainTests.resets('delegator unit rewards', env => { let testContract: TestDelegatorRewardsContract; before(async () => { @@ -14,9 +30,543 @@ blockchainTests('delegator rewards', env => { ); }); + interface RewardPoolMembersOpts { + poolId: string; + reward: Numberish; + stake: Numberish; + } + + async function rewardPoolMembersAsync( + opts?: Partial, + ): Promise { + const _opts = { + poolId: hexRandom(), + reward: getRandomInteger(1, toBaseUnitAmount(100)), + stake: getRandomInteger(1, toBaseUnitAmount(10)), + ...opts, + }; + await testContract.recordRewardForDelegators.awaitTransactionSuccessAsync( + _opts.poolId, + new BigNumber(_opts.reward), + new BigNumber(_opts.stake), + ); + return _opts; + } + + interface SetUnfinalizedMembersRewardsOpts { + poolId: string; + reward: Numberish; + stake: Numberish; + } + + async function setUnfinalizedMembersRewardsAsync( + opts?: Partial, + ): Promise { + const _opts = { + poolId: hexRandom(), + reward: getRandomInteger(1, toBaseUnitAmount(100)), + stake: getRandomInteger(1, toBaseUnitAmount(10)), + ...opts, + }; + await testContract.setUnfinalizedMembersRewards.awaitTransactionSuccessAsync( + _opts.poolId, + new BigNumber(_opts.reward), + new BigNumber(_opts.stake), + ); + return _opts; + } + + type ResultWithDeposit = T & { + deposit: BigNumber; + }; + + interface DelegateStakeOpts { + delegator: string; + stake: Numberish; + } + + async function delegateStakeNowAsync( + poolId: string, + opts?: Partial, + ): Promise> { + return delegateStakeAsync(poolId, opts, true); + } + + async function delegateStakeAsync( + poolId: string, + opts?: Partial, + now?: boolean, + ): Promise> { + const _opts = { + delegator: randomAddress(), + stake: getRandomInteger(1, toBaseUnitAmount(10)), + ...opts, + }; + const fn = now ? testContract.delegateStakeNow : testContract.delegateStake; + const receipt = await fn.awaitTransactionSuccessAsync( + _opts.delegator, + poolId, + new BigNumber(_opts.stake), + ); + return { + ..._opts, + deposit: getDepositFromLogs(receipt.logs, poolId, _opts.delegator), + }; + } + + async function undelegateStakeAsync( + poolId: string, + delegator: string, + stake?: Numberish, + ): Promise> { + const _stake = new BigNumber( + stake || (await + testContract + .getStakeDelegatedToPoolByOwner + .callAsync(delegator, poolId) + ).currentEpochBalance, + ); + const receipt = await testContract.undelegateStake.awaitTransactionSuccessAsync( + delegator, + poolId, + _stake, + ); + return { + stake: _stake, + deposit: getDepositFromLogs(receipt.logs, poolId, delegator), + }; + } + + function getDepositFromLogs(logs: LogEntry[], poolId: string, delegator?: string): BigNumber { + const events = + filterLogsToArguments( + logs, + TestDelegatorRewardsEvents.Deposit, + ); + if (events.length > 0) { + expect(events.length).to.eq(1); + expect(events[0].poolId).to.eq(poolId); + if (delegator !== undefined) { + expect(events[0].member).to.eq(delegator); + } + return events[0].balance; + } + return constants.ZERO_AMOUNT; + } + + async function advanceEpochAsync(): Promise { + await testContract.advanceEpoch.awaitTransactionSuccessAsync(); + const epoch = await testContract.getCurrentEpoch.callAsync(); + return epoch.toNumber(); + } + + async function getDelegatorRewardAsync(poolId: string, delegator: string): Promise { + return testContract.computeRewardBalanceOfDelegator.callAsync( + poolId, + delegator, + ); + } + + async function touchStakeAsync(poolId: string, delegator: string): Promise> { + return undelegateStakeAsync(poolId, delegator, 0); + } + + async function finalizePoolAsync(poolId: string): Promise> { + const receipt = await testContract.internalFinalizePool.awaitTransactionSuccessAsync(poolId); + return { + deposit: getDepositFromLogs(receipt.logs, poolId), + }; + } + + function randomAddress(): string { + return hexRandom(constants.ADDRESS_LENGTH); + } + + function computeDelegatorRewards( + totalRewards: Numberish, + delegatorStake: Numberish, + totalDelegatorStake: Numberish, + ): BigNumber { + return new BigNumber(totalRewards) + .times(delegatorStake) + .dividedBy(new BigNumber(totalDelegatorStake)) + .integerValue(BigNumber.ROUND_DOWN); + } + describe('computeRewardBalanceOfDelegator()', () => { - it('does stuff', () => { - // TODO + it('nothing in epoch 0 for delegator with no stake', async () => { + const { poolId } = await rewardPoolMembersAsync(); + const delegator = randomAddress(); + const delegatorReward = await getDelegatorRewardAsync(poolId, delegator); + expect(delegatorReward).to.bignumber.eq(0); + }); + + it('nothing in epoch 1 for delegator with no stake', async () => { + await advanceEpochAsync(); // epoch 1 + const { poolId } = await rewardPoolMembersAsync(); + const delegator = randomAddress(); + const delegatorReward = await getDelegatorRewardAsync(poolId, delegator); + expect(delegatorReward).to.bignumber.eq(0); + }); + + it('nothing in epoch 0 for delegator staked in epoch 0', async () => { + const { poolId } = await rewardPoolMembersAsync(); + // Assign active stake to pool in epoch 0, which is usuaslly not + // possible due to delegating delays. + const { delegator } = await delegateStakeNowAsync(poolId); + const delegatorReward = await getDelegatorRewardAsync(poolId, delegator); + expect(delegatorReward).to.bignumber.eq(0); + }); + + it('nothing in epoch 1 for delegator delegating in epoch 1', async () => { + await advanceEpochAsync(); // epoch 1 + const { poolId } = await rewardPoolMembersAsync(); + const { delegator } = await delegateStakeAsync(poolId); + const delegatorReward = await getDelegatorRewardAsync(poolId, delegator); + expect(delegatorReward).to.bignumber.eq(0); + }); + + it('nothing in epoch 1 for delegator delegating in epoch 0', async () => { + const poolId = hexRandom(); + const { delegator, stake } = await delegateStakeAsync(poolId); + await advanceEpochAsync(); // epoch 1 (stake now active) + // rewards paid for stake in epoch 0. + await rewardPoolMembersAsync({ poolId, stake }); + const delegatorReward = await getDelegatorRewardAsync(poolId, delegator); + expect(delegatorReward).to.bignumber.eq(0); + }); + + it('all rewards from epoch 2 for delegator delegating in epoch 0', async () => { + const poolId = hexRandom(); + const { delegator, stake } = await delegateStakeAsync(poolId); + await advanceEpochAsync(); // epoch 1 (stake now active) + await advanceEpochAsync(); // epoch 2 + // rewards paid for stake in epoch 1. + const { reward } = await rewardPoolMembersAsync({ poolId, stake }); + const delegatorReward = await getDelegatorRewardAsync(poolId, delegator); + expect(delegatorReward).to.bignumber.eq(reward); + }); + + it('all rewards from epoch 2 and 3 for delegator delegating in epoch 0', async () => { + const poolId = hexRandom(); + const { delegator, stake } = await delegateStakeAsync(poolId); + await advanceEpochAsync(); // epoch 1 (stake now active) + await advanceEpochAsync(); // epoch 2 + const { reward: reward1 } = await rewardPoolMembersAsync({ poolId, stake }); + await advanceEpochAsync(); // epoch 3 + const { reward: reward2 } = await rewardPoolMembersAsync({ poolId, stake }); + const delegatorReward = await getDelegatorRewardAsync(poolId, delegator); + expect(delegatorReward).to.bignumber.eq(BigNumber.sum(reward1, reward2)); + }); + + it('partial rewards from epoch 2 and 3 for delegator partially delegating in epoch 0', async () => { + const poolId = hexRandom(); + const { delegator, stake: delegatorStake } = await delegateStakeAsync(poolId); + await advanceEpochAsync(); // epoch 1 (stake now active) + await advanceEpochAsync(); // epoch 2 + // rewards paid for stake in epoch 1. + const { reward, stake: rewardStake } = await rewardPoolMembersAsync( + { poolId, stake: new BigNumber(delegatorStake).times(2) }, + ); + const delegatorReward = await getDelegatorRewardAsync(poolId, delegator); + const expectedDelegatorRewards = computeDelegatorRewards(reward, delegatorStake, rewardStake); + assertRoughlyEquals(delegatorReward, expectedDelegatorRewards); + }); + + it.only('has correct reward immediately after unstaking', async () => { + const poolId = hexRandom(); + const { delegator, stake } = await delegateStakeAsync(poolId); + await advanceEpochAsync(); // epoch 1 (stake now active) + await advanceEpochAsync(); // epoch 2 + // rewards paid for stake in epoch 1. + const { reward } = await rewardPoolMembersAsync( + { poolId, stake }, + ); + await undelegateStakeAsync(poolId, delegator); + await advanceEpochAsync(); // epoch 3 + const delegatorReward = await getDelegatorRewardAsync(poolId, delegator); + expect(delegatorReward).to.bignumber.eq(reward); + }); + + it('computes correct rewards for 2 staggered delegators', async () => { + const poolId = hexRandom(); + const { delegator: delegatorA, stake: stakeA } = await delegateStakeAsync(poolId); + await advanceEpochAsync(); // epoch 1 (stake A now active) + const { delegator: delegatorB, stake: stakeB } = await delegateStakeAsync(poolId); + const totalStake = BigNumber.sum(stakeA, stakeB); + await advanceEpochAsync(); // epoch 2 (stake B now active) + // rewards paid for stake in epoch 1 (delegator A only) + const { reward: reward1 } = await rewardPoolMembersAsync( + { poolId, stake: stakeA }, + ); + await advanceEpochAsync(); // epoch 3 + // rewards paid for stake in epoch 2 (delegator A and B) + const { reward: reward2 } = await rewardPoolMembersAsync( + { poolId, stake: totalStake }, + ); + const delegatorRewardA = await getDelegatorRewardAsync(poolId, delegatorA); + const expectedDelegatorRewardA = BigNumber.sum( + computeDelegatorRewards(reward1, stakeA, stakeA), + computeDelegatorRewards(reward2, stakeA, totalStake), + ); + assertRoughlyEquals(delegatorRewardA, expectedDelegatorRewardA); + const delegatorRewardB = await getDelegatorRewardAsync(poolId, delegatorB); + const expectedDelegatorRewardB = BigNumber.sum( + computeDelegatorRewards(reward2, stakeB, totalStake), + ); + assertRoughlyEquals(delegatorRewardB, expectedDelegatorRewardB); + }); + + it('computes correct rewards for 2 staggered delegators with a 2 epoch gap between payments', async () => { + const poolId = hexRandom(); + const { delegator: delegatorA, stake: stakeA } = await delegateStakeAsync(poolId); + await advanceEpochAsync(); // epoch 1 (stake A now active) + const { delegator: delegatorB, stake: stakeB } = await delegateStakeAsync(poolId); + const totalStake = BigNumber.sum(stakeA, stakeB); + await advanceEpochAsync(); // epoch 2 (stake B now active) + // rewards paid for stake in epoch 1 (delegator A only) + const { reward: reward1 } = await rewardPoolMembersAsync( + { poolId, stake: stakeA }, + ); + await advanceEpochAsync(); // epoch 3 + await advanceEpochAsync(); // epoch 4 + // rewards paid for stake in epoch 3 (delegator A and B) + const { reward: reward2 } = await rewardPoolMembersAsync( + { poolId, stake: totalStake }, + ); + const delegatorRewardA = await getDelegatorRewardAsync(poolId, delegatorA); + const expectedDelegatorRewardA = BigNumber.sum( + computeDelegatorRewards(reward1, stakeA, stakeA), + computeDelegatorRewards(reward2, stakeA, totalStake), + ); + assertRoughlyEquals(delegatorRewardA, expectedDelegatorRewardA); + const delegatorRewardB = await getDelegatorRewardAsync(poolId, delegatorB); + const expectedDelegatorRewardB = BigNumber.sum( + computeDelegatorRewards(reward2, stakeB, totalStake), + ); + assertRoughlyEquals(delegatorRewardB, expectedDelegatorRewardB); + }); + + it('correct rewards for rewards with different stakes', async () => { + const poolId = hexRandom(); + const { delegator, stake: delegatorStake } = await delegateStakeAsync(poolId); + await advanceEpochAsync(); // epoch 1 (stake now active) + await advanceEpochAsync(); // epoch 2 + // rewards paid for stake in epoch 1. + const { reward: reward1, stake: rewardStake1 } = await rewardPoolMembersAsync( + { poolId, stake: new BigNumber(delegatorStake).times(2) }, + ); + await advanceEpochAsync(); // epoch 3 + // rewards paid for stake in epoch 2 + const { reward: reward2, stake: rewardStake2 } = await rewardPoolMembersAsync( + { poolId, stake: new BigNumber(delegatorStake).times(3) }, + ); + const delegatorReward = await getDelegatorRewardAsync(poolId, delegator); + const expectedDelegatorReward = BigNumber.sum( + computeDelegatorRewards(reward1, delegatorStake, rewardStake1), + computeDelegatorRewards(reward2, delegatorStake, rewardStake2), + ); + assertRoughlyEquals(delegatorReward, expectedDelegatorReward); + }); + + describe('with unfinalized rewards', async () => { + it('nothing with only unfinalized rewards from epoch 1 for deleator with nothing delegated', async () => { + const poolId = hexRandom(); + const { delegator, stake } = await delegateStakeAsync(poolId, { stake: 0 }); + await advanceEpochAsync(); // epoch 1 + await setUnfinalizedMembersRewardsAsync({ poolId, stake }); + const reward = await getDelegatorRewardAsync(poolId, delegator); + expect(reward).to.bignumber.eq(0); + }); + + it('nothing with only unfinalized rewards from epoch 1 for deleator delegating in epoch 0', async () => { + const poolId = hexRandom(); + const { delegator, stake } = await delegateStakeAsync(poolId); + await advanceEpochAsync(); // epoch 1 + await setUnfinalizedMembersRewardsAsync({ poolId, stake }); + const reward = await getDelegatorRewardAsync(poolId, delegator); + expect(reward).to.bignumber.eq(0); + }); + + it('returns only unfinalized rewards from epoch 2 for delegator delegating in epoch 1', async () => { + const poolId = hexRandom(); + const { delegator, stake } = await delegateStakeAsync(poolId); + await advanceEpochAsync(); // epoch 1 + await advanceEpochAsync(); // epoch 2 + const { reward: unfinalizedReward } = await setUnfinalizedMembersRewardsAsync({ poolId, stake }); + const reward = await getDelegatorRewardAsync(poolId, delegator); + expect(reward).to.bignumber.eq(unfinalizedReward); + }); + + it('returns only unfinalized rewards from epoch 3 for delegator delegating in epoch 1', async () => { + const poolId = hexRandom(); + const { delegator, stake } = await delegateStakeAsync(poolId); + await advanceEpochAsync(); // epoch 1 + await advanceEpochAsync(); // epoch 2 + await advanceEpochAsync(); // epoch 3 + const { reward: unfinalizedReward } = await setUnfinalizedMembersRewardsAsync({ poolId, stake }); + const reward = await getDelegatorRewardAsync(poolId, delegator); + expect(reward).to.bignumber.eq(unfinalizedReward); + }); + + it('returns unfinalized rewards from epoch 3 + rewards from epoch 2 for delegator delegating in epoch 1', async () => { + const poolId = hexRandom(); + const { delegator, stake } = await delegateStakeAsync(poolId); + await advanceEpochAsync(); // epoch 1 + await advanceEpochAsync(); // epoch 2 + const { reward: prevReward } = await rewardPoolMembersAsync({ poolId, stake }); + await advanceEpochAsync(); // epoch 3 + const { reward: unfinalizedReward } = await setUnfinalizedMembersRewardsAsync({ poolId, stake }); + const reward = await getDelegatorRewardAsync(poolId, delegator); + const expectedReward = BigNumber.sum(prevReward, unfinalizedReward); + expect(reward).to.bignumber.eq(expectedReward); + }); + + it('returns unfinalized rewards from epoch 4 + rewards from epoch 2 for delegator delegating in epoch 1', async () => { + const poolId = hexRandom(); + const { delegator, stake } = await delegateStakeAsync(poolId); + await advanceEpochAsync(); // epoch 1 + await advanceEpochAsync(); // epoch 2 + const { reward: prevReward } = await rewardPoolMembersAsync({ poolId, stake }); + await advanceEpochAsync(); // epoch 3 + await advanceEpochAsync(); // epoch 4 + const { reward: unfinalizedReward } = await setUnfinalizedMembersRewardsAsync({ poolId, stake }); + const reward = await getDelegatorRewardAsync(poolId, delegator); + const expectedReward = BigNumber.sum(prevReward, unfinalizedReward); + expect(reward).to.bignumber.eq(expectedReward); + }); + + it('returns correct rewards if unfinalized stake is different from previous rewards', async () => { + const poolId = hexRandom(); + const { delegator, stake } = await delegateStakeAsync(poolId); + await advanceEpochAsync(); // epoch 1 + await advanceEpochAsync(); // epoch 2 + const { reward: prevReward, stake: prevStake } = await rewardPoolMembersAsync( + { poolId, stake: new BigNumber(stake).times(2) }, + ); + await advanceEpochAsync(); // epoch 3 + await advanceEpochAsync(); // epoch 4 + const { reward: unfinalizedReward, stake: unfinalizedStake } = + await setUnfinalizedMembersRewardsAsync( + { poolId, stake: new BigNumber(stake).times(5) }, + ); + const reward = await getDelegatorRewardAsync(poolId, delegator); + const expectedReward = BigNumber.sum( + computeDelegatorRewards(prevReward, stake, prevStake), + computeDelegatorRewards(unfinalizedReward, stake, unfinalizedStake), + ); + assertRoughlyEquals(reward, expectedReward); + }); + }); + }); + + describe('reward transfers', async () => { + it('transfers all rewards to eth vault when touching stake', async () => { + const poolId = hexRandom(); + const { delegator, stake } = await delegateStakeAsync(poolId); + await advanceEpochAsync(); // epoch 1 (stake now active) + await advanceEpochAsync(); // epoch 2 + // rewards paid for stake in epoch 1 + const { reward } = await rewardPoolMembersAsync({ poolId, stake }); + const { deposit } = await touchStakeAsync(poolId, delegator); + expect(deposit).to.bignumber.eq(reward); + }); + + it('does not collect extra rewards from delegating more stake in the reward epoch', async () => { + const poolId = hexRandom(); + const stakeResults = []; + // stake + stakeResults.push(await delegateStakeAsync(poolId)); + const { delegator, stake } = stakeResults[0]; + const totalStake = new BigNumber(stake).times(2); + await advanceEpochAsync(); // epoch 1 (stake now active) + // add more stake. + stakeResults.push(await delegateStakeAsync(poolId, { delegator, stake })); + await advanceEpochAsync(); // epoch 1 (2 * stake now active) + // reward for epoch 1, using 2 * stake so delegator should + // only be entitled to a fraction of the rewards. + const { reward } = await rewardPoolMembersAsync({ poolId, stake: totalStake }); + await advanceEpochAsync(); // epoch 2 + // touch the stake one last time + stakeResults.push(await touchStakeAsync(poolId, delegator)); + // Should only see deposits for epoch 2. + const expectedDeposit = computeDelegatorRewards(reward, stake, totalStake); + const allDeposits = stakeResults.map(r => r.deposit); + assertRoughlyEquals(BigNumber.sum(...allDeposits), expectedDeposit); + }); + + it('only collects rewards from staked epochs', async () => { + const poolId = hexRandom(); + const stakeResults = []; + // stake + stakeResults.push(await delegateStakeAsync(poolId)); + const { delegator, stake } = stakeResults[0]; + await advanceEpochAsync(); // epoch 1 (stake now active) + // unstake before and after reward payout, to be extra sneaky. + const unstake1 = new BigNumber(stake).dividedToIntegerBy(2); + stakeResults.push(await undelegateStakeAsync(poolId, delegator, unstake1)); + // reward for epoch 0 + await rewardPoolMembersAsync({ poolId, stake }); + const unstake2 = new BigNumber(stake).minus(unstake1); + stakeResults.push(await undelegateStakeAsync(poolId, delegator, unstake2)); + await advanceEpochAsync(); // epoch 2 (no active stake) + // reward for epoch 1 + const { reward } = await rewardPoolMembersAsync({ poolId, stake }); + // re-stake + stakeResults.push(await delegateStakeAsync(poolId, { delegator, stake })); + await advanceEpochAsync(); // epoch 3 (stake now active) + // reward for epoch 2 + await rewardPoolMembersAsync({ poolId, stake }); + // touch the stake one last time + stakeResults.push(await touchStakeAsync(poolId, delegator)); + // Should only see deposits for epoch 2. + const allDeposits = stakeResults.map(r => r.deposit); + assertRoughlyEquals(BigNumber.sum(...allDeposits), reward); + }); + + it('delegator B collects correct rewards after delegator A finalizes', async () => { + const poolId = hexRandom(); + const { delegator: delegatorA, stake: stakeA } = await delegateStakeAsync(poolId); + const { delegator: delegatorB, stake: stakeB } = await delegateStakeAsync(poolId); + const totalStake = BigNumber.sum(stakeA, stakeB); + await advanceEpochAsync(); // epoch 1 (stakes now active) + await advanceEpochAsync(); // epoch 2 + // rewards paid for stake in epoch 1 + const { reward: prevReward } = await rewardPoolMembersAsync({ poolId, stake: totalStake }); + await advanceEpochAsync(); // epoch 3 + // unfinalized rewards for stake in epoch 2 + const { reward: unfinalizedReward } = await setUnfinalizedMembersRewardsAsync({ poolId, stake: totalStake }); + const totalRewards = BigNumber.sum(prevReward, unfinalizedReward); + // delegator A will finalize and collect rewards by touching stake. + const { deposit: depositA } = await touchStakeAsync(poolId, delegatorA); + assertRoughlyEquals(depositA, computeDelegatorRewards(totalRewards, stakeA, totalStake)); + // delegator B will collect rewards by touching stake + const { deposit: depositB } = await touchStakeAsync(poolId, delegatorB); + assertRoughlyEquals(depositB, computeDelegatorRewards(totalRewards, stakeB, totalStake)); + }); + + it('delegator A and B collect correct rewards after external finalization', async () => { + const poolId = hexRandom(); + const { delegator: delegatorA, stake: stakeA } = await delegateStakeAsync(poolId); + const { delegator: delegatorB, stake: stakeB } = await delegateStakeAsync(poolId); + const totalStake = BigNumber.sum(stakeA, stakeB); + await advanceEpochAsync(); // epoch 1 (stakes now active) + await advanceEpochAsync(); // epoch 2 + // rewards paid for stake in epoch 1 + const { reward: prevReward } = await rewardPoolMembersAsync({ poolId, stake: totalStake }); + await advanceEpochAsync(); // epoch 3 + // unfinalized rewards for stake in epoch 2 + const { reward: unfinalizedReward } = await setUnfinalizedMembersRewardsAsync({ poolId, stake: totalStake }); + const totalRewards = BigNumber.sum(prevReward, unfinalizedReward); + // finalize + await finalizePoolAsync(poolId); + // delegator A will collect rewards by touching stake. + const { deposit: depositA } = await touchStakeAsync(poolId, delegatorA); + assertRoughlyEquals(depositA, computeDelegatorRewards(totalRewards, stakeA, totalStake)); + // delegator B will collect rewards by touching stake + const { deposit: depositB } = await touchStakeAsync(poolId, delegatorB); + assertRoughlyEquals(depositB, computeDelegatorRewards(totalRewards, stakeB, totalStake)); }); }); }); +// tslint:disable: max-file-line-count diff --git a/contracts/staking/test/unit_tests/finalizer_test.ts b/contracts/staking/test/unit_tests/finalizer_test.ts index f53f6d0a79..11a8a15056 100644 --- a/contracts/staking/test/unit_tests/finalizer_test.ts +++ b/contracts/staking/test/unit_tests/finalizer_test.ts @@ -23,16 +23,14 @@ import { } from '../../src'; import { getRandomInteger, toBaseUnitAmount } from '../utils/number_utils'; -blockchainTests.resets('finalizer tests', env => { +blockchainTests.resets('finalizer unit tests', env => { const { ZERO_AMOUNT } = constants; const INITIAL_EPOCH = 0; const INITIAL_BALANCE = toBaseUnitAmount(32); - let senderAddress: string; let rewardReceiverAddress: string; let testContract: TestFinalizerContract; before(async () => { - [senderAddress] = await env.getAccountAddressesAsync(); rewardReceiverAddress = hexRandom(constants.ADDRESS_LENGTH); testContract = await TestFinalizerContract.deployFrom0xArtifactAsync( artifacts.TestFinalizer, @@ -48,7 +46,7 @@ blockchainTests.resets('finalizer tests', env => { async function sendEtherAsync(to: string, amount: Numberish): Promise { await env.web3Wrapper.awaitTransactionSuccessAsync( await env.web3Wrapper.sendTransactionAsync({ - from: senderAddress, + from: (await env.getAccountAddressesAsync())[0], to, value: new BigNumber(amount), }), diff --git a/contracts/staking/test/utils/types.ts b/contracts/staking/test/utils/types.ts index 9aa685b05d..a21918a09b 100644 --- a/contracts/staking/test/utils/types.ts +++ b/contracts/staking/test/utils/types.ts @@ -121,7 +121,7 @@ export interface RewardByPoolId { [key: string]: BigNumber; } -export interface MemberBalancesByPoolId { +export interface DelegatorBalancesByPoolId { [key: string]: BalanceByOwner; } @@ -129,6 +129,6 @@ export interface OperatorByPoolId { [key: string]: string; } -export interface MembersByPoolId { +export interface DelegatorsByPoolId { [key: string]: string[]; } diff --git a/contracts/utils/contracts/src/LibFractions.sol b/contracts/utils/contracts/src/LibFractions.sol index 98a894b7d3..9f3f7750c2 100644 --- a/contracts/utils/contracts/src/LibFractions.sol +++ b/contracts/utils/contracts/src/LibFractions.sol @@ -57,6 +57,9 @@ library LibFractions { pure returns (uint256 result) { + if (s == 0) { + return 0; + } if (n2 == 0) { return result = s .safeMul(n1)