diff --git a/contracts/staking/CHANGELOG.json b/contracts/staking/CHANGELOG.json index cd30ad7573..998db9ca16 100644 --- a/contracts/staking/CHANGELOG.json +++ b/contracts/staking/CHANGELOG.json @@ -29,6 +29,10 @@ { "note": "Unit tests for MixinScheduler", "pr": 2314 + }, + { + "note": "Unit tests for MixinCumulativeRewards", + "pr": 2316 } ] }, diff --git a/contracts/staking/contracts/test/TestMixinCumulativeRewards.sol b/contracts/staking/contracts/test/TestMixinCumulativeRewards.sol new file mode 100644 index 0000000000..e5ed71805b --- /dev/null +++ b/contracts/staking/contracts/test/TestMixinCumulativeRewards.sol @@ -0,0 +1,125 @@ +/* + + 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 "./TestStaking.sol"; + + +contract TestMixinCumulativeRewards is + TestStaking +{ + + constructor( + address wethAddress, + address zrxVaultAddress + ) + public + TestStaking( + wethAddress, + zrxVaultAddress + ) + { + _addAuthorizedAddress(msg.sender); + init(); + _removeAuthorizedAddressAtIndex(msg.sender, 0); + } + + /// @dev Exposes `_isCumulativeRewardSet` + function isCumulativeRewardSet(IStructs.Fraction memory cumulativeReward) + public + pure + returns (bool) + { + return _isCumulativeRewardSet(cumulativeReward); + } + + /// @dev Exposes `_addCumulativeReward` + function addCumulativeReward( + bytes32 poolId, + uint256 reward, + uint256 stake + ) + public + { + _addCumulativeReward(poolId, reward, stake); + } + + /// @dev Exposes `_updateCumulativeReward` + function updateCumulativeReward(bytes32 poolId) + public + { + _updateCumulativeReward(poolId); + } + + /// @dev Exposes _computeMemberRewardOverInterval + function computeMemberRewardOverInterval( + bytes32 poolId, + uint256 memberStakeOverInterval, + uint256 beginEpoch, + uint256 endEpoch + ) + public + view + returns (uint256 reward) + { + return _computeMemberRewardOverInterval(poolId, memberStakeOverInterval, beginEpoch, endEpoch); + } + + /// @dev Increments current epoch by 1 + function incrementEpoch() + public + { + currentEpoch += 1; + } + + /// @dev Stores an arbitrary cumulative reward for a given epoch. + /// Also sets the `_cumulativeRewardsByPoolLastStored` to the input epoch. + function storeCumulativeReward( + bytes32 poolId, + IStructs.Fraction memory cumulativeReward, + uint256 epoch + ) + public + { + _cumulativeRewardsByPool[poolId][epoch] = cumulativeReward; + _cumulativeRewardsByPoolLastStored[poolId] = epoch; + } + + /// @dev Returns the most recent cumulative reward for a given pool. + function getMostRecentCumulativeReward(bytes32 poolId) + public + returns (IStructs.Fraction memory) + { + uint256 mostRecentEpoch = _cumulativeRewardsByPoolLastStored[poolId]; + return _cumulativeRewardsByPool[poolId][mostRecentEpoch]; + } + + /// @dev Returns the raw cumulative reward for a given pool in an epoch. + /// This is considered "raw" because the internal implementation + /// (_getCumulativeRewardAtEpochRaw) will query other state variables + /// to determine the most accurate cumulative reward for a given epoch. + function getCumulativeRewardAtEpochRaw(bytes32 poolId, uint256 epoch) + public + returns (IStructs.Fraction memory) + { + return _cumulativeRewardsByPool[poolId][epoch]; + } +} + diff --git a/contracts/staking/package.json b/contracts/staking/package.json index 86c3f5e1fc..8ca31ec93a 100644 --- a/contracts/staking/package.json +++ b/contracts/staking/package.json @@ -37,7 +37,7 @@ }, "config": { "abis:comment": "This list is auto-generated by contracts-gen. Don't edit manually.", - "abis": "./generated-artifacts/@(IStaking|IStakingEvents|IStakingProxy|IStorage|IStorageInit|IStructs|IZrxVault|LibCobbDouglas|LibFixedMath|LibFixedMathRichErrors|LibSafeDowncast|LibStakingRichErrors|MixinAbstract|MixinConstants|MixinCumulativeRewards|MixinDeploymentConstants|MixinExchangeFees|MixinExchangeManager|MixinFinalizer|MixinParams|MixinScheduler|MixinStake|MixinStakeBalances|MixinStakeStorage|MixinStakingPool|MixinStakingPoolRewards|MixinStorage|Staking|StakingProxy|TestAssertStorageParams|TestCobbDouglas|TestCumulativeRewardTracking|TestDelegatorRewards|TestExchangeManager|TestFinalizer|TestInitTarget|TestLibFixedMath|TestLibSafeDowncast|TestMixinParams|TestMixinScheduler|TestMixinStake|TestMixinStakeBalances|TestMixinStakeStorage|TestMixinStakingPool|TestMixinStakingPoolRewards|TestProtocolFees|TestProxyDestination|TestStaking|TestStakingNoWETH|TestStakingProxy|TestStakingProxyUnit|TestStorageLayoutAndConstants|ZrxVault).json" + "abis": "./generated-artifacts/@(IStaking|IStakingEvents|IStakingProxy|IStorage|IStorageInit|IStructs|IZrxVault|LibCobbDouglas|LibFixedMath|LibFixedMathRichErrors|LibSafeDowncast|LibStakingRichErrors|MixinAbstract|MixinConstants|MixinCumulativeRewards|MixinDeploymentConstants|MixinExchangeFees|MixinExchangeManager|MixinFinalizer|MixinParams|MixinScheduler|MixinStake|MixinStakeBalances|MixinStakeStorage|MixinStakingPool|MixinStakingPoolRewards|MixinStorage|Staking|StakingProxy|TestAssertStorageParams|TestCobbDouglas|TestCumulativeRewardTracking|TestDelegatorRewards|TestExchangeManager|TestFinalizer|TestInitTarget|TestLibFixedMath|TestLibSafeDowncast|TestMixinCumulativeRewards|TestMixinParams|TestMixinScheduler|TestMixinStake|TestMixinStakeBalances|TestMixinStakeStorage|TestMixinStakingPool|TestMixinStakingPoolRewards|TestProtocolFees|TestProxyDestination|TestStaking|TestStakingNoWETH|TestStakingProxy|TestStakingProxyUnit|TestStorageLayoutAndConstants|ZrxVault).json" }, "repository": { "type": "git", diff --git a/contracts/staking/src/artifacts.ts b/contracts/staking/src/artifacts.ts index 27cdc0604e..2729748a69 100644 --- a/contracts/staking/src/artifacts.ts +++ b/contracts/staking/src/artifacts.ts @@ -43,6 +43,7 @@ import * as TestFinalizer from '../generated-artifacts/TestFinalizer.json'; import * as TestInitTarget from '../generated-artifacts/TestInitTarget.json'; import * as TestLibFixedMath from '../generated-artifacts/TestLibFixedMath.json'; import * as TestLibSafeDowncast from '../generated-artifacts/TestLibSafeDowncast.json'; +import * as TestMixinCumulativeRewards from '../generated-artifacts/TestMixinCumulativeRewards.json'; import * as TestMixinParams from '../generated-artifacts/TestMixinParams.json'; import * as TestMixinScheduler from '../generated-artifacts/TestMixinScheduler.json'; import * as TestMixinStake from '../generated-artifacts/TestMixinStake.json'; @@ -98,6 +99,7 @@ export const artifacts = { TestInitTarget: TestInitTarget as ContractArtifact, TestLibFixedMath: TestLibFixedMath as ContractArtifact, TestLibSafeDowncast: TestLibSafeDowncast as ContractArtifact, + TestMixinCumulativeRewards: TestMixinCumulativeRewards as ContractArtifact, TestMixinParams: TestMixinParams as ContractArtifact, TestMixinScheduler: TestMixinScheduler as ContractArtifact, TestMixinStake: TestMixinStake as ContractArtifact, diff --git a/contracts/staking/src/wrappers.ts b/contracts/staking/src/wrappers.ts index ff10601900..04657b7802 100644 --- a/contracts/staking/src/wrappers.ts +++ b/contracts/staking/src/wrappers.ts @@ -41,6 +41,7 @@ export * from '../generated-wrappers/test_finalizer'; export * from '../generated-wrappers/test_init_target'; export * from '../generated-wrappers/test_lib_fixed_math'; export * from '../generated-wrappers/test_lib_safe_downcast'; +export * from '../generated-wrappers/test_mixin_cumulative_rewards'; export * from '../generated-wrappers/test_mixin_params'; export * from '../generated-wrappers/test_mixin_scheduler'; export * from '../generated-wrappers/test_mixin_stake'; diff --git a/contracts/staking/test/unit_tests/mixin_cumulative_rewards_test.ts b/contracts/staking/test/unit_tests/mixin_cumulative_rewards_test.ts new file mode 100644 index 0000000000..97dcb1753c --- /dev/null +++ b/contracts/staking/test/unit_tests/mixin_cumulative_rewards_test.ts @@ -0,0 +1,258 @@ +import { blockchainTests, expect } from '@0x/contracts-test-utils'; +import { BigNumber } from '@0x/utils'; +import * as _ from 'lodash'; + +import { artifacts, TestMixinCumulativeRewardsContract } from '../../src'; + +import { constants as stakingConstants } from '../utils/constants'; +import { toBaseUnitAmount } from '../utils/number_utils'; + +blockchainTests.resets('MixinCumulativeRewards unit tests', env => { + const ZERO = new BigNumber(0); + const testRewards = [ + { + numerator: new BigNumber(1), + denominator: new BigNumber(2), + }, + { + numerator: new BigNumber(3), + denominator: new BigNumber(4), + }, + ]; + const sumOfTestRewardsNormalized = { + numerator: new BigNumber(10), + denominator: new BigNumber(8), + }; + let testPoolId: string; + let testContract: TestMixinCumulativeRewardsContract; + + before(async () => { + // Deploy contracts + testContract = await TestMixinCumulativeRewardsContract.deployFrom0xArtifactAsync( + artifacts.TestMixinCumulativeRewards, + env.provider, + env.txDefaults, + artifacts, + stakingConstants.NIL_ADDRESS, + stakingConstants.NIL_ADDRESS, + ); + + // Create a test pool + const operatorShare = new BigNumber(1); + const addOperatorAsMaker = true; + const txReceipt = await testContract.createStakingPool.awaitTransactionSuccessAsync( + operatorShare, + addOperatorAsMaker, + ); + const createStakingPoolLog = txReceipt.logs[0]; + testPoolId = (createStakingPoolLog as any).args.poolId; + }); + + describe('_isCumulativeRewardSet', () => { + it('Should return true iff denominator is non-zero', async () => { + const isSet = await testContract.isCumulativeRewardSet.callAsync({ + numerator: ZERO, + denominator: new BigNumber(1), + }); + expect(isSet).to.be.true(); + }); + it('Should return false iff denominator is zero', async () => { + const isSet = await testContract.isCumulativeRewardSet.callAsync({ + numerator: new BigNumber(1), + denominator: ZERO, + }); + expect(isSet).to.be.false(); + }); + }); + + describe('_addCumulativeReward', () => { + it('Should set value to `reward/stake` if this is the first cumulative reward', async () => { + await testContract.addCumulativeReward.awaitTransactionSuccessAsync( + testPoolId, + testRewards[0].numerator, + testRewards[0].denominator, + ); + const mostRecentCumulativeReward = await testContract.getMostRecentCumulativeReward.callAsync(testPoolId); + expect(mostRecentCumulativeReward).to.deep.equal(testRewards[0]); + }); + + it('Should do nothing if a cumulative reward has already been recorded in the current epoch (`lastStoredEpoch == currentEpoch_`)', async () => { + await testContract.addCumulativeReward.awaitTransactionSuccessAsync( + testPoolId, + testRewards[0].numerator, + testRewards[0].denominator, + ); + // this call should not overwrite existing value (testRewards[0]) + await testContract.addCumulativeReward.awaitTransactionSuccessAsync( + testPoolId, + testRewards[1].numerator, + testRewards[1].denominator, + ); + const mostRecentCumulativeReward = await testContract.getMostRecentCumulativeReward.callAsync(testPoolId); + expect(mostRecentCumulativeReward).to.deep.equal(testRewards[0]); + }); + + it('Should set value to normalized sum of `reward/stake` plus most recent cumulative reward, given one exists', async () => { + await testContract.addCumulativeReward.awaitTransactionSuccessAsync( + testPoolId, + testRewards[0].numerator, + testRewards[0].denominator, + ); + await testContract.incrementEpoch.awaitTransactionSuccessAsync(); + await testContract.addCumulativeReward.awaitTransactionSuccessAsync( + testPoolId, + testRewards[1].numerator, + testRewards[1].denominator, + ); + const mostRecentCumulativeReward = await testContract.getMostRecentCumulativeReward.callAsync(testPoolId); + expect(mostRecentCumulativeReward).to.deep.equal(sumOfTestRewardsNormalized); + }); + }); + + describe('_updateCumulativeReward', () => { + it('Should set current cumulative reward to most recent cumulative reward', async () => { + await testContract.addCumulativeReward.awaitTransactionSuccessAsync( + testPoolId, + testRewards[0].numerator, + testRewards[0].denominator, + ); + await testContract.incrementEpoch.awaitTransactionSuccessAsync(); + await testContract.updateCumulativeReward.awaitTransactionSuccessAsync(testPoolId); + const epoch = new BigNumber(2); + const mostRecentCumulativeReward = await testContract.getCumulativeRewardAtEpochRaw.callAsync( + testPoolId, + epoch, + ); + expect(mostRecentCumulativeReward).to.deep.equal(testRewards[0]); + }); + }); + + describe('_computeMemberRewardOverInterval', () => { + const runTest = async ( + amountToStake: BigNumber, + epochOfFirstReward: BigNumber, + epochOfSecondReward: BigNumber, + epochOfIntervalStart: BigNumber, + epochOfIntervalEnd: BigNumber, + ): Promise => { + // Simulate earning reward + await testContract.storeCumulativeReward.awaitTransactionSuccessAsync( + testPoolId, + testRewards[0], + epochOfFirstReward, + ); + await testContract.storeCumulativeReward.awaitTransactionSuccessAsync( + testPoolId, + sumOfTestRewardsNormalized, + epochOfSecondReward, + ); + const reward = await testContract.computeMemberRewardOverInterval.callAsync( + testPoolId, + amountToStake, + epochOfIntervalStart, + epochOfIntervalEnd, + ); + // Compute expected reward + const lhs = sumOfTestRewardsNormalized.numerator.dividedBy(sumOfTestRewardsNormalized.denominator); + const rhs = testRewards[0].numerator.dividedBy(testRewards[0].denominator); + const expectedReward = lhs.minus(rhs).multipliedBy(amountToStake); + // Assert correctness + expect(reward).to.bignumber.equal(expectedReward); + }; + + it('Should successfully compute reward over a valid interval when staking non-zero ZRX', async () => { + const amountToStake = toBaseUnitAmount(1); + const epochOfFirstReward = new BigNumber(1); + const epochOfSecondReward = new BigNumber(2); + const epochOfIntervalStart = new BigNumber(1); + const epochOfIntervalEnd = new BigNumber(2); + await runTest( + amountToStake, + epochOfFirstReward, + epochOfSecondReward, + epochOfIntervalStart, + epochOfIntervalEnd, + ); + }); + + it('Should successfully compute reward if no entry for current epoch, but there is an entry for epoch n-1', async () => { + // End epoch = n-1 forces the code to query the previous epoch's cumulative reward + const amountToStake = toBaseUnitAmount(1); + const epochOfFirstReward = new BigNumber(1); + const epochOfSecondReward = new BigNumber(2); + const epochOfIntervalStart = new BigNumber(1); + const epochOfIntervalEnd = new BigNumber(3); + await runTest( + amountToStake, + epochOfFirstReward, + epochOfSecondReward, + epochOfIntervalStart, + epochOfIntervalEnd, + ); + }); + + it('Should successfully compute reward if no entry for current epoch, but there is an entry for epoch n-2', async () => { + // End epoch = n-2 forces the code to query the most recent cumulative reward + const amountToStake = toBaseUnitAmount(1); + const epochOfFirstReward = new BigNumber(1); + const epochOfSecondReward = new BigNumber(2); + const epochOfIntervalStart = new BigNumber(1); + const epochOfIntervalEnd = new BigNumber(4); + await runTest( + amountToStake, + epochOfFirstReward, + epochOfSecondReward, + epochOfIntervalStart, + epochOfIntervalEnd, + ); + }); + + it('Should successfully compute reward are no cumulative reward entries', async () => { + // No entries forces the default cumulatie reward to be used in computations + const stake = toBaseUnitAmount(1); + const beginEpoch = new BigNumber(1); + const endEpoch = new BigNumber(2); + const reward = await testContract.computeMemberRewardOverInterval.callAsync( + testPoolId, + stake, + beginEpoch, + endEpoch, + ); + expect(reward).to.bignumber.equal(ZERO); + }); + + it('Should return zero if no stake was delegated', async () => { + const stake = toBaseUnitAmount(0); + const beginEpoch = new BigNumber(1); + const endEpoch = new BigNumber(2); + const reward = await testContract.computeMemberRewardOverInterval.callAsync( + testPoolId, + stake, + beginEpoch, + endEpoch, + ); + expect(reward).to.bignumber.equal(ZERO); + }); + + it('Should return zero if the start/end of the interval are the same epoch', async () => { + const stake = toBaseUnitAmount(1); + const beginEpoch = new BigNumber(1); + const endEpoch = new BigNumber(1); + const reward = await testContract.computeMemberRewardOverInterval.callAsync( + testPoolId, + stake, + beginEpoch, + endEpoch, + ); + expect(reward).to.bignumber.equal(ZERO); + }); + + it('Should revert if start is greater than the end of the interval', async () => { + const stake = toBaseUnitAmount(1); + const beginEpoch = new BigNumber(2); + const endEpoch = new BigNumber(1); + const tx = testContract.computeMemberRewardOverInterval.callAsync(testPoolId, stake, beginEpoch, endEpoch); + return expect(tx).to.revertWith('CR_INTERVAL_INVALID'); + }); + }); +}); diff --git a/contracts/staking/tsconfig.json b/contracts/staking/tsconfig.json index 281e750670..9f73beed73 100644 --- a/contracts/staking/tsconfig.json +++ b/contracts/staking/tsconfig.json @@ -41,6 +41,7 @@ "generated-artifacts/TestInitTarget.json", "generated-artifacts/TestLibFixedMath.json", "generated-artifacts/TestLibSafeDowncast.json", + "generated-artifacts/TestMixinCumulativeRewards.json", "generated-artifacts/TestMixinParams.json", "generated-artifacts/TestMixinScheduler.json", "generated-artifacts/TestMixinStake.json",