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

Muon Architecture

Muon is the off-chain computation and signing layer that feeds Symmio with verified data. Every state-changing operation that depends on an external market passes through Muon. The off-chain TSS network calculates the data, signs it, and a gateway node relays it; the on-chain protocol verifies both the threshold signature and the gateway's ECDSA signature before accepting the call.

In our current version the Muon layer keeps the same dual-signature model but pulls its trust set out of the diamond. Instead of one TSS public key and one gateway address baked into MuonStorage, the protocol now consults a standalone MuonSignatureVerifier contract that supports multiple keys, multiple gateway signers, key rotation without downtime, and per-category authorization.

Why Symmio needs an oracle at all

Symmio is a bilateral derivatives protocol. At any moment, the contract needs to know:

  • Current prices for the trading symbols involved.

  • Unrealized PnL for PartyA and PartyB.

  • Whether each party is solvent enough to execute the requested action.

Calculating UPNL on-chain would require iterating every open position and fetching every price which would be expensive to do on most networks. Muon performs the calculation off-chain and ships back a signed attestation that the contracts verify cheaply. The protocol's correctness rests on two things: that the math the network performs is correct, and that the signatures can't be forged or replayed. The dual-layer verification model exists to make the second condition robust to a partial compromise.

Dual-layer signature verification

Symmio doesn't trust a single signer. Every Muon signature passes through two independent layers before the contract accepts it.

Layer 1: TSS (Threshold Signature Scheme)

Muon operates a distributed network of nodes. When a signature request comes in, multiple nodes independently fetch price data, calculate the requested values (UPNL, solvency, etc.), and contribute to a threshold signature. The result is a Schnorr signature that proves a threshold of nodes agreed on the data. The contract verifies this Schnorr signature against the registered TSS public keys.

In the current version (0.8.5), the verification iterates over all registered public keys in MuonSignatureVerifier. If any one of them validates the Schnorr signature, the TSS check passes. This is what makes seamless key rotation possible; old and new keys can coexist during a transition window with no downtime.

Layer 2: Gateway ECDSA

After the TSS check, the contract recovers the signer from a standard ECDSA signature on the same hash and matches it against the list of registered gateway signers. The gateway is the Muon node that relayed the TSS-signed data to the user. The second signature is what prevents a compromised TSS key from being exploited without simultaneously compromising a gateway node.

Per-category authorization

After a matching TSS key or gateway signer is found, the verifier also checks that the key/signer is explicitly authorized for the MuonFunction category being verified. The categories are:

A key authorized only for Trading cannot produce valid signatures for LiquidationPartyA. Permissions are opt-in, so newly added keys and gateway signers start with no permissions and cannot validate any signatures until explicitly granted via setPublicKeyPermissions or setGatewaySignerPermissions (both restricted to SETTER_ROLE on the verifier).

The MuonSignatureVerifier contract

The verifier lives at contracts/helpers/verification/SymmioSignatureVerifier.sol (the contract itself is named MuonSignatureVerifier) and is deployed as an independent contract, not part of the diamond. The diamond stores its address in GlobalAppStorage.signatureVerifier and LibMuon.verifyTSSAndGateway delegates to it. The interface:

Verification flow inside the contract

Key rotation procedure

  1. addPublicKey(newKey) on the verifier.

  2. setPublicKeyPermissions(newKey, [...], true) to authorize it for the required functions.

  3. Both old and new keys now validate Schnorr signatures — no downtime.

  4. Once all Muon nodes have switched to the new key, removePublicKey(oldKey).

The same flow applies independently to gatewaySigners.

What gets signed

Different operations require different signature payloads. Two things are worth separating clearly:

  • Struct fields: what the caller actually passes in the Sig struct (defined in MuonStorage.sol). Every struct carries reqId, timestamp, a gatewaySignature, and the sigs (SchnorrSign), plus the UPNL/price values listed below.

  • Hashed inputs: the contract reconstructs the signed keccak256 hash inside the LibMuon* functions. The hash additionally binds values that are not struct fields: muonAppId, address(this), the relevant party address(es), the on-chain nonce, chainId, and (for price sigs) the symbolId. These come from chain state or function arguments, not from the struct.

SingleUpnlSig

Used for: deallocate() (MuonFunction.AccountManagement), deallocateForPartyB() / transferAllocation(), liquidatePartyB() (MuonFunction.LiquidationPartyB).

SingleUpnlAndPriceSig

Used for: sendQuote(). This is the only signature sendQuote accepts.

SingleUpnlWithPendingBalanceSig

Muon signature attesting to a party's UPNL plus their pending withdrawal balance.

PairUpnlSig

Used for: funding operations: chargeFundingRate() and chargeAccumulatedFundingFee().

PairUpnlAndPriceSig

Used for: openPosition(), fillCloseRequest(), and emergencyClosePosition() (MuonFunction.Trading). PairUpnlSig data plus a single symbol price; symbolId is supplied as a function argument and enters the hash.

PairUpnlAndPricesSig (batch)

Used for: openPositions(), fillCloseRequests() (MuonFunction.Trading). Carries arrays of symbolIds and prices for the batched quotes, but the UPNL values are two aggregates per party (upnlPartyA, upnlPartyB).

LiquidationSig

Used for: liquidatePartyA() and setSymbolsPrice() (MuonFunction.LiquidationPartyA). Not used for PartyB liquidation — liquidatePartyB() uses SingleUpnlSig.

Replay protection via nonces

Muon signatures include two distinct identifiers that serve different purposes.

reqId (Muon Request ID)

Each Muon request generates a unique reqId that's included in the signed hash.

Contract nonces

The contract maintains nonce counters in AccountStorage for each PartyA / PartyB. Muon reads the current on-chain nonce when it signs, includes it in the hash, and the contract verifies that the signed nonce matches the current on-chain value. After a successful state-changing call, the relevant nonce increments making any older signature invalid.

In the current version, cross-mode (cross-PartyB) solvers will automatically sign with nonce = 0 for parallel operations. Sequential nonces remain available where ordering matters.

Signature expiration

Signatures have a limited validity window defined by upnlValidTime in MuonStorage. This prevents an old signature from being reused after market conditions have moved. The window is measured against the timestamp field in the signed payload, not the block timestamp at submission, so a signed message that sits in a mempool for too long simply expires rather than landing on stale state.

Oracle-less trading: bypassing Muon for bound pairs

When a PartyA trades exclusively with a single PartyB, the dual-Muon trust path is overkill. A malicious PartyB can't harm a different PartyA because there is no different PartyA. The current version lets a PartyA opt into this by binding to a specific PartyB via bindToPartyB. Once bound (and while that PartyB is bindable), the contract skips both the Muon signature check and the on-chain solvency check for the trading path against that PartyB.

Binding is also the gate for instant trading: activateInstantActionMode requires BindStatus.BOUND.

In bound mode the trading functions still take the full Sig struct, and they still read the numeric fields. What is not used are the cryptographic fields (reqId, gatewaySignature, and the sigs Schnorr signature), since the verify call is skipped entirely; callers leave those empty. So no valid Muon signature is required.

Binding requires a clean state:

  • No pending quotes (zero pending locked balance).

  • No open positions with any other PartyB.

Unbinding is a soft, time-windowed flow:

  • requestToUnbindFromPartyB — moves the relationship to PENDING_UNBIND, records a timestamp.

  • cancelUnbindRequest — restore to BOUND if the user changes their mind.

  • completeUnbindRequest — finishes the unbind. PartyB can complete immediately. Anyone else (including PartyA) must wait out the cooldown.

Last updated