From 7896078872fc1a17b0568e3fedafe855dbba7800 Mon Sep 17 00:00:00 2001 From: mooncitydev Date: Tue, 19 May 2026 08:33:00 -0700 Subject: [PATCH] fix market orders using stale book prices or log page stop falling back to mark price when the book is empty, refresh the on-chain log page right before building txs, and fix the orderbook watchdog that reset its timer without new data --- .../MarketCloseModal/MarketCloseModal.tsx | 38 ++++++++++-- .../Trade/OrderInput/OrderInput.tsx | 58 +++++++++++++++--- .../app/routes/trade/orderbook/orderbook.tsx | 5 +- .../app/services/MarketOrderLogManager.ts | 14 +++++ .../app/services/cancelOrderService.ts | 13 ++-- .../app/services/limitOrderService.ts | 15 ++--- .../app/services/marketOrderService.ts | 17 ++---- .../frontend/app/stores/OrderBookStore.ts | 5 ++ packages/frontend/app/utils/Constants.ts | 3 + .../orderbook/orderBookExecution.test.ts | 59 +++++++++++++++++++ .../app/utils/orderbook/orderBookExecution.ts | 28 +++++++++ packages/frontend/locales/en/translation.json | 6 ++ 12 files changed, 217 insertions(+), 44 deletions(-) create mode 100644 packages/frontend/app/utils/orderbook/orderBookExecution.test.ts create mode 100644 packages/frontend/app/utils/orderbook/orderBookExecution.ts diff --git a/packages/frontend/app/components/Trade/MarketCloseModal/MarketCloseModal.tsx b/packages/frontend/app/components/Trade/MarketCloseModal/MarketCloseModal.tsx index a97e4cdd4..765926830 100644 --- a/packages/frontend/app/components/Trade/MarketCloseModal/MarketCloseModal.tsx +++ b/packages/frontend/app/components/Trade/MarketCloseModal/MarketCloseModal.tsx @@ -17,6 +17,7 @@ import { } from '~/utils/Constants'; import { getDurationSegment } from '~/utils/functions/getSegment'; import type { OrderBookMode } from '~/utils/orderbook/OrderBookIFs'; +import { getMarketExecutionPrice } from '~/utils/orderbook/orderBookExecution'; import type { PositionIF } from '~/utils/UserDataIFs'; import PositionSize from '../OrderInput/PositionSIze/PositionSize'; import SizeInput from '../OrderInput/SizeInput/SizeInput'; @@ -34,7 +35,7 @@ export default function MarketCloseModal({ close, position }: PropsIF) { const { symbolInfo, symbol } = useTradeDataStore(); const { executeMarketOrder } = useMarketOrderService(); - const { buys, sells } = useOrderBookStore(); + const { buys, sells, lastBookUpdateAt } = useOrderBookStore(); const [isProcessingOrder, setIsProcessingOrder] = useState(false); @@ -255,10 +256,39 @@ export default function MarketCloseModal({ close, position }: PropsIF) { setIsProcessingOrder(true); try { - // Get order book prices for the closing order const closingSide = isPositionLong ? 'sell' : 'buy'; - const bestBidPrice = buys.length > 0 ? buys[0].px : markPx; - const bestAskPrice = sells.length > 0 ? sells[0].px : markPx; + const { price: executionPrice, error: bookError } = + getMarketExecutionPrice( + closingSide, + buys, + sells, + lastBookUpdateAt, + ); + if (bookError || executionPrice === undefined) { + setIsProcessingOrder(false); + notifications.add({ + title: t( + bookError === 'no_asks' + ? 'transactions.orderBookNoAsksTitle' + : bookError === 'no_bids' + ? 'transactions.orderBookNoBidsTitle' + : 'transactions.orderBookStaleTitle', + ), + message: t( + bookError === 'no_asks' + ? 'transactions.orderBookNoAsksMessage' + : bookError === 'no_bids' + ? 'transactions.orderBookNoBidsMessage' + : 'transactions.orderBookStaleMessage', + ), + icon: 'error', + }); + return; + } + const bestBidPrice = + closingSide === 'sell' ? executionPrice : undefined; + const bestAskPrice = + closingSide === 'buy' ? executionPrice : undefined; const timeOfTxBuildStart = Date.now(); // Execute market order in opposite direction to close position diff --git a/packages/frontend/app/components/Trade/OrderInput/OrderInput.tsx b/packages/frontend/app/components/Trade/OrderInput/OrderInput.tsx index b552df469..9fe8361d8 100644 --- a/packages/frontend/app/components/Trade/OrderInput/OrderInput.tsx +++ b/packages/frontend/app/components/Trade/OrderInput/OrderInput.tsx @@ -36,6 +36,7 @@ import { type NotificationStoreIF, } from '~/stores/NotificationStore'; import { useOrderBookStore } from '~/stores/OrderBookStore'; +import { getMarketExecutionPrice } from '~/utils/orderbook/orderBookExecution'; import { usePythPrice } from '~/stores/PythPriceStore'; import { useTradeDataStore, type marginModesT } from '~/stores/TradeDataStore'; import { useUnifiedMarginStore } from '~/stores/UnifiedMarginStore'; @@ -296,7 +297,8 @@ function OrderInput({ const [selectedDenom, setSelectedDenom] = useState('usd'); - const { buys, sells, midPrice, usualResolution } = useOrderBookStore(); + const { buys, sells, midPrice, usualResolution, lastBookUpdateAt } = + useOrderBookStore(); const midPriceRef = useRef(midPrice); midPriceRef.current = midPrice; const { useMockLeverage, mockMinimumLeverage } = useDebugStore(); @@ -1419,8 +1421,26 @@ function OrderInput({ try { setIsProcessingOrder(true); - // Get best ask price for buy order - const bestAskPrice = sells.length > 0 ? sells[0].px : markPx; + const { price: bestAskPrice, error: bookError } = + getMarketExecutionPrice('buy', buys, sells, lastBookUpdateAt); + if (bookError || bestAskPrice === undefined) { + setIsProcessingOrder(false); + confirmOrderModal.close(); + notifications.add({ + title: t( + bookError === 'no_asks' + ? 'transactions.orderBookNoAsksTitle' + : 'transactions.orderBookStaleTitle', + ), + message: t( + bookError === 'no_asks' + ? 'transactions.orderBookNoAsksMessage' + : 'transactions.orderBookStaleMessage', + ), + icon: 'error', + }); + return; + } const usdValueOfOrderStr = formatNum( roundDownToHundredth(notionalQtyNum * (bestAskPrice || 1)), 2, @@ -1566,8 +1586,9 @@ function OrderInput({ } }, [ notionalQtyNum, + buys, sells, - markPx, + lastBookUpdateAt, activeOptions.skipOpenOrderConfirm, symbol, executeMarketOrder, @@ -1599,15 +1620,33 @@ function OrderInput({ const slug = makeSlug(10); try { - // Get best bid price for sell order - const bestBidPrice = buys.length > 0 ? buys[0].px : markPx; + setIsProcessingOrder(true); + const { price: bestBidPrice, error: bookError } = + getMarketExecutionPrice('sell', buys, sells, lastBookUpdateAt); + if (bookError || bestBidPrice === undefined) { + setIsProcessingOrder(false); + confirmOrderModal.close(); + notifications.add({ + title: t( + bookError === 'no_bids' + ? 'transactions.orderBookNoBidsTitle' + : 'transactions.orderBookStaleTitle', + ), + message: t( + bookError === 'no_bids' + ? 'transactions.orderBookNoBidsMessage' + : 'transactions.orderBookStaleMessage', + ), + icon: 'error', + }); + return; + } const usdValueOfOrderStr = formatNum( - Math.round(notionalQtyNum * (bestBidPrice || 1) * 100) / 100, + Math.round(notionalQtyNum * bestBidPrice * 100) / 100, 2, true, true, ); - setIsProcessingOrder(true); if (activeOptions.skipOpenOrderConfirm) { confirmOrderModal.close(); notifications.add({ @@ -1748,7 +1787,8 @@ function OrderInput({ }, [ notionalQtyNum, buys, - markPx, + sells, + lastBookUpdateAt, activeOptions.skipOpenOrderConfirm, symbol, executeMarketOrder, diff --git a/packages/frontend/app/routes/trade/orderbook/orderbook.tsx b/packages/frontend/app/routes/trade/orderbook/orderbook.tsx index 0a8705e17..fec776667 100644 --- a/packages/frontend/app/routes/trade/orderbook/orderbook.tsx +++ b/packages/frontend/app/routes/trade/orderbook/orderbook.tsx @@ -573,8 +573,11 @@ const OrderBook: React.FC = ({ console.warn( 'No orderbook updates for 5s, reconnecting...', ); + setOrderBookState(TableState.LOADING); + setWsError( + 'Order book data is stale. Waiting for live updates...', + ); forceReconnect(); - lastMessageTimeRef.current = Date.now(); } }, 6000); diff --git a/packages/frontend/app/services/MarketOrderLogManager.ts b/packages/frontend/app/services/MarketOrderLogManager.ts index 82bdcf04f..dd1ea3a15 100644 --- a/packages/frontend/app/services/MarketOrderLogManager.ts +++ b/packages/frontend/app/services/MarketOrderLogManager.ts @@ -79,6 +79,20 @@ class MarketOrderLogManager { return this.fetchData(); } + /** + * Fetch the current on-chain log page immediately before building a transaction. + * Avoids submitting orders with a stale cached page from the 30s poll interval. + */ + async getLogPageForTransaction(): Promise { + await this.forceRefresh(); + if (this.cachedLogPage === undefined) { + throw new Error( + 'Cannot fetch market order log page. Try again in a moment.', + ); + } + return this.cachedLogPage; + } + private start(): void { if (this.isPolling || !this.connection) { return; diff --git a/packages/frontend/app/services/cancelOrderService.ts b/packages/frontend/app/services/cancelOrderService.ts index 3ff215503..45f7ed909 100644 --- a/packages/frontend/app/services/cancelOrderService.ts +++ b/packages/frontend/app/services/cancelOrderService.ts @@ -58,14 +58,9 @@ export class CancelOrderService { ? params.orderId : BigInt(params.orderId); - // Get the cached market order log page to avoid RPC call - const cachedLogPage = marketOrderLogManager.getCachedLogPage(); - if (cachedLogPage !== undefined) { - console.log( - ' - Using cached marketOrderLogPage:', - cachedLogPage, - ); - } + const marketOrderLogPage = + await marketOrderLogManager.getLogPageForTransaction(); + console.log(' - marketOrderLogPage:', marketOrderLogPage); const transaction = await buildCancelOrderTransaction( this.connection, @@ -75,7 +70,7 @@ export class CancelOrderService { user: userPublicKey, actor: sessionPublicKey, rentPayer: rentPayer, - marketOrderLogPage: cachedLogPage, + marketOrderLogPage, }, ); diff --git a/packages/frontend/app/services/limitOrderService.ts b/packages/frontend/app/services/limitOrderService.ts index 66f64616f..723b915d0 100644 --- a/packages/frontend/app/services/limitOrderService.ts +++ b/packages/frontend/app/services/limitOrderService.ts @@ -96,14 +96,9 @@ export class LimitOrderService { ); } - // Get the cached market order log page to avoid RPC call - const cachedLogPage = marketOrderLogManager.getCachedLogPage(); - if (cachedLogPage !== undefined) { - console.log( - ' - Using cached marketOrderLogPage:', - cachedLogPage, - ); - } + const marketOrderLogPage = + await marketOrderLogManager.getLogPageForTransaction(); + console.log(' - marketOrderLogPage:', marketOrderLogPage); // Build the appropriate transaction based on side if (params.side === 'buy') { @@ -121,7 +116,7 @@ export class LimitOrderService { userSetImBps: userSetImBps, includesFillAtMarket: true, cancelOrderId: params.replaceOrderId, - marketOrderLogPage: cachedLogPage, + marketOrderLogPage, reduceOnly: params.reduceOnly, }; @@ -147,7 +142,7 @@ export class LimitOrderService { userSetImBps: userSetImBps, includesFillAtMarket: true, cancelOrderId: params.replaceOrderId, - marketOrderLogPage: cachedLogPage, + marketOrderLogPage, reduceOnly: params.reduceOnly, }; diff --git a/packages/frontend/app/services/marketOrderService.ts b/packages/frontend/app/services/marketOrderService.ts index 2dc57bd57..bbf5b2a95 100644 --- a/packages/frontend/app/services/marketOrderService.ts +++ b/packages/frontend/app/services/marketOrderService.ts @@ -84,14 +84,9 @@ export class MarketOrderService { console.log(' - Calculated userSetImBps:', userSetImBps); } - // Get the cached market order log page to avoid RPC call - const cachedLogPage = marketOrderLogManager.getCachedLogPage(); - if (cachedLogPage !== undefined) { - console.log( - ' - Using cached marketOrderLogPage:', - cachedLogPage, - ); - } + const marketOrderLogPage = + await marketOrderLogManager.getLogPageForTransaction(); + console.log(' - marketOrderLogPage:', marketOrderLogPage); // Calculate fill prices based on order book let fillPrice: bigint; @@ -136,7 +131,7 @@ export class MarketOrderService { // Build the appropriate transaction based on side if (params.side === 'buy') { console.log(' - Building market BUY order...'); - console.log(' - Log page:', cachedLogPage); + console.log(' - Log page:', marketOrderLogPage); const orderParams: any = { marketId: marketId, @@ -152,7 +147,7 @@ export class MarketOrderService { keeper: sessionPublicKey, userSetImBps: userSetImBps, includesFillAtMarket: true, - marketOrderLogPage: cachedLogPage, + marketOrderLogPage, reduceOnly: params.reduceOnly, }; @@ -180,7 +175,7 @@ export class MarketOrderService { keeper: sessionPublicKey, userSetImBps: userSetImBps, includesFillAtMarket: true, // Ensure fill at market is included - marketOrderLogPage: cachedLogPage, + marketOrderLogPage, reduceOnly: params.reduceOnly, }; diff --git a/packages/frontend/app/stores/OrderBookStore.ts b/packages/frontend/app/stores/OrderBookStore.ts index f39b58cef..4958a847b 100644 --- a/packages/frontend/app/stores/OrderBookStore.ts +++ b/packages/frontend/app/stores/OrderBookStore.ts @@ -23,6 +23,7 @@ interface OrderBookStore { resolutionPair: OrderRowResolutionIF, ) => void; midPrice: number | null; + lastBookUpdateAt: number | null; setMidPrice: (midPrice: number) => void; usualResolution: OrderRowResolutionIF | null; setUsualResolution: (resolution: OrderRowResolutionIF) => void; @@ -40,16 +41,19 @@ export const useOrderBookStore = create()( sells: OrderBookRowIF[], setMid?: boolean, ) => { + const lastBookUpdateAt = Date.now(); if (setMid) { set({ buys: buys, sells: sells, midPrice: (buys[0].px + sells[0].px) / 2, + lastBookUpdateAt, }); } else { set({ buys, sells, + lastBookUpdateAt, }); } }, @@ -66,6 +70,7 @@ export const useOrderBookStore = create()( })), resolutionPairs: {}, midPrice: null, + lastBookUpdateAt: null, setMidPrice: (midPrice: number) => set({ midPrice }), usualResolution: null, setUsualResolution: (resolution: OrderRowResolutionIF) => diff --git a/packages/frontend/app/utils/Constants.ts b/packages/frontend/app/utils/Constants.ts index 410af2668..2aa8b0f64 100644 --- a/packages/frontend/app/utils/Constants.ts +++ b/packages/frontend/app/utils/Constants.ts @@ -196,6 +196,9 @@ export const getTxLink = (signature?: string | null) => { */ export const MARKET_ORDER_PRICE_OFFSET_USD = 50; +/** Reject market orders if the order book has not updated within this window. */ +export const ORDER_BOOK_MAX_AGE_MS = 10_000; + export const wsUrls = [ MARKET_WS_ENDPOINT, 'wss://pulse-api-mock.liquidity.tools/ws', diff --git a/packages/frontend/app/utils/orderbook/orderBookExecution.test.ts b/packages/frontend/app/utils/orderbook/orderBookExecution.test.ts new file mode 100644 index 000000000..25945db43 --- /dev/null +++ b/packages/frontend/app/utils/orderbook/orderBookExecution.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it, vi, afterEach } from 'vitest'; +import type { OrderBookRowIF } from './OrderBookIFs'; + +const importExecution = async () => { + return await import('./orderBookExecution'); +}; + +const row = (px: number): OrderBookRowIF => ({ + coin: 'BTC', + px, + sz: 1, + n: 1, + type: 'buy', + total: 1, +}); + +describe('getMarketExecutionPrice', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('returns top of book when data is fresh', async () => { + const { getMarketExecutionPrice } = await importExecution(); + vi.useFakeTimers(); + vi.setSystemTime(1_000_000); + const now = Date.now(); + + expect( + getMarketExecutionPrice('buy', [row(99)], [row(101)], now), + ).toEqual({ price: 101 }); + expect( + getMarketExecutionPrice('sell', [row(99)], [row(101)], now), + ).toEqual({ price: 99 }); + }); + + it('rejects stale books', async () => { + const { getMarketExecutionPrice } = await importExecution(); + vi.useFakeTimers(); + vi.setSystemTime(20_000); + + expect(getMarketExecutionPrice('buy', [], [row(101)], 0)).toEqual({ + error: 'stale', + }); + }); + + it('rejects empty sides', async () => { + const { getMarketExecutionPrice } = await importExecution(); + vi.useFakeTimers(); + vi.setSystemTime(1_000_000); + const now = Date.now(); + + expect(getMarketExecutionPrice('buy', [], [], now)).toEqual({ + error: 'no_asks', + }); + expect(getMarketExecutionPrice('sell', [], [], now)).toEqual({ + error: 'no_bids', + }); + }); +}); diff --git a/packages/frontend/app/utils/orderbook/orderBookExecution.ts b/packages/frontend/app/utils/orderbook/orderBookExecution.ts new file mode 100644 index 000000000..bce891ede --- /dev/null +++ b/packages/frontend/app/utils/orderbook/orderBookExecution.ts @@ -0,0 +1,28 @@ +import { ORDER_BOOK_MAX_AGE_MS } from '../Constants'; +import type { OrderBookRowIF } from '~/utils/orderbook/OrderBookIFs'; + +export type OrderBookExecutionError = 'stale' | 'no_asks' | 'no_bids'; + +export function getMarketExecutionPrice( + side: 'buy' | 'sell', + buys: OrderBookRowIF[], + sells: OrderBookRowIF[], + lastBookUpdateAt: number | null, +): { price?: number; error?: OrderBookExecutionError } { + if ( + !lastBookUpdateAt || + Date.now() - lastBookUpdateAt > ORDER_BOOK_MAX_AGE_MS + ) { + return { error: 'stale' }; + } + if (side === 'buy') { + if (sells.length === 0) { + return { error: 'no_asks' }; + } + return { price: sells[0].px }; + } + if (buys.length === 0) { + return { error: 'no_bids' }; + } + return { price: buys[0].px }; +} diff --git a/packages/frontend/locales/en/translation.json b/packages/frontend/locales/en/translation.json index 92d0e5503..0d04f0472 100644 --- a/packages/frontend/locales/en/translation.json +++ b/packages/frontend/locales/en/translation.json @@ -88,6 +88,12 @@ "placingSellShortLimitOrderFor": "Placing limit sell order for {{usdValueOfOrderStr}} {{symbol}} at {{limitPrice}}", "enterValidLimitPrice": "Enter a valid limit price", "enterValidOrderSize": "Enter a valid order size", + "orderBookStaleTitle": "Order book unavailable", + "orderBookStaleMessage": "Live order book data is missing or out of date. Wait for the book to reconnect, then try again.", + "orderBookNoAsksTitle": "Cannot buy right now", + "orderBookNoAsksMessage": "There are no ask prices on the order book. Wait for liquidity to return before placing a market buy.", + "orderBookNoBidsTitle": "Cannot sell right now", + "orderBookNoBidsMessage": "There are no bid prices on the order book. Wait for liquidity to return before placing a market sell.", "buyLongLimitOrderPlaced": "Buy/Long Limit Order Placed", "successfullyPlacedBuyOrderFor": "Buy order placed for {{usdValueOfOrderStr}} {{symbol}} at {{limitPrice}}", "buyLongLimitOrderPending": "Buy/Long Limit Order Pending",