Build a Perps DEX on SYMMIO
Ship a perpetuals trading UI on @symmio/trading-react: connect a wallet, pick a market, open and close leveraged positions, manage TP/SL. This page maps each part of the app to the exact hooks, providers, and the gotchas that break integrations. Follow the cross-links for per-hook reference and snippets.
Architecture
A perps DEX is five parts plus a shared shell. Distribute them however you like — one dense trading page, or a route per part.
| Part | What it does | Primary hooks |
|---|---|---|
| Market selection | Discover and switch markets — header + searchable list. | useMarkets |
| Trade form | Side, size, leverage, TP/SL, slippage, submit. | useInstantOpenAuto, useInstantOpenWithTpSl, useLockedParams, useFeeForUser |
| Positions | Live open positions, per-row uPnL, close. | useManagedQuotes, useQuoteUpnlAndPnl, useInstantCloseAuto |
| Collateral | Available vs allocated balance, deposit / allocate. | useAccountBalanceOf, useAccountBalanceInfo, useAllocate, useDeallocate |
| TP/SL | Take-profit / stop-loss per position. | useQuoteTpSl, useSetQuoteTpSl |
The shell — wallet connection and the SubAccount picker — is shared by every part and surfaces once: useWalletAccount, useConnectWallet, useDisconnectWallet, useSwitchToSymmioChain, useUserSubAccounts.
A common split is trade (market + form + chart + positions) on one page, portfolio (collateral + history) on another, and TP/SL either inline in a position row or on its own screen. The guide documents each part standalone — nothing forces the split.
Page layout
Three rules that hold regardless of layout:
- Keep the market in the URL —
/trade/BTCUSDT,/perps/BTC-USDT, or?symbol=BTCUSDT. An addressable market survives refresh, back-button, and shared links; component state loses all three. - The header opens a searchable selector, not a
<select>. Back it withuseMarkets().dataand let the user filter by symbol. - Trade-form field order is fixed: Side → Margin → Leverage → TP/SL → Slippage → Submit.
The chart is its own guide. See Lowcap Chart. Option A — a DexScreener <iframe>
keyed off the market’s token_address via useEnigmaPriceServiceMetadata — is a real candlestick chart with
no extra dependency. Reach for Option B (a charting library + OHLC feed) only when you want a native,
themeable chart.
Setup
pnpm add @symmio/trading-core @symmio/trading-react @symmio/session-key @symmio/utils \
@tanstack/react-query wagmi @wagmi/connectors viem next react react-domwagmi, viem, and React are peer dependencies of @symmio/trading-react — your app owns them and the SDK adopts whatever you install within range. Satisfy the range; you don’t pin an exact version. (next is not an SDK peer — it’s your app’s choice — but Next 16 is required in practice; see below.)
| Package | Version |
|---|---|
wagmi | ^3.4.2 |
@wagmi/connectors | ^8 |
viem | ^2.49.0 |
next | 16.x |
react / react-dom | ^19.2.7 |
Keep a single wagmi copy. The SDK shares wagmi’s React context with your WagmiProvider, so there must be exactly
one wagmi in node_modules — a clean single-project install gives you that automatically. If two ever end up there,
every wallet hook throws useConfig must be used within WagmiProvider; dedupe to one copy with your package manager’s
override field (overrides in npm/pnpm, resolutions in yarn). It usually happens when a transitive dependency pulls
a different wagmi version.
Next 16, not 15. wagmi 3.x imports from viem/tempo; the Next 15 bundler fails to tree-shake it and emits
Actions.wallet.send is not exported from 'viem/tempo' at build. Next 16 handles the export map correctly. On Next 15
you must add wagmi to transpilePackages. Either way, keep @symmio/trading-react in transpilePackages — it
ships uncompiled TSX.
Run local dev on http://localhost:3000. Some SYMMIO backend services allow-list the request origin for CORS; a
dev server on a different port (or host) has its API and WebSocket calls rejected with a CORS error. Set your dev port
to 3000 (next dev -p 3000), or serve behind a proxy that presents that origin.
Import paths
Runtime (hooks, providers, selectors, calc helpers) comes from @symmio/trading-react. Chain config and quote value-types come from @symmio/trading-core.
@symmio/trading-react—SymmioProvider,useSymmioConfig,useSymmioChainId, everyuse*hook, the delegation selector constants, the pure calc helpers (calculateTradeParams,validateInstantOpenAgainstMarket, …), and the enumsPositionType/OrderType/QuoteStatus/NotificationType.@symmio/trading-core—SymmioSupportedChainId,CreateConfigParameters,SymbolContractSymbol,UnifiedQuote,QuoteLifecycle,SubAccountDetail,SubAccountIsolationType,getChainConfig, and the other chain-config / quote types.
SymmioSupportedChainId, UnifiedQuote, QuoteLifecycle, and the chain-config types (getChainConfig,
CreateConfigParameters, SymbolContractSymbol) are not re-exported from @symmio/trading-react — import them
from @symmio/trading-core, or you get undefined at runtime and a build-time type error. The plain enums
PositionType / OrderType / QuoteStatus are re-exported from react, so either path works there.
Providers
Nest WagmiProvider → QueryClientProvider → SymmioProvider. The SDK reads both wagmi and TanStack Query from context.
-
WagmiProvider— yourcreateConfig({ chains, transports, connectors })fromwagmi. The host owns connectors and RPCs; the SDK never picks them. -
QueryClientProvider— onenew QueryClient(), held inuseStateso it survives remounts. -
SymmioProvider— props arechainOverrides,defaultChainId,getWalletClient(pluschildren). There is noconfigoraffiliateAddressprop. Set your affiliate throughchainOverrides, which deep-merges onto the SDK’s built-in per-chain config:import { SymmioProvider } from "@symmio/trading-react"; import { SymmioSupportedChainId, type CreateConfigParameters } from "@symmio/trading-core"; const chainOverrides: CreateConfigParameters["chainOverrides"] = { [SymmioSupportedChainId.HYPER_EVM]: { // affiliate earns a fee cut on trades routed through your UI — set before shipping addresses: { affiliatesAddress: "0xYourAffiliate…" }, }, }; <SymmioProvider chainOverrides={chainOverrides} getWalletClient={getWalletClient}> {children} </SymmioProvider>;
getWalletClient is the resolver the SDK calls to sign each write. For instant trading it returns a session-key viem client when the requested from matches your loaded session key, and falls back to the connected wallet otherwise (see §4). Omit it and every instant open/close falls back to a per-trade wallet popup. See the SymmioProvider reference for the full prop list.
Mount the provider tree client-only. WagmiProvider + SymmioProvider read browser state and must not
server-render. Under SSG/SSR, next build fails prerendering /_not-found (and every static route) with the same
useConfig must be used within WagmiProvider error — this time from the server, not a duplicate copy. Gate the
tree on mount (hold a mounted flag, return null until a useEffect sets it) so the wallet stack renders only in
the browser.
The gate ladder
A trading page reveals one call-to-action at a time. Each gate hides everything below it:
Connect wallet ← useWalletAccount.isConnected
Switch to HyperEVM ← useWalletAccount.isOnExpectedChain
Select SubAccount ← your SubAccount picker
Deposit collateral ← useAccountBalanceOf > 0
Initialize session key ← @symmio/session-key
Grant delegation ← useIsDelegationActive ×3, useGrantDelegation
Open position ← trade form rendersThe sections below follow this order.
1. Wallet + chain
Every trading page starts with the same guard — connected, on the right chain, then render.
useWalletAccount→{ address, chainId, isConnected, isReconnecting, isOnExpectedChain }.isOnExpectedChainsaves comparing chain IDs by hand.useConnectWallet→{ connectors, connect, status, error }. Connectors come from your wagmi config.useDisconnectWallet→{ disconnect }.useSwitchToSymmioChain→{ switchChain, status, error }. Targets the SDK’s chain — no hardcoded id.
A single WalletButton renders three states off useWalletAccount(): not-connected, wrong-chain, connected.
2. SubAccount
SYMMIO trades happen against a SubAccount address, not the EOA. Every trading page needs a picker.
useUserSubAccounts— takes{ user }, returns the SubAccount list (accountAddress,name, …). The query auto-disables whileuserisundefined, so passing the connected address directly is safe — no manualenabledguard.
Auto-disable-on-undefined-address applies to every SDK hook that takes an account — useAccountBalanceOf,
useAccountBalanceInfo, useManagedQuotes, and the rest.
3. Collateral
The diamond keeps two balances per SubAccount. The instant (lowcap) flow this guide describes funds from available.
- Available (deallocated) — deposited collateral idle on the account. Read via
useAccountBalanceOf({ account }→bigint, 18 decimals). Instant open consumes this directly. - Allocated — collateral moved into the classic-flow pool where CVA + LF locks sit. Read via
useAccountBalanceInfo({ account }→{ allocatedBalance, lockedCVA, lockedLF, lockedPartyAMM, lockedPartyBMM, … }).
Show both. Wire the buttons to:
useAllocate— available → allocated.useDeallocate— allocated → available. Fetches a fresh Muon uPnL signature for you. Subject to an on-chain debounce — a rapid second deallocate reverts.
Gate trading on available, not allocated. Instant open pulls from useAccountBalanceOf (available).
useAllocate moves funds into the classic-flow pool and actually reduces what an instant open can use, so
the fund gate is useAccountBalanceOf > 0 — the submit button says Deposit collateral, not Allocate.
Amounts are 18-decimal raw units. Convert user input with parseEther (viem) or the SDK’s re-exported
parseTokenAmount from @symmio/utils/amounts.
4. Session key + delegation
Every instant open / close / margin-add is signed by a session key — a hot signer the user grants scoped, time-boxed permission to. Without it, every trade prompts the wallet. Without an active delegation, the on-chain call reverts.
Flow: mint or load a session key with @symmio/session-key (its SessionKeyManager owns the keystore) → check the delegation for each required selector → if any is missing, prompt one wallet transaction that grants all three → render the trade form only once all are active.
Selectors
From @symmio/trading-react:
| Constant | Grants |
|---|---|
SEND_QUOTE_WITH_AFFILIATE_AND_DATA_SELECTOR | open a position |
REQUEST_TO_CLOSE_POSITION_SELECTOR | close a position |
ADD_MARGIN_TO_NEXT_VA_SELECTOR | top-up margin |
INSTANT_TRADE_REQUIRED_SELECTORS | all three, in the order grantDelegation expects |
Read + grant
useIsDelegationActive({ account, delegate, selector })→boolean. Call once per selector (account= SubAccount,delegate= session-key address). Compose them:allActive = addMarginActive && sendQuoteActive && closePositionActive.useDelegationExpiry({ account, delegate })→bigintexpiry timestamp, so you can render “expires in 6 days” and re-grant early.useGrantDelegation()— mutation. Variables:{ account: { addr: subAccount, isPartyB: false }, delegatedSigner: sessionKeyAddress, selectors: INSTANT_TRADE_REQUIRED_SELECTORS, expiryTimestamp }.expiryTimestampis Unix seconds (a 24h–30d TTL keeps returning users from re-granting). On success the threeuseIsDelegationActivequeries auto-invalidate and the gate flips totrue.
Missing delegation is the #1 “why did my open silently fail?” bug. The hedger accepts the session-key payload, but
the on-chain call reverts if the SubAccount hasn’t delegated the selector — after the spinner has ended. Always gate
the trade form on allActive === true.
TP/SL needs a second delegation
TP/SL requests are signed by the session key, but the on-chain execution when a trigger fires is done by the solver’s COH wallet, not the session key. The SubAccount must delegate to two signers:
- Session key →
INSTANT_TRADE_REQUIRED_SELECTORS(open, close, add margin). - COH wallet →
REQUEST_TO_CLOSE_POSITION_SELECTOR(executes the trigger).
Read the COH address from config — config.getChainConfig().solver.tpsl?.cohWalletAddress — then check and grant it exactly like the session-key delegation. A user can trade with only the session-key delegation, but cannot set TP/SL without the COH delegation. Gate the TP/SL panel on cohActive === true.
Reference: apps/web/src/features/integration/instant-open-flow.tsx and tpsl-flow.tsx.
5. Market data
useMarkets— the solver catalog (SymbolContractSymbol[]) with precision, tick, and symbol metadata. Cached and deduped by TanStack Query — call it in any component. Prefer overuseOnchainContractMarketsfor anything user-facing.useLockedParams— CVA / LF percentages at a given leverage. Renders “at 10x you lock X USDC” hints.useNotionalCapBySymbolId— remaining notional the solver allows. Gate order size on this; the solver rejects over-cap opens.useOpenInterestBySymbolId/useNotionalCapAll— used-vs-cap per market, and all caps in one call for a table.
SymbolContractSymbol is generated: every field is snake_case and optional. There is no maxLeverage / id /
pricePrecision. Read market.symbol_id, market.name, market.max_leverage, market.price_precision,
market.quantity_precision, market.token_address, market.lot_size — and always guard with a default
(Number(market.max_leverage ?? 50)), since any field can be absent. When you pass a market bag into an instant
action, its id is Number(row.symbol_id).
6. Open a position
Two orchestrators cover the open flow:
useInstantOpenAuto— one-line open. Fetches market metadata, mark price, locked params, and fees inline, signs, and POSTs. Returns{ tempQuoteId }(a negative int) that pins the pending row until it anchors on-chain. Use when the form has no TP/SL row.useInstantOpenWithTpSl— same, plus atomically attaches TP/SL against the returnedtempQuoteId. Passtpsl: undefinedwhen both legs are empty.
Both mutate with { from, subAccountAddress, market: { id }, positionType, initialMargin, leverage, slippage, tpsl? }. from is the session key. market.id is Number(row.symbol_id). positionType is PositionType.LONG / PositionType.SHORT.
For a low-latency path (pre-fetch on mount, one-shot submit), use useInstantOpen with a cached prepareInstantOpenParams — same inputs, you own the fetch.
Form details that matter:
- Side toggle is a colored segmented control (Long / Short), not a dropdown.
- Leverage bounds read from
market.max_leverage, never a hardcoded value. useLockedParams({ symbol, leverage })drives a live “Locks CVA X% + LF Y%” hint.- Slippage is a chip row (
0.1 / 0.5 / 1 / custom), never a raw number field. - The submit button is smart-labeled from a single gate (see the gate ladder).
Available margin (the Max chip)
Raw available balance is not the safe max — the request must survive fees and, for SHORT, worst-case slippage fill. Shave it before writing to Max:
available = balance
× max(0, 1 − slippageFactor) // slippageFactor = slippage on SHORT, 0 on LONG
× max(0, 1 − leverage × (openFee + closeFee))- Fees are charged on leveraged notional — subtract
leverage × (openFee + closeFee). - Slippage (SHORT only) — quantity is sized off
markPrice × (1 − s); a closer fill inflates notional, so cap usable balance atbalance × (1 − s). LONG fills deflate notional, so no extra cap.
Inputs: balance = useAccountBalanceOf (1e18), openFee/closeFee = useFeeForUser (18-dec fixed-point), slippage as a wei fraction (slippage × 1e16). Do it all in BigInt with ONE_E18 = 10n ** 18n; formatUnits(_, 18) only at display time.
No SDK hook exposes this yet — copy the availableMarginWei memo from
apps/web/src/features/integration/open-position-step.tsx.
Pre-submit validation
Two pure helpers run the same math the solver runs, so you can block a doomed submit. Both are in @symmio/trading-react.
calculateTradeParams({ markPrice, slippage, positionType, userInput, inputField, leverage, pricePrecision, quantityPrecision, cvaPercent, lfPercent, partyAmmPercent, partyBmmPercent })→{ quantity, requestedOpenPrice, cva, lf, partyAmm, partyBmm }as decimal strings, ornullif an input is missing. The*Percentvalues come fromuseLockedParams; precisions from the market row.validateInstantOpenAgainstMarket({ market, quantity, markPrice, cva, lf, partyAmm, notionalCap?, positionType? })→{ ok, violations }. Each violation carries actual-vs-required numbers.
| Constraint | Violation kind | Meaning |
|---|---|---|
min_acceptable_portion_lf | LF_PORTION_TOO_LOW | lf / (cva + lf + partyAmm) below the market floor. |
min_acceptable_quote_value | QUOTE_VALUE_TOO_LOW | Locked margin sum below the market floor. |
max_notional_value | NOTIONAL_TOO_HIGH | markPrice × quantity above the cap. |
min_notional_value | NOTIONAL_TOO_LOW | markPrice × quantity below the floor. |
max_quantity | QUANTITY_TOO_HIGH | Leveraged quantity above the cap. |
lot_size | QUANTITY_BELOW_LOT_SIZE | Leveraged quantity below lot_size. |
lot_size | QUANTITY_NOT_LOT_MULTIPLE | Leveraged quantity not a lot_size multiple. |
notionalCap | CAP_REACHED | Notional exceeds the side’s liquidity (pass positionType). |
Pattern: on every input change run calculateTradeParams → if non-null run validateInstantOpenAgainstMarket → gate submit on violations.length === 0 && !exceedsAvailable → render violations inline. Each constraint fires only when the market publishes that field, so a partial constraint set still validates the rest.
7. Positions & close
useManagedQuotes is the one abstraction for the positions table. It reconciles four sources — on-chain reads, pending instant-opens, pending instant-closes, and the notifications WebSocket — into a reactive UnifiedQuote[], and accelerates polling during in-flight transitions.
- Inputs:
{ partyA, live?, extraAccounts?, chainId? } - Outputs:
{ quotes, byKey, isLoading, isFetching, socketStatus, error }
Per-row: useQuoteUpnlAndPnl → { upnl, upnlPercent, markPrice }; useInstantCloseAuto to close; useQuotePlatformFee for a fee estimate.
Pending rows carry tempQuoteId (negative int) until the solver anchors them and assigns an on-chain quoteId. Pick
quote.quoteId ?? BigInt(quote.tempQuoteId) for actions — the WS handler links the two once the anchor lands.
Closing is WS-authoritative
A successful instant-close POST means only that the hedger accepted the request — the position is not closed yet. The on-chain settle (and the balance change) lands later over the notifications WebSocket, which useManagedQuotes folds into each UnifiedQuote.lifecycle. Drive the row from the lifecycle, not from mutation.isSuccess.
Close stages (QuoteLifecycle): OPTIMISTIC_CLOSE → CLOSE_PRICE_FILLED → WRITE_ONCHAIN_CLOSE → CLOSING → CLOSED. A row is “Closing…” while its lifecycle is any of the first four:
const CLOSING_STAGES = [
QuoteLifecycle.OPTIMISTIC_CLOSE,
QuoteLifecycle.CLOSE_PRICE_FILLED,
QuoteLifecycle.WRITE_ONCHAIN_CLOSE,
QuoteLifecycle.CLOSING,
];
const isClosing = CLOSING_STAGES.includes(quote.lifecycle);Settlement is self-describing — no manual “done” flag:
- Full close → the quote leaves the active set; the row disappears from
useManagedQuotes. - Partial close → same row (same
key/quoteId), lifecycle returns toONCHAIN,openQuantity(quantity − closedAmount) shrinks. - Failed close → lifecycle returns to
ONCHAINunchanged. Reading the spinner off the lifecycle means it clears on failure too — no stuck “Closing…”.
There is no optimistic close seed. Unlike open (which seeds useOptimisticQuotesStore at mutate time), a close
does not flip the lifecycle synchronously — OPTIMISTIC_CLOSE appears only after the hedger’s pending-close feed or a
WS frame lands. Bridge that gap with a local per-quote flag set on close success and cleared once
isClosing(quote) takes over, plus a short failsafe timeout.
For a partial close, scale the amount in wei off the remaining openQuantity (basis points, floored) and pass the SDK a decimal string — it clamps to market.quantity_precision:
const remaining = quote.openQuantity; // wei (18-dec)
const closeWei = percent >= 100 ? remaining : (remaining * BigInt(Math.round(percent * 100))) / 10_000n;
// useInstantCloseAuto().mutate({ …, quantityToClose: formatUnits(closeWei, 18) })Refetch margin after settle
Neither open nor close changes the SubAccount balance at POST time — margin locks/unlocks on the settle notification. Subscribe with useNotifications({ account: subAccount }) and, on a NotificationType.SUCCESS frame whose lastSeenAction is a settle action, invalidate the balance queries so Available / Allocated / the trade-form Max all refetch:
// open anchored (margin locked) + close filled (margin unlocked) — NOT the earlier request/price-fill frames
const SETTLE = new Set([
"SendQuoteTransaction",
"SendQuote",
"FillLimitOrderOpen",
"FillMarketOrderInstantClose",
"FillLimitOrderClose",
]);
// onNotification(n): if (n.type === NotificationType.SUCCESS && SETTLE.has(n.lastSeenAction ?? "")) {
// queryClient.invalidateQueries({ queryKey: ["getAccountBalanceOf"] });
// queryClient.invalidateQueries({ queryKey: ["getAccountBalanceInfo"] });
// }The balance query-key factories are not re-exported through react and the hooks expose no queryKey, so invalidate by the string tag (["getAccountBalanceOf"]) — TanStack prefix-matches it.
8. TP/SL
Prerequisites: session-key delegation and the second COH-wallet delegation for REQUEST_TO_CLOSE_POSITION_SELECTOR (see §4). Skip either and the on-chain trigger reverts.
useQuoteTpSl— reads the current TP/SL record for a quote ({ quoteId }→{ tp, sl, … }). Folds handler-side conditional orders and on-chain quotes into one record.useSetQuoteTpSl— mutates it. Passtp: { price, priceType }and/orsl: { price, priceType };undefinedclears a leg.
See the TP/SL reference for the state machine, store shape, and the tempQuoteId ↔ quoteId linking rules.
Checklist
Setup & providers
- Exactly one wagmi copy resolves — automatic for a clean single-project install; force it with a package-manager override if two ever appear.
- Providers nested wagmi → QueryClient → Symmio, mounted client-only so
next builddoesn’t fail prerendering/_not-found. -
SymmioSupportedChainId,UnifiedQuote,QuoteLifecycleimported from@symmio/trading-core. - Affiliate set via
SymmioProviderchainOverrides, and agetWalletClientresolver wired for session-key signing.
Trade UX
- Market is addressable (URL / query / hash), not component state only.
- Header opens a searchable selector, not a
<select>. - Field order: Side → Margin → Leverage → TP/SL → Slippage → Submit.
- Colored Long / Short segmented control.
-
MaxreadsuseAccountBalanceOf(available), shaved for fees + slippage — notallocatedBalance. - Leverage bounds from
market.max_leverage. - Slippage is a chip row with a custom field.
- Submit is smart-gated (Connect → Switch → Select SubAccount → Deposit → Insufficient → Open).
- Pre-submit validation runs
calculateTradeParams+validateInstantOpenAgainstMarket.
Trading correctness
- Trade form blocks until a SubAccount is picked and all delegations are active.
-
fromis a session key in production, not the raw EOA. - Delegation checked for all three selectors; granted at once via
INSTANT_TRADE_REQUIRED_SELECTORS; expiry surfaced viauseDelegationExpiry. - TP/SL panel gates on the second (COH-wallet) delegation.
- Positions use
useManagedQuotes; actions pickquote.quoteId ?? BigInt(quote.tempQuoteId). - Close state is driven by
UnifiedQuote.lifecycle(WS-authoritative), not the mutation’sisSuccess. - Margin refetches after an open/close settles (on the settle notification), not just at POST time.
Pitfalls
| Symptom | Cause | Fix |
|---|---|---|
Cannot read properties of undefined (reading 'HYPER_EVM') | SymmioSupportedChainId imported from react | Import from @symmio/trading-core |
useConfig must be used within WagmiProvider (at runtime) | Two wagmi copies in node_modules | Force one copy via a package-manager override (Setup) |
useConfig must be used within WagmiProvider (during next build, /_not-found) | Provider tree server-rendered | Mount it client-only — gate on mounted (Providers) |
Actions.wallet.send is not exported from 'viem/tempo' at build | Next 15 fails to tree-shake viem/tempo | Upgrade to Next 16, or add wagmi to transpilePackages |
| Open silently fails after the spinner ends | Selector not delegated | Gate the form on allActive === true |
| Positions render then vanish for a second | invalidateQueries on a useManagedQuotes mutation onSuccess | Let the WS handler own state; don’t invalidate |
| ”Closing…” never clears | Close state read from mutation.isSuccess | Read it from UnifiedQuote.lifecycle |
| Deallocate reverts right after a prior deallocate | On-chain debounce window | Surface SymmioRequestError and prompt the user to wait |
Next steps
- Session keys —
@symmio/session-keyto remove the wallet popup from the hot path. - Notional gating — clamp order size on
useNotionalCapBySymbolIdbefore submit. - Withdraw — Withdraw hooks for request / cancel / finalize.
- Notifications —
useNotificationsfor a toast stream of every state transition.