diff --git a/contracts/staking/contracts/src/fees/MixinExchangeFees.sol b/contracts/staking/contracts/src/fees/MixinExchangeFees.sol index 4ce2cae889..9e61dcbb69 100644 --- a/contracts/staking/contracts/src/fees/MixinExchangeFees.sol +++ b/contracts/staking/contracts/src/fees/MixinExchangeFees.sol @@ -19,6 +19,7 @@ pragma solidity ^0.5.9; pragma experimental ABIEncoderV2; +import "@0x/contracts-erc20/contracts/src/interfaces/IEtherToken.sol"; import "@0x/contracts-utils/contracts/src/LibRichErrors.sol"; import "@0x/contracts-utils/contracts/src/LibSafeMath.sol"; import "../libs/LibStakingRichErrors.sol"; @@ -71,7 +72,8 @@ contract MixinExchangeFees is { _assertValidProtocolFee(protocolFeePaid); - // Transfer the protocol fee to this address if it should be paid in WETH. + // Transfer the protocol fee to this address if it should be paid in + // WETH. if (msg.value == 0) { wethAssetProxy.transferFrom( WETH_ASSET_DATA, @@ -89,7 +91,8 @@ contract MixinExchangeFees is return; } - uint256 poolStake = getTotalStakeDelegatedToPool(poolId).currentEpochBalance; + uint256 poolStake = + getTotalStakeDelegatedToPool(poolId).currentEpochBalance; // Ignore pools with dust stake. if (poolStake < minimumPoolStake) { return; @@ -103,35 +106,24 @@ contract MixinExchangeFees is // If the pool was previously inactive in this epoch, initialize it. if (pool.feesCollected == 0) { - // Compute weighted stake. - uint256 operatorStake = getStakeDelegatedToPoolByOwner( - rewardVault.operatorOf(poolId), - poolId - ).currentEpochBalance; - pool.weightedStake = operatorStake.safeAdd( - poolStake - .safeSub(operatorStake) - .safeMul(rewardDelegatedStakeWeight) - .safeDiv(PPM_DENOMINATOR) - ); - - // Compute delegated (non-operator) stake. - pool.delegatedStake = poolStake.safeSub(operatorStake); + // Compute member and total weighted stake. + (pool.membersStake, pool.weightedStake) = + _computeMembersAndWeightedStake(poolId, poolStake); // Increase the total weighted stake. - totalWeightedStakeThisEpoch = totalWeightedStakeThisEpoch.safeAdd( - pool.weightedStake - ); + totalWeightedStakeThisEpoch = + totalWeightedStakeThisEpoch.safeAdd(pool.weightedStake); // Increase the numberof active pools. numActivePoolsThisEpoch += 1; - // Emit an event so keepers know what pools to pass into `finalize()`. + // Emit an event so keepers know what pools to pass into + // `finalize()`. emit StakingPoolActivated(currentEpoch, poolId); } // Credit the fees to the pool. - pool.feesCollected = protocolFeePaid; + pool.feesCollected = pool.feesCollected.safeAdd(protocolFeePaid); // Increase the total fees collected this epoch. totalFeesCollectedThisEpoch = totalFeesCollectedThisEpoch.safeAdd( @@ -142,7 +134,8 @@ contract MixinExchangeFees is activePoolsThisEpoch[poolId] = pool; } - /// @dev Returns the total amount of fees collected thus far, in the current epoch. + /// @dev Returns the total amount of fees collected thus far, in the current + /// epoch. /// @return _totalFeesCollectedThisEpoch Total fees collected this epoch. function getTotalProtocolFeesThisEpoch() external @@ -152,9 +145,21 @@ contract MixinExchangeFees is _totalFeesCollectedThisEpoch = totalFeesCollectedThisEpoch; } + /// @dev Returns the total balance of this contract, including WETH. + /// @return totalBalance Total balance. + function getTotalBalance() + external + view + returns (uint256 totalBalance) + { + totalBalance = address(this).balance + + IEtherToken(WETH_ADDRESS).balanceOf(address(this)); + } + /// @dev Returns the amount of fees attributed to the input pool this epoch. /// @param poolId Pool Id to query. - /// @return feesCollectedByPool Amount of fees collected by the pool this epoch. + /// @return feesCollectedByPool Amount of fees collected by the pool this + /// epoch. function getProtocolFeesThisEpochByPool(bytes32 poolId) external view @@ -168,20 +173,55 @@ contract MixinExchangeFees is feesCollected = pool.feesCollected; } - /// @dev Checks that the protocol fee passed into `payProtocolFee()` is valid. - /// @param protocolFeePaid The `protocolFeePaid` parameter to `payProtocolFee.` + /// @dev Computes the members and weighted stake for a pool at the current + /// epoch. + /// @param poolId ID of the pool. + /// @param totalStake Total (unweighted) stake in the pool. + /// @return membersStake Non-operator stake in the pool. + /// @return weightedStake Weighted stake of the pool. + function _computeMembersAndWeightedStake( + bytes32 poolId, + uint256 totalStake + ) + private + view + returns (uint256 membersStake, uint256 weightedStake) + { + uint256 operatorStake = getStakeDelegatedToPoolByOwner( + getPoolOperator(poolId), + poolId + ).currentEpochBalance; + membersStake = totalStake.safeSub(operatorStake); + weightedStake = operatorStake.safeAdd( + membersStake + .safeMul(rewardDelegatedStakeWeight) + .safeDiv(PPM_DENOMINATOR) + ); + } + + /// @dev Checks that the protocol fee passed into `payProtocolFee()` is + /// valid. + /// @param protocolFeePaid The `protocolFeePaid` parameter to + /// `payProtocolFee.` function _assertValidProtocolFee(uint256 protocolFeePaid) private view { - if (protocolFeePaid == 0 || (msg.value != protocolFeePaid && msg.value != 0)) { - LibRichErrors.rrevert(LibStakingRichErrors.InvalidProtocolFeePaymentError( - protocolFeePaid == 0 ? - LibStakingRichErrors.ProtocolFeePaymentErrorCodes.ZeroProtocolFeePaid : - LibStakingRichErrors.ProtocolFeePaymentErrorCodes.MismatchedFeeAndPayment, - protocolFeePaid, - msg.value - )); + if (protocolFeePaid == 0 || + (msg.value != protocolFeePaid && msg.value != 0)) { + LibRichErrors.rrevert( + LibStakingRichErrors.InvalidProtocolFeePaymentError( + protocolFeePaid == 0 ? + LibStakingRichErrors + .ProtocolFeePaymentErrorCodes + .ZeroProtocolFeePaid : + LibStakingRichErrors + .ProtocolFeePaymentErrorCodes + .MismatchedFeeAndPayment, + protocolFeePaid, + msg.value + ) + ); } } } diff --git a/contracts/staking/contracts/src/interfaces/IStructs.sol b/contracts/staking/contracts/src/interfaces/IStructs.sol index 69ba1291d2..790c0c2bcf 100644 --- a/contracts/staking/contracts/src/interfaces/IStructs.sol +++ b/contracts/staking/contracts/src/interfaces/IStructs.sol @@ -25,11 +25,11 @@ interface IStructs { /// (see MixinExchangeFees). /// @param feesCollected Fees collected in ETH by this pool. /// @param weightedStake Amount of weighted stake in the pool. - /// @param delegatedStake Amount of delegated, non-operator stake in the pool. + /// @param membersStake Amount of non-operator stake in the pool. struct ActivePool { uint256 feesCollected; uint256 weightedStake; - uint256 delegatedStake; + uint256 membersStake; } /// @dev Rewards credited to a pool during finalization. diff --git a/contracts/staking/contracts/src/staking_pools/MixinStakingPool.sol b/contracts/staking/contracts/src/staking_pools/MixinStakingPool.sol index 4f3ce21f8e..5b599350d7 100644 --- a/contracts/staking/contracts/src/staking_pools/MixinStakingPool.sol +++ b/contracts/staking/contracts/src/staking_pools/MixinStakingPool.sol @@ -113,6 +113,17 @@ contract MixinStakingPool is return poolById[poolId]; } + /// @dev Look up the operator of a pool. + /// @param poolId The ID of the pool. + /// @return operatorAddress The pool operator. + function getPoolOperator(bytes32 poolId) + public + view + returns (address operatorAddress) + { + return rewardVault.operatorOf(poolId); + } + /// @dev Computes the unique id that comes after the input pool id. /// @param poolId Unique id of pool. /// @return Next pool id after input pool. diff --git a/contracts/staking/contracts/src/sys/MixinFinalizer.sol b/contracts/staking/contracts/src/sys/MixinFinalizer.sol index a226ed1633..1420f010c1 100644 --- a/contracts/staking/contracts/src/sys/MixinFinalizer.sol +++ b/contracts/staking/contracts/src/sys/MixinFinalizer.sol @@ -93,7 +93,7 @@ contract MixinFinalizer is // Emit an event. emit EpochEnded( closingEpoch, - numActivePoolsThisEpoch, + unfinalizedPoolsRemaining, unfinalizedRewardsAvailable, unfinalizedTotalFeesCollected, unfinalizedTotalWeightedStake @@ -179,7 +179,9 @@ contract MixinFinalizer is } // Deposit all the rewards at once into the RewardVault. - _depositIntoStakingPoolRewardVault(rewardsPaid); + if (rewardsPaid != 0) { + _depositIntoStakingPoolRewardVault(rewardsPaid); + } // Update finalization states. totalRewardsPaidLastEpoch = @@ -310,6 +312,11 @@ contract MixinFinalizer is IStructs.ActivePool memory pool = _getActivePoolFromEpoch(epoch - 1, poolId); + // There can't be any rewards if the pool was active or if it has + // no stake. + if (pool.feesCollected == 0 || pool.weightedStake == 0) { + return rewards; + } // Use the cobb-douglas function to compute the total reward. uint256 totalReward = LibCobbDouglas._cobbDouglas( @@ -323,16 +330,13 @@ contract MixinFinalizer is ); // Split the reward between the operator and delegators. - if (pool.delegatedStake == 0) { + if (pool.membersStake == 0) { rewards.operatorReward = totalReward; } else { (rewards.operatorReward, rewards.membersReward) = - rewardVault.splitAmountBetweenOperatorAndMembers( - poolId, - totalReward - ); + _splitAmountBetweenOperatorAndMembers(poolId, totalReward); } - rewards.membersStake = pool.delegatedStake; + rewards.membersStake = pool.membersStake; } /// @dev Converts the entire WETH balance of the contract into ETH. @@ -344,6 +348,48 @@ contract MixinFinalizer is } } + /// @dev Splits an amount between the pool operator and members of the + /// pool based on the pool operator's share. + /// @param poolId The ID of the pool. + /// @param amount Amount to to split. + /// @return operatorPortion Portion of `amount` attributed to the operator. + /// @return membersPortion Portion of `amount` attributed to the pool. + function _splitAmountBetweenOperatorAndMembers( + bytes32 poolId, + uint256 amount + ) + internal + view + returns (uint256 operatorReward, uint256 membersReward) + { + (operatorReward, membersReward) = + rewardVault.splitAmountBetweenOperatorAndMembers(poolId, amount); + } + + /// @dev Record a deposit for a pool in the RewardVault. + /// @param poolId ID of the pool. + /// @param amount Amount in ETH to record. + /// @param operatorOnly Only attribute amount to operator. + /// @return operatorPortion Portion of `amount` attributed to the operator. + /// @return membersPortion Portion of `amount` attributed to the pool. + function _recordDepositInRewardVaultFor( + bytes32 poolId, + uint256 amount, + bool operatorOnly + ) + internal + returns ( + uint256 operatorPortion, + uint256 membersPortion + ) + { + (operatorPortion, membersPortion) = rewardVault.recordDepositFor( + poolId, + amount, + operatorOnly + ); + } + /// @dev Computes the reward owed to a pool during finalization and /// credits it to that pool for the CURRENT epoch. /// @param poolId The pool's ID. @@ -356,6 +402,12 @@ contract MixinFinalizer is private returns (IStructs.PoolRewards memory rewards) { + // There can't be any rewards if the pool was active or if it has + // no stake. + if (pool.feesCollected == 0 || pool.weightedStake == 0) { + return rewards; + } + // Use the cobb-douglas function to compute the total reward. uint256 totalReward = LibCobbDouglas._cobbDouglas( unfinalizedRewardsAvailable, @@ -369,20 +421,20 @@ contract MixinFinalizer is // Credit the pool the reward in the RewardVault. (rewards.operatorReward, rewards.membersReward) = - rewardVault.recordDepositFor( + _recordDepositInRewardVaultFor( poolId, totalReward, // If no delegated stake, all rewards go to the operator. - pool.delegatedStake == 0 + pool.membersStake == 0 ); - rewards.membersStake = pool.delegatedStake; + rewards.membersStake = pool.membersStake; // Sync delegator rewards. if (rewards.membersReward != 0) { _recordRewardForDelegators( poolId, rewards.membersReward, - pool.delegatedStake + pool.membersStake ); } } diff --git a/contracts/staking/contracts/test/TestFinalizer.sol b/contracts/staking/contracts/test/TestFinalizer.sol new file mode 100644 index 0000000000..b56013b09f --- /dev/null +++ b/contracts/staking/contracts/test/TestFinalizer.sol @@ -0,0 +1,145 @@ +/* + + Copyright 2019 ZeroEx Intl. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +*/ + +pragma solidity ^0.5.9; +pragma experimental ABIEncoderV2; + +import "../src/interfaces/IStructs.sol"; +import "../src/Staking.sol"; + + +contract TestFinalizer is + Staking +{ + struct RecordedReward { + uint256 membersReward; + uint256 membersStake; + } + + struct DepositedReward { + uint256 totalReward; + bool operatorOnly; + } + mapping (bytes32 => uint32) internal _operatorSharesByPool; + mapping (bytes32 => RecordedReward) internal _recordedRewardsByPool; + mapping (bytes32 => DepositedReward) internal _depositedRewardsByPool; + + function getFinalizationState() + external + view + returns ( + uint256 _closingEpoch, + uint256 _unfinalizedPoolsRemaining, + uint256 _unfinalizedRewardsAvailable, + uint256 _unfinalizedTotalFeesCollected, + uint256 _unfinalizedTotalWeightedStake + ) + { + _closingEpoch = currentEpoch - 1; + _unfinalizedPoolsRemaining = unfinalizedPoolsRemaining; + _unfinalizedRewardsAvailable = unfinalizedRewardsAvailable; + _unfinalizedTotalFeesCollected = unfinalizedTotalFeesCollected; + _unfinalizedTotalWeightedStake = unfinalizedTotalWeightedStake; + } + + function addActivePool( + bytes32 poolId, + uint32 operatorShare, + uint256 feesCollected, + uint256 membersStake, + uint256 weightedStake + ) + external + { + mapping (bytes32 => IStructs.ActivePool) storage activePools = + _getActivePoolsFromEpoch(currentEpoch); + assert(activePools[poolId].feesCollected == 0); + _operatorSharesByPool[poolId] = operatorShare; + activePools[poolId] = IStructs.ActivePool({ + feesCollected: feesCollected, + membersStake: membersStake, + weightedStake: weightedStake + }); + totalFeesCollectedThisEpoch += feesCollected; + totalWeightedStakeThisEpoch += weightedStake; + numActivePoolsThisEpoch += 1; + } + + /// @dev Overridden to just store inputs. + function _recordRewardForDelegators( + bytes32 poolId, + uint256 membersReward, + uint256 membersStake + ) + internal + { + _recordedRewardsByPool[poolId] = RecordedReward({ + membersReward: membersReward, + membersStake: membersStake + }); + } + + /// @dev Overridden to store inputs and do some really basic math. + function _recordDepositInRewardVaultFor( + bytes32 poolId, + uint256 totalReward, + bool operatorOnly + ) + internal + returns ( + uint256 operatorPortion, + uint256 membersPortion + ) + { + _depositedRewardsByPool[poolId] = DepositedReward({ + totalReward: totalReward, + operatorOnly: operatorOnly + }); + + if (operatorOnly) { + operatorPortion = totalReward; + } else { + (operatorPortion, membersPortion) = + _splitAmountBetweenOperatorAndMembers(poolId, totalReward); + } + } + + /// @dev Overridden to do some really basic math. + function _splitAmountBetweenOperatorAndMembers( + bytes32 poolId, + uint256 amount + ) + internal + view + returns (uint256 operatorPortion, uint256 membersPortion) + { + uint32 operatorShare = _operatorSharesByPool[poolId]; + operatorPortion = operatorShare * amount / PPM_DENOMINATOR; + membersPortion = amount - operatorPortion; + } + + /// @dev Overriden to always succeed. + function _goToNextEpoch() internal { + currentEpoch += 1; + } + + /// @dev Overridden to do nothing. + function _unwrapWETH() internal { + // NOOP + } +} diff --git a/contracts/staking/contracts/test/TestProtocolFees.sol b/contracts/staking/contracts/test/TestProtocolFees.sol index 37f989d9e4..1df9efbf53 100644 --- a/contracts/staking/contracts/test/TestProtocolFees.sol +++ b/contracts/staking/contracts/test/TestProtocolFees.sol @@ -20,6 +20,7 @@ pragma solidity ^0.5.9; pragma experimental ABIEncoderV2; import "@0x/contracts-asset-proxy/contracts/src/interfaces/IAssetProxy.sol"; +import "../src/interfaces/IStructs.sol"; import "../src/Staking.sol"; @@ -27,7 +28,8 @@ contract TestProtocolFees is Staking { struct TestPool { - uint256 stake; + uint256 operatorStake; + uint256 membersStake; mapping(address => bool) isMaker; } @@ -58,13 +60,15 @@ contract TestProtocolFees is /// @dev Create a test pool. function createTestPool( bytes32 poolId, - uint256 stake, + uint256 operatorStake, + uint256 membersStake, address[] memory makerAddresses ) public { TestPool storage pool = _testPools[poolId]; - pool.stake = stake; + pool.operatorStake = operatorStake; + pool.membersStake = membersStake; for (uint256 i = 0; i < makerAddresses.length; ++i) { pool.isMaker[makerAddresses[i]] = true; _makersToTestPoolIds[makerAddresses[i]] = poolId; @@ -86,10 +90,34 @@ contract TestProtocolFees is view returns (IStructs.StakeBalance memory balance) { - uint256 stake = _testPools[poolId].stake; + TestPool memory pool = _testPools[poolId]; + uint256 stake = pool.operatorStake + pool.membersStake; return IStructs.StakeBalance({ currentEpochBalance: stake, nextEpochBalance: stake }); } + + /// @dev Overridden to use test pools. + function getStakeDelegatedToPoolByOwner(address, bytes32 poolId) + public + view + returns (IStructs.StakeBalance memory balance) + { + TestPool memory pool = _testPools[poolId]; + return IStructs.StakeBalance({ + currentEpochBalance: pool.operatorStake, + nextEpochBalance: pool.operatorStake + }); + } + + /// @dev Overridden to use test pools. + function getPoolOperator(bytes32) + public + view + returns (address operatorAddress) + { + // Just return nil, we won't use it. + return address(0); + } } diff --git a/contracts/staking/test/actors/staker_actor.ts b/contracts/staking/test/actors/staker_actor.ts index e2b130a017..07423f5668 100644 --- a/contracts/staking/test/actors/staker_actor.ts +++ b/contracts/staking/test/actors/staker_actor.ts @@ -146,6 +146,15 @@ export class StakerActor extends BaseActor { expect(finalZrxBalanceOfVault, 'final balance of zrx vault').to.be.bignumber.equal(initZrxBalanceOfVault); } + public async stakeWithPoolAsync(poolId: string, amount: BigNumber): Promise { + await this.stakeAsync(amount); + await this.moveStakeAsync( + new StakeInfo(StakeStatus.Active), + new StakeInfo(StakeStatus.Delegated, poolId), + amount, + ); + } + public async goToNextEpochAsync(): Promise { // cache balances const initZrxBalanceOfVault = await this._stakingApiWrapper.utils.getZrxTokenBalanceOfZrxVaultAsync(); diff --git a/contracts/staking/test/protocol_fees.ts b/contracts/staking/test/protocol_fees.ts index 34ea87a11f..d96d9653f1 100644 --- a/contracts/staking/test/protocol_fees.ts +++ b/contracts/staking/test/protocol_fees.ts @@ -4,6 +4,7 @@ import { expect, filterLogsToArguments, hexRandom, + Numberish, randomAddress, } from '@0x/contracts-test-utils'; import { StakingRevertErrors } from '@0x/order-utils'; @@ -54,9 +55,26 @@ blockchainTests('Protocol Fee Unit Tests', env => { wethAssetData = await testContract.getWethAssetData.callAsync(); }); - async function createTestPoolAsync(stake: BigNumber, makers: string[]): Promise { + interface CreatePoolOpts { + operatorStake: Numberish; + membersStake: Numberish; + makers: string[]; + } + + async function createTestPoolAsync(opts: Partial): Promise { + const _opts = { + operatorStake: 0, + membersStake: 0, + makers: [], + ...opts, + }; const poolId = hexRandom(); - await testContract.createTestPool.awaitTransactionSuccessAsync(poolId, stake, makers); + await testContract.createTestPool.awaitTransactionSuccessAsync( + poolId, + new BigNumber(_opts.operatorStake), + new BigNumber(_opts.membersStake), + _opts.makers, + ); return poolId; } @@ -154,7 +172,7 @@ blockchainTests('Protocol Fee Unit Tests', env => { } it('should not transfer WETH if value is sent', async () => { - await createTestPoolAsync(minimumStake, []); + await createTestPoolAsync({ operatorStake: minimumStake }); const receipt = await testContract.payProtocolFee.awaitTransactionSuccessAsync( makerAddress, payerAddress, @@ -164,8 +182,8 @@ blockchainTests('Protocol Fee Unit Tests', env => { assertNoWETHTransferLogs(receipt.logs); }); - it('should update `protocolFeesThisEpochByPool` if the maker is in a pool', async () => { - const poolId = await createTestPoolAsync(minimumStake, [makerAddress]); + it('should credit pool if the maker is in a pool', async () => { + const poolId = await createTestPoolAsync({ operatorStake: minimumStake, makers: [makerAddress] }); const receipt = await testContract.payProtocolFee.awaitTransactionSuccessAsync( makerAddress, payerAddress, @@ -177,8 +195,8 @@ blockchainTests('Protocol Fee Unit Tests', env => { expect(poolFees).to.bignumber.eq(DEFAULT_PROTOCOL_FEE_PAID); }); - it('should not update `protocolFeesThisEpochByPool` if maker is not in a pool', async () => { - const poolId = await createTestPoolAsync(minimumStake, []); + it('should not credit the pool if maker is not in a pool', async () => { + const poolId = await createTestPoolAsync({ operatorStake: minimumStake }); const receipt = await testContract.payProtocolFee.awaitTransactionSuccessAsync( makerAddress, payerAddress, @@ -191,7 +209,7 @@ blockchainTests('Protocol Fee Unit Tests', env => { }); it('fees paid to the same maker should go to the same pool', async () => { - const poolId = await createTestPoolAsync(minimumStake, [makerAddress]); + const poolId = await createTestPoolAsync({ operatorStake: minimumStake, makers: [makerAddress] }); const payAsync = async () => { const receipt = await testContract.payProtocolFee.awaitTransactionSuccessAsync( makerAddress, @@ -225,7 +243,7 @@ blockchainTests('Protocol Fee Unit Tests', env => { } it('should transfer WETH if no value is sent and the maker is not in a pool', async () => { - await createTestPoolAsync(minimumStake, []); + await createTestPoolAsync({ operatorStake: minimumStake }); const receipt = await testContract.payProtocolFee.awaitTransactionSuccessAsync( makerAddress, payerAddress, @@ -236,7 +254,7 @@ blockchainTests('Protocol Fee Unit Tests', env => { }); it('should update `protocolFeesThisEpochByPool` if the maker is in a pool', async () => { - const poolId = await createTestPoolAsync(minimumStake, [makerAddress]); + const poolId = await createTestPoolAsync({ operatorStake: minimumStake, makers: [makerAddress] }); const receipt = await testContract.payProtocolFee.awaitTransactionSuccessAsync( makerAddress, payerAddress, @@ -249,7 +267,7 @@ blockchainTests('Protocol Fee Unit Tests', env => { }); it('should not update `protocolFeesThisEpochByPool` if maker is not in a pool', async () => { - const poolId = await createTestPoolAsync(minimumStake, []); + const poolId = await createTestPoolAsync({ operatorStake: minimumStake }); const receipt = await testContract.payProtocolFee.awaitTransactionSuccessAsync( makerAddress, payerAddress, @@ -262,7 +280,7 @@ blockchainTests('Protocol Fee Unit Tests', env => { }); it('fees paid to the same maker should go to the same pool', async () => { - const poolId = await createTestPoolAsync(minimumStake, [makerAddress]); + const poolId = await createTestPoolAsync({ operatorStake: minimumStake, makers: [makerAddress] }); const payAsync = async () => { const receipt = await testContract.payProtocolFee.awaitTransactionSuccessAsync( makerAddress, @@ -280,7 +298,7 @@ blockchainTests('Protocol Fee Unit Tests', env => { }); it('fees paid to the same maker in WETH then ETH should go to the same pool', async () => { - const poolId = await createTestPoolAsync(minimumStake, [makerAddress]); + const poolId = await createTestPoolAsync({ operatorStake: minimumStake, makers: [makerAddress] }); const payAsync = async (inWETH: boolean) => { await testContract.payProtocolFee.awaitTransactionSuccessAsync( makerAddress, @@ -303,7 +321,7 @@ blockchainTests('Protocol Fee Unit Tests', env => { describe('Multiple makers', () => { it('fees paid to different makers in the same pool go to that pool', async () => { const otherMakerAddress = randomAddress(); - const poolId = await createTestPoolAsync(minimumStake, [makerAddress, otherMakerAddress]); + const poolId = await createTestPoolAsync({ operatorStake: minimumStake, makers: [makerAddress, otherMakerAddress] }); const payAsync = async (_makerAddress: string) => { await testContract.payProtocolFee.awaitTransactionSuccessAsync( _makerAddress, @@ -322,8 +340,8 @@ blockchainTests('Protocol Fee Unit Tests', env => { it('fees paid to makers in different pools go to their respective pools', async () => { const [fee, otherFee] = _.times(2, () => getRandomPortion(DEFAULT_PROTOCOL_FEE_PAID)); const otherMakerAddress = randomAddress(); - const poolId = await createTestPoolAsync(minimumStake, [makerAddress]); - const otherPoolId = await createTestPoolAsync(minimumStake, [otherMakerAddress]); + const poolId = await createTestPoolAsync({ operatorStake: minimumStake, makers: [makerAddress] }); + const otherPoolId = await createTestPoolAsync({ operatorStake: minimumStake, makers: [otherMakerAddress]}); const payAsync = async (_poolId: string, _makerAddress: string, _fee: BigNumber) => { // prettier-ignore await testContract.payProtocolFee.awaitTransactionSuccessAsync( @@ -346,7 +364,7 @@ blockchainTests('Protocol Fee Unit Tests', env => { describe('Dust stake', () => { it('credits pools with stake > minimum', async () => { - const poolId = await createTestPoolAsync(minimumStake.plus(1), [makerAddress]); + const poolId = await createTestPoolAsync({ operatorStake: minimumStake.plus(1), makers: [makerAddress] }); await testContract.payProtocolFee.awaitTransactionSuccessAsync( makerAddress, constants.NULL_ADDRESS, @@ -358,7 +376,7 @@ blockchainTests('Protocol Fee Unit Tests', env => { }); it('credits pools with stake == minimum', async () => { - const poolId = await createTestPoolAsync(minimumStake, [makerAddress]); + const poolId = await createTestPoolAsync({ operatorStake: minimumStake, makers: [makerAddress] }); await testContract.payProtocolFee.awaitTransactionSuccessAsync( makerAddress, constants.NULL_ADDRESS, @@ -370,7 +388,7 @@ blockchainTests('Protocol Fee Unit Tests', env => { }); it('does not credit pools with stake < minimum', async () => { - const poolId = await createTestPoolAsync(minimumStake.minus(1), [makerAddress]); + const poolId = await createTestPoolAsync({ operatorStake: minimumStake.minus(1), makers: [makerAddress] }); await testContract.payProtocolFee.awaitTransactionSuccessAsync( makerAddress, constants.NULL_ADDRESS, diff --git a/contracts/staking/test/rewards_test.ts b/contracts/staking/test/rewards_test.ts index faad8c8b78..739db688a4 100644 --- a/contracts/staking/test/rewards_test.ts +++ b/contracts/staking/test/rewards_test.ts @@ -6,6 +6,7 @@ import * as _ from 'lodash'; import { artifacts } from '../src'; import { FinalizerActor } from './actors/finalizer_actor'; +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'; @@ -26,8 +27,9 @@ blockchainTests.resets('Testing Rewards', env => { let erc20Wrapper: ERC20Wrapper; // test parameters let stakers: StakerActor[]; + let poolOperatorStaker: StakerActor; let poolId: string; - let poolOperator: string; + let poolOperator: PoolOperatorActor; let finalizer: FinalizerActor; // tests before(async () => { @@ -43,7 +45,7 @@ blockchainTests.resets('Testing Rewards', env => { stakingApiWrapper = await deployAndConfigureContractsAsync(env, owner, erc20Wrapper, artifacts.TestStaking); // set up staking parameters await stakingApiWrapper.utils.setParamsAsync({ - minimumPoolStake: new BigNumber(0), + minimumPoolStake: new BigNumber(1), cobbDouglasAlphaNumerator: new BigNumber(1), cobbDouglasAlphaDenominator: new BigNumber(6), rewardVaultAddress: stakingApiWrapper.rewardVaultContract.address, @@ -51,22 +53,26 @@ blockchainTests.resets('Testing Rewards', env => { zrxVaultAddress: stakingApiWrapper.zrxVaultContract.address, }); // setup stakers - stakers = [new StakerActor(actors[0], stakingApiWrapper), new StakerActor(actors[1], stakingApiWrapper)]; + stakers = actors.slice(0, 2).map(a => new StakerActor(a, stakingApiWrapper)); // setup pools - poolOperator = actors[2]; - poolId = await stakingApiWrapper.utils.createStakingPoolAsync(poolOperator, 0, true); // add operator as maker + poolOperator = new PoolOperatorActor(actors[2], stakingApiWrapper); + // Create a pool where all rewards go to members. + poolId = await poolOperator.createStakingPoolAsync(0, true); + // Stake something in the pool or else it won't get any rewards. + poolOperatorStaker = new StakerActor(poolOperator.getOwner(), stakingApiWrapper); + await poolOperatorStaker.stakeWithPoolAsync(poolId, new BigNumber(1)); // set exchange address await stakingApiWrapper.stakingContract.addExchangeAddress.awaitTransactionSuccessAsync(exchangeAddress); // associate operators for tracking in Finalizer const operatorByPoolId: OperatorByPoolId = {}; - operatorByPoolId[poolId] = poolOperator; - operatorByPoolId[poolId] = poolOperator; + operatorByPoolId[poolId] = poolOperator.getOwner(); // 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], stakingApiWrapper, [poolId], operatorByPoolId, membersByPoolId); + // Skip to next epoch so operator stake is realized. + await stakingApiWrapper.utils.skipToNextEpochAndFinalizeAsync(); }); describe('Reward Simulation', () => { interface EndBalances { @@ -154,7 +160,7 @@ blockchainTests.resets('Testing Rewards', env => { const fee = _fee !== undefined ? _fee : ZERO; if (!fee.eq(ZERO)) { await stakingApiWrapper.stakingContract.payProtocolFee.awaitTransactionSuccessAsync( - poolOperator, + poolOperator.getOwner(), takerAddress, fee, { from: exchangeAddress, value: fee }, @@ -196,12 +202,7 @@ blockchainTests.resets('Testing Rewards', env => { (staker joins this epoch but is active next epoch)`, async () => { // delegate const amount = toBaseUnitAmount(4); - await stakers[0].stakeAsync(amount); - await stakers[0].moveStakeAsync( - new StakeInfo(StakeStatus.Active), - new StakeInfo(StakeStatus.Delegated, poolId), - amount, - ); + await stakers[0].stakeWithPoolAsync(poolId, amount); // finalize const reward = toBaseUnitAmount(10); await payProtocolFeeAndFinalize(reward); @@ -213,12 +214,7 @@ blockchainTests.resets('Testing Rewards', env => { it('Should give pool reward to delegator', async () => { // delegate const amount = toBaseUnitAmount(4); - await stakers[0].stakeAsync(amount); - await stakers[0].moveStakeAsync( - new StakeInfo(StakeStatus.Active), - new StakeInfo(StakeStatus.Delegated, poolId), - amount, - ); + await stakers[0].stakeWithPoolAsync(poolId, amount); // skip epoch, so staker can start earning rewards await payProtocolFeeAndFinalize(); // finalize @@ -232,22 +228,12 @@ blockchainTests.resets('Testing Rewards', env => { }); }); it('Should split pool reward between delegators', async () => { - // first staker delegates const stakeAmounts = [toBaseUnitAmount(4), toBaseUnitAmount(6)]; const totalStakeAmount = toBaseUnitAmount(10); - await stakers[0].stakeAsync(stakeAmounts[0]); - await stakers[0].moveStakeAsync( - new StakeInfo(StakeStatus.Active), - new StakeInfo(StakeStatus.Delegated, poolId), - stakeAmounts[0], - ); + // first staker delegates + await stakers[0].stakeWithPoolAsync(poolId, stakeAmounts[0]); // second staker delegates - await stakers[1].stakeAsync(stakeAmounts[1]); - await stakers[1].moveStakeAsync( - new StakeInfo(StakeStatus.Active), - new StakeInfo(StakeStatus.Delegated, poolId), - stakeAmounts[1], - ); + await stakers[1].stakeWithPoolAsync(poolId, stakeAmounts[1]); // skip epoch, so staker can start earning rewards await payProtocolFeeAndFinalize(); // finalize @@ -299,24 +285,14 @@ blockchainTests.resets('Testing Rewards', env => { }); }); it('Should give pool reward to delegators only for the epoch during which they delegated', async () => { - // first staker delegates (epoch 0) const stakeAmounts = [toBaseUnitAmount(4), toBaseUnitAmount(6)]; const totalStakeAmount = toBaseUnitAmount(10); - await stakers[0].stakeAsync(stakeAmounts[0]); - await stakers[0].moveStakeAsync( - new StakeInfo(StakeStatus.Active), - new StakeInfo(StakeStatus.Delegated, poolId), - stakeAmounts[0], - ); + // first staker delegates (epoch 0) + await stakers[0].stakeWithPoolAsync(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( - new StakeInfo(StakeStatus.Active), - new StakeInfo(StakeStatus.Delegated, poolId), - stakeAmounts[1], - ); + await stakers[1].stakeWithPoolAsync(poolId, stakeAmounts[1]); // only the first staker will get this reward const rewardForOnlyFirstDelegator = toBaseUnitAmount(10); await payProtocolFeeAndFinalize(rewardForOnlyFirstDelegator); @@ -349,24 +325,14 @@ blockchainTests.resets('Testing Rewards', env => { return v.toNumber(); }); const totalSharedRewards = new BigNumber(totalSharedRewardsAsNumber); - // first staker delegates (epoch 0) const stakeAmounts = [toBaseUnitAmount(4), toBaseUnitAmount(6)]; const totalStakeAmount = toBaseUnitAmount(10); - await stakers[0].stakeAsync(stakeAmounts[0]); - await stakers[0].moveStakeAsync( - new StakeInfo(StakeStatus.Active), - new StakeInfo(StakeStatus.Delegated, poolId), - stakeAmounts[0], - ); + // first staker delegates (epoch 0) + await stakers[0].stakeWithPoolAsync(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( - new StakeInfo(StakeStatus.Active), - new StakeInfo(StakeStatus.Delegated, poolId), - stakeAmounts[1], - ); + await stakers[1].stakeWithPoolAsync(poolId, stakeAmounts[1]); // only the first staker will get this reward await payProtocolFeeAndFinalize(rewardForOnlyFirstDelegator); // earn a bunch of rewards @@ -386,14 +352,9 @@ blockchainTests.resets('Testing Rewards', env => { }); }); it('Should send existing rewards from reward vault to eth vault correctly when undelegating stake', async () => { - // first staker delegates (epoch 0) const stakeAmount = toBaseUnitAmount(4); - await stakers[0].stakeAsync(stakeAmount); - await stakers[0].moveStakeAsync( - new StakeInfo(StakeStatus.Active), - new StakeInfo(StakeStatus.Delegated, poolId), - stakeAmount, - ); + // first staker delegates (epoch 0) + await stakers[0].stakeWithPoolAsync(poolId, stakeAmount); // skip epoch, so first staker can start earning rewards await payProtocolFeeAndFinalize(); // earn reward @@ -412,26 +373,16 @@ blockchainTests.resets('Testing Rewards', env => { }); }); it('Should send existing rewards from reward vault to eth vault correctly when delegating more stake', async () => { - // first staker delegates (epoch 0) const stakeAmount = toBaseUnitAmount(4); - await stakers[0].stakeAsync(stakeAmount); - await stakers[0].moveStakeAsync( - new StakeInfo(StakeStatus.Active), - new StakeInfo(StakeStatus.Delegated, poolId), - stakeAmount, - ); + // first staker delegates (epoch 0) + await stakers[0].stakeWithPoolAsync(poolId, stakeAmount); // skip epoch, so first staker can start earning rewards await payProtocolFeeAndFinalize(); // earn reward const reward = toBaseUnitAmount(10); await payProtocolFeeAndFinalize(reward); // add more stake - await stakers[0].stakeAsync(stakeAmount); - await stakers[0].moveStakeAsync( - new StakeInfo(StakeStatus.Active), - new StakeInfo(StakeStatus.Delegated, poolId), - stakeAmount, - ); + await stakers[0].stakeWithPoolAsync(poolId, stakeAmount); // sanity check final balances await validateEndBalances({ stakerRewardVaultBalance_1: ZERO, @@ -453,23 +404,13 @@ blockchainTests.resets('Testing Rewards', env => { return v.toNumber(); }), ); - // first staker delegates (epoch 0) const stakeAmounts = [toBaseUnitAmount(4), toBaseUnitAmount(6)]; - await stakers[0].stakeAsync(stakeAmounts[0]); - await stakers[0].moveStakeAsync( - new StakeInfo(StakeStatus.Active), - new StakeInfo(StakeStatus.Delegated, poolId), - stakeAmounts[0], - ); + // first staker delegates (epoch 0) + await stakers[0].stakeWithPoolAsync(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( - new StakeInfo(StakeStatus.Active), - new StakeInfo(StakeStatus.Delegated, poolId), - stakeAmounts[1], - ); + await stakers[1].stakeWithPoolAsync(poolId, stakeAmounts[1]); // only the first staker will get this reward await payProtocolFeeAndFinalize(rewardBeforeAddingMoreStake); // earn a bunch of rewards @@ -488,12 +429,7 @@ blockchainTests.resets('Testing Rewards', env => { const rewardForDelegator = toBaseUnitAmount(10); const rewardNotForDelegator = toBaseUnitAmount(7); const stakeAmount = toBaseUnitAmount(4); - await stakers[0].stakeAsync(stakeAmount); - await stakers[0].moveStakeAsync( - new StakeInfo(StakeStatus.Active), - new StakeInfo(StakeStatus.Delegated, poolId), - stakeAmount, - ); + await stakers[0].stakeWithPoolAsync(poolId, stakeAmount); // skip epoch, so first staker can start earning rewards await payProtocolFeeAndFinalize(); // earn reward @@ -534,12 +470,7 @@ blockchainTests.resets('Testing Rewards', env => { }), ); const stakeAmount = toBaseUnitAmount(4); - await stakers[0].stakeAsync(stakeAmount); - await stakers[0].moveStakeAsync( - new StakeInfo(StakeStatus.Active), - new StakeInfo(StakeStatus.Delegated, poolId), - stakeAmount, - ); + await stakers[0].stakeWithPoolAsync(poolId, stakeAmount); // skip epoch, so first staker can start earning rewards await payProtocolFeeAndFinalize(); // earn reward @@ -566,12 +497,7 @@ blockchainTests.resets('Testing Rewards', env => { const rewardsForDelegator = [toBaseUnitAmount(10), toBaseUnitAmount(15)]; const rewardNotForDelegator = toBaseUnitAmount(7); const stakeAmount = toBaseUnitAmount(4); - await stakers[0].stakeAsync(stakeAmount); - await stakers[0].moveStakeAsync( - new StakeInfo(StakeStatus.Active), - new StakeInfo(StakeStatus.Delegated, poolId), - stakeAmount, - ); + await stakers[0].stakeWithPoolAsync(poolId, stakeAmount); // skip epoch, so first staker can start earning rewards await payProtocolFeeAndFinalize(); // earn reward @@ -668,12 +594,7 @@ blockchainTests.resets('Testing Rewards', env => { const staker = stakers[0]; const stakeAmount = toBaseUnitAmount(5); // stake and delegate - await staker.stakeAsync(stakeAmount); - await staker.moveStakeAsync( - new StakeInfo(StakeStatus.Active), - new StakeInfo(StakeStatus.Delegated, poolId), - stakeAmount, - ); + await stakers[0].stakeWithPoolAsync(poolId, stakeAmount); // skip epoch, so staker can start earning rewards await payProtocolFeeAndFinalize(); // undelegate some stake @@ -703,12 +624,7 @@ blockchainTests.resets('Testing Rewards', env => { // stake and delegate both const stakersAndStake = _.zip(stakers.slice(0, 2), stakeAmounts) as Array<[StakerActor, BigNumber]>; for (const [staker, stakeAmount] of stakersAndStake) { - await staker.stakeAsync(stakeAmount); - await staker.moveStakeAsync( - new StakeInfo(StakeStatus.Active), - new StakeInfo(StakeStatus.Delegated, poolId), - stakeAmount, - ); + await staker.stakeWithPoolAsync(poolId, stakeAmount); } // skip epoch, so staker can start earning rewards await payProtocolFeeAndFinalize(); @@ -739,12 +655,7 @@ blockchainTests.resets('Testing Rewards', env => { // stake and delegate both const stakersAndStake = _.zip(stakers.slice(0, 2), stakeAmounts) as Array<[StakerActor, BigNumber]>; for (const [staker, stakeAmount] of stakersAndStake) { - await staker.stakeAsync(stakeAmount); - await staker.moveStakeAsync( - new StakeInfo(StakeStatus.Active), - new StakeInfo(StakeStatus.Delegated, poolId), - stakeAmount, - ); + await staker.stakeWithPoolAsync(poolId, stakeAmount); } // skip epoch, so staker can start earning rewards await payProtocolFeeAndFinalize(); diff --git a/contracts/staking/test/unit_tests/delegator_reward_balance.ts b/contracts/staking/test/unit_tests/delegator_reward_balance_test.ts similarity index 93% rename from contracts/staking/test/unit_tests/delegator_reward_balance.ts rename to contracts/staking/test/unit_tests/delegator_reward_balance_test.ts index 1fb12adac8..42564bab3f 100644 --- a/contracts/staking/test/unit_tests/delegator_reward_balance.ts +++ b/contracts/staking/test/unit_tests/delegator_reward_balance_test.ts @@ -7,7 +7,7 @@ blockchainTests('delegator rewards', env => { before(async () => { testContract = await TestDelegatorRewardsContract.deployFrom0xArtifactAsync( - artifacts.TestLibFixedMath, + artifacts.TestDelegatorRewards, env.provider, env.txDefaults, artifacts, diff --git a/contracts/staking/test/unit_tests/finalizer_test.ts b/contracts/staking/test/unit_tests/finalizer_test.ts new file mode 100644 index 0000000000..fbaea90989 --- /dev/null +++ b/contracts/staking/test/unit_tests/finalizer_test.ts @@ -0,0 +1,55 @@ +import { blockchainTests, expect, filterLogsToArguments, Numberish } from '@0x/contracts-test-utils'; + +import { + artifacts, + IStakingEventsEpochEndedEventArgs, + IStakingEventsEpochFinalizedEventArgs, + IStakingEventsEvents, + TestFinalizerContract, +} from '../../src'; + +blockchainTests.resets.only('finalization tests', env => { + let testContract: TestFinalizerContract; + const INITIAL_EPOCH = 0; + + before(async () => { + testContract = await TestFinalizerContract.deployFrom0xArtifactAsync( + artifacts.TestFinalizer, + env.provider, + env.txDefaults, + artifacts, + ); + }); + + describe('endEpoch()', () => { + it('emits an `EpochEnded` event', async () => { + const receipt = await testContract.endEpoch.awaitTransactionSuccessAsync(); + const [epochEndedEvent] = filterLogsToArguments( + receipt.logs, + IStakingEventsEvents.EpochEnded, + ); + expect(epochEndedEvent.epoch).to.bignumber.eq(INITIAL_EPOCH); + expect(epochEndedEvent.numActivePools).to.bignumber.eq(0); + expect(epochEndedEvent.rewardsAvailable).to.bignumber.eq(0); + expect(epochEndedEvent.totalFeesCollected).to.bignumber.eq(0); + expect(epochEndedEvent.totalWeightedStake).to.bignumber.eq(0); + }); + + it('advances the epoch', async () => { + await testContract.endEpoch.awaitTransactionSuccessAsync(); + const currentEpoch = await testContract.getCurrentEpoch.callAsync(); + expect(currentEpoch).to.be.bignumber.eq(INITIAL_EPOCH + 1); + }); + + it('immediately finalizes if there are no active pools', async () => { + const receipt = await testContract.endEpoch.awaitTransactionSuccessAsync(); + const [epochFinalizedEvent] = filterLogsToArguments( + receipt.logs, + IStakingEventsEvents.EpochFinalized, + ); + expect(epochFinalizedEvent.epoch).to.bignumber.eq(INITIAL_EPOCH); + expect(epochFinalizedEvent.rewardsPaid).to.bignumber.eq(0); + expect(epochFinalizedEvent.rewardsRemaining).to.bignumber.eq(0); + }); + }); +});