-
Notifications
You must be signed in to change notification settings - Fork 20
Description
Severity: Medium
Difficulty: High
Type: Data Validation
Found by: Echidna
Finding ID: TOB-UNIFEE-1
Target: UniStaker.sol#L574-L576
Description
notifyRewardAmount()
can be called without transferring the reward tokens first. This would result in insufficient balance by the contract to cover all reward claims.
The impact of this is that users can claim more rewards than they are entitled to due to the rewardRate being artificially high. Eventually, there won't be enough reward tokens in the contract for all beneficiaries to withdraw, and the claimRewards
transactions will revert. When this artificially inflated rewards amount gets high enough, the rewards feature is effectively disabled.
This issues is made possible by a bug in the check that is meant to ensure the reward amount was transferred in with the call:
As shown, the check does not consider any unclaimed reward amounts. When the amount of unclaimed rewards is greater than the new total reward amount, then this check will always pass.
According to the development team, this function is only intended to be called from the claimFees() function in V3FactoryOwner and within that function, the transfer is made prior to this call and no other account will be set as the rewardNotifier
. In this case, if the protocol is deployed as intended, there would be no way to even call notifyRewardAmount()
directly if the only rewardNotifier
is the V3FactoryOwner.
However, there is no guarantee the contracts will be deployed in this manner or that no other account will be set as a rewardNotifier
. While the likelihood of this issue being exploited is extremely low, the consequences if occurred could be significant.
Scenario
See PoC below showing an example of a staker, Eve, who receives double her fair reward due to this.
Mitigation
Short term, there is no clear cut mitigation for this. Here are some options:
- Change the UniStaker.notifyReward() function so that it uses transferFrom to ensure tokens are received.
- Introduce a way to track unclaimed rewards. The total rewards notified could be tracked in one state variable, and the rewards distributed in another. The net of the two is the unclaimed amount.
- Consider tighter restrictions when enabling a new rewardNotifier.
- Does there need to be more than one active rewardIdentifier at any time?
- Does the rewardIdentifier need to be a contract? (i.e. should we check the address has code at time of setting).
Either way, it is important to document this issue in both the UniStaker and V3FactoryOwner contracts as well as any related external documentation.
Long term, stateful invariant tests can help identify issues such as this.
Proof Of Concept
function test_notifyRewardBug_eveStealsRewards() public {
// An alternate reward notifier is set by admin
address alternateRewardNotifier = address(0x123);
vm.prank(admin);
uniStaker.setRewardNotifier(alternateRewardNotifier, true);
// Alice and Eve stake 100 gov tokens each
uint stakeAmount = 100e18;
// Alice stakes
govToken.mint(alice, stakeAmount);
vm.startPrank(alice);
govToken.approve(address(uniStaker), stakeAmount);
uniStaker.stake(stakeAmount, alice);
vm.stopPrank();
// Eve stakes
govToken.mint(eve, stakeAmount);
vm.startPrank(eve);
govToken.approve(address(uniStaker), stakeAmount);
UniStaker.DepositIdentifier eveDepositId = uniStaker.stake(stakeAmount, eve);
vm.stopPrank();
vm.warp(block.timestamp + 1 days);
// Reward notifier calls notifyReward and transfers 10 reward tokens
rewardToken.mint(address(rewardNotifier), 10e18);
vm.startPrank(rewardNotifier);
rewardToken.transfer(address(uniStaker), 10e18);
uniStaker.notifyRewardAmount(10e18);
// Since Alice and Eve have both staked the same amount, they should both be streamed
// 5 reward tokens over the next 7 days
uint expectedRewardPerStaker = 5e18;
vm.warp(block.timestamp + 7 days);
// alternateRewardNotifier calls notifyReward(10) WITHOUT transferring any reward tokens
vm.startPrank(alternateRewardNotifier);
uniStaker.notifyRewardAmount(10e18);
vm.warp(block.timestamp + 7 days);
// Eve claims double sized reward
uint startingBalance = rewardToken.balanceOf(eve);
vm.startPrank(eve);
uniStaker.claimReward();
uint endingBalance = rewardToken.balanceOf(eve);
// Assert that Eve's reward is double the expected reward:
assertEq(endingBalance - startingBalance, 2 * expectedRewardPerStaker - 1); // 1 wei is lost to rounding
// Eve withdraws stake
uniStaker.withdraw(eveDepositId, stakeAmount);
assertEq(govToken.balanceOf(eve), stakeAmount);
vm.stopPrank();
// Eve has gotten away with her stake and a double reward.
// There are no reward tokens left in the contract for Alice
assertEq(rewardToken.balanceOf(address(uniStaker)), 0 + 1); // 1 wei is due to rounding
// The contract's unclaimedRewards is permanently inflated by 10 reward tokens.
// If the contract is to continue normal operations, 10 reward tokens would always be missing.
// If this amount were higher, for example 500 WETH, then this contract would be effectively
// bricked.
}
Echidna
This issue was also identified by Echidna during our invariant testing campaign with the following call sequence:
Using this invariant check and associated handler:
function echidna_rewardTokenBalanceGTERemainingRewards() external returns (bool) {
uint256 remainingTime; // = uniStaker.REWARD_DURATION() - (block.timestamp -
// uniStaker.rewardLastUpdateTime());
if (uniStaker.rewardEndTime() <= block.timestamp) return true; // no remaining rewards to stream
else remainingTime = uniStaker.rewardEndTime() - block.timestamp;
return (
(uniStaker.scaledRewardRate() * remainingTime)
<= (uniStaker.REWARD_TOKEN().balanceOf(address(uniStaker)) * uniStaker.SCALE_FACTOR())
);
}
function adminNotifyRewardsNOTRANSFER(uint256 amount) public {
hevm.prank(rewardNotifier);
uniStaker.notifyRewardAmount(amount);
rewardsNotified += amount;
rewardsSkipped += amount;
}