diff --git a/contracts/RewardsDistributor.sol b/contracts/RewardsDistributor.sol index 2af137d9..a4e4ecbe 100644 --- a/contracts/RewardsDistributor.sol +++ b/contracts/RewardsDistributor.sol @@ -77,6 +77,14 @@ contract RewardsDistributor is IRewardsDistributor, Initializable, OwnableUpgrad /// @notice Reward information: runner => RewardInfo mapping(address => RewardInfo) private info; + /// @notice perMill, = * + /// 0: disabled + uint256 public maxCommissionFactor; + + /// @notice perMill, = * + /// 0: disabled + uint256 public maxRewardFactor; + /// @dev ### EVENTS /// @notice Emitted when rewards are distributed for the earliest pending distributed Era. event DistributeRewards( @@ -98,6 +106,8 @@ contract RewardsDistributor is IRewardsDistributor, Initializable, OwnableUpgrad event InstantRewards(address indexed runner, uint256 indexed eraIdx, uint256 token); /// @notice Emitted when rewards arrive via increaseAgreementRewards() event AgreementRewards(address indexed runner, uint256 agreementId, uint256 token); + /// @notice Emitted when rewards return to treasury due to exceed reward cap + event ReturnRewards(address indexed runner, uint256 rewards, uint256 commission); modifier onlyRewardsStaking() { require(msg.sender == settings.getContractAddress(SQContracts.RewardsStaking), 'G014'); @@ -119,6 +129,14 @@ contract RewardsDistributor is IRewardsDistributor, Initializable, OwnableUpgrad settings = _settings; } + function setMaxCommissionFactor(uint256 _maxCommissionFactor) external onlyOwner { + maxCommissionFactor = _maxCommissionFactor; + } + + function setMaxRewardFactor(uint256 _maxRewardFactor) external onlyOwner { + maxRewardFactor = _maxRewardFactor; + } + /** * @notice Initialize the runner first last claim era. * Only RewardsStaking can call. @@ -347,36 +365,55 @@ contract RewardsDistributor is IRewardsDistributor, Initializable, OwnableUpgrad delete rewardInfo.eraRewardRemoveTable[rewardInfo.lastClaimEra]; if (rewardInfo.eraReward != 0) { uint256 totalStake = rewardsStaking.getTotalStakingAmount(runner); + uint256 selfStake = rewardsStaking.getDelegationAmount(runner, runner); require(totalStake > 0, 'RD006'); uint256 commissionRate = IIndexerRegistry( settings.getContractAddress(SQContracts.IndexerRegistry) ).getCommissionRate(runner); - uint256 commission = MathUtil.mulDiv(commissionRate, rewardInfo.eraReward, PER_MILL); - - info[runner].accSQTPerStake += MathUtil.mulDiv( - rewardInfo.eraReward - commission, + uint256 commission = commissionRate.mulDiv(rewardInfo.eraReward, PER_MILL); + + // 1. total reward can not greater than maxRewardFactor * totalStake + // 2. commission can not greater than maxCommissionFactor * selfStake + uint256 cappedReward = maxRewardFactor > 0 + ? MathUtil.min(rewardInfo.eraReward, totalStake.mulDiv(maxRewardFactor, PER_MILL)) + : rewardInfo.eraReward; + uint256 cappedCommission = maxCommissionFactor > 0 + ? MathUtil.min(commission, selfStake.mulDiv(maxCommissionFactor, PER_MILL)) + : commission; + info[runner].accSQTPerStake += cappedReward.sub(cappedCommission).mulDiv( PER_TRILL, totalStake ); - if (commission > 0) { + IERC20 SQToken = IERC20(settings.getContractAddress(SQContracts.SQToken)); + if (cappedCommission > 0) { // add commission to unbonding request - IERC20(settings.getContractAddress(SQContracts.SQToken)).safeTransfer( + SQToken.safeTransfer( settings.getContractAddress(SQContracts.Staking), - commission + cappedCommission ); IStaking(settings.getContractAddress(SQContracts.Staking)).unbondCommission( runner, - commission + cappedCommission ); } emit DistributeRewards( runner, rewardInfo.lastClaimEra, - rewardInfo.eraReward, - commission + MathUtil.max(cappedReward, cappedCommission), + cappedCommission ); + if (rewardInfo.eraReward - cappedReward > 0 || commission - cappedCommission > 0) { + uint256 rewardsReturn; + rewardsReturn += + (rewardInfo.eraReward - commission) - + (cappedReward.sub(cappedCommission)); + rewardsReturn += commission - cappedCommission; + address treasury = ISettings(settings).getContractAddress(SQContracts.Treasury); + SQToken.safeTransfer(treasury, rewardsReturn); + emit ReturnRewards(runner, rewardsReturn, commission - cappedCommission); + } } return rewardInfo.lastClaimEra; } diff --git a/contracts/StakingManager.sol b/contracts/StakingManager.sol index 15ae5573..93fb6b80 100644 --- a/contracts/StakingManager.sol +++ b/contracts/StakingManager.sol @@ -189,6 +189,15 @@ contract StakingManager is IStakingManager, Initializable, OwnableUpgradeable { return StakingUtil.currentStaking(sm, _currentEra); } + function getDelegationAmount(address _source, address _runner) public view returns (uint256) { + uint256 eraNumber = IEraManager(settings.getContractAddress(SQContracts.EraManager)) + .eraNumber(); + Staking staking = Staking(settings.getContractAddress(SQContracts.Staking)); + (uint256 era, uint256 valueAt, uint256 valueAfter) = staking.delegation(_source, _runner); + StakingAmount memory sm = StakingAmount(era, valueAt, valueAfter); + return StakingUtil.currentStaking(sm, eraNumber); + } + function getTotalStakingAmount(address _runner) public view override returns (uint256) { uint256 eraNumber = IEraManager(settings.getContractAddress(SQContracts.EraManager)) .eraNumber(); diff --git a/publish/ABI/RewardsDistributor.json b/publish/ABI/RewardsDistributor.json index e517efde..0d5de298 100644 --- a/publish/ABI/RewardsDistributor.json +++ b/publish/ABI/RewardsDistributor.json @@ -137,6 +137,31 @@ "name": "OwnershipTransferred", "type": "event" }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "runner", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "rewards", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "commission", + "type": "uint256" + } + ], + "name": "ReturnRewards", + "type": "event" + }, { "anonymous": false, "inputs": [ @@ -404,6 +429,32 @@ "stateMutability": "nonpayable", "type": "function" }, + { + "inputs": [], + "name": "maxCommissionFactor", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "maxRewardFactor", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [], "name": "owner", @@ -460,6 +511,32 @@ "stateMutability": "nonpayable", "type": "function" }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_maxCommissionFactor", + "type": "uint256" + } + ], + "name": "setMaxCommissionFactor", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_maxRewardFactor", + "type": "uint256" + } + ], + "name": "setMaxRewardFactor", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, { "inputs": [ { diff --git a/publish/ABI/StakingManager.json b/publish/ABI/StakingManager.json index 89abf26c..88e8bb1d 100644 --- a/publish/ABI/StakingManager.json +++ b/publish/ABI/StakingManager.json @@ -86,6 +86,30 @@ "stateMutability": "view", "type": "function" }, + { + "inputs": [ + { + "internalType": "address", + "name": "_source", + "type": "address" + }, + { + "internalType": "address", + "name": "_runner", + "type": "address" + } + ], + "name": "getDelegationAmount", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [ { diff --git a/test/RewardsDistributer.test.ts b/test/RewardsDistributer.test.ts index cf7c9b64..ea8716d5 100644 --- a/test/RewardsDistributer.test.ts +++ b/test/RewardsDistributer.test.ts @@ -17,7 +17,7 @@ import { StakingManager, } from '../src'; import { DEPLOYMENT_ID, METADATA_HASH, VERSION } from './constants'; -import { acceptPlan, etherParse, startNewEra, time, timeTravel } from './helper'; +import { acceptPlan, addInstantRewards, etherParse, eventFrom, startNewEra, time, timeTravel } from './helper'; import { deployContracts } from './setup'; describe('RewardsDistributor Contract', () => { @@ -72,10 +72,10 @@ describe('RewardsDistributor Contract', () => { await token.connect(root).transfer(delegator.address, etherParse('10')); await token.connect(root).transfer(delegator2.address, etherParse('10')); await token.connect(root).transfer(consumer.address, etherParse('10')); - await token.connect(consumer).increaseAllowance(planManager.address, etherParse('10')); - await token.connect(delegator).increaseAllowance(staking.address, etherParse('10')); - await token.connect(delegator2).increaseAllowance(staking.address, etherParse('10')); - await token.connect(root).increaseAllowance(rewardsDistributor.address, etherParse('10')); + await token.connect(consumer).increaseAllowance(planManager.address, etherParse('10000')); + await token.connect(delegator).increaseAllowance(staking.address, etherParse('10000')); + await token.connect(delegator2).increaseAllowance(staking.address, etherParse('10000')); + await token.connect(root).increaseAllowance(rewardsDistributor.address, etherParse('10000')); //setup era period be 5 days await eraManager.connect(root).updateEraPeriod(time.duration.days(5).toString()); @@ -131,6 +131,86 @@ describe('RewardsDistributor Contract', () => { }); }); + describe('Capped Rewards', async () => { + beforeEach(async () => { + await token.connect(root).transfer(runner.address, etherParse('10000')); + await token.connect(root).transfer(delegator.address, etherParse('10000')); + await token.connect(root).transfer(consumer.address, etherParse('10000')); + await stakingManager.connect(delegator).delegate(runner.address, etherParse(9000)); + await startNewEra(eraManager); + await rewardsHelper.connect(runner).indexerCatchup(runner.address); + }); + it('receive capped rewards', async () => { + // self stake 1000 SQT + // delegation: 9000 SQT + // commission rate: 5% + // reward cap: 10% + // commission cap: 10% + // arrival rewards: 1500 SQT + await rewardsDistributor.setMaxCommissionFactor(5e4); + await rewardsDistributor.setMaxRewardFactor(1e5); + const totalStake = await stakingManager.getTotalStakingAmount(runner.address); + expect(totalStake).to.eq(etherParse(10000)); + const selfStake = await stakingManager.getDelegationAmount(runner.address, runner.address); + expect(selfStake).to.eq(etherParse(1000)); + const arrivalReward = etherParse(1500); + const era = await eraManager.eraNumber(); + await addInstantRewards(token, rewardsDistributor, consumer, runner.address, era, arrivalReward); + await startNewEra(eraManager); + const tx = await rewardsDistributor.connect(runner).collectAndDistributeRewards(runner.address); + const distributedRewards = await eventFrom( + tx, + rewardsDistributor, + 'DistributeRewards(address,uint256,uint256,uint256)' + ); + expect(distributedRewards.rewards).to.eq(etherParse(1000)); + expect(distributedRewards.commission).to.eq(etherParse(50)); + const returnRewards = await eventFrom(tx, rewardsDistributor, 'ReturnRewards(address,uint256,uint256)'); + expect(returnRewards.rewards).to.eq(etherParse(500)); + expect(returnRewards.commission).to.eq(etherParse(100)); + }); + it('rewards after capped may become zero', async () => { + // self stake 9000 SQT + // delegation: 9000 SQT + // commission rate: 30% + // reward cap: 10% + // commission cap: 25% + // arrival rewards: 10000 SQT + // commission: 3000 SQT + // capped commission: 2250 SQT + // capped reward: 1800 SQT + await rewardsDistributor.setMaxCommissionFactor(2.5e5); + await rewardsDistributor.setMaxRewardFactor(1e5); + await token.connect(runner).increaseAllowance(staking.address, etherParse(8000)); + await stakingManager.connect(runner).stake(runner.address, etherParse(8000)); + await indexerRegistry.connect(runner).setCommissionRate(3e5); + await startNewEra(eraManager); + await rewardsHelper.connect(runner).indexerCatchup(runner.address); + await startNewEra(eraManager); + await rewardsHelper.connect(runner).indexerCatchup(runner.address); + + const totalStake = await stakingManager.getTotalStakingAmount(runner.address); + expect(totalStake).to.eq(etherParse(18000)); + const selfStake = await stakingManager.getDelegationAmount(runner.address, runner.address); + expect(selfStake).to.eq(etherParse(9000)); + const arrivalReward = etherParse(10000); + const era = await eraManager.eraNumber(); + await addInstantRewards(token, rewardsDistributor, consumer, runner.address, era, arrivalReward); + await startNewEra(eraManager); + const tx = await rewardsDistributor.connect(runner).collectAndDistributeRewards(runner.address); + const distributedRewards = await eventFrom( + tx, + rewardsDistributor, + 'DistributeRewards(address,uint256,uint256,uint256)' + ); + expect(distributedRewards.rewards).to.eq(etherParse(2250)); // exclude commission + expect(distributedRewards.commission).to.eq(etherParse(2250)); + const returnRewards = await eventFrom(tx, rewardsDistributor, 'ReturnRewards(address,uint256,uint256)'); + expect(returnRewards.rewards).to.eq(etherParse(7750)); + expect(returnRewards.commission).to.eq(etherParse(750)); + }); + }); + describe('distribute and claim rewards', async () => { beforeEach(async () => { //a 30 days agreement with 400 rewards come in at Era2 @@ -144,14 +224,14 @@ describe('RewardsDistributor Contract', () => { expect((await rewardsHelper.getRewardsAddTable(runner.address, 2, 1))[0]).to.be.eq(etherParse('0')); expect((await rewardsHelper.getRewardsRemoveTable(runner.address, 2, 1))[0]).to.be.eq(etherParse('0')); await rewardsDistributor.connect(runner).claim(runner.address); - + rewards = (await token.balanceOf(runner.address)).div(1e14); //move to Era 4 await startNewEra(eraManager); await rewardsDistributor.collectAndDistributeRewards(runner.address); expect((await rewardsHelper.getRewardsAddTable(runner.address, 3, 1))[0]).to.be.eq(etherParse('0')); expect((await rewardsHelper.getRewardsRemoveTable(runner.address, 3, 1))[0]).to.be.eq(etherParse('0')); await rewardsDistributor.connect(runner).claim(runner.address); - + rewards = (await token.balanceOf(runner.address)).div(1e14); //move to Era 5 await startNewEra(eraManager); await rewardsDistributor.collectAndDistributeRewards(runner.address);