Mute Switch - Versus contest - evan's results

A zkRollup based AMM DEX w/ limit orders, farming platform, and Bond platform - built on zkSync.

General Information

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

Mute.io

Findings Distribution

Researcher Performance

Rank: 5/5

Findings: 7

Award: $0.00

QA:
grade-b
Gas:
grade-b

🌟 Selected for report: 4

πŸš€ Solo Findings: 1

Findings Information

🌟 Selected for report: HollaDieWaldfee

Also found by: chaduke, evan

Labels

bug
3 (High Risk)
partial-50
upgraded by judge
duplicate-6

Awards

Data not available

External Links

Lines of code

https://github.com/code-423n4/2023-03-mute/blob/main/contracts/dao/dMute.sol#L112

Vulnerability details

Proof of Concept

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.

Impact

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.

Tools Used

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

Findings Information

🌟 Selected for report: evan

Also found by: HollaDieWaldfee

Labels

bug
2 (Med Risk)
disagree with severity
primary issue
satisfactory
selected for report
sponsor confirmed
M-01

Awards

Data not available

External Links

Lines of code

https://github.com/code-423n4/2023-03-mute/blob/main/contracts/amplifier/MuteAmplifier.sol#L95-L118

Vulnerability details

Proof of Concept

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.

Impact

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.

Tools Used

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.

Findings Information

🌟 Selected for report: hansfriese

Also found by: evan

Labels

bug
2 (Med Risk)
satisfactory
duplicate-32

Awards

Data not available

External Links

Lines of code

https://github.com/code-423n4/2023-03-mute/blob/main/contracts/amplifier/MuteAmplifier.sol#L185-L191

Vulnerability details

Proof of Concept

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

Impact

RescueTokens cannot necessarily rescue all the mute tokens.

Tools Used

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

Findings Information

🌟 Selected for report: evan

Also found by: HollaDieWaldfee, chaduke, hansfriese

Labels

bug
2 (Med Risk)
primary issue
satisfactory
selected for report
sponsor confirmed
M-04

Awards

Data not available

External Links

Lines of code

https://github.com/code-423n4/2023-03-mute/blob/main/contracts/amplifier/MuteAmplifier.sol#L208-L212

Vulnerability details

Proof of Concept

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" ); } }

Impact

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.

Tools Used

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

Findings Information

🌟 Selected for report: evan

Also found by: HollaDieWaldfee

Labels

bug
2 (Med Risk)
primary issue
satisfactory
selected for report
sponsor confirmed
M-05

Awards

Data not available

External Links

Lines of code

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

Vulnerability details

Proof of Concept

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; })

Impact

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.

Tools Used

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

Findings Information

🌟 Selected for report: evan

Labels

bug
2 (Med Risk)
satisfactory
selected for report
sponsor confirmed
M-06

Awards

Data not available

External Links

Lines of code

https://github.com/code-423n4/2023-03-mute/blob/main/contracts/amplifier/MuteAmplifier.sol#L111

Vulnerability details

Proof of Concept

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.

Impact

Some LP fees can be trapped inside the mute switch pair when it should really be going to the amplifier users.

Tools Used

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

Findings Information

🌟 Selected for report: HollaDieWaldfee

Also found by: evan, hansfriese

Labels

2 (Med Risk)
satisfactory
duplicate-18

Awards

Data not available

External Links

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

Findings Information

🌟 Selected for report: HollaDieWaldfee

Also found by: 0xA5DF, chaduke, evan, hansfriese

Labels

bug
grade-b
QA (Quality Assurance)
Q-01

Awards

Data not available

External Links

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

Low 2

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

Findings Information

🌟 Selected for report: 0xA5DF

Also found by: chaduke, evan

Labels

bug
G (Gas Optimization)
grade-b
G-01

Awards

Data not available

External Links

Gas 1:

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);

Gas 2:

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

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax Β© 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter