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: 20/169
Findings: 7
Award: $1,163.03
π Selected for report: 1
π Solo Findings: 0
π Selected for report: waldenyan20
Also found by: 0xRobocop, KIntern_NA, Ruhum, cccz, hansfriese, mert_eren, minhtrng, peanuts
92.501 USDC - $92.50
In MultiRewardStaking.changeRewardSpeed, the end time is adjusted using the balance of rewardTokens, which will include vested but unclaimed rewardTokens and unvested rewardTokens.
function changeRewardSpeed(IERC20 rewardToken, uint160 rewardsPerSecond) external onlyOwner { RewardInfo memory rewards = rewardInfos[rewardToken]; if (rewardsPerSecond == 0) revert ZeroAmount(); if (rewards.lastUpdatedTimestamp == 0) revert RewardTokenDoesntExist(rewardToken); if (rewards.rewardsPerSecond == 0) revert RewardsAreDynamic(rewardToken); _accrueRewards(rewardToken, _accrueStatic(rewards)); uint256 remainder = rewardToken.balanceOf(address(this)); uint32 prevEndTime = rewards.rewardsEndTimestamp; uint32 rewardsEndTimestamp = _calcRewardsEnd( prevEndTime > block.timestamp ? prevEndTime : block.timestamp.safeCastTo32(), rewardsPerSecond, remainder );
But in _calcRewardsEnd the unvested rewardTokens are added to the calculation again, which can cause the calculated end time to be inflated and lead to incorrect rewardToken distribution because the rewardToken is not sufficient to cover the end time.
function _calcRewardsEnd( uint32 rewardsEndTimestamp, uint160 rewardsPerSecond, uint256 amount ) internal returns (uint32) { if (rewardsEndTimestamp > block.timestamp) amount += uint256(rewardsPerSecond) * (rewardsEndTimestamp - block.timestamp); // @auidt: unvested rewardTokens return (block.timestamp + (amount / uint256(rewardsPerSecond))).safeCastTo32(); }
The POC is as follows, the total number of reward tokens is 10 eth, and the initial rewardsPerSecond == 0.1 eth. alice/bob deposit 1 eth respectively. After 10s, the unvested reward is 9 eth, adjust rewardsPerSecond to 0.2. At this time, the end time should be 9/0.2 = 45, but the actual result is greater than 45. And after another 80s, alice's reward is much greater than 5 eth (the actual alice and bob should share the 10 eth reward equally) If alice takes out the reward at this time, bob will not be able to take it out due to insufficient rewards.
function test__POC() public { _addRewardToken(rewardToken1); (, , uint32 oldRewardsEndTimestamp, , ) = staking.rewardInfos(iRewardToken1); assertEq(uint256(oldRewardsEndTimestamp) - block.timestamp, 100); stakingToken.mint(alice, 1 ether); stakingToken.mint(bob, 1 ether); vm.prank(alice); stakingToken.approve(address(staking), 1 ether); vm.prank(alice); staking.deposit(1 ether); vm.prank(bob); stakingToken.approve(address(staking), 1 ether); vm.prank(bob); staking.deposit(1 ether); // 10% of rewards paid out vm.warp(block.timestamp + 10); // Double Accrual (from original) staking.changeRewardSpeed(iRewardToken1, 0.2 ether); (, , uint32 RewardsEndTimestamp, , ) = staking.rewardInfos(iRewardToken1); assertGt(uint256(RewardsEndTimestamp) - block.timestamp, 45); vm.warp(block.timestamp + 80); vm.prank(alice); staking.withdraw(1 ether); vm.prank(bob); staking.withdraw(1 ether); assertGt(staking.accruedRewards(alice, iRewardToken1), 5 ether); IERC20[] memory rewardsTokenKeys = new IERC20[](1); rewardsTokenKeys[0] = iRewardToken1; staking.claimRewards(alice, rewardsTokenKeys); // staking.claimRewards(bob, rewardsTokenKeys); @ audit: ERC20: transfer amount exceeds balance }
https://github.com/code-423n4/2023-01-popcorn/blob/d95fc31449c260901811196d617366d6352258cd/src/utils/MultiRewardStaking.sol#L296-L315 https://github.com/code-423n4/2023-01-popcorn/blob/d95fc31449c260901811196d617366d6352258cd/src/utils/MultiRewardStaking.sol#L351-L360
None
function changeRewardSpeed(IERC20 rewardToken, uint160 rewardsPerSecond) external onlyOwner { RewardInfo memory rewards = rewardInfos[rewardToken]; if (rewardsPerSecond == 0) revert ZeroAmount(); if (rewards.lastUpdatedTimestamp == 0) revert RewardTokenDoesntExist(rewardToken); if (rewards.rewardsPerSecond == 0) revert RewardsAreDynamic(rewardToken); _accrueRewards(rewardToken, _accrueStatic(rewards)); - uint256 remainder = rewardToken.balanceOf(address(this)); uint32 prevEndTime = rewards.rewardsEndTimestamp; uint32 rewardsEndTimestamp = _calcRewardsEnd( prevEndTime > block.timestamp ? prevEndTime : block.timestamp.safeCastTo32(), rewardsPerSecond, - remainder + 0 ); rewardInfos[rewardToken].rewardsPerSecond = rewardsPerSecond; rewardInfos[rewardToken].rewardsEndTimestamp = rewardsEndTimestamp; }
#0 - c4-judge
2023-02-16T03:55:53Z
dmvt marked the issue as duplicate of #156
#1 - c4-sponsor
2023-02-18T11:57:08Z
RedVeil marked the issue as sponsor confirmed
#2 - c4-sponsor
2023-02-18T11:58:44Z
RedVeil marked the issue as disagree with severity
#3 - c4-judge
2023-02-23T15:22:13Z
dmvt marked the issue as not a duplicate
#4 - c4-judge
2023-02-23T15:22:23Z
dmvt marked the issue as duplicate of #191
#5 - c4-judge
2023-02-23T22:51:14Z
dmvt marked the issue as partial-50
#6 - c4-judge
2023-02-25T14:37:41Z
dmvt marked the issue as full credit
#7 - c4-judge
2023-02-25T14:40:30Z
dmvt marked the issue as partial-50
π Selected for report: 0xdeadbeef0x
Also found by: 0Kage, 0xNazgul, 0xRobocop, Aymen0909, KIntern_NA, Kenshin, KingNFT, Krace, Kumpa, SadBase, aashar, apvlki, btk, cccz, critical-or-high, eccentricexit, fs0c, gjaldon, hansfriese, immeas, mert_eren, mgf15, mrpathfindr, orion, peanuts, rvi0x, rvierdiiev, supernova, ulqiorra, waldenyan20, y1cunhui
1.1529 USDC - $1.15
In MultiRewardStaking.claimRewards, the accruedRewards are reset after the token transfer. If the rewardToken is an ERC777 token, the malicious user can call claimRewards again in the token transfer callback, and since the accruedRewards have not been reset, the user can claim the reward again until the ERC777 reward tokens in the contract are exhausted.
function claimRewards(address user, IERC20[] memory _rewardTokens) external accrueRewards(msg.sender, user) { for (uint8 i; i < _rewardTokens.length; i++) { uint256 rewardAmount = accruedRewards[user][_rewardTokens[i]]; if (rewardAmount == 0) revert ZeroRewards(_rewardTokens[i]); EscrowInfo memory escrowInfo = escrowInfos[_rewardTokens[i]]; if (escrowInfo.escrowPercentage > 0) { _lockToken(user, _rewardTokens[i], rewardAmount, escrowInfo); emit RewardsClaimed(user, _rewardTokens[i], rewardAmount, true); } else { _rewardTokens[i].transfer(user, rewardAmount); emit RewardsClaimed(user, _rewardTokens[i], rewardAmount, false); } accruedRewards[user][_rewardTokens[i]] = 0; } }
None
Change to
function claimRewards(address user, IERC20[] memory _rewardTokens) external accrueRewards(msg.sender, user) { for (uint8 i; i < _rewardTokens.length; i++) { uint256 rewardAmount = accruedRewards[user][_rewardTokens[i]]; + accruedRewards[user][_rewardTokens[i]] = 0; if (rewardAmount == 0) revert ZeroRewards(_rewardTokens[i]); EscrowInfo memory escrowInfo = escrowInfos[_rewardTokens[i]]; if (escrowInfo.escrowPercentage > 0) { _lockToken(user, _rewardTokens[i], rewardAmount, escrowInfo); emit RewardsClaimed(user, _rewardTokens[i], rewardAmount, true); } else { _rewardTokens[i].transfer(user, rewardAmount); emit RewardsClaimed(user, _rewardTokens[i], rewardAmount, false); } - accruedRewards[user][_rewardTokens[i]] = 0; } }
#0 - c4-judge
2023-02-16T07:38:18Z
dmvt marked the issue as duplicate of #54
#1 - c4-sponsor
2023-02-18T12:10:45Z
RedVeil marked the issue as sponsor confirmed
#2 - c4-judge
2023-02-23T01:00:11Z
dmvt marked the issue as partial-25
#3 - c4-judge
2023-02-23T01:11:00Z
dmvt changed the severity to 3 (High Risk)
π Selected for report: rbserver
Also found by: bin2chen, cccz, chaduke, eccentricexit, hansfriese, ustas
88.0962 USDC - $88.10
Vault.deposit/mint/withdraw will cause totalSupply/totalAssets to change, and the syncFeeCheckpoint modifier will be used to update highWaterMark.
modifier syncFeeCheckpoint() { _; highWaterMark = convertToAssets(1e18); } ... function deposit(uint256 assets, address receiver) public nonReentrant whenNotPaused syncFeeCheckpoint returns (uint256 shares) {
Vault.redeem also causes totalSupply/totalAssets to change, but does not use the syncFeeCheckpoint modifier to update highWaterMark.
function redeem( uint256 shares, address receiver, address owner ) public nonReentrant returns (uint256 assets) {
This will cause the highWaterMark to be updated incorrectly and thus the PerformanceFee charged will be incorrect
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; }
https://github.com/code-423n4/2023-01-popcorn/blob/d95fc31449c260901811196d617366d6352258cd/src/vault/Vault.sol#L253-L257 https://github.com/code-423n4/2023-01-popcorn/blob/d95fc31449c260901811196d617366d6352258cd/src/vault/Vault.sol#L496-L499
None
Change to
function redeem( uint256 shares, address receiver, address owner - ) public nonReentrant returns (uint256 assets) { + ) public nonReentrant syncFeeCheckpoint returns (uint256 assets) {
#0 - c4-judge
2023-02-16T05:54:50Z
dmvt marked the issue as duplicate of #252
#1 - c4-sponsor
2023-02-18T12:03:50Z
RedVeil marked the issue as sponsor confirmed
#2 - c4-judge
2023-02-23T21:24:02Z
dmvt marked the issue as not a duplicate
#3 - c4-judge
2023-02-23T21:24:40Z
dmvt marked the issue as duplicate of #557
#4 - c4-judge
2023-02-23T22:33:53Z
dmvt marked the issue as satisfactory
678.8223 USDC - $678.82
The strategy contract will generally let the Adapter contract use delegatecall to call its functions So IAdapter(address(this)).call is used frequently in strategy contracts, because when the Adapter calls the strategy's functions using delegatecall, address(this) is the Adapter
function harvest() public override { address router = abi.decode(IAdapter(address(this)).strategyConfig(), (address)); address asset = IAdapter(address(this)).asset(); ...
But in AdapterBase._verifyAndSetupStrategy, the verifyAdapterSelectorCompatibility/verifyAdapterCompatibility/setUp functions are not called with delegatecall, which causes the context of these functions to be the strategy contract
function _verifyAndSetupStrategy(bytes4[8] memory requiredSigs) internal { strategy.verifyAdapterSelectorCompatibility(requiredSigs); strategy.verifyAdapterCompatibility(strategyConfig); strategy.setUp(strategyConfig); }
and since the strategy contract does not implement the interface of the Adapter contract, these functions will fail, making it impossible to create a Vault using that strategy.
function verifyAdapterCompatibility(bytes memory data) public override { address router = abi.decode(data, (address)); address asset = IAdapter(address(this)).asset();
More dangerously, if functions such as setup are executed successfully because they do not call the Adapter's functions, they may later error out when calling the havest function because the settings in setup are invalid
None
In AdapterBase._verifyAndSetupStrategy, the verifyAdapterSelectorCompatibility/verifyAdapterCompatibility/setUp functions are called using delegatecall
#0 - c4-judge
2023-02-16T07:15:22Z
dmvt marked the issue as primary issue
#1 - c4-sponsor
2023-02-17T10:10:13Z
RedVeil marked the issue as sponsor confirmed
#2 - c4-judge
2023-02-25T21:00:28Z
dmvt marked the issue as selected for report
π Selected for report: chaduke
Also found by: KIntern_NA, cccz, rbserver
211.4793 USDC - $211.48
In Vault, highWaterMark should represent the highest point of the share price. In accruedPerformanceFee, PerformanceFee is charged when the current share price is higher than highWaterMark.
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; }
In syncFeeCheckpoint, the highWaterMark is updated to the current share price even if the current share price is less than the highWaterMark.
modifier syncFeeCheckpoint() { _; highWaterMark = convertToAssets(1e18); }
And this means that when the share price fluctuates greatly, a lot of PerformanceFee will be charged!
None
modifier syncFeeCheckpoint() { _; + if(highWaterMark < convertToAssets(1e18)) highWaterMark = convertToAssets(1e18); }
#0 - c4-judge
2023-02-16T06:29:11Z
dmvt marked the issue as primary issue
#1 - c4-sponsor
2023-02-17T09:43:33Z
RedVeil marked the issue as sponsor confirmed
#2 - c4-judge
2023-02-23T21:53:38Z
dmvt marked issue #365 as primary and marked this issue as a duplicate of 365
#3 - c4-judge
2023-02-23T22:34:59Z
dmvt marked the issue as satisfactory
π Selected for report: chaduke
Also found by: KIntern_NA, cccz, rbserver
211.4793 USDC - $211.48
In Vault, highWaterMark represents the highest point of share price. In accruedPerformanceFee, PerformanceFee is collected when the current share price is higher than highWaterMark.
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; }
However, in syncFeeCheckpoint(when deposit/withdraw is called), highWaterMark is updated to the current share price and no PerformanceFee is charged.
modifier syncFeeCheckpoint() { _; highWaterMark = convertToAssets(1e18); }
PerformanceFee is only charged when the takeManagementAndPerformanceFees function is called, in the takeFees modifier.
function takeManagementAndPerformanceFees() external nonReentrant takeFees {} /// @notice Collect management and performance fees and update vault share high water mark. modifier takeFees() { uint256 managementFee = accruedManagementFee(); uint256 totalFee = managementFee + accruedPerformanceFee(); uint256 currentAssets = totalAssets(); uint256 shareValue = convertToAssets(1e18); if (shareValue > highWaterMark) highWaterMark = shareValue; if (managementFee > 0) feesUpdatedAt = block.timestamp; if (totalFee > 0 && currentAssets > 0) _mint(feeRecipient, convertToShares(totalFee)); _; }
If functions such as deposit/withdraw are called (syncFeeCheckpoint will be called) before takeManagementAndPerformanceFees is called, then as highWaterMark is updated to the current share price takeManagementAndPerformanceFees will not charge any PerformanceFee. Considering that functions such as deposit/withdraw are called frequently, this can make it difficult to collect PerformanceFee
https://github.com/code-423n4/2023-01-popcorn/blob/d95fc31449c260901811196d617366d6352258cd/src/vault/Vault.sol#L447-L460 https://github.com/code-423n4/2023-01-popcorn/blob/d95fc31449c260901811196d617366d6352258cd/src/vault/Vault.sol#L473-L494
None
Consider using takeFees instead of syncFeeCheckpoint in the deposit/withdraw functions. The takeFees modifier will update the highWaterMark while collecting PerformanceFee.
#0 - c4-judge
2023-02-16T06:29:59Z
dmvt marked the issue as duplicate of #309
#1 - c4-sponsor
2023-02-18T12:05:52Z
RedVeil marked the issue as sponsor confirmed
#2 - c4-judge
2023-02-23T22:36:06Z
dmvt marked the issue as satisfactory
55.5006 USDC - $55.50
When calculating the end time in _calcRewardsEnd, the unvested rewards are calculated first and the end time is calculated afterwards. Both calculations require the use of rewardsPerSecond But note that when rewardsPerSecond changes, the rewardsPerSecond used in these two places should be different, in the first step, the old rewardsPerSecond should be used, while the second step should use the new rewardsPerSecond.
function _calcRewardsEnd( uint32 rewardsEndTimestamp, uint160 rewardsPerSecond, uint256 amount ) internal returns (uint32) { if (rewardsEndTimestamp > block.timestamp) amount += uint256(rewardsPerSecond) * (rewardsEndTimestamp - block.timestamp); return (block.timestamp + (amount / uint256(rewardsPerSecond))).safeCastTo32(); }
However, in the current implementation, the new rewardsPerSecond is used in both steps, which leads to miscalculation of the end time and thus incorrect reward distribution.
function changeRewardSpeed(IERC20 rewardToken, uint160 rewardsPerSecond) external onlyOwner { RewardInfo memory rewards = rewardInfos[rewardToken]; if (rewardsPerSecond == 0) revert ZeroAmount(); if (rewards.lastUpdatedTimestamp == 0) revert RewardTokenDoesntExist(rewardToken); if (rewards.rewardsPerSecond == 0) revert RewardsAreDynamic(rewardToken); _accrueRewards(rewardToken, _accrueStatic(rewards)); //uint256 remainder = rewardToken.balanceOf(address(this)); uint32 prevEndTime = rewards.rewardsEndTimestamp; uint32 rewardsEndTimestamp = _calcRewardsEnd( prevEndTime > block.timestamp ? prevEndTime : block.timestamp.safeCastTo32(), rewardsPerSecond,
Please fix another bug in changeRewardSpeed first.
function changeRewardSpeed(IERC20 rewardToken, uint160 rewardsPerSecond) external onlyOwner { RewardInfo memory rewards = rewardInfos[rewardToken]; if (rewardsPerSecond == 0) revert ZeroAmount(); if (rewards.lastUpdatedTimestamp == 0) revert RewardTokenDoesntExist(rewardToken); if (rewards.rewardsPerSecond == 0) revert RewardsAreDynamic(rewardToken); _accrueRewards(rewardToken, _accrueStatic(rewards)); - uint256 remainder = rewardToken.balanceOf(address(this)); uint32 prevEndTime = rewards.rewardsEndTimestamp; uint32 rewardsEndTimestamp = _calcRewardsEnd( prevEndTime > block.timestamp ? prevEndTime : block.timestamp.safeCastTo32(), rewardsPerSecond, - remainder + 0 ); rewardInfos[rewardToken].rewardsPerSecond = rewardsPerSecond; rewardInfos[rewardToken].rewardsEndTimestamp = rewardsEndTimestamp; }
The POC is as follows, the total number of reward tokens is 10 eth, and the initial rewardsPerSecond == 0.1 eth. alice/bob deposit 1 eth respectively. Adjust rewardsPerSecond to 0.2. At this time, the end time should be 10/0.2 = 50, but the actual result is greater than 50. And after another 80s, alice's reward is much greater than 5 eth (the actual alice and bob should share the 10 eth reward equally) If alice takes out the reward at this time, bob will not be able to take it out due to insufficient rewards.
function test__POC() public { _addRewardToken(rewardToken1); (, , uint32 oldRewardsEndTimestamp, , ) = staking.rewardInfos(iRewardToken1); assertEq(uint256(oldRewardsEndTimestamp) - block.timestamp, 100); stakingToken.mint(alice, 1 ether); stakingToken.mint(bob, 1 ether); vm.prank(alice); stakingToken.approve(address(staking), 1 ether); vm.prank(alice); staking.deposit(1 ether); vm.prank(bob); stakingToken.approve(address(staking), 1 ether); vm.prank(bob); staking.deposit(1 ether); staking.changeRewardSpeed(iRewardToken1, 0.2 ether); (, , uint32 RewardsEndTimestamp, , ) = staking.rewardInfos(iRewardToken1); assertEq(uint256(RewardsEndTimestamp) - block.timestamp, 50); vm.warp(block.timestamp + 80); vm.prank(alice); staking.withdraw(1 ether); vm.prank(bob); staking.withdraw(1 ether); assertEq(staking.accruedRewards(alice, iRewardToken1), 5 ether); IERC20[] memory rewardsTokenKeys = new IERC20[](1); rewardsTokenKeys[0] = iRewardToken1; staking.claimRewards(alice, rewardsTokenKeys); // staking.claimRewards(bob, rewardsTokenKeys); @ audit: ERC20: transfer amount exceeds balance }
None
Consider using the oldRewardsPerSecond and newRewardsPerSecond parameters in _calcRewardsEnd and changing the parameters when calling _calcRewardsEnd
function _calcRewardsEnd( uint32 rewardsEndTimestamp, - uint160 rewardsPerSecond, + uint160 oldRewardsPerSecond, + uint160 newRewardsPerSecond, uint256 amount ) internal returns (uint32) { if (rewardsEndTimestamp > block.timestamp) - amount += uint256(rewardsPerSecond) * (rewardsEndTimestamp - block.timestamp); + amount += uint256(oldRewardsPerSecond) * (rewardsEndTimestamp - block.timestamp); - return (block.timestamp + (amount / uint256(rewardsPerSecond))).safeCastTo32(); + return (block.timestamp + (amount / uint256(newRewardsPerSecond))).safeCastTo32(); }
#0 - c4-judge
2023-02-16T03:55:57Z
dmvt marked the issue as duplicate of #156
#1 - c4-sponsor
2023-02-18T11:58:50Z
RedVeil marked the issue as disagree with severity
#2 - c4-sponsor
2023-02-18T11:58:53Z
RedVeil marked the issue as sponsor confirmed
#3 - c4-judge
2023-02-23T16:05:16Z
dmvt changed the severity to QA (Quality Assurance)
#4 - Simon-Busch
2023-02-23T16:14:31Z
Revert back to M as requested by @dmvt
#5 - c4-judge
2023-02-23T22:26:32Z
dmvt marked the issue as satisfactory