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: 1/7
Findings: 10
Award: $26,145.52
π Selected for report: 7
π Solo Findings: 5
49.5711 SUSHI - $619.64
hickuphh3
The positionId
is used to retrieve the incentive info instead of incentiveId
.
Incentive storage / memory incentive = incentives[position.pool][positionId];
Incentive storage / memory incentive = incentives[position.pool][incentiveId];
#0 - sarangparikh22
2021-10-12T10:00:12Z
Duplicate of #39 Disagree with severity, it should be 2.
49.5711 SUSHI - $619.64
hickuphh3
reclaimIncentive()
withdraws any unclaimed rewards to the incentive owner. While there is a check to prevent re-claiming of rewards
require(incentive.rewardsUnclaimed >= amount, "ALREADY_CLAIMED");
it is ineffective because incentive.rewardsUnclaimed
is not decremented by the amount specified. This means an incentive owner can claim other incentives that are denominated in the same token.
The critical line incentive.rewardsUnclaimed -= amount;
should be added after the checks are performed, as part of the best practice of the CEI pattern.
#0 - sarangparikh22
2021-10-11T14:10:16Z
Duplicate of #37
π Selected for report: hickuphh3
377.7706 SUSHI - $4,722.13
hickuphh3
Swap fees are taken from the output. Hence, if swapping token0 for token1 (zeroForOne
is true), then fees are taken in token1. We see this to be the case in the initialization of feeGrowthGlobal in the swap cache
feeGrowthGlobal = zeroForOne ? feeGrowthGlobal1 : feeGrowthGlobal0;
and in _updateFees()
.
However, looking at Ticks.cross()
, the logic is the reverse, which causes wrong fee accounting.
if (zeroForOne) { ... ticks[nextTickToCross].feeGrowthOutside0 = feeGrowthGlobal - ticks[nextTickToCross].feeGrowthOutside0; } else { ... ticks[nextTickToCross].feeGrowthOutside1 = feeGrowthGlobal - ticks[nextTickToCross].feeGrowthOutside1; }
Switch the 0
and 1
in Ticks.cross()
.
if (zeroForOne) { ... // feeGrowthGlobal = feeGrowthGlobal1 ticks[nextTickToCross].feeGrowthOutside1 = feeGrowthGlobal - ticks[nextTickToCross].feeGrowthOutside1; } else { ... // feeGrowthGlobal = feeGrowthGlobal0 ticks[nextTickToCross].feeGrowthOutside0 = feeGrowthGlobal - ticks[nextTickToCross].feeGrowthOutside0; }
π Selected for report: hickuphh3
377.7706 SUSHI - $4,722.13
hickuphh3
secondsPerLiquidity
is updated as such: secondsPerLiquidity += uint160((diff << 128) / liquidity);
where diff = timestamp - uint256(lastObservation)
. Hence, whenever liquidity changes, secondsPerLiquidity
should be updated prior to the change.
In particular, this affects the mint()
and burn()
functions, in the case where liquidity changes when lowerTick <= currentTick < upperTick
.
In fact, the latest secondsPerLiquidity
value should be calculated and used in Ticks.insert()
. For comparison, notice how UniswapV3 fetches the latest value by calling observations.observeSingle()
in its _updatePosition()
function.
The secondsPerLiquidity
increment logic should be applied prior to liquidity addition in mint()
and removal in burn()
.
// insert logic before these lines in mint() unchecked { if (priceLower < currentPrice && currentPrice < priceUpper) liquidity += uint128(_liquidity); } nearestTick = Ticks.insert( ticks, feeGrowthGlobal0, feeGrowthGlobal1, secondsPerLiquidity, // should calculate and use latest secondsPerLiquidity value ... ); // insert logic before before these lines in burn() unchecked { if (priceLower < currentPrice && currentPrice < priceUpper) liquidity -= amount; }
#0 - sarangparikh22
2021-10-28T22:33:49Z
The secondsPerLiquidity is same, changing the order of that will not affect anything, since it is not getting calculated at the mint or burn function.
#1 - alcueca
2021-11-12T14:28:40Z
@sarangparikh22, could you please elaborate on why this isn't an issue?
#2 - sarangparikh22
2021-11-16T23:46:55Z
@alcueca my apologies, this is an issue. I could confirm this.
π Selected for report: hickuphh3
377.7706 SUSHI - $4,722.13
hickuphh3
The fee growth mechanism, and by extension, secondsPerLiquidity mechanism of Uniswap V3 has the ability to underflow. It is therefore a necessity for the math to (ironically) be unsafe / unchecked.
Assume the following scenario and initial conditions:
rangeFeeGrowth(20,30)
.
lowerTick = 20, upperTick = 30
feeGrowthBelow = 50 (lowerTick's feeGrowthOutside) since lowerTick < currentTick
feeGrowthAbove = 50 - 40 = 10 (feeGrowthGlobal - upperTick's feeGrowthOutside) since upperTick < currentTick
feeGrowthInside
= feeGrowthGlobal - feeGrowthBelow - feeGrowthAbove
= 50 - 50 - 10
= -10
We therefore have negative feeGrowthInside.
This behaviour is actually acceptable, because the important thing about this mechanism is the relative values to each other, not the absolute values themselves.
rangeFeeGrowth()
and rangeSecondsInside()
has to be unchecked. In addition, the subtraction of feeGrowthInside values should also be unchecked in _updatePosition()
and ConcentratedLiquidityPosition#collect()
.
The same also applies for the subtraction of pool.rangeSecondsInside
and stake.secondsInsideLast
in claimReward()
and getReward()
of the ConcentratedLiquidityPoolManager
contract.
#0 - sarangparikh22
2021-11-05T19:01:03Z
Can you give more elaborate example.
#1 - alcueca
2021-11-12T13:52:57Z
@sarangparikh22, I find the example quite elaborate. It shows an specific example in which underflow is desired, by comparing with other platform using similar mechanics. It explains that with your current implementation you can't have negative feeGrowthInside
, which is a possible and acceptable scenario. Could you please elaborate on what your grounds are for disputing this finding?
#2 - sarangparikh22
2021-11-17T22:50:56Z
@alcueca Yes this a valid issue.
π Selected for report: hickuphh3
377.7706 SUSHI - $4,722.13
hickuphh3
No check is performed for the initial price. This means that it can be set to be below the MIN_SQRT_RATIO
or above MAX_SQRT_RATIO
(Eg. zero value), which will prevent the usability of all other functions (minting, swapping, burning).
For example, Ticks.insert()
would fail when attempting to calculate actualNearestTick = TickMath.getTickAtSqrtRatio(currentPrice);
, which means no one will be able to mint positions.
Check the initialPrice
is within the acceptable range, ie. MIN_SQRT_RATIO <= initialPrice <= MAX_SQRT_RATIO
π Selected for report: hickuphh3
377.7706 SUSHI - $4,722.13
hickuphh3
Uniswap V3's whitepaper describes the fee growth mechanism, but the intuition behind it is not explained well (IMO). I've not been able to find any material that tries to describe it, so allow me the luxury of doing so. It is crucial to understand how it works, so that other issues regarding the fee growth variables (and by extension, secondsPerLiquidity) raised by fellow wardens / auditors are better understood by readers.
We want a way to accurately track the fees accumulated by a position. Fees should only be given to the position it is active (the current tick / price is within the lower and upper ticks of the position).
Defined as the total amount of fees that would have been earned by 1 unit of unbounded liquidity that was deposited when the contract was first initialized. For simplicity, we can take this to be the range between MIN_TICK
and MAX_TICK
. We represent it visually like this:
// <--------------------------------------------------------------------------> // MIN_TICK MAX_TICK
The fee growth per unit of liquidity on the other side of this tick (relative to the current tick). What does this mean?
As defined, it is the fee growth relative to the current tick. Based on the convention, we define 2 cases:
Visually, the feeGrowthOutside will look like this:
// CASE 1 // <--------------------|--------------------| // MIN_TICK INIT_TICK POOL_TICK // <-----------------------------------------| // MIN_TICK INIT_TICK = POOL_TICK // CASE 2 // |--------------------|----------------> // POOL_TICK INIT_TICK MAX_TICK
Hence, regardless of whether the tick to initialize is either a lower or upper tick of a position, the feeGrowthOutside
value that it is referring to is relatve to the pool tick.
In other words, if initialized tick β€ pool tick, then its feeGrowthOutside
is towards MIN_TICK
. Otherwise, its feeGrowthOutside
is towards MAX_TICK
.
By convention, when a tick is initialized, all fee growth is assumed to happen below it. Hence, the feeGrowthOutside is initialized to the following values:
One should now understand why the feeGrowthOutside
value is being flipped when crossing a tick, ie. tick.feeGrowthOutside = feeGrowthGlobal - tick.feeGrowthOutside
in Tick.cross()
, because it needs to follow the definition. (Case 1 becomes case 2 and vice versa).
It should hopefully become clear why using nearestTick
as the reference point for fee growth calculations instead of the pool tick might not a wise choice. (Case 1 and 2 becomes rather ambiguous).
Going back to our objective of calculating the fee growth accumulated for a position, we can break it down into 3 cases (take caution with the boundary cases), and understand how their values are calculated. In general, we take it to be feeGrowthGlobal - fee growth below lower tick - fee growth above upper tick (see illustrations), although it can be simplified further.
pool tick < lower tick
// ---------------------|---------------------|-----------------|----------------- // POOL_TICK LOWER_TICK UPPER_TICK // <---------------------------- feeGrowthGlobal --------------------------------> // LOWER_TICK.feeGrowthOutside (CASE 2) |----------------------------------> // UPPER_TICK.feeGrowthOutside (CASE 2) |----------------> // we want the range between LOWER_TICK and UPPER_TICK // = LOWER_TICK.feeGrowthOutside - UPPER_TICK.feeGrowthOutside // alternatively, following the general formula, it is // = feeGrowthGLobal - fee growth below LOWER_TICK - fee growth above UPPER_TICK // = feeGrowthGlobal - (feeGrowthGlobal - LOWER_TICK.feeGrowthOutside) - UPPER_TICK.feeGrowthOtuside // = LOWER_TICK.feeGrowthOutside - UPPER_TICK.feeGrowthOutside
lower tick β€ pool tick < upper tick
// ---------------------|---------------------|-----------------|----------------- // LOWER_TICK POOL_TICK UPPER_TICK // <---------------------------- feeGrowthGlobal --------------------------------> // <--------------------| LOWER_TICK's feeGrowthOutside (CASE 1) // UPPER_TICK's feeGrowthOutside (CASE 2) |----------------> // we want the range between LOWER_TICK and UPPER_TICK // = feeGrowthGLobal - fee growth below LOWER_TICK - fee growth above UPPER_TICK // = feeGrowthGLobal - LOWER_TICK.feeGrowthOutside - UPPER_TICK.feeGrowthOutside
upper tick β€ pool tick
// ---------------------|---------------------|-----------------|----------------- // LOWER_TICK POOL_TICK UPPER_TICK // <---------------------------- feeGrowthGlobal --------------------------------> // <--------------------| LOWER_TICK's feeGrowthOutside (CASE 1) // <------------------------------------------------------------| UPPER_TICK's feeGrowthOutside (CASE 1) // we want the range between LOWER_TICK and UPPER_TICK // = UPPER_TICK.feeGrowthOutside - LOWER_TICK.feeGrowthOutside // alternatively, following the general formula, it is // = feeGrowthGLobal - fee growth below LOWER_TICK - fee growth above UPPER_TICK // = feeGrowthGLobal - LOWER_TICK.feeGrowthOutside - (feeGrowthGlobal - UPPER_TICK.feeGrowthOutside) // = UPPER_TICK.feeGrowthOutside - LOWER_TICK.feeGrowthOutside
An under appreciated, but very critical line of Uniswap V3's pool contract is the following:
state.tick = zeroForOne ? step.tickNext - 1 : step.tickNext;
It serves a dual purpose:
zeroForOne
is true (pool tick goes down). In this scenario, case 1 becomes case 2 when the tick is crossed. However, should the poolTick after the swap be equal to step.tickNext
, then when calculating fee growth inside a position that so happens to have step.tickNext
as one of its ticks, it will be treated as case 1 (poolTick = lowerTick / upperTick) when it is required to be treated as case 2.Hopefully, this writeup helps readers understand the fee growth mechanism and its workings. More importantly, I hope it helps the team to understand why using nearestTick
as the reference point for fee growth mechanism is unsuitable. Specifically, we have 2 high severity issues:
feeGrowthOutside
in the case either the lower or upper tick becomes the nearestTick
upon insertion of a new tick.
secondsPerLiquidityOutside
has to be flippedfeeGrowthOutside
is incorrectly initialized to be 0
when it should be feeGrowthOutside
nearestTick
, it is treated to be case 1 when in fact there are times it should be treated as case 2.Having a pool tick counter that closely matches the current pool price is rather critical for fee growth and seconds per liquidity initializations / calculations.
Where relevant, the nearestTick
should be replaced by poolTick
.
14.8713 SUSHI - $185.89
hickuphh3
The subtraction of secondsClaimed
should be performed after the left shifting of bits in
uint256 secondsUnclaimed = (maxTime - incentive.startTime) << (128 - incentive.secondsClaimed);
uint256 secondsUnclaimed = ((maxTime - incentive.startTime) << 128) - incentive.secondsClaimed);
#0 - sarangparikh22
2021-10-12T09:57:36Z
Duplicate of #41, Disagree with severity, it should be 2.
#1 - alcueca
2021-11-12T10:39:40Z
Functionality is degraded, but assets are not lost. Severity 2.
50.999 SUSHI - $637.49
hickuphh3
getTickState()
attempts to fetch the state of all inserted ticks (including MIN_TICK
and MAX_TICK
) of a pool. Depending on the tick spacing, this function may run out of gas.
Have a starting index parameter to start the iteration from. Also, tickCount
can be made use of more meaningfully to limit the number of iterations performed.
function getTickState( IConcentratedLiquidityPool pool, int24 startIndex, uint24 tickCount ) external view returns (SimpleTick[] memory) { SimpleTick[] memory ticks = new SimpleTick[](tickCount); IConcentratedLiquidityPool.Tick memory tick; int24 current = startIndex; for (uint24 i; i < tickCount; i++) { tick = pool.ticks(current); ticks[i] = SimpleTick({index: current, liquidity: tick.liquidity}); // reached end of linked list, exit loop if (current == TickMath.MAX_TICK) break; // else, continue with next iteration current = tick.nextTick; } return ticks; }
#0 - alcueca
2021-11-12T10:47:29Z
Functionality is affected, severity 2.
π Selected for report: hickuphh3
37.7771 SUSHI - $472.21
hickuphh3
MAX_TICK_LIQUIDITY
is defined as the maximum allowable liquidity of an initialised tick. However, the check is instead performed in 2 places. First, in mint()
require(_liquidity <= MAX_TICK_LIQUIDITY, "LIQUIDITY_OVERFLOW");
and then, in _updatePosition()
require(position.liquidity < MAX_TICK_LIQUIDITY, "MAX_TICK_LIQUIDITY");
(side note: notice the discrepancy in the comparisons: <
versus <=
)
These checks don't satisfy the intended purpose of MAX_TICK_LIQUIDITY
, because what matters is not an individual user or position's liquidity, but the cumulative tick liquidity to be added or removed.
A simple example would be Alice and Bob minting an amount with MAX_TICK_LIQUIDITY
each with the same lower and upper ticks. The checks pass, but the ticks' liquidity values exceed MAX_TICK_LIQUIDITY
.
MAX_TICK_LIQUIDITY
should be passed into Ticks.insert()
and checked when liquidity is added to the lower and upper ticks.
The other checks mentioned above are redundant.
if (currentLowerLiquidity != 0 || lower == TickMath.MIN_TICK) { // We are adding liquidity to an existing tick. require(currentLowerLiquidity + amount <= MAX_TICK_LIQUIDITY, "LIQUIDITY_OVERFLOW"); ticks[lower].liquidity = currentLowerLiquidity + amount; } else { // We are inserting a new tick. require(amount <= MAX_TICK_LIQUIDITY, "LIQUIDITY_OVERFLOW"); ... } ... if (currentUpperLiquidity != 0 || upper == TickMath.MAX_TICK) { // We are adding liquidity to an existing tick. require(currentUpperLiquidity + amount <= MAX_TICK_LIQUIDITY, "LIQUIDITY_OVERFLOW"); ticks[upper].liquidity = currentUpperLiquidity + amount; } else { // Inserting a new tick. require(amount <= MAX_TICK_LIQUIDITY, "LIQUIDITY_OVERFLOW"); ... }
#0 - sarangparikh22
2021-10-13T20:49:07Z
The issue doesn't affect the contract in anyway to rate it to 2. It should be 1 instead.
#1 - alcueca
2021-11-12T11:00:01Z
Function incorrect as to spec, not proven that assets are at risk. Severity 1.