Skip to Content
Symmio Trading-SDK — the SDK surface for builders on HyperEVM
GuidesBuild a Perps DEXWith React (recommended)

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
CollateralDeposit and withdraw the SubAccount’s available balance.useAccountBalanceOf, useDeposit, useInitiateWithdraw, useFinalizeWithdrawRequest
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, label each row with the market’s symbol field (the human ticker you display), and let the user filter on it.
  • 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 symmioConfig (required), defaultChainId, getWalletClient (plus children). symmioConfig is the per-chain SYMMIO config, keyed by chain id; each supported chain must have addresses.affiliatesAddress present (the provider throws only when it is missing — zeroAddress works out of the box; a registered affiliate earns fees):

    import { SymmioSupportedChainId } from "@symmio/trading-core"; import { SymmioProvider } from "@symmio/trading-react"; import { zeroAddress } from "viem"; const symmioConfig = { [SymmioSupportedChainId.HYPER_EVM]: { // `zeroAddress` works out of the box — trades open, you earn no fee share. // Swap in your registered affiliate to collect fees (see the callout below). addresses: { affiliatesAddress: zeroAddress }, // optional: per-chain subgraphs / solver / … overrides }, }; <SymmioProvider symmioConfig={symmioConfig} getWalletClient={getWalletClient}> {children} </SymmioProvider>;

What the affiliate address is — and why it’s how you get paid. In SYMMIO perps, trades are opened through frontends — dApps, brokers, bots. The affiliate address is that frontend’s identity onchain. It rides on every quote (that’s the …WithAffiliate… in SEND_QUOTE_WITH_AFFILIATE_AND_DATA_SELECTOR), so the protocol knows who sourced the trade and routes that source’s share of the trading fee to it. Affiliate addresses are per chain — a registration on one chain is not valid on another — so symmioConfig carries one under each chain’s addresses.affiliatesAddress.

It’s mandatory in the type: the SDK throws (AFFILIATE_ADDRESS_REQUIRED) only if a supported chain’s affiliate is missing — the field must be present, not merely non-zero. The zero address is accepted as a no-affiliate test placeholder (trades still open, but you earn no fee share). Registering lets your affiliate collect a share of the trading fees. Start with the zero address; when you want to earn fees,

register your affiliate address and swap it in under symmioConfig[chainId].addresses.affiliatesAddress.

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 user-facing collateral flow for a lowcap DEX is just deposit and withdraw — funds live in the SubAccount’s available balance, which instant trading spends directly. (Full picture of every balance and where it lives: Balance Model.)

  • Available (deallocated) — deposited collateral idle on the account: what instant open consumes and what a user withdraws. Read via useAccountBalanceOf ({ account }bigint, 18 decimals).
  • Deposit (wallet → available) — useApproveCollateral (ERC-20 approve) then useDeposit ({ account, amount }).

Skip allocate / deallocate in a lowcap UI. useAllocate / useDeallocate shuffle funds in and out of the classic (non-instant) pool where CVA / LF locks sit — a concept a lowcap trader never touches, and allocating actually reduces what an instant open can use. Keep the panel to Deposit + Withdraw; the fund gate is useAccountBalanceOf > 0 (the submit button says Deposit collateral), never Allocate.

Withdraw

Withdrawing returns funds to the wallet from the available balance in a two-step, cooldown-gated flow:

  1. InitiateuseInitiateWithdraw. Build a part with createClassicWithdrawPart({ id: 0n, amount, receiver, chainId: BigInt(chainId) }) (amount in collateral decimals, receiver = the connected wallet), then mutate({ account: subAccount, parts: [part] }). This queues a request and starts the cooldown.
  2. Cooldown — a protocol-configured on-chain value (max(lastDeallocation + cooldownPeriod, now)), read per request as request.cooldownEndTime, or via useWithdrawableTime for an “if you withdrew now” estimate. It’s usually short (hours) but not fixed — render a countdown that adds a day segment only when remaining ≥ 24 h, so it never shows a bare clock time if the protocol ever raises it.
  3. Finalize / cancelusePendingWithdrawRequests ({ user }WithdrawRequest[] with id, totalAmount, cooldownEndTime) lists active requests. useFinalizeWithdrawRequest ({ user, requestId }) pays out to the wallet once the cooldown elapses (permissionless); useRequestCancelWithdraw ({ account, requestId }) cancels a pending one.

Account balances (available) are 18-decimal internal units. Deposit and withdraw amounts are in the collateral token’s decimals (getChainConfig().addresses.collateralDecimals) — parse them with parseUnits(input, collateralDecimals), not parseEther.

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: on mount, load the persisted session key from durable storage (manager.initialize(owner)) — mint a new one only if none exists → 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.

Persist the session key — never keep it in memory only. Back your SessionKeyManager with a durable SessionKeyStorage adapter (localStorage or IndexedDB), and auto-load it on mount with manager.initialize(owner). If the key lives only in memory (or you don’t re-initialize on load), every page refresh mints a brand-new session-key wallet — which drops all delegations and forces the user to send another grantDelegation transaction just to trade again. With durable storage + auto-load, a refresh reuses the same key and its existing (non-expired) delegation, so the trade form is immediately usable and no new wallet tx is needed. Encrypt the stored private key — see Security & storage.

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.

Gate every write, not just the open form

Open, close, and set-TP/SL are all signed by the session key — each reverts on-chain without the matching delegation. Gating only the open form is a trap: a position opened in a prior session (or before the key was rotated) will still render in the positions table, and its Close and TP/SL actions will silently fail if you let the user trigger them without an active delegation. Rotating the session key drops all three delegations for the new key, so “I could trade a minute ago” is not proof the delegation still holds.

Enforce the same gate on every write path. Extract the check + grant once and reuse it — for the open form, the close modal, and the TP/SL modal:

// one hook, three call sites function useTradingDelegation(subAccount?: Address) { const { sessionKeyAddress, isLoading: sessionKeyLoading } = useSessionKey(); const enabled = Boolean(subAccount && sessionKeyAddress); const addMargin = useIsDelegationActive({ account: subAccount!, delegate: sessionKeyAddress!, selector: ADD_MARGIN_TO_NEXT_VA_SELECTOR, query: { enabled }, }); const sendQuote = useIsDelegationActive({ account: subAccount!, delegate: sessionKeyAddress!, selector: SEND_QUOTE_WITH_AFFILIATE_AND_DATA_SELECTOR, query: { enabled }, }); const closePos = useIsDelegationActive({ account: subAccount!, delegate: sessionKeyAddress!, selector: REQUEST_TO_CLOSE_POSITION_SELECTOR, query: { enabled }, }); const grant = useGrantDelegation(); const isActive = addMargin.data === true && sendQuote.data === true && closePos.data === true; const enable = () => grant.mutate({ account: { addr: subAccount!, isPartyB: false }, delegatedSigner: sessionKeyAddress!, selectors: INSTANT_TRADE_REQUIRED_SELECTORS, expiryTimestamp, }); return { sessionKeyAddress, sessionKeyLoading, isActive, granting: grant.isPending, enable }; }

In the close modal and the TP/SL modal, swap the action button for an “Enable trading” button whenever isActive === false. Because useGrantDelegation auto-invalidates the three useIsDelegationActive queries on success, the button flips back to the real action the moment the grant lands — no manual refetch.

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. TP/SL needs both delegations, so gate the panel in order: first the session-key trading delegation (isActive, same as open/close — see Gate every write), then cohActive === true. A user can trade with only the session-key delegation, but cannot set TP/SL until the COH delegation is also granted.

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 (the ticker to display in the selector / header), 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).

Market header stats (funding + liquidity)

A trader expects the market header to carry a live stat strip alongside the mark price — funding and available liquidity. Each is one hook, polled so the strip stays fresh:

  • FundinguseFundingInfo returns each market’s next-epoch figures; match the row by the market’s solver symbol field — not name or the route ticker, which can differ (SymbolContractSymbol carries both). You can also pass a symbols filter to fetch just the one market. Each row has nextFundingRateLong / nextFundingRateShort (per-epoch decimal fractions0.0001 = 0.01%, so ×100 for a percent), nextFundingTime, and epochDurationSeconds. A positive rate means that side receives funding; negative pays. For a settlement countdown, read nextFundingTime — a Unix timestamp in milliseconds, but stay defensive about the unit (treat a value below 1e12 as seconds). It does not poll by default; pass query.refetchInterval.
  • LiquidityuseNotionalCapBySymbolId returns { openInterest, availableToLong, availableToShort, totalCap, used } for one market. Available liquidity is availableToLong / availableToShort (the notional the solver still allows to open per side); open interest is openInterest. Poll it with DEFAULT_NOTIONAL_CAP_POLLING_MS.
// Filter the fetch by the current market, and match by the market's solver // `symbol` field (which can differ from `name` / the route ticker). const funding = useFundingInfo({ symbols: [symbol], query: { refetchInterval: 30_000 } }).data?.find( (f) => f.symbol === market.symbol, ); const cap = useNotionalCapBySymbolId({ symbolId: Number(market.symbol_id), query: { enabled: market.symbol_id != null, refetchInterval: DEFAULT_NOTIONAL_CAP_POLLING_MS }, }).data; const rate = funding?.nextFundingRateLong; // ×100 for a percent; color by receive (≥0) / pay (<0) const targetMs = funding && funding.nextFundingTime < 1e12 ? funding.nextFundingTime * 1000 : funding?.nextFundingTime; // countdown cap?.availableToLong; // available liquidity per side cap?.availableToShort; cap?.openInterest; // open interest

useFundingInfo returns all markets, so call it once and reuse (TanStack dedupes it). useNotionalCapBySymbolId is the same hook the trade form uses to gate order size — the header and the form share one cached query per market.

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.

Attaching TP/SL at open

useInstantOpenWithTpSl posts the open, then — the instant the hedger returns a tempQuoteId — submits setQuoteTpSl against it, in one flow. The catch: TP/SL is signed against the position’s Virtual Account (VA), and the VA does not exist on-chain until the open lands. Predict it up front with usePredictedNextVirtualAccount, keyed by the side’s isolation type:

const positionType = side === "long" ? PositionType.LONG : PositionType.SHORT; const { data: predictedVa } = usePredictedNextVirtualAccount({ subAccount, isolationType: isolationTypeForSide(positionType), // MARKET_LONG / MARKET_SHORT symbolId: BigInt(market.symbol_id), query: { enabled: market.symbol_id != null }, }); const hasTp = tpPrice.length > 0; const hasSl = slPrice.length > 0; const tpsl = (hasTp || hasSl) && predictedVa ? { from: sessionKey, // session key signs it virtualAccount: predictedVa, // the predicted VA, not the SubAccount subAccount, symbolId: BigInt(market.symbol_id), positionType, quantity: tradeParams.quantity, // decimal string from calculateTradeParams pricePrecision: Number(market.price_precision), tp: hasTp ? { triggerPrice: tpPrice, priceType: tpType } : undefined, // "markPrice" | "lastPrice" sl: hasSl ? { triggerPrice: slPrice, priceType: slType } : undefined, } : undefined; await open.mutateAsync({ from: sessionKey, subAccountAddress: subAccount, market: { id }, positionType, initialMargin, leverage, slippage, tpsl, });

tpsl: undefined (both legs empty) makes the hook behave exactly like useInstantOpenAuto — no wasted call. The open and the TP/SL leg fail independently: if the open lands but the TP/SL POST is rejected, the mutation still resolves and surfaces data.tpslError (an amber “position opened, TP/SL failed” is friendlier than throwing away the trade). See §8 for how the attached order then confirms over the WebSocket.

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 / 5 / custom), never a raw number field — default 5%, since lowcap fills move more than majors do.
  • The submit button is smart-labeled from a single gate (see the gate ladder).

Estimated fill price + impact

Preview the price the solver would actually fill at with useEstimatedPrice — pass the slippage-adjusted request price (calculateTradeParams().requestedOpenPrice for an open, calculateClosePrice() for a close), the quantity, side, and entry. From the returned estimatedPrice:

  • Open — show it in the ticket plus the price impact vs mark: calculatePriceImpact({ estimatedPrice, referencePrice: markPrice }).
  • Close — show the estimated close price and fold it into an estimated PnL with the realized-PnL calc: calculateQuotePnl({ positionType, closedAmount, closedPrice: parseEther(estimatedPrice), openedPrice, leverage }) (get leverage from useQuoteUpnlAndPnl).
const est = useEstimatedPrice({ symbolId: Number(market.symbol_id), quantity: tradeParams.quantity, positionType, entry: "open", price: tradeParams.requestedOpenPrice, // slippage-adjusted, NOT the raw mark }).data?.estimatedPrice; const impact = est ? calculatePriceImpact({ estimatedPrice: est, referencePrice: String(markPrice) }) : 0;

Debounce the quantity / price inputs so it doesn’t refetch on every keystroke.

Available margin (the Max chip)

Max is not the raw balance — the request must survive fees and, for SHORT, a worst-case slippage fill. The SDK does the shave for you with useAvailableInstantOpenMargin:

const { availableMargin, availableMarginWei } = useAvailableInstantOpenMargin({ account: subAccount, symbolId: market.symbol_id, leverage, positionType, slippage, // percent }); // wire `availableMargin` to the Max chip; block submit when the typed margin exceeds it.

It applies available = balance × max(0, 1 − slippageFactor) × max(0, 1 − leverage × (openFee + closeFee))slippageFactor = slippage on SHORT and 0 on LONG (fees are charged on the leveraged notional; SHORT also caps for the worst-case fill). Because it reads the balance with live: true, the Max refetches automatically when the open/close settles on-chain — no manual refresh.

Want to own the pieces? Call the pure calculateAvailableInstantOpenMargin from @symmio/trading-core with your own balance + fee reads — the hook is just that function fed by useAccountBalanceOf + useFeeForUser.

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.

The close ticket carries its own slippage control — a chip row like the open form (default 5%). It shaves the request price via calculateClosePrice({ markPrice, slippage, positionType, pricePrecision }) and is passed straight through to useInstantCloseAuto, so surface it to the user rather than hardcoding it.

Before you show a Close button, confirm 1-click trading is enabled. Closing is session-key-signed, so it lives behind the same delegation gate as the open form — and a position that survived from a prior session can still show up here with a stale key. When the trading delegation is inactive, don’t render a live Close action: swap it for an “Enable trading” button that prompts the user to grant the session-key delegation, then flips back to Close the moment the grant lands (see §4 · Gate every write). Skip this and the hedger accepts the close while the on-chain anchor reverts — a silent failure.

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.

Show the user when an open fails — don’t let the row silently vanish. When the notifications stream reports a NotificationType.FAILED frame for a pending open, useManagedQuotes drops that optimistic row automatically (a failed order never becomes a position, so it never renders as one). But a row that just disappears reads as a bug — surface the failure to the user (a toast with the failure reason / error code) so they know the open didn’t land, and why.

Position details

A per-position details view (a modal or side panel) is pure composition — no new fetch beyond three per-quote hooks plus the UnifiedQuote fields:

DetailSource
Quote idquote.quoteId ?? quote.tempQuoteId (pending until it anchors)
Sidequote.positionType
Quantity / open sizequote.quantity (total requested, fixed) · quote.openQuantity (remaining open) — both wei
Closed amountquote.closedAmount (wei) — how much of the position was already closed (quantity − openQuantity)
Opened pricequote.openedPrice ?? quote.requestedOpenPrice (wei)
Mark priceuseQuoteUpnlAndPnlmarkPrice (a decimal string, not wei)
uPnLuseQuoteUpnlAndPnl{ upnl, upnlPercent } (decimal strings, not wei)
Position value ($)openQuantity × mark — the dollar notional
Liquidation priceuseAccountLiquidationPrice ({ account: quote.vaAddress ?? quote.partyA })liquidationPrice (wei). Lowcap isolates each position into its own single-position VA, so the account liq price is the position’s.
Open-price historyuseQuotePriceHistory ({ quoteId, orderDirection: "asc" }){ rows }. Label why each change happened from row.type (QuoteEventType: settle-uPnL recompute on partial close, or a funding tick); row.newPrice (wei) is the open price after it, and row.prevPrice gives the signed Δ. Filter to rows with newPrice and prepend the entry — quote.initialOpenedPrice (the original open before any recompute), not openedPrice, which is the latest adjusted open. Color each Δ by P&L impact, not price direction: uPnL = (mark − open) × size for a long, so a higher open price hurts a long (red) and helps a short — flip the color by positionType. Needs the on-chain quoteId — empty while the row is still pending.
Opened atquote.createTimestamp — a block timestamp in seconds (× 1000 for a JS Date)
Platform feeuseQuotePlatformFee ({ quote }){ openFee, closeFee } (wei)

The UnifiedQuote amount / price fields — quantities, prices, liquidationPrice, and the fees — are 18-decimal wei bigint; divide by 1e18 (formatUnits) to display. The exceptions are useQuoteUpnlAndPnl’s outputs — markPrice, upnl, and upnlPercent are already decimal strings, so pass them straight to your formatter without dividing.

Render every quote value at the market’s precision. Show each price (entry, mark, liquidation, TP/SL, price-history) at market.price_precision decimals and each quantity / size at market.quantity_precision — both fields live on SymbolContractSymbol (Number(market.price_precision ?? 2) / Number(market.quantity_precision ?? 4)). Format with a fixed-decimals helper (e.g. formatWithCommas(value, {fixedDecimals})) so a market always renders in its own format across the header, rows, and modals — not a global “4 decimals everywhere”.

A just-opened position isn’t closeable yet

An instant open seeds the row optimistically, so it shows in the table the moment you submit — but it has no on-chain quoteId until the solver’s on-chain settle notification lands. Closing a row that hasn’t anchored reverts. Drive the row’s open state from UnifiedQuote.lifecycle, exactly like the close state, and show a loading state — a disabled “Opening…” button — until it clears:

const OPENING_STAGES = [QuoteLifecycle.OPTIMISTIC, QuoteLifecycle.PRICE_FILLED, QuoteLifecycle.WRITE_ONCHAIN]; const isOpening = OPENING_STAGES.includes(quote.lifecycle);

The open advances OPTIMISTIC → PRICE_FILLED → WRITE_ONCHAIN → ONCHAIN; only at ONCHAIN — the second, on-chain solver notification — does the position get its on-chain quoteId and become closeable. Gate the Close row action on !isOpening and swap it for a spinner until that notification arrives, so a user can’t fire a close that would silently revert mid-open. TP/SL is unaffected — it keys off the tempQuoteId and the store re-links it once the anchor lands, so leave it enabled through the open.

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) })

Keep the balance fresh after settle

Neither open nor close changes the SubAccount balance at POST time — margin locks/unlocks on the on-chain settle notification, which lands later over the WebSocket. So a balance read shown at trade time goes stale after the position anchors.

Rather than wiring a separate invalidation, pass live: true to the balance read hooks. They subscribe to the account’s settle notifications (a shared socket) and refetch themselves on each open-anchor / close-fill:

const { data: available } = useAccountBalanceOf({ account: subAccount, live: true }); const { data: info } = useAccountBalanceInfo({ account: subAccount, live: true });

useAvailableInstantOpenMargin already reads its balance with live: true, so the trade-form Max stays fresh with no extra wiring. All reads of the same account share one query key, so any one live read refreshes the value everywhere.

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, account }{ tp, sl, tpState, slState, … }). Folds handler-side conditional orders and on-chain quotes into one record.
  • useSetQuoteTpSl — mutates it. Pass tp: { triggerPrice, priceType } and/or sl: { triggerPrice, priceType } (priceType is "markPrice" | "lastPrice"). Send only the leg the user actually changed — an omitted leg is left untouched on the service (omitting is not clearing; at least one leg is required). Track a per-side “dirty” flag so editing TP alone doesn’t re-submit the unchanged SL. To remove a leg entirely, use useDeleteQuoteTpSl, not an empty leg. quoteId accepts either the on-chain quoteId or the pre-chain tempQuoteId.

quoteId picks the same id as any other per-row action: quote.quoteId ?? BigInt(quote.tempQuoteId). A TP/SL attached at open (see §6) is keyed by the tempQuoteId; the store re-links it to the on-chain quoteId once the open anchors, so the record follows the position across the transition.

Confirmation is WS-driven — don’t finish on the POST

A setQuoteTpSl POST returning 200 only means the handler accepted the order. It is not live until the handler broadcasts a report frame over the TP/SL WebSocket. useQuoteTpSl folds that stream into tpState / slState:

pending (POST in flight) → confirming (POST returned, awaiting the report) → new (active) → triggered / canceled / killed.

To receive the stream, pass account (the SubAccount) to useQuoteTpSl — that is the whole notification setup; the hook opens and reconciles the socket for you. Omit it and you get the folded snapshot but no live transitions.

Do not close the TP/SL modal — or clear the form — on the mutation’s isSuccess. That is the confirming state, not the final one. Keep the panel open and drive its status badge off record.tpState / record.slState; the leg is only done when it reads new. The set-at-open path is identical: useInstantOpenWithTpSl marks the leg confirming internally, so a useQuoteTpSl({ quoteId: tempQuoteId, account }) elsewhere in the UI shows “Processing…” until the same report lands.

To show a position’s live TP/SL inline (e.g. under the market name in the positions row), read it the same way — useQuoteTpSl({ quoteId, account: subAccount }) — and render record.tp / record.sl.

Existing TP/SL only appears if you mount useQuoteTpSl per position — the WebSocket does not backfill on load. The TP/SL socket streams transitions from the moment you subscribe; it never replays the conditional orders a position already has. useQuoteTpSl({ quoteId, account }) is what performs the REST read on mount that hydrates the current state and opens the stream. So on app load — and whenever the positions table first renders — mount it for every anchored quote (quote.quoteId ?? BigInt(quote.tempQuoteId)). If you render TP/SL only from the store selector (useTpSlRecord) or from useWatchTpSlNotifications alone — without ever mounting useQuoteTpSl — an existing take-profit / stop-loss stays invisible until it next changes. Symptom: “TP/SL doesn’t show after a refresh.”

See the TP/SL reference for the full 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 under SymmioProvider’s required symmioConfig — each chain’s addresses.affiliatesAddress present (the SDK throws only on a missing field; zeroAddress is the correct no-affiliate default — trades open, no fee share), upgraded to a registered address when you monetize (registration earns a share of the trading fees), plus 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>.
  • Market header carries a live stat strip — funding (useFundingInfo, rate + countdown) and liquidity (useNotionalCapBySymbolId: open interest + available-to-long/short), both polled.
  • Field order: Side → Margin → Leverage → TP/SL → Slippage → Submit.
  • Colored Long / Short segmented control.
  • Max uses useAvailableInstantOpenMargin (balance shaved for fees + slippage), not the raw useAccountBalanceOf / allocatedBalance.
  • Leverage bounds from market.max_leverage.
  • Slippage is a chip row with a custom field (default 5%), on both the open ticket and the close modal.
  • 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.
  • Every write path gates on the delegation — the close modal and the TP/SL modal show “Enable trading” when inactive, not just the open form.
  • TP/SL panel gates on the session-key delegation and the second (COH-wallet) delegation, in that order.
  • TP/SL attached at open signs against the predicted VA (usePredictedNextVirtualAccount + isolationTypeForSide), not the SubAccount.
  • TP/SL confirmation is WS-driven — useQuoteTpSl gets account, the panel stays open on POST success, and a leg is “done” only at tpState/slState === "new".
  • Positions use useManagedQuotes; actions pick quote.quoteId ?? BigInt(quote.tempQuoteId).
  • Close state is driven by UnifiedQuote.lifecycle (WS-authoritative), not the mutation’s isSuccess.
  • A just-opened row gates Close on the open lifecycle (OPTIMISTIC → PRICE_FILLED → WRITE_ONCHAIN), showing a loading state until it reaches ONCHAIN (TP/SL stays enabled — it keys off tempQuoteId).
  • 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
Users told to register an affiliate before they can tradeAffiliate treated as a prerequisitezeroAddress is the valid no-affiliate default; register to earn fees (Providers)
Trade form shows zero margin right after a depositFunds allocated via useAllocate / useDepositAndAllocateInstant trading spends the available balance — deposit only (§3)
Balance panel driven by allocatedBalanceClassic-pool read used for the instant flowShow useAccountBalanceOf (available); see the Balance Model

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