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
addPublicKey(newKey)on the verifier.setPublicKeyPermissions(newKey, [...], true) to authorize it for the required functions.
Both old and new keys now validate Schnorr signatures — no downtime.
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
Sigstruct (defined inMuonStorage.sol). Every struct carriesreqId,timestamp, agatewaySignature, and thesigs(SchnorrSign), plus the UPNL/price values listed below.Hashed inputs: the contract reconstructs the signed
keccak256hash inside theLibMuon*functions. The hash additionally binds values that are not struct fields:muonAppId,address(this), the relevant party address(es), the on-chainnonce,chainId, and (for price sigs) thesymbolId. 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 toPENDING_UNBIND, records a timestamp.cancelUnbindRequest— restore toBOUNDif the user changes their mind.completeUnbindRequest— finishes the unbind. PartyB can complete immediately. Anyone else (including PartyA) must wait out the cooldown.
Last updated

