diff --git a/contracts/components/staking/rewards/RewardsDistributor.sol b/contracts/components/staking/rewards/RewardsDistributor.sol index ca5d6a33..f3ade486 100644 --- a/contracts/components/staking/rewards/RewardsDistributor.sol +++ b/contracts/components/staking/rewards/RewardsDistributor.sol @@ -53,6 +53,9 @@ contract RewardsDistributor is BaseComponentUpgradeable, SubjectTypeValidator, I uint256 public delegationParamsEpochDelay; uint256 public defaultFeeBps; + // subject => epoch => claimedByPoolOwner + mapping(uint256 => mapping(uint256 => bool)) public poolRewardsAtEpochClaimedByOwner; + event DidAccumulateRate(uint8 indexed subjectType, uint256 indexed subject, address indexed staker, uint256 stakeAmount, uint256 sharesAmount); event DidReduceRate(uint8 indexed subjectType, uint256 indexed subject, address indexed staker, uint256 stakeAmount, uint256 sharesAmount); event Rewarded(uint8 indexed subjectType, uint256 indexed subject, uint256 amount, uint256 epochNumber); @@ -63,6 +66,7 @@ contract RewardsDistributor is BaseComponentUpgradeable, SubjectTypeValidator, I event TokensSwept(address indexed token, address to, uint256 amount); error RewardingNonRegisteredSubject(uint8 subjectType, uint256 subject); + error AlreadyClaimedByOwner(); error AlreadyClaimed(); error AlreadyRewarded(uint256 epochNumber); error SetDelegationFeeNotReady(); @@ -153,6 +157,9 @@ contract RewardsDistributor is BaseComponentUpgradeable, SubjectTypeValidator, I function availableReward(uint8 subjectType, uint256 subjectId, uint256 epochNumber, address staker) public view returns (uint256) { (uint256 shareId, bool isDelegator) = _getShareId(subjectType, subjectId); + if (!isDelegator && (_subjectGateway.ownerOf(subjectType, subjectId) != staker || poolRewardsAtEpochClaimedByOwner[subjectId][epochNumber])) { + return 0; + } if (claimedRewardsPerEpoch[shareId][epochNumber][staker]) { return 0; } @@ -221,7 +228,11 @@ contract RewardsDistributor is BaseComponentUpgradeable, SubjectTypeValidator, I if (_subjectGateway.ownerOf(subjectType, subjectId) != _msgSender()) revert SenderNotOwner(_msgSender(), subjectId); } for (uint256 i = 0; i < epochNumbers.length; i++) { + if (!isDelegator && poolRewardsAtEpochClaimedByOwner[subjectId][epochNumbers[i]]) { + revert AlreadyClaimedByOwner(); + } if (claimedRewardsPerEpoch[shareId][epochNumbers[i]][_msgSender()]) revert AlreadyClaimed(); + if (!isDelegator) poolRewardsAtEpochClaimedByOwner[subjectId][epochNumbers[i]] = true; claimedRewardsPerEpoch[shareId][epochNumbers[i]][_msgSender()] = true; uint256 epochRewards = _availableReward(shareId, isDelegator, epochNumbers[i], _msgSender()); if (epochRewards == 0) revert ZeroAmount("epochRewards"); diff --git a/test/components/staking.rewards.test.js b/test/components/staking.rewards.test.js index 9db43075..630bcb96 100644 --- a/test/components/staking.rewards.test.js +++ b/test/components/staking.rewards.test.js @@ -131,13 +131,83 @@ describe('Staking Rewards', function () { expect(await this.rewardsDistributor.availableReward(DELEGATOR_SUBJECT_TYPE, SCANNER_POOL_ID, epoch, this.accounts.user2.address)).to.be.equal('0'); await expect(this.rewardsDistributor.connect(this.accounts.user1).claimRewards(SCANNER_POOL_SUBJECT_TYPE, SCANNER_POOL_ID, [epoch])).to.be.revertedWith( - 'AlreadyClaimed()' + 'AlreadyClaimedByOwner()' ); await expect(this.rewardsDistributor.connect(this.accounts.user2).claimRewards(DELEGATOR_SUBJECT_TYPE, SCANNER_POOL_ID, [epoch])).to.be.revertedWith( 'AlreadyClaimed()' ); }); + it('should fail to reclaim for same pool and epoch even if pool ownership is transferred', async function () { + // disable automine so deposits are instantaneous to simplify math + await network.provider.send('evm_setAutomine', [false]); + await this.staking.connect(this.accounts.user1).deposit(SCANNER_POOL_SUBJECT_TYPE, SCANNER_POOL_ID, '100'); + await this.scannerPools.connect(this.accounts.user1).registerScannerNode(registration, signature); + await network.provider.send('evm_setAutomine', [true]); + await network.provider.send('evm_mine'); + + expect(await this.stakeAllocator.allocatedManagedStake(SCANNER_POOL_SUBJECT_TYPE, SCANNER_POOL_ID)).to.be.equal('100'); + + const latestTimestamp = await helpers.time.latest(); + const timeToNextEpoch = EPOCH_LENGTH - ((latestTimestamp - OFFSET) % EPOCH_LENGTH); + await helpers.time.increase(Math.floor(timeToNextEpoch / 2)); + + const epoch = await this.rewardsDistributor.getCurrentEpochNumber(); + + await helpers.time.increase(1 + EPOCH_LENGTH /* 1 week */); + + expect(await this.rewardsDistributor.availableReward(SCANNER_POOL_SUBJECT_TYPE, SCANNER_POOL_ID, epoch, this.accounts.user1.address)).to.be.equal('0'); + + await this.rewardsDistributor.connect(this.accounts.manager).reward(SCANNER_POOL_SUBJECT_TYPE, SCANNER_POOL_ID, '2000', epoch); + + expect(await this.rewardsDistributor.availableReward(SCANNER_POOL_SUBJECT_TYPE, SCANNER_POOL_ID, epoch, this.accounts.user1.address)).to.be.equal('2000'); + + const balanceBefore1 = await this.token.balanceOf(this.accounts.user1.address); + + await this.rewardsDistributor.connect(this.accounts.user1).claimRewards(SCANNER_POOL_SUBJECT_TYPE, SCANNER_POOL_ID, [epoch]); + + expect(await this.token.balanceOf(this.accounts.user1.address)).to.eq(balanceBefore1.add('2000')); + expect(await this.rewardsDistributor.availableReward(SCANNER_POOL_SUBJECT_TYPE, SCANNER_POOL_ID, epoch, this.accounts.user1.address)).to.be.equal('0'); + + await expect(this.rewardsDistributor.connect(this.accounts.user1).claimRewards(SCANNER_POOL_SUBJECT_TYPE, SCANNER_POOL_ID, [epoch])).to.be.revertedWith( + 'AlreadyClaimedByOwner()' + ); + + expect(await this.scannerPools.ownerOf(SCANNER_POOL_ID)).to.eq(this.accounts.user1.address); + await this.scannerPools.connect(this.accounts.user1).transferFrom(this.accounts.user1.address, this.accounts.user2.address, SCANNER_POOL_ID); + expect(await this.scannerPools.ownerOf(SCANNER_POOL_ID)).to.eq(this.accounts.user2.address); + + expect(await this.rewardsDistributor.availableReward(SCANNER_POOL_SUBJECT_TYPE, SCANNER_POOL_ID, epoch, this.accounts.user2.address)).to.be.equal('0'); + + await expect(this.rewardsDistributor.connect(this.accounts.user2).claimRewards(SCANNER_POOL_SUBJECT_TYPE, SCANNER_POOL_ID, [epoch])).to.be.revertedWith( + 'AlreadyClaimedByOwner()' + ); + + const epochTwo = await this.rewardsDistributor.getCurrentEpochNumber(); + + await helpers.time.increase(1 + EPOCH_LENGTH /* 1 week */); + + expect(await this.rewardsDistributor.availableReward(SCANNER_POOL_SUBJECT_TYPE, SCANNER_POOL_ID, epochTwo, this.accounts.user1.address)).to.be.equal('0'); + expect(await this.rewardsDistributor.availableReward(SCANNER_POOL_SUBJECT_TYPE, SCANNER_POOL_ID, epochTwo, this.accounts.user2.address)).to.be.equal('0'); + + await this.rewardsDistributor.connect(this.accounts.manager).reward(SCANNER_POOL_SUBJECT_TYPE, SCANNER_POOL_ID, '2000', epochTwo); + + expect(await this.rewardsDistributor.availableReward(SCANNER_POOL_SUBJECT_TYPE, SCANNER_POOL_ID, epochTwo, this.accounts.user1.address)).to.be.equal('0'); + expect(await this.rewardsDistributor.availableReward(SCANNER_POOL_SUBJECT_TYPE, SCANNER_POOL_ID, epochTwo, this.accounts.user2.address)).to.be.equal('2000'); + await expect(this.rewardsDistributor.connect(this.accounts.user1).claimRewards(SCANNER_POOL_SUBJECT_TYPE, SCANNER_POOL_ID, [epochTwo])).to.be.revertedWith( + `SenderNotOwner("${this.accounts.user1.address}", ${SCANNER_POOL_ID})` + ); + + const balanceBefore2 = await this.token.balanceOf(this.accounts.user2.address); + + await this.rewardsDistributor.connect(this.accounts.user2).claimRewards(SCANNER_POOL_SUBJECT_TYPE, SCANNER_POOL_ID, [epochTwo]); + + expect(await this.token.balanceOf(this.accounts.user2.address)).to.eq(balanceBefore2.add('2000')); + await expect(this.rewardsDistributor.connect(this.accounts.user2).claimRewards(SCANNER_POOL_SUBJECT_TYPE, SCANNER_POOL_ID, [epochTwo])).to.be.revertedWith( + 'AlreadyClaimedByOwner()' + ); + }); + it('should fail to reclaim if no rewards available', async function () { await expect(this.rewardsDistributor.connect(this.accounts.user1).claimRewards(SCANNER_POOL_SUBJECT_TYPE, SCANNER_POOL_ID, [1])).to.be.revertedWith( 'ZeroAmount("epochRewards")'