Exploiter can abuse slippage to claim more weekly reward.
The amount of slippage damage is unclear due to lack of deployment context and testing.
Worst case scenario is the exploiter own 100% deposit of single pool allowing extreme slippage to steal entire contract token.
Owning 100% of single pool rarely happen on live network. But it is possible to flashloan to own majority of the pool token.
Manual
New sidecar LiquidityMiningPath.sol provide function to claim new CANTO reward token based on time spend deposit on UniswapV2 (AmbientPosition) or V3(Concentrated) pool position.
The new rewards fomular can be simplified as:
reward = userTimeWeight * weeklyRewardRate / totalTimeWeighted
Here is how acrrued reward calculated in code:
Under assumption that:
reward can be inflated to really high value by making this condition become true pos.seeds_ >= curve.ambientSeeds_ or userTimeWeight > totalTimeWeighted
As an exploiter, all I need to do is the following:
Look at how rewards is calcualted in LiquidityMining.sol:
File: canto_ambient\contracts\mixins\LiquidityMining.sol
256: function claimAmbientRewards(//@user operation
257: address owner,//msg.sender through delegatecall to Dex
258: bytes32 poolIdx,//user
259: uint32[] memory weeksToClaim//user
260: ) internal {
...
273: uint256 overallTimeWeightedLiquidity = timeWeightedWeeklyGlobalAmbLiquidity_[
274: poolIdx
275: ][week];//@overallTimeWeightedLiquidity == totalTimeWeighted
276: if (overallTimeWeightedLiquidity > 0) {//@ timeWeightedWeeklyPositionAmbLiquidity_ == userTimeWeight per week
277: uint256 rewardsForWeek = (timeWeightedWeeklyPositionAmbLiquidity_[
278: poolIdx
279: ][posKey][week] * ambRewardPerWeek_[poolIdx][week]) /
280: overallTimeWeightedLiquidity;//@audit M user can exploit timeweighted weekly to very small value to get more reward
281: rewardsToSend += rewardsForWeek;
282: }
As above, this can simplified as:
reward = userTimeWeight * weeklyRewardRate / totalTimeWeighted
The value timeWeightedWeeklyGlobalAmbLiquidity_ is updated in function LiquidityMining.accrueAmbientGlobalTimeWeightedLiquidity(). Which is called everytime user mint/burn/claim position.
File: canto_ambient\contracts\mixins\TradeMatcher.sol
63: function mintAmbient (CurveMath.CurveState memory curve, uint128 liqAdded,
64: bytes32 poolHash, address lpOwner)
65: internal returns (int128 baseFlow, int128 quoteFlow) {
66: // Can be used to increase position, need to accrue first
67: accrueAmbientGlobalTimeWeightedLiquidity(poolHash, curve);
68: accrueAmbientPositionTimeWeightedLiquidity(payable(lpOwner), poolHash);
69: uint128 liqSeeds = mintPosLiq(lpOwner, poolHash, liqAdded,
70: curve.seedDeflator_);
71: depositConduit(poolHash, liqSeeds, curve.seedDeflator_, lpOwner);
72:
73: (uint128 base, uint128 quote) = liquidityReceivable(curve, liqSeeds);
74: (baseFlow, quoteFlow) = signMintFlow(base, quote);
75: }
Look at how global weight and user weight is calculated
File: canto_ambient\contracts\mixins\LiquidityMining.sol
198: function accrueAmbientGlobalTimeWeightedLiquidity(
199: bytes32 poolIdx,//@audit can accrue non exist pool
200: CurveMath.CurveState memory curve
201: ) internal {
202: uint32 lastAccrued = timeWeightedWeeklyGlobalAmbLiquidityLastSet_[poolIdx];
203: // Only set time on first call
204: if (lastAccrued != 0) {
205: uint256 liquidity = curve.ambientSeeds_;//@audit where is this value come from
206: uint32 time = lastAccrued;
207: while (time < block.timestamp) {
208: uint32 currWeek = uint32((time / WEEK) * WEEK);
209: uint32 nextWeek = uint32(((time + WEEK) / WEEK) * WEEK);
210: uint32 dt = uint32(
211: nextWeek < block.timestamp
212: ? nextWeek - time
213: : block.timestamp - time
214: );
215: timeWeightedWeeklyGlobalAmbLiquidity_[poolIdx][currWeek] += dt * liquidity;
216: time += dt;
217: }
218: }
219: timeWeightedWeeklyGlobalAmbLiquidityLastSet_[poolIdx] = uint32(
220: block.timestamp
221: );
222: }
224: function accrueAmbientPositionTimeWeightedLiquidity(
225: address payable owner,
226: bytes32 poolIdx
227: ) internal {
228: bytes32 posKey = encodePosKey(owner, poolIdx);
229: uint32 lastAccrued = timeWeightedWeeklyPositionAmbLiquidityLastSet_[
230: poolIdx
231: ][posKey];
232: // Only init time on first call
233: if (lastAccrued != 0) {
234: AmbientPosition storage pos = lookupPosition(owner, poolIdx);
235: uint256 liquidity = pos.seeds_;//@audit-ok M can pos.seeds_ change midway. if it can then manipulate reward accrue
236: uint32 time = lastAccrued;
237: while (time < block.timestamp) {
238: uint32 currWeek = uint32((time / WEEK) * WEEK);
239: uint32 nextWeek = uint32(((time + WEEK) / WEEK) * WEEK);//@gas
240: uint32 dt = uint32(
241: nextWeek < block.timestamp
242: ? nextWeek - time
243: : block.timestamp - time
244: );
245: timeWeightedWeeklyPositionAmbLiquidity_[poolIdx][posKey][
246: currWeek
247: ] += dt * liquidity;
248: time += dt;//@if (nextweek >= block.timestamp) break;
249: }//@1st loop give reward from lasttime to the end of the week.
250: }//@2nd time skip to next week. give reward of current week then loop to next week.
251: timeWeightedWeeklyPositionAmbLiquidityLastSet_[poolIdx][
252: posKey
253: ] = uint32(block.timestamp);//@3 give final reward of current timestamp to beginning of the week
254: }
There are several things to look at here:
Now we only need to figure out how to manipulate curve.ambientSeeds_ and pos.seeds_.
Back-tracking this project is a nightmarish process.
To replicate this bug, it is much simpler to add a bunch of console.log on
LiquidityCurve.liquidityPayable() and LiquidityCurve.liquidityReceivable() to see how curve.ambientSeeds_ change.
Also, PositionRegistar.mintPosLiq and PositionRegistar.burnPosLiq to see how pos.seeds_ change.
Running test file, it is easy to found out another several things:
So to exploit this bug, we only need to making sure accrue global method called when curve.ambientSeeds_ is small value.
Then deposit a bunch of token to inflate pos.seeds_ value on the same block.
Then call claim/mint to update accrue reward. Because global weight or accrueAmbientGlobalTimeWeightedLiquidity() never update global weight on same block, global weight still using old value which is smaller than new pos.seeds_ value.
None
Math
The text was updated successfully, but these errors were encountered:
All reactions