Platform: Code4rena
Start Date: 30/09/2021
Pot Size: $100,000 SUSHI
Total HM: 24
Participants: 7
Period: 7 days
Judge: alcueca
Total Solo HM: 16
Id: 35
League: ETH
Rank: 3/7
Findings: 8
Award: $18,531.43
🌟 Selected for report: 7
🚀 Solo Findings: 4
49.5711 SUSHI - $619.64
WatchPug
The reclaimIncentive()
function allows users who added incentives before to withdraw unclaimed rewards.
However, the current implementation did not manage the state correctly, incentive.rewardsUnclaimed
is not updated after the token transfer, which allows the user to call reclaimIncentive()
again and get incentive.token
up to the amount of rewardsUnclaimed
.
When the incentive.token
is set to token0
or token1
, this can be used for stealing yields from other users.
/// @dev Withdraws any unclaimed incentive rewards. function reclaimIncentive( IConcentratedLiquidityPool pool, uint256 incentiveId, uint256 amount, address receiver, bool unwrapBento ) public { Incentive storage incentive = incentives[pool][incentiveId]; require(incentive.owner == msg.sender, "NOT_OWNER"); require(incentive.expiry < block.timestamp, "EXPIRED"); require(incentive.rewardsUnclaimed >= amount, "ALREADY_CLAIMED"); _transfer(incentive.token, address(this), receiver, amount, unwrapBento); emit ReclaimIncentive(pool, incentiveId); }
Consider removing the amount
parameter and always withdraw all the unclaimed rewards to the owner, and delete the incentive.
#0 - sarangparikh22
2021-10-11T14:23:26Z
Duplicate of #37
🌟 Selected for report: WatchPug
377.7706 SUSHI - $4,722.13
WatchPug
When a user calls ConcentratedLiquidityPosition.sol#collect()
to collect their yield, it calcuates the yield based on position.pool.rangeFeeGrowth()
and position.feeGrowthInside0, position.feeGrowthInside1
:
When there are enough tokens in bento.balanceOf
, it will not call position.pool.collect()
to collect fees from the pool.
This makes the user who collect()
their yield when there is enough balance to get double yield when they call burn()
to remove liquidity. Because burn()
will automatically collect fees on the pool contract.
The yield belongs to other users will be diluted.
Consider making ConcentratedLiquidityPosition.sol#burn()
call position.pool.collect()
before position.pool.burn()
. User will need to call ConcentratedLiquidityPosition.sol#collect()
to collect unclaimed fees after burn()
.
Or ConcentratedLiquidityPosition.sol#collect()
can be changed into a public
method and ConcentratedLiquidityPosition.sol#burn()
can call it after position.pool.burn()
.
🌟 Selected for report: WatchPug
377.7706 SUSHI - $4,722.13
WatchPug
When a user calls ConcentratedLiquidityPosition.sol#burn()
to burn their liquidity, it calls ConcentratedLiquidityPool.sol#burn()
-> _updatePosition()
:
The _updatePosition()
function will return amount0fees
and amount1fees
of the whole position with the lower
and upper
tick and send them to the recipient
alongside the burned liquidity amounts.
lower
and upper
tick set to 99 and 199;lower
and upper
tick;ConcentratedLiquidityPosition.sol#burn()
to steal all the unclaimed yield with the ticks of (99, 199) include the $1000 worth of yield from Alice.Consider making ConcentratedLiquidityPosition.sol#burn()
always use address(this)
as recipient
in:
position.pool.burn(abi.encode(position.lower, position.upper, amount, recipient, unwrapBento));
and transfer proper amounts to the user.
🌟 Selected for report: WatchPug
377.7706 SUSHI - $4,722.13
WatchPug
The reserves should be updated once LP tokens are burned to match the actual total bento shares hold by the pool.
However, the current implementation only updated reserves with the fees subtracted.
Makes the reserve0
and reserve1
smaller than the current balance0
and balance1
.
As a result, many essential features of the contract will malfunction, includes swap()
and mint()
.
Change:
unchecked { reserve0 -= uint128(amount0fees); reserve1 -= uint128(amount1fees); }
to:
unchecked { reserve0 -= uint128(amount0); reserve1 -= uint128(amount1); }
14.8713 SUSHI - $185.89
WatchPug
ConcentratedLiquidityPoolManager.sol
Wrong implementation of claimReward()
and getReward()
Given incentive.secondsClaimed
can usually be larger than 128
, both claimReward()
and getReward()
will revert at:
uint256 secondsUnclaimed = (maxTime - incentive.startTime) << (128 - incentive.secondsClaimed);
#0 - sarangparikh22
2021-10-12T09:55:45Z
Duplicate of #41
🌟 Selected for report: WatchPug
113.3312 SUSHI - $1,416.64
WatchPug
In ConcentratedLiquidityPosition.collect()
, balances of token0 and token1 in bento will be used to pay the fees.
uint256 balance0 = bento.balanceOf(token0, address(this)); uint256 balance1 = bento.balanceOf(token1, address(this)); if (balance0 < token0amount || balance1 < token1amount) { (uint256 amount0fees, uint256 amount1fees) = position.pool.collect(position.lower, position.upper, address(this), false); uint256 newBalance0 = amount0fees + balance0; uint256 newBalance1 = amount1fees + balance1; /// @dev Rounding errors due to frequent claiming of other users in the same position may cost us some raw if (token0amount > newBalance0) token0amount = newBalance0; if (token1amount > newBalance1) token1amount = newBalance1; } _transfer(token0, address(this), recipient, token0amount, unwrapBento); _transfer(token1, address(this), recipient, token1amount, unwrapBento);
In the case of someone add an incentive with token0
or token1
, the incentive in the balance of bento will be used to pay fees until the balance is completely consumed.
As a result, when a user calls claimReward()
, the contract may not have enough balance to pay (it supposed to have it), cause the transaction to fail.
function claimReward( uint256 positionId, uint256 incentiveId, address recipient, bool unwrapBento ) public { require(ownerOf[positionId] == msg.sender, "OWNER"); Position memory position = positions[positionId]; IConcentratedLiquidityPool pool = position.pool; Incentive storage incentive = incentives[position.pool][positionId]; Stake storage stake = stakes[positionId][incentiveId]; require(stake.initialized, "UNINITIALIZED"); uint256 secondsPerLiquidityInside = pool.rangeSecondsInside(position.lower, position.upper) - stake.secondsInsideLast; uint256 secondsInside = secondsPerLiquidityInside * position.liquidity; uint256 maxTime = incentive.endTime < block.timestamp ? block.timestamp : incentive.endTime; uint256 secondsUnclaimed = (maxTime - incentive.startTime) << (128 - incentive.secondsClaimed); uint256 rewards = (incentive.rewardsUnclaimed * secondsInside) / secondsUnclaimed; incentive.rewardsUnclaimed -= rewards; incentive.secondsClaimed += uint160(secondsInside); stake.secondsInsideLast += uint160(secondsPerLiquidityInside); _transfer(incentive.token, address(this), recipient, rewards, unwrapBento); emit ClaimReward(positionId, incentiveId, recipient); }
The same issue applies to reclaimIncentive()
as well.
function reclaimIncentive( IConcentratedLiquidityPool pool, uint256 incentiveId, uint256 amount, address receiver, bool unwrapBento ) public { Incentive storage incentive = incentives[pool][incentiveId]; require(incentive.owner == msg.sender, "NOT_OWNER"); require(incentive.expiry < block.timestamp, "EXPIRED"); require(incentive.rewardsUnclaimed >= amount, "ALREADY_CLAIMED"); _transfer(incentive.token, address(this), receiver, amount, unwrapBento); emit ReclaimIncentive(pool, incentiveId); }
Consider making adding token0
or token1
as incentives disallowed, or keep a record of total remaining incentive amounts for the incentive tokens and avoid consuming these revered balances when collect()
.
🌟 Selected for report: WatchPug
57.1429 SUSHI - $714.29
WatchPug
Members of structs should be grouped into bunches of 32 bytes for saving gas.
For example:
ConcentratedLiquidityPoolManager.sol#Incentive
rewardsUnclaimed
and secondsClaimed
can be moved to the bottom to optimize for Variable Packing.
🌟 Selected for report: WatchPug
57.1429 SUSHI - $714.29
WatchPug
For the storage variables that will be accessed multiple times, cache them in the stack can save ~100 gas from each extra read (SLOAD
after Berlin).
For example:
ConcentratedLiquidityPosition.sol#positionMintCallback()
totalSupply
can be cached as it will be read for 2 times;ConcentratedLiquidityPosition.sol#burn()
position.liquidity
can be cached as it will be read for more than 3 times;ConcentratedLiquidityPosition.sol#collect()
position.pool
, position.lower
, position.upper
, position.liquidity
can be cached as it will be read for >= 2 times;🌟 Selected for report: WatchPug
57.1429 SUSHI - $714.29
WatchPug
For the arithmetic operations that will never over/underflow, using the unchecked directive (Solidity v0.8 has default overflow/underflow checks) can save some gas from the unnecessary internal over/underflow checks.
For example:
ConcentratedLiquidityPool.sol#mint()
https://github.com/sushiswap/trident/blob/c405f3402a1ed336244053f8186742d2da5975e9/contracts/pool/concentrated/ConcentratedLiquidityPool.sol#L165-L171
reserve0 -= uint128(amount0fees);
and reserve1 -= uint128(amount1fees);
will never underlow, they can be put in a block with unchecked directive to save gas.
ConcentratedLiquidityPosition.sol#burn()
https://github.com/sushiswap/trident/blob/c405f3402a1ed336244053f8186742d2da5975e9/contracts/pool/concentrated/ConcentratedLiquidityPosition.sol#L67
position.liquidity -= amount;
will never underlow.
ConcentratedLiquidityPosition.sol#collect()
uint256 newBalance0 = amount0fees + balance0;
and uint256 newBalance1 = amount1fees + balance1;
will never overlow.
ConcentratedLiquidityPool.sol#collectProtocolFee()
amount0 = token0ProtocolFee - 1;
and mount1 = token1ProtocolFee - 1;
will never underlow.