Contract Architecture Overview
Symmio is an intent-based bilateral derivatives protocol. Retail users (PartyA) trade against professional market makers (PartyB), every critical operation is gated by cryptographically verified off-chain signatures, and the whole system is built on the Diamond Standard (EIP-2535), so individual facets can be added, replaced, or removed without changing the diamond's address or disturbing storage.
What was once a single diamond is now a small network of diamonds with clearly separated responsibilities, plus an externalized signature verifier and a privileged operator (the ClearingHouse) for the cases the decentralized flow can't handle. This page is a tour of what lives where, how the account hierarchy is structured, and how a quote moves through the system end to end.
The Diamond contracts
Three diamonds make up the live protocol surface, plus the supporting contracts they coordinate with.
Symmio Core (Perps Diamond). The main diamond for facilitating trades between the trader (PartyA) and the solver (PartyB). It handles sending quotes, locking, opening, closing, settling, and liquidating.
Account Layer. A standalone diamond proxy that replaces the per-frontend MultiAccount contracts. It owns every user's
SubAccountsand deterministicVirtualAccounts, registers affiliates, distributes affiliate fees to stakeholders, supports express deposits, and exposes hooks for custom on-chain logic (NFT mints, loyalty points, cashback).Instant Layer. A diamond that mediates user-signed operations using EIP-712
SignedOperationstructs, flex fields, templates, and a delegation system. Operators withOPERATOR_ROLEsubmit batches; the Instant Layer routes them through the Account Layer's_callso Symmio Core sees the right trading account.
Around them:
MuonSignatureVerifier. External contract, addressed fromGlobalAppStorage.signatureVerifier. Manages multiple TSS public keys and gateway signers with per-MuonFunctionauthorization. See the Muon Architecture page.ClearingHouse. Privileged operator (
CLEARING_HOUSE_ROLE) that handles cross-mode PartyB insolvency and stuck PartyA liquidations. It's trusted to provide its own UPNL and prices; the contract enforces lifecycle invariants.Express Provider / Virtual Provider contracts. Per-chain liquidity contracts that participate in the new Withdraw System.
PledgeFacet vault. Holds PartyB pledge collateral (slashable), with a two-step request-approval withdrawal flow.
Liquidation Insurance Vault. Receives the excess of liquidator profit above
maxLiquidationProfitPerPosition.
Storage
DiamondStorage: contract ownership.GlobalAppStorage: collateral, fee collector, role mappings, pause states, emergency mode, balance limits, and the like.AccountStorage: user balances, allocated balances, locked values (CVA, LF, MM), pending locks, nonces, suspension status, liquidation details.QuoteStorage: quote structs, pending quote arrays, position counts, the quote ID counter.MAStorage: cooldown durations, liquidator share, PartyB registration status, liquidation timestamps, withdraw cooldown period.SymbolStorage: trading symbol configurations including leverage limits, fees, funding rate parameters, and the newsymbolTypemapping.MuonStorage: kept for legacy fields, though the active key set lives inMuonSignatureVerifier.
Account hierarchy: SubAccounts and Virtual Accounts
SubAccounts are the user's top-level organizational unit: each one is bound to a specific frontend (affiliate), a Symmio core instance, and an isolation type chosen at creation. A user can keep one for conservative trades and another for an aggressive book.
Virtual Accounts (VAs) sit beneath SubAccounts and provide position isolation. Each VA is an independent trading address on Symmio Core with its own balance, allocated margin, and positions. A liquidation in one VA can't touch another. When a VA's last position closes, its funds are swept back to the parent SubAccount automatically and the address is recycled.
Funds flow:
addMarginToNextVA lets a frontend (or a delegated service) pre-fund the next VA address before it exists, which is how trigger-market orders pre-fund the VA that the subsequent sendQuoteWithAffiliateAndData will create.
Isolation types
Chosen at SubAccount creation, with a "Single VA mode" toggle for the market-bucketed types:
POSITION: one VA per quote. Maximum isolation; each position has its own margin pool.MARKET: one VA per market. All BTC trades share a VA, all ETH trades share another.MARKET_DIRECTION: one VA per (market, direction). BTC longs share a VA; BTC shorts share a different one.CUSTOM: caller-supplied VA addresses, useful for advanced strategies.
Roles and admin model
Role-based access control is still anchored on DEFAULT_ADMIN_ROLE, but v0.8.5 adds a delegation layer so an admin team doesn't have to bottleneck every grant and revoke through one address.
A roleAdmin for a specific role can grant and revoke that role without touching any other. Combined with the existing hasRole mapping, the system has a clear hierarchy: Owner → Default Admin → Role Admin → Role Holder.
The active roles:
DEFAULT_ADMIN_ROLE
Grant/revoke all roles, set collateral, emergency mode, unsuspend addresses, set fee collector and balance limits, designate role admins
PAUSER_ROLE
Pause global operations, liquidation, accounting, PartyA/PartyB actions
UNPAUSER_ROLE
Unpause any paused function
PARTY_B_MANAGER_ROLE
Register and deregister PartyB addresses; toggle ADL (Auto-Deleveraging) eligibility
SYMBOL_MANAGER_ROLE
Add symbols, configure leverage, trading fees, funding parameters, symbol types
SETTER_ROLE
Configure cooldowns, liquidator share, force close parameters, withdraw cooldown
MUON_SETTER_ROLE
Set the MuonSignatureVerifier address; legacy Muon config
SUSPENDER_ROLE
Suspend addresses and bridge transactions
LIQUIDATOR_ROLE
Decentralized liquidator path (still active for isolated PartyB and standard PartyA)
CLEARING_HOUSE_ROLE
ClearingHouse operator for cross-mode PartyB insolvency and stuck PartyA liquidations
OPERATOR_ROLE (Instant Layer)
Submit executeBatch / executeTemplate
Diamond ownership transfer is two-step: the current owner sets a pending owner, and the pending owner must call acceptOwnership() to complete the transfer. Mistakes are recoverable until the second transaction lands.
Quote lifecycle
Stage 1: Quote creation (PENDING)
PartyA initiates via one of:
sendQuote(partyBsWhiteList, symbolId, positionType, orderType, price, quantity, cva, lf, partyAmm, partyBmm, maxFundingRate, deadline, affiliate, upnlAndPriceSig): the original signature.sendQuoteWithAffiliate(...): kept for compatibility.sendQuoteWithAffiliateAndData(..., bytes data): same assendQuoteWithAffiliate(), but with arbitrarybytesattached to the quote.
Margin components:
CVA (Credit Valuation Adjustment): penalty paid by the liquidated party to the counterparty.
MM (Maintenance Margin):
partyAmmandpartyBmm, considered in liquidation calculations.LF (Liquidation Fee): reward for the liquidator.
Funds lock in pendingLockedBalances[partyA]. The SingleUpnlAndPriceSig Muon signature is required here, unless PartyA is bound to a single PartyB.
Stage 2: Quote locking (LOCKED)
PartyB calls lockQuote(quoteId, upnlSig, increaseNonce). LibMuon.verifyPartyBUpnl checks PartyB's UPNL, LibPartyB.checkPartyBValidationToLockQuote enforces validation rules, and partyBPendingLockedBalances updates. PartyB can release via unlockQuote(), reverting the quote to PENDING or EXPIRED.
Stage 3: Position opening (OPENED)
openPosition(quoteId, filledAmount, openedPrice, upnlSig) supports partial fills: filling 20 of 100 units creates a new quote for the remainder. Trading fees are applied, solvency is checked via LibSolvency.isSolventAfterOpenPosition, and the call requires a PairUpnlAndPriceSig attestation.
In the current version, PartyBGroupActionsFacet adds batch variants:
One Muon signature, many positions. The same idea applies to fillCloseRequests. This keeps solver response time low under heavy load.
Stage 4: Position closing
PartyA requests closure via requestToClosePosition(quoteId, closePrice, quantityToClose, orderType, deadline), moving the quote to CLOSE_PENDING. PartyB executes via fillCloseRequest(quoteId, filledAmount, closedPrice, upnlSig). LIMIT orders allow multiple partial fills; MARKET orders require full execution. Both check solvency via LibSolvency.isSolventAfterClosePosition.
fillCloseRequestToLiquidation: when a normal close would push PartyA into insolvency, this calculates the maximum partial close that brings them exactly to the liquidation threshold.ADL Close: PartyBs (enabled with
PARTY_B_MANAGER_ROLE) can unilaterally close to reduce risk exposure, with no solvency check on themselves. Backed by pledge collateral that can be slashed if ADL is abused.
Cancellation
The cancellation transitions are unchanged: requestToCancelQuote (PENDING → CANCELED, or LOCKED → CANCEL_PENDING), acceptCancelRequest (PartyB accepts), requestToCancelCloseRequest (CLOSE_PENDING → CANCEL_CLOSE_PENDING), acceptCancelCloseRequest (returns to OPENED).
Liquidation
PartyA liquidation (4-step)
Trigger condition: total UPNL < -(allocated_balance - mm - pending_locked_values).
liquidatePartyA(partyA, upnlSig): records the liquidation timestamp andLiquidationDetail.setSymbolsPrice(partyA, priceSig): establishes liquidation prices for all position symbols.liquidatePendingPositionsPartyA(partyA): clears unlocked quotes.liquidatePositionsPartyA(partyA, quoteIds[]): liquidates the specified open positions.
The LiquidationDetail captures liquidationId, liquidationType (NORMAL / LATE / OVERDUE), upnl, totalUnrealizedLoss, deficit, liquidationFee, timestamp, involvedPartyBCounts, partyAAccumulatedUpnl, and a disputed flag.
Per-position liquidator profit is capped at maxLiquidationProfitPerPosition; the excess flows to a configured Liquidation Insurance Vault that the protocol can reuse to reimburse PartyBs hit by late-liquidation losses.
In LATE and OVERDUE PartyA liquidations, pending fee reimbursements are routed to a ClearingHouse-controlled escrow rather than back to PartyA. Deferred excess balance (funds added after the historical insolvency point) still goes back to PartyA.
When the on-chain state is too inconsistent for the dispute resolver to fix, the ClearingHouse can take over the liquidation entirely, reset the state, and finish it with its own price feed.
PartyB liquidation
Isolated-mode PartyB uses a 2-step flow:
liquidatePartyB(partyB, partyA, upnlSig).liquidatePositionsPartyB(partyB, partyA, priceSig).
Cross-mode PartyB doesn't use that flow. Because cross mode pools all collateral into a single address(0) bucket shared across PartyAs, a single insolvency event affects everyone with positions against that PartyB. The ClearingHouse runs the unwind:
Settlement requires all positions closed and the deallocated pool fully distributed. The ClearingHouse provides UPNL and prices directly; the contract trusts the operator and enforces lifecycle invariants.
Soft liquidation
For PartyBs, the protocol supports a tiered warning system before hard liquidation. As balance approaches the danger zone, the ClearingHouse can issue on-chain notices and small penalties at configurable thresholds, giving the solver time to top up funds instead of disrupting every user on every frontend with an immediate close-all.
Cross-mode settlement during PartyA liquidation
In cross mode, a PartyB's solvency includes unrealized profit from all its other counterparties, so its raw allocated balance can be much lower than the value it actually controls. settlePartyBUpnlForLiquidation (on SettlementFacet) lets a liquidator realize a cross-mode PartyB's UPNL from positions with other (non-liquidated) PartyAs before the PartyA liquidation settlement draws funds. Cross-mode PartyBs with insufficient raw balance revert until this step runs; isolated PartyBs keep the prior capped-payment behavior.
Funding rate mechanics
In the previous version, funding was charged per quote, so PartyB had to process every individual position at each epoch, O(quotes). The current version uses an accumulated weighted average at the symbol level.
When a solver sets a new rate, it blends into the running average. Rates are pre-multiplied by market price at storage time, so the calculation doesn't need a price lookup. Dynamic epoch-duration changes are supported with automatic rate scaling.
Aggregated positions track running totals of size and notional per (party, symbol, positionType), so UPNL calculations iterate over active symbols instead of quotes. For a solver with 5,000 positions across 50 symbols, solvency goes from O(5000) to O(50). Aggregate funding debt is tracked the same way.
FundingRateFacet.chargeFundingRate(partyA, quoteIds[], rates[], upnlSig) and chargeAccumulatedFundingFee(...) apply periodic adjustments, with timing controlled by symbol-specific fundingRateEpochDuration and fundingRateWindowTime.
Withdraw System
Withdrawals draw only from an account's free (deallocated) balance, so the flow has a mandatory first step:
initiateWithdraw requires balances[user] >= amount and reverts with "WithdrawFacet : Insufficient balance" otherwise — funds still allocated to a position can't be withdrawn. Use removeMargin(virtualAccount, amount, upnlSig) to deallocate and sweep margin back to the parent SubAccount before initiating.
A withdrawal request is composed of one or more receiver parts, each routed through one of four withdrawal paths.
Cooldown is anchored to the user's last deallocation, not to the withdrawal initiation — because deallocation is what produces the withdrawable balance in the first place:
Later deallocations don't reset the cooldown of an already-initiated withdrawal. getWithdrawableTime(user) returns the earliest time a withdrawal initiated now could finalize. Express providers front the funds for a fee. Virtual providers deliver cross-chain; they implement lifecycle callbacks (onWithdrawRequest, acceptWithdrawRequest, onWithdrawComplete) that the protocol calls during the lifecycle. Cancellation has its own state machine, with a blackout window for in-flight provider acceptances and an admin force-cancel.
Normal (Classic)
✗
✗
Standard 12h cooldown, same-chain delivery
Pure Express
✓
✗
Instant same-chain withdrawal
Pure Virtual
✗
✓
Standard cooldown, cross-chain delivery
Virtual Express
✓
✓
Instant cross-chain withdrawal
Cooldown is anchored to the user's last deallocation, not to the withdrawal initiation:
Later deallocations don't reset the cooldown of an already-initiated withdrawal. getWithdrawableTime(user) returns the earliest time a withdrawal initiated now could finalize. Express providers front the funds for a fee. Virtual providers deliver cross-chain; they implement lifecycle callbacks (onWithdrawRequest, acceptWithdrawRequest, onWithdrawComplete) that the protocol calls during the lifecycle. Cancellation has its own state machine, with a blackout window for in-flight provider acceptances and an admin force-cancel.
Last updated

