From 213e56a64a8cf16cc9d58e4dc171d339d62fb378 Mon Sep 17 00:00:00 2001 From: Greg Hysen Date: Mon, 2 Sep 2019 16:40:49 -0700 Subject: [PATCH] Tests for new staking mechanics --- .../contracts/src/fees/MixinExchangeFees.sol | 7 +- .../interfaces/IStakingPoolRewardVault.sol | 8 + .../src/vaults/StakingPoolRewardVault.sol | 11 + .../staking/contracts/test/LibFeeMathTest.sol | 2 - .../staking/test/actors/delegator_actor.ts | 160 ----- .../staking/test/actors/finalizer_actor.ts | 186 ++++++ contracts/staking/test/actors/staker_actor.ts | 214 +++++-- contracts/staking/test/rewards_test.ts | 600 ++++++++++++++++++ contracts/staking/test/simulations_test.ts | 6 +- contracts/staking/test/stake_test.ts | 336 +++++++++- contracts/staking/test/utils/Simulation.ts | 9 +- .../staking/test/utils/staking_wrapper.ts | 38 +- contracts/staking/test/utils/types.ts | 69 +- packages/types/src/index.ts | 2 + 14 files changed, 1369 insertions(+), 279 deletions(-) delete mode 100644 contracts/staking/test/actors/delegator_actor.ts create mode 100644 contracts/staking/test/actors/finalizer_actor.ts create mode 100644 contracts/staking/test/rewards_test.ts diff --git a/contracts/staking/contracts/src/fees/MixinExchangeFees.sol b/contracts/staking/contracts/src/fees/MixinExchangeFees.sol index 5ad5275ab9..8e69de2b69 100644 --- a/contracts/staking/contracts/src/fees/MixinExchangeFees.sol +++ b/contracts/staking/contracts/src/fees/MixinExchangeFees.sol @@ -200,7 +200,7 @@ contract MixinExchangeFees is // sanity check - this is a gas optimization that can be used because we assume a non-zero // split between stake and fees generated in the cobb-douglas computation (see below). - if (totalFeesCollected == 0 || totalWeightedStake == 0) { + if (totalFeesCollected == 0) { return ( totalActivePools, totalFeesCollected, @@ -214,12 +214,13 @@ contract MixinExchangeFees is // step 2/3 - record reward for each pool for (uint256 i = 0; i != totalActivePools; i++) { // compute reward using cobb-douglas formula + // we set weighted stake to 1/1 if there is no delegated stake. uint256 reward = LibFeeMath._cobbDouglasSuperSimplified( initialContractBalance, activePools[i].feesCollected, totalFeesCollected, - activePools[i].weightedStake, - totalWeightedStake + totalWeightedStake != 0 ? activePools[i].weightedStake : 1, + totalWeightedStake != 0 ? totalWeightedStake : 1 ); // record reward in vault diff --git a/contracts/staking/contracts/src/interfaces/IStakingPoolRewardVault.sol b/contracts/staking/contracts/src/interfaces/IStakingPoolRewardVault.sol index 2f8531f037..3f32c4e0d1 100644 --- a/contracts/staking/contracts/src/interfaces/IStakingPoolRewardVault.sol +++ b/contracts/staking/contracts/src/interfaces/IStakingPoolRewardVault.sol @@ -165,4 +165,12 @@ interface IStakingPoolRewardVault { external view returns (uint256); + + /// @dev Returns the operator share of a pool's balance. + /// @param poolId Unique Id of pool. + /// @return Operator share (integer out of 100) + function getOperatorShare(bytes32 poolId) + external + view + returns (uint256); } diff --git a/contracts/staking/contracts/src/vaults/StakingPoolRewardVault.sol b/contracts/staking/contracts/src/vaults/StakingPoolRewardVault.sol index 0790742e0d..e37aae9c0a 100644 --- a/contracts/staking/contracts/src/vaults/StakingPoolRewardVault.sol +++ b/contracts/staking/contracts/src/vaults/StakingPoolRewardVault.sol @@ -250,6 +250,17 @@ contract StakingPoolRewardVault is return balanceByPoolId[poolId].membersBalance; } + /// @dev Returns the operator share of a pool's balance. + /// @param poolId Unique Id of pool. + /// @return Operator share (integer out of 100) + function getOperatorShare(bytes32 poolId) + external + view + returns (uint256) + { + return balanceByPoolId[poolId].operatorShare; + } + /// @dev Increments a balance struct, splitting the input amount between the /// pool operator and members of the pool based on the pool operator's share. /// @param balance Balance struct to increment. diff --git a/contracts/staking/contracts/test/LibFeeMathTest.sol b/contracts/staking/contracts/test/LibFeeMathTest.sol index d22661904a..4b90e03ee2 100644 --- a/contracts/staking/contracts/test/LibFeeMathTest.sol +++ b/contracts/staking/contracts/test/LibFeeMathTest.sol @@ -21,8 +21,6 @@ pragma solidity ^0.5.5; - - contract LibFeeMathTest { function nthRoot(uint256 base, uint256 n) public pure returns (uint256 root) { diff --git a/contracts/staking/test/actors/delegator_actor.ts b/contracts/staking/test/actors/delegator_actor.ts deleted file mode 100644 index e76148b4a3..0000000000 --- a/contracts/staking/test/actors/delegator_actor.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { expect } from '@0x/contracts-test-utils'; -import { BigNumber, RevertError } from '@0x/utils'; -import * as _ from 'lodash'; - -import { StakingWrapper } from '../utils/staking_wrapper'; -import { DelegatorBalances, StakerBalances } from '../utils/types'; - -import { StakerActor } from './staker_actor'; - -export class DelegatorActor extends StakerActor { - /** - constructor(owner: string, stakingWrapper: StakingWrapper) { - super(owner, stakingWrapper); - } - public async depositZrxAndDelegateToStakingPoolAsync( - poolId: string, - amount: BigNumber, - revertError?: RevertError, - ): Promise { - // query init balances - const initZrxBalanceOfVault = await this._stakingWrapper.getZrxTokenBalanceOfZrxVaultAsync(); - const initDelegatorBalances = await this.getBalancesAsync([poolId]); - // deposit stake - const txReceiptPromise = this._stakingWrapper.depositZrxAndDelegateToStakingPoolAsync( - this._owner, - poolId, - amount, - ); - if (revertError !== undefined) { - await expect(txReceiptPromise).to.revertWith(revertError); - return; - } - await txReceiptPromise; - // @TODO check receipt logs and return value via eth_call - // check balances - const expectedDelegatorBalances = initDelegatorBalances; - expectedDelegatorBalances.zrxBalance = initDelegatorBalances.zrxBalance.minus(amount); - expectedDelegatorBalances.stakeBalance = initDelegatorBalances.stakeBalance.plus(amount); - expectedDelegatorBalances.stakeBalanceInVault = initDelegatorBalances.stakeBalanceInVault.plus(amount); - expectedDelegatorBalances.activatedStakeBalance = initDelegatorBalances.activatedStakeBalance.plus(amount); - expectedDelegatorBalances.delegatedStakeBalance = initDelegatorBalances.delegatedStakeBalance.plus(amount); - expectedDelegatorBalances.stakeDelegatedToPoolByOwner[0] = initDelegatorBalances.stakeDelegatedToPoolByOwner[0].plus( - amount, - ); - expectedDelegatorBalances.stakeDelegatedToPool[0] = initDelegatorBalances.stakeDelegatedToPool[0].plus(amount); - await this.assertBalancesAsync(expectedDelegatorBalances, [poolId]); - // check zrx balance of vault - const finalZrxBalanceOfVault = await this._stakingWrapper.getZrxTokenBalanceOfZrxVaultAsync(); - expect(finalZrxBalanceOfVault).to.be.bignumber.equal(initZrxBalanceOfVault.plus(amount)); - } - public async activateAndDelegateStakeAsync( - poolId: string, - amount: BigNumber, - revertError?: RevertError, - ): Promise { - // query init balances - const initDelegatorBalances = await this.getBalancesAsync([poolId]); - // activate and delegate - const txReceiptPromise = this._stakingWrapper.activateAndDelegateStakeAsync(this._owner, poolId, amount); - if (revertError !== undefined) { - await expect(txReceiptPromise).to.revertWith(revertError); - return; - } - await txReceiptPromise; - // @TODO check receipt logs and return value via eth_call - // check balances - // check balances - const expectedDelegatorBalances = initDelegatorBalances; - expectedDelegatorBalances.activatedStakeBalance = initDelegatorBalances.activatedStakeBalance.plus(amount); - expectedDelegatorBalances.withdrawableStakeBalance = expectedDelegatorBalances.withdrawableStakeBalance.minus( - amount, - ); - expectedDelegatorBalances.activatableStakeBalance = expectedDelegatorBalances.activatableStakeBalance.minus( - amount, - ); - expectedDelegatorBalances.deactivatedStakeBalance = expectedDelegatorBalances.deactivatedStakeBalance.minus( - amount, - ); - expectedDelegatorBalances.delegatedStakeBalance = initDelegatorBalances.delegatedStakeBalance.plus(amount); - expectedDelegatorBalances.stakeDelegatedToPoolByOwner[0] = initDelegatorBalances.stakeDelegatedToPoolByOwner[0].plus( - amount, - ); - expectedDelegatorBalances.stakeDelegatedToPool[0] = initDelegatorBalances.stakeDelegatedToPool[0].plus(amount); - await this.assertBalancesAsync(expectedDelegatorBalances, [poolId]); - } - public async deactivateAndTimeLockDelegatedStakeAsync( - poolId: string, - amount: BigNumber, - revertError?: RevertError, - ): Promise { - // query init balances - const initDelegatorBalances = await this.getBalancesAsync([poolId]); - // deactivate and timeLock - const txReceiptPromise = this._stakingWrapper.deactivateAndTimeLockDelegatedStakeAsync( - this._owner, - poolId, - amount, - ); - if (revertError !== undefined) { - await expect(txReceiptPromise).to.revertWith(revertError); - return; - } - await txReceiptPromise; - // @TODO check receipt logs and return value via eth_call - // check balances - const expectedDelegatorBalances = initDelegatorBalances; - expectedDelegatorBalances.activatedStakeBalance = initDelegatorBalances.activatedStakeBalance.minus(amount); - expectedDelegatorBalances.timeLockedStakeBalance = expectedDelegatorBalances.timeLockedStakeBalance.plus( - amount, - ); - expectedDelegatorBalances.deactivatedStakeBalance = expectedDelegatorBalances.deactivatedStakeBalance.plus( - amount, - ); - expectedDelegatorBalances.delegatedStakeBalance = initDelegatorBalances.delegatedStakeBalance.minus(amount); - expectedDelegatorBalances.stakeDelegatedToPoolByOwner[0] = initDelegatorBalances.stakeDelegatedToPoolByOwner[0].minus( - amount, - ); - expectedDelegatorBalances.stakeDelegatedToPool[0] = initDelegatorBalances.stakeDelegatedToPool[0].minus(amount); - await this.assertBalancesAsync(expectedDelegatorBalances, [poolId]); - } - public async getBalancesAsync(maybePoolIds?: string[]): Promise { - const stakerBalances = await super.getBalancesAsync(); - const delegatorBalances = { - ...stakerBalances, - delegatedStakeBalance: await this._stakingWrapper.getStakeDelegatedByOwnerAsync(this._owner), - stakeDelegatedToPoolByOwner: Array(), - stakeDelegatedToPool: Array(), - }; - const poolIds = maybePoolIds !== undefined ? maybePoolIds : []; - for (const poolId of poolIds) { - const stakeDelegatedToPoolByOwner = await this._stakingWrapper.getStakeDelegatedToPoolByOwnerAsync( - poolId, - this._owner, - ); - delegatorBalances.stakeDelegatedToPoolByOwner.push(stakeDelegatedToPoolByOwner); - const stakeDelegatedToPool = await this._stakingWrapper.getTotalStakeDelegatedToPoolAsync(poolId); - delegatorBalances.stakeDelegatedToPool.push(stakeDelegatedToPool); - } - return delegatorBalances; - } - public async assertBalancesAsync(expectedBalances: DelegatorBalances, maybePoolIds?: string[]): Promise { - await super.assertBalancesAsync(expectedBalances); - const balances = await this.getBalancesAsync(maybePoolIds); - expect(balances.delegatedStakeBalance, 'delegated stake balance').to.be.bignumber.equal( - expectedBalances.delegatedStakeBalance, - ); - const poolIds = maybePoolIds !== undefined ? maybePoolIds : []; - for (let i = 0; i < poolIds.length; i++) { - expect( - balances.stakeDelegatedToPoolByOwner[i], - `stake delegated to pool ${poolIds[i]} by owner`, - ).to.be.bignumber.equal(expectedBalances.stakeDelegatedToPoolByOwner[i]); - expect( - balances.stakeDelegatedToPool[i], - `total stake delegated to pool ${poolIds[i]}`, - ).to.be.bignumber.equal(expectedBalances.stakeDelegatedToPool[i]); - } - } - */ -} diff --git a/contracts/staking/test/actors/finalizer_actor.ts b/contracts/staking/test/actors/finalizer_actor.ts new file mode 100644 index 0000000000..535fa4f040 --- /dev/null +++ b/contracts/staking/test/actors/finalizer_actor.ts @@ -0,0 +1,186 @@ +import { expect } from '@0x/contracts-test-utils'; +import { BigNumber } from '@0x/utils'; +import * as _ from 'lodash'; + +import { StakingWrapper } from '../utils/staking_wrapper'; +import { + MemberBalancesByPoolId, + MembersByPoolId, + OperatorByPoolId, + OperatorShareByPoolId, + RewardVaultBalance, + RewardVaultBalanceByPoolId, +} from '../utils/types'; + +import { BaseActor } from './base_actor'; + +interface Reward { + reward: BigNumber; + poolId: string; +} + +export class FinalizerActor extends BaseActor { + private readonly _poolIds: string[]; + // @TODO (hysz): this will be used later to liquidate the reward vault. + // tslint:disable-next-line no-unused-variable + private readonly _operatorByPoolId: OperatorByPoolId; + private readonly _membersByPoolId: MembersByPoolId; + + constructor( + owner: string, + stakingWrapper: StakingWrapper, + poolIds: string[], + operatorByPoolId: OperatorByPoolId, + membersByPoolId: MembersByPoolId, + ) { + super(owner, stakingWrapper); + this._poolIds = _.cloneDeep(poolIds); + this._operatorByPoolId = _.cloneDeep(operatorByPoolId); + this._membersByPoolId = _.cloneDeep(membersByPoolId); + } + + 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); + // compute expecnted changes + const expectedRewardVaultBalanceByPoolId = await this._computeExpectedRewardVaultBalanceAsyncByPoolIdAsync( + rewards, + 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, + ); + // finalize + await this._stakingWrapper.skipToNextEpochAsync(); + // assert reward vault changes + const finalRewardVaultBalanceByPoolId = await this._getRewardVaultBalanceByPoolIdAsync(this._poolIds); + expect(finalRewardVaultBalanceByPoolId).to.be.deep.equal(expectedRewardVaultBalanceByPoolId); + // assert member balances + const finalMemberBalancesByPoolId = await this._getMemberBalancesByPoolIdAsync(this._membersByPoolId); + expect(finalMemberBalancesByPoolId).to.be.deep.equal(expectedMemberBalancesByPoolId); + } + + 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) { + continue; + } + const totalStakeDelegatedToPool = (await this._stakingWrapper.getTotalStakeDelegatedToPoolAsync(poolId)) + .current; + for (const member of membersByPoolId[poolId]) { + if (totalStakeDelegatedToPool.eq(0)) { + expectedMemberBalancesByPoolId[poolId][member] = new BigNumber(0); + } else { + const stakeDelegatedToPoolByMember = (await this._stakingWrapper.getStakeDelegatedToPoolByOwnerAsync( + poolId, + member, + )).current; + const rewardThisEpoch = rewardByPoolId[poolId] + .times(stakeDelegatedToPoolByMember) + .dividedToIntegerBy(totalStakeDelegatedToPool); + expectedMemberBalancesByPoolId[poolId][member] = + memberBalancesByPoolId[poolId][member] === undefined + ? rewardThisEpoch + : memberBalancesByPoolId[poolId][member].plus(rewardThisEpoch); + } + } + } + return expectedMemberBalancesByPoolId; + } + + 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._stakingWrapper.computeRewardBalanceOfStakingPoolMemberAsync(poolId, member); + } + } + return memberBalancesByPoolId; + } + + private async _computeExpectedRewardVaultBalanceAsyncByPoolIdAsync( + rewards: Reward[], + rewardVaultBalanceByPoolId: RewardVaultBalanceByPoolId, + operatorShareByPoolId: OperatorShareByPoolId, + ): Promise { + const expectedRewardVaultBalanceByPoolId = _.cloneDeep(rewardVaultBalanceByPoolId); + for (const reward of rewards) { + const operatorShare = operatorShareByPoolId[reward.poolId]; + expectedRewardVaultBalanceByPoolId[reward.poolId] = await this._computeExpectedRewardVaultBalanceAsync( + reward.poolId, + reward.reward, + expectedRewardVaultBalanceByPoolId[reward.poolId], + operatorShare, + ); + } + return expectedRewardVaultBalanceByPoolId; + } + + private async _computeExpectedRewardVaultBalanceAsync( + poolId: string, + reward: BigNumber, + rewardVaultBalance: RewardVaultBalance, + operatorShare: BigNumber, + ): Promise { + const totalStakeDelegatedToPool = (await this._stakingWrapper.getTotalStakeDelegatedToPoolAsync(poolId)) + .current; + const operatorPortion = totalStakeDelegatedToPool.eq(0) + ? reward + : reward.times(operatorShare).dividedToIntegerBy(100); + const membersPortion = reward.minus(operatorPortion); + return { + poolBalance: rewardVaultBalance.poolBalance.plus(reward), + operatorBalance: rewardVaultBalance.operatorBalance.plus(operatorPortion), + membersBalance: rewardVaultBalance.membersBalance.plus(membersPortion), + }; + } + + private async _getOperatorShareByPoolIdAsync(poolIds: string[]): Promise { + const operatorShareByPoolId: OperatorShareByPoolId = {}; + for (const poolId of poolIds) { + const operatorShare = await this._stakingWrapper + .getStakingPoolRewardVaultContract() + .getOperatorShare.callAsync(poolId); + operatorShareByPoolId[poolId] = operatorShare; + } + return operatorShareByPoolId; + } + + private async _getRewardVaultBalanceByPoolIdAsync(poolIds: string[]): Promise { + const rewardVaultBalanceByPoolId: RewardVaultBalanceByPoolId = {}; + for (const poolId of poolIds) { + rewardVaultBalanceByPoolId[poolId] = await this._getRewardVaultBalanceAsync(poolId); + } + return rewardVaultBalanceByPoolId; + } + + private async _getRewardVaultBalanceAsync(poolId: string): Promise { + const balances = await Promise.all([ + this._stakingWrapper.rewardVaultBalanceOfAsync(poolId), + this._stakingWrapper.rewardVaultBalanceOfOperatorAsync(poolId), + this._stakingWrapper.rewardVaultBalanceOfMembersAsync(poolId), + ]); + return { + poolBalance: balances[0], + operatorBalance: balances[1], + membersBalance: balances[2], + }; + } +} diff --git a/contracts/staking/test/actors/staker_actor.ts b/contracts/staking/test/actors/staker_actor.ts index 546c066a84..81c2b3e7ab 100644 --- a/contracts/staking/test/actors/staker_actor.ts +++ b/contracts/staking/test/actors/staker_actor.ts @@ -3,25 +3,23 @@ import { BigNumber, RevertError } from '@0x/utils'; import * as _ from 'lodash'; import { StakingWrapper } from '../utils/staking_wrapper'; -import { StakerBalances } from '../utils/types'; +import { StakeBalances, StakeState, StakeStateInfo } from '../utils/types'; import { BaseActor } from './base_actor'; export class StakerActor extends BaseActor { - /* + private readonly _poolIds: string[]; + constructor(owner: string, stakingWrapper: StakingWrapper) { super(owner, stakingWrapper); + this._poolIds = []; } - public async depositZrxAndMintDeactivatedStakeAsync(amount: BigNumber, revertError?: RevertError): Promise { - await this._stakingWrapper.depositZrxAndMintDeactivatedStakeAsync(this._owner, amount); - throw new Error('Checks Unimplemented'); - } - public async depositZrxAndMintActivatedStakeAsync(amount: BigNumber, revertError?: RevertError): Promise { - // query init balances + + public async stakeAsync(amount: BigNumber, revertError?: RevertError): Promise { const initZrxBalanceOfVault = await this._stakingWrapper.getZrxTokenBalanceOfZrxVaultAsync(); const initStakerBalances = await this.getBalancesAsync(); // deposit stake - const txReceiptPromise = this._stakingWrapper.depositZrxAndMintActivatedStakeAsync(this._owner, amount); + const txReceiptPromise = this._stakingWrapper.stakeAsync(this._owner, amount); if (revertError !== undefined) { await expect(txReceiptPromise).to.revertWith(revertError); return; @@ -31,19 +29,20 @@ export class StakerActor extends BaseActor { // check balances const expectedStakerBalances = initStakerBalances; expectedStakerBalances.zrxBalance = initStakerBalances.zrxBalance.minus(amount); - expectedStakerBalances.stakeBalance = initStakerBalances.stakeBalance.plus(amount); expectedStakerBalances.stakeBalanceInVault = initStakerBalances.stakeBalanceInVault.plus(amount); - expectedStakerBalances.activatedStakeBalance = initStakerBalances.activatedStakeBalance.plus(amount); + expectedStakerBalances.activeStakeBalance.current = initStakerBalances.activeStakeBalance.current.plus(amount); + expectedStakerBalances.activeStakeBalance.next = initStakerBalances.activeStakeBalance.next.plus(amount); await this.assertBalancesAsync(expectedStakerBalances); // check zrx balance of vault const finalZrxBalanceOfVault = await this._stakingWrapper.getZrxTokenBalanceOfZrxVaultAsync(); expect(finalZrxBalanceOfVault).to.be.bignumber.equal(initZrxBalanceOfVault.plus(amount)); } - public async activateStakeAsync(amount: BigNumber, revertError?: RevertError): Promise { - // query init balances + + public async unstakeAsync(amount: BigNumber, revertError?: RevertError): Promise { + const initZrxBalanceOfVault = await this._stakingWrapper.getZrxTokenBalanceOfZrxVaultAsync(); const initStakerBalances = await this.getBalancesAsync(); - // activate stake - const txReceiptPromise = this._stakingWrapper.activateStakeAsync(this._owner, amount); + // deposit stake + const txReceiptPromise = this._stakingWrapper.unstakeAsync(this._owner, amount); if (revertError !== undefined) { await expect(txReceiptPromise).to.revertWith(revertError); return; @@ -52,89 +51,181 @@ export class StakerActor extends BaseActor { // @TODO check receipt logs and return value via eth_call // check balances const expectedStakerBalances = initStakerBalances; + expectedStakerBalances.zrxBalance = initStakerBalances.zrxBalance.plus(amount); + expectedStakerBalances.stakeBalanceInVault = initStakerBalances.stakeBalanceInVault.minus(amount); + expectedStakerBalances.inactiveStakeBalance.next = initStakerBalances.inactiveStakeBalance.next.minus(amount); + expectedStakerBalances.inactiveStakeBalance.current = initStakerBalances.inactiveStakeBalance.current.minus( + amount, + ); expectedStakerBalances.withdrawableStakeBalance = initStakerBalances.withdrawableStakeBalance.minus(amount); - expectedStakerBalances.activatableStakeBalance = initStakerBalances.activatableStakeBalance.minus(amount); - expectedStakerBalances.activatedStakeBalance = initStakerBalances.activatedStakeBalance.plus(amount); - expectedStakerBalances.deactivatedStakeBalance = initStakerBalances.deactivatedStakeBalance.minus(amount); await this.assertBalancesAsync(expectedStakerBalances); + // check zrx balance of vault + const finalZrxBalanceOfVault = await this._stakingWrapper.getZrxTokenBalanceOfZrxVaultAsync(); + expect(finalZrxBalanceOfVault).to.be.bignumber.equal(initZrxBalanceOfVault.minus(amount)); } - public async deactivateAndTimeLockStakeAsync(amount: BigNumber, revertError?: RevertError): Promise { - // query init balances + + public async moveStakeAsync( + from: StakeStateInfo, + to: StakeStateInfo, + amount: BigNumber, + revertError?: RevertError, + ): Promise { + // check if we're moving stake into a new pool + if (to.state === StakeState.Delegated && to.poolId !== undefined && !_.includes(this._poolIds, to.poolId)) { + this._poolIds.push(to.poolId); + } + // cache balances + const initZrxBalanceOfVault = await this._stakingWrapper.getZrxTokenBalanceOfZrxVaultAsync(); const initStakerBalances = await this.getBalancesAsync(); - // deactivate and timeLock stake - const txReceiptPromise = this._stakingWrapper.deactivateAndTimeLockStakeAsync(this._owner, amount); + // @TODO check receipt logs and return value via eth_call + // check balances + const expectedStakerBalances = initStakerBalances; + // from + if (from.state === StakeState.Active) { + expectedStakerBalances.activeStakeBalance.next = initStakerBalances.activeStakeBalance.next.minus(amount); + } else if (from.state === StakeState.Inactive) { + expectedStakerBalances.inactiveStakeBalance.next = initStakerBalances.inactiveStakeBalance.next.minus( + amount, + ); + if ( + expectedStakerBalances.inactiveStakeBalance.next.isLessThan( + expectedStakerBalances.withdrawableStakeBalance, + ) + ) { + expectedStakerBalances.withdrawableStakeBalance = expectedStakerBalances.inactiveStakeBalance.next; + } + } else if (from.state === StakeState.Delegated && from.poolId !== undefined) { + expectedStakerBalances.delegatedStakeBalance.next = initStakerBalances.delegatedStakeBalance.next.minus( + amount, + ); + expectedStakerBalances.delegatedStakeByPool[from.poolId].next = initStakerBalances.delegatedStakeByPool[ + from.poolId + ].next.minus(amount); + expectedStakerBalances.totalDelegatedStakeByPool[ + from.poolId + ].next = initStakerBalances.totalDelegatedStakeByPool[from.poolId].next.minus(amount); + } + // to + if (to.state === StakeState.Active) { + expectedStakerBalances.activeStakeBalance.next = initStakerBalances.activeStakeBalance.next.plus(amount); + } else if (to.state === StakeState.Inactive) { + expectedStakerBalances.inactiveStakeBalance.next = initStakerBalances.inactiveStakeBalance.next.plus( + amount, + ); + } else if (to.state === StakeState.Delegated && to.poolId !== undefined) { + expectedStakerBalances.delegatedStakeBalance.next = initStakerBalances.delegatedStakeBalance.next.plus( + amount, + ); + expectedStakerBalances.delegatedStakeByPool[to.poolId].next = initStakerBalances.delegatedStakeByPool[ + to.poolId + ].next.plus(amount); + expectedStakerBalances.totalDelegatedStakeByPool[ + to.poolId + ].next = initStakerBalances.totalDelegatedStakeByPool[to.poolId].next.plus(amount); + } + // move stake + const txReceiptPromise = this._stakingWrapper.moveStakeAsync(this._owner, from, to, amount); if (revertError !== undefined) { await expect(txReceiptPromise).to.revertWith(revertError); return; } await txReceiptPromise; - // @TODO check receipt logs and return value via eth_call // check balances - const expectedStakerBalances = initStakerBalances; - expectedStakerBalances.activatedStakeBalance = initStakerBalances.activatedStakeBalance.minus(amount); - expectedStakerBalances.timeLockedStakeBalance = initStakerBalances.timeLockedStakeBalance.plus(amount); - expectedStakerBalances.deactivatedStakeBalance = initStakerBalances.deactivatedStakeBalance.plus(amount); await this.assertBalancesAsync(expectedStakerBalances); + // check zrx balance of vault + const finalZrxBalanceOfVault = await this._stakingWrapper.getZrxTokenBalanceOfZrxVaultAsync(); + expect(finalZrxBalanceOfVault).to.be.bignumber.equal(initZrxBalanceOfVault); } - public async burnDeactivatedStakeAndWithdrawZrxAsync(amount: BigNumber, revertError?: RevertError): Promise { - // query init balances + + public async goToNextEpochAsync(): Promise { + // cache balances const initZrxBalanceOfVault = await this._stakingWrapper.getZrxTokenBalanceOfZrxVaultAsync(); const initStakerBalances = await this.getBalancesAsync(); - // withdraw stake - const txReceiptPromise = this._stakingWrapper.burnDeactivatedStakeAndWithdrawZrxAsync(this._owner, amount); - if (revertError !== undefined) { - await expect(txReceiptPromise).to.revertWith(revertError); - return; - } - await txReceiptPromise; - // @TODO check receipt logs and return value via eth_call + // go to next epoch + await this._stakingWrapper.skipToNextEpochAsync(); // check balances - const expectedStakerBalances = initStakerBalances; - expectedStakerBalances.zrxBalance = initStakerBalances.zrxBalance.plus(amount); - expectedStakerBalances.stakeBalance = initStakerBalances.stakeBalance.minus(amount); - expectedStakerBalances.stakeBalanceInVault = initStakerBalances.stakeBalanceInVault.minus(amount); - expectedStakerBalances.withdrawableStakeBalance = initStakerBalances.withdrawableStakeBalance.minus(amount); - expectedStakerBalances.activatableStakeBalance = initStakerBalances.activatableStakeBalance.minus(amount); - expectedStakerBalances.deactivatedStakeBalance = initStakerBalances.deactivatedStakeBalance.minus(amount); + const expectedStakerBalances = this.getNextEpochBalances(initStakerBalances); await this.assertBalancesAsync(expectedStakerBalances); // check zrx balance of vault const finalZrxBalanceOfVault = await this._stakingWrapper.getZrxTokenBalanceOfZrxVaultAsync(); - expect(finalZrxBalanceOfVault).to.be.bignumber.equal(initZrxBalanceOfVault.minus(amount)); + expect(finalZrxBalanceOfVault).to.be.bignumber.equal(initZrxBalanceOfVault); + } + + public getNextEpochBalances(balances: StakeBalances): StakeBalances { + const nextBalances = _.cloneDeep(balances); + nextBalances.withdrawableStakeBalance = nextBalances.inactiveStakeBalance.next.isLessThan( + nextBalances.inactiveStakeBalance.current, + ) + ? nextBalances.inactiveStakeBalance.next + : nextBalances.inactiveStakeBalance.current; + nextBalances.activeStakeBalance.current = nextBalances.activeStakeBalance.next; + nextBalances.inactiveStakeBalance.current = nextBalances.inactiveStakeBalance.next; + nextBalances.delegatedStakeBalance.current = nextBalances.delegatedStakeBalance.next; + for (const poolId of this._poolIds) { + nextBalances.delegatedStakeByPool[poolId].current = nextBalances.delegatedStakeByPool[poolId].next; + nextBalances.totalDelegatedStakeByPool[poolId].current = + nextBalances.totalDelegatedStakeByPool[poolId].next; + } + return nextBalances; } - public async getBalancesAsync(): Promise { - const stakerBalances = { + public async getBalancesAsync(): Promise { + const stakerBalances: StakeBalances = { zrxBalance: await this._stakingWrapper.getZrxTokenBalanceAsync(this._owner), stakeBalance: await this._stakingWrapper.getTotalStakeAsync(this._owner), stakeBalanceInVault: await this._stakingWrapper.getZrxVaultBalanceAsync(this._owner), withdrawableStakeBalance: await this._stakingWrapper.getWithdrawableStakeAsync(this._owner), - activatableStakeBalance: await this._stakingWrapper.getActivatableStakeAsync(this._owner), - activatedStakeBalance: await this._stakingWrapper.getActivatedStakeAsync(this._owner), - timeLockedStakeBalance: await this._stakingWrapper.getTimeLockedStakeAsync(this._owner), - deactivatedStakeBalance: await this._stakingWrapper.getDeactivatedStakeAsync(this._owner), + activeStakeBalance: await this._stakingWrapper.getActiveStakeAsync(this._owner), + inactiveStakeBalance: await this._stakingWrapper.getInactiveStakeAsync(this._owner), + delegatedStakeBalance: await this._stakingWrapper.getStakeDelegatedByOwnerAsync(this._owner), + delegatedStakeByPool: {}, + totalDelegatedStakeByPool: {}, }; + // lookup for each pool + for (const poolId of this._poolIds) { + const delegatedStakeBalanceByPool = await this._stakingWrapper.getStakeDelegatedToPoolByOwnerAsync( + poolId, + this._owner, + ); + const totalDelegatedStakeBalanceByPool = await this._stakingWrapper.getTotalStakeDelegatedToPoolAsync( + poolId, + ); + stakerBalances.delegatedStakeByPool[poolId] = delegatedStakeBalanceByPool; + stakerBalances.totalDelegatedStakeByPool[poolId] = totalDelegatedStakeBalanceByPool; + } return stakerBalances; } - public async assertBalancesAsync(expectedBalances: StakerBalances): Promise { + public async assertBalancesAsync(expectedBalances: StakeBalances): Promise { const balances = await this.getBalancesAsync(); expect(balances.zrxBalance, 'zrx balance').to.be.bignumber.equal(expectedBalances.zrxBalance); - expect(balances.stakeBalance, 'stake balance').to.be.bignumber.equal(expectedBalances.stakeBalance); expect(balances.stakeBalanceInVault, 'stake balance, recorded in vault').to.be.bignumber.equal( expectedBalances.stakeBalanceInVault, ); expect(balances.withdrawableStakeBalance, 'withdrawable stake balance').to.be.bignumber.equal( expectedBalances.withdrawableStakeBalance, ); - expect(balances.activatableStakeBalance, 'activatable stake balance').to.be.bignumber.equal( - expectedBalances.activatableStakeBalance, + expect(balances.activeStakeBalance.current, 'active stake balance (current)').to.be.bignumber.equal( + expectedBalances.activeStakeBalance.current, + ); + expect(balances.activeStakeBalance.next, 'active stake balance (next)').to.be.bignumber.equal( + expectedBalances.activeStakeBalance.next, + ); + expect(balances.inactiveStakeBalance.current, 'inactive stake balance (current)').to.be.bignumber.equal( + expectedBalances.inactiveStakeBalance.current, + ); + expect(balances.inactiveStakeBalance.next, 'inactive stake balance (next)').to.be.bignumber.equal( + expectedBalances.inactiveStakeBalance.next, + ); + expect(balances.delegatedStakeBalance.current, 'delegated stake balance (current)').to.be.bignumber.equal( + expectedBalances.delegatedStakeBalance.current, ); - expect(balances.activatedStakeBalance, 'activated stake balance').to.be.bignumber.equal( - expectedBalances.activatedStakeBalance, + expect(balances.delegatedStakeBalance.next, 'delegated stake balance (next)').to.be.bignumber.equal( + expectedBalances.delegatedStakeBalance.next, ); - expect(balances.timeLockedStakeBalance, 'timeLocked stake balance').to.be.bignumber.equal( - expectedBalances.timeLockedStakeBalance, + expect(balances.delegatedStakeByPool, 'delegated stake by pool').to.be.deep.equal( + expectedBalances.delegatedStakeByPool, ); - expect(balances.deactivatedStakeBalance, 'deactivated stake balance').to.be.bignumber.equal( - expectedBalances.deactivatedStakeBalance, + expect(balances.totalDelegatedStakeByPool, 'total delegated stake by pool').to.be.deep.equal( + expectedBalances.totalDelegatedStakeByPool, ); } public async forceBalanceSyncAsync(): Promise { @@ -142,5 +233,4 @@ export class StakerActor extends BaseActor { await this._stakingWrapper.stakeAsync(this._owner, new BigNumber(0)); await this.assertBalancesAsync(initBalances); } - */ } diff --git a/contracts/staking/test/rewards_test.ts b/contracts/staking/test/rewards_test.ts new file mode 100644 index 0000000000..7956642217 --- /dev/null +++ b/contracts/staking/test/rewards_test.ts @@ -0,0 +1,600 @@ +import { ERC20ProxyContract, ERC20Wrapper } from '@0x/contracts-asset-proxy'; +import { DummyERC20TokenContract } from '@0x/contracts-erc20'; +import { blockchainTests, describe, expect, provider, web3Wrapper } from '@0x/contracts-test-utils'; +import { BigNumber } from '@0x/utils'; +import * as _ from 'lodash'; + +import { FinalizerActor } from './actors/finalizer_actor'; +import { StakerActor } from './actors/staker_actor'; +import { StakingWrapper } from './utils/staking_wrapper'; +import { MembersByPoolId, OperatorByPoolId, StakeState } from './utils/types'; + +// tslint:disable:no-unnecessary-type-assertion +// tslint:disable:max-file-line-count +blockchainTests.resets.only('Testing Rewards', () => { + // constants + const ZRX_TOKEN_DECIMALS = new BigNumber(18); + // tokens & addresses + let accounts: string[]; + let owner: string; + let actors: string[]; + let exchangeAddress: string; + let takerAddress: string; + let zrxTokenContract: DummyERC20TokenContract; + let erc20ProxyContract: ERC20ProxyContract; + // wrappers + let stakingWrapper: StakingWrapper; + // let testWrapper: TestRewardBalancesContract; + let erc20Wrapper: ERC20Wrapper; + // test parameters + let stakers: StakerActor[]; + let poolId: string; + let poolOperator: string; + let finalizer: FinalizerActor; + // tests + before(async () => { + // create accounts + accounts = await web3Wrapper.getAvailableAddressesAsync(); + owner = accounts[0]; + exchangeAddress = accounts[1]; + takerAddress = accounts[2]; + actors = accounts.slice(3); + // deploy erƒsc20 proxy + erc20Wrapper = new ERC20Wrapper(provider, accounts, owner); + erc20ProxyContract = await erc20Wrapper.deployProxyAsync(); + // deploy zrx token + [zrxTokenContract] = await erc20Wrapper.deployDummyTokensAsync(1, ZRX_TOKEN_DECIMALS); + await erc20Wrapper.setBalancesAndAllowancesAsync(); + // deploy staking contracts + stakingWrapper = new StakingWrapper(provider, owner, erc20ProxyContract, zrxTokenContract, accounts); + await stakingWrapper.deployAndConfigureContractsAsync(); + // setup stakers + stakers = [new StakerActor(actors[0], stakingWrapper), new StakerActor(actors[1], stakingWrapper)]; + // setup pools + poolOperator = actors[2]; + poolId = await stakingWrapper.createStakingPoolAsync(poolOperator, 0); + // add operator as maker + const approvalMessage = stakingWrapper.signApprovalForStakingPool(poolId, poolOperator); + await stakingWrapper.addMakerToStakingPoolAsync(poolId, poolOperator, approvalMessage.signature, poolOperator); + // set exchange address + await stakingWrapper.addExchangeAddressAsync(exchangeAddress); + // associate operators for tracking in Finalizer + const operatorByPoolId: OperatorByPoolId = {}; + operatorByPoolId[poolId] = poolOperator; + operatorByPoolId[poolId] = poolOperator; + // associate actors with pools for tracking in Finalizer + const membersByPoolId: MembersByPoolId = {}; + membersByPoolId[poolId] = [actors[0], actors[1]]; + membersByPoolId[poolId] = [actors[0], actors[1]]; + // create Finalizer actor + finalizer = new FinalizerActor(actors[3], stakingWrapper, [poolId], operatorByPoolId, membersByPoolId); + }); + describe('Reward Simulation', () => { + interface EndBalances { + // staker 1 + stakerRewardVaultBalance_1?: BigNumber; + stakerEthVaultBalance_1?: BigNumber; + // staker 2 + stakerRewardVaultBalance_2?: BigNumber; + stakerEthVaultBalance_2?: BigNumber; + // operator + operatorRewardVaultBalance?: BigNumber; + operatorEthVaultBalance?: BigNumber; + // undivided balance in reward pool + poolRewardVaultBalance?: BigNumber; + membersRewardVaultBalance?: BigNumber; + } + const validateEndBalances = async (_expectedEndBalances: EndBalances): Promise => { + const expectedEndBalances = { + // staker 1 + stakerRewardVaultBalance_1: + _expectedEndBalances.stakerRewardVaultBalance_1 !== undefined + ? _expectedEndBalances.stakerRewardVaultBalance_1 + : ZERO, + stakerEthVaultBalance_1: + _expectedEndBalances.stakerEthVaultBalance_1 !== undefined + ? _expectedEndBalances.stakerEthVaultBalance_1 + : ZERO, + // staker 2 + stakerRewardVaultBalance_2: + _expectedEndBalances.stakerRewardVaultBalance_2 !== undefined + ? _expectedEndBalances.stakerRewardVaultBalance_2 + : ZERO, + stakerEthVaultBalance_2: + _expectedEndBalances.stakerEthVaultBalance_2 !== undefined + ? _expectedEndBalances.stakerEthVaultBalance_2 + : ZERO, + // operator + operatorRewardVaultBalance: + _expectedEndBalances.operatorRewardVaultBalance !== undefined + ? _expectedEndBalances.operatorRewardVaultBalance + : ZERO, + operatorEthVaultBalance: + _expectedEndBalances.operatorEthVaultBalance !== undefined + ? _expectedEndBalances.operatorEthVaultBalance + : ZERO, + // undivided balance in reward pool + poolRewardVaultBalance: + _expectedEndBalances.poolRewardVaultBalance !== undefined + ? _expectedEndBalances.poolRewardVaultBalance + : ZERO, + membersRewardVaultBalance: + _expectedEndBalances.membersRewardVaultBalance !== undefined + ? _expectedEndBalances.membersRewardVaultBalance + : ZERO, + }; + const finalEndBalancesAsArray = await Promise.all([ + // staker 1 + stakingWrapper.computeRewardBalanceOfStakingPoolMemberAsync(poolId, stakers[0].getOwner()), + stakingWrapper.getEthVaultContract().balanceOf.callAsync(stakers[0].getOwner()), + // staker 2 + stakingWrapper.computeRewardBalanceOfStakingPoolMemberAsync(poolId, stakers[1].getOwner()), + stakingWrapper.getEthVaultContract().balanceOf.callAsync(stakers[1].getOwner()), + // operator + stakingWrapper.rewardVaultBalanceOfOperatorAsync(poolId), + stakingWrapper.getEthVaultContract().balanceOf.callAsync(poolOperator), + // undivided balance in reward pool + stakingWrapper.rewardVaultBalanceOfAsync(poolId), + stakingWrapper.rewardVaultBalanceOfMembersAsync(poolId), + ]); + expect(finalEndBalancesAsArray[0], 'stakerRewardVaultBalance_1').to.be.bignumber.equal( + expectedEndBalances.stakerRewardVaultBalance_1, + ); + expect(finalEndBalancesAsArray[1], 'stakerEthVaultBalance_1').to.be.bignumber.equal( + expectedEndBalances.stakerEthVaultBalance_1, + ); + expect(finalEndBalancesAsArray[2], 'stakerRewardVaultBalance_2').to.be.bignumber.equal( + expectedEndBalances.stakerRewardVaultBalance_2, + ); + expect(finalEndBalancesAsArray[3], 'stakerEthVaultBalance_2').to.be.bignumber.equal( + expectedEndBalances.stakerEthVaultBalance_2, + ); + expect(finalEndBalancesAsArray[4], 'operatorRewardVaultBalance').to.be.bignumber.equal( + expectedEndBalances.operatorRewardVaultBalance, + ); + expect(finalEndBalancesAsArray[5], 'operatorEthVaultBalance').to.be.bignumber.equal( + expectedEndBalances.operatorEthVaultBalance, + ); + expect(finalEndBalancesAsArray[6], 'poolRewardVaultBalance').to.be.bignumber.equal( + expectedEndBalances.poolRewardVaultBalance, + ); + expect(finalEndBalancesAsArray[7], 'membersRewardVaultBalance').to.be.bignumber.equal( + expectedEndBalances.membersRewardVaultBalance, + ); + }; + const payProtocolFeeAndFinalize = async (_fee?: BigNumber) => { + const fee = _fee !== undefined ? _fee : ZERO; + if (!fee.eq(ZERO)) { + await stakingWrapper.payProtocolFeeAsync(poolOperator, takerAddress, fee, fee, exchangeAddress); + } + await finalizer.finalizeAsync([{ reward: fee, poolId }]); + }; + const ZERO = new BigNumber(0); + it('Reward balance should be zero in same epoch as delegation', async () => { + const amount = StakingWrapper.toBaseUnitAmount(4); + await stakers[0].stakeAsync(amount); + await stakers[0].moveStakeAsync( + { state: StakeState.Active }, + { state: StakeState.Delegated, poolId }, + amount, + ); + await payProtocolFeeAndFinalize(); + // sanit check final balances - all zero + await validateEndBalances({}); + }); + it('Operator should receive entire reward if no delegators in their pool', async () => { + const reward = StakingWrapper.toBaseUnitAmount(10); + await payProtocolFeeAndFinalize(reward); + // sanity check final balances - all zero + await validateEndBalances({ + operatorRewardVaultBalance: reward, + poolRewardVaultBalance: reward, + }); + }); + it('Operator should receive entire reward if no delegators in their pool (staker joins this epoch but is active next epoch)', async () => { + // delegate + const amount = StakingWrapper.toBaseUnitAmount(4); + await stakers[0].stakeAsync(amount); + await stakers[0].moveStakeAsync( + { state: StakeState.Active }, + { state: StakeState.Delegated, poolId }, + amount, + ); + // finalize + const reward = StakingWrapper.toBaseUnitAmount(10); + await payProtocolFeeAndFinalize(reward); + // sanity check final balances + await validateEndBalances({ + operatorRewardVaultBalance: reward, + poolRewardVaultBalance: reward, + }); + }); + it('Should give pool reward to delegator', async () => { + // delegate + const amount = StakingWrapper.toBaseUnitAmount(4); + await stakers[0].stakeAsync(amount); + await stakers[0].moveStakeAsync( + { state: StakeState.Active }, + { state: StakeState.Delegated, poolId }, + amount, + ); + // skip epoch, so staker can start earning rewards + await payProtocolFeeAndFinalize(); + // finalize + const reward = StakingWrapper.toBaseUnitAmount(10); + await payProtocolFeeAndFinalize(reward); + // sanity check final balances + await validateEndBalances({ + stakerRewardVaultBalance_1: reward, + poolRewardVaultBalance: reward, + membersRewardVaultBalance: reward, + }); + }); + it('Should split pool reward between delegators', async () => { + // first staker delegates + const stakeAmounts = [StakingWrapper.toBaseUnitAmount(4), StakingWrapper.toBaseUnitAmount(6)]; + const totalStakeAmount = StakingWrapper.toBaseUnitAmount(10); + await stakers[0].stakeAsync(stakeAmounts[0]); + await stakers[0].moveStakeAsync( + { state: StakeState.Active }, + { state: StakeState.Delegated, poolId }, + stakeAmounts[0], + ); + // second staker delegates + await stakers[1].stakeAsync(stakeAmounts[1]); + await stakers[1].moveStakeAsync( + { state: StakeState.Active }, + { state: StakeState.Delegated, poolId }, + stakeAmounts[1], + ); + // skip epoch, so staker can start earning rewards + await payProtocolFeeAndFinalize(); + // finalize + const reward = StakingWrapper.toBaseUnitAmount(10); + await payProtocolFeeAndFinalize(reward); + // sanity check final balances + await validateEndBalances({ + stakerRewardVaultBalance_1: reward.times(stakeAmounts[0]).dividedToIntegerBy(totalStakeAmount), + stakerRewardVaultBalance_2: reward.times(stakeAmounts[1]).dividedToIntegerBy(totalStakeAmount), + poolRewardVaultBalance: reward, + membersRewardVaultBalance: reward, + }); + }); + it('Should split pool reward between delegators, when they join in different epochs', async () => { + // first staker delegates (epoch 0) + const stakeAmounts = [StakingWrapper.toBaseUnitAmount(4), StakingWrapper.toBaseUnitAmount(6)]; + const totalStakeAmount = StakingWrapper.toBaseUnitAmount(10); + await stakers[0].stakeAsync(stakeAmounts[0]); + await stakers[0].moveStakeAsync( + { state: StakeState.Active }, + { state: StakeState.Delegated, poolId }, + stakeAmounts[0], + ); + // skip epoch, so staker can start earning rewards + await payProtocolFeeAndFinalize(); + // second staker delegates (epoch 1) + await stakers[1].stakeAsync(stakeAmounts[1]); + await stakers[1].moveStakeAsync( + { state: StakeState.Active }, + { state: StakeState.Delegated, poolId }, + stakeAmounts[1], + ); + // skip epoch, so staker can start earning rewards + await payProtocolFeeAndFinalize(); + // finalize + const reward = StakingWrapper.toBaseUnitAmount(10); + await payProtocolFeeAndFinalize(reward); + // sanity check final balances + await validateEndBalances({ + stakerRewardVaultBalance_1: reward.times(stakeAmounts[0]).dividedToIntegerBy(totalStakeAmount), + stakerRewardVaultBalance_2: reward.times(stakeAmounts[1]).dividedToIntegerBy(totalStakeAmount), + poolRewardVaultBalance: reward, + membersRewardVaultBalance: reward, + }); + }); + it('Should give pool reward to delegators only for the epoch during which they delegated', async () => { + // first staker delegates (epoch 0) + const stakeAmounts = [StakingWrapper.toBaseUnitAmount(4), StakingWrapper.toBaseUnitAmount(6)]; + const totalStakeAmount = StakingWrapper.toBaseUnitAmount(10); + await stakers[0].stakeAsync(stakeAmounts[0]); + await stakers[0].moveStakeAsync( + { state: StakeState.Active }, + { state: StakeState.Delegated, poolId }, + stakeAmounts[0], + ); + // skip epoch, so first staker can start earning rewards + await payProtocolFeeAndFinalize(); + // second staker delegates (epoch 1) + await stakers[1].stakeAsync(stakeAmounts[1]); + await stakers[1].moveStakeAsync( + { state: StakeState.Active }, + { state: StakeState.Delegated, poolId }, + stakeAmounts[1], + ); + // only the first staker will get this reward + const rewardForOnlyFirstDelegator = StakingWrapper.toBaseUnitAmount(10); + await payProtocolFeeAndFinalize(rewardForOnlyFirstDelegator); + // finalize + const rewardForBothDelegators = StakingWrapper.toBaseUnitAmount(20); + await payProtocolFeeAndFinalize(rewardForBothDelegators); + // sanity check final balances + await validateEndBalances({ + stakerRewardVaultBalance_1: rewardForOnlyFirstDelegator.plus( + rewardForBothDelegators.times(stakeAmounts[0]).dividedToIntegerBy(totalStakeAmount), + ), + stakerRewardVaultBalance_2: rewardForBothDelegators + .times(stakeAmounts[1]) + .dividedToIntegerBy(totalStakeAmount), + poolRewardVaultBalance: rewardForOnlyFirstDelegator.plus(rewardForBothDelegators), + membersRewardVaultBalance: rewardForOnlyFirstDelegator.plus(rewardForBothDelegators), + }); + }); + it('Should split pool reward between delegators, over several consecutive epochs', async () => { + const rewardForOnlyFirstDelegator = StakingWrapper.toBaseUnitAmount(10); + const sharedRewards = [ + StakingWrapper.toBaseUnitAmount(20), + StakingWrapper.toBaseUnitAmount(16), + StakingWrapper.toBaseUnitAmount(24), + StakingWrapper.toBaseUnitAmount(5), + StakingWrapper.toBaseUnitAmount(0), + StakingWrapper.toBaseUnitAmount(17), + ]; + const totalSharedRewardsAsNumber = _.sumBy(sharedRewards, v => { + return v.toNumber(); + }); + const totalSharedRewards = new BigNumber(totalSharedRewardsAsNumber); + // first staker delegates (epoch 0) + const stakeAmounts = [StakingWrapper.toBaseUnitAmount(4), StakingWrapper.toBaseUnitAmount(6)]; + const totalStakeAmount = StakingWrapper.toBaseUnitAmount(10); + await stakers[0].stakeAsync(stakeAmounts[0]); + await stakers[0].moveStakeAsync( + { state: StakeState.Active }, + { state: StakeState.Delegated, poolId }, + stakeAmounts[0], + ); + // skip epoch, so first staker can start earning rewards + await payProtocolFeeAndFinalize(); + // second staker delegates (epoch 1) + await stakers[1].stakeAsync(stakeAmounts[1]); + await stakers[1].moveStakeAsync( + { state: StakeState.Active }, + { state: StakeState.Delegated, poolId }, + stakeAmounts[1], + ); + // only the first staker will get this reward + await payProtocolFeeAndFinalize(rewardForOnlyFirstDelegator); + // earn a bunch of rewards + for (const reward of sharedRewards) { + await payProtocolFeeAndFinalize(reward); + } + // sanity check final balances + await validateEndBalances({ + stakerRewardVaultBalance_1: rewardForOnlyFirstDelegator.plus( + totalSharedRewards.times(stakeAmounts[0]).dividedToIntegerBy(totalStakeAmount), + ), + stakerRewardVaultBalance_2: totalSharedRewards + .times(stakeAmounts[1]) + .dividedToIntegerBy(totalStakeAmount), + poolRewardVaultBalance: rewardForOnlyFirstDelegator.plus(totalSharedRewards), + membersRewardVaultBalance: rewardForOnlyFirstDelegator.plus(totalSharedRewards), + }); + }); + it('Should send existing rewards from reward vault to eth vault correctly when undelegating stake', async () => { + // first staker delegates (epoch 0) + const stakeAmount = StakingWrapper.toBaseUnitAmount(4); + await stakers[0].stakeAsync(stakeAmount); + await stakers[0].moveStakeAsync( + { state: StakeState.Active }, + { state: StakeState.Delegated, poolId }, + stakeAmount, + ); + // skip epoch, so first staker can start earning rewards + await payProtocolFeeAndFinalize(); + // earn reward + const reward = StakingWrapper.toBaseUnitAmount(10); + await payProtocolFeeAndFinalize(reward); + // undelegate (moves delegator's from the transient reward vault into the eth vault) + await stakers[0].moveStakeAsync( + { state: StakeState.Delegated, poolId }, + { state: StakeState.Active }, + stakeAmount, + ); + // sanity check final balances + await validateEndBalances({ + stakerRewardVaultBalance_1: ZERO, + stakerEthVaultBalance_1: reward, + }); + }); + it('Should send existing rewards from reward vault to eth vault correctly when delegating more stake', async () => { + // first staker delegates (epoch 0) + const stakeAmount = StakingWrapper.toBaseUnitAmount(4); + await stakers[0].stakeAsync(stakeAmount); + await stakers[0].moveStakeAsync( + { state: StakeState.Active }, + { state: StakeState.Delegated, poolId }, + stakeAmount, + ); + // skip epoch, so first staker can start earning rewards + await payProtocolFeeAndFinalize(); + // earn reward + const reward = StakingWrapper.toBaseUnitAmount(10); + await payProtocolFeeAndFinalize(reward); + // add more stake + await stakers[0].stakeAsync(stakeAmount); + await stakers[0].moveStakeAsync( + { state: StakeState.Active }, + { state: StakeState.Delegated, poolId }, + stakeAmount, + ); + // sanity check final balances + await validateEndBalances({ + stakerRewardVaultBalance_1: ZERO, + stakerEthVaultBalance_1: reward, + }); + }); + it('Should continue earning rewards after adding more stake and progressing several epochs', async () => { + const rewardBeforeAddingMoreStake = StakingWrapper.toBaseUnitAmount(10); + const rewardsAfterAddingMoreStake = [ + StakingWrapper.toBaseUnitAmount(20), + StakingWrapper.toBaseUnitAmount(16), + StakingWrapper.toBaseUnitAmount(24), + StakingWrapper.toBaseUnitAmount(5), + StakingWrapper.toBaseUnitAmount(0), + StakingWrapper.toBaseUnitAmount(17), + ]; + const totalRewardsAfterAddingMoreStake = new BigNumber( + _.sumBy(rewardsAfterAddingMoreStake, v => { + return v.toNumber(); + }), + ); + // first staker delegates (epoch 0) + const stakeAmounts = [StakingWrapper.toBaseUnitAmount(4), StakingWrapper.toBaseUnitAmount(6)]; + await stakers[0].stakeAsync(stakeAmounts[0]); + await stakers[0].moveStakeAsync( + { state: StakeState.Active }, + { state: StakeState.Delegated, poolId }, + stakeAmounts[0], + ); + // skip epoch, so first staker can start earning rewards + await payProtocolFeeAndFinalize(); + // second staker delegates (epoch 1) + await stakers[0].stakeAsync(stakeAmounts[1]); + await stakers[0].moveStakeAsync( + { state: StakeState.Active }, + { state: StakeState.Delegated, poolId }, + stakeAmounts[1], + ); + // only the first staker will get this reward + await payProtocolFeeAndFinalize(rewardBeforeAddingMoreStake); + // earn a bunch of rewards + for (const reward of rewardsAfterAddingMoreStake) { + await payProtocolFeeAndFinalize(reward); + } + // sanity check final balances + await validateEndBalances({ + stakerRewardVaultBalance_1: rewardBeforeAddingMoreStake.plus(totalRewardsAfterAddingMoreStake), + poolRewardVaultBalance: rewardBeforeAddingMoreStake.plus(totalRewardsAfterAddingMoreStake), + membersRewardVaultBalance: rewardBeforeAddingMoreStake.plus(totalRewardsAfterAddingMoreStake), + }); + }); + it('Should stop collecting rewards after undelegating', async () => { + // first staker delegates (epoch 0) + const rewardForDelegator = StakingWrapper.toBaseUnitAmount(10); + const rewardNotForDelegator = StakingWrapper.toBaseUnitAmount(7); + const stakeAmount = StakingWrapper.toBaseUnitAmount(4); + await stakers[0].stakeAsync(stakeAmount); + await stakers[0].moveStakeAsync( + { state: StakeState.Active }, + { state: StakeState.Delegated, poolId }, + stakeAmount, + ); + // skip epoch, so first staker can start earning rewards + await payProtocolFeeAndFinalize(); + // earn reward + await payProtocolFeeAndFinalize(rewardForDelegator); + // undelegate stake and finalize epoch + await stakers[0].moveStakeAsync( + { state: StakeState.Delegated, poolId }, + { state: StakeState.Active }, + stakeAmount, + ); + await payProtocolFeeAndFinalize(); + // this should not go do the delegator + await payProtocolFeeAndFinalize(rewardNotForDelegator); + // sanity check final balances + await validateEndBalances({ + stakerEthVaultBalance_1: rewardForDelegator, + poolRewardVaultBalance: rewardNotForDelegator, + operatorRewardVaultBalance: rewardNotForDelegator, + }); + }); + it('Should stop collecting rewards after undelegating, after several epochs', async () => { + // first staker delegates (epoch 0) + const rewardForDelegator = StakingWrapper.toBaseUnitAmount(10); + const rewardsNotForDelegator = [ + StakingWrapper.toBaseUnitAmount(20), + StakingWrapper.toBaseUnitAmount(16), + StakingWrapper.toBaseUnitAmount(24), + StakingWrapper.toBaseUnitAmount(5), + StakingWrapper.toBaseUnitAmount(0), + StakingWrapper.toBaseUnitAmount(17), + ]; + const totalRewardsNotForDelegator = new BigNumber( + _.sumBy(rewardsNotForDelegator, v => { + return v.toNumber(); + }), + ); + const stakeAmount = StakingWrapper.toBaseUnitAmount(4); + await stakers[0].stakeAsync(stakeAmount); + await stakers[0].moveStakeAsync( + { state: StakeState.Active }, + { state: StakeState.Delegated, poolId }, + stakeAmount, + ); + // skip epoch, so first staker can start earning rewards + await payProtocolFeeAndFinalize(); + // earn reward + await payProtocolFeeAndFinalize(rewardForDelegator); + // undelegate stake and finalize epoch + await stakers[0].moveStakeAsync( + { state: StakeState.Delegated, poolId }, + { state: StakeState.Active }, + stakeAmount, + ); + await payProtocolFeeAndFinalize(); + // this should not go do the delegator + for (const reward of rewardsNotForDelegator) { + await payProtocolFeeAndFinalize(reward); + } + // sanity check final balances + await validateEndBalances({ + stakerEthVaultBalance_1: rewardForDelegator, + poolRewardVaultBalance: totalRewardsNotForDelegator, + operatorRewardVaultBalance: totalRewardsNotForDelegator, + }); + }); + it('Should collect fees correctly when leaving and returning to a pool', async () => { + // first staker delegates (epoch 0) + const rewardsForDelegator = [StakingWrapper.toBaseUnitAmount(10), StakingWrapper.toBaseUnitAmount(15)]; + const rewardNotForDelegator = StakingWrapper.toBaseUnitAmount(7); + const stakeAmount = StakingWrapper.toBaseUnitAmount(4); + await stakers[0].stakeAsync(stakeAmount); + await stakers[0].moveStakeAsync( + { state: StakeState.Active }, + { state: StakeState.Delegated, poolId }, + stakeAmount, + ); + // skip epoch, so first staker can start earning rewards + await payProtocolFeeAndFinalize(); + // earn reward + await payProtocolFeeAndFinalize(rewardsForDelegator[0]); + // undelegate stake and finalize epoch + await stakers[0].moveStakeAsync( + { state: StakeState.Delegated, poolId }, + { state: StakeState.Active }, + stakeAmount, + ); + await payProtocolFeeAndFinalize(); + // this should not go do the delegator + await payProtocolFeeAndFinalize(rewardNotForDelegator); + // delegate stake and go to next epoch + await stakers[0].moveStakeAsync( + { state: StakeState.Active }, + { state: StakeState.Delegated, poolId }, + stakeAmount, + ); + await payProtocolFeeAndFinalize(); + // this reward should go to delegator + await payProtocolFeeAndFinalize(rewardsForDelegator[1]); + // sanity check final balances + await validateEndBalances({ + stakerRewardVaultBalance_1: rewardsForDelegator[1], + stakerEthVaultBalance_1: rewardsForDelegator[0], + operatorRewardVaultBalance: rewardNotForDelegator, + poolRewardVaultBalance: rewardNotForDelegator.plus(rewardsForDelegator[1]), + membersRewardVaultBalance: rewardsForDelegator[1], + }); + }); + }); +}); +// tslint:enable:no-unnecessary-type-assertion diff --git a/contracts/staking/test/simulations_test.ts b/contracts/staking/test/simulations_test.ts index 16620b9c83..383f46e912 100644 --- a/contracts/staking/test/simulations_test.ts +++ b/contracts/staking/test/simulations_test.ts @@ -1,3 +1,6 @@ +/* +@TODO (hysz) - update once new staking mechanics are merged + import { ERC20ProxyContract, ERC20Wrapper } from '@0x/contracts-asset-proxy'; import { DummyERC20TokenContract } from '@0x/contracts-erc20'; import { blockchainTests, expect } from '@0x/contracts-test-utils'; @@ -9,7 +12,6 @@ import { Simulation } from './utils/Simulation'; import { StakingWrapper } from './utils/staking_wrapper'; // tslint:disable:no-unnecessary-type-assertion blockchainTests('End-To-End Simulations', env => { - /* // constants const ZRX_TOKEN_DECIMALS = new BigNumber(18); // tokens & addresses @@ -340,6 +342,6 @@ blockchainTests('End-To-End Simulations', env => { await expect(tx).to.revertWith(revertError); }); }); - */ }); // tslint:enable:no-unnecessary-type-assertion +*/ diff --git a/contracts/staking/test/stake_test.ts b/contracts/staking/test/stake_test.ts index b6d7208ada..96ea553f6a 100644 --- a/contracts/staking/test/stake_test.ts +++ b/contracts/staking/test/stake_test.ts @@ -1,47 +1,357 @@ import { ERC20ProxyContract, ERC20Wrapper } from '@0x/contracts-asset-proxy'; import { DummyERC20TokenContract } from '@0x/contracts-erc20'; -import { blockchainTests } from '@0x/contracts-test-utils'; -import { StakingRevertErrors } from '@0x/order-utils'; -import { BigNumber } from '@0x/utils'; +import { blockchainTests, describe, provider, web3Wrapper } from '@0x/contracts-test-utils'; +import { BigNumber, StringRevertError } from '@0x/utils'; import * as _ from 'lodash'; -import { DelegatorActor } from './actors/delegator_actor'; import { StakerActor } from './actors/staker_actor'; import { StakingWrapper } from './utils/staking_wrapper'; +import { StakeState, StakeStateInfo } from './utils/types'; // tslint:disable:no-unnecessary-type-assertion -blockchainTests('Staking & Delegating', env => { +blockchainTests.resets.only('Stake States', () => { // constants const ZRX_TOKEN_DECIMALS = new BigNumber(18); + const ZERO = new BigNumber(0); // tokens & addresses let accounts: string[]; let owner: string; - let stakers: string[]; + let actors: string[]; let zrxTokenContract: DummyERC20TokenContract; let erc20ProxyContract: ERC20ProxyContract; // wrappers let stakingWrapper: StakingWrapper; let erc20Wrapper: ERC20Wrapper; + // stake actor + let staker: StakerActor; + let poolIds: string[]; + let poolOperator: string; // tests before(async () => { // create accounts - accounts = await env.web3Wrapper.getAvailableAddressesAsync(); + accounts = await web3Wrapper.getAvailableAddressesAsync(); owner = accounts[0]; - stakers = accounts.slice(2, 5); + actors = accounts.slice(2, 5); // deploy erc20 proxy - erc20Wrapper = new ERC20Wrapper(env.provider, accounts, owner); + erc20Wrapper = new ERC20Wrapper(provider, accounts, owner); erc20ProxyContract = await erc20Wrapper.deployProxyAsync(); // deploy zrx token [zrxTokenContract] = await erc20Wrapper.deployDummyTokensAsync(1, ZRX_TOKEN_DECIMALS); await erc20Wrapper.setBalancesAndAllowancesAsync(); // deploy staking contracts - stakingWrapper = new StakingWrapper(env.provider, owner, erc20ProxyContract, zrxTokenContract, accounts); + stakingWrapper = new StakingWrapper(provider, owner, erc20ProxyContract, zrxTokenContract, accounts); await stakingWrapper.deployAndConfigureContractsAsync(); + // setup new staker + staker = new StakerActor(actors[0], stakingWrapper); + // setup pools + poolOperator = actors[1]; + poolIds = await Promise.all([ + await stakingWrapper.createStakingPoolAsync(poolOperator, 4), + await stakingWrapper.createStakingPoolAsync(poolOperator, 5), + ]); }); - blockchainTests.resets('Staking', () => { + describe('Stake', () => { + it('should successfully stake zero ZRX', async () => { + const amount = StakingWrapper.toBaseUnitAmount(0); + await staker.stakeAsync(amount); + }); + it('should successfully stake non-zero ZRX', async () => { + const amount = StakingWrapper.toBaseUnitAmount(10); + await staker.stakeAsync(amount); + }); + it('should retain stake balance across 1 epoch', async () => { + const amount = StakingWrapper.toBaseUnitAmount(10); + await staker.stakeAsync(amount); + await staker.goToNextEpochAsync(); + }); + it('should retain stake balance across 2 epochs', async () => { + const amount = StakingWrapper.toBaseUnitAmount(10); + await staker.stakeAsync(amount); + await staker.goToNextEpochAsync(); + await staker.goToNextEpochAsync(); + }); }); - - blockchainTests.resets('Delegating', () => { + describe('Move Stake', () => { + it("should be able to rebalance next epoch's stake", async () => { + // epoch 1 + const amount = StakingWrapper.toBaseUnitAmount(10); + await staker.stakeAsync(amount); + await staker.moveStakeAsync({ state: StakeState.Active }, { state: StakeState.Inactive }, amount); + // still epoch 1 ~ should be able to move stake again + await staker.moveStakeAsync( + { state: StakeState.Inactive }, + { state: StakeState.Delegated, poolId: poolIds[0] }, + amount, + ); + }); + it('should fail to move the same stake more than once', async () => { + // epoch 1 + const amount = StakingWrapper.toBaseUnitAmount(10); + await staker.stakeAsync(amount); + await staker.moveStakeAsync({ state: StakeState.Active }, { state: StakeState.Inactive }, amount); + // stake is now inactive, should not be able to move it out of active state again + await staker.moveStakeAsync( + { state: StakeState.Active }, + { state: StakeState.Inactive }, + amount, + new StringRevertError('Insufficient Balance'), + ); + }); + it('should fail to reassign stake', async () => { + // epoch 1 + const amount = StakingWrapper.toBaseUnitAmount(10); + await staker.stakeAsync(amount); + await staker.moveStakeAsync({ state: StakeState.Active }, { state: StakeState.Inactive }, amount); + // still epoch 1 ~ should be able to move stake again + await staker.moveStakeAsync( + { state: StakeState.Inactive }, + { state: StakeState.Delegated, poolId: poolIds[0] }, + amount, + ); + // stake is now delegated; should fail to re-assign it from inactive back to active + await staker.moveStakeAsync( + { state: StakeState.Inactive }, + { state: StakeState.Active }, + amount, + new StringRevertError('Insufficient Balance'), + ); + }); + }); + describe('Move Zero Stake', () => { + it('active -> active', async () => { + await staker.moveStakeAsync({ state: StakeState.Active }, { state: StakeState.Active }, ZERO); + }); + it('active -> inactive', async () => { + await staker.moveStakeAsync({ state: StakeState.Active }, { state: StakeState.Inactive }, ZERO); + }); + it('active -> delegated', async () => { + await staker.moveStakeAsync( + { state: StakeState.Active }, + { state: StakeState.Delegated, poolId: poolIds[0] }, + ZERO, + ); + }); + it('inactive -> active', async () => { + await staker.moveStakeAsync({ state: StakeState.Inactive }, { state: StakeState.Active }, ZERO); + }); + it('inactive -> inactive', async () => { + await staker.moveStakeAsync({ state: StakeState.Inactive }, { state: StakeState.Inactive }, ZERO); + }); + it('inactive -> delegated', async () => { + await staker.moveStakeAsync( + { state: StakeState.Inactive }, + { state: StakeState.Delegated, poolId: poolIds[0] }, + ZERO, + ); + }); + it('delegated -> active', async () => { + await staker.moveStakeAsync( + { state: StakeState.Delegated, poolId: poolIds[0] }, + { state: StakeState.Active }, + ZERO, + ); + }); + it('delegated -> inactive', async () => { + await staker.moveStakeAsync( + { state: StakeState.Delegated, poolId: poolIds[0] }, + { state: StakeState.Inactive }, + ZERO, + ); + }); + it('delegated -> delegated (same pool)', async () => { + await staker.moveStakeAsync( + { state: StakeState.Delegated, poolId: poolIds[0] }, + { state: StakeState.Delegated, poolId: poolIds[0] }, + ZERO, + ); + }); + it('delegated -> delegated (other pool)', async () => { + await staker.moveStakeAsync( + { state: StakeState.Delegated, poolId: poolIds[0] }, + { state: StakeState.Delegated, poolId: poolIds[1] }, + ZERO, + ); + }); + }); + describe('Move Non-Zero Stake', () => { + const testMovePartialStake = async (from: StakeStateInfo, to: StakeStateInfo) => { + // setup + const amount = StakingWrapper.toBaseUnitAmount(10); + await staker.stakeAsync(amount); + if (from.state !== StakeState.Active) { + await staker.moveStakeAsync({ state: StakeState.Active }, from, amount); + } + // run test, checking balances in epochs [n .. n + 2] + // in epoch `n` - `next` is set + // in epoch `n+1` - `current` is set + // in epoch `n+2` - only withdrawable balance should change. + await staker.moveStakeAsync(from, to, amount.div(2)); + await staker.goToNextEpochAsync(); + await staker.goToNextEpochAsync(); + }; + it('active -> active', async () => { + await testMovePartialStake({ state: StakeState.Active }, { state: StakeState.Active }); + }); + it('active -> inactive', async () => { + await testMovePartialStake({ state: StakeState.Active }, { state: StakeState.Inactive }); + }); + it('active -> delegated', async () => { + await testMovePartialStake( + { state: StakeState.Active }, + { state: StakeState.Delegated, poolId: poolIds[0] }, + ); + }); + it('inactive -> active', async () => { + await testMovePartialStake({ state: StakeState.Inactive }, { state: StakeState.Active }); + }); + it('inactive -> inactive', async () => { + await testMovePartialStake({ state: StakeState.Inactive }, { state: StakeState.Inactive }); + }); + it('inactive -> delegated', async () => { + await testMovePartialStake( + { state: StakeState.Inactive }, + { state: StakeState.Delegated, poolId: poolIds[0] }, + ); + }); + it('delegated -> active', async () => { + await testMovePartialStake( + { state: StakeState.Delegated, poolId: poolIds[0] }, + { state: StakeState.Active }, + ); + }); + it('delegated -> inactive', async () => { + await testMovePartialStake( + { state: StakeState.Delegated, poolId: poolIds[0] }, + { state: StakeState.Inactive }, + ); + }); + it('delegated -> delegated (same pool)', async () => { + await testMovePartialStake( + { state: StakeState.Delegated, poolId: poolIds[0] }, + { state: StakeState.Delegated, poolId: poolIds[0] }, + ); + }); + it('delegated -> delegated (other pool)', async () => { + await testMovePartialStake( + { state: StakeState.Delegated, poolId: poolIds[0] }, + { state: StakeState.Delegated, poolId: poolIds[1] }, + ); + }); + }); + describe('Unstake', () => { + it('should successfully unstake zero ZRX', async () => { + const amount = StakingWrapper.toBaseUnitAmount(0); + await staker.unstakeAsync(amount); + }); + it('should successfully unstake after being inactive for 1 epoch', async () => { + const amount = StakingWrapper.toBaseUnitAmount(10); + await staker.stakeAsync(amount); + await staker.moveStakeAsync({ state: StakeState.Active }, { state: StakeState.Inactive }, amount); + await staker.goToNextEpochAsync(); // stake is now inactive + await staker.goToNextEpochAsync(); // stake is now withdrawable + await staker.unstakeAsync(amount); + }); + it('should fail to unstake with insufficient balance', async () => { + const amount = StakingWrapper.toBaseUnitAmount(10); + await staker.stakeAsync(amount); + await staker.unstakeAsync(amount, new StringRevertError('INSUFFICIENT_FUNDS')); + }); + it('should fail to unstake in the same epoch as stake was set to inactive', async () => { + const amount = StakingWrapper.toBaseUnitAmount(10); + await staker.stakeAsync(amount); + await staker.moveStakeAsync({ state: StakeState.Active }, { state: StakeState.Inactive }, amount); + await staker.unstakeAsync(amount, new StringRevertError('INSUFFICIENT_FUNDS')); + }); + it('should fail to unstake after being inactive for <1 epoch', async () => { + const amount = StakingWrapper.toBaseUnitAmount(10); + await staker.stakeAsync(amount); + await staker.moveStakeAsync({ state: StakeState.Active }, { state: StakeState.Inactive }, amount); + await staker.goToNextEpochAsync(); + await staker.unstakeAsync(amount, new StringRevertError('INSUFFICIENT_FUNDS')); + }); + it('should fail to unstake in same epoch that inactive/withdrawable stake has been reactivated', async () => { + const amount = StakingWrapper.toBaseUnitAmount(10); + await staker.stakeAsync(amount); + await staker.moveStakeAsync({ state: StakeState.Active }, { state: StakeState.Inactive }, amount); + await staker.goToNextEpochAsync(); // stake is now inactive + await staker.goToNextEpochAsync(); // stake is now withdrawable + await staker.moveStakeAsync({ state: StakeState.Inactive }, { state: StakeState.Active }, amount); + await staker.unstakeAsync(amount, new StringRevertError('INSUFFICIENT_FUNDS')); + }); + it('should fail to unstake one epoch after inactive/withdrawable stake has been reactivated', async () => { + const amount = StakingWrapper.toBaseUnitAmount(10); + await staker.stakeAsync(amount); + await staker.moveStakeAsync({ state: StakeState.Active }, { state: StakeState.Inactive }, amount); + await staker.goToNextEpochAsync(); // stake is now inactive + await staker.goToNextEpochAsync(); // stake is now withdrawable + await staker.moveStakeAsync({ state: StakeState.Inactive }, { state: StakeState.Active }, amount); + await staker.goToNextEpochAsync(); // stake is active and not withdrawable + await staker.unstakeAsync(amount, new StringRevertError('INSUFFICIENT_FUNDS')); + }); + it('should fail to unstake >1 epoch after inactive/withdrawable stake has been reactivated', async () => { + const amount = StakingWrapper.toBaseUnitAmount(10); + await staker.stakeAsync(amount); + await staker.moveStakeAsync({ state: StakeState.Active }, { state: StakeState.Inactive }, amount); + await staker.goToNextEpochAsync(); // stake is now inactive + await staker.goToNextEpochAsync(); // stake is now withdrawable + await staker.moveStakeAsync({ state: StakeState.Inactive }, { state: StakeState.Active }, amount); + await staker.goToNextEpochAsync(); // stake is active and not withdrawable + await staker.goToNextEpochAsync(); // stake is active and not withdrawable + await staker.unstakeAsync(amount, new StringRevertError('INSUFFICIENT_FUNDS')); + }); + }); + describe('Simulations', () => { + it('Simulation from Staking Spec', async () => { + // Epoch 1: Stake some ZRX + await staker.stakeAsync(StakingWrapper.toBaseUnitAmount(4)); + // Later in Epoch 1: User delegates and deactivates some stake + await staker.moveStakeAsync( + { state: StakeState.Active }, + { state: StakeState.Inactive }, + StakingWrapper.toBaseUnitAmount(1), + ); + await staker.moveStakeAsync( + { state: StakeState.Active }, + { state: StakeState.Delegated, poolId: poolIds[0] }, + StakingWrapper.toBaseUnitAmount(2), + ); + // Epoch 2: State updates (no user intervention required) + await staker.goToNextEpochAsync(); + // Epoch 3: Stake that has been inactive for an epoch can be withdrawn (no user intervention required) + await staker.goToNextEpochAsync(); + // Later in Epoch 3: User reactivates half of their inactive stake; this becomes Active next epoch + await staker.moveStakeAsync( + { state: StakeState.Inactive }, + { state: StakeState.Active }, + StakingWrapper.toBaseUnitAmount(0.5), + ); + // Later in Epoch 3: User re-delegates half of their stake from Pool 1 to Pool 2 + await staker.moveStakeAsync( + { state: StakeState.Delegated, poolId: poolIds[0] }, + { state: StakeState.Delegated, poolId: poolIds[1] }, + StakingWrapper.toBaseUnitAmount(1), + ); + // Epoch 4: State updates (no user intervention required) + await staker.goToNextEpochAsync(); + // Later in Epoch 4: User deactivates all active stake + await staker.moveStakeAsync( + { state: StakeState.Active }, + { state: StakeState.Inactive }, + StakingWrapper.toBaseUnitAmount(1.5), + ); + // Later in Epoch 4: User withdraws all available inactive stake + await staker.unstakeAsync(StakingWrapper.toBaseUnitAmount(0.5)); + // Epoch 5: State updates (no user intervention required) + await staker.goToNextEpochAsync(); + // Later in Epoch 5: User reactivates a portion of their stake + await staker.moveStakeAsync( + { state: StakeState.Inactive }, + { state: StakeState.Active }, + StakingWrapper.toBaseUnitAmount(1), + ); + // Epoch 6: State updates (no user intervention required) + await staker.goToNextEpochAsync(); + }); }); }); // tslint:enable:no-unnecessary-type-assertion diff --git a/contracts/staking/test/utils/Simulation.ts b/contracts/staking/test/utils/Simulation.ts index b9e4c59148..79b90e8e2d 100644 --- a/contracts/staking/test/utils/Simulation.ts +++ b/contracts/staking/test/utils/Simulation.ts @@ -1,9 +1,11 @@ +/* +@TODO (hysz) - update once new staking mechanics are merged + import { chaiSetup } from '@0x/contracts-test-utils'; import { BigNumber } from '@0x/utils'; import * as chai from 'chai'; import * as _ from 'lodash'; -import { DelegatorActor } from '../actors/delegator_actor'; import { MakerActor } from '../actors/maker_actor'; import { PoolOperatorActor } from '../actors/pool_operator_actor'; @@ -15,9 +17,6 @@ chaiSetup.configure(); const expect = chai.expect; export class Simulation { - /* - - private readonly _stakingWrapper: StakingWrapper; private readonly _p: SimulationParams; private _userQueue: Queue; @@ -282,5 +281,5 @@ export class Simulation { ); } } - */ } +*/ diff --git a/contracts/staking/test/utils/staking_wrapper.ts b/contracts/staking/test/utils/staking_wrapper.ts index 7d3b3c2375..1e902d194f 100644 --- a/contracts/staking/test/utils/staking_wrapper.ts +++ b/contracts/staking/test/utils/staking_wrapper.ts @@ -1,4 +1,3 @@ -import { BaseContract } from '@0x/base-contract'; import { ERC20ProxyContract } from '@0x/contracts-asset-proxy'; import { artifacts as erc20Artifacts, DummyERC20TokenContract } from '@0x/contracts-erc20'; import { constants as testUtilsConstants, LogDecoder, txDefaults } from '@0x/contracts-test-utils'; @@ -10,12 +9,12 @@ import * as _ from 'lodash'; import { artifacts, + EthVaultContract, LibFeeMathTestContract, StakingContract, StakingPoolRewardVaultContract, StakingProxyContract, ZrxVaultContract, - EthVaultContract, } from '../../src'; import { ApprovalFactory } from './approval_factory'; @@ -63,7 +62,6 @@ export class StakingWrapper { .dividedBy(scalar); return amountAsFloatingPoint; } - constructor( provider: Provider, ownerAddres: string, @@ -129,7 +127,9 @@ export class StakingWrapper { artifacts, ); // set eth vault in reward vault - await this._rewardVaultContractIfExists.setEthVault.sendTransactionAsync(this._ethVaultContractIfExists.address); + await this._rewardVaultContractIfExists.setEthVault.sendTransactionAsync( + this._ethVaultContractIfExists.address, + ); // configure erc20 proxy to accept calls from zrx vault await this._erc20ProxyContract.addAuthorizedAddress.awaitTransactionSuccessAsync( this._zrxVaultContractIfExists.address, @@ -194,23 +194,13 @@ export class StakingWrapper { return balance; } ///// STAKE ///// - public async stakeAsync( - owner: string, - amount: BigNumber, - ): Promise { - const calldata = this.getStakingContract().stake.getABIEncodedTransactionData( - amount, - ); + public async stakeAsync(owner: string, amount: BigNumber): Promise { + const calldata = this.getStakingContract().stake.getABIEncodedTransactionData(amount); const txReceipt = await this._executeTransactionAsync(calldata, owner); return txReceipt; } - public async unstakeAsync( - owner: string, - amount: BigNumber, - ): Promise { - const calldata = this.getStakingContract().unstake.getABIEncodedTransactionData( - amount, - ); + public async unstakeAsync(owner: string, amount: BigNumber): Promise { + const calldata = this.getStakingContract().unstake.getABIEncodedTransactionData(amount); const txReceipt = await this._executeTransactionAsync(calldata, owner); return txReceipt; } @@ -390,10 +380,10 @@ export class StakingWrapper { return txReceipt; } public async fastForwardToNextEpochAsync(): Promise { - // increase timestamp of next block - const epochDurationInSeconds = await this.getEpochDurationInSecondsAsync(); - await this._web3Wrapper.increaseTimeAsync(epochDurationInSeconds.toNumber()); - // mine next block + // increase timestamp of next block + const epochDurationInSeconds = await this.getEpochDurationInSecondsAsync(); + await this._web3Wrapper.increaseTimeAsync(epochDurationInSeconds.toNumber()); + // mine next block await this._web3Wrapper.mineBlockAsync(); } public async skipToNextEpochAsync(): Promise { @@ -490,9 +480,7 @@ export class StakingWrapper { owner, ); const returnData = await this._callAsync(calldata); - const value = this.getStakingContract().computeRewardBalanceOfDelegator.getABIDecodedReturnData( - returnData, - ); + const value = this.getStakingContract().computeRewardBalanceOfDelegator.getABIDecodedReturnData(returnData); return value; } ///// REWARD VAULT ///// diff --git a/contracts/staking/test/utils/types.ts b/contracts/staking/test/utils/types.ts index afe3526617..0f585c2c93 100644 --- a/contracts/staking/test/utils/types.ts +++ b/contracts/staking/test/utils/types.ts @@ -50,12 +50,67 @@ export interface SimulationParams { } export interface StakeBalance { - current: BigNumber, - next: BigNumber, + current: BigNumber; + next: BigNumber; } -export enum StakeStateId { - ACTIVE, - INACTIVE, - DELEGATED -}; +export interface StakeBalanceByPool { + [key: string]: StakeBalance; +} + +export enum StakeState { + Active, + Inactive, + Delegated, +} + +export interface StakeStateInfo { + state: StakeState; + poolId?: string; +} + +export interface StakeBalances { + zrxBalance: BigNumber; + stakeBalance: BigNumber; + stakeBalanceInVault: BigNumber; + withdrawableStakeBalance: BigNumber; + activeStakeBalance: StakeBalance; + inactiveStakeBalance: StakeBalance; + delegatedStakeBalance: StakeBalance; + delegatedStakeByPool: StakeBalanceByPool; + totalDelegatedStakeByPool: StakeBalanceByPool; +} + +export interface RewardVaultBalance { + poolBalance: BigNumber; + operatorBalance: BigNumber; + membersBalance: BigNumber; +} + +export interface RewardVaultBalanceByPoolId { + [key: string]: RewardVaultBalance; +} + +export interface OperatorShareByPoolId { + [key: string]: BigNumber; +} + +export interface BalanceByOwner { + [key: string]: BigNumber; +} + +export interface RewardByPoolId { + [key: string]: BigNumber; +} + +export interface MemberBalancesByPoolId { + [key: string]: BalanceByOwner; +} + +export interface OperatorByPoolId { + [key: string]: string; +} + +export interface MembersByPoolId { + [key: string]: string[]; +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 43dbd8a5fc..fb117d3265 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -341,6 +341,8 @@ export enum RevertReason { TargetNotEven = 'TARGET_NOT_EVEN', UnexpectedStaticCallResult = 'UNEXPECTED_STATIC_CALL_RESULT', TransfersSuccessful = 'TRANSFERS_SUCCESSFUL', + // Staking + InsufficientFunds = 'INSUFFICIENT_FUNDS', } export enum StatusCodes {