Skip to content

Instantly share code, notes, and snippets.

@patcito
Last active April 23, 2026 15:22
Show Gist options
  • Select an option

  • Save patcito/219527570c21006bc7b21432d31b5a92 to your computer and use it in GitHub Desktop.

Select an option

Save patcito/219527570c21006bc7b21432d31b5a92 to your computer and use it in GitHub Desktop.
why orders fail + session routing + WS latency UX

Reply for the FE dev — why orders fail + WS latency

Why orders fail (even when the params look fine)

The executor now emits stable errorCode on rejections. Branch your toast logic on it so the UX names the problem instead of "order failed" guessing. Most common on dev:

errorCode What it really means Fix on the FE side
ORDER_TOO_SMALL sellAmount is below the wrapper minimum on ftDNMM. 0.01 USDC or 0.0001 wS will trip this. Validate a min size per pair in the form. For dev use ≥ 0.1 USDC / ≥ 1 wS to be safe.
ORDER_REVERTED: Return amount is not enough The DEX quote came back under buyAmount. Happens on limit orders with a floor that's stricter than current market, or on market orders where you didn't relax buyAmount. For market orders set buyAmount = 1. For limit orders set buyAmount from the actual limit price minus slippage tolerance.
ORDER_REVERTED: arithmetic underflow or overflow Usually triggered by tiny amounts making the HF/margin math blow up. Same root cause as ORDER_TOO_SMALL. Same — keep test amounts above dust.
SESSION_CAP_NOTIONAL_EXCEEDED sellAmount × oracle price exceeded the server-side cap (currently $1000 on dev). Show the cap and pre-clip. /mm/leverage/config returns sessionCaps.maxNotionalUsd — you already have it.
ORDER_SLIPPAGE_TOO_TIGHT Same shape as "Return amount is not enough" but pre-flight; the executor's oracle-fair floor rejected the quote before it even reached the engine. Relax buyAmount or raise the user's slippage tolerance.
ORDER_BELOW_MIN_EQUITY ftDNMM has a per-position minimum equity floor. A 5x long on $0.10 gives you only $0.10 of equity (with $0.40 borrow) — below the protocol's floor, tx reverts with 0xea70c7ed. This is the selector that showed up as "revert 0xea70c7ed" before — decoder ships in the next executor build. Bump sellAmount (→ more equity), lower leverage, or both. For dev test: ≥$1 equity is safe.
ORDER_SESSION_DATAHASH_MISMATCH / ORDER_SESSION_EXECUTOR_MISMATCH The signed dataHash or executor in SessionCall doesn't match what the executor recomputes. Usually a stale /leverage/config cache on FE or a wrong engine/executor address constant. Always re-fetch /leverage/config right before signing. Don't cache across wallet swaps or chain swaps.
ORDER_SESSION_EXPIRED deadline (in SessionCall) was in the past by the time the executor tried to submit. Use a deadline ≥ 5 minutes out, not ≥ 1 second.

All of these come through both /mm/leverage/orders POST response ({success:false, code, error, details}) and /mm/leverage/orders/:id poll (errorCode, errorMessage). The FE hook surface already exposes .errorCode after round 6 — switch the toast on that.

Session nonce — correction on my earlier take

I was wrong before. Session flow is already 0 wallet popups per order. The ephemeral delegate key signs SessionCall locally — see useSessionManager.signSessionCall at packages/ft-sdk/src/hooks/sessions/useSessionManager.ts:1116. The 3 signatures you're seeing on TP/SL must be coming from somewhere else — most likely the drawer is routing to useLeverageOrderAction (direct EIP-712, 1 popup per order) instead of useSessionLeverageExecutorAction (session, 0 popups). Quick check: does your local delegateKeyRef.current have a value when TP/SL fires? If yes you'd see zero popups; if no the session fell back to wallet-signing. Usually means the session expired or the delegate key got cleared from localStorage between order submissions.

The real problem is separate from popup count: the executor's idempotency check keys on (sessionId, nonce). While Order A is pending at nonce N, submitting Order B (same session, same nonce) dedups back to A. That's what breaks OCO and limit-stacking.

Two fix paths — I'd push for option 2 on our side, since 1 is a contract change:

  1. SC team: non-sequential nonces. Replace the require(nonces[sessionId] == nonce) pattern with a bitmap of used nonces within a sliding window (e.g. 64-slot). User can sign out-of-order within the window. Clean, but needs redeployment.

  2. Executor-side OCO semantics. Relax the idempotency check: allow multiple pending orders with the same (sessionId, nonce) when tagged as mutually-exclusive. When one fires on-chain, the session nonce advances → the others auto-invalidate → executor marks them cancelled. That's exactly OCO behavior for free. FE flags the pair with e.g. ocoGroupId on submit and the executor groups them.

For limit-stacking without OCO (e.g. two independent limits sitting pending), option 2 needs client nonce pre-advance: on submit, FE bumps nextNonce = onchainNonce + pendingCount. Works as long as pending orders all eventually fill/expire in submit order. Order-failure needs a resync. Option 1 sidesteps this entirely.

I can ship option 2 on the executor this week if you want to unblock OCO — it's ~100 lines. Flag the SC team in parallel so we can pick option 1 long-term.

Net: keep sigType: "session" for all order types (markets AND limits). Do NOT route limits to EIP-712 like I suggested before — that would actually add popups. If you're seeing popups on session-flow orders, the delegate key isn't loaded — that's a session-restore bug to chase separately.

WS latency — optimize for UX, not for the BE

Two different paths here, with very different latency profiles.

A. Executor-pushed events (order_filled / order_failed). These fire via the executor's notifier.NotifyAsync immediately after the fill tx returns a receipt. End-to-end: tx submit → receipt (~1s Sonic) → NotifyAsync POST (~100ms) → indexer fan-out → WS frame. Typically 1–2 seconds total. If you're seeing longer, check:

  • indexerNotifier.url is set on the executor's ECS task (INDEXER_NOTIFIER_URL).
  • No context deadline exceeded in the executor logs (round 6 #7 fixed this — timeout is now 5s).
  • The FE WS connection is actually subscribed to account:<addr>:c146 before the order is submitted.

BE work in progress — order_pending WS frame. We're adding an order_pending frame that fires the instant the executor accepts the POST, BEFORE the tx is submitted on-chain. End-to-end latency drops to ~50–100ms (just the POST→notifier→indexer hop, no chain wait). Shape:

{
  "type": "order_pending",
  "address": "0x…",
  "chainId": 146,
  "data": {
    "orderId": "lev_…",
    "status": "pending",
    "orderType": "open|close|swap",
    "createdAt": 1776944355
  }
}

Dispatch on type like the other account-channel frames. When it arrives, insert the row into your Open Orders table — the later order_filled / order_failed updates it in place via orderId. This is the FE-visible version of your round 6 item #8 and lands on the leverage-executor side (not indexer). PR incoming.

Until that ships, the FE-side optimistic-insert pattern below covers the gap: insert the synthetic row on POST-response, reconcile on WS frame.

B. Pure on-chain events (Deposit, Withdraw, Borrow, Repay). These don't go through the executor. The indexer picks them up by polling the chain, decoding the event, running the exposures projector, and only then emitting balance_update. Latency floor is:

chain block time (~1s) + collector poll (≤1s) + projector poll (≤1s) = 2–4s best case

That's about right for what you observed on Withdraw.

BE update (shipping now) — balance_update carries the full snapshot. You asked if balance_update could return the same schema as REST so you can drop the refetch. Done. The WS frame now embeds data.balances: AccountBalance[] with exactly the shape /mm/spot/account/balance returns (chainId, network, token, tokenAddress, totalBalance, usdValue, pnlPct). Replace your balances state directly from the frame — no REST call. Example:

{
  "type": "balance_update",
  "address": "0x…",
  "chainId": 146,
  "data": {
    "asset": "0x…", "eventSig": "Borrow", "blockNumber": 12345,
    "timestamp": 1776944355, "invalidate": true,
    "balances": [
      { "chainId": 146, "network": "Sonic",
        "token": "wS", "tokenAddress": "0x…",
        "totalBalance": "1.5", "usdValue": "0.065",
        "pnlPct": "+2.4%" }
    ]
  }
}

Degradation: if the indexer's snapshot build times out (2s), the frame falls back to the old signal-only shape (no balances field). Your FE should handle both — when balances is present, replace state; when absent, treat as invalidation and refetch. That keeps your optimistic-local-state pattern working as a backstop even if the snapshot path has hiccups.

FE-side UX patterns that fix the "few seconds" feel:

  1. Optimistic local state. When the user clicks Withdraw, immediately insert a synthetic row into the balances table with status: "pending". Don't wait for WS. When the balance_update arrives, reconcile by asset+chainId.

  2. Refetch cascade, not single refetch. After the on-chain tx is mined, don't wait only for WS. Trigger a REST refetch at t=1s, t=3s, t=8s, stop when the new balance differs from cached. WS acts as a "the answer is ready now, stop the cascade" signal, not as the primary notifier.

  3. Invalidate on tx-mined, not on WS frame. TanStack Query's onSuccess of the write (deposit/withdraw mutation) should queryClient.invalidateQueries(['balances', address]). The refetch fires immediately after the mined callback — that's the 1-2s path, independent of WS.

  4. Prefetch the WS snapshot on subscribe. Your WS client already receives a snapshot-on-subscribe for klines and orderbook. For account the indexer doesn't replay history, but your REST fetch on mount covers the initial state. Just make sure the REST call happens before the WS subscription is active, so you don't miss a fast-fire frame.

  5. If you need sub-second absolute latency, the only structural improvement is indexer→chain WebSocket subscription (push newHeads instead of polling). That's a non-trivial backend change — flag it if the UX complaints are blocking, otherwise the cascade above gets 95% of the way there for 5% of the code.

Can you test the UI yourself?

The executor's test key (SIGNER_PRIVATE_KEY) has open approvals on dev — I'll send you the user test address I've been driving the e2e shape-check against. Any order you place via that address goes through the real executor on Sonic. Use amounts ≥ 0.1 USDC and leave buyAmount=1 for market orders — the failures you've been hitting are almost certainly the dust/slippage cases in the table above.

If you want me to replicate a specific failure end-to-end, send me the exact order payload that failed (/mm/leverage/orders/:id) and I'll trace it through the executor logs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment