Platform: Code4rena
Start Date: 31/01/2023
Pot Size: $90,500 USDC
Total HM: 47
Participants: 169
Period: 7 days
Judge: LSDan
Total Solo HM: 9
Id: 211
League: ETH
Rank: 46/169
Findings: 4
Award: $345.36
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: waldenyan20
Also found by: 0xRobocop, KIntern_NA, Ruhum, cccz, hansfriese, mert_eren, minhtrng, peanuts
185.0021 USDC - $185.00
In MultiRewardStaking.changeRewardSpeed
the new rewardsEndTimetamp
is calculated based on the current balance of reward tokens in the contract. However, a fraction of this balance might already be accrued and accounted as reward, but just has not been claimed yet. This can cause more tokens to be claimable than the contract contains, leading to a "first come first serve" scenario for users concerning rewards.
The function MultiRewardStaking.changeRewardSpeed
uses the current reward token balance of the contract as input for the calculation of the new rewardsEndTimeStamp
:
uint256 remainder = rewardToken.balanceOf(address(this)); uint32 prevEndTime = rewards.rewardsEndTimestamp; uint32 rewardsEndTimestamp = _calcRewardsEnd( prevEndTime > block.timestamp ? prevEndTime : block.timestamp.safeCastTo32(), rewardsPerSecond, remainder ); rewardInfos[rewardToken].rewardsPerSecond = rewardsPerSecond; rewardInfos[rewardToken].rewardsEndTimestamp = rewardsEndTimestamp;
The idea is that the current balance of reward tokens is what still needs to be distributed (as the name remainder
implies). However, as mentioned under #Impact a fraction of this balance might already be attributed to users and is just unclaimed. Using the full balance will lead to rewardsEndTimeStamp
being later than it should be, causing more tokens to be accounted for claiming than the contract holds:
function _calcRewardsEnd( ... //amount = rewardToken.balanceOf(address(this)); return (block.timestamp + (amount / uint256(rewardsPerSecond))).safeCastTo32(); }
Manual Review
Only the fraction of reward tokens that has not yet accrued should be used to calculate the rewardsEndTimestamp
when changing the rewardspeed. A separate variable might be necessary to track the amount of tokens that are eligible to be claimed. That variable would need to be incremented on all _accrueStatic
calls that occur when totalSupply > 0
.
#0 - c4-judge
2023-02-16T03:56:45Z
dmvt marked the issue as duplicate of #156
#1 - c4-sponsor
2023-02-18T11:59:19Z
RedVeil marked the issue as sponsor confirmed
#2 - c4-judge
2023-02-23T16:05:16Z
dmvt changed the severity to QA (Quality Assurance)
#3 - Simon-Busch
2023-02-23T16:15:33Z
Revert back to M as requested by @dmvt
#4 - c4-judge
2023-02-23T22:32:03Z
dmvt marked the issue as satisfactory
#5 - c4-judge
2023-02-25T14:31:49Z
dmvt marked the issue as not a duplicate
#6 - c4-judge
2023-02-25T14:31:57Z
dmvt marked the issue as duplicate of #191
#7 - c4-judge
2023-02-25T14:48:44Z
dmvt changed the severity to 3 (High Risk)
#8 - c4-judge
2023-02-25T15:27:46Z
dmvt marked the issue as partial-50
#9 - c4-judge
2023-03-01T00:41:54Z
dmvt marked the issue as satisfactory
🌟 Selected for report: immeas
Also found by: KIntern_NA, bin2chen, minhtrng, rbserver, rvierdiiev, ustas, yellowBirdy
69.3758 USDC - $69.38
The Vault
contract syncs the highWaterMark
on deposit, mint and withdraw, which can be used to avoid paying the performance fees, by performing any of these actions with a tiny amount. After Clarification with the sponsor, this was caused by a merge gone wrong and is not supposed to be there
The highWaterMark
is used as a threshold to trigger the payment of a performance fee:
function accruedPerformanceFee() public view returns (uint256) { uint256 highWaterMark_ = highWaterMark; uint256 shareValue = convertToAssets(1e18); uint256 performanceFee = fees.performance; return performanceFee > 0 && shareValue > highWaterMark ? performanceFee.mulDiv( (shareValue - highWaterMark) * totalSupply(), 1e36, Math.Rounding.Down ) : 0; }
The Vault
contains the following modifier used for deposits, mints and withdraws:
modifier syncFeeCheckpoint() { _; highWaterMark = convertToAssets(1e18); }
This modifier can be abused by performing tiny deposits, mints and withdraws to move the highWaterMark
to current share values to avoid the triggering of the performance fee.
Manual Review
Remove the syncFeeCheckpoint
modifier and all its usages from Vault
#0 - c4-judge
2023-02-16T06:11:45Z
dmvt marked the issue as duplicate of #30
#1 - c4-sponsor
2023-02-18T12:05:25Z
RedVeil marked the issue as sponsor confirmed
#2 - c4-judge
2023-02-23T00:12:51Z
dmvt marked the issue as satisfactory
55.5006 USDC - $55.50
https://github.com/code-423n4/2023-01-popcorn/blob/d95fc31449c260901811196d617366d6352258cd/src/utils/MultiRewardStaking.sol#L308-L312 https://github.com/code-423n4/2023-01-popcorn/blob/d95fc31449c260901811196d617366d6352258cd/src/utils/MultiRewardStaking.sol#L351-L360
The function _calcRewardsEnd
is called with the previousEndTime
as first parameter in MultiRewardStaking.changeRewardSpeed
, which leads to wrong calculation of the new rewardsEndTimestamp
, causing it to be later than it should be. This will lead to more rewards being accrued "on paper" than the contract contains.
The function MultiRewardStaking.changeRewardSpeed
calls the _calcRewardsEnd
function like this:
uint32 rewardsEndTimestamp = _calcRewardsEnd( prevEndTime > block.timestamp ? prevEndTime : block.timestamp.safeCastTo32(), rewardsPerSecond, remainder );
If prevEndTime > block.timestamp == true
then the following block in calcRewardsEnd
will be executed:
if (rewardsEndTimestamp > block.timestamp) amount += uint256(rewardsPerSecond) * (rewardsEndTimestamp - block.timestamp);
A quick calculation why the lines above should not be executed in this case:
function _calcRewardsEnd( uint32 rewardsEndTimestamp, // @note previousRewardsEndtime = 1000; block.Timestamp=700 uint160 rewardsPerSecond, //@note newRewardsPersecond = 10 (previous = 1) uint256 amount //@note currentRewardTokenBalance = 300 ) internal returns (uint32) { if (rewardsEndTimestamp > block.timestamp) //@note with values above: amount = 300 + 10*300 = 3300 // this logic is only compatible with `fundReward` (where `amount` is meant to be added rewards, //while the righthandside calculates current reward amounts left) amount += uint256(rewardsPerSecond) * (rewardsEndTimestamp - block.timestamp); //@note 700 + 3300/10 = 1030 (should be 730, since no new tokens have entered the contract) //will try to distribute 330*10 = 3300 tokens even though there are only 300 in the contract //enables "theft" of rewards or permanent (?) lock of reward tokens (for some scenarios where each participant is entitled to more than the actual balance) return (block.timestamp + (amount / uint256(rewardsPerSecond))).safeCastTo32(); }
Manual Review
Call calcRewardsEnd
the same way as is done in the addRewardToken
function: _calcRewardsEnd(0, rewardsPerSecond, amount);
(first parameter = 0)
#0 - c4-judge
2023-02-16T03:56:54Z
dmvt marked the issue as duplicate of #156
#1 - c4-sponsor
2023-02-18T11:59:22Z
RedVeil marked the issue as sponsor confirmed
#2 - c4-judge
2023-02-23T16:05:16Z
dmvt changed the severity to QA (Quality Assurance)
#3 - Simon-Busch
2023-02-23T16:16:05Z
Revert back to M as requested by @dmvt
#4 - c4-judge
2023-02-23T22:32:47Z
dmvt marked the issue as satisfactory