The claimConcentratedRewards and claimAmbientRewards functions do not update the liquidity amount after withdrawing rewards. This could allow a user to withdraw rewards multiple times for the same liquidity.
Proof of Concept
The liquidity amount is not updated after claiming rewards in the claimConcentratedRewards and claimAmbientRewards functions.
Specifically, the liquidity amount that should be updated is:
For concentrated rewards: pos.liquidity_ in the RangePosition72 struct
For ambient rewards: pos.seeds_ in the AmbientPosition struct
It also calls accrueConcentratedGlobalTimeWeightedLiquidity to calculate the total time-weighted liquidity provided globally in the pool's current tick range for each week. This updates timeWeightedWeeklyGlobalConcLiquidity.
It reads the user's position liquidity amount from pos.liquidity_: Line75-Line80, Line87
For each week to claim, it calculates the rewards as:
(User's time-weighted liquidity for week / Total time-weighted liquidity for week) * Total concentrated rewards for week
It sends the accumulated rewards to the user.
The issue is that pos.liquidity_ is never updated after withdrawing the rewards!
This means the user can repeatedly call claimConcentratedRewards for the same weeks and will continue getting rewards based on their original, unchanged liquidity amount.
claimAmbientRewards has the same flaw - it reads the pos.seeds_ liquidity amount but does not update it after sending rewards.
Here is some example showing how a user could exploit the lack of liquidity update in claimConcentratedRewards to double claim rewards:
// User has a range position with 100 ETH liquidity
RangePosition72 pos = lookupPosition(user, poolId, tickLower, tickUpper);
pos.liquidity_ = 100 ether;
// User provided 50% of pool's liquidity in week 1
timeWeightedWeeklyPositionInRangeConcLiquidity[week1][posKey][tick] = 50 ether
timeWeightedWeeklyGlobalConcLiquidity[week1] = 100 ether
// Total rewards in week 1 is 10 ETH
concRewardPerWeek[week1] = 10 ether
// User claims week 1 rewards
claimConcentratedRewards(user, poolId, tickLower, tickUpper, [week1])
// User gets 50% of 10 ETH = 5 ETH rewards
// pos.liquidity_ is STILL 100 ETH!
// User claims week 1 again
claimConcentratedRewards(user, poolId, tickLower, tickUpper, [week1])
// User gets 50% of 10 ETH = 5 ETH rewards again!
// For a total of 10 ETH instead of the 5 ETH they should get
This works because the initial pos.liquidity_ of 100 ETH is not updated after the first claim.
The same exploit would work for claimAmbientRewards by leaving pos.seeds_ unchanged after claims.
Lines of code
https://github.com/code-423n4/2023-10-canto/blob/40edbe0c9558b478c84336aaad9b9626e5d99f34/canto_ambient/contracts/mixins/LiquidityMining.sol#L75-L80 https://github.com/code-423n4/2023-10-canto/blob/40edbe0c9558b478c84336aaad9b9626e5d99f34/canto_ambient/contracts/mixins/LiquidityMining.sol#L87 https://github.com/code-423n4/2023-10-canto/blob/40edbe0c9558b478c84336aaad9b9626e5d99f34/canto_ambient/contracts/mixins/LiquidityMining.sol#L234-L235
Vulnerability details
Impact
The
claimConcentratedRewards
andclaimAmbientRewards
functions do not update the liquidity amount after withdrawing rewards. This could allow a user to withdraw rewards multiple times for the same liquidity.Proof of Concept
The liquidity amount is not updated after claiming rewards in the
claimConcentratedRewards
andclaimAmbientRewards
functions.Specifically, the liquidity amount that should be updated is:
pos.liquidity_
in theRangePosition72
structpos.seeds_
in theAmbientPosition
structThese liquidity amounts are read from the respective position structs: Line75-80, Line80, Line234-Line235
But they are never updated after transferring rewards.
Here is how claimConcentratedRewards
It first calls accrueConcentratedPositionTimeWeightedLiquidity to calculate the time-weighted liquidity the user provided in the position's tick range for each week. This updates the timeWeightedWeeklyPositionInRangeConcLiquidity mapping.
It also calls accrueConcentratedGlobalTimeWeightedLiquidity to calculate the total time-weighted liquidity provided globally in the pool's current tick range for each week. This updates
timeWeightedWeeklyGlobalConcLiquidity
.It reads the user's position liquidity amount from
pos.liquidity_
: Line75-Line80, Line87For each week to claim, it calculates the rewards as:
(User's time-weighted liquidity for week / Total time-weighted liquidity for week) * Total concentrated rewards for week
It sends the accumulated rewards to the user.
The issue is that
pos.liquidity_
is never updated after withdrawing the rewards!This means the user can repeatedly call
claimConcentratedRewards
for the same weeks and will continue getting rewards based on their original, unchanged liquidity amount.claimAmbientRewards
has the same flaw - it reads thepos.seeds_
liquidity amount but does not update it after sending rewards.This works because the initial
pos.liquidity_
of 100 ETH is not updated after the first claim.The same exploit would work for
claimAmbientRewards
by leavingpos.seeds_
unchanged after claims.Adding the missing liquidity update would fix it:
Tools Used
Vs
Recommended Mitigation Steps
The liquidity amount should be updated after each reward claim.
So the fix would be to add a line updating the liquidity amount after sending the rewards in both functions:
This ensures the liquidity amount stays in sync after withdrawing rewards. Let me know if this helps identify the specific issue!
Assessed type
Governance