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 / sizeMMR = 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
-
Validation (FxEngine)
- Verify account exists via
accounts[accountId] - Verify market exists and is configured
- Verify position is open with non-zero size
- Verify account exists via
-
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
- Call
-
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 withPositionNotLiquidatableif not
- Get mark price via
-
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()
- Call
-
Order Execution (Market → FxEngine → FxAccount)
- Market matches the liquidation order against resting orders
onFill()callback fires on FxEngine, which routes tohandleFill()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()
-
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
-
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
- Call
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
- FxAccount deducts total rewards from
availableMarginand approves USDC transfer - FxEngine calls
transferRewards()to sendliquidatorRewardtomsg.sender - FxEngine calls
transferRewards()to sendinsuranceFundProfittoinsuranceFundRecipient
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:
- Position is already insolvent at time of liquidation (
expectedFreedMargin < 0) - 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:
- Market order fills available liquidity (partial fill)
- Position size reduces proportionally
- Rewards are distributed proportional to the liquidated amount only
- Remaining position may still be liquidatable
- 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
| Function | Access |
|---|---|
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:
- No authorization required —
FxEngine.liquidate()is permissionless - Monitor positions using
Reader.isLiquidatable(accountId, marketId) - Use
Reader.batchIsLiquidatable(accountIds, marketId)for efficient batch checks - When a position is liquidatable, call
FxEngine.liquidate(accountId, marketId) - Earn the liquidator reward (up to
liquidationFeeBpsof the liquidated notional) on success
Known Limitations (Beta)
-
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. -
Bad Debt Risk — Sustained illiquid conditions can accumulate bad debt. There is no automatic backstop buyer.
-
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 falseFxEngine.liquidate()reverts withFundingOracleNotSet
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.