Skip to main content
This page documents the protocol’s behavior at boundary conditions and in unusual scenarios. Understanding these edge cases is essential for integrators building on top of the contracts, keepers implementing automated settlement/liquidation, and auditors verifying correctness.
This content is primarily relevant for smart contract integrators, keeper operators, and security researchers. Traders using the frontend do not need to understand these details — the UI handles all edge cases transparently.
When a position’s equity is exactly equal to its maintenance margin (MM) threshold, the position is NOT liquidatable.The liquidation check uses a strict less-than comparison:
isLiquidatable = equity < maintenanceMargin
This means equity == maintenanceMargin evaluates to false, and the position remains safe. Attempting to liquidate such a position reverts with NotLiquidatable().
This is a deliberate design choice. The strict-less-than threshold ensures traders are not penalized at the exact boundary and provides a clear, deterministic rule with no ambiguity.
Bad debt occurs when a position’s loss exceeds its locked initial margin. This can happen when prices move sharply between keeper liquidation checks.When |marketPnl| > imLocked:
  • Realized PnL: Capped at -imLocked (the trader loses their entire locked margin but no more)
  • Market PnL: The uncapped mathematical PnL is recorded for accounting purposes
  • Bad debt: The difference |marketPnl| - imLocked is absorbed by pool equity
  • Event: A BadDebt event is emitted with the shortfall amount
  • Isolation: The trader’s free collateral and other open positions are completely unaffected
Bad debt reduces the pool’s share price (NAV per share), distributing the loss across all LPs proportionally. This is a fundamental property of the isolated margin + zero-sum pool design.
When the Pyth oracle returns invalid data (no price available, feed not found), all oracle-dependent operations revert with OracleInvalid():
  • Position open: Cannot compute entry price
  • Unrealized PnL: Cannot mark positions to market
  • Settlement: Cannot determine final PnL
  • Liquidation: Cannot assess position equity
Additionally, the ModeController may auto-transition to PAUSED mode via checkOracleAndPause() if it detects persistent oracle invalidity, blocking all protocol operations until the oracle is restored.
Recovery requires the oracle feed to return valid data. For spot prices, this means Pyth must resume publishing. For forward prices, the Publisher service must submit fresh prices via publishForwardRound().
When a position’s raw maturity timestamp falls on a weekend, the DateTime.getNextBusinessDayFixing library rolls it forward to the next business day:
  • Saturday (dayOfWeek = 6): Rolls forward +2 days to Monday
  • Sunday (dayOfWeek = 0): Rolls forward +1 day to Monday
  • Post-roll check: If the computed fixing time is less than or equal to rawMaturity after the weekend roll, it advances by +1 additional day and rechecks for Saturday
All fixing times are anchored to 16:00 UTC on business days.
M2 defines business days as Monday through Friday only. There is no holiday calendar. Holiday support may be added in future milestones via a configurable calendar contract.
When the calculated fee (trading fee, oracle fee, or liquidation penalty) exceeds the margin remaining after PnL settlement, the fee is silently capped:
actualFee = min(fee, marginAfterPnl)
The protocol does not revert. It collects whatever margin is available after PnL and distributes that reduced fee to the configured fee destinations. This ensures settlements and liquidations always complete even when the position’s remaining margin is insufficient to cover fees.
When a trader closes a position with a profit, the pool must pay out the difference. If the pool’s total assets are insufficient to cover the trader’s profit, PoolVault.applyPnl() reverts with InsufficientPoolLiquidity().This is a hard revert — the settlement cannot proceed. The position remains open until either:
  • LPs deposit additional liquidity
  • Market prices move to reduce the trader’s profit
  • The position is liquidated (if applicable)
This scenario indicates the pool is functionally insolvent for that specific settlement. Risk parameters (netExposureCapFactorBps, maxUtilizationBps) are designed to prevent this condition from occurring under normal market conditions.
A matured position that is also liquidatable (equity below MM) is skipped by batch settlement. The batchSettle() function checks the liquidation condition and excludes such positions.These positions must be resolved through a separate liquidate() call. This design ensures that the liquidation penalty fee is properly collected and distributed rather than being bypassed through the standard settlement path.
Calling openPosition() with notional = 0 reverts with ZeroAmount(). The protocol enforces a minimum position size via minPositionNotional, but the zero check fires first as a basic input validation.
Two independent staleness checks protect against outdated price data:
  • Forward staleness: If (block.timestamp - publishTimestamp) > maxForwardAge, the forward price’s isValid flag is false. Operations that require a forward price will revert with OracleStale() or OracleInvalid().
  • Spot staleness: If (block.timestamp - cachedPublishTime) > maxOracleAge, the spot price’s isValid flag is false.
Recovery: The Publisher must call publishForwardRound() with a fresh forward price. For spot, the caller must provide fresh Pyth price update data that the OracleModule forwards to the Pyth contract.
The protocol’s operating mode gates which operations are permitted:
ModeBlocked OperationsError
PAUSEDAll trading, settlement, and liquidation operationsProtocolPaused()
REDUCE_ONLYopenPosition(), increasePosition()ReduceOnlyMode()
DEGRADEDopenPosition(), increasePosition()ReduceOnlyMode()
NORMALNone
The mode check is implemented as a modifier that runs before any business logic. PAUSED is checked first, so a PAUSED protocol always reverts with ProtocolPaused() regardless of the specific operation attempted.
All integer arithmetic uses Solidity’s default truncation (round toward zero). There is no explicit rounding mode configurable in the protocol.Specific rounding behaviors:
  • PnL calculations: Truncated toward zero
  • Fee calculations: Truncated toward zero (protocol receives less in edge cases)
  • ERC-4626 redeem: Shares-to-assets conversion rounds down, which can produce a 1-wei difference from the expected withdrawal amount. Tests use assertApproxEqAbs(..., 1) to account for this.
The recordFixingPriceFromPyth() function reverts with FixingPriceAlreadySet() if a fixing price has already been recorded for the given (pairId, fixingTimestamp) combination.Fixing prices are immutable once recorded. They cannot be overwritten or corrected. The admin setFixingPrice() function has the same constraint — it can only set a fixing price if one does not already exist for that timestamp.
The clearForwardRound() function allows cleaning up stale forward data for past fixing timestamps. However, if any open positions still reference the fixing timestamp being cleared, the function silently skips rather than reverting.The forward data is preserved as long as any open position depends on it for PnL calculation. Only after all positions at that fixing timestamp are settled or closed does clearing succeed.
There is no TOCTOU (time-of-check-time-of-use) vulnerability between settlement and liquidation. EVM transactions are atomic — all state reads and writes within a single transaction are consistent. Both settlement and liquidation check their preconditions (maturity, fixing price, equity threshold) at execution time within the same transaction context.If two keepers submit a settle and a liquidate for the same position in the same block, the second transaction will find the position already closed and revert with PositionNotOpen().
When imFactorBps is increased after a position is opened:
  • The existing position continues using its snapshotted IM rate (stored at open time)
  • An increasePosition() call uses the current config for the additional margin calculation
  • The resulting combined margin uses a weighted-average approach that preserves the existing position’s economics while applying the new rate to the incremental notional
This prevents retroactive margin calls on existing positions when parameters change.
reducePosition() validates that the remaining notional after reduction is greater than or equal to minPositionNotional. If the reduction would leave the position with less than the minimum notional, the transaction reverts.To close a position below the minimum notional threshold, the trader must execute a full close (reduce to zero) rather than a partial reduction.