Summary
The liquidation fee was included in the settlement fee for validating a close order hence the margin validation becomes inflated and prevents users from adjusting their positions even though their position is not presently liquidatable.
DOS to a time sensitive function causing users to lose their collateral.
Vulnerability Details
Addition of the liquidation fee to settlement fees while trying to validate a closing position will cause Healthy positions close to liquidation to revert and never be closed risking liquidation and asset loss.
function _fillOrder(
uint128 tradingAccountId,
uint128 marketId,
uint128 settlementConfigurationId,
SD59x18 sizeDeltaX18,
UD60x18 fillPriceX18
)
internal
virtual
{
FillOrderContext memory ctx;
PerpsEngineConfiguration.Data storage perpsEngineConfiguration = PerpsEngineConfiguration.load();
SettlementConfiguration.Data storage settlementConfiguration =
SettlementConfiguration.load(marketId, settlementConfigurationId);
ctx.isNotionalValueIncreasing =
Position.isNotionalValueIncreasing(tradingAccountId, marketId, sizeDeltaX18.intoInt256().toInt128());
if (ctx.isNotionalValueIncreasing) {
perpsEngineConfiguration.checkMarketIsEnabled(marketId);
settlementConfiguration.checkIsSettlementEnabled();
}
TradingAccount.Data storage tradingAccount = TradingAccount.loadExisting(tradingAccountId);
PerpMarket.Data storage perpMarket = PerpMarket.load(marketId);
perpMarket.checkTradeSize(sizeDeltaX18);
ctx.fundingRateX18 = perpMarket.getCurrentFundingRate();
ctx.fundingFeePerUnitX18 = perpMarket.getNextFundingFeePerUnit(ctx.fundingRateX18, fillPriceX18);
perpMarket.updateFunding(ctx.fundingRateX18, ctx.fundingFeePerUnitX18);
ctx.orderFeeUsdX18 = perpMarket.getOrderFeeUsd(sizeDeltaX18, fillPriceX18);
ctx.settlementFeeUsdX18 = ud60x18(uint256(settlementConfiguration.fee));
Position.Data storage oldPosition = Position.load(tradingAccountId, marketId);
ctx.oldPositionSizeX18 = sd59x18(oldPosition.size);
{
(
UD60x18 requiredInitialMarginUsdX18,
UD60x18 requiredMaintenanceMarginUsdX18,
SD59x18 accountTotalUnrealizedPnlUsdX18
) = tradingAccount.getAccountMarginRequirementUsdAndUnrealizedPnlUsd(marketId, sizeDeltaX18);
ctx.shouldUseMaintenanceMargin = !ctx.isNotionalValueIncreasing && !ctx.oldPositionSizeX18.isZero();
ctx.requiredMarginUsdX18 =
ctx.shouldUseMaintenanceMargin ? requiredMaintenanceMarginUsdX18 : requiredInitialMarginUsdX18;
tradingAccount.validateMarginRequirement(
ctx.requiredMarginUsdX18,
tradingAccount.getMarginBalanceUsd(accountTotalUnrealizedPnlUsdX18),
ctx.orderFeeUsdX18.add(ctx.settlementFeeUsdX18).add(
@audit>> ud60x18(perpsEngineConfiguration.liquidationFeeUsdX18)
)
);
}
The validation uses an inflated total fee causing a healthy position to revert
function validateMarginRequirement(
Data storage self,
UD60x18 requiredMarginUsdX18,
SD59x18 marginBalanceUsdX18,
UD60x18 totalFeesUsdX18
)
internal
view
{
@audit>> revert>> if (requiredMarginUsdX18.add(totalFeesUsdX18).intoSD59x18().gt(marginBalanceUsdX18)) {
revert Errors.InsufficientMargin(
self.id,
marginBalanceUsdX18.intoInt256(),
requiredMarginUsdX18.intoUint256(),
totalFeesUsdX18.intoUint256()
);
}
}
When actually when we want to settle a position we do not deduct liquidation fee hence it should not be part of this verification.
tradingAccount.deductAccountMargin(
TradingAccount.DeductAccountMarginParams({
feeRecipients: FeeRecipients.Data({
marginCollateralRecipient: perpsEngineConfiguration.marginCollateralRecipient,
orderFeeRecipient: perpsEngineConfiguration.orderFeeRecipient,
settlementFeeRecipient: perpsEngineConfiguration.settlementFeeRecipient
}),
pnlUsdX18: ctx.pnlUsdX18.lt(SD59x18_ZERO) ? ctx.pnlUsdX18.abs().intoUD60x18() : UD60x18_ZERO,
@audit>> orderFeeUsdX18: ctx.orderFeeUsdX18,
@audit>> settlementFeeUsdX18: ctx.settlementFeeUsdX18,
marketIds: ctx.marketIds,
accountPositionsNotionalValueX18: new UD60x18[](1)
})
);
The liquidation fee is used to check if a position is liquidatable and should not prevent settlement
if (
TradingAccount.isLiquidatable(
requiredMaintenanceMarginUsdX18,
marginBalanceUsdX18,
ud60x18(perpsEngineConfiguration.liquidationFeeUsdX18)
)
) {
revert Errors.AccountIsLiquidatable(tradingAccountId);
}
see below
function isLiquidatable(
UD60x18 requiredMaintenanceMarginUsdX18,
SD59x18 marginBalanceUsdX18,
UD60x18 liquidationFeeUsdX18
)
internal
pure
returns (bool)
{
@audit >> return requiredMaintenanceMarginUsdX18.add(liquidationFeeUsdX18).intoSD59x18().gt(marginBalanceUsdX18);
}
Impact
Adding the liquidation fee to a position we are adjusting close to the liquidation point will revert a healthy position and DOS a healthy position adjustment.
This will also DOS a user who has the enough initial margin to open a position as the transaction will revert because the fee to be paid used in the check is inflated, more than the user actually pays.
Tools Used
Manual review
Recommendations
Remove the liquidation fee from the validation and check if a position is either liquidated or if the trade been adjusted is healthy with the order fee plus the settlement fee covered.