Skip to main content

Liquidation Engine / Risk Engine

Overview

The liquidation system protects the protocol from bad debt by closing underwater positions before they become insolvent. Liquidation is executed via FxEngine.liquidate() — a permissionless function that anyone can call. Liquidation orders execute through the order book as market orders, ensuring proper token flow and maintaining virtual token balance integrity.

Architecture

┌─────────────────┐
│ Liquidator │
│ (Anyone) │
└────────┬────────┘
│ liquidate(accountId, marketId)

┌─────────────────────────────────────────────────────────────────┐
│ FxEngine │
│ 1. Validate account and market exist │
│ 2. Update cumulative funding │
│ 3. Settle pending funding on position │
│ 4. Get mark price from FundingRateOracle │
│ 5. Calculate unrealized PnL at mark price │
│ 6. Verify position is liquidatable (margin ratio < MMR) │
│ 7. Call placeLiquidationOrder() on FxAccount │
│ 8. Calculate rewards proportional to liquidated size │
│ 9. Call finalizeLiquidation() to approve USDC transfers │
│ 10. Transfer rewards to liquidator and insurance fund │
│ 11. Record bad debt if applicable │
└─────────────────────────────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│FundingRateOracle│ │ FxAccount │ │ Market │
│ • getPrices() │ │ • placeLiqOrder │ │ • place() │
│ (mark price) │ │ • finalizeLiq │ │ • handleFill() │
│ │ │ • transferReward│ │ callback │
└─────────────────┘ └─────────────────┘ └─────────────────┘

Liquidation Criteria

Margin Ratio Calculation

effectiveMargin = positionMargin + unrealizedPnL
positionValue = positionSize
marginRatioBps = (effectiveMargin * 10_000) / positionValue

Liquidation Condition

A position is liquidatable when:

marginRatioBps < maintenanceMarginBps

Default maintenanceMarginBps = 100 (1%)

Unrealized PnL Calculation

// LONG position
if (markPrice >= entryPrice) {
pnl = size * (markPrice - entryPrice) / entryPrice // profit
} else {
pnl = -size * (entryPrice - markPrice) / entryPrice // loss
}

// SHORT position
if (entryPrice >= markPrice) {
pnl = size * (entryPrice - markPrice) / entryPrice // profit
} else {
pnl = -size * (markPrice - entryPrice) / entryPrice // loss
}

Liquidation Price Calculation

LONG Position

liquidationPrice = entryPrice × (1 - marginRatio + MMR)
insolvencyPrice = entryPrice × (1 - marginRatio)

SHORT Position

liquidationPrice = entryPrice × (1 + marginRatio - MMR)
insolvencyPrice = entryPrice × (1 + marginRatio)

Where:

  • marginRatio = margin / size
  • MMR = maintenanceMarginBps / 10_000

The liquidation price is where the position becomes eligible for liquidation. The insolvency price is where the position's margin is fully consumed — any movement beyond this creates bad debt.

Liquidation Flow

Step-by-Step

  1. Validation (FxEngine)

    • Verify account exists via accounts[accountId]
    • Verify market exists and is configured
    • Verify position is open with non-zero size
  2. Funding Settlement (FxEngine → FxAccount)

    • Call updateCumulativeFunding(marketId) to sync market funding
    • Call FxAccount.settleFundingExternal() to apply pending funding payments
    • This ensures the margin is accurate before checking liquidation eligibility
  3. Liquidation Check (FxEngine → FundingRateOracle)

    • Get mark price via fundingRateOracle.getPrices(marketId)
    • Calculate unrealized PnL using LiquidationLib.calculateUnrealizedPnL()
    • Calculate margin ratio in bps using LiquidationLib.calculateMarginRatioBps()
    • Verify marginRatioBps < maintenanceMarginBps, revert with PositionNotLiquidatable if not
  4. Order Placement (FxEngine → FxAccount)

    • Call placeLiquidationOrder(marketId, config)
    • FxAccount determines close side (opposite of position)
    • Generates a unique liquidation client key (includes "liq" suffix)
    • Uses existing tokens held by the FxAccount (no new minting)
    • Places a MARKET order on the order book via Market.place()
  5. Order Execution (Market → FxEngine → FxAccount)

    • Market matches the liquidation order against resting orders
    • onFill() callback fires on FxEngine, which routes to handleFill() on both maker and taker FxAccounts
    • The liquidated position is reduced/closed via _updatePositionFromFill()
    • PnL is realized to margin via _realizePnL()
    • If fully closed, tokens and debt are cleared via _clearTokensAndDebt()
  6. Reward Calculation (FxEngine)

    • Calculate how much of the position was actually liquidated (may be partial)
    • Scale PnL and margin proportionally to liquidated size
    • Determine liquidator reward (capped by liquidationFeeBps)
    • Remainder goes to insurance fund
    • Record bad debt if position was insolvent
  7. Distribution (FxEngine → FxAccount)

    • Call finalizeLiquidation(totalRewards) to deduct rewards from available margin and approve USDC
    • Call transferRewards(liquidator, amount) for the liquidator's share
    • Call transferRewards(insuranceFundRecipient, amount) for the insurance fund

Reward Distribution

Calculation

// Scale by proportion actually liquidated
scaledMargin = originalMargin * liquidatedSize / originalSize
scaledPnL = unrealizedPnL * liquidatedSize / originalSize
expectedFreedMargin = scaledMargin + scaledPnL

if (expectedFreedMargin > 0) {
// Position has remaining margin after loss
liquidatedValue = liquidatedSize * markPrice / PRICE_PRECISION
maxReward = liquidatedValue * liquidationFeeBps / 10_000

liquidatorReward = min(maxReward, availableMargin)
insuranceFundProfit = availableMargin - liquidatorReward
}

Distribution Flow

  1. FxAccount deducts total rewards from availableMargin and approves USDC transfer
  2. FxEngine calls transferRewards() to send liquidatorReward to msg.sender
  3. FxEngine calls transferRewards() to send insuranceFundProfit to insuranceFundRecipient

Configuration

Liquidation parameters are configurable per market:

function setMarketLiquidationParams(
bytes32 marketId,
uint16 maintenanceMarginBps, // e.g., 100 = 1%
uint16 liquidationFeeBps // e.g., 50 = 0.5%
) external onlyOwner;

Bad Debt Handling

When Bad Debt Occurs

Bad debt is recorded when:

  1. Position is already insolvent at time of liquidation (expectedFreedMargin < 0)
  2. Slippage during liquidation execution causes additional loss beyond available margin

Recording

// In FxEngine.liquidate():
if (badDebtAmount > 0) {
badDebt[marketId] += badDebtAmount;
emit BadDebtRecorded(marketId, badDebtAmount, badDebt[marketId]);
}

Bad debt accumulates per market and is queryable via FxEngine.badDebt(marketId).

Socialization

Bad debt accumulates per market. Future implementations may socialize bad debt across:

  • Insurance fund reserves
  • Position holders via funding adjustments
  • Protocol revenue

Partial Liquidation

Behavior

When insufficient liquidity exists to fully close a position via the market order:

  1. Market order fills available liquidity (partial fill)
  2. Position size reduces proportionally
  3. Rewards are distributed proportional to the liquidated amount only
  4. Remaining position may still be liquidatable
  5. Subsequent liquidate() calls can close the remainder

Example

Initial position: 100,000 fxUSD LONG
Available liquidity: 60,000 fxUSD worth of bids

After liquidation:
- 60,000 fxUSD closed via order book
- 40,000 fxUSD position remains
- Rewards calculated based on 60,000 fxUSD
- Position likely still liquidatable → call liquidate() again

Contract Interface

FxEngine

/// @notice Liquidate an underwater position via order book
/// @dev Permissionless — anyone can call and earn reward
/// @dev Reverts with InsufficientLiquidity if no counterparty exists
/// @dev Partial fills allowed; position shrinks proportionally
/// @param accountId The account to liquidate
/// @param marketId The market of the position to liquidate
function liquidate(uint256 accountId, bytes32 marketId) external;

/// @notice Update insurance fund recipient
function setInsuranceFundRecipient(address newRecipient) external onlyOwner;

/// @notice Update market liquidation parameters
function setMarketLiquidationParams(
bytes32 marketId,
uint16 maintenanceMarginBps,
uint16 liquidationFeeBps
) external onlyOwner;

FxAccount (called by FxEngine during liquidation)

/// @notice Place a market close order for liquidation
/// @dev Only callable by FxEngine
function placeLiquidationOrder(bytes32 marketId, IFxEngine.MarketConfig memory config) external onlyFxEngine;

/// @notice Finalize liquidation by deducting rewards and approving USDC
/// @dev Only callable by FxEngine
function finalizeLiquidation(uint256 totalRewards) external onlyFxEngine;

/// @notice Transfer USDC rewards during liquidation
/// @dev Only callable by FxEngine
function transferRewards(address to, uint256 amount) external onlyFxEngine;

Reader (View Functions)

/// @notice Check if a position is liquidatable
function isLiquidatable(uint256 accountId, bytes32 marketId) public view returns (bool);

/// @notice Get comprehensive position health information
function getPositionHealth(uint256 accountId, bytes32 marketId)
external view returns (PositionHealth memory health);

/// @notice Get liquidation and insolvency prices for a position
function getLiquidationPrices(uint256 accountId, bytes32 marketId)
external view returns (uint128 liquidationPrice, uint128 insolvencyPrice);

/// @notice Get mark price for a market (from oracle)
function getMarkPrice(bytes32 marketId) public view returns (uint128);

/// @notice Batch check liquidatable positions
function batchIsLiquidatable(uint256[] calldata accountIds, bytes32 marketId)
external view returns (bool[] memory liquidatable);

/// @notice Get position with health data in one call
function getPositionWithHealth(uint256 accountId, bytes32 marketId)
external view returns (IFxAccount.Position memory, PositionHealth memory);

Data Structures

PositionHealth

struct PositionHealth {
uint128 markPrice; // Current oracle perp price
uint128 positionValue; // Position size
int256 unrealizedPnL; // Profit/loss at current mark price
uint256 marginRatioBps; // Current margin ratio in basis points
uint128 liquidationPrice; // Price at which position becomes liquidatable
uint128 insolvencyPrice; // Price at which margin is fully depleted
bool isLiquidatable; // Whether position can be liquidated now
}

Events

// FxEngine
event PositionLiquidated(
uint256 indexed accountId,
bytes32 indexed marketId,
address indexed liquidator,
uint128 size, // Amount liquidated
uint128 markPrice, // Price at liquidation
int256 pnl, // Scaled PnL
uint256 liquidatorReward, // Reward to liquidator
uint256 insuranceFundProfit, // Profit to insurance fund
uint256 badDebtAmount // Bad debt if insolvent
);

event BadDebtRecorded(
bytes32 indexed marketId,
uint256 amount,
uint256 totalBadDebt
);

event InsuranceFundRecipientUpdated(
address indexed oldRecipient,
address indexed newRecipient
);

Errors

error AccountNotFound();         // Account does not exist
error MarketNotFound(); // Market not registered
error NoOpenPosition(); // Position not open or size is zero
error PositionNotLiquidatable(); // Margin ratio above maintenance margin
error FundingOracleNotSet(); // Oracle not configured or mark price is 0
error InsufficientLiquidity(); // From Market: no resting orders to fill against

Access Control

FunctionAccess
liquidate (FxEngine)Permissionless
placeLiquidationOrder (FxAccount)FxEngine only
finalizeLiquidation (FxAccount)FxEngine only
transferRewards (FxAccount)FxEngine only
setMarketLiquidationParams (FxEngine)Owner only
setInsuranceFundRecipient (FxEngine)Owner only

Liquidator Bot Setup

To run a liquidator bot:

  1. No authorization requiredFxEngine.liquidate() is permissionless
  2. Monitor positions using Reader.isLiquidatable(accountId, marketId)
  3. Use Reader.batchIsLiquidatable(accountIds, marketId) for efficient batch checks
  4. When a position is liquidatable, call FxEngine.liquidate(accountId, marketId)
  5. Earn the liquidator reward (up to liquidationFeeBps of the liquidated notional) on success

Known Limitations (Beta)

  1. No Liquidity = Revert — If no counterparty exists on the opposite side of the book, the liquidation transaction reverts with InsufficientLiquidity. The liquidator must retry when liquidity appears. Gas cost on failure is borne by the liquidator.

  2. Bad Debt Risk — Sustained illiquid conditions can accumulate bad debt. There is no automatic backstop buyer.

  3. No Insurance Fund Backstop — The insurance fund collects profits but does not act as a market maker during illiquid periods. Future enhancement: insurance fund places bids during illiquid conditions.

Security Considerations

Oracle Dependency

Liquidation relies on fundingRateOracle.getPrices() for the mark price. If the oracle returns 0 or is not set:

  • Reader.isLiquidatable() returns false
  • FxEngine.liquidate() reverts with FundingOracleNotSet

Reentrancy

Liquidation involves external calls to:

  • Market contract (order placement and fill callbacks)
  • USDC transfers (reward distribution)

State updates occur before external calls where possible. The onlyFxEngine modifier on FxAccount liquidation functions prevents unauthorized calls.

Same-Block Protection

Positions opened in the current block could theoretically be liquidated in the same block if the mark price moves adversely. The openBlock field on positions can be used for future same-block protections.