Absence of Slippage Protection in LiquidityBorrowingManager#repay
Summary
The repay function within LiquidityBorrowingManager currently lacks robust safeguards against slippage, potentially exposing borrowers to losses in their profits.
Vulnerability Detail
Consider a scenario where a borrower's position is profitable, and after repaying their debt, they anticipate receiving the remaining tokens following liquidity restoration. This occurs at the conclusion of the LiquidityBorrowingManager repay function.
// Pay a profit to a msg.sender
_pay(borrowing.holdToken, address(this), msg.sender, holdTokenBalance);
_pay(borrowing.saleToken, address(this), msg.sender, saleTokenBalance);
The issue arises from the fact that the borrower is paid whatever remains in the contract. This could be zero, even if the borrower initiated the repayment transaction with full awareness of their profitable position. The first part of the problem lies in the calculation of the liquidity restoration amount, which is determined based on stored liquidity figures and the current state of the pool ratio. The second part is that the borrower cannot specify the quantity of tokens they expect to receive.
Current ratio for each lone position is calculated here in _getCurrentSqrtPriceX96.
This function retrieves the current pool state from slot0, which is susceptible to changes between the creation of the borrower's repayment transaction and its processing. The 'sqrtPriceX96' value plays a crucial role in the subsequent call to QuoterV2, used to estimate the amount of sale tokens the contract will receive after swapping hold tokens.
The saleTokenAmountOut is later used for amountOutMinimum value when the swap is executed. Because the saleTokenAmountOut represents the outcome of this swap calculated via QuoterV2, slippage protection won't work. It is the same as using block.timestamp in smart contracts for deadline protection.
In another scenario, a borrower may not realize a profit, yet they should still receive their remaining collateral and liquidation bonus. Due to market condition changes that can occur between the creation of their transaction and its processing, these assets could also be at risk.
PoC
I have updated the first repay test to demonstrate how output can change when ratio changes. Results for this demonstration aren't as big as they could be because WBTC/WETH is a big pool. This problem would be more severe for pools with lower liquidity because it is easier to move with the price.
Demonstration logs values of two scenarios.
Borrower expects a profit, repays loan and gets it.
Borrower expects a profit, creates a transaction, another transaction is proccessed before his and because of it he gets less.
it("repay borrowing and restore liquidity (long position WBTC zeroForSaleToken = false) will be successful", async () => {
// ...
+ await hackDonor(DONOR_ADDRESS,[alice.address],[{ tokenAddress: WETH_ADDRESS, amount: ethers.utils.parseEther('1000') },{ tokenAddress: WBTC_ADDRESS, amount: ethers.utils.parseUnits('100', 8) },]);
+ await maxApprove(alice, router.address, [USDT_ADDRESS, WETH_ADDRESS, WBTC_ADDRESS]);
+ // After next transaction the user should end up in profit
+ await router.connect(alice).exactInputSingle({ tokenIn: WETH_ADDRESS, tokenOut: WBTC_ADDRESS, fee: 500, amountIn: ethers.utils.parseEther('500'), amountOutMinimum: 0, recipient: alice.address, deadline: (await time.latest()) + 60, sqrtPriceLimitX96: 0 });
+ // Image the next swap happens after the user initiates transaction and before it is processed
+ // Uncomment next line to log different values than user expects
+ await router.connect(alice).exactInputSingle({ tokenIn: WBTC_ADDRESS, tokenOut: WETH_ADDRESS, fee: 500, amountIn: ethers.utils.parseUnits('50', 8), amountOutMinimum: 0, recipient: alice.address, deadline: (await time.latest()) + 60, sqrtPriceLimitX96: 0 });
params = {
isEmergency: false,
internalSwapPoolfee: 500,
externalSwap: swapParams,
borrowingKey: borrowingKey,
swapSlippageBP1000: 990, //1%
};
+ const wbtc = await ethers.getContractAt("IERC20", WBTC_ADDRESS);
+ console.log('Bob\' WBTC balance before', (await wbtc.balanceOf(bob.address)).toString());
+ console.log('Bob\' WETH balance before', (await WETH.balanceOf(bob.address)).toString());
await borrowingManager.connect(bob).repay(params, deadline);
const rateInfo = await borrowingManager.getHoldTokenDailyRateInfo(WETH_ADDRESS, WBTC_ADDRESS);
expect(rateInfo[1].totalBorrowed).to.be.equal(0);
await time.increase(86400);
+ console.log('Bob\' WBTC balance after', (await wbtc.balanceOf(bob.address)).toString());
+ console.log('Bob\' WETH balance after', (await WETH.balanceOf(bob.address)).toString());
});
Transaction in the middle
Token
Before
After
No
WETH
99000000000000000018
99012225608862363471
Yes
WETH
99000000000000000018
99005404442822551938
As you can see the outcomes, in the second scenario user expected 99012225608862363471 tokens, but received only 99005404442822551938. The difference is 6,821,166,039,811,533 wei ~ 0.00682 ether.
In current market conditions, it is around 12 USD (1676 USD for 1 ETH). In the time of market's all-time high where prices are even more volatile this would equal 33 USD (4891 for 1 ETH).
Impact
The repay function fails to offer adequate protection during token swaps from hold tokens to sale tokens. Furthermore, borrowers lack the ability to specify the minimum token amounts they anticipate, resulting in potential losses of profit, collateral, and liquidation bonus.
To address this issue, it is advised to integrate Uniswap TWAP (Time-Weighted Average Price) for precise slippage protection during token swaps. Additionally, a parameter should be introduced to enable borrowers to specify the minimum token quantities they expect to receive, thus enhancing transparency and security.
I found these params in test where slippage revert should be simulated.
The 1001 slippage value should revert every time because it basically says that you expect more than 100% of what you should get. I would suggest finding another way to simulate slippage revert.
0xJuda
high
Absence of Slippage Protection in LiquidityBorrowingManager#repay
Summary
The repay function within LiquidityBorrowingManager currently lacks robust safeguards against slippage, potentially exposing borrowers to losses in their profits.
Vulnerability Detail
Consider a scenario where a borrower's position is profitable, and after repaying their debt, they anticipate receiving the remaining tokens following liquidity restoration. This occurs at the conclusion of the LiquidityBorrowingManager repay function.
LiquidityBorrowingManager.sol#L669-L670
The issue arises from the fact that the borrower is paid whatever remains in the contract. This could be zero, even if the borrower initiated the repayment transaction with full awareness of their profitable position. The first part of the problem lies in the calculation of the liquidity restoration amount, which is determined based on stored liquidity figures and the current state of the pool ratio. The second part is that the borrower cannot specify the quantity of tokens they expect to receive.
Current ratio for each lone position is calculated here in _getCurrentSqrtPriceX96.
LiquidityManager.sol#L331-L342
This function retrieves the current pool state from slot0, which is susceptible to changes between the creation of the borrower's repayment transaction and its processing. The 'sqrtPriceX96' value plays a crucial role in the subsequent call to QuoterV2, used to estimate the amount of sale tokens the contract will receive after swapping hold tokens.
LiquidityManager.sol#L246-L256
The saleTokenAmountOut is later used for amountOutMinimum value when the swap is executed. Because the saleTokenAmountOut represents the outcome of this swap calculated via QuoterV2, slippage protection won't work. It is the same as using block.timestamp in smart contracts for deadline protection.
LiquidityManager.sol#L259-L287
In another scenario, a borrower may not realize a profit, yet they should still receive their remaining collateral and liquidation bonus. Due to market condition changes that can occur between the creation of their transaction and its processing, these assets could also be at risk.
PoC
I have updated the first repay test to demonstrate how output can change when ratio changes. Results for this demonstration aren't as big as they could be because WBTC/WETH is a big pool. This problem would be more severe for pools with lower liquidity because it is easier to move with the price.
Demonstration logs values of two scenarios.
WagmiLeverageTests.ts#L424-L452
As you can see the outcomes, in the second scenario user expected 99012225608862363471 tokens, but received only 99005404442822551938. The difference is 6,821,166,039,811,533 wei ~ 0.00682 ether.
In current market conditions, it is around 12 USD (1676 USD for 1 ETH). In the time of market's all-time high where prices are even more volatile this would equal 33 USD (4891 for 1 ETH).
Impact
The repay function fails to offer adequate protection during token swaps from hold tokens to sale tokens. Furthermore, borrowers lack the ability to specify the minimum token amounts they anticipate, resulting in potential losses of profit, collateral, and liquidation bonus.
Code Snippet
LiquidityBorrowingManager.sol#L532
LiquidityBorrowingManager.sol#L669-L670
LiquidityManager.sol#L331-L342
LiquidityManager.sol#L246-L256
LiquidityManager.sol#L259-L287
WagmiLeverageTests.ts#L424-L452
Tool used
Manual Review
Recommendation
To address this issue, it is advised to integrate Uniswap TWAP (Time-Weighted Average Price) for precise slippage protection during token swaps. Additionally, a parameter should be introduced to enable borrowers to specify the minimum token quantities they expect to receive, thus enhancing transparency and security.
I found these params in test where slippage revert should be simulated.
The 1001 slippage value should revert every time because it basically says that you expect more than 100% of what you should get. I would suggest finding another way to simulate slippage revert.
Duplicate of #109