Skip to content

Instantly share code, notes, and snippets.

@patcito
Last active April 18, 2026 12:30
Show Gist options
  • Select an option

  • Save patcito/07a4d9a2479bb16a130f8991bdc4d732 to your computer and use it in GitHub Desktop.

Select an option

Save patcito/07a4d9a2479bb16a130f8991bdc4d732 to your computer and use it in GitHub Desktop.
Spot API — structural overhaul + enrichment + risk fields (PRs #12-#18)

Spot API — changelog 2026-04-16 → 2026-04-17

Everything that landed on spot-trs-indexor in the last 24 h. Base URL on dev: https://api.opbalance.com/mm/spot. Paths below are as they appear through the ft-api proxy.


1. Structural overhaul (PR #12 — 9c85f96)

Response to the structural feedback — this is the foundation; all later changes plug into it.

REST surface (REST-0 .. REST-12)

Method Path Purpose
GET /chains Configured chain list with RPC / explorer URLs
GET /markets?chainId= Market metadata + spot/perps twin rows
GET /market/klines?symbol=&resolution=&from=&to= OHLCV candles, unix-ms
GET /ticker/:symbol?chainId= Single ticker + 24 h stats
GET /tickers?chainId= All tickers, batch
GET /account/trades?address=&chainId=&page=&limit= Paginated trade history
GET /account/orders?address=&chainId=&page=&limit= Paginated open orders (stub — no index yet)
GET /account/twap?address=&chainId=&page=&limit= Paginated TWAP orders (stub — no infra yet)
GET /account/funding?address=&asset=&page=&limit= Paginated funding events
GET /account/balance?address=&chainId= Per-token balance + usdValue
GET /account/positions?address=&chainId= Open positions with risk fields (see §2)
POST /order 501 — executor owns wallet-signed writes
POST /order/preview Sim-only: orderValue, marginRequired
DELETE /order/:orderId 501 — executor owns wallet-signed writes
  • Symbol-based routing: (symbol, chainId) → market_key lookup means the frontend talks in wS-USDC, not raw contract addresses.
  • Legacy /spot/* aliases preserved for the rollout window so nothing breaks mid-deploy.

Pagination envelope

All list endpoints return:

{
  data: T[],
  pagination: {
    total: number,
    page: number,
    limit: number,
    hasMore: boolean,
  }
}

page (1-based) and limit (max 500, default 50) are accepted as query params. Totals come from a live COUNT(*) on the server, not from a length-of-slice.

Timestamps & numeric conventions

  • Time: unix milliseconds as a JSON number (time.Time.UnixMilli()). No more ISO strings in list endpoints.
  • Prices / sizes / fees: JSON strings to preserve on-chain precision. Never a JS number.
  • IDs, chainId, leverage, pagination fields: JSON number.
  • Empty result sets: serialize as [], never null.

WebSocket overhaul (/ws)

Connect once, subscribe to many channels. No more one-socket-per-feed polling.

Client → server:

{ "op": "subscribe",   "channels": [{"channel":"ticker","symbol":"wS-USDC","chainId":146}] }
{ "op": "unsubscribe", "channels": [{"channel":"ticker","symbol":"wS-USDC","chainId":146}] }
{ "op": "ping" }

Server → client:

{ "op": "pong" }

// Any data message — unified envelope
{
  "channel":  "ticker",
  "symbol":   "wS-USDC",
  "chainId":  146,
  "network":  "Sonic",
  "seq":      42,     // monotonic per connection
  "prevSeq":  41,     // so client can detect gap + reorder
  "data":     { /* channel-specific payload */ }
}
  • Per-connection monotonic seq + prevSeq so clients can detect gaps after reconnect and resync.
  • Snapshot-on-subscribe where it makes sense (ticker, orderbook).
  • Bounded per-connection send queue — slow consumers get disconnected cleanly.
  • JWT auth gate on user:<addr> channels (routed by GET /mm/spot/ws-token on ft-api; short-lived).

Channels landed:

Channel Snapshot? Notes
ticker yes Broadcasts on oracle tick
klines:<resolution> yes Candle close / update
trades no Per-trade stream
depth:<market> yes Snapshot + per-side deltas
account.balance_update no Requires user: auth
account.position_update no Requires user: auth
account.order_update TODO (stub subscribe)
account.perp_account_update TODO (stub subscribe)

Source

  • Handlers: internal/api/spec_handlers.go
  • WS: internal/ws/server.go, internal/ws/hub.go
  • Commit: 9c85f96 (PR #12)

2. Risk fields on /account/positions (PRs #13–#15, #18)

After the structural PR, a user row on /account/positions was shape- correct but the risk fields were "0" placeholders. Now every position carries:

{
  // ... existing fields ...
  healthFactor:             string    // account-wide, equity / maint. "999999" when no debt.
  partialLiquidationPrice:  string    // price at which HF < target (soft trigger)
  fullLiquidationPrice:     string    // price at which HF = 1.0 (full closeout)
  liquidationPrice:         string    // legacy alias = partialLiquidationPrice
  liquidationPriceReason?:  string    // omitempty — present only when degraded
}

Liquidation-price math (single-asset, correct closed form)

Derived from ftDNMM's authoritative risk model — verified against contracts/utils/RiskLib.sol + contracts/LendingLens.sol + the AccountSnapshot struct:

HF(P)     = equity(P) / maint(P)
equity(P) = E_other ± A · P        (+ long / − short)
maint(P)  = M_other + A · m · P

Long:  P_liq = (target · M_other − E_other) / (A · (1 − target · m))
Short: P_liq = (E_other − target · M_other) / (A · (1 + target · m))

where A = user's total base-asset coll (from PositionsManager.collateral), m = mmBps / BPS, and M_other, E_other are the maint/equity contributions from every other asset at current prices.

  • Partial trigger uses marginHfTargetBps from chain config (live reads from ConfigRegistry.marginHfTargetBps). Dev currently runs with 15000 (HF = 1.5 as the partial trigger).
  • Full trigger pins target = 1.0 = HF breach = closeout.

Precision is picked by price magnitude, not token decimals

Token decimals ≠ price magnitude (wS, S, stS are all 18-dec and sub-dollar; WBTC is 8-dec at $75k). Render scale is now:

Price range Fraction digits
≥ $1000 2 dp
$1 – $1000 4 dp
$0.01 – $1 5 dp
$0.0001 – $0.01 7 dp
< $0.0001 9+ dp

So a wS position now renders as partialLiquidationPrice: "0.02021" instead of the useless "0.02".

liquidationPriceReason (diagnostic)

omitempty — appears only when we had to degrade. Helps you distinguish "position is genuinely safe from this asset's price alone" (absent, price "0") from "we couldn't compute" (reason set, price "0").

Value Meaning
account_values_unavailable LendingLens.accountValues reverted
asset_cfg_unavailable LendingLens.assetCfg reverted (no mmBps)
user_collateral_unavailable PositionsManager.collateral reverted / zero
mark_price_unavailable No live mark price for the base asset
user_debt_unavailable LendingLens.debt returned zero (shorts)

Sample response (live for test wallet)

{
  "symbol": "wS-USDC",
  "side": "long",
  "size": "0.000000000000000003",
  "sizeCurrency": "wS",
  "entryPrice": "50000000000000000",
  "healthFactor": "2.3296",
  "partialLiquidationPrice": "0.02021",
  "fullLiquidationPrice": "0.01581",
  "liquidationPrice": "0.02021",
  "leverage": 2,
  "margin": "0",
  "marginCurrency": "USD",
  "marginType": "cross",
  "contractAddress": "0x8f143d84ebf0751e56437a62bab0528d1c8657bf"
}

Note: the size = 3 raw wei and entryPrice = 5×10¹⁶ look weird because the test user's three trades are literally 1 raw wei of wS each. Sanity check: size · entryPrice = 0.15 USD = debt ✓. Real-sized trades (e.g. 1 wS = 1e18 raw) render as size: "1", entryPrice: "0.045…".

Source

  • Formula: internal/onchain/format.go (LiquidationPriceEstimate)
  • Handler: internal/api/spec_handlers.go (computeLiquidationPrices)
  • Commits: 203a168 (PR #13), c3cc533 (PR #15), 44c4d67 (PR #18)

3. Market enrichment on /markets (PR #15)

What used to be "0" placeholder is now live for spot rows:

{
  symbol: "wS-USDC",
  marketType: "spot",
  marketCap:         "10874864.5551",  // totalSupply × oracle / 10^decimals
  openInterest:      "30.0317",         // LendingLens.astate.borrows × price
  fundingRate:       "57.6597%",        // IRM.borrowAPR(asset, util)
  priceChange24h:    "+0.0020",         // from 24h candle window
  priceChangePct24h: "+4.5677%",        // same
  volume24h:         "30.1024"           // SUM(notional) over last 24h from trades
}

Perps twin rows still emit marketCap: "0" (perps have no supply). Other fields populate for both.


4. /account/balance — humanized totals + correct symbols (PR #15)

Two bugs fixed together:

  • totalBalance is now divided by 10^decimals: "3563980235052143345""3.563980235052143345" for wS at 18 dec.
  • Quote tokens (e.g. USDC on a debt row) now resolve their symbol instead of rendering as token: "".
  • usdValue was already correct; keeps falling back to ticker markPrice if oracle reverts.

Sample:

[
  {"token":"wS",   "totalBalance":"3.563980235052143345", "usdValue":"0.1642"},
  {"token":"USDC", "totalBalance":"-0.153",               "usdValue":"0"}
]

5. Decimal-aware trade projector (PR #15)

Historical trades were stored with raw integer ratios as price (e.g. "50000" for a 0.05 USDC / 1 wei trade). The projector now humanizes each leg by its token's decimals before computing price/notional.

  • MarketResolver signature extended to carry baseDecimals / quoteDecimals.
  • Existing rows don't migrate themselves: run cmd/reproject --table trades --from-block N to re-run the projector on a historical range.

6. Caching (server-side, transparent)

Transparent TTL cache on all on-chain reads — no client-visible impact beyond slightly faster repeat requests:

  • Oracle prices: 5 s
  • assetCfg, accountValues, astate, userAssetCollateral: 30 s
  • Failures cached briefly (~5 s) so reverts don't hammer the RPC
  • Per-key mutex coalesces concurrent readers — no thundering herd

7. Operational (not user-visible)

  • reproject binary shipped in the image (PR #16). Can be invoked via aws ecs execute-command for historical fixes, no redeploy.
  • pgx conn-busy fix in reproject (PR #17). The tool used to die mid-run when the raw-events cursor and trade INSERTs fought over one connection.

Status of your original asks (TL;DR)

Ask Status
WS overhaul (avoid REST polling) ✅ PR #12 — ws multiplex + snapshot + seq
REST path consolidation ✅ PR #12 — unprefixed paths, legacy aliases
Pagination ✅ PR #12 — Paginated[T] envelope everywhere
Unix-ms timestamps, string prices ✅ PR #12
Symbol-based routing ✅ PR #12
Populated market metadata ✅ PR #15 — marketCap, openInterest, fundingRate
Populated risk fields ✅ PR #13–15 — HF, partial/full liq price, reason
Humanized balances ✅ PR #15
Decimal-correct trade prices ✅ PR #15 (+ reproject for history)
Write endpoints (POST/DELETE /order*) ❌ Intentional 501 — call the executor directly
/account/orders, /account/twap reads ⚠️ Envelope live, data: [] — no event source yet
account.order_update / perp_account_update WS channels ⚠️ Subscribe accepted, no events yet
volume24h on /markets ✅ SUM from trades over trailing 24h
TWAP endpoints ⚠️ Placeholder — no on-chain infra
pnlPct on /account/balance ✅ FIFO cost basis vs current mark
Short positions liq price ✅ Uses LendingLens.debt as A; same formula

What we didn't ship — and why

🛑 POST /order, DELETE /order/:orderId → HTTP 501

Not an omission, a deliberate design call. These are wallet-signed writes: the caller has to produce a signature the contract accepts. The indexer has no wallet, no key, and no business being a signer. If we proxied the call to leverage-executor here, you'd just pay an extra network hop for zero added value, and we'd enlarge the indexer's attack surface (anything holding a key is a liability).

Call leverage-executor directly. The 501 body returns a small JSON hint pointing at the executor so nobody ships a broken client by accident.

How to submit an orderPOST /leverage/orders on the executor:

POST https://api.opbalance.com/lev/leverage/orders
Content-Type: application/json

{
  "action": 0,                          // 0 = open, 1 = close, 2 = swap
  "user":   "0x…",                      // order owner
  "order":  { /* LeverageOrder EIP-712 struct */ },
  "session":   { /* LeverageSessionCall */ },
  "signature": "0x…"                    // owner EIP-712 sig over the order
}

How to cancel a pending orderDELETE /leverage/orders/{id} on the executor:

DELETE https://api.opbalance.com/lev/leverage/orders/<orderId>
Content-Type: application/json

{
  "cancelSignature": "0x…"              // EIP-712 sig from the owner
}

The cancel signature is EIP-712 over the typed-data struct:

CancelOrder(string orderId)

…with the same domain separator the executor uses for session calls (same chainId, same verifying-contract). The executor recovers the signer from the signature and checks it matches order.User.

Cancel rules:

  • Only works on pending orders (status pending). Once the order has been submitted on-chain it cannot be cancelled — you have to submit an opposite leverage order (action: 1 = close).
  • Signature must come from the order owner; anyone else → 403.
  • Orders that are already expired / failed / cancelled → 400.

On success you get:

{ "success": true, "orderId": "", "status": "cancelled" }

…and the executor also broadcasts order:cancelled on its SSE stream (GET /leverage/orders/stream) so your open-orders UI can update without a refetch. The indexer does not emit a cancelled event — cancellation happens entirely in the executor's DB, never touches the chain.

🚫 Closing a filled position ≠ cancel

If the order already filled on-chain, it's not "cancellable" — it's a position. To get out, submit a new order with action: 1 (close). That's another POST /leverage/orders call with a close intent; executor signs + submits it the same way.

⚠️ /account/orders on the indexer returns data: []

Don't use the indexer's /account/orders for pending / active orders. Orders live in the executor's database until they fill on-chain — the indexer has no visibility into pre-fill state.

What to call instead — the executor's orders list:

GET https://api.opbalance.com/lev/leverage/orders?user=0x…&status=pending

Query params:

  • user (required) — 0x… hex address
  • status (optional) — pending | submitting | submitted | filled | failed | cancelled | expired. Omit for all states.

Response:

{
  success: true,
  orders: Array<{
    id: string
    user: string
    order: LeverageOrder            // the signed struct
    session: LeverageSessionCall
    hasSignature: boolean           // actual sig redacted
    status: OrderStatus
    isOpen: boolean
    orderType: "open" | "close" | "swap"
    createdAt: number               // unix-ms
    updatedAt: number
    filledAt?: number
    txHash?: string                 // set when filled on-chain
    errorMessage?: string
  }>
}

Lifecycle on that same endpoint:

  • pending — signed, waiting on a filler to claim
  • submittingsubmitted — a filler has claimed and sent the tx
  • filled — on-chain confirmation. At this point the trade also shows up on the indexer's /mm/spot/account/trades?address=… and (for position-mutating actions) /mm/spot/account/positions?address=….
  • failed / cancelled / expired — terminal states

Why we didn't mirror this into the indexer. Orders are a mutable pre-chain state the executor owns. Mirroring would introduce two sources of truth and a sync bug surface. Keep the indexer for immutable on-chain derivations (fills, exposures, funding, positions).

When the order flips to filled, switch to the indexer for the resulting trade / position data — that's the hand-off.

⚠️ /account/twap returns data: []

No on-chain TWAP engine exists on our protocol. A TWAP feature needs a keeper-run service that slices the parent order into child orders and submits them over time. That service hasn't been built and is a product decision pending, not an indexer bug.

For now: hide the TWAP section of the UI until the service is announced. When it lands the endpoint shape is already final, so it's a feature-flag flip — no client refactor.

⚠️ WS channels account.order_update, account.perp_account_update

The indexer's WS accepts the subscribe (so the client stays in sync with the subscription map and doesn't crash), but emits nothing because the underlying event sources aren't wired yet.

In the meantime — use the executor's SSE stream for pre-fill order lifecycle, and the indexer's existing account.position_update / account.balance_update WS channels for on-chain state.

Executor SSEGET /leverage/orders/stream:

GET https://api.opbalance.com/lev/leverage/orders/stream
Accept: text/event-stream
Authorization: Bearer <JWT>

Events (JSON payload):

type When
order:new A new signed intent hit the executor
order:claimed A filler grabbed it
order:released Filler gave up the claim
order:filled On-chain confirmation — also triggers indexer events
order:cancelled Owner signed a cancel

Each event wraps the redacted order view (same shape as GET /leverage/orders rows).

Mapping to the future indexer channel — when we build account.order_update on the indexer, it will carry the same union of event types but under the unified WS envelope. Plan to flip the subscription from SSE to WS with minimal refactor: the data payload will be identical modulo the envelope.

account.perp_account_update — when a perp engine ships, this will emit per-user perp margin / PnL updates. Today the ftDNMM perp engine is the same account-level accountValues the indexer already surfaces, so the account.position_update channel already covers the "my position changed" use case for spot-margined users. Only if / when we add an isolated-margin perp engine will this channel carry net-new data.

⚠️ liquidationPriceReason on a position

When an upstream on-chain read fails, the three liq-price fields degrade to "0" and the response carries a diagnostic slug so you can tell "safe" from "unknown":

if (position.liquidationPriceReason) {
  // Hide the liq price row, show "—" or "Calculating…".
  // healthFactor is still live and meaningful here.
} else if (position.partialLiquidationPrice === "0") {
  // Genuinely safe from this asset's price alone → "Safe".
} else {
  // Show the price.
}

healthFactor is correct in every case — it comes from LendingLens.accountValues directly and doesn't depend on per-asset RPCs. Render HF always; show the per-asset liq price only when meaningful.

✅ Anti-pattern we deliberately rejected

You flagged REST polling as the thing to avoid — agreed, and the WS envelope is now the canonical stream for ticker / trades / depth / account state. REST endpoints return the same shape as the WS data field per channel, so an initial REST load + WS subscribe is the right hydration pattern. Please don't build REST polling loops over the new endpoints — use them for the first paint, then switch to the stream.


Env (for reference)

Indexer reads these from ECS env; both set on dev:

  • SPOT_ORACLE_ROUTER_ADDR = 0xe4372dB43D2814750a19b93950157AD81D93674A
  • SPOT_LENDING_LENSE_ADDR — set, governance-managed

If either is missing, on-chain enrichment falls back to placeholders (graceful degrade; no 500s).


Commits on main

05f34e0 feat(api): volume24h from trades, pnlPct via FIFO, short liq price (#19)
44c4d67 fix(api): pick liq-price precision by magnitude, not token decimals (#18)
d5a9c66 fix(reproject): avoid pgx "conn busy" from single-tx streaming (#17)
856357e chore(docker): ship reproject binary in image (#16)
c3cc533 fix(api): correct liq-price formula, scale balances + trade prices (#15)
72d11d5 fix(onchain): correct single-asset liquidation-price formula (#14)
203a168 feat(api): add healthFactor + liquidationPrice to /account/positions (#13)
9c85f96 feat: implement /tmp/spot-spec.md REST + multiplexed WS protocol (#12)

Update 2026-04-18 — FE integration round (commit ad21fd3)

Closes 8 of the 10 items from the FE feedback gist. PR on ft-api and ft-relayer still pending review; indexer side is live on dev once CI finishes rolling the image.

REST (indexer)

Change Endpoint Shape
Added icon URLs GET /markets-v2 baseTokenIconUrl, quoteTokenIconUrl as "/public/tokens/<lower>.png" so the FE host prefixes its own origin
New GET /account/market-context?address=&symbol=&marketType=&chainId= { baseToken, availableBalance, availableBalanceCurrency, currentPosition, currentPositionCurrency } — single call for the trading panel

REST (ft-api proxy, PR #444)

Change Endpoint
New GET /mm/spot/leverage/assets?address=&chainId= — returns [{symbol, name, logoUrl, contractAddress, decimals, collateralEnabled, ltvBps, isStable, userBalance, userBalanceRaw, userBalanceUsd, priceUsd}]

Backed by the existing /mm/assets Redis registry (same source as /mm/lend) + batched /balance/multi-chain Multicall3 + GetPrice. No new RPC paths. Also mounted at /spot/leverage/assets for direct use.

WebSocket

  • Origin allowlistws://localhost:*, https://*.flyingtulip.com, https://*.vercel.app, http://127.0.0.1:* accepted on dev. Exact- match, subdomain wildcard, and port-wildcard patterns all supported. No more proxy workaround.

  • orderbook.spreadPct — now a plain numeric string ("0.0999") instead of "0.0999%". Format with the unit on your side.

  • klines channel fix — was publishing to candles:<marketKey>:*, which no subscriber could match. Now publishes to klines:<SYMBOL>:c<chainID>:<resolution>, matching the subscribe key exactly. Snapshot-on-subscribe sends the last 300 candles on the initial frame so chart libs render immediately:

    { "type": "snapshot", "symbol": "WBTC-USDC", "chainId": 146,
      "interval": "1h",
      "candles": [{ "t": 1744925400, "o": "67012.34", "h": "67210.00",
                    "l": "66980.11", "c": "67105.55", "v": "0" }] }
  • tickers.marketTypeChannelRef now accepts marketType and folds it into the subscription key. Aliases "perps" and "perpetual" canonicalize to "perp" so REST ("perps") and WS ("perp") stay aligned. Omitting the field subscribes to the untyped/any stream (current behavior, no breaking change).

  • account channel wired up — The order_update / perp_account_update TODO frame is gone. The executor now has a webhook it can POST to (indexer-side only for now — executor still needs to call it):

    POST /internal/account-event
    Header: X-Spot-Webhook-Token: <shared-secret>
    Body:   { "type": "order_update", "address": "0x…",
              "chainId": 146, "data": { … } }
    

    The indexer fans out to every socket subscribed to account:<addr>:c<chainID> with the standard envelope shape. Subscription keys now include the user address (they didn't before), and chainIds on subscribe expands to one subscription per chain.

    ⚠️ Security note: the account channel still trusts the client-supplied address field. Before prod, add a signed challenge (SIWE / short-lived JWT) so a socket can't subscribe to someone else's feed. The webhook itself is protected by the shared secret, but subscription authz needs work.

ft-relayer (PR #28 on flyingtulipdotcom/ft-relayer)

New intent type SPOT_COLLATERAL_SWAP_SESSION_INTENT for on-chain spot swaps (LeverageRfqEngine.swapCollateralFlashWithSession). Same session-key shape as LEVERAGE_OPEN_SESSION_INTENT; just a different ABI method + fillTarget/fillData for the external-venue flash-fill. Config goes in config.*.json:

{ "type": "SPOT_COLLATERAL_SWAP_SESSION_INTENT",
  "contract": "0x…", "blockchain": "sonic",
  "signer": "sonic", "max_gas": 500000 }

Still open

# Item Why
3 clarify /mm/spot/lev/leverage/ scope endpoint doesn't exist as written; closest is /mm/leverage/orders (leverage-only). Tell me if you want a market-type filter there or a new spot-orders pipeline
10 executor-side order_update wiring indexer webhook is ready; needs a matching POST in leverage-executor. One more PR after the indexer deploys on dev

Commits in this round

ad21fd3 feat(spot,ws,api): FE integration round — icons, market-context,
        klines snapshot, origin allowlist, account-event webhook (indexer)
e763609 feat(spot): GET /spot/leverage/assets for FE trading panel (ft-api, PR #444)
985ceb4 feat(spot): SPOT_COLLATERAL_SWAP_SESSION_INTENT (ft-relayer, PR #28)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment