Skip to main content

Liquidations

Overview

The goal of this document is to specify the high-level design of liquidation execution.

For risk specifics, see Margin.

Liquidation consists of several phases, which are enabled based on the account's health.

All actions are initiated by a permissionless liquidator operating through a regular user account. The liquidator does not need to post collateral.

Phases

The first phase consists of per-market or per-balance activity on behalf of the user. Order cancellation, position reduction, and trading an existing token for a token owed to the system are typical actions. As long as the user is unhealthy and there are actions that can improve health per market, they are executed.

The next phase is account-wide. The backstop takes the user's positions and some amount of balance, up to the full balance, at prices near the index price.

Liquidation search strategy

The liquidator and engine state may be described as a distributed state machine running in a loop, potentially until bankruptcy.

Here is the general flow of liquidation states:

stateDiagram-v2
state can_cancel <<choice>>
state can_reduce_perp_position <<choice>>
state can_sell <<choice>>

[*] --> can_cancel
CancelOrders --> can_cancel
can_cancel --> CancelOrders: Can cancel
can_cancel --> can_reduce_perp_position : Cannot cancel
ReducePerpPosition --> can_cancel
can_reduce_perp_position --> ReducePerpPosition : Can reduce negative PnL perpetual
can_reduce_perp_position --> can_sell : Cannot reduce negative PnL
can_sell --> SellOrCollectPositivePnL: Can sell or collect positive PnL
SellOrCollectPositivePnL --> can_cancel
can_sell --> TakeAllOrBankruptcy: Cannot sell or collect positive PnL
TakeAllOrBankruptcy --> [*]

The priority of a borrow or perp position is based on its notional value: the value of the borrow for spot positions or the value of the position for perps.

The actions and choices available during liquidation are outlined in Margin.

So, in general, the next steps are:

1. Reduce or cancel outstanding orders

The liquidator closes all orders that would increase the user's position and make the account less healthy if filled.

This ensures the user will not increase the risk of their positions through newly settled orders.

Orders outside the price band are cancelled.

2. Reduce perp positions and borrows

Per-market perp reduction is executed by a liquidation action that does not specify a size. The engine derives the reducing side from the liquidated account's current position, computes the ideal reduction size from the current risk state, position, market margin parameters, and conservative index price, then executes an internal, immediate, reduce-only order.

Orderbook depth does not determine the ideal size. It only constrains how much of the internal order can actually fill.

The liquidator selects the target account and market, while the engine computes the ideal reduction size.

deficit=IMF_numernowOMF_numernowRO_quote_size=max(0,deficit)IMFbase,m\begin{aligned} \text{deficit} &= \text{IMF\_numer}_{now} - \text{OMF\_numer}_{now} \\ \text{RO\_quote\_size} &= \frac{\max(0, \text{deficit})}{\text{IMF}_{base,m}} \end{aligned}

Here, RO_quote_size is the quote notional to reduce. It is converted to base size using the same reference price used for the selected market's risk notional and rounded up.

3. Not enough balances to reduce

ΔPrice=PsellPindex\Delta \text{Price} = P_\text{sell} - P_\text{index}

If ΔPrice>0\Delta \text{Price} > 0, token trading is allowed unless the token being sold is not owed to the market.

Otherwise:

ΔRatio=ΔPricePindexΔSize=(SizeDepth+1)2\begin{aligned} \Delta \text{Ratio} &= \frac{\Delta \text{Price}}{P_\text{index}} \\ \Delta \text{Size} &= \left(\frac{\text{Size}}{\text{Depth} + 1}\right)^2 \end{aligned}

Not enough token to pay debt

Selling is allowed if:

ΔRatio1ΔSize<slippage\frac{\Delta \text{Ratio}}{1 - \Delta \text{Size}} < \text{slippage}

where slippage=120\text{slippage} = \frac{1}{20}.

The token is sold for USDC, which is then used to buy the token owed.

Not enough token to close perp position

If there is not enough USDC to close the position, tokens with no outstanding debt may be sold for USDC.

The same sell-decision formula is used.

Details

The liquidator sorts tokens to check the smallest ΔSize\Delta \text{Size} first, but still validates that the sell-decision formula holds.

This also allows a low-collateral liquidator to improve health, or bankrupt the account, by market-trading the position.

4. Take all positions below 2/3 MMF when not bankrupt

  1. Validate eligibility and select transfer scope = all positions.
  2. Cancel all open orders for the liquidated account.
  3. Settle funding for the liquidated account and the backstop account.
  4. Compute transfer prices for each position using index bounds and exchange-favoring rounding.
    • With favor_exchange = true, a long uses the low floor (lower mantissa), and a short uses the high ceiling (higher mantissa).
    • With favor_exchange = false, a long uses the high ceiling (higher mantissa), and a short uses the low floor (lower mantissa).
  5. Apply closing fees before transfer:
    • notional = abs(size) * transfer_price
    • fee = ceil((trading_fee_bps + 1 bp) * notional)
    • Split fee between system and backstop.
  6. Transfer positions to the backstop at the transfer price:
    • Liquidated account realizes price PnL at the transfer price.
    • The backstop's unrealized PnL starts at zero for the transferred size.
  7. If the account cannot pay debt after fees and price PnL, fall through to deficit absorption:
    • The backstop takes the minimum positive token balances needed to cover the remaining negative quote balance.
    • Token conversion uses a conservative token value: the weighted low index price.
    • Conversion stops once the user's quote balance becomes non-negative or no positive token balances remain.
  8. Leave remaining balances in the user account.
  9. Emit a transfer notification with per-market details and fee split.

Bankruptcy

All assets are moved from the liquidated account to the backstop account at index prices to cover the liquidated account's negative AV. The liquidated account retains zero balances, and the negative quote balance is applied to the system account.

Liquidation score

The liquidator uses liquidation_score to prioritize accounts. Higher scores are checked first.

Account stateliquidation_score
NoPositionnot tracked
Bankrupt2+negative account valuepositive account value2 + \text{negative account value} - \text{positive account value}
Active, PN>0\text{PN} > 0, MFMMF\text{MF} \le \text{MMF}1+(PONMMFbasePONAVPN)1 + \left(\frac{\sum \text{PON} \cdot \text{MMF}_\text{base}}{\sum \text{PON}} - \frac{\text{AV}}{\sum \text{PN}}\right)
Active, PN=0\text{PN} = 0, PON>0\text{PON} > 0, OMFCMF\text{OMF} \le \text{CMF}PONCMFbasePONmin{AV,TV}PON\frac{\sum \text{PON} \cdot \text{CMF}_\text{base}}{\sum \text{PON}} - \frac{\min\{\text{AV}, \text{TV}\}}{\sum \text{PON}}
Active, PN>0\text{PN} > 0, MF>MMF\text{MF} > \text{MMF}(PONMMFbasePONAVPN)PN\frac{\left(\frac{\sum \text{PON} \cdot \text{MMF}_\text{base}}{\sum \text{PON}} - \frac{\text{AV}}{\sum \text{PN}}\right)}{\sum \text{PN}}
Active, PN=0\text{PN} = 0, PON>0\text{PON} > 0, OMF>CMF\text{OMF} > \text{CMF}(PONCMFbasePONmin{AV,TV}PON)PON\frac{\left(\frac{\sum \text{PON} \cdot \text{CMF}_\text{base}}{\sum \text{PON}} - \frac{\min\{\text{AV}, \text{TV}\}}{\sum \text{PON}}\right)}{\sum \text{PON}}

The score bands keep bankruptcy above position liquidation, and position liquidation above open-order cancellation.

Backstop liquidation specification

1. Overview and principles

This section specifies the deterministic logic for backstop liquidation in the Nord engine. This process transfers distressed positions from a user to the system backstop.

Core invariants

  1. Zero-sum value transfer (excluding system fees): The sum of the user and backstop account values (balance + base_size * (index - open_price)) changes only by the fees taken by the system. Including the system account, the net change is zero.
  2. Conservative valuation: Backstop position merging uses rounding that favors the system (round up for longs, round down for shorts).
  3. Non-negative prices: Prices are u64. Merge logic follows apply_fill semantics and never stores a negative price.

2. Data types and definitions

VariableTypeNotes
balancei64Signed quote token balance (USDC).
base_sizei64Signed position size (positive for long, negative for short).
open_priceu64Entry price (always positive).

3. Algorithm details

Step A. Funding settlement

Before transfer, funding is settled to mark positions to the current index. If the backstop merge is implemented via apply_fill, backstop funding is already handled there, so only user settlement is strictly required.

  • pnl = position.base_size * (position.funding_index - market.funding_index)
  • account.balance += pnl
  • position.funding_index = market.funding_index

Step B. Transfer price

The transfer occurs at the index mantissa that favors backstop liquidity and is least favorable to the user.

  • If user_pos.base_size > 0 (long): transfer_price = index.mantissa_low
  • If user_pos.base_size < 0 (short): transfer_price = index.mantissa_high

Step C. Exchange fees

Fees are calculated as if the user closed the position at transfer_price. One basis point of the fees is paid to the backstop.

  • notional = abs(base_size) * transfer_price
  • total_fee = ceil(abs(base_size) * transfer_price * fee_rate)
  • backstop_fee = min(1 bps of notional, total_fee)
  • system_fee = total_fee - backstop_fee
  • Updates:
    • user.balance -= total_fee
    • system.balance += system_fee
    • backstop.balance += backstop_fee

Step D. Position transfer

The user closes the position at transfer_price, and the position is transferred to the backstop in the same direction. PnL is realized at the transfer price; there is no direct balance transfer between the user and backstop.

  • user_pnl = base_size * (transfer_price - open_price)
  • Updates:
    • user.balance += user_pnl
    • user.positions.remove(id)

Step E. Backstop merge logic

The backstop now has an incoming position piece: (incoming_size, transfer_price), where incoming_size = user_pos.base_size because the position is transferred, not traded. It is merged into the existing backstop position: (old_size, old_price).

This merge is equivalent to applying a fill of incoming_size at transfer_price to the backstop position, using PerpPosition::apply_fill semantics.

1. Zeroed out (exact close)

  • Condition: new_size == 0
  • Logic: The entire position is closed at transfer_price.
  • PnL:
    • closed_size = abs(old_size)
    • backstop.balance += closed_size * sign(old_size) * (transfer_price - old_price)
  • State: Remove the position.

2. Increase (same sign)

  • Condition: sign(old_size) == sign(incoming_size)
  • Logic: Use the weighted-average price over the total size.
  • Price:
    • new_size = old_size + incoming_size
    • new_price = (abs(old_size) * old_price + abs(incoming_size) * transfer_price) / abs(new_size)
  • Balance: No change.

3. Reduce (opposite sign, no flip)

  • Condition: sign(old_size) != sign(incoming_size) and sign(new_size) == sign(old_size)
  • Logic: Realize PnL on the closed portion at transfer_price.
  • PnL:
    • closed_size = min(abs(old_size), abs(incoming_size))
    • backstop.balance += closed_size * sign(old_size) * (transfer_price - old_price)
  • Price: new_price = old_price.

4. Flip (opposite sign)

  • Condition: sign(old_size) != sign(incoming_size) and sign(new_size) != sign(old_size)
  • Logic: Realize PnL on the closed portion at transfer_price.
  • PnL:
    • closed_size = abs(old_size)
    • backstop.balance += closed_size * sign(old_size) * (transfer_price - old_price)
  • Price: new_price = transfer_price.

Step F. Absorb negative user balance

If the user ends with a negative balance, the system absorbs it.

  • If user.balance < 0:
    • system.balance += user.balance
    • user.balance = 0

This is a pure transfer between the user and system and does not change total value.

4. Invariant validation (pre-absorption)

This validation is a proof sketch only and is not a runtime step. It is evaluated after Step E and before loss absorption. For this proof sketch only, use index everywhere (so transfer_price = index) and ignore rounding and mantissa_low/mantissa_high selection.

Assumptions:

account_value=balance+base_size(indexopen_price)\text{account\_value} = \text{balance} + \text{base\_size} \cdot (\text{index} - \text{open\_price})

Target equalities:

user_ac_value_beforesystem_feebackstop_fee=user_ac_value_afterbackstop_ac_value_before+backstop_fee=backstop_ac_value_aftersystem_balance_before+system_fee=system_balance_after\begin{aligned} \text{user\_ac\_value\_before} - \text{system\_fee} - \text{backstop\_fee} &= \text{user\_ac\_value\_after} \\ \text{backstop\_ac\_value\_before} + \text{backstop\_fee} &= \text{backstop\_ac\_value\_after} \\ \text{system\_balance\_before} + \text{system\_fee} &= \text{system\_balance\_after} \end{aligned}

Start state:

user_ac_value_before=user.balance+user.size(indexuser.entry_price)backstop_ac_value_before=backstop.balance+backstop.size(indexbackstop.entry_price)system_balance_before=system.balance\begin{aligned} \text{user\_ac\_value\_before} &= \text{user.balance} + \text{user.size} \cdot (\text{index} - \text{user.entry\_price}) \\ \text{backstop\_ac\_value\_before} &= \text{backstop.balance} + \text{backstop.size} \cdot (\text{index} - \text{backstop.entry\_price}) \\ \text{system\_balance\_before} &= \text{system.balance} \end{aligned}

After fees and transfer (Step C + Step D):

user_ac_value_after=user.balancesystem_feebackstop_fee+user.size(indexuser.entry_price)system_balance_after=system.balance+system_fee\begin{aligned} \text{user\_ac\_value\_after} &= \text{user.balance} - \text{system\_fee} - \text{backstop\_fee} + \text{user.size} \cdot (\text{index} - \text{user.entry\_price}) \\ \text{system\_balance\_after} &= \text{system.balance} + \text{system\_fee} \end{aligned}

Backstop merge proof by cases (Step E):

Case 1: same-sign increase

new_entry_price=backstop.sizebackstop.entry_price+user.sizeindexbackstop.size+user.sizenew_size=backstop.size+user.sizebackstop_ac_value_after=backstop.balance+backstop_fee+(backstop.size+user.size)(indexnew_entry_price)=backstop.balance+backstop_fee+backstop.size(indexbackstop.entry_price)=backstop_ac_value_before+backstop_fee\begin{aligned} \text{new\_entry\_price} &= \frac{\text{backstop.size} \cdot \text{backstop.entry\_price} + \text{user.size} \cdot \text{index}}{\text{backstop.size} + \text{user.size}} \\ \text{new\_size} &= \text{backstop.size} + \text{user.size} \\ \text{backstop\_ac\_value\_after} &= \text{backstop.balance} + \text{backstop\_fee} + (\text{backstop.size} + \text{user.size}) \cdot (\text{index} - \text{new\_entry\_price}) \\ &= \text{backstop.balance} + \text{backstop\_fee} + \text{backstop.size} \cdot (\text{index} - \text{backstop.entry\_price}) \\ &= \text{backstop\_ac\_value\_before} + \text{backstop\_fee} \end{aligned}

Case 2: opposite-sign reduce (no flip)

closed_size=min(backstop.size,user.size)new_size=backstop.size+user.sizebackstop.balance+=closed_sizesign(backstop.size)(indexbackstop.entry_price)backstop_ac_value_after=backstop.balance+backstop_fee+new_size(indexbackstop.entry_price)=backstop_ac_value_before+backstop_fee\begin{aligned} \text{closed\_size} &= \min(\lvert\text{backstop.size}\rvert, \lvert\text{user.size}\rvert) \\ \text{new\_size} &= \text{backstop.size} + \text{user.size} \\ \text{backstop.balance} &\mathrel{+}= \text{closed\_size} \cdot \operatorname{sign}(\text{backstop.size}) \cdot (\text{index} - \text{backstop.entry\_price}) \\ \text{backstop\_ac\_value\_after} &= \text{backstop.balance} + \text{backstop\_fee} + \text{new\_size} \cdot (\text{index} - \text{backstop.entry\_price}) \\ &= \text{backstop\_ac\_value\_before} + \text{backstop\_fee} \end{aligned}

Case 3: opposite-sign exact close (new_size = 0)

closed_size=backstop.sizebackstop.balance+=closed_sizesign(backstop.size)(indexbackstop.entry_price)backstop_ac_value_after=backstop.balance+backstop_fee=backstop_ac_value_before+backstop_fee\begin{aligned} \text{closed\_size} &= \lvert\text{backstop.size}\rvert \\ \text{backstop.balance} &\mathrel{+}= \text{closed\_size} \cdot \operatorname{sign}(\text{backstop.size}) \cdot (\text{index} - \text{backstop.entry\_price}) \\ \text{backstop\_ac\_value\_after} &= \text{backstop.balance} + \text{backstop\_fee} \\ &= \text{backstop\_ac\_value\_before} + \text{backstop\_fee} \end{aligned}

Case 4: opposite-sign flip

closed_size=backstop.sizenew_size=backstop.size+user.sizebackstop.balance+=closed_sizesign(backstop.size)(indexbackstop.entry_price)backstop_ac_value_after=backstop.balance+backstop_fee+new_size(indexindex)=backstop.balance+backstop_fee+backstop.size(indexbackstop.entry_price)=backstop_ac_value_before+backstop_fee\begin{aligned} \text{closed\_size} &= \lvert\text{backstop.size}\rvert \\ \text{new\_size} &= \text{backstop.size} + \text{user.size} \\ \text{backstop.balance} &\mathrel{+}= \text{closed\_size} \cdot \operatorname{sign}(\text{backstop.size}) \cdot (\text{index} - \text{backstop.entry\_price}) \\ \text{backstop\_ac\_value\_after} &= \text{backstop.balance} + \text{backstop\_fee} + \text{new\_size} \cdot (\text{index} - \text{index}) \\ &= \text{backstop.balance} + \text{backstop\_fee} + \text{backstop.size} \cdot (\text{index} - \text{backstop.entry\_price}) \\ &= \text{backstop\_ac\_value\_before} + \text{backstop\_fee} \end{aligned}

5. Mermaid flowchart

flowchart TD
Start([Start Backstop Liquidation]) --> Funding[Step A: Settle Funding<br>User; Backstop optional if apply_fill]
Funding --> TransferPrice[Step B: Transfer Price<br>mantissa_low or mantissa_high]
TransferPrice --> Fees[Step C: Fees<br>User -> System/Backstop]
Fees --> Transfer[Step D: Transfer Position<br>User realizes PnL<br>Backstop receives same side]
Transfer --> Merge[Step E: Merge Backstop Position]

Merge --> SameSign{"sign old_size == sign incoming_size?"}
SameSign -- Yes (Increase) --> Increase[Weighted avg price<br>Conservative rounding]
SameSign -- No --> NewSizeZero{"new_size == 0?"}
NewSizeZero -- Yes (Exact Close) --> Close[Realize PnL<br>Remove Position]
NewSizeZero -- No --> Flip{"sign new_size != sign old_size?"}
Flip -- Yes (Flip) --> FlipPrice[Realize PnL<br>Set price = transfer_price]
Flip -- No (Reduce) --> Reduce[Realize PnL<br>Keep price = old_price]

Increase --> Deficit{"User balance < 0?"}
Close --> Deficit
FlipPrice --> Deficit
Reduce --> Deficit
Deficit -- Yes --> Absorb[Step F: System absorbs deficit<br>User balance = 0]
Deficit -- No --> End([Finish])
Absorb --> End

These materials are provided for informational purposes only and do not constitute financial, investment, legal, or tax advice, or an offer or solicitation to buy or sell any asset. Trading digital assets and derivatives involves substantial risk, including the possible loss of some or all capital. Products may not be available in all jurisdictions. Users are responsible for evaluating suitability and complying with applicable laws.