Why the ClearingHouse Exists
SYMMIO's normal liquidation flow is decentralized — any address with the LIQUIDATOR_ROLE can liquidate insolvent
parties. This works well for isolated-mode partyBs and for partyAs in the common case, but two situations require centralized
intervention:
-
Cross-mode PartyB insolvency. When a partyB operates in cross-margin mode, its funds are pooled in a single bucket (
address(0)) shared across all partyAs. A decentralized liquidator cannot safely unwind this because it requires coordinated decisions about how to distribute a shared pool across multiple counterparties. The ClearingHouse handles this as a multi-step process with explicit deallocate/distribute phases. -
Stuck or irrecoverably disputed PartyA liquidation. A normal partyA liquidation can get stuck — the liquidator may abandon it, or a dispute may arise where the accumulated UPNL doesn't match the Muon oracle's reported UPNL. In the normal case, the dispute resolver (a multisig) handles disputes and corrects the liquidation state. However, there are edge cases where the on-chain state is corrupted or inconsistent to the point that even the dispute resolver cannot fix it through the normal dispute resolution flow. For these cases, the ClearingHouse can take over the liquidation entirely, resetting the liquidation state and completing the process with its own price feeds.
All ClearingHouse functions require the CLEARING_HOUSE_ROLE, which is granted to a trusted operator address.
Cross-Mode vs Isolated-Mode PartyB
Before understanding the liquidation flows, it helps to understand how partyB allocation modes work.
Isolated mode (default): PartyB allocates funds separately per partyA. Balances are tracked under
partyBAllocatedBalances[partyB][partyA]. Each partyA relationship has its own margin pool, its own locked
balances, and its own liquidation status.
Cross mode (opt-in via migration): PartyB pools all funds into a single bucket tracked under
partyBAllocatedBalances[partyB][address(0)]. All positions across all partyAs draw from this shared pool. The
helper LibAccount.partyBAllocationKey(partyB, partyA) returns address(0) for cross-mode and
partyA for isolated-mode.
Flow 1: Cross PartyB Liquidation
This flow handles the insolvency of a cross-mode partyB. Because funds are pooled, a single insolvency event affects all partyAs that have positions with this partyB.
When It Triggers
The ClearingHouse operator detects that a cross-mode partyB's available balance has gone negative. The on-chain contract
verifies partyBAvailableBalanceForLiquidation(upnl, partyB, address(0)) < 0 using the UPNL value provided
directly by the ClearingHouse (no Muon signature required — the ClearingHouse is trusted).
Lifecycle
flowchart TD
A["liquidateCrossPartyB(partyB, liquidationId, upnl, timestamp)"] --> B["deallocateForClearingHouse(partyB, ...)"]
B --> C["liquidatePendingPositionsForClearingHouse(partyB, partyAs[])"]
C --> D["liquidatePositionsForClearingHouse(partyB, quoteIds[], prices[])"]
D --> E["distributeForClearingHouse(partyB, receivers[], ...)"]
E --> F["settleCrossPartyBLiquidation(partyB)"]
style A fill:#e74c3c,color:#fff
style F fill:#27ae60,color:#fff
Steps 2–5 are repeatable and can be called in any order and multiple times. The settlement step (6) requires all positions closed and the deallocated pool fully distributed.
Step-by-Step
Step 1 — liquidateCrossPartyB(partyB, liquidationId, upnl, timestamp)
Initiates the liquidation. The ClearingHouse provides the UPNL, liquidation ID, and timestamp directly (no Muon signature —
the ClearingHouse is trusted). The contract confirms the partyB is insolvent using the provided UPNL and creates a
CrossLiquidationDetail record with inProgress = true. This flag gates all subsequent operations and
prevents the partyB from operating normally until settlement.
Step 2 — deallocateForClearingHouse(partyB, parties[], allocationKeys[], amounts[])
Pulls funds into a deallocatedPool. The ClearingHouse decides who to pull from and how much — the on-chain
contract trusts the ClearingHouse to make correct decisions. Funds can be pulled from the partyB's cross bucket, but also from
partyAs. For example, if the partyB has positive UPNL against a partyA (i.e., the partyA owes the partyB), the ClearingHouse
will pull funds from that partyA's allocated balance as well.
// Pull from partyB's cross bucket and from a partyA who owes partyB
parties: [hedgerAddress, userAddress]
allocationKeys: [address(0), address(0)] // cross bucket, partyA allocation
amounts: [crossBalance, amountOwed]
Multiple calls can pull from different sources across multiple parties.
Step 3 — liquidatePendingPositionsForClearingHouse(partyB, partyAs[])
Cancels all pending quotes (LOCKED, CANCEL_PENDING) where the partyB matches. For each cancelled quote: - The opening trading
fee is returned to the partyA (to allocatedBalances if partyA is healthy, or partyAReimbursement if
partyA is also being liquidated). - The quote status becomes LIQUIDATED_PENDING. - PartyB's per-partyA pending
arrays are cleared.
Step 4 — liquidatePositionsForClearingHouse(partyB, quoteIds[], prices[])
Closes open positions at ClearingHouse-specified prices (no Muon signature required — the ClearingHouse is trusted). For each
quote: - Validates the quote belongs to the subject partyB. - Sets status to LIQUIDATED. - Subtracts from both
partyA and partyB locked balances. - Removes from open positions tracking and decrements position counts. - Calls affiliate
and system hooks. - Cleans up connections when a partyA has no more positions with this partyB.
Step 5 — distributeForClearingHouse(partyB, receivers[], allocationKeys[], amounts[])
Distributes funds from the deallocatedPool to receivers. The routing depends on the receiver type:
| Receiver | Where funds go | Event |
|---|---|---|
| PartyB (any) | partyBAllocatedBalances[receiver][allocationKey] |
BalanceChangePartyB |
| PartyA (healthy) | allocatedBalances[receiver] |
BalanceChangePartyA |
| PartyA (liquidating) | partyAReimbursement[receiver] |
None (escrow) |
Step 6 — settleCrossPartyBLiquidation(partyB)
Finalizes the liquidation. Requires: - All positions closed (partyBPositionsCount[partyB][address(0)] == 0) - All
funds distributed (deallocatedPool == 0)
Sets inProgress = false, allowing the partyB to resume operations.
Flow 2: PartyA Takeover
This flow handles partyA liquidations that are stuck or in a state that cannot be resolved through normal means. The dispute resolver (multisig) handles normal disputes — correcting UPNL mismatches, adjusting settlement states, etc. But when the on-chain state is corrupted or inconsistent beyond what the resolver can fix, the ClearingHouse takes over as a last resort, resetting the liquidation and completing it with its own price feeds.
When It Triggers
A partyA is already in the liquidation state (liquidationStatus[partyA] == true) but the process cannot be
completed through normal channels. This typically means the liquidation is either abandoned (liquidator stopped processing) or
disputed with state corruption that the dispute resolver multisig cannot rectify. The ClearingHouse operator decides to
intervene as a last resort.
Lifecycle
flowchart TD
A["takeoverPartyALiquidation(partyA)"] --> B["deallocateForClearingHouse(partyA, ...)"]
B --> C["liquidatePendingPositionsForClearingHouse(partyA, [])"]
C --> D["liquidatePositionsForClearingHouse(partyA, quoteIds[], prices[])"]
D --> E["distributeForClearingHouse(partyA, receivers[], ...)"]
E --> F["settlePartyATakeover(partyA, settledPartyBs[])"]
style A fill:#e74c3c,color:#fff
style F fill:#27ae60,color:#fff
What Takeover Does
When takeoverPartyALiquidation is called, it: 1. Clears the disputed flag — the ClearingHouse
will resolve the state directly. 2. Clears the liquidation fee — original liquidators get nothing (they
abandoned). 3. Deletes the liquidators array — fresh start. 4.
Sets partyATakeoverDetails[partyA].inProgress = true — blocks all normal liquidation functions
for this partyA.
After takeover, the normal liquidation functions (liquidatePositionsPartyA, settlePartyALiquidation,
etc.) are blocked by a require(!partyATakeoverDetails[partyA].inProgress) check. Only ClearingHouse functions can
proceed.
Step-by-Step
Step 1 — takeoverPartyALiquidation(partyA)
Takes control from the normal liquidation flow. See above for what it clears.
Step 2 — deallocateForClearingHouse(partyA, parties[], allocationKeys[], amounts[])
Pulls funds into the takeover's deallocatedPool. Three source types are supported:
| Source | Party | Allocation Key | Pulls From |
|---|---|---|---|
| PartyA allocation | partyA | address(0) |
allocatedBalances[partyA] |
| PartyA reimbursement | partyA | address(1) |
partyAReimbursement[partyA] |
| PartyB allocation | partyB | partyA address | partyBAllocatedBalances[partyB][partyA] |
The address(1) key (REIMBURSEMENT_KEY) is special — it accesses the escrow where fees and other
credits accumulate during liquidation.
Step 3 — liquidatePendingPositionsForClearingHouse(partyA, [])
The counterparties parameter is ignored for takeover — all of partyA's pending quotes are processed regardless of which partyB
they belong to. Fees go to partyAReimbursement (escrow).
Step 4 — liquidatePositionsForClearingHouse(partyA, quoteIds[], prices[])
Closes positions at ClearingHouse-specified prices. An additional check prevents liquidating positions where the counterparty partyB is itself being liquidated (either via normal isolated liquidation or cross liquidation).
Step 5 — distributeForClearingHouse(partyA, receivers[], ...)
Same routing logic as the cross partyB flow.
Step 6 — settlePartyATakeover(partyA, settledPartyBs[])
Finalizes the takeover. The settledPartyBs parameter is important: if the normal liquidation flow had already
processed some partyBs before the takeover (creating settlement states), those states need to be cleaned up explicitly since
the connections may already be removed.
Settlement: - Releases both partyAReimbursement and partyADeferredBalance back to
allocatedBalances[partyA]. The reimbursement contains pending fee refunds and credits from the liquidation
process; the deferred balance contains excess funds from deferred liquidation (see
Liquidation Escrow). In the takeover flow, both are returned to the PartyA because the
ClearingHouse already has full control over distribution via deallocateForClearingHouse and
distributeForClearingHouse. - Zeros out locked balances. - Increments partyA nonce. - Sets
liquidationStatus[partyA] = false. - Deletes both liquidationDetails and
partyATakeoverDetails.
After settlement, the partyA can deposit funds and trade again.
Flow 3: Simultaneous PartyA + Cross PartyB Liquidation
This is the most complex scenario. A cross-mode partyB becomes insolvent, AND one or more of its counterparty partyAs are also being liquidated at the same time.
The Problem
When the ClearingHouse processes a cross partyB liquidation, it encounters partyAs that are already mid-liquidation by regular liquidators. Two independent liquidation flows now compete over the same positions:
-
The normal partyA liquidation wants to close positions via
liquidatePositionsPartyAand settle viasettlePartyALiquidation. -
The cross partyB liquidation wants to close the same positions via
liquidatePositionsForClearingHouse.
Without coordination, this creates accounting conflicts — both flows would try to subtract from locked balances, track settlement states, and finalize independently.
The Solution: Auto-Takeover
Rather than bridging the two accounting systems, the ClearingHouse automatically takes over any partyA liquidation it encounters during cross partyB processing. This collapses two concurrent flows into one coordinated flow.
flowchart TD
subgraph "Normal PartyA Liquidation (blocked)"
N1["liquidatePartyA"] --> N2["setSymbolsPrice"]
N2 --> N3["liquidatePositionsPartyA"]
N3 --> N4["settlePartyALiquidation"]
end
subgraph "Cross PartyB Liquidation"
C1["liquidateCrossPartyB"]
C1 --> C2["liquidatePendingPositionsForClearingHouse"]
C1 --> C3["liquidatePositionsForClearingHouse"]
C2 -- "detects partyA is liquidated" --> AT["Auto-Takeover"]
C3 -- "detects partyA is liquidated" --> AT
end
AT -- "blocks" --> N3
AT -- "blocks" --> N4
subgraph "PartyA Takeover (after cross partyB settles)"
T1["deallocateForClearingHouse(partyA)"]
T1 --> T2["liquidatePendingPositionsForClearingHouse(partyA)"]
T2 --> T3["distributeForClearingHouse(partyA)"]
T3 --> T4["settlePartyATakeover(partyA)"]
end
AT --> T1
style AT fill:#f39c12,color:#fff
style N3 fill:#95a5a6,color:#fff
style N4 fill:#95a5a6,color:#fff
How Auto-Takeover Works
The _autoTakeoverPartyALiquidation(partyA) function is called at two catch points during cross partyB processing:
-
liquidatePositionsForClearingHouse— for each quote in the CROSS_PARTY_B branch, after validatingpartyB == subject, the function calls_autoTakeoverPartyALiquidation(partyA). This is the common case. -
liquidatePendingPositionsForClearingHouse— inside the counterparties loop for CROSS_PARTY_B, before processing pending quotes for each partyA.
The function is idempotent — safe to call multiple times for the same partyA: - If partyA is not being
liquidated → returns false (no-op). - If takeover already in progress → returns false (no-op). -
Otherwise → executes the takeover and emits AutoTakeoverPartyALiquidation, returns true.
Both takeoverPartyALiquidation (manual) and _autoTakeoverPartyALiquidation (automatic) use the same
internal _executeTakeover helper, which clears the disputed flag, liquidation fee, and liquidators array, then
sets the takeover state.
Settlement Guard
There is an additional guard in settlePartyALiquidation (the normal partyA settlement function) that prevents
settling with a partyB that is in cross liquidation:
require(
!ClearingHouseStorage.layout().crossLiquidationDetails[partyB].inProgress,
"LiquidationFacet: PartyB is in cross liquidation"
);
This catches an edge case where the normal liquidator has already closed all of a partyA's positions (with non-cross partyBs) before the cross partyB liquidation starts. In this case, auto-takeover wouldn't trigger (no positions left to process), but settlement must still be blocked because the cross-liquidated partyB's settlement state cannot be finalized through the normal flow.
End-to-End Simultaneous Flow
Here is the full sequence when both partyA and cross partyB are liquidated at the same time:
sequenceDiagram
participant L as Normal Liquidator
participant CH as ClearingHouse
participant PA as PartyA State
participant PB as Cross PartyB State
L->>PA: liquidatePartyA(partyA, sig)
L->>PA: setSymbolsPrice(partyA, sig)
Note over PA: partyA now in liquidation
CH->>PB: liquidateCrossPartyB(partyB, liquidationId, upnl, timestamp)
Note over PB: cross partyB now in liquidation
CH->>PB: deallocateForClearingHouse(partyB, ...)
Note over PB: funds pulled into deallocatedPool
CH->>PB: liquidatePendingPositionsForClearingHouse(partyB, [partyA])
Note over PA: Auto-takeover triggered!<br/>Normal liq functions blocked
CH->>PB: liquidatePositionsForClearingHouse(partyB, quoteIds, prices)
Note over PB: positions closed, connections cleaned
CH->>PB: distributeForClearingHouse(partyB, [partyA], ...)
Note over PA: funds → partyAReimbursement (escrow)
CH->>PB: settleCrossPartyBLiquidation(partyB)
Note over PB: cross partyB liquidation complete
CH->>PA: liquidatePendingPositionsForClearingHouse(partyA, [])
Note over PA: remaining pending quotes cleared
CH->>PA: deallocateForClearingHouse(partyA, ...)
CH->>PA: distributeForClearingHouse(partyA, ...)
CH->>PA: settlePartyATakeover(partyA, [])
Note over PA: partyA liquidation complete,<br/>can trade again
Key observations: - The cross partyB flow runs first and triggers auto-takeover when it encounters the liquidated partyA. -
Funds distributed to the liquidated partyA during cross partyB processing go to partyAReimbursement (escrow), not
allocatedBalances. - After cross partyB settles, the ClearingHouse completes the partyA takeover separately. -
The partyA may have remaining pending quotes (SENT status, no partyB assigned) that weren't handled by the cross partyB flow —
these are processed via the takeover's liquidatePendingPositionsForClearingHouse(partyA, []).
Liquidation Escrow Distribution
After a normal (non-takeover) PartyA liquidation settles as LATE or OVERDUE, the pending fee
reimbursement is not returned to the PartyA. Instead, it is held in liquidationEscrow[partyA] for the
ClearingHouse to distribute. See Liquidation Escrow for the full motivation and
mechanics.
The ClearingHouse distributes escrowed funds via:
function distributeFromLiquidationEscrow(
address partyA,
address[] memory receivers,
address[] memory allocationKeys,
uint256[] memory amounts
) external onlyRole(CLEARING_HOUSE_ROLE)
This function is independent of the takeover and cross partyB liquidation flows — it operates on the escrow pool that was
created during settlePartyALiquidation, not during a ClearingHouse lifecycle. The ClearingHouse operator calls it
after the normal liquidation has fully settled to compensate PartyBs for haircut losses or return funds to the PartyA if the
fees were legitimate.