Skip to content

Instantly share code, notes, and snippets.

@patcito
Created April 22, 2026 16:12
Show Gist options
  • Select an option

  • Save patcito/8fb781186110de73a5dcf50d64553059 to your computer and use it in GitHub Desktop.

Select an option

Save patcito/8fb781186110de73a5dcf50d64553059 to your computer and use it in GitHub Desktop.
FE gist round 4 — what shipped (items 1-15)

FE gist round 4 — what shipped

All 15 items are now done in dev (items marked 🟡 need you to verify end-to-end). Dev endpoints: REST https://api.opbalance.com/mm/spot/*, WS same host / /spot/ws.

1. Trade fee in wrong units

Fee was base units for all sides; now scaled by the sell-token's decimals so it matches the fee-currency you render. Also fee-currency tracks the side (sell → base, buy → quote). Verify: /spot/trades/:market_keyfee is human-scaled for both sides.

2. Funding rate reported as raw WAD

formatFundingRatePct was dividing the cumulative rate_index instead of the per-event delta, so you saw values like 1000000%. Now uses e.Accrued (per-event delta) divided by WAD, with the first event getting a zero baseline. Verify: /spot/funding/:assetratePct reads like "0.0042%" not scientific notation.

3–5. Simulation errors hard to map + opaque codes

Revert decoder now walks nested map/DataError shapes (previously only string-asserted ErrorData). Added reasonCodeBySig so each known selector maps to a stable FE code (ORDER_TOO_SMALL, SESSION_CAP_EXCEEDED, …). extractErrorCode recognises the ORDER_* / SESSION_CAP_* prefixes on poll responses. Verify: submit an order that should fail (sellAmount=1 wei) → response {success:false, code:"ORDER_TOO_SMALL", error:"…"}. Poll /leverage/orders/:id → same errorCode after on-chain revert.

6. maxLeverage missing on perps rows

Perps twin row in /markets-v2 now carries maxLeverage: 10 (defaultPerpMaxLeverage). Spot rows omit the field (json:"maxLeverage,omitempty"). Follow-up: wire per-asset maxLeverageBps from ftLendingLens later; 10 is a conservative default for now. Verify: /spot/markets-v2 → every perps row has maxLeverage: 10, spot rows don't.

7. Skipped per your note.

8. positions[].leverage === 1 always

Added leverage string field on Position. Value comes from on-chain LendingLens.accountValues.PnlAdjCollUSDWad / EquityUSDWad — the same formula the codebase already uses for /account/balance. Rendered as an integer string ("2", "5") matching the slider. Returns "1" when there's no debt or equity is ≤ 0 (underwater). Verify: open a 3x leveraged order in dev, hit /spot/positions/:addrleverage: "3".

9. Missing wBTC / wETH in /spot/leverage/assets

Root cause: MM refresh loop continues past any token whose LendingLense.AssetState(token) reverts, so assets not yet enabled on-chain silently dropped out of Redis. Fix merges the static config universe into the response — any LendingTokens entry missing from Redis now synthesises a row with collateralEnabled: false, ltvBps: 0. Price + balance still work for those rows. Verify: GET /mm/spot/leverage/assets?chainId=146 → wBTC + wETH rows present. FE can gray out entries with collateralEnabled: false if you want a "pending activation" cue.

10 + 13. order_update / perp_account_update never emitted

These frames arrive via the indexer's /internal/account-event webhook. The executor wasn't posting to it. Shipped:

  • New internal/notifier in leverage-executor — bounded fire-and-forget HTTP client with 64-slot semaphore, 2s timeout, body-drain-to-EOF, startup healthcheck.
  • After every fill, the executor POSTs one frame (see next section). No more two-POSTs-per-fill race.
  • Infra wired both sides' secrets + DNS in dev (opbalance-dev).

Channel: subscribe to account:<addr>:c146 (per-chain) or account:<addr> (chain-agnostic). Auth is still the client-supplied address field — the real signed-challenge is on the infra backlog.

Single order_filled / order_failed frame (was two frames)

Previous design sent order_update + perp_account_update as two independent POSTs — under an indexer hiccup they could land partially. Now one POST per terminal status with an invalidate array telling you which caches to refresh:

{
  "type": "order_filled",
  "address": "0x…",
  "chainId": 146,
  "data": {
    "orderId": "",
    "status": "filled",
    "txHash": "0x…",
    "orderType": "open",
    "filledAt": 1735689600,
    "invalidate": ["order", "positions", "balances"]
  }
}

On failure:

{
  "type": "order_failed",
  "address": "0x…",
  "chainId": 146,
  "data": {
    "orderId": "",
    "status": "failed",
    "txHash": "0x…",
    "orderType": "open",
    "error": "ORDER_TOO_SMALL: …",
    "invalidate": ["order"]
  }
}

Action for you: dispatch on type. For order_filled refresh order row + positions + balances; for order_failed refresh order row and show the toast. Don't listen for order_update / perp_account_update anymore — those names are retired on the executor side.

🟡 Verify after dev CI rolls the infra task def (~minutes after this gist lands).

11. trades WS not emitted

Added a trades publisher in the indexer — fires only on rows that actually committed (ON CONFLICT DO NOTHING replays don't re-broadcast). Frame shape mirrors api.Trade so you reuse the REST schema. Channel: trades:<SYMBOL>:c146 (untyped) / :mspot / :mperp. Plus legacy trades:<market_key>. Verify: subscribe, submit a leverage order in dev → you should see a frame within ~1-2 blocks.

12. balance_update schema mismatch

Was emitting flat top-level fields (asset, eventSig, blockNumber, …). Now wrapped to match the /internal/account-event envelope:

{
  "type": "balance_update",
  "address": "0x…",
  "chainId": 146,
  "data": {
    "asset": "0x…",
    "eventSig": "Deposit",
    "blockNumber": 12345,
    "timestamp": 1735689600,
    "invalidate": true
  }
}

All three account-channel frame types (order_filled, order_failed, balance_update) share this envelope — one dispatch on type covers all.

14. Tickers WS had all 24h fields = "0"

Indexer now fans real 24h stats into each ticker publish frame. Volume comes from trades.notional (oracle candles always have 0 volume, that was the bug). Price change uses the latest vs the candle closest to latest.bucket_start - 24h, with the same source tie-breaker (mark > dex > other) REST already uses. Stats loaded once at the top of each tick so all markets share the same snapshot. Verify: subscribe to tickers:WBTC-USDC:c146priceChange24h, priceChangePct24h, volume24h are non-zero after a few minutes of trades.

15. Klines stalls during flat markets

Candle poller only persisted/published on bucket boundary or high-low shift, so flat-price ticks dropped the klines stream. Now every same-bucket tick emits a WS delta (no DB write) so your chart keeps a heartbeat. Verify: subscribe to klines:WBTC-USDC:c146:1m on a quiet market — you should see a type:"delta" frame each second even when OHLC hasn't changed.


One-shot smoke check for all of the above

# 1. Markets list has maxLeverage on perps
curl -s 'https://api.opbalance.com/mm/spot/markets-v2?chainId=146' | jq '.data[] | select(.marketType=="perps") | {marketKey,maxLeverage}' | head

# 2. Leverage assets list has WBTC + WETH
curl -s 'https://api.opbalance.com/mm/spot/leverage/assets?chainId=146' | jq '[.data[].symbol]'

# 3. Position leverage non-1 (replace <addr>)
curl -s 'https://api.opbalance.com/mm/spot/positions/<addr>' | jq '.data.positions[] | {marketKey, leverage}'

# 4. Ticker WS snapshot
wscat -c 'wss://api.opbalance.com/spot/ws' \
  -x '{"op":"subscribe","channels":[{"name":"tickers","symbol":"WBTC-USDC","chainId":146}]}'

# 5. Account channel (see order_filled / balance_update live)
wscat -c 'wss://api.opbalance.com/spot/ws' \
  -x '{"op":"subscribe","address":"<your addr>","channels":[{"name":"account","chainIds":[146]}]}'

Ping if any surface still shows the old shape after the infra rollout settles — I'll loop back.

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