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.
Response to the structural feedback — this is the foundation; all later changes plug into it.
| 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_keylookup means the frontend talks inwS-USDC, not raw contract addresses. - Legacy
/spot/*aliases preserved for the rollout window so nothing breaks mid-deploy.
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.
- 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
[], nevernull.
Connect once, subscribe to many channels. No more one-socket-per-feed polling.
Client → server:
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+prevSeqso 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 byGET /mm/spot/ws-tokenon 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) |
- Handlers:
internal/api/spec_handlers.go - WS:
internal/ws/server.go,internal/ws/hub.go - Commit:
9c85f96(PR #12)
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
}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
marginHfTargetBpsfrom chain config (live reads fromConfigRegistry.marginHfTargetBps). Dev currently runs with15000(HF = 1.5 as the partial trigger). - Full trigger pins
target = 1.0= HF breach = closeout.
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".
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) |
{
"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…".
- Formula:
internal/onchain/format.go(LiquidationPriceEstimate) - Handler:
internal/api/spec_handlers.go(computeLiquidationPrices) - Commits:
203a168(PR #13),c3cc533(PR #15),44c4d67(PR #18)
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.
Two bugs fixed together:
totalBalanceis now divided by10^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: "". usdValuewas 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"}
]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.
MarketResolversignature extended to carrybaseDecimals/quoteDecimals.- Existing rows don't migrate themselves: run
cmd/reproject --table trades --from-block Nto re-run the projector on a historical range.
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
reprojectbinary shipped in the image (PR #16). Can be invoked viaaws ecs execute-commandfor 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.
| 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 |
data: [] — no event source yet |
account.order_update / perp_account_update WS channels |
|
volume24h on /markets |
✅ SUM from trades over trailing 24h |
| TWAP endpoints | |
pnlPct on /account/balance |
✅ FIFO cost basis vs current mark |
| Short positions liq price | ✅ Uses LendingLens.debt as A; same formula |
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 order — POST /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 order — DELETE /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.
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.
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=pendingQuery params:
user(required) —0x…hex addressstatus(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 claimsubmitting→submitted— a filler has claimed and sent the txfilled— 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.
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.
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 SSE — GET /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.
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.
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.
Indexer reads these from ECS env; both set on dev:
SPOT_ORACLE_ROUTER_ADDR = 0xe4372dB43D2814750a19b93950157AD81D93674ASPOT_LENDING_LENSE_ADDR— set, governance-managed
If either is missing, on-chain enrichment falls back to placeholders (graceful degrade; no 500s).
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)
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.
| 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 |
| 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.
-
Origin allowlist —
ws://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. -
klineschannel fix — was publishing tocandles:<marketKey>:*, which no subscriber could match. Now publishes toklines:<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.marketType—ChannelRefnow acceptsmarketTypeand 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). -
accountchannel wired up — Theorder_update/perp_account_updateTODO 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), andchainIdson subscribe expands to one subscription per chain.⚠️ Security note: the account channel still trusts the client-suppliedaddressfield. 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.
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 }| # | 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 |
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)
{ "op": "subscribe", "channels": [{"channel":"ticker","symbol":"wS-USDC","chainId":146}] } { "op": "unsubscribe", "channels": [{"channel":"ticker","symbol":"wS-USDC","chainId":146}] } { "op": "ping" }