Summary
When an account is about to be liquidated, the check uses the Adjusted margin and compares this with the required margin but based on the current implementation it was spotted that the margin balance may be higher than the margin returned thus an account that should still be healthy will be unfairly liquidated.
Vulnerability Details
The margin balance used to validate if an account is liquidatable is flawed
function liquidateAccounts(uint128[] calldata accountsIds) external {
if (accountsIds.length == 0) return;
PerpsEngineConfiguration.Data storage perpsEngineConfiguration = PerpsEngineConfiguration.load();
if (!perpsEngineConfiguration.isLiquidatorEnabled[msg.sender]) {
revert Errors.LiquidatorNotRegistered(msg.sender);
}
LiquidationContext memory ctx;
ctx.liquidationFeeUsdX18 = ud60x18(perpsEngineConfiguration.liquidationFeeUsdX18);
for (uint256 i; i < accountsIds.length; i++) {
ctx.tradingAccountId = accountsIds[i];
if (ctx.tradingAccountId == 0) continue;
TradingAccount.Data storage tradingAccount = TradingAccount.loadExisting(ctx.tradingAccountId);
(, ctx.requiredMaintenanceMarginUsdX18, ctx.accountTotalUnrealizedPnlUsdX18) =
tradingAccount.getAccountMarginRequirementUsdAndUnrealizedPnlUsd(0, SD59x18_ZERO);
@audit>>> ctx.marginBalanceUsdX18 = tradingAccount.getMarginBalanceUsd(ctx.accountTotalUnrealizedPnlUsdX18);
if (
@audit>>> !TradingAccount.isLiquidatable(
ctx.requiredMaintenanceMarginUsdX18, ctx.marginBalanceUsdX18, ctx.liquidationFeeUsdX18
)
) {
continue;
}
When we call GETMARGIN BALANCE.
function getMarginBalanceUsd(
Data storage self,
SD59x18 activePositionsUnrealizedPnlUsdX18
)
internal
view
returns (SD59x18 marginBalanceUsdX18)
{
uint256 cachedMarginCollateralBalanceLength = self.marginCollateralBalanceX18.length();
for (uint256 i; i < cachedMarginCollateralBalanceLength; i++) {
(address collateralType, uint256 balance) = self.marginCollateralBalanceX18.at(i);
MarginCollateralConfiguration.Data storage marginCollateralConfiguration =
MarginCollateralConfiguration.load(collateralType);
UD60x18 adjustedBalanceUsdX18 = marginCollateralConfiguration.getPrice().mul(ud60x18(balance)).mul(
ud60x18(marginCollateralConfiguration.loanToValue)
);
marginBalanceUsdX18 = marginBalanceUsdX18.add(adjustedBalanceUsdX18.intoSD59x18());
}
marginBalanceUsdX18 = marginBalanceUsdX18.add(activePositionsUnrealizedPnlUsdX18);
}
USING the values below to demonstrate.
Collaterl token - 2000 USD
Collateral factor - 80%
Ajusted Margin balance without PNL - 1600 USD
PNL - -500 USD
Required Margin - 10% of Postion side (11,000 USD ) - 1,100 USD
Liquidation fee - 50 USD
Check is position Liquidatable
New Margin Balance = Adjusted + PNL = 1600 - 500 = 1100 USD
since Required + Fee > New Margin Balance we will liquidate this account.
1150 USD > 1100 USD
function isLiquidatable(
UD60x18 requiredMaintenanceMarginUsdX18,
SD59x18 marginBalanceUsdX18,
UD60x18 liquidationFeeUsdX18
)
internal
pure
returns (bool)
{
return requiredMaintenanceMarginUsdX18.add(liquidationFeeUsdX18).intoSD59x18().gt(marginBalanceUsdX18);
}
But this liquidation act is actually incorrect because the actual balance is still above the Required Margin,
Before check if the account is liquidatable should settle the debt/use a view function to balance the actually collateral available
Collateral Balance = 2000 USD
PNL = -500 USD
GET the NET collateral Balance = 2000 - 500 USD = 1500 USD
Adjust the real Collateral balance
REAL adjusted balance = 1500 USD * 80% = 1200 USD
Now check if liquidatable
1100 USD + 50 USD > 1200 USD => will not be liquidated.
The current check will cause a user to be liquidated even when his current balance is enough to keep his position afloat.
Impact
A safe account with a negative PNL will be liquidated before his actual Collateral margin drops below the required margin, causing such users to lose their collateral unfairly.
Tools Used
Manual Review
Recommendations
Settle or update the balance temporarily or use a view function and accurately calculate using the actual margin of the user to avoid unfair liquidation