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 | Deposit and withdraw the SubAccount’s available balance. | useAccountBalanceOf, useDeposit, useInitiateWithdraw, useFinalizeWithdrawRequest |
| 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().data, label each row with the market’ssymbolfield (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-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 aresymmioConfig(required),defaultChainId,getWalletClient(pluschildren).symmioConfigis the per-chain SYMMIO config, keyed by chain id; each supported chain must haveaddresses.affiliatesAddresspresent (the provider throws only when it is missing —zeroAddressworks 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 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 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) thenuseDeposit({ 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:
- Initiate —
useInitiateWithdraw. Build a part withcreateClassicWithdrawPart({ id: 0n, amount, receiver, chainId: BigInt(chainId) })(amountin collateral decimals,receiver= the connected wallet), thenmutate({ account: subAccount, parts: [part] }). This queues a request and starts the cooldown. - Cooldown — a protocol-configured on-chain value (
max(lastDeallocation + cooldownPeriod, now)), read per request asrequest.cooldownEndTime, or viauseWithdrawableTimefor 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. - Finalize / cancel —
usePendingWithdrawRequests({ user }→WithdrawRequest[]withid,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:
| 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.
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 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. 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 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 (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:
- Funding —
useFundingInforeturns each market’s next-epoch figures; match the row by the market’s solversymbolfield — notnameor the route ticker, which can differ (SymbolContractSymbolcarries both). You can also pass asymbolsfilter to fetch just the one market. Each row hasnextFundingRateLong/nextFundingRateShort(per-epoch decimal fractions —0.0001=0.01%, so ×100for a percent),nextFundingTime, andepochDurationSeconds. A positive rate means that side receives funding; negative pays. For a settlement countdown, readnextFundingTime— a Unix timestamp in milliseconds, but stay defensive about the unit (treat a value below1e12as seconds). It does not poll by default; passquery.refetchInterval. - Liquidity —
useNotionalCapBySymbolIdreturns{ openInterest, availableToLong, availableToShort, totalCap, used }for one market. Available liquidity isavailableToLong/availableToShort(the notional the solver still allows to open per side); open interest isopenInterest. Poll it withDEFAULT_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 interestuseFundingInfo 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 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.
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 })(getleveragefromuseQuoteUpnlAndPnl).
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, 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.
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:
| Detail | Source |
|---|---|
| Quote id | quote.quoteId ?? quote.tempQuoteId (pending until it anchors) |
| Side | quote.positionType |
| Quantity / open size | quote.quantity (total requested, fixed) · quote.openQuantity (remaining open) — both wei |
| Closed amount | quote.closedAmount (wei) — how much of the position was already closed (quantity − openQuantity) |
| Opened price | quote.openedPrice ?? quote.requestedOpenPrice (wei) |
| Mark price | useQuoteUpnlAndPnl → markPrice (a decimal string, not wei) |
| uPnL | useQuoteUpnlAndPnl → { upnl, upnlPercent } (decimal strings, not wei) |
| Position value ($) | openQuantity × mark — the dollar notional |
| Liquidation price | useAccountLiquidationPrice ({ 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 history | useQuotePriceHistory ({ 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 at | quote.createTimestamp — a block timestamp in seconds (× 1000 for a JS Date) |
| Platform fee | useQuotePlatformFee ({ 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 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) })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. Passtp: { triggerPrice, priceType }and/orsl: { triggerPrice, priceType }(priceTypeis"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, useuseDeleteQuoteTpSl, not an empty leg.quoteIdaccepts either the on-chainquoteIdor the pre-chaintempQuoteId.
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 builddoesn’t fail prerendering/_not-found. -
SymmioSupportedChainId,UnifiedQuote,QuoteLifecycleimported from@symmio/trading-core. - Affiliate set under
SymmioProvider’s requiredsymmioConfig— each chain’saddresses.affiliatesAddresspresent (the SDK throws only on a missing field;zeroAddressis 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 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>. - 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.
-
MaxusesuseAvailableInstantOpenMargin(balance shaved for fees + slippage), not the rawuseAccountBalanceOf/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.
-
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. - 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 —
useQuoteTpSlgetsaccount, the panel stays open on POST success, and a leg is “done” only attpState/slState === "new". - Positions use
useManagedQuotes; actions pickquote.quoteId ?? BigInt(quote.tempQuoteId). - Close state is driven by
UnifiedQuote.lifecycle(WS-authoritative), not the mutation’sisSuccess. - A just-opened row gates Close on the open lifecycle (
OPTIMISTIC → PRICE_FILLED → WRITE_ONCHAIN), showing a loading state until it reachesONCHAIN(TP/SL stays enabled — it keys offtempQuoteId). - 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 |
| Users told to register an affiliate before they can trade | Affiliate treated as a prerequisite | zeroAddress is the valid no-affiliate default; register to earn fees (Providers) |
| Trade form shows zero margin right after a deposit | Funds allocated via useAllocate / useDepositAndAllocate | Instant trading spends the available balance — deposit only (§3) |
Balance panel driven by allocatedBalance | Classic-pool read used for the instant flow | Show useAccountBalanceOf (available); see the Balance Model |
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.