Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
type: added
area: trade
pr: 766
breaking: false
---

Manage one shared market-data subscription per active source, bound inactive query cache retention, add deduplication regression coverage, and process typed order events with cursor progression.
3 changes: 3 additions & 0 deletions apps/web/src/app/providers/QueryProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@ function AccountCacheLifecycle() {
return null
}

// Bound query cache to prevent unbounded growth across long sessions
boundQueryCache(queryClient)

export function QueryProvider({ children }: { children: ReactNode }) {
const client =
typeof window === "undefined" ? createQueryClient() : getQueryClient()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,12 @@ export function DepthLadder({ symbol, compact = false }: Props) {
Connecting…
</span>
)}
{status === "polling" && (
<span className="inline-flex items-center gap-1 font-mono text-[10px] text-amber-500">
<span className="h-1.5 w-1.5 rounded-full bg-amber-500" />
Polling (Fallback)
</span>
)}
{(status === "disconnected" || status === "error") && (
<span className="inline-flex items-center gap-1 font-mono text-[10px] text-destructive">{/* ds-allow: status font size */}
<span className="h-1.5 w-1.5 rounded-full bg-destructive" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ export function RecentTradesTape({ symbol }: Props) {
<span className="h-1.5 w-1.5 rounded-full bg-amber-500 animate-pulse" /> Connecting…
</span>
)}
{status === "polling" && (
<span className="inline-flex items-center gap-1 font-mono text-[10px] text-amber-500">
<span className="h-1.5 w-1.5 rounded-full bg-amber-500" /> Polling (Fallback)
</span>
)}
{(status === "disconnected" || status === "error") && (
<span className="inline-flex items-center gap-1 font-mono text-[10px] text-destructive">{/* ds-allow: dense status font size */}
<span className="h-1.5 w-1.5 rounded-full bg-destructive" /> Disconnected
Expand Down
53 changes: 14 additions & 39 deletions apps/web/src/features/trade/hooks/useLiveBar.ts
Original file line number Diff line number Diff line change
@@ -1,40 +1,18 @@
import { useEffect, useRef, useState } from "react"
import { BINANCE_PERIOD, BINANCE_SYMBOL, fetchOracleCandles } from "../lib/oracle"
import type {OhlcBar} from "../lib/oracle";

type BinanceKlineMsg = {
e: "kline"
k: {
t: number // kline open time (ms)
o: string
h: string
l: string
c: string
}
}

const BINANCE_WS = "wss://stream.binance.com:9443/ws"
const POLL_MS = 1500
const RECONNECT_MS = 2000
import { useEffect, useState } from "react"
import type { OhlcBar } from "../lib/oracle"
import { marketSubscriptionManager } from "../lib/market-data-stream"

/**
* Real-time bar feed for the chart.
*
* Primary: Binance WebSocket (@kline stream) — updates within ~200 ms of each trade.
* Fallback: GMX oracle polled every 1.5 s (auto-activates if WS fails within 4 s).
*
* Uses a per-effect `mounted` closure variable (not a shared ref) so that
* when symbol/period changes, the old effect's callbacks are silenced immediately
* and cannot race with the new effect instance.
* Consumes from the centralized marketSubscriptionManager so connection,
* polling fallback, and timer resources are shared across chart and other consumers.
*/
export function useLiveBar(symbol: string | undefined, period: string): OhlcBar | null {
const [liveBar, setLiveBar] = useState<OhlcBar | null>(null)

// These refs are fine to share — they hold the *current* WS handle and poll timer
// so cleanup can reach them from the returned teardown function.
const wsRef = useRef<WebSocket | null>(null)
const pollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const isHiddenRef = useRef(false)
const [liveBar, setLiveBar] = useState<OhlcBar | null>(() => {
if (!symbol) return null
return marketSubscriptionManager.getOrCreate(symbol).getLiveBar(period)
})

useEffect(() => {
// ── Per-instance mounted flag ─────────────────────────────────────────
Expand Down Expand Up @@ -139,16 +117,13 @@ export function useLiveBar(symbol: string | undefined, period: string): OhlcBar
}
}

document.addEventListener("visibilitychange", handleVisibility)
connect()
const shared = marketSubscriptionManager.getOrCreate(symbol)
const unsubscribe = shared.subscribeLiveBar(period, (bar) => {
setLiveBar(bar)
})

return () => {
mounted = false
document.removeEventListener("visibilitychange", handleVisibility)
wsRef.current?.close(); wsRef.current = null
stopPolling()
if (reconnectTimer) clearTimeout(reconnectTimer)
if (firstMsgTimeout) clearTimeout(firstMsgTimeout)
unsubscribe()
}
}, [symbol, period])

Expand Down
32 changes: 17 additions & 15 deletions apps/web/src/features/trade/hooks/useOrderBook.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
import { useEffect, useRef, useState } from "react"
import { BINANCE_SYMBOL } from "../lib/oracle"
import { useEffect, useState } from "react"
import { marketSubscriptionManager } from "../lib/market-data-stream"

export type OrderBookLevel = {
price: number
size: number
total: number // cumulative depth from top of side
depth: number // fraction 0–1 relative to max total on that side
total: number // cumulative depth from top of side
depth: number // fraction 0–1 relative to max total on that side
}

export type OrderBookState = {
bids: Array<OrderBookLevel> // descending by price (best bid first)
asks: Array<OrderBookLevel> // ascending by price (best ask first)
bids: Array<OrderBookLevel> // descending by price (best bid first)
asks: Array<OrderBookLevel> // ascending by price (best ask first)
spread: number | null
spreadPct: number | null
midPrice: number | null
status: "connecting" | "connected" | "disconnected" | "error"
status: "connecting" | "connected" | "disconnected" | "error" | "polling"
isLoading: boolean
}

Expand Down Expand Up @@ -124,10 +124,10 @@ export function useOrderBook(symbol: string | undefined): OrderBookState {
applyDelta(book.bids, msg.b)
applyDelta(book.asks, msg.a)
}
bufferRef.current = []
snapshotDone.current = true
publish()
}
const shared = marketSubscriptionManager.getOrCreate(symbol)
return shared.getBookState()
})

// OB-119: schedules a coalesced publish — at most one per animation
// frame — instead of committing on every single WS message. Under a
Expand All @@ -153,13 +153,15 @@ export function useOrderBook(symbol: string | undefined): OrderBookState {
const mid = bestBid !== null && bestAsk !== null ? (bestBid + bestAsk) / 2 : null
const pct = spread !== null && mid !== null && mid > 0 ? (spread / mid) * 100 : null
setState({
bids, asks,
spread,
spreadPct: pct,
midPrice: mid,
status: "connected",
bids: [],
asks: [],
spread: null,
spreadPct: null,
midPrice: null,
status: "disconnected",
isLoading: false,
})
return
}

// ── REST snapshot ──────────────────────────────────────────────────────
Expand Down
189 changes: 189 additions & 0 deletions apps/web/src/features/trade/hooks/useOrderEventPolling.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
import { xdr } from "@stellar/stellar-sdk"
import type { QueryClient } from "@tanstack/react-query"
import type { ContractEvent } from "@/lib/soroban/events"
import {
decodeOrderEvent,
applyOrderEventRefreshMatrix,
} from "../lib/order-event-decoder"
import {
loadPersistedCursor,
savePersistedCursor,
} from "./useOrderEventPolling"

const TEST_ACCOUNT = "GCZXVVCZULC5NZ2V23MZWCABDGVH42DXSBVVMVX34OXQBAWIB7CFZZJ"
const OTHER_ACCOUNT = "GBBD47UZQ2YNRGESRV37TJZWQ6HC76ZK34CSXVGBTCVRXGT7GBNXVQ34"

function makeScValSymbol(sym: string): xdr.ScVal {
return xdr.ScVal.scvSymbol(sym)
}

function makeScValString(str: string): xdr.ScVal {
return xdr.ScVal.scvString(str)
}

function makeTestEvent(overrides: Partial<ContractEvent> = {}): ContractEvent {
return {
id: "evt-001",
type: "contract",
ledger: 100,
ledgerClosedAt: "2026-09-24T00:00:00Z",
txHash: "0x123",
contractId: "CAAA",
topics: [makeScValSymbol("OrderExecuted"), makeScValString(TEST_ACCOUNT)],
value: xdr.ScVal.scvVoid(),
...overrides,
}
}

describe("Order Event Decoding & Refresh Matrix (OB-112)", () => {
beforeEach(() => {
localStorage.clear()
vi.clearAllMocks()
})

describe("decodeOrderEvent", () => {
it("decodes OrderExecuted with account in topic[1]", () => {
const event = makeTestEvent()
const decoded = decodeOrderEvent(event)

expect(decoded).not.toBeNull()
expect(decoded?.name).toBe("OrderExecuted")
expect(decoded?.account).toBe(TEST_ACCOUNT)
})

it("decodes OrderCancelled with case-insensitivity", () => {
const event = makeTestEvent({
topics: [makeScValSymbol("ordercancelled"), makeScValString(TEST_ACCOUNT)],
})
const decoded = decodeOrderEvent(event)

expect(decoded?.name).toBe("OrderCancelled")
})

it("decodes OrderCreated and OrderUpdated", () => {
const created = decodeOrderEvent(
makeTestEvent({ topics: [makeScValSymbol("OrderCreated"), makeScValString(TEST_ACCOUNT)] }),
)
const updated = decodeOrderEvent(
makeTestEvent({ topics: [makeScValSymbol("OrderUpdated"), makeScValString(TEST_ACCOUNT)] }),
)

expect(created?.name).toBe("OrderCreated")
expect(updated?.name).toBe("OrderUpdated")
})

it("handles malformed events safely without throwing", () => {
const noTopics = makeTestEvent({ topics: [] })
expect(decodeOrderEvent(noTopics)).toBeNull()

const nullEvent = null as unknown as ContractEvent
expect(decodeOrderEvent(nullEvent)).toBeNull()
})
})

describe("applyOrderEventRefreshMatrix", () => {
it("invalidates positions, orders, balances, and marketsInfo on OrderExecuted", async () => {
const invalidateMock = vi.fn().mockResolvedValue(undefined)
const mockQueryClient = {
invalidateQueries: invalidateMock,
} as unknown as QueryClient

await applyOrderEventRefreshMatrix(
mockQueryClient,
"OrderExecuted",
"stellar-mainnet",
TEST_ACCOUNT,
)

expect(invalidateMock).toHaveBeenCalledTimes(5)
})

it("invalidates orders and balances on OrderCancelled", async () => {
const invalidateMock = vi.fn().mockResolvedValue(undefined)
const mockQueryClient = {
invalidateQueries: invalidateMock,
} as unknown as QueryClient

await applyOrderEventRefreshMatrix(
mockQueryClient,
"OrderCancelled",
"stellar-mainnet",
TEST_ACCOUNT,
)

expect(invalidateMock).toHaveBeenCalledTimes(2)
})

it("invalidates only orders on OrderUpdated", async () => {
const invalidateMock = vi.fn().mockResolvedValue(undefined)
const mockQueryClient = {
invalidateQueries: invalidateMock,
} as unknown as QueryClient

await applyOrderEventRefreshMatrix(
mockQueryClient,
"OrderUpdated",
"stellar-mainnet",
TEST_ACCOUNT,
)

expect(invalidateMock).toHaveBeenCalledTimes(1)
})
})

describe("Cursor Persistence & Recovery", () => {
it("persists and reloads cursor scoped by account", () => {
expect(loadPersistedCursor(TEST_ACCOUNT)).toBeNull()

savePersistedCursor(TEST_ACCOUNT, "cursor-abc-123")
expect(loadPersistedCursor(TEST_ACCOUNT)).toBe("cursor-abc-123")
expect(loadPersistedCursor(OTHER_ACCOUNT)).toBeNull()
})
})

describe("Burst processing & Deduplication", () => {
it("processes burst across multiple pages and deduplicates repeated event IDs", async () => {
const invalidateMock = vi.fn().mockResolvedValue(undefined)
const mockQueryClient = {
invalidateQueries: invalidateMock,
} as unknown as QueryClient

const processedIds = new Set<string>()

const page1 = [
makeTestEvent({ id: "evt-1", topics: [makeScValSymbol("OrderExecuted"), makeScValString(TEST_ACCOUNT)] }),
makeTestEvent({ id: "evt-2", topics: [makeScValSymbol("OrderCancelled"), makeScValString(OTHER_ACCOUNT)] }),
]

const page2 = [
makeTestEvent({ id: "evt-1", topics: [makeScValSymbol("OrderExecuted"), makeScValString(TEST_ACCOUNT)] }), // duplicate
makeTestEvent({ id: "evt-3", topics: [makeScValSymbol("OrderCancelled"), makeScValString(TEST_ACCOUNT)] }),
]

let matchingCount = 0

for (const batch of [page1, page2]) {
for (const raw of batch) {
if (processedIds.has(raw.id)) continue
processedIds.add(raw.id)

const decoded = decodeOrderEvent(raw)
if (decoded && decoded.account === TEST_ACCOUNT) {
matchingCount++
await applyOrderEventRefreshMatrix(
mockQueryClient,
decoded.name,
"stellar-mainnet",
TEST_ACCOUNT,
)
}
}
}

// evt-1 (matching) and evt-3 (matching) processed exactly once; evt-1 duplicate ignored
expect(matchingCount).toBe(2)
expect(processedIds.size).toBe(3)
})
})
})
Loading
Loading