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.
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:
-
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. -
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 themcancelled. That's exactly OCO behavior for free. FE flags the pair with e.g.ocoGroupIdon 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.
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.urlis set on the executor's ECS task (INDEXER_NOTIFIER_URL).- No
context deadline exceededin the executor logs (round 6 #7 fixed this — timeout is now 5s). - The FE WS connection is actually subscribed to
account:<addr>:c146before 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:
-
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 thebalance_updatearrives, reconcile by asset+chainId. -
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. -
Invalidate on tx-mined, not on WS frame. TanStack Query's
onSuccessof the write (deposit/withdraw mutation) shouldqueryClient.invalidateQueries(['balances', address]). The refetch fires immediately after the mined callback — that's the 1-2s path, independent of WS. -
Prefetch the WS snapshot on subscribe. Your WS client already receives a snapshot-on-subscribe for
klinesandorderbook. Foraccountthe 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. -
If you need sub-second absolute latency, the only structural improvement is indexer→chain WebSocket subscription (push
newHeadsinstead 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.
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.