Perp v2 Liquidation Mechanism Update

No more market-selling positions upon liquidation that can impact the market price!

Written by Shaoku Tien, Senior Software Engineer at Perpetual Protocol.

Outline

  1. Intro
  2. Market-selling Liquidation
    • Cascading Liquidation
    • System Bad Debt
  3. Position-transferring Liquidation
    • Interfaces
    • Condition Checks
    • Core Calculation Logic
    • Liquidation Fees Distribution
  4. Getting A Liquidator's Maximum Capacity
  5. Conclusion

1. Intro

Hi, this post is a new attempt to introduce smart contract updates by posting articles to explain changes with code snippets and rationales behind the decision.

While smart contract updates can be impactful on all users, their existence usually goes unnoticed as non-devs lack the incentive to pay attention and devs might only know when there’s an error telling them some functions have been modified already.

Thus, as a starter, we’d like to introduce the upcoming update on our liquidation mechanism!

Cascading Liquidation

A cascading liquidation refers to a series of liquidations in that a former liquidation triggers several following ones. This can happen if the market price determines the margin ratio (or leverage) of a position. As the market-selling of positions influences the market price, the action can worsen the health condition of other positions with already low margin ratios (or high leverage).

Fortunately, it’s not a concern for us as we have been using index prices from an oracle (e.g., Chainlink) to measure margin ratios.

We can verify this by tracing down (click on function names to go to repos).

ClearingHouse._liquidate()
-> ClearingHouse._isLiquidatable()
-> AccountBalance.getMarginRequirementForLiquidation()
-> AccountBalance.getTotalAbsPositionValue()
-> AccountBalance.getTotalPositionValue()
-> AccountBalance._getReferencePrice()
-> BaseToken.getIndexPrice()
-> IPriceFeed.getPrice()

to finally see that index prices fetched from the oracle are the ones for determining the position value, not market prices.

System Bad Debt

Preventing cascading liquidation, however, doesn’t guarantee the elimination of bad debt. Bad debt describes the insolvency of a position. In our case, it’s when a position’s collateral is insufficient to cover the position’s loss.

Though margin ratios are derived from index prices, the real notional values of positions are based on market prices. Therefore, even though a position can be safe from liquidation, it isn’t necessarily solvent as index prices on the blockchain venue can lag real-time prices in the outside world.

Let’s look at an example! (assuming there is no slippage)

Say when both index & market prices of ETH are $1,000, Alice pays $100 USDC collateral to open a 1 ETH long position, whose notional value is 1 * 1,000 = $1,000. Her leverage is 10x: 1,000 (notional value) / 100 (collateral) = 10.

Suddenly, the market price of ETH drops drastically to $890, while the index price remains at $1,000.

Now, Alice’s margin ratio is intact since the index price doesn’t change, while the real notional value of her position is only worth $890, suggesting that she has a loss of $110 :

  • current notional value: $890
  • original notional value (when opening the position): $1,000
  • pnl: 890 — 1000 = -110

However, remember that she only deposited $100 USDC as collateral, which is less than her $110 loss, meaning that the system has a $10 (100 — 110 = -10) bad debt.

So what does this have to do with liquidation? If liquidation is executed with market-selling positions, while the index price doesn’t change, the market price does. In other words, although we avoid a cascading liquidation, each liquidation can still cause bad debt to the system.

Hence, to avoid liquidation resulting in bad debts, the solution is to not market-sell positions upon liquidations.

2. Position-transferring Liquidation

What should we do instead of selling positions directly for liquidation? Offer a discount to incentivize others to take up ownership of the position!

Transferring the ownership of an (almost) insolvent position is a relatively common practice in other DeFi protocols, such as Aave & MakerDAO.

Another example is FTX, which adopts a hybrid approach: if market-selling position isn’t effective enough to bring back the position to a safe level, the mechanism of transferring the nearly or already bankrupt position will kick in, which they call Backstop Liquidity Provider. They’ve written a detailed article on the topic and feel free to check that out!

By transferring positions instead of market-selling them, liquidation can happen without affecting the market price!

Therefore, after the contract upgrade is successfully deployed, there will only be position-transferring liquidations on Perp v2.

Some might ask: why not adopt the hybrid approach of FTX? The reason is quite straightforward and, as mentioned, index prices on blockchains can lag prices in the outside world. Thus, setting a threshold margin ratio between market-selling or taking over positions might not be as effective as on CEXs.

Now, without further ado, let’s dive into the code!

Interfaces

To start with, apart from the original liquidate() function, we have added another interface with an extra param: int256 positionSize

  1. Original: function liquidate(address trader, address baseToken)
  2. Added: function liquidate(address trader, address baseToken, int256 positionSize)

The first one is kept for backward compatibility and we aim to deprecate it after a few more upgrades.

As a liquidator might not always be capable of taking up the trader’s entire position, one can specify the position size willing to take over with positionSize.

But how can a liquidator know his/her limit? We’ll touch on this later, see 3. Getting A Liquidator’s Maximum Capacity!

Back to the code, we can see that both versions of liquidate() call an internal _liquidate() function.

function liquidate(address trader, address baseToken, int256 positionSize) external override {
    _liquidate(trader, baseToken, positionSize);
}function liquidate(address trader, address baseToken) external override {
    _liquidate(trader, baseToken, 0);
}

Inside this function, its logic can be divided into three sections:

  1. condition checks
  2. core calculation logic
  3. liquidation fees distribution

Condition Checks

There isn’t any critical change introduced to this section, while there is one thing that will come in handy later: the condition of whether a trader can be liquidated or not.

For a trader to become liquidatable, its account value has to be lower than its margin requirement for the existing positions, which is the utility of function _isLiquidatable().

Things can get quite complicated if we go through details of how account value and margin requirements are decided. Hence, a simple explanation would be: margin ratio, which is total collateral value / total positions' notional value, should be no less than 6.25% (≥ 0.0625). Otherwise, liquidators can get in and take over the trader’s positions.

Core Calculation Logic

The majority of the calculation logic lies in the function _getLiquidatedPositionSizeAndNotional(). It is used to get the maximum liquidatable position size of a trader, given the amount specified by the liquidator as positionSizeToBeLiquidated, which is the same as the param positionSize in the new liquidate() function interface. The renaming is due to the existence of a local positionSize variable which describes the trader’s original position size.

/// @param positionSizeToBeLiquidated its direction should be the same as taker's existing positionfunction _getLiquidatedPositionSizeAndNotional(
    address trader,
    address baseToken,
    int256 positionSizeToBeLiquidated
) internal view returns (int256, int256) {
    int256 positionSize = _getTakerPosition(trader, baseToken);

Next, depending on the trader’s margin ratio, the maximum liquidatable ratio of the trader’s position maxLiquidationRatio varies:

  • can liquidate 50% of the trader’s position if 6.25% > margin ratio ≥ 3.125% (6.25% / 2)
  • can liquidate 100% of the trader’s position if 3.125% > margin ratio
int256 maxLiquidatablePositionSize = positionSize;if (getAccountValue(trader) >= 
_getMarginRequirementForLiquidation(trader).div(2)) {    uint24 maxLiquidationRatio = FullMath.mulDiv(
        _getTotalAbsPositionValue(trader),
        1e6,
        _getTotalPositionValue(trader, baseToken).abs().mul(2)
    ).toUint24();    if (maxLiquidationRatio < 1e6) {
        maxLiquidatablePositionSize = 
        positionSize.mulRatio(maxLiquidationRatio);
    }
}

Even if the liquidator specifies a positionSizeToBeLiquidated larger than the maximum liquidatable position size maxLiquidatablePositionSize, the final liquidated position size liquidatedPositionSize cannot be larger though.

After liquidatedPositionSize is determined, liquidatedPositionNotional as the position’s notional value being liquidated is then calculated using index price. These two values, liquidatedPositionSize & liquidatedPositionNotional, will then become the liquidator’s positionSize & position’s openNotional value.

if (positionSizeToBeLiquidated.abs() >= maxLiquidatablePositionSize.abs() || positionSizeToBeLiquidated == 0) {
    positionSizeToBeLiquidated = maxLiquidatablePositionSize;
}int256 liquidatedPositionSize = positionSizeToBeLiquidated.neg256();int256 liquidatedPositionNotional = positionSizeToBeLiquidated.mulDiv(_getIndexPrice(baseToken).toInt256(), 1e18);

Why are we using index price to define notional value here? Since positions are transferred from traders to liquidators, we need a price to define how much the liquidated position is worth for accounting. We cannot use market price, as it’s prone to price manipulation, which leaves us with the hopefully-not-so-easily-manipulated index price.

Liquidation Fees Distribution

To incentivize liquidators, they will receive a discount on the position taken over for injecting liquidity into the platform, perhaps during a time of drastic market change!

However, depending on how dire the need is, the discount is as follows:

  • 2.5% * liquidatedPositionNotional if the trader’s accountValue < 0, meaning the position is bankrupt and causing bad debt to the system already
  • otherwise, 1.25% * liquidatedPositionNotional; the trader still gets a 2.5% * liquidatedPositionNotional loss, while the other 1.25% goes to the insurance fund
uint256 liquidationPenalty = liquidatedPositionNotional.abs().mulRatio(_getLiquidationPenaltyRatio());_modifyOwedRealizedPnl(trader, liquidationPenalty.neg256());uint256 liquidationFeeToLiquidator = liquidationPenalty.div(2);if (accountValue < 0) {
    liquidationFeeToLiquidator = liquidationPenalty;
}uint256 liquidationFeeToIF = liquidationPenalty.sub(liquidationFeeToLiquidator);_modifyOwedRealizedPnl(_insuranceFund, liquidationFeeToIF.toInt256());

The discount will be appended to the liquidator’s open notional value liquidatorExchangedPositionNotional, indicating that the liquidator opens the position at a lower cost.

int256 liquidatorExchangedPositionSize = liquidatedPositionSize.neg256();int256 liquidatorExchangedPositionNotional =
liquidatedPositionNotional.neg256().add(liquidationFeeToLiquidator.toInt256());_modifyPosition(liquidator, baseToken, liquidatorExchangedPositionSize, liquidatorExchangedPositionNotional, 0, 0);

Lastly, as usual, we have to make sure liquidators will maintain a healthy margin ratio after taking over the position. Also, an event is emitted to record this liquidation incident.

_requireEnoughFreeCollateral(liquidator);emit PositionLiquidated(trader, baseToken,
liquidatedPositionNotional.abs(),
liquidatedPositionSize.abs(), liquidationPenalty, liquidator);

3. Getting A Liquidator’s Maximum Capacity

As promised earlier, let’s now see how to obtain a liquidator’s maximum capacity of taking over a liquidatable position!

Since taking over a position is essentially opening a new one, we can observe that the _requireEnoughFreeCollateral() check also exists in adding liquidity addLiquidity() & opening position _openPosition().

Thus, looking up one’s freeCollateral with Vault.getFreeCollateral() is the way to go. (we’re demonstrating the code in Solidity here but you can do it with whatever language in your bot!)

uint256 freeCollateral = Vault.getFreeCollateral(trader);

To calculate the maximum affordable position size, we need two more things: leverage and the 15-minute Time-weighted Average Price (TWAP) of the market of that position, as the price for the liquidated position, is also calculated using 15-minute TWAP.

uint8 leverage = YOUR_DESIRED_LEVERAGE;// BaseToken is the token of the market, e.g., ETH for ETH's market
// the interval is 15 minutes = 900 secondsuint256 indexPrice = BaseToken.getIndexPrice(900);// this is for demonstration only; can consider using SafeMathuint256 maxPositionSize = freeCollateral * leverage / indexPrice

Lastly, we also need to specify the direction (long or short) of the position size to be liquidated positionSizeToBeLiquidated to be the same as the trader’s existing position size. (notice the Natspec above _getLiquidatedPositionSizeAndNotional())

Therefore, we can get the trader’s existing position size with AccountBalance.getTakerPositionSize() and append the sign accordingly to transform maxPositionSize to signedMaxPositionSize.

int256 takerPositionSize = AccountBalance.getTakerPositionSize(trader, baseToken);int256 signedMaxPositionSize = takerPositionSize >= 0 ? maxPositionSize.toInt256() : maxPositionSize.toInt256() * -1;

4. Conclusion

Voila! That isn’t as hard as some of you might expect, right? Hopefully!

Let us know if this article helps you understand more about our development process and the rationales behind the decisions. Also, if this article turns out to be far lengthier than acceptable, please do comment below as we’re still exploring how to do this effectively and your suggestions are much needed for future improvement!

Until the next one!

Subscribe to Perpetual Protocol 🥨
Receive the latest updates directly to your inbox.
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.