Platform: Code4rena
Start Date: 28/03/2023
Pot Size: $23,500 USDC
Total HM: 12
Participants: 5
Period: 6 days
Judge: Picodes
Total Solo HM: 3
Id: 227
League: ETH
Rank: 5/5
Findings: 7
Award: $0.00
π Selected for report: 4
π Solo Findings: 1
π Selected for report: HollaDieWaldfee
Data not available
https://github.com/code-423n4/2023-03-mute/blob/main/contracts/dao/dMute.sol#L112
When redeeming, the user must iterate through all the elements of _userLock to destroy any redeemed locks. https://github.com/code-423n4/2023-03-mute/blob/main/contracts/dao/dMute.sol#L112
for(uint256 i = _userLocks[msg.sender].length; i > 0; i--){ UserLockInfo memory lock_info = _userLocks[msg.sender][i - 1]; // recently redeemed lock, destroy it if(lock_info.time == 0){ _userLocks[msg.sender][i - 1] = _userLocks[msg.sender][_userLocks[msg.sender].length - 1]; _userLocks[msg.sender].pop(); } }
However, any user can add an arbitrary number of entries to a victim's _userLocks via lockTo at a negligible expense. https://github.com/code-423n4/2023-03-mute/blob/main/contracts/dao/dMute.sol#L81
function LockTo(uint256 _amount, uint256 _lock_time, address to) public nonReentrant { require(IERC20(MuteToken).balanceOf(msg.sender) >= _amount, "dMute::Lock: INSUFFICIENT_BALANCE"); //transfer tokens to this contract IERC20(MuteToken).transferFrom(msg.sender, address(this), _amount); // calculate dTokens to mint uint256 tokens_to_mint = timeToTokens(_amount, _lock_time); require(tokens_to_mint > 0, 'dMute::Lock: INSUFFICIENT_TOKENS_MINTED'); _mint(to, tokens_to_mint); _userLocks[to].push(UserLockInfo(_amount, block.timestamp.add(_lock_time), tokens_to_mint)); emit LockEvent(to, _amount, tokens_to_mint, _lock_time); }
Suppose the malicious user calls lockTo 100 times, with _amount = 10 (any bare minimum amount while ensuring tokens_to_mint > 0), and _lock_time = 52 weeks (maximum duration).
These 100 entries stays in _userLocks for at least 1 year (since they can't be redeemed for a year). Any time the victim redeems within this year, they must iterate through these 100 entries, wasting a significant amount of gas.
Due to the unbounded for loop where the number of iterations can be controlled by a malicious user, the victim has to pay for a lot of gas every time they call redeem. From my elementary understanding of zksync, while it uses rollups to significantly reduce the gas fee, the price for gas is still not negligible, especially in periods of low usage. Furthermore, the attacker only has to call lockTo once to add an entry, while the victim must iterate through it every time they redeem. Therefore, this setup this far from ideal.
Manual Review
From my understanding, keeping the redeemed entries in _userLocks doesn't impact the functionality since the contract reverts when it hits them, and the user can select which _userLock entries to redeem.
Therefore, a potential solution is to add a bool clean
parameter to the redeemTo function, and only run the for loop when clean
is true.
#0 - c4-judge
2023-04-04T14:25:55Z
Picodes marked the issue as duplicate of #6
#1 - c4-judge
2023-04-04T14:26:00Z
Picodes marked the issue as partial-50
#2 - Picodes
2023-04-10T22:52:51Z
Giving partial credit as this report doesn't highlight how this could lead to a loss of all locked funds
#3 - c4-judge
2023-04-10T22:53:03Z
Picodes marked the issue as satisfactory
#4 - c4-judge
2023-04-10T22:55:59Z
Picodes changed the severity to 3 (High Risk)
#5 - c4-judge
2023-04-10T22:56:10Z
Picodes changed the severity to 2 (Med Risk)
#6 - c4-judge
2023-04-10T22:56:20Z
Picodes changed the severity to 3 (High Risk)
#7 - c4-judge
2023-04-12T20:37:29Z
Picodes marked the issue as partial-50
π Selected for report: evan
Also found by: HollaDieWaldfee
Data not available
https://github.com/code-423n4/2023-03-mute/blob/main/contracts/amplifier/MuteAmplifier.sol#L95-L118
Consider the update modifier for the amplifier.
modifier update() { if (_mostRecentValueCalcTime == 0) { _mostRecentValueCalcTime = firstStakeTime; } uint256 totalCurrentStake = totalStake(); if (totalCurrentStake > 0 && _mostRecentValueCalcTime < endTime) { uint256 value = 0; uint256 sinceLastCalc = block.timestamp.sub(_mostRecentValueCalcTime); uint256 perSecondReward = totalRewards.div(endTime.sub(firstStakeTime)); if (block.timestamp < endTime) { value = sinceLastCalc.mul(perSecondReward); } else { uint256 sinceEndTime = block.timestamp.sub(endTime); value = (sinceLastCalc.sub(sinceEndTime)).mul(perSecondReward); } _totalWeight = _totalWeight.add(value.mul(10**18).div(totalCurrentStake)); _mostRecentValueCalcTime = block.timestamp; (uint fee0, uint fee1) = IMuteSwitchPairDynamic(lpToken).claimFees(); _totalWeightFee0 = _totalWeightFee0.add(fee0.mul(10**18).div(totalCurrentStake)); _totalWeightFee1 = _totalWeightFee1.add(fee1.mul(10**18).div(totalCurrentStake)); totalFees0 = totalFees0.add(fee0); totalFees1 = totalFees1.add(fee1); } _; }
Suppose there's been a period with totalCurrentStake = 0, and a user stakes and immediately withdraws in the same transaction. When the user stakes, update doesn't do anything (including updating _mostRecentValueCalcTime) since totalCurrentStake = 0, and _userWeighted[account] gets set to _totalWeight (which hasn't been updated) in the stake function. https://github.com/code-423n4/2023-03-mute/blob/main/contracts/amplifier/MuteAmplifier.sol#L349
function _stake(uint256 lpTokenIn, address account) private { ... _userWeighted[account] = _totalWeight; ... }
When the user withdraws, totalCurrentStake is no longer zero. Since _mostRecentValueCalcTime wasn't updated when the user staked (since totalCurrentStake was 0), the reward from the period with no stakers gets added to _totalWeight.
uint256 sinceLastCalc = block.timestamp.sub(_mostRecentValueCalcTime);
value = sinceLastCalc.mul(perSecondReward);
_totalWeight = _totalWeight.add(value.mul(10**18).div(totalCurrentStake));
(These are lines in the update modifier)
As a result, this user who staked and immediately withdrew, got all the reward from the period with no stakers. See the reward calculation: https://github.com/code-423n4/2023-03-mute/blob/main/contracts/amplifier/MuteAmplifier.sol#L371
function _applyReward(address account) private returns (uint256 lpTokenOut, uint256 reward, uint256 remainder, uint256 fee0, uint256 fee1) { ... reward = lpTokenOut.mul(_totalWeight.sub(_userWeighted[account])).div(calculateMultiplier(account, true)); ... }
Observe that the exploit condition is met as soon as the staking period starts (as long as nobody stakes immediately). The code attempts to prevent this situation setting _mostRecentValueCalcTime to firstStakeTime the first time that update is invoked (which must be a stake call). https://github.com/code-423n4/2023-03-mute/blob/main/contracts/amplifier/MuteAmplifier.sol#L89-L91
if (_mostRecentValueCalcTime == 0) { _mostRecentValueCalcTime = firstStakeTime; }
However, this doesn't do anything since the user can first set the firstStakeTime by staking as soon as the staking period starts, and then make totalCurrentStake 0 by immediately withdrawing. In fact, observe that this takes away from other staker's rewards since perSecondReward is now lower.
https://github.com/code-423n4/2023-03-mute/blob/main/contracts/amplifier/MuteAmplifier.sol#L98
uint256 perSecondReward = totalRewards.div(endTime.sub(firstStakeTime));
Please add the following test to the "advance to start time" context in amplifier.ts (add it here), and run with npm run test-amplifier
.
it("get reward without staking", async function () { await this.lpToken.transfer(this.staker1.address, staker1Initial.toFixed(), {from: this.owner.address}); await this.lpToken.connect(this.staker1).approve( this.amplifier.address, staker1Initial.toFixed() ); console.log("dmute balance before: " + await this.dMuteToken.balanceOf(this.staker1.address)) await this.amplifier.connect(this.staker1).stake(1); await this.amplifier.connect(this.staker1).withdraw(); await time.increaseTo(staking_end-10); await this.amplifier.connect(this.staker1).stake(1); await this.amplifier.connect(this.staker1).withdraw(); console.log("dmute balance after: " + await this.dMuteToken.balanceOf(this.staker1.address)) }); it("get reward with staking", async function () { await this.lpToken.transfer(this.staker1.address, staker1Initial.toFixed(), {from: this.owner.address}); await this.lpToken.connect(this.staker1).approve( this.amplifier.address, staker1Initial.toFixed() ); console.log("dmute balance before: " + await this.dMuteToken.balanceOf(this.staker1.address)) await this.amplifier.connect(this.staker1).stake(1); //await this.amplifier.connect(this.staker1).withdraw(); await time.increaseTo(staking_end-10); //await this.amplifier.connect(this.staker1).stake(1); await this.amplifier.connect(this.staker1).withdraw(); console.log("dmute balance after: " + await this.dMuteToken.balanceOf(this.staker1.address)) });
Result on my side:
advance to start time β reverts without tokens approved for staking dmute balance before: 0 dmute balance after: 576922930658129099273 β get reward without staking dmute balance before: 0 dmute balance after: 576922912276064160247 β get reward with staking
This POC is a bit on the extreme side to get the point across. In the first test, the user stakes and then immediately unstakes, while in the second test, the user stakes for the entire period. In the end, the user gets roughly the same amount of reward.
After periods with no stakers, users can get reward without staking. This is also possible at the beginning of the staking period, and doing so then will reduce the reward for other users in the process.
Manual review, Hardhat
Possible solution 1: set a minimum duration that a user must stake for (prevent them from staking and immediately withdrawing) Possible solution 2: always update _mostRecentValueCalcTime (regardless totalCurrentStake). i.e. move the following line out of the if statement. https://github.com/code-423n4/2023-03-mute/blob/main/contracts/amplifier/MuteAmplifier.sol#L109
Keep in mind that with solution 2, no one gets the rewards in periods with no stakers - this means that the rescueTokens function needs to be updated to get retrieve these rewards.
#0 - c4-sponsor
2023-04-06T15:40:40Z
mattt21 marked the issue as sponsor disputed
#1 - c4-sponsor
2023-04-06T15:40:44Z
mattt21 marked the issue as disagree with severity
#2 - c4-sponsor
2023-04-06T15:42:42Z
mattt21 marked the issue as sponsor confirmed
#3 - EvanYu0816
2023-04-06T20:01:34Z
Can the sponsor explain why they disagree with the severity?
#4 - Picodes
2023-04-10T22:47:30Z
The issue is valid in my opinion. The desired behavior from this line seems to be that rewards not distributed because there is no stakers are added to the rewards per seconds distributed when there are some. The fact that the first next staker could take all pending rewards is a bug.
An other mitigation could be to reset firstStakeTime
and replicate the initial behavior if at some point totalStake()
goes back to 0.
#5 - c4-judge
2023-04-10T22:49:29Z
Picodes marked the issue as satisfactory
#6 - HollaDieWaldfee100
2023-04-11T07:51:55Z
@Picodes Can you please have a look at L-07 from my QA report (https://github.com/code-423n4/2023-03-mute-findings/blob/main/data/HollaDieWaldfee-Q.md).
It talks about the same scenario where a user can get rewards without actually staking.
I did remark there that the sponsor should have a look and decide if they want to fix it. I did not see an immediate necessity to fix it and also had a thought there that the current behavior may be beneficial in some sense because it entices users to stake
#7 - EvanYu0816
2023-04-11T17:25:47Z
@HollaDieWaldfee100 Yea that's the same issue, and you have brought up a fair point.
Since you didn't mention in your report, I'm not sure if you are aware that you can get this extra reward without staking (by staking and immediately withdrawing), and this is what made me believe that this behaviour is unintended. Furthermore, this vuln is exploitable as soon as the staking period starts since firstStakeTime doesn't actually do the job it's supposed to (someone can stake and immediately withdraw to set it early)
But I agree that the sponsor should have a say in this.
#8 - c4-judge
2023-04-12T20:41:55Z
Picodes marked the issue as primary issue
#9 - c4-judge
2023-04-12T20:42:35Z
Picodes marked the issue as selected for report
#10 - Picodes
2023-04-12T20:43:57Z
Indeed @HollaDieWaldfee100 your issue is a valid duplicate. In the absence of comment from the sponsor I'll keep Med severity, considering it isn't the desired behavior.
π Selected for report: hansfriese
Also found by: evan
Data not available
https://github.com/code-423n4/2023-03-mute/blob/main/contracts/amplifier/MuteAmplifier.sol#L185-L191
The logic for RescueTokens doesn't take into account the reward remainders.
I wanted to write a POC but I'm in a bit of a time crunch. So, imagine the following situation: totalRewards = 100, and staker A, B (the only stakers) staked for the first and second half (respectively) of the staking duration with multiplier 1/2.
Remainder and reward for staker A should both be 25.
reward = lpTokenOut.mul(_totalWeight.sub(_userWeighted[account])).div(calculateMultiplier(account, true)); // max possible rewards remainder = lpTokenOut.mul(_totalWeight.sub(_userWeighted[account])).div(10**18); // calculate left over rewards remainder = remainder.sub(reward);
https://github.com/code-423n4/2023-03-mute/blob/main/contracts/amplifier/MuteAmplifier.sol#L371-L375
When A withdraws, the remainder gets added to totalReclaimed and transferred to the treasury, while the reward gets added to totalClaimedRewards and locked in dMute.
if(remainder > 0){ totalReclaimed = totalReclaimed.add(remainder); IERC20(muteToken).transfer(treasury, remainder); } // payout rewards if (reward > 0) { uint256 week_time = 60 * 60 * 24 * 7; IDMute(dToken).LockTo(reward, week_time ,msg.sender); userClaimedRewards[msg.sender] = userClaimedRewards[msg.sender].add( reward ); totalClaimedRewards = totalClaimedRewards.add(reward); emit Payout(msg.sender, reward, remainder); }
https://github.com/code-423n4/2023-03-mute/blob/main/contracts/amplifier/MuteAmplifier.sol#L240-L255
So, after A withdraws, the mute balance of the contract is 50, totalRewards is still 100, and totalClaimedRewards is 25. Assume that 20 mute tokens got transferred to the contract by mistake and need to be rescued. Observe as long as staker B doesn't withdraw (totalStakers > 0), rescueTokens for mute will fail due to arithmetic underflow (balance - (totalRewards - totalClaimedRewards) = 70 - (100-25) = -5 < 0) :
else if (tokenToRescue == muteToken) { if (totalStakers > 0) { require(amount <= IERC20(muteToken).balanceOf(address(this)).sub(totalRewards.sub(totalClaimedRewards)), "MuteAmplifier::rescueTokens: that muteToken belongs to stakers" ); } }
https://github.com/code-423n4/2023-03-mute/blob/main/contracts/amplifier/MuteAmplifier.sol#L185-L191
RescueTokens cannot necessarily rescue all the mute tokens.
Manual Review
https://github.com/code-423n4/2023-03-mute/blob/main/contracts/amplifier/MuteAmplifier.sol#L187
Instead of ^, RescueTokens should check amount <= balance - (totalRewards - totalClaimedRewards - totalReclaimed)
#0 - c4-judge
2023-04-04T14:10:29Z
Picodes marked the issue as duplicate of #32
#1 - c4-judge
2023-04-10T22:09:44Z
Picodes marked the issue as satisfactory
π Selected for report: evan
Also found by: HollaDieWaldfee, chaduke, hansfriese
Data not available
https://github.com/code-423n4/2023-03-mute/blob/main/contracts/amplifier/MuteAmplifier.sol#L208-L212
Observe that if nobody has staked after the period has ended, it's still possible for a single user to stake even though the period has ended. https://github.com/code-423n4/2023-03-mute/blob/main/contracts/amplifier/MuteAmplifier.sol#L208-L212
if (firstStakeTime == 0) { firstStakeTime = block.timestamp; } else { require(block.timestamp < endTime, "MuteAmplifier::stake: staking is over"); }
The staker can't get any of the rewards because the update modifier won't drip the rewards (since _mostRecentValueCalcTime = firstStakeTime >= endTime). https://github.com/code-423n4/2023-03-mute/blob/main/contracts/amplifier/MuteAmplifier.sol#L89-L95
if (_mostRecentValueCalcTime == 0) { _mostRecentValueCalcTime = firstStakeTime; } uint256 totalCurrentStake = totalStake(); if (totalCurrentStake > 0 && _mostRecentValueCalcTime < endTime) { ... }
At the same time, the protocol can't withdraw the rewards with rescueToken either since there is a staker, and no reward has been claimed yet (so the following check fails). https://github.com/code-423n4/2023-03-mute/blob/main/contracts/amplifier/MuteAmplifier.sol#L187
else if (tokenToRescue == muteToken) { if (totalStakers > 0) { require(amount <= IERC20(muteToken).balanceOf(address(this)).sub(totalRewards.sub(totalClaimedRewards)), "MuteAmplifier::rescueTokens: that muteToken belongs to stakers" ); } }
Suppose the staking period ends and nobody has staked. The admin would like to withdraw the rewards. A malicious user can front-run the rescueTokens call with a call to stake to lock all the rewards inside the contract indefinitely.
Manual Review
https://github.com/code-423n4/2023-03-mute/blob/main/contracts/amplifier/MuteAmplifier.sol#L208-L212 The require shouldn't be inside the else block.
#0 - c4-judge
2023-04-04T14:13:23Z
Picodes marked the issue as primary issue
#1 - c4-sponsor
2023-04-06T15:59:45Z
mattt21 marked the issue as sponsor confirmed
#2 - c4-judge
2023-04-10T22:07:53Z
Picodes marked the issue as satisfactory
#3 - c4-judge
2023-04-10T22:08:35Z
Picodes marked the issue as selected for report
π Selected for report: evan
Also found by: HollaDieWaldfee
Data not available
https://github.com/code-423n4/2023-03-mute/blob/main/contracts/bonds/MuteBond.sol#L179 https://github.com/code-423n4/2023-03-mute/blob/main/contracts/dao/dMute.sol#L75-L77 https://github.com/code-423n4/2023-03-mute/blob/main/contracts/dao/dMute.sol#L57
Observe that if timeToTokens is called with _lock_time = 1 week, _amount < 52, it will return 0. https://github.com/code-423n4/2023-03-mute/blob/main/contracts/dao/dMute.sol#L57
function timeToTokens(uint256 _amount, uint256 _lock_time) internal pure returns (uint256){ uint256 week_time = 1 weeks; uint256 max_lock = 52 weeks; require(_lock_time >= week_time, "dMute::Lock: INSUFFICIENT_TIME_PARAM"); require(_lock_time <= max_lock, "dMute::Lock: INSUFFICIENT_TIME_PARAM"); // amount * % of time locked up from min to max uint256 base_tokens = _amount.mul(_lock_time.mul(10**18).div(max_lock)).div(10**18); // apply % min max bonus //uint256 boosted_tokens = base_tokens.mul(lockBonus(lock_time)).div(10**18); return base_tokens; }
This causes lockTo to revert. https://github.com/code-423n4/2023-03-mute/blob/main/contracts/dao/dMute.sol#L75-L77
function LockTo(uint256 _amount, uint256 _lock_time, address to) public nonReentrant { require(IERC20(MuteToken).balanceOf(msg.sender) >= _amount, "dMute::Lock: INSUFFICIENT_BALANCE"); //transfer tokens to this contract IERC20(MuteToken).transferFrom(msg.sender, address(this), _amount); // calculate dTokens to mint uint256 tokens_to_mint = timeToTokens(_amount, _lock_time); require(tokens_to_mint > 0, 'dMute::Lock: INSUFFICIENT_TOKENS_MINTED'); _mint(to, tokens_to_mint); _userLocks[to].push(UserLockInfo(_amount, block.timestamp.add(_lock_time), tokens_to_mint)); emit LockEvent(to, _amount, tokens_to_mint, _lock_time); }
The deposit function of muteBond calls lockTo with _amount = payout.
https://github.com/code-423n4/2023-03-mute/blob/main/contracts/bonds/MuteBond.sol#L179
IDMute(dMuteToken).LockTo(payout, bond_time_lock, _depositor);
Observe that regardless what the inputs are, payout <= maxDeposit()
is always satisfied after the following code segment.
https://github.com/code-423n4/2023-03-mute/blob/main/contracts/bonds/MuteBond.sol#L155-L164
uint payout = payoutFor( value ); if(max_buy == true){ value = maxPurchaseAmount(); payout = maxDeposit(); } else { // safety checks for custom purchase require( payout >= ((10**18) / 100), "Bond too small" ); // must be > 0.01 payout token ( underflow protection ) require( payout <= maxPayout, "Bond too large"); // size protection because there is no slippage require( payout <= maxDeposit(), "Deposit too large"); // size protection because there is no slippage }
So, if an attacker manipulates the muteBond to get maxDeposit() < 52, deposit will always fail.
Please add the following test case to bonds.ts and run it with npm run test-bond
Note that if the bond price is too high (> 52e18), then this won't always be possible (because payout will change by bondPrice every time we increment/decrement value). So in my POC, I set the price range to be (1e18 - 2e18), which I believe are reasonable values as well.
it('Bond DOS', async function () { await bondContract.setStartPrice(new BigNumber(1).times(Math.pow(10,18)).toFixed()) await bondContract.setMaxPrice(new BigNumber(2).times(Math.pow(10,18)).toFixed()) await bondContract.setMaxPayout(new BigNumber(100).times(Math.pow(10,18)).toFixed()) // ideally, the following line is what I had in mind // var val = new BigNumber((await bondContract.maxPurchaseAmount()).toString()).minus(1).toFixed() // but due to timing issues I couldn't get it to work (I'm not very familiar with hardhat) // so I just ran this to get the value for the next line // await time.increase(1) // console.log(await bondContract.maxPurchaseAmount()); var val = new BigNumber("99998511926905849653").toFixed(); console.log("before:") console.log(await bondContract.maxDeposit()) /* console.log(await bondContract.payoutFor(val)) console.log(await bondContract.maxPayout()) console.log((await bondContract.maxPurchaseAmount())) */ await bondContract.connect(buyer1).deposit(val, buyer1.address, false) console.log("after:") console.log(await bondContract.maxDeposit()) /* console.log(await bondContract.payoutFor(val)) console.log(await bondContract.maxPayout()) console.log((await bondContract.maxPurchaseAmount())) */ await expect( bondContract.connect(buyer1).deposit(1, buyer1.address, false) ).to.be.reverted; await expect( bondContract.connect(buyer1).deposit(1, buyer1.address, true) ).to.be.reverted; await expect( bondContract.connect(buyer1).deposit(new BigNumber(1).times(Math.pow(10,18)).toFixed(), buyer1.address, false) ).to.be.reverted; await expect( bondContract.connect(buyer1).deposit(new BigNumber(1).times(Math.pow(10,18)).toFixed(), buyer1.address, true) ).to.be.reverted; })
This vulnerability causes deposit to fail indefinitely. That being said, the contract itself doesn't seem to store funds, and it looks like there are ways for the admin to manually fix the DOS (e.g. deploy a new contract, set startPrice / maxPrice). So overall, I would say it warrants a medium severity.
Manual Review, Hardhat
Start a new epoch if maxDeposit() is smaller than a certain threshold.
#0 - c4-judge
2023-04-04T14:30:05Z
Picodes marked the issue as primary issue
#1 - c4-sponsor
2023-04-06T16:04:48Z
mattt21 marked the issue as sponsor confirmed
#2 - c4-judge
2023-04-10T22:07:05Z
Picodes marked the issue as satisfactory
#3 - c4-judge
2023-04-10T22:07:09Z
Picodes marked the issue as selected for report
π Selected for report: evan
Data not available
https://github.com/code-423n4/2023-03-mute/blob/main/contracts/amplifier/MuteAmplifier.sol#L111
Observe that there is only one place that the amplifier is calling claimFees, and it's inside an if statement of the update modifier, requiring _mostRecentValueCalcTime < endTime
.
https://github.com/code-423n4/2023-03-mute/blob/main/contracts/amplifier/MuteAmplifier.sol#L111
modifier update() { if (_mostRecentValueCalcTime == 0) { _mostRecentValueCalcTime = firstStakeTime; } uint256 totalCurrentStake = totalStake(); if (totalCurrentStake > 0 && _mostRecentValueCalcTime < endTime) { uint256 value = 0; uint256 sinceLastCalc = block.timestamp.sub(_mostRecentValueCalcTime); uint256 perSecondReward = totalRewards.div(endTime.sub(firstStakeTime)); if (block.timestamp < endTime) { value = sinceLastCalc.mul(perSecondReward); } else { uint256 sinceEndTime = block.timestamp.sub(endTime); value = (sinceLastCalc.sub(sinceEndTime)).mul(perSecondReward); } _totalWeight = _totalWeight.add(value.mul(10**18).div(totalCurrentStake)); _mostRecentValueCalcTime = block.timestamp; (uint fee0, uint fee1) = IMuteSwitchPairDynamic(lpToken).claimFees(); _totalWeightFee0 = _totalWeightFee0.add(fee0.mul(10**18).div(totalCurrentStake)); _totalWeightFee1 = _totalWeightFee1.add(fee1.mul(10**18).div(totalCurrentStake)); totalFees0 = totalFees0.add(fee0); totalFees1 = totalFees1.add(fee1); } _; }
Consider the following situation. An user X has staked a large amount of LP tokens, and a user Y has staked a normal amount.
Y withdraws as soon as the staking period ends (block.timestamp > endTime), triggering the update modifier, which sets _mostRecentValueCalcTime = block.timestamp
> endTime. Observe that after this point, the amplifier will never call claimFees again since _mostRecentValueCalcTime < endTime
will forever be false.
Meanwhile, X forgot about it, and doesn't withdraw until say 2 weeks after endTime. When X calls withdraw, X won't get the LP fees for those 2 weeks. In fact, nobody will - they are trapped inside the mute switch pair forever since the amplifier won't call claim.
Some LP fees can be trapped inside the mute switch pair when it should really be going to the amplifier users.
Manual Review
I believe it's best to move the LP fee calculation out of the if statement.
#0 - c4-sponsor
2023-04-06T16:08:08Z
mattt21 marked the issue as sponsor confirmed
#1 - c4-judge
2023-04-10T22:05:52Z
Picodes marked the issue as satisfactory
π Selected for report: HollaDieWaldfee
Also found by: evan, hansfriese
Data not available
Judge has assessed an item in Issue #44 as 2 risk. The relevant finding follows:
Low 1 https://github.com/code-423n4/2023-03-mute/blob/main/contracts/amplifier/MuteAmplifier.sol#L180 RescueTokens doesn't have checks for fee0 & fee1 tokens. Admin might accidentally withdraw fee tokens that are supposed to be for the stakers: https://github.com/code-423n4/2023-03-mute/blob/main/contracts/amplifier/MuteAmplifier.sol#L259-L260
#0 - c4-judge
2023-04-04T15:02:27Z
Picodes marked the issue as duplicate of #18
#1 - c4-judge
2023-04-10T22:02:33Z
Picodes marked the issue as satisfactory
π Selected for report: HollaDieWaldfee
Also found by: 0xA5DF, chaduke, evan, hansfriese
Data not available
https://github.com/code-423n4/2023-03-mute/blob/main/contracts/amplifier/MuteAmplifier.sol#L180 RescueTokens doesn't have checks for fee0 & fee1 tokens. Admin might accidentally withdraw fee tokens that are supposed to be for the stakers: https://github.com/code-423n4/2023-03-mute/blob/main/contracts/amplifier/MuteAmplifier.sol#L259-L260
https://github.com/code-423n4/2023-03-mute/blob/main/contracts/amplifier/MuteAmplifier.sol#L186 It's entirely possible for totalStakers = 0 during the staking period. If rescueToken is called incorrectly here, then there might not be enough reward balance to distribute if users start staking afterwards. This could mean that withdraw fails: https://github.com/code-423n4/2023-03-mute/blob/main/contracts/amplifier/MuteAmplifier.sol#L247
#0 - c4-judge
2023-04-04T15:05:58Z
Picodes marked the issue as grade-b
https://github.com/code-423n4/2023-03-mute/blob/main/contracts/amplifier/MuteAmplifier.sol#L103-L104
I believe this does the exact same thing as value = (endTime.sub(_mostRecentValueCalcTime)).mul(perSecondReward);
https://github.com/code-423n4/2023-03-mute/blob/main/contracts/amplifier/MuteAmplifier.sol#L98 perSecondReward doesn't change after firstStakeTime is set. So, it can be a global variable initialized here: https://github.com/code-423n4/2023-03-mute/blob/main/contracts/amplifier/MuteAmplifier.sol#L90
#0 - c4-judge
2023-04-04T15:08:11Z
Picodes marked the issue as grade-b