For the complete documentation index, see llms.txt. This page is also available as Markdown.

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 SubAccounts and deterministic VirtualAccounts, 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 SignedOperation structs, flex fields, templates, and a delegation system. Operators with OPERATOR_ROLE submit batches; the Instant Layer routes them through the Account Layer's _call so Symmio Core sees the right trading account.

Around them:

  • MuonSignatureVerifier. External contract, addressed from GlobalAppStorage.signatureVerifier. Manages multiple TSS public keys and gateway signers with per-MuonFunction authorization. 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 new symbolType mapping.

  • MuonStorage: kept for legacy fields, though the active key set lives in MuonSignatureVerifier.

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:

Role
Permissions

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 as sendQuoteWithAffiliate(), but with arbitrary bytes attached to the quote.

Margin components:

  • CVA (Credit Valuation Adjustment): penalty paid by the liquidated party to the counterparty.

  • MM (Maintenance Margin): partyAmm and partyBmm, 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 (PENDINGCANCELED, or LOCKEDCANCEL_PENDING), acceptCancelRequest (PartyB accepts), requestToCancelCloseRequest (CLOSE_PENDINGCANCEL_CLOSE_PENDING), acceptCancelCloseRequest (returns to OPENED).

Liquidation

PartyA liquidation (4-step)

Trigger condition: total UPNL < -(allocated_balance - mm - pending_locked_values).

  1. liquidatePartyA(partyA, upnlSig): records the liquidation timestamp and LiquidationDetail.

  2. setSymbolsPrice(partyA, priceSig): establishes liquidation prices for all position symbols.

  3. liquidatePendingPositionsPartyA(partyA): clears unlocked quotes.

  4. 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:

  1. liquidatePartyB(partyB, partyA, upnlSig).

  2. 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.

Type
Express
Virtual
Description

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