This fragmentation created a structural inefficiency in capital utilization. Because each allocation was evaluated in isolation, liquidation risk was also assessed independently. A PartyB could find itself liquidated on positions with one PartyA simply because the allocated balance for that counterparty was insufficient — even if, when considering all positions together, PartyB remained solvent overall.
Cross mode makes it possible for PartyBs to be cross-margined with users — a single pooled balance covers all positions across all PartyAs. But the change has far-reaching consequences for nonce handling, settlement, force close, and liquidation, each of which is explained below.
Why Cross Mode Requires Trust
A solver can close a position at any price it wants. In isolated mode this was contained: the worst case affected only one PartyA. In cross mode, a solver could "late-liquidate" itself across multiple PartyAs by transferring its funds to one PartyA via manipulated close prices.
Because of this risk, cross mode is restricted to trusted solvers. Symmio's off-chain monitoring and anomaly-detection systems watch PartyB actions 24/7 and will suspend PartyB — or any user — if bad behavior is detected (whether intentional, due to a bug, or caused by a compromise).
Activation
Activating cross mode for a PartyB is a multi-step process:
-
Migration (performed by the Symmio team upon upgrade). Immediately after the contract upgrade, the Symmio team runs two migration functions while the system is paused:
-
migrateQuotes(quoteIds[])on theMigrationFacet— backfills v0.8.5 derived state for all active positions: aggregated positions, funding baselines, thepartyBPositionsCount[partyB][address(0)]total positions counter, and connection tracking. -
migrateCrossLockedValues(partyB, partyAs[])on theMigrationFacet— aggregates all per-PartyA locked balances and pending locked balances into the cross bucket (address(0)):partyBLockedBalances[partyB][address(0)] += partyBLockedBalances[partyB][partyA] partyBPendingLockedBalances[partyB][address(0)] += partyBPendingLockedBalances[partyB][partyA]Allocated balances are not aggregated -- the cross bucket[address(0)]for allocated balances is an independent pool that the solver funds explicitly after enabling cross mode. This function is idempotent per partyB+partyA pair — already-migrated pairs are skipped.
-
-
Global feature flag. After migration is complete, an admin with
MIGRATION_ROLEcallssetCrossPartyBModeActivated(true)on theControlFacet. This enables the feature protocol-wide. -
Activation (one of two paths):
-
Admin path: An admin with
MIGRATION_ROLEcallssetCrossPartyB(partyB, true)on theControlFacet. Requires the global flag to be on and the PartyB to be registered. Does not check migration status on-chain. -
Self-activation path: The PartyB itself calls
activateCrossPartyB()on thePartyBAccountFacet. Requires the global flag to be on and cross mode to not already be active.
-
Admin path: An admin with
After activation, address(0) becomes the PartyB's allocation key. The solver only needs to allocate to
address(0) — no need to allocate per PartyA. The helper
LibAccount.partyBAllocationKey(partyB, partyA) returns address(0) when cross mode is active, and
partyA otherwise. All balance functions (available balance, locked balance calculations) use this key
transparently.
Deactivation
An admin with MIGRATION_ROLE can call setCrossPartyB(partyB, false) to disable cross mode. There is
no on-chain guard that checks for zero open positions at the contract level for admin deactivation — the operational
constraint is enforced off-chain.
Post-Activation Workflow
After cross mode is activated, PartyB is expected to:
-
Fund the cross bucket by calling
allocateForPartyB(amount, address(0)). This is the pool that backs all cross-mode positions. -
Drain legacy per-PartyA allocations by calling
deallocateForPartyB(amount, partyA, upnlSig)for each PartyA that had funds allocated before cross mode. These funds move from the per-PartyA bucket to PartyB's free balance, from where they can be re-allocated to the cross bucket or withdrawn. - Continue normal operations using only
address(0)for both allocation and deallocation.
Allocation and Deallocation in Cross Mode
When cross mode is active, allocateForPartyB requires partyA == address(0). Calling with any other
partyA address reverts with "Cross partyB mode is active". transferAllocation (which
moves funds between per-PartyA buckets) reverts entirely in cross mode since it is meaningless when everything is pooled.
deallocateForPartyB supports two modes in cross:
-
Cross bucket deallocation (
partyA == address(0)): Standard deallocation from the cross pool. RequirescrossAvailableBalance >= amount— the same solvency check as isolated mode. -
Legacy per-PartyA drain (
partyA != address(0)): Withdraws stranded funds from pre-cross per-PartyA buckets. Only requirescrossAvailableBalance >= 0(cross solvency), not>= amount, because these funds are not backing any cross-pool positions. The Muon signature and solvency check always useaddress(0)as the PartyA — consistent with all other cross-mode signatures.
Settlement Reserve During PartyA Liquidation
When a PartyA is liquidated, liquidatePositionsPartyA closes positions and records a deferred PnL settlement
amount (actualAmount in settlementStates[partyA][partyB]). For cross-mode PartyBs, the locked
balances drop immediately (freeing up available balance), but the actual PnL transfer doesn't happen until
settlePartyALiquidation runs later.
This creates a window where a cross-mode PartyB's available balance is artificially inflated — the locked balance reduction already happened, but the settlement debit hasn't been applied yet. Without protection, PartyB could deallocate funds it owes to pending liquidation settlements.
The fix: partyBLiquidationSettlementReserve
The contract tracks a conservative reserve equal to sum(max(0, actualAmount)) across all pending liquidation
settlements for each cross-mode PartyB. This reserve is:
-
Incremented in
liquidatePositionsPartyAasactualAmountaccumulates for each batch of liquidated positions - Adjusted in
resolveLiquidationDisputeif an admin overrides the settlement amount -
Cleared in
settlePartyALiquidation(orsettlePartyATakeover) when the settlement is finalized
During deallocateForPartyB, the reserve is subtracted from the effective available balance before the solvency
check:
if (isCrossMode) {
availableBalance -= int256(accountLayout.partyBLiquidationSettlementReserve[signer]);
}
This allows PartyB to continue operating (opening/closing/settling positions) and even partially deallocate — as long as it keeps enough in the cross pool to cover the pending settlement. The reserve is intentionally conservative: it does not subtract CVA (which is returned to PartyB at settlement time), so PartyB may have slightly less available balance than strictly necessary until the settlement completes.
The reserve only applies to cross bucket deallocation (partyA == address(0)). Legacy per-PartyA drains are
already protected by the notLiquidatedPartyA modifier on deallocateForPartyB.
Dual-Tracking of Locked Balances
Even though the allocation key is address(0) in cross mode, the contract always maintains
both per-PartyA and cross locked balances. Every call to addToPartyBLockedBalances,
subFromPartyBLockedBalances, addToPartyBPendingLockedBalances, and
subFromPartyBPendingLockedBalances updates both [partyB][partyA] and
[partyB][address(0)]. This allows the system to compute per-PartyA solvency in isolated mode or aggregate
solvency in cross mode using the same storage.
Nonce Handling
The Problem
Before cross mode, PartyB nonces were scoped per PartyA: partyBNonces[partyB][partyA]. Each PartyA-PartyB pair
had its own nonce, and signatures included this nonce to prevent replay attacks. This worked because operations with different
PartyAs were completely independent.
In cross mode, a global nonce (partyBNonces[partyB][address(0)]) replaces the per-PartyA nonce for solvency
purposes. But using a single global nonce in signatures would create a bottleneck: PartyB could not sign operations for
PartyA₁ and PartyA₂ in parallel, because each signature would need the latest global nonce, and whichever lands first would
invalidate the other.
The Solution: Zero Nonce in Signatures
When cross mode is enabled, the nonce included in PartyB signatures for opening and closing positions is
zero (a constant), not the actual nonce. This is implemented in
LibAccount.getPartyBSignatureNonce:
function getPartyBSignatureNonce(address partyB, address partyA, bool useCrossNonce) internal view returns (uint256) {
if (MAStorage.layout().crossModeEnabledForPartyB[partyB]) {
return useCrossNonce ? accountLayout.partyBNonces[partyB][address(0)] : 0;
}
return accountLayout.partyBNonces[partyB][partyA];
}
The useCrossNonce flag controls which behavior is used:
-
false(default): Returns0in cross mode. Used forPairUpnlAndPriceSig(open/close/forceClose/funding rate signatures) where PartyB needs to operate in parallel with different PartyAs. -
true: Returns the actual cross nonce (partyBNonces[partyB][address(0)]). Used forSingleUpnlSigin deallocate operations, where nonce protection is still needed because deallocate affects the shared pool.
Nonce Increment
Even though signatures ignore the nonce, the on-chain nonce still increments on every state-changing operation.
LibAccount.increasePartyBNonce always increments both the per-PartyA nonce and the cross nonce:
function increasePartyBNonce(address partyB, address partyA) internal {
accountLayout.partyBNonces[partyB][partyA]++;
accountLayout.partyBNonces[partyB][address(0)]++;
}
This keeps the cross nonce as a monotonically increasing counter that reflects total state changes, even though it is not checked in most signatures.
Unified Settlement Nonce
The unified settlement signature (verifyUnifiedSettlement) handles nonces differently depending on mode:
-
Cross mode: Uses
uint256(0)as the nonce in the hash — matching the zero-nonce convention. -
Normal mode: Uses per-PartyA nonces for PartyB (
partyBNonces[partyB][partyAs[i]]for each PartyA in the settlement).
Settlement
Why Unified Settlement Was Needed
The legacy settleUpnl function settles one PartyA at a time: it takes a single partyA address,
iterates over quotes belonging to different PartyBs, and tracks PartyB balances per PartyA. This design works for isolated
mode but cannot model cross mode correctly, because in cross mode PartyB's collateral is pooled — settlement must treat
PartyB's balance as one shared pool across all PartyAs.
How settleUpnlUnified Works
settleUpnlUnified (in LibSettlement) inverts the settlement axis: it settles one
PartyB across one or more PartyAs in a single transaction.
Signature structure (UnifiedSettlementSig):
partyB — the PartyB being settled
partyAs[] — array of PartyA addresses involved
upnlPartyAs[] — per-PartyA UPNLs
upnlPartyB — aggregated UPNL (cross mode)
upnlPartyBPerPartyA[] — per-PartyA UPNLs for PartyB (normal mode)
quotesSettlementsData[] — per-quote data with partyAIndex mapping each quote to its partyA
Settlement flow:
- Validate lengths, solvency of all parties, and that no one is being liquidated.
-
Process quotes: For each quote, validate it belongs to the specified
partyBandpartyAs[partyAIndex]. Calculate the settlement amount from the price change (openedPrice → updatedPrice). Update the quote'sopenedPrice. -
Apply settlements per PartyA: For each PartyA, compute the net settlement amount. If positive (PartyB
loses, PartyA gains), deduct from
partyBAllocatedBalances[partyB][allocKey]and credit toallocatedBalances[partyA]. If negative, the reverse. TheallocKeyisaddress(0)in cross mode,partyAin normal mode.
Key differences from legacy settleUpnl:
| Aspect | Legacy settleUpnl |
Unified settleUpnlUnified |
|---|---|---|
| Axis | One PartyA, multiple PartyBs | One PartyB, multiple PartyAs |
| PartyB balance key | partyBAllocationKey(partyB, partyA) |
address(0) (cross) or partyA (normal) |
| Solvency check | Per-PartyA for PartyB | Aggregated (cross) or per-PartyA (normal) |
| Nonce in signature | Per-PartyA PartyB nonce | Zero (cross) or per-PartyA (normal) |
| Cross mode support | Partially (uses allocation key) | Native |
The legacy settleUpnl is kept for backward compatibility with integrations that have not migrated.
Quote Subset Constraints
Solvency is validated using aggregate UPNL — which includes unrealized gains from positions not being settled — but each
party's uint256 balance must independently absorb the realized settlement amount. If a party is solvent in
aggregate but the settlement loss from the chosen quote subset exceeds their raw allocated balance, the transaction will
revert.
Callers must select quote subsets where each individual party's allocated balance can cover the realized settlement:
-
PartyA: The net settlement loss from quotes with this specific PartyB must not exceed
allocatedBalances[partyA]. -
Non-cross PartyB: The net settlement loss from quotes with a specific PartyA must not exceed
partyBAllocatedBalances[partyB][partyA]. -
Cross PartyB: The net settlement loss across all PartyAs must not exceed
partyBAllocatedBalances[partyB][address(0)]. The ordering of PartyAs in the signature does not matter — the contract accumulates a signed delta and applies it once.
If a desired settlement would violate these constraints, callers should either include offsetting (winning) quotes in the batch, split the settlement into multiple transactions, or wait for the party to deposit/allocate additional funds.
Settlement Examples
Scenario 1: PartyA lacks money, PartyB settles to charge PartyA. PartyA has a position to close but
insufficient available balance. PartyB uses settleUpnlUnified to realize PartyA's positive uPnL from other open
positions with this PartyB. The realization increases PartyA's allocated balance and decreases PartyB's allocated balance.
Once PartyA is funded, PartyB can execute FillClose.
Scenario 2: PartyB lacks money, PartyB settles to charge itself. PartyA requests a close but PartyB lacks
available balance to pay. PartyB settles its own profitable positions — the settleUpnlUnified flow realizes
PartyB's positive uPnL, moving funds into its cross pool (or per-PartyA bucket). This is a PartyB action and does not require
PartyA's participation.
Force Close
Why the Step-Based Flow
The original forceClosePosition on ForceActionsFacet performs everything in a single transaction:
validate, compute close price, check solvency, close the position. This worked in isolated mode where settlement was scoped to
one PartyA. In cross mode, settlement needs a unified signature covering multiple PartyAs, and the settlement + close must use
consistent price snapshots. Bundling everything into one transaction with potentially large settlement data would hit gas
limits and complicate the signature flow.
The ForceCloseStepsFacet breaks force close into steps that can be executed in separate transactions.
Additionally, the legacy forceClosePosition explicitly rejects cross-mode PartyBs:
require(!MAStorage.layout().crossModeEnabledForPartyB[partyB], "ForceActionsFacet: Cross partyB mode enabled");
The 3-Step Flow
Step 1 — initializeForceClose(quoteId, HighLowPriceSig)
Validates force-close conditions (quote in CLOSE_PENDING, cooldowns met, order type is LIMIT, close price
reached). Computes the close price with penalty. Checks that PartyA remains solvent after the close. Stores a
ForceCloseDetail snapshot:
closePrice— the computed force-close price (does not change after init)upnlPartyB— PartyB's uPnL from the signaturecurrentPrice— the current market price from the signatureinProgress = true— gates subsequent steps
Step 2 — settleUpnlForForceClose(quoteId, UnifiedSettlementSig, updatedPrices[]) (optional,
repeatable)
Calls settleUpnlUnified with privilegedMode = true, which bypasses the "caller must have a position"
check and settlement cooldowns. The settlement can target any PartyB — not just the one on the force-close quote. Which
positions can be settled depends on the scenario:
-
PartyA lacks funds: Settle PartyA's profitable positions with any other PartyB (
sig.partyB != forceCloseQuote.partyB). This funds PartyA'sallocatedBalancesso the close can proceed. No restriction on which PartyBs or PartyAs are involved. -
PartyB (non-cross) lacks funds: Settle the same PartyB's profitable positions (
sig.partyB == forceCloseQuote.partyB), but only with the force-close quote's PartyA. In isolated mode each per-PartyA bucket is separate, so settling with a different PartyA would fund the wrong bucket. The contract enforces this:require(sig.partyAs.length == 1 && sig.partyAs[0] == forceCloseQuote.partyA). -
PartyB (cross) lacks funds: Settle the same PartyB's positions with any PartyA. Since everything goes to the
address(0)pool, settling with any PartyA funds the same shared bucket.
After settlement, the stored upnlPartyB snapshot is adjusted by the settlement delta (only when
sig.partyB == forceCloseQuote.partyB) so that the finalize step uses consistent numbers:
-
Cross mode:
upnlPartyB += sum(settleAmountsPerPartyA)— all settlement amounts affect the single pool. -
Normal mode:
upnlPartyB += settleAmountsPerPartyA[forceClosePartyAIndex]— only the settlement with the force-close quote's PartyA is relevant.
The timestamp is also advanced, which allows a fresh refreshForceCloseSnapshot to be used later.
Step 3 — finalizeForceClose(quoteId, PairUpnlAndPriceSig)
First refreshes the uPnL/currentPrice snapshot with a fresh Muon signature (ensuring partyA solvency at the latest prices), then closes the position. The close behavior differs by mode:
-
Normal PartyB: Uses
closeQuoteWithReserveFallback. If PartyB is solvent after close, emitsForceClosePosition. If insolvent, tries the reserve vault as a fallback. If the reserve vault covers the deficit, closes the position. If not, triggersliquidatePartyB— the normal isolated-mode PartyB liquidation. -
Cross PartyB: Uses
closeQuoteCrossIgnoringUpnl. First tries to close using the uPnL-based solvency check. If PartyB is insolvent with uPnL, retries withupnlPartyB = 0(ignoring uPnL). If the close is possible ignoring uPnL (i.e., the allocated balance minus locked balances covers the cost), the position closes and is markedCLOSED_INSOLVENT. If even ignoring uPnL is insufficient, the transaction reverts with"Insufficient balance". PartyB is never liquidated during cross-mode force close — the position either closes or the transaction reverts.When the close succeeds but PartyB is marked insolvent, the facet emits both
ForceClosePositionandForceClosePartyBInsolvent. The second event signals to off-chain monitoring that the ClearingHouse should investigate.
Convenience Function
forceCloseAndSettlePositionsUnified combines all three steps in a single transaction: init → settle (if
updatedPrices is non-empty) → finalize. Unlike the step-by-step flow, the finalize here does
not take a fresh PairUpnlAndPriceSig — it uses the uPnL and currentPrice values from the init
signature directly (adjusted by any settlement delta). This works when the settlement data is small enough to fit in one
transaction's gas limit.
Reserve Vault
In normal (isolated) mode, the reserve vault (reserveVault[partyB]) serves as a last-resort fallback during force
close. If PartyB is insolvent after the close but the reserve vault covers the deficit, the deficit is transferred from the
reserve vault into PartyB's allocated balance, and the close proceeds.
In cross mode, the reserve vault is not used during force close. The cross-mode path uses the "ignore uPnL" fallback instead.
Liquidation via ClearingHouse
With cross mode enabled, PartyB liquidation becomes impractical on-chain because there can be thousands of positions and PartyAs involved. The decentralized liquidator flow (used for isolated-mode PartyBs) cannot safely unwind a shared pool across multiple counterparties.
To address this, liquidation of cross-enabled PartyBs is handled by the ClearingHouse, a privileged off-chain
Symmio entity. The ClearingHouse computes liquidation outcomes off-chain and executes balance updates on-chain through the
ClearingHouseFacet. All functions require the CLEARING_HOUSE_ROLE. The ClearingHouse is fully
trusted — liquidateCrossPartyB takes the UPNL and liquidation parameters directly from the ClearingHouse without
requiring a Muon oracle signature.
The ClearingHouse handles two distinct liquidation flows:
- Cross PartyB liquidation — when a cross-mode PartyB becomes insolvent
- PartyA takeover — when a PartyA liquidation gets stuck or corrupted
Both flows, their mechanics, and how they interact with each other are documented in detail in Clearing House.
Why Cross PartyB Liquidation Is Different
When an isolated-mode PartyB got liquidated, only that one PartyA was affected. In cross mode, liquidation of a solver affects all users trading with that solver — all positions are closed. This would be a terrible experience for users and for PartyB itself, because PartyB would need to pay many CVAs. This is why soft liquidation exists: to catch and penalize PartyBs before they reach full insolvency, making actual cross liquidation a rare last resort.