DeFiFoundry
60,000 USDC
View results
Submission Details
Severity: high
Valid

User can escape the liquidation fee if his collateral only has USDC and USDz

Summary

User can escape the liquidation fee if his collateral only has USDC and USDz.

Vulnerability Details

In LiquidationBranch.sol, the check of liquidatable account is as follows:

// get account's required maintenance margin & unrealized PNL
(, UD60x18 requiredMaintenanceMarginUsdX18, SD59x18 accountTotalUnrealizedPnlUsdX18) =
tradingAccount.getAccountMarginRequirementUsdAndUnrealizedPnlUsd(0, SD59x18_ZERO);
// get then save margin balance into working data
ctx.marginBalanceUsdX18 = tradingAccount.getMarginBalanceUsd(accountTotalUnrealizedPnlUsdX18);
// if account is not liquidatable, skip to next account
// account is liquidatable if requiredMaintenanceMarginUsdX18 > ctx.marginBalanceUsdX18
if (!TradingAccount.isLiquidatable(requiredMaintenanceMarginUsdX18, ctx.marginBalanceUsdX18)) {
continue;
}

getMarginBalanceUsd function is as follows:

/// @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);
}

We can conclude that collateral margin balance = collateral_price * deposited_balance * collateral_loan_to_value_ratio.
The collateral_loan_to_value_ratio is as follows for different collaterals.
1 - USDz- 1e18 LTV
2 - USDC- 1e18 LTV
3 - WETH- 0.8e18 LTV
4 - WBTC- 0.8e18 LTV
5 - wstETH - 0.7e18 LTV
6 - weETH- 0.7e18 LTV
So if users only have collateral like USDC and USDz, marginBalanceUsdX18(effective collateral balance) is equal to actual collateral balance.

ctx.liquidatedCollateralUsdX18 = tradingAccount.deductAccountMargin({
feeRecipients: FeeRecipients.Data({
marginCollateralRecipient: globalConfiguration.marginCollateralRecipient,
orderFeeRecipient: address(0),
settlementFeeRecipient: globalConfiguration.liquidationFeeRecipient
}),
pnlUsdX18: requiredMaintenanceMarginUsdX18,
orderFeeUsdX18: UD60x18_ZERO,
settlementFeeUsdX18: ctx.liquidationFeeUsdX18
});

In deductAccountMargin, requiredMaintenanceMarginUsdX18 is equal or less than marginBalanceUsdX18(effective collateral balance/actual collateral balance for only USDC/USDz collateral). In most case, if liquidatio keeper works well, requiredMaintenanceMarginUsdX18 is equal or slightly less than margin balance.
The margin balance is only enough to pay requiredMaintenanceMarginUsdX18. The liquidation fee is missed to be paid without enough collateral.

Impact

User can escape the liquidation fee if his collateral only has USDC and USDz.

Tools Used

manual

Recommendations

LiquidationBranch::checkLiquidatableAccounts

+ GlobalConfiguration.Data storage globalConfiguration = GlobalConfiguration.load();
+ SD59x18 liquidationFeeUsdX18 = sd59x18(globalConfiguration.liquidationFeeUsdX18);
+ if (TradingAccount.isLiquidatable(requiredMaintenanceMarginUsdX18 + liquidationFeeUsdX18, marginBalanceUsdX18)) {
- if (TradingAccount.isLiquidatable(requiredMaintenanceMarginUsdX18, marginBalanceUsdX18)) {
liquidatableAccountsIds[i] = tradingAccountId;
}

LiquidationBranch::liquidateAccounts

// if account is not liquidatable, skip to next account
// account is liquidatable if requiredMaintenanceMarginUsdX18 > ctx.marginBalanceUsdX18
- if (!TradingAccount.isLiquidatable(requiredMaintenanceMarginUsdX18, ctx.marginBalanceUsdX18)) {
+ if (!TradingAccount.isLiquidatable(requiredMaintenanceMarginUsdX18 + sd59x18(ctx.liquidationFeeUsdX18), ctx.marginBalanceUsdX18)) {
continue;
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 11 months ago
Submission Judgement Published
Validated
Assigned finding tags:

Liquidation doesn't take the liquidation fee in consideration inside the isLiquidatable check

Support

FAQs

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