import { AccountInfo, PublicKey } from '@solana/web3.js' import mangoStore from '@store/mangoStore' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Market, Orderbook as SpotOrderBook } from '@project-serum/serum' import useLocalStorageState from 'hooks/useLocalStorageState' import { floorToDecimal, formatNumericValue, getDecimalCount, } from 'utils/numbers' import { ANIMATION_SETTINGS_KEY, USE_ORDERBOOK_FEED_KEY } from 'utils/constants' import { useTranslation } from 'next-i18next' import Decimal from 'decimal.js' import OrderbookIcon from '@components/icons/OrderbookIcon' import Tooltip from '@components/shared/Tooltip' import GroupSize from './GroupSize' import { breakpoints } from '../../utils/theme' import { useViewport } from 'hooks/useViewport' import { BookSide, BookSideType, MangoClient, PerpMarket, Serum3Market, } from '@blockworks-foundation/mango-v4' import useSelectedMarket from 'hooks/useSelectedMarket' import { INITIAL_ANIMATION_SETTINGS } from '@components/settings/AnimationSettings' import { ArrowPathIcon } from '@heroicons/react/20/solid' import { sleep } from 'utils' import { OrderbookFeed } from '@blockworks-foundation/mango-feeds' import useOrderbookSubscription from 'hooks/useOrderbookSubscription' const sizeCompacter = Intl.NumberFormat('en', { maximumFractionDigits: 6, notation: 'compact', }) const SHOW_EXPONENTIAL_THRESHOLD = 0.00001 const getMarket = () => { const group = mangoStore.getState().group const selectedMarket = mangoStore.getState().selectedMarket.current if (!group || !selectedMarket) return return selectedMarket instanceof PerpMarket ? selectedMarket : group?.getSerum3ExternalMarket(selectedMarket.serumMarketExternal) } export const decodeBookL2 = (book: SpotOrderBook | BookSide): number[][] => { const depth = 300 if (book instanceof SpotOrderBook) { return book.getL2(depth).map(([price, size]) => [price, size]) } else if (book instanceof BookSide) { return book.getL2Ui(depth) } return [] } export function decodeBook( client: MangoClient, market: Market | PerpMarket, accInfo: AccountInfo, side: 'bids' | 'asks' ): SpotOrderBook | BookSide { if (market instanceof Market) { const book = SpotOrderBook.decode(market, accInfo.data) return book } else { const decodedAcc = client.program.coder.accounts.decode( 'bookSide', accInfo.data ) const book = BookSide.from( client, market, side === 'bids' ? BookSideType.bids : BookSideType.asks, decodedAcc ) return book } } const updatePerpMarketOnGroup = (book: BookSide, side: 'bids' | 'asks') => { const group = mangoStore.getState().group const perpMarket = group?.getPerpMarketByMarketIndex( book.perpMarket.perpMarketIndex ) if (perpMarket) { perpMarket[`_${side}`] = book // mangoStore.getState().actions.fetchOpenOrders() } } const Orderbook = ({ grouping, setGrouping, }: { grouping: number setGrouping: (g: number) => void }) => { const { t } = useTranslation(['common', 'trade']) const { serumOrPerpMarket: market, baseSymbol, quoteSymbol, } = useSelectedMarket() const connection = mangoStore((s) => s.connection) const [isScrolled, setIsScrolled] = useState(false) const [tickSize, setTickSize] = useState(0) const [showBids, setShowBids] = useState(true) const [showAsks, setShowAsks] = useState(true) const [useOrderbookFeed, setUseOrderbookFeed] = useState( localStorage.getItem(USE_ORDERBOOK_FEED_KEY) !== null ? localStorage.getItem(USE_ORDERBOOK_FEED_KEY) === 'true' : true ) const orderbookElRef = useRef(null) const { width } = useViewport() const isMobile = width ? width < breakpoints.md : false const depth = useMemo(() => { return isMobile ? 9 : 40 }, [isMobile]) const verticallyCenterOrderbook = useCallback(() => { const element = orderbookElRef.current if (element) { if (element.scrollHeight > window.innerHeight) { element.scrollTop = (element.scrollHeight - element.scrollHeight) / 2 + (element.scrollHeight - window.innerHeight) / 2 + 94 } else { element.scrollTop = (element.scrollHeight - element.offsetHeight) / 2 } } }, []) const orderbookData = useOrderbookSubscription( depth, grouping, isScrolled, verticallyCenterOrderbook ) const depthArray: number[] = useMemo(() => { return Array(depth).fill(0) }, [depth]) const orderbookFeed = useRef(null) useEffect(() => { if (market && market.tickSize !== tickSize) { setTickSize(market.tickSize) setGrouping(market.tickSize) } }, [market, tickSize]) const bidAccountAddress = useMemo(() => { if (!market) return '' const bidsPk = market instanceof Market ? market['_decoded'].bids : market.bids return bidsPk.toString() }, [market]) const askAccountAddress = useMemo(() => { if (!market) return '' const asksPk = market instanceof Market ? market['_decoded'].asks : market.asks return asksPk.toString() }, [market]) // subscribe to the bids and asks orderbook accounts useEffect(() => { const set = mangoStore.getState().set const client = mangoStore.getState().client const group = mangoStore.getState().group const market = getMarket() if (!group || !market) return if (useOrderbookFeed) { if (!orderbookFeed.current) { orderbookFeed.current = new OrderbookFeed( `wss://api.mngo.cloud/orderbook/v1/`, { reconnectionIntervalMs: 5_000, reconnectionMaxAttempts: 6, } ) } let hasConnected = false orderbookFeed.current.onConnect(() => { if (!orderbookFeed.current) return console.log('[OrderbookFeed] connected') hasConnected = true orderbookFeed.current.subscribe({ marketId: market.publicKey.toBase58(), }) }) orderbookFeed.current.onDisconnect((reconnectionAttemptsExhausted) => { // fallback to rpc if we couldn't reconnect or if we never connected if (reconnectionAttemptsExhausted || !hasConnected) { console.warn('[OrderbookFeed] disconnected') setUseOrderbookFeed(false) } else { console.log('[OrderbookFeed] reconnecting...') } }) let lastWriteVersion = 0 orderbookFeed.current.onL2Update((update) => { const selectedMarket = mangoStore.getState().selectedMarket if (!useOrderbookFeed || !selectedMarket || !selectedMarket.current) return const selectedMarketKey = selectedMarket.current instanceof Serum3Market ? selectedMarket.current['serumMarketExternal'] : selectedMarket.current.publicKey if (update.market != selectedMarketKey.toBase58()) return // ensure updates are applied in the correct order by checking slot and writeVersion const lastSeenSlot = update.side == 'bid' ? mangoStore.getState().selectedMarket.lastSeenSlot.bids : mangoStore.getState().selectedMarket.lastSeenSlot.asks if (update.slot < lastSeenSlot) return if ( update.slot == lastSeenSlot && update.writeVersion < lastWriteVersion ) return lastWriteVersion = update.writeVersion const bookside = update.side == 'bid' ? selectedMarket.orderbook.bids : selectedMarket.orderbook.asks const new_bookside = Array.from(bookside) for (const diff of update.update) { // find existing level for each update const levelIndex = new_bookside.findIndex( (level) => level && level.length && level[0] === diff[0] ) if (diff[1] > 0) { // level being added or updated if (levelIndex !== -1) { new_bookside[levelIndex] = diff } else { // add new level and resort new_bookside.push(diff) new_bookside.sort((a, b) => { return update.side == 'bid' ? b[0] - a[0] : a[0] - b[0] }) } } else { // level being removed if zero size if (levelIndex !== -1) { new_bookside.splice(levelIndex, 1) } else { console.warn('[OrderbookFeed] tried to remove missing level') } } } set((state) => { if (update.side == 'bid') { state.selectedMarket.bidsAccount = undefined state.selectedMarket.orderbook.bids = new_bookside state.selectedMarket.lastSeenSlot.bids = update.slot } else { state.selectedMarket.asksAccount = undefined state.selectedMarket.orderbook.asks = new_bookside state.selectedMarket.lastSeenSlot.asks = update.slot } }) }) orderbookFeed.current.onL2Checkpoint((checkpoint) => { if ( !useOrderbookFeed || checkpoint.market !== market.publicKey.toBase58() ) return set((state) => { state.selectedMarket.lastSeenSlot.bids = checkpoint.slot state.selectedMarket.lastSeenSlot.asks = checkpoint.slot state.selectedMarket.bidsAccount = undefined state.selectedMarket.asksAccount = undefined state.selectedMarket.orderbook.bids = checkpoint.bids state.selectedMarket.orderbook.asks = checkpoint.asks }) }) return () => { if (!orderbookFeed.current) return console.log( `[OrderbookFeed] unsubscribe ${market.publicKey.toBase58()}` ) orderbookFeed.current.unsubscribe(market.publicKey.toBase58()) } } else { console.log(`[OrderbookRPC] subscribe ${market.publicKey.toBase58()}`) let bidSubscriptionId: number | undefined = undefined let askSubscriptionId: number | undefined = undefined const bidsPk = new PublicKey(bidAccountAddress) if (bidsPk) { connection .getAccountInfoAndContext(bidsPk) .then(({ context, value: info }) => { if (!info) return const decodedBook = decodeBook(client, market, info, 'bids') set((state) => { state.selectedMarket.lastSeenSlot.bids = context.slot state.selectedMarket.bidsAccount = decodedBook state.selectedMarket.orderbook.bids = decodeBookL2(decodedBook) }) }) bidSubscriptionId = connection.onAccountChange( bidsPk, (info, context) => { const lastSeenSlot = mangoStore.getState().selectedMarket.lastSeenSlot.bids if (context.slot > lastSeenSlot) { const market = getMarket() if (!market) return const decodedBook = decodeBook(client, market, info, 'bids') if (decodedBook instanceof BookSide) { updatePerpMarketOnGroup(decodedBook, 'bids') } set((state) => { state.selectedMarket.bidsAccount = decodedBook state.selectedMarket.orderbook.bids = decodeBookL2(decodedBook) state.selectedMarket.lastSeenSlot.bids = context.slot }) } }, 'processed' ) } const asksPk = new PublicKey(askAccountAddress) if (asksPk) { connection .getAccountInfoAndContext(asksPk) .then(({ context, value: info }) => { if (!info) return const decodedBook = decodeBook(client, market, info, 'asks') set((state) => { state.selectedMarket.asksAccount = decodedBook state.selectedMarket.orderbook.asks = decodeBookL2(decodedBook) state.selectedMarket.lastSeenSlot.asks = context.slot }) }) askSubscriptionId = connection.onAccountChange( asksPk, (info, context) => { const lastSeenSlot = mangoStore.getState().selectedMarket.lastSeenSlot.asks if (context.slot > lastSeenSlot) { const market = getMarket() if (!market) return const decodedBook = decodeBook(client, market, info, 'asks') if (decodedBook instanceof BookSide) { updatePerpMarketOnGroup(decodedBook, 'asks') } set((state) => { state.selectedMarket.asksAccount = decodedBook state.selectedMarket.orderbook.asks = decodeBookL2(decodedBook) state.selectedMarket.lastSeenSlot.asks = context.slot }) } }, 'processed' ) } return () => { console.log(`[OrderbookRPC] unsubscribe ${market.publicKey.toBase58()}`) if (typeof bidSubscriptionId !== 'undefined') { connection.removeAccountChangeListener(bidSubscriptionId) } if (typeof askSubscriptionId !== 'undefined') { connection.removeAccountChangeListener(askSubscriptionId) } } } }, [bidAccountAddress, askAccountAddress, connection, useOrderbookFeed]) useEffect(() => { const market = getMarket() if (!orderbookFeed.current || !market) return console.log(`[OrderbookFeed] subscribe ${market.publicKey.toBase58()}`) orderbookFeed.current.subscribe({ marketId: market.publicKey.toBase58(), }) }, [bidAccountAddress]) useEffect(() => { window.addEventListener('resize', verticallyCenterOrderbook) }, [verticallyCenterOrderbook]) const resetOrderbook = useCallback(async () => { setShowBids(true) setShowAsks(true) await sleep(300) verticallyCenterOrderbook() }, [verticallyCenterOrderbook]) const onGroupSizeChange = useCallback((groupSize: number) => { setGrouping(groupSize) }, []) const handleScroll = useCallback(() => { setIsScrolled(true) }, []) const toggleSides = (side: string) => { if (side === 'bids') { setShowBids(true) setShowAsks(false) } else { setShowBids(false) setShowAsks(true) } } return (
{market ? ( <>

{t('trade:grouping')}:

) : null}
{t('trade:size')} ({baseSymbol})
{t('price')} ({quoteSymbol})
{showAsks ? depthArray.map((_x, idx) => { let index = idx const reverse = showAsks && !showBids if (orderbookData?.asks && !reverse) { const lengthDiff = depthArray.length - orderbookData.asks.length if (lengthDiff > 0) { index = index < lengthDiff ? -1 : Math.abs(lengthDiff - index) } } return (
{!!orderbookData?.asks[index] && market ? ( ) : null}
) }) : null} {showBids && showAsks ? (
{t('trade:spread')}
{orderbookData?.spreadPercentage.toFixed(2)}%
{orderbookData?.spread ? orderbookData.spread < SHOW_EXPONENTIAL_THRESHOLD ? orderbookData.spread.toExponential() : formatNumericValue( orderbookData.spread, market ? getDecimalCount(market.tickSize) : undefined ) : null}
) : null} {showBids ? depthArray.map((_x, index) => (
{!!orderbookData?.bids[index] && market ? ( ) : null}
)) : null}
) } const OrderbookRow = ({ side, price, size, sizePercent, // invert, hasOpenOrder, minOrderSize, cumulativeSizePercent, tickSize, grouping, }: { side: 'buy' | 'sell' price: number size: number sizePercent: number cumulativeSizePercent: number hasOpenOrder: boolean // invert: boolean grouping: number minOrderSize: number tickSize: number }) => { const element = useRef(null) const [animationSettings] = useLocalStorageState( ANIMATION_SETTINGS_KEY, INITIAL_ANIMATION_SETTINGS ) const flashClassName = side === 'sell' ? 'red-flash' : 'green-flash' useEffect(() => { animationSettings['orderbook-flash'].active && !element.current?.classList.contains(`${flashClassName}`) && element.current?.classList.add(`${flashClassName}`) const id = setTimeout( () => element.current?.classList.contains(`${flashClassName}`) && element.current?.classList.remove(`${flashClassName}`), 500 ) return () => clearTimeout(id) }, [price, size]) const formattedSize = useMemo(() => { return minOrderSize && !isNaN(size) ? floorToDecimal(size, getDecimalCount(minOrderSize)) : new Decimal(size ?? -1) }, [size, minOrderSize]) const formattedPrice = useMemo(() => { return tickSize && !isNaN(price) ? floorToDecimal(price, getDecimalCount(tickSize)) : new Decimal(price) }, [price, tickSize]) const handlePriceClick = useCallback(() => { const set = mangoStore.getState().set set((state) => { state.tradeForm.price = formattedPrice.toFixed() state.tradeForm.tradeType = 'Limit' if (state.tradeForm.baseSize) { const quoteSize = floorToDecimal( formattedPrice.mul(new Decimal(state.tradeForm.baseSize)), getDecimalCount(tickSize) ) state.tradeForm.quoteSize = quoteSize.toFixed() } }) }, [formattedPrice, tickSize]) const handleSizeClick = useCallback(() => { const set = mangoStore.getState().set set((state) => { state.tradeForm.baseSize = formattedSize.toString() if (formattedSize && state.tradeForm.price) { const quoteSize = floorToDecimal( formattedSize.mul(new Decimal(state.tradeForm.price)), getDecimalCount(tickSize) ) state.tradeForm.quoteSize = quoteSize.toString() } }) }, [formattedSize, tickSize]) const groupingDecimalCount = useMemo( () => getDecimalCount(grouping), [grouping] ) const minOrderSizeDecimals = useMemo( () => getDecimalCount(minOrderSize), [minOrderSize] ) if (!minOrderSize) return null return (
<>
{size >= 1000000 ? sizeCompacter.format(size) : formattedSize.toFixed(minOrderSizeDecimals)}
{price < SHOW_EXPONENTIAL_THRESHOLD ? formattedPrice.toExponential() : formattedPrice.toFixed(groupingDecimalCount)}
) } const MemoizedOrderbookRow = React.memo(OrderbookRow) const Line = (props: { className: string invert?: boolean 'data-width': string }) => { return (
) } export default Orderbook