Summary
Protocol uses Uniswap V2 and Uniswap V3 adapters for few functions: CreditDelegationBranch.sol:convertMarketsCreditDepositsToUsdc(), CreditDelegationBranch.sol:rebalanceVaultsAssets(), CreditDelegationBranch.sol:settleVaultsDebt() and FeeDistributionBranch.sol:convertAccumulatedFeesToWeth(). All these functions are executed by Chainlink Keepers (not the contract owner) and depend on asset prices, making it crucial to use the deadline
parameter correctly to avoid outdated slippage.
However, the current implementation sets the deadline
parameter only by the owner, which can be ineffective, because owner can't be 100% sure when performUpkeep
functions will be called. Instead, the deadline
should be dynamically set based on the timestamp of the performUpkeep
function call in Chainlink Keepers to ensure timely execution of swaps.
Vulnerability Details
deadline
parameter is set by the owner and is used universally in Uniswap adapters regardless of the swap call time:
BaseAdapter.sol:setDeadline():
function setDeadline(uint256 _deadline) public onlyOwner {
if (_deadline < block.timestamp) revert Errors.SwapDeadlineInThePast();
deadline = _deadline;
emit LogSetDeadline(_deadline);
}
UniswapV2Adapter.sol:
function executeSwapExactInputSingle(SwapExactInputSinglePayload calldata swapPayload)
external
returns (uint256 amountOut)
{
...
uint256[] memory amountsOut = IUniswapV2Router02(uniswapV2SwapStrategyRouterCache).swapExactTokensForTokens({
amountIn: swapPayload.amountIn,
amountOutMin: amountOutMinimum,
path: path,
to: swapPayload.recipient,
deadline: deadline
});
return amountsOut[1];
}
function executeSwapExactInput(SwapExactInputPayload calldata swapPayload) external returns (uint256 amountOut) {
...
uint256[] memory amountsOut = IUniswapV2Router02(uniswapV2SwapStrategyRouterCache).swapExactTokensForTokens({
amountIn: swapPayload.amountIn,
amountOutMin: amountOutMinimum,
path: tokens,
to: swapPayload.recipient,
deadline: deadline
});
return amountsOut[tokens.length - 1];
}
UniswapV3Adapter.sol:
function executeSwapExactInputSingle(SwapExactInputSinglePayload calldata swapPayload)
external
returns (uint256 amountOut)
{
...
return swapRouter.exactInputSingle(
IUniswapV3RouterInterface.ExactInputSingleParams({
tokenIn: swapPayload.tokenIn,
tokenOut: swapPayload.tokenOut,
fee: feeBps,
recipient: swapPayload.recipient,
deadline: deadline,
amountIn: swapPayload.amountIn,
amountOutMinimum: amountOutMin,
sqrtPriceLimitX96: 0
})
);
}
function executeSwapExactInput(SwapExactInputPayload calldata swapPayload) external returns (uint256 amountOut) {
...
return swapRouter.exactInput(
IUniswapV3RouterInterface.ExactInputParams({
path: swapPayload.path,
recipient: swapPayload.recipient,
deadline: deadline,
amountIn: swapPayload.amountIn,
amountOutMinimum: amountOutMinimum
})
);
}
PoC
Take for example FeeConversionKeeper
(for PoC just "keeper") and assume that owner
don't know exactly when performUpkeep
will be called, so he sets the deadline = block.timestamp + 1 days
this can lead to unintended consequences.
1 - Keeper calls checkUpkeep()
and checks if fee distribution is needed for asset:
function checkUpkeep(bytes calldata ) external view returns (bool upkeepNeeded, bytes memory performData) {
...
for (uint128 i; i < liveMarketIds.length; i++) {
...
for (uint128 j; j < marketAssets.length; j++) {
distributionNeeded = checkFeeDistributionNeeded(marketAssets[j], feesCollected[j]);
if (distributionNeeded) {
upkeepNeeded = true;
...
}
}
}
...
}
function checkFeeDistributionNeeded(
address asset,
uint256 collectedFee
)
public
view
returns (bool distributionNeeded)
{
FeeConversionKeeperStorage storage self = _getFeeConversionKeeperStorage();
uint256 assetValue = self.marketMakingEngine.getAssetValue(asset, collectedFee);
distributionNeeded = assetValue > self.minFeeDistributionValueUsd;
}
2 - Now if distributionNeeded
keeper calls performUpkeep()
with marketMakingEngine.convertAccumulatedFeesToWeth
in which contract swaps assets for WETH with deadline
approximatly block.timestamp + 1 days
:
function performUpkeep(bytes calldata performData) external override onlyForwarder {
FeeConversionKeeperStorage memory self = _getFeeConversionKeeperStorage();
IMarketMakingEngine marketMakingEngine = self.marketMakingEngine;
(uint128[] memory marketIds, address[] memory assets) = abi.decode(performData, (uint128[], address[]));
for (uint256 i; i < marketIds.length; i++) {
marketMakingEngine.convertAccumulatedFeesToWeth(marketIds[i], assets[i], self.dexSwapStrategyId, "");
}
}
3 - Due to high network load or low gas fees transaction is stuck and does not being included in the block right away.
4 - Now price of the asset drops and distributionNeeded = assetValue > self.minFeeDistributionValueUsd
= false
. (I know that contract sets amountMin
with current slippageToleranceBps
which can be minimum = 1%, but still followed condition can be false
and current slippageToleranceBps
can be actually set more than 1% by owner)
5 - But because deadline
for swap is not dynamic depending on the swap call - asset will be swaped with lower price later, instead of being reverted.
Impact
Trading activity is very time senstive and failing to set an appropriate deadline allows pending transactions to be executed later, even if the original conditions for the call have changed, potentially leading to unintended losses.
Tools Used
Manual Review
Recommendations
Set the deadline
parameter for each swap separately depending on when the function was called, something like:
block.timestamp + minimumDeadline