Platform: Code4rena
Start Date: 03/10/2023
Pot Size: $24,500 USDC
Total HM: 6
Participants: 62
Period: 3 days
Judge: LSDan
Total Solo HM: 3
Id: 288
League: ETH
Rank: 8/62
Findings: 2
Award: $614.49
π Selected for report: 0
π Solo Findings: 0
609.5458 USDC - $609.55
https://github.com/code-423n4/2023-10-canto/blob/main/canto_ambient/contracts/mixins/LiquidityMining.sol#L205 https://github.com/code-423n4/2023-10-canto/blob/main/canto_ambient/contracts/mixins/LiquidityMining.sol#L215 https://github.com/code-423n4/2023-10-canto/blob/main/canto_ambient/contracts/mixins/LiquidityMining.sol#L235 https://github.com/code-423n4/2023-10-canto/blob/main/canto_ambient/contracts/mixins/LiquidityMining.sol#L245 https://github.com/code-423n4/2023-10-canto/blob/main/canto_ambient/contracts/mixins/LiquidityMining.sol#L277
The sponsor mentioned that they would like to ensure that the amount of incentives released is exactly as we specify in README. https://github.com/code-423n4/2023-10-canto/blob/main/README.md#scoping-details
This LM protocol will be used to incentivize pools on Canto. We would like to ensure that the amount of incentives released is exactly as we specify and the wallets who receive the incentives are the correct ones
But current logic of ambient reward calculation could make some rewards unable to be claimed by liquidity providers.
First, we need to know how to calculate the ambient rewards for liquidity providers.
In LiquidityMining.accrueAmbientGlobalTimeWeightedLiquidity
, It calculates timeWeightedWeeklyGlobalAmbLiquidity_
which is the total weight of ambient liquidity.
https://github.com/code-423n4/2023-10-canto/blob/main/canto_ambient/contracts/mixins/LiquidityMining.sol#L205
https://github.com/code-423n4/2023-10-canto/blob/main/canto_ambient/contracts/mixins/LiquidityMining.sol#L215
function accrueAmbientGlobalTimeWeightedLiquidity( bytes32 poolIdx, CurveMath.CurveState memory curve ) internal { uint32 lastAccrued = timeWeightedWeeklyGlobalAmbLiquidityLastSet_[poolIdx]; // Only set time on first call if (lastAccrued != 0) { uint256 liquidity = curve.ambientSeeds_; uint32 time = lastAccrued; while (time < block.timestamp) { uint32 currWeek = uint32((time / WEEK) * WEEK); uint32 nextWeek = uint32(((time + WEEK) / WEEK) * WEEK); uint32 dt = uint32( nextWeek < block.timestamp ? nextWeek - time : block.timestamp - time ); timeWeightedWeeklyGlobalAmbLiquidity_[poolIdx][currWeek] += dt * liquidity; time += dt; } } timeWeightedWeeklyGlobalAmbLiquidityLastSet_[poolIdx] = uint32( block.timestamp ); }
Suppose curve.ambientSeeds_
is 100. And for simplicity, we suppose that it is not changed in Week-X. Thus, timeWeightedWeeklyGlobalAmbLiquidity_[poolIdx][X]
is 100 * WEEK;
And the user shares are calculated in LiquidityMining.accrueAmbientPositionTimeWeightedLiquidity
. It calculates timeWeightedWeeklyPositionAmbLiquidity_
https://github.com/code-423n4/2023-10-canto/blob/main/canto_ambient/contracts/mixins/LiquidityMining.sol#L235
https://github.com/code-423n4/2023-10-canto/blob/main/canto_ambient/contracts/mixins/LiquidityMining.sol#L245
function accrueAmbientPositionTimeWeightedLiquidity( address payable owner, bytes32 poolIdx ) internal { bytes32 posKey = encodePosKey(owner, poolIdx); uint32 lastAccrued = timeWeightedWeeklyPositionAmbLiquidityLastSet_[ poolIdx ][posKey]; // Only init time on first call if (lastAccrued != 0) { AmbientPosition storage pos = lookupPosition(owner, poolIdx); uint256 liquidity = pos.seeds_; uint32 time = lastAccrued; while (time < block.timestamp) { uint32 currWeek = uint32((time / WEEK) * WEEK); uint32 nextWeek = uint32(((time + WEEK) / WEEK) * WEEK); uint32 dt = uint32( nextWeek < block.timestamp ? nextWeek - time : block.timestamp - time ); timeWeightedWeeklyPositionAmbLiquidity_[poolIdx][posKey][ currWeek ] += dt * liquidity; time += dt; } } timeWeightedWeeklyPositionAmbLiquidityLastSet_[poolIdx][ posKey ] = uint32(block.timestamp); }
Suppose pos.seeds_
of Alice is 50. And for simplicity, we suppose that it is not changed in Week-X. Thus, timeWeightedWeeklyPositionAmbLiquidity_[poolIdx][posKey][X]
is 50 * WEEK;
And we suppose that Bob provides another 50 liquidity. Bob and Alice have the same weight.
LiquidityMining.claimAmbientRewards
computes rewardsForWeek
.
https://github.com/code-423n4/2023-10-canto/blob/main/canto_ambient/contracts/mixins/LiquidityMining.sol#L277
function claimAmbientRewards( address owner, bytes32 poolIdx, uint32[] memory weeksToClaim ) internal { CurveMath.CurveState memory curve = curves_[poolIdx]; accrueAmbientPositionTimeWeightedLiquidity(payable(owner), poolIdx); accrueAmbientGlobalTimeWeightedLiquidity(poolIdx, curve); bytes32 posKey = encodePosKey(owner, poolIdx); uint256 rewardsToSend; for (uint256 i; i < weeksToClaim.length; ++i) { uint32 week = weeksToClaim[i]; require(week + WEEK < block.timestamp, "Week not over yet"); require( !ambLiquidityRewardsClaimed_[poolIdx][posKey][week], "Already claimed" ); uint256 overallTimeWeightedLiquidity = timeWeightedWeeklyGlobalAmbLiquidity_[ poolIdx ][week]; if (overallTimeWeightedLiquidity > 0) { uint256 rewardsForWeek = (timeWeightedWeeklyPositionAmbLiquidity_[ poolIdx ][posKey][week] * ambRewardPerWeek_[poolIdx][week]) / overallTimeWeightedLiquidity; rewardsToSend += rewardsForWeek; } ambLiquidityRewardsClaimed_[poolIdx][posKey][week] = true; } if (rewardsToSend > 0) { (bool sent, ) = owner.call{value: rewardsToSend}(""); require(sent, "Sending rewards failed"); } }
rewardsForWeek
is (timeWeightedWeeklyPositionAmbLiquidity_[poolIdx][posKey][week] * ambRewardPerWeek_[poolIdx][week]) / overallTimeWeightedLiquidity
. Suppose the ambRewardPerWeek_[poolIdx][X]
is 2000. Alice can get 1000:
(timeWeightedWeeklyPositionAmbLiquidity_[poolIdx][posKey][week] * ambRewardPerWeek_[poolIdx][week]) / overallTimeWeightedLiquidity (50 * WEEK * 2000) / (100 * WEEK) = 1000
Because Bob owns the same amount of ambient liquidity. Bob can also get 1000. All the rewards are sent.
However, it is possible that some rewards are not able to be claimed by liquidity providers. If curve.ambientSeeds_
is greater than the sum of all pos.seeds_
. Suppose the total ambient liquidity is 200 instead of 100. Alice can only get 500:
(timeWeightedWeeklyPositionAmbLiquidity_[poolIdx][posKey][week] * ambRewardPerWeek_[poolIdx][week]) / overallTimeWeightedLiquidity (50 * WEEK * 2000) / (200 * WEEK) = 500
It seems to be impossible that the total ambient liquidity is more than the sum of all liquidity provided by users. But there is an example that shows the possibility. TradeMatcher.lockAmbient
calls liquidityReceivable
to increase curve.ambientSeeds_
. Then the total ambient liquidity is more than the sum of all liquidity provided by users.
https://github.com/code-423n4/2023-10-canto/blob/main/canto_ambient/contracts/mixins/TradeMatcher.sol#L79
function lockAmbient (CurveMath.CurveState memory curve, uint128 liqAdded) internal pure returns (int128, int128) { (uint128 base, uint128 quote) = liquidityReceivable(curve, liqAdded); return signMintFlow(base, quote); } function liquidityReceivable (CurveMath.CurveState memory curve, uint128 seeds) internal pure returns (uint128, uint128) { (uint128 base, uint128 quote) = liquidityFlows(curve, seeds); bumpAmbient(curve, seeds); return chargeConservative(base, quote, true); } function bumpAmbient (CurveMath.CurveState memory curve, uint128 seedDelta) private pure { bumpAmbient(curve, seedDelta.toInt128Sign()); } function bumpAmbient (CurveMath.CurveState memory curve, int128 seedDelta) private pure { curve.ambientSeeds_ = curve.ambientSeeds_.addDelta(seedDelta); }
In conclusion, if the total ambient liquidity is more than the sum of all liquidity provided by users. Liquidity providers cannot get the full rewards.
Manual Review
If the goal is to ensure that liquidity providers can get full rewards. I suggest not using curve.ambientSeeds
in LiquidityMining.accrueAmbientGlobalTimeWeightedLiquidity
since curve.ambientSeeds
could be more than the sum of all liquidity provided by users. Maintain another state variable like curve.totalUserSeeds
to record the sum of liquidity provided by users and update curve.totalUserSeeds
when pos.seeds_
is changed. Then, use curve.totalUserSeeds
instead of curve.ambientSeeds
in LiquidityMining.accrueAmbientGlobalTimeWeightedLiquidity
.
Context
#0 - c4-pre-sort
2023-10-09T14:16:39Z
141345 marked the issue as duplicate of #94
#1 - c4-pre-sort
2023-10-09T16:46:17Z
141345 marked the issue as sufficient quality report
#2 - c4-judge
2023-10-18T11:20:08Z
dmvt changed the severity to 2 (Med Risk)
#3 - c4-judge
2023-10-18T11:22:26Z
dmvt marked the issue as satisfactory
π Selected for report: adriro
Also found by: 0x3b, 0xAadi, 0xDING99YA, 0xTheC0der, 0xWaitress, 0xdice91, 100su, 3docSec, BRONZEDISC, BoRonGod, Eurovickk, GKBG, HChang26, IceBear, JP_Courses, MatricksDeCoder, Mike_Bello90, SovaSlava, Topmark, albahaca, cookedcookee, gzeon, hunter_w3b, kutugu, lukejohn, marqymarq10, matrix_0wl, orion, pep7siup, radev_sw, sces60107, taner2344, tpiliposian, wahedtalash77, xAriextz, zpan
4.9369 USDC - $4.94
I list 2 low-critical finding2 and 1 non-critical finding:
LiquidityMining.crossTicks
should ensure numElementsExit != 0
LiquidityMiningPath.userCmd
is not compatible with userCmdRouter
and userCmdRelayer
LiquidityMining.crossTicks
should ensure numElementsExit != 0
LiquidityMining.crossTicks
updates tickTracking_[poolIdx][exitTick][numElementsExit - 1].exitTimeStamp
. But it should check whether numElementsExit
is zero. Or LiquidityMining.crossTicks
could revert.
function crossTicks( bytes32 poolIdx, int24 exitTick, int24 entryTick ) internal { uint256 numElementsExit = tickTracking_[poolIdx][exitTick].length; tickTracking_[poolIdx][exitTick][numElementsExit - 1] .exitTimestamp = uint32(block.timestamp); StorageLayout.TickTracking memory tickTrackingData = StorageLayout .TickTracking(uint32(block.timestamp), 0); tickTracking_[poolIdx][entryTick].push(tickTrackingData); }
Manual Review
If numElementsExit
is zero. no exitTimeStamp
should be updated.
https://github.com/code-423n4/2023-10-canto/blob/main/canto_ambient/contracts/callpaths/LiquidityMiningPath.sol#L69 https://github.com/code-423n4/2023-10-canto/blob/main/canto_ambient/contracts/callpaths/LiquidityMiningPath.sol#L78
Once the weekly rewards are set, the governor cannot change the value of weekly rewards. Because if one user has already claimed the rewards, changing weekly rewards leads to unfairness.
The governor can set weekly rewards. And the functions donβt stop them from changing the value of weekly rewards. https://github.com/code-423n4/2023-10-canto/blob/main/canto_ambient/contracts/callpaths/LiquidityMiningPath.sol#L69 https://github.com/code-423n4/2023-10-canto/blob/main/canto_ambient/contracts/callpaths/LiquidityMiningPath.sol#L78
However, if the rewards are changed. It only leads to unfairness. There are two examples.
Suppose Alice and Bob have the same amount of liquidity. The original reward is 1000. Alice claims the reward and gets 500.
Manual Review
Add a check in setConcRewards
and setAmbRewards
to stop changing the rewards.
LiquidityMiningPath.userCmd
is not compatible with userCmdRouter
and userCmdRelayer
https://github.com/code-423n4/2023-10-canto/blob/main/canto_ambient/contracts/callpaths/LiquidityMiningPath.sol#L58 https://github.com/code-423n4/2023-10-canto/blob/main/canto_ambient/contracts/callpaths/LiquidityMiningPath.sol#L62
userCmdRouter
and userCmdRelayer
in CrocSwapDex.sol
are used to call userCmd
on behalf of another user. But LiquidityMiningPath.claimConcentratedRewards
and LiquidityMiningPath.claimAmbientRewards
always use msg.sender
.
LiquidityMiningPath.claimConcentratedRewards
and LiquidityMiningPath.claimAmbientRewards
always use msg.sender
.
https://github.com/code-423n4/2023-10-canto/blob/main/canto_ambient/contracts/callpaths/LiquidityMiningPath.sol#L58
https://github.com/code-423n4/2023-10-canto/blob/main/canto_ambient/contracts/callpaths/LiquidityMiningPath.sol#L62
function claimConcentratedRewards(bytes32 poolIdx, int24 lowerTick, int24 upperTick, uint32[] memory weeksToClaim) public payable { claimConcentratedRewards(payable(msg.sender), poolIdx, lowerTick, upperTick, weeksToClaim); } function claimAmbientRewards(bytes32 poolIdx, uint32[] memory weeksToClaim) public payable { claimAmbientRewards(payable(msg.sender), poolIdx, weeksToClaim); }
But userCmdRouter
and userCmdRelayer
in CrocSwapDex.sol
are used to call userCmd
on behalf of another user.
https://github.com/code-423n4/2023-10-canto/blob/main/canto_ambient/contracts/CrocSwapDex.sol#L136
https://github.com/code-423n4/2023-10-canto/blob/main/canto_ambient/contracts/CrocSwapDex.sol#L155
Manual Review
It seems to be a design choice, but the protocol provides userCmdRouter
and userCmdRelayer
in CrocSwapDex
. It is better to be compatible with these two functions. Or the users may misuse these two functions.
#0 - 141345
2023-10-09T04:16:51Z
Low-1 is dup of https://github.com/code-423n4/2023-10-canto-findings/issues/142
Low-2 is dup of https://github.com/code-423n4/2023-10-canto-findings/issues/81
#1 - c4-pre-sort
2023-10-09T17:21:52Z
141345 marked the issue as sufficient quality report
#2 - c4-judge
2023-10-18T22:58:27Z
dmvt marked the issue as grade-b