Skip to Content
Symmio Frontier — the SDK surface for builders on HyperEVM
GuidesBuild a Perps DEX

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.

PartWhat it doesPrimary hooks
Market selectionDiscover and switch markets — header + searchable list.useMarkets
Trade formSide, size, leverage, TP/SL, slippage, submit.useInstantOpenAuto, useInstantOpenWithTpSl, useLockedParams, useFeeForUser
PositionsLive open positions, per-row uPnL, close.useManagedQuotes, useQuoteUpnlAndPnl, useInstantCloseAuto
CollateralAvailable vs allocated balance, deposit / allocate.useAccountBalanceOf, useAccountBalanceInfo, useAllocate, useDeallocate
TP/SLTake-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 with useMarkets().data and 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-dom

wagmi, 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.)

PackageVersion
wagmi^3.4.2
@wagmi/connectors^8
viem^2.49.0
next16.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-reactSymmioProvider, useSymmioConfig, useSymmioChainId, every use* hook, the delegation selector constants, the pure calc helpers (calculateTradeParams, validateInstantOpenAgainstMarket, …), and the enums PositionType / OrderType / QuoteStatus / NotificationType.
  • @symmio/trading-coreSymmioSupportedChainId, 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 WagmiProviderQueryClientProviderSymmioProvider. The SDK reads both wagmi and TanStack Query from context.

  • WagmiProvider — your createConfig({ chains, transports, connectors }) from wagmi. The host owns connectors and RPCs; the SDK never picks them.

  • QueryClientProvider — one new QueryClient(), held in useState so it survives remounts.

  • SymmioProvider — props are chainOverrides, defaultChainId, getWalletClient (plus children). There is no config or affiliateAddress prop. Set your affiliate through chainOverrides, 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 renders

The 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 }. isOnExpectedChain saves 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 while user is undefined, so passing the connected address directly is safe — no manual enabled guard.

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:

ConstantGrants
SEND_QUOTE_WITH_AFFILIATE_AND_DATA_SELECTORopen a position
REQUEST_TO_CLOSE_POSITION_SELECTORclose a position
ADD_MARGIN_TO_NEXT_VA_SELECTORtop-up margin
INSTANT_TRADE_REQUIRED_SELECTORSall 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 })bigint expiry 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 }. expiryTimestamp is Unix seconds (a 24h–30d TTL keeps returning users from re-granting). On success the three useIsDelegationActive queries auto-invalidate and the gate flips to true.

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 keyINSTANT_TRADE_REQUIRED_SELECTORS (open, close, add margin).
  • COH walletREQUEST_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 over useOnchainContractMarkets for 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 returned tempQuoteId. Pass tpsl: undefined when 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))
  1. Fees are charged on leveraged notional — subtract leverage × (openFee + closeFee).
  2. Slippage (SHORT only) — quantity is sized off markPrice × (1 − s); a closer fill inflates notional, so cap usable balance at balance × (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, or null if an input is missing. The *Percent values come from useLockedParams; precisions from the market row.
  • validateInstantOpenAgainstMarket({ market, quantity, markPrice, cva, lf, partyAmm, notionalCap?, positionType? }){ ok, violations }. Each violation carries actual-vs-required numbers.
ConstraintViolation kindMeaning
min_acceptable_portion_lfLF_PORTION_TOO_LOWlf / (cva + lf + partyAmm) below the market floor.
min_acceptable_quote_valueQUOTE_VALUE_TOO_LOWLocked margin sum below the market floor.
max_notional_valueNOTIONAL_TOO_HIGHmarkPrice × quantity above the cap.
min_notional_valueNOTIONAL_TOO_LOWmarkPrice × quantity below the floor.
max_quantityQUANTITY_TOO_HIGHLeveraged quantity above the cap.
lot_sizeQUANTITY_BELOW_LOT_SIZELeveraged quantity below lot_size.
lot_sizeQUANTITY_NOT_LOT_MULTIPLELeveraged quantity not a lot_size multiple.
notionalCapCAP_REACHEDNotional 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 to ONCHAIN, openQuantity (quantity − closedAmount) shrinks.
  • Failed close → lifecycle returns to ONCHAIN unchanged. 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. Pass tp: { price, priceType } and/or sl: { price, priceType }; undefined clears 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 build doesn’t fail prerendering /_not-found.
  • SymmioSupportedChainId, UnifiedQuote, QuoteLifecycle imported from @symmio/trading-core.
  • Affiliate set via SymmioProvider chainOverrides, and a getWalletClient resolver 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.
  • Max reads useAccountBalanceOf (available), shaved for fees + slippage — not allocatedBalance.
  • 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.
  • from is 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 via useDelegationExpiry.
  • TP/SL panel gates on the second (COH-wallet) delegation.
  • Positions use useManagedQuotes; actions pick quote.quoteId ?? BigInt(quote.tempQuoteId).
  • Close state is driven by UnifiedQuote.lifecycle (WS-authoritative), not the mutation’s isSuccess.
  • Margin refetches after an open/close settles (on the settle notification), not just at POST time.

Pitfalls

SymptomCauseFix
Cannot read properties of undefined (reading 'HYPER_EVM')SymmioSupportedChainId imported from reactImport from @symmio/trading-core
useConfig must be used within WagmiProvider (at runtime)Two wagmi copies in node_modulesForce one copy via a package-manager override (Setup)
useConfig must be used within WagmiProvider (during next build, /_not-found)Provider tree server-renderedMount it client-only — gate on mounted (Providers)
Actions.wallet.send is not exported from 'viem/tempo' at buildNext 15 fails to tree-shake viem/tempoUpgrade to Next 16, or add wagmi to transpilePackages
Open silently fails after the spinner endsSelector not delegatedGate the form on allActive === true
Positions render then vanish for a secondinvalidateQueries on a useManagedQuotes mutation onSuccessLet the WS handler own state; don’t invalidate
”Closing…” never clearsClose state read from mutation.isSuccessRead it from UnifiedQuote.lifecycle
Deallocate reverts right after a prior deallocateOn-chain debounce windowSurface SymmioRequestError and prompt the user to wait

Next steps

  • Session keys@symmio/session-key to remove the wallet popup from the hot path.
  • Notional gating — clamp order size on useNotionalCapBySymbolId before submit.
  • WithdrawWithdraw hooks for request / cancel / finalize.
  • NotificationsuseNotifications for a toast stream of every state transition.
Last updated on