Part 2

Zaros
PerpetualsDEXFoundrySolidity
70,000 USDC
View results
Submission Details
Severity: medium
Invalid

Positions are liquidated eventhough their actual Margin is safe because of a wrong assessment.

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

/// @param accountsIds The list of accounts to liquidate
function liquidateAccounts(uint128[] calldata accountsIds) external {
// return if no input accounts to process
if (accountsIds.length == 0) return;
// fetch storage slot for perps engine configuration
PerpsEngineConfiguration.Data storage perpsEngineConfiguration = PerpsEngineConfiguration.load();
// only authorized liquidators are able to liquidate
if (!perpsEngineConfiguration.isLiquidatorEnabled[msg.sender]) {
revert Errors.LiquidatorNotRegistered(msg.sender);
}
// working data
LiquidationContext memory ctx;
// load liquidation fee from perps engine config; will be passed in as `settlementFeeUsdX18`
// to `TradingAccount::deductAccountMargin`. The user being liquidated has to pay
// this liquidation fee as a "settlement fee"
ctx.liquidationFeeUsdX18 = ud60x18(perpsEngineConfiguration.liquidationFeeUsdX18);
// iterate over every account being liquidated; intentionally not caching
// length as reading from calldata is faster
for (uint256 i; i < accountsIds.length; i++) {
// store current accountId being liquidated in working data
ctx.tradingAccountId = accountsIds[i];
// sanity check for non-sensical accountId; should never be true
if (ctx.tradingAccountId == 0) continue;
// load account's leaf (data + functions)
TradingAccount.Data storage tradingAccount = TradingAccount.loadExisting(ctx.tradingAccountId);
// get account's required maintenance margin & unrealized PNL
(, ctx.requiredMaintenanceMarginUsdX18, ctx.accountTotalUnrealizedPnlUsdX18) =
tradingAccount.getAccountMarginRequirementUsdAndUnrealizedPnlUsd(0, SD59x18_ZERO);
// get then save margin balance into working data
@audit>>> ctx.marginBalanceUsdX18 = tradingAccount.getMarginBalanceUsd(ctx.accountTotalUnrealizedPnlUsdX18);
// if account is not liquidatable, skip to next account
// account is liquidatable if requiredMaintenanceMarginUsdX18 > ctx.marginBalanceUsdX18
if (
@audit>>> !TradingAccount.isLiquidatable(
ctx.requiredMaintenanceMarginUsdX18, ctx.marginBalanceUsdX18, ctx.liquidationFeeUsdX18
)
) {
continue;
}

When we call GETMARGIN BALANCE.

/// @notice Returns the margin balance of the account in usd.
/// @dev The margin balance takes uPnL and each collateral type's LTV into account.
/// @param self The trading account storage pointer.
/// @param activePositionsUnrealizedPnlUsdX18 The total unrealized PnL of the account's active positions.
function getMarginBalanceUsd(
Data storage self,
SD59x18 activePositionsUnrealizedPnlUsdX18
)
internal
view
returns (SD59x18 marginBalanceUsdX18)
{
// cache colllateral length
uint256 cachedMarginCollateralBalanceLength = self.marginCollateralBalanceX18.length();
// iterate over every collateral account has deposited
for (uint256 i; i < cachedMarginCollateralBalanceLength; i++) {
// read key/value from storage for current iteration
(address collateralType, uint256 balance) = self.marginCollateralBalanceX18.at(i);
// load collateral margin config for this collateral type
MarginCollateralConfiguration.Data storage marginCollateralConfiguration =
MarginCollateralConfiguration.load(collateralType);
// calculate the collateral's "effective" balance as:
// collateral_price * deposited_balance * collateral_loan_to_value_ratio
UD60x18 adjustedBalanceUsdX18 = marginCollateralConfiguration.getPrice().mul(ud60x18(balance)).mul(
ud60x18(marginCollateralConfiguration.loanToValue)
);
// add this account's "effective" collateral balance to cumulative output
marginBalanceUsdX18 = marginBalanceUsdX18.add(adjustedBalanceUsdX18.intoSD59x18());
}
// finally add the unrealized PNL to the cumulative output
marginBalanceUsdX18 = marginBalanceUsdX18.add(activePositionsUnrealizedPnlUsdX18);
}

USING the values below to demonstrate.

  1. Collaterl token - 2000 USD

  2. Collateral factor - 80%

  3. Ajusted Margin balance without PNL - 1600 USD

  4. PNL - -500 USD

  5. Required Margin - 10% of Postion side (11,000 USD ) - 1,100 USD

  6. Liquidation fee - 50 USD

  7. 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

/// @notice Checks if the account is liquidatable.
/// @param requiredMaintenanceMarginUsdX18 The required maintenance margin in USD.
/// @param marginBalanceUsdX18 The account's margin balance in USD.
/// @param liquidationFeeUsdX18 The liquidation fee in 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

Updates

Lead Judging Commences

inallhonesty Lead Judge
5 months ago
inallhonesty Lead Judge 4 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Appeal created

bigsam Submitter
4 months ago
bigsam Submitter
4 months ago
inallhonesty Lead Judge
4 months ago
inallhonesty Lead Judge 4 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.