diff --git a/app/components/UI/AssetOverview/Price/Price.advanced.test.tsx b/app/components/UI/AssetOverview/Price/Price.advanced.test.tsx index b61e870b882..239a52ce220 100644 --- a/app/components/UI/AssetOverview/Price/Price.advanced.test.tsx +++ b/app/components/UI/AssetOverview/Price/Price.advanced.test.tsx @@ -89,6 +89,10 @@ jest.mock('../../Charts/AdvancedChart/useOHLCVChart', () => ({ useOHLCVChart: (...args: unknown[]) => mockUseOHLCVChart(...args), })); +jest.mock('../../Charts/AdvancedChart/useOHLCVRealtime', () => ({ + useOHLCVRealtime: () => ({ latestBar: null }), +})); + jest.mock('../../Charts/AdvancedChart/TimeRangeSelector', () => { // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires const { View, Pressable, Text } = require('react-native'); diff --git a/app/components/UI/AssetOverview/Price/Price.advanced.tsx b/app/components/UI/AssetOverview/Price/Price.advanced.tsx index 0477302d54e..e1490cb7ef3 100644 --- a/app/components/UI/AssetOverview/Price/Price.advanced.tsx +++ b/app/components/UI/AssetOverview/Price/Price.advanced.tsx @@ -36,6 +36,7 @@ import TimeRangeSelector, { type TimeRange, } from '../../Charts/AdvancedChart/TimeRangeSelector'; import { useOHLCVChart } from '../../Charts/AdvancedChart/useOHLCVChart'; +import { useOHLCVRealtime } from '../../Charts/AdvancedChart/useOHLCVRealtime'; import { OHLCVBar } from '../../Charts/AdvancedChart/OHLCVBar/OHLCVBar'; import { Box, @@ -65,6 +66,19 @@ import { const EMPTY_INDICATORS: IndicatorType[] = []; +/** + * Maps UI time-range selections to the WebSocket candle interval used by + * OHLCVService. These match the default intervals the REST OHLCV API returns + * for each timePeriod. + */ +const WS_INTERVAL_BY_TIME_RANGE: Record = { + '1H': '1m', + '1D': '15m', + '1W': '1h', + '1M': '1d', + '1Y': '1d', +}; + const TIME_RANGE_LABELS: Record = { '1H': 'asset_overview.chart_time_period.1h', '1D': 'asset_overview.chart_time_period.1d', @@ -293,6 +307,37 @@ const PriceAdvanced = ({ vsCurrency: currentCurrency, }); + const wsInterval = WS_INTERVAL_BY_TIME_RANGE[timeRange]; + // TODO: Check if we want to add a feature flag to gate the WS OHLCV feature + const wsEnabled = + !chartLoading && + ohlcvData.length >= CHART_DATA_THRESHOLD && + !hasEmptyData && + !chartError; + + const { latestBar } = useOHLCVRealtime({ + assetId, + interval: wsInterval, + currency: currentCurrency, + timePeriod: timeRange.toLowerCase(), + enabled: wsEnabled, + }); + + // TradingView Advanced Charts Bar.time expects milliseconds + // https://www.tradingview.com/charting-library-docs/latest/api/interfaces/Datafeed.Bar/ + // OHLCVService delivers bars with `timestamp` in Unix seconds — multiply by 1000 + const realtimeBar = useMemo(() => { + if (!latestBar) return undefined; + return { + time: latestBar.timestamp * 1000, + open: latestBar.open, + high: latestBar.high, + low: latestBar.low, + close: latestBar.close, + volume: latestBar.volume, + }; + }, [latestBar]); + const ohlcvPagination = useMemo( () => ({ nextCursor, @@ -554,6 +599,7 @@ const PriceAdvanced = ({ ({ useOHLCVChart: (...args: unknown[]) => mockUseOHLCVChart(...args), })); +jest.mock('../../Charts/AdvancedChart/useOHLCVRealtime', () => ({ + useOHLCVRealtime: () => ({ latestBar: null }), +})); + jest.mock('../PriceChart/PriceChart', () => { // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires const { View } = require('react-native'); diff --git a/app/components/UI/Charts/AdvancedChart/useOHLCVRealtime.test.ts b/app/components/UI/Charts/AdvancedChart/useOHLCVRealtime.test.ts new file mode 100644 index 00000000000..8d135c6eff4 --- /dev/null +++ b/app/components/UI/Charts/AdvancedChart/useOHLCVRealtime.test.ts @@ -0,0 +1,426 @@ +import { act, renderHook } from '@testing-library/react-native'; +import { useOHLCVRealtime } from './useOHLCVRealtime'; + +const mockCall = jest.fn().mockResolvedValue(undefined); +const mockSubscribe = jest.fn(); +const mockUnsubscribe = jest.fn(); + +jest.mock('../../../../core/Engine', () => ({ + __esModule: true, + default: { + controllerMessenger: { + call: (...args: unknown[]) => mockCall(...args), + subscribe: (...args: unknown[]) => mockSubscribe(...args), + unsubscribe: (...args: unknown[]) => mockUnsubscribe(...args), + }, + }, +})); + +const mockFetch = jest.fn(); +global.fetch = mockFetch as jest.Mock; + +function arrangeDefaultOptions() { + return { + assetId: 'eip155:8453/erc20:0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + interval: '15m', + currency: 'usd', + timePeriod: '1d', + enabled: true, + }; +} + +function makeFetchResponse(bar: Record) { + return { + ok: true, + json: jest.fn().mockResolvedValue(bar), + }; +} + +describe('useOHLCVRealtime', () => { + beforeEach(() => { + jest.useFakeTimers(); + mockCall.mockReset().mockResolvedValue(undefined); + mockSubscribe.mockReset(); + mockUnsubscribe.mockReset(); + mockFetch.mockReset(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('subscribes to barUpdated, subscriptionError, and chainStatusChanged events', () => { + renderHook(() => useOHLCVRealtime(arrangeDefaultOptions())); + + expect(mockSubscribe).toHaveBeenCalledWith( + 'OHLCVService:barUpdated', + expect.any(Function), + ); + expect(mockSubscribe).toHaveBeenCalledWith( + 'OHLCVService:subscriptionError', + expect.any(Function), + ); + expect(mockSubscribe).toHaveBeenCalledWith( + 'OHLCVService:chainStatusChanged', + expect.any(Function), + ); + }); + + it('does not call OHLCVService:subscribe before debounce period', () => { + renderHook(() => useOHLCVRealtime(arrangeDefaultOptions())); + + expect(mockCall).not.toHaveBeenCalledWith( + 'OHLCVService:subscribe', + expect.anything(), + ); + }); + + it('calls OHLCVService:subscribe after 500ms debounce', async () => { + renderHook(() => useOHLCVRealtime(arrangeDefaultOptions())); + + await act(async () => { + jest.advanceTimersByTime(500); + }); + + expect(mockCall).toHaveBeenCalledWith('OHLCVService:subscribe', { + assetId: 'eip155:8453/erc20:0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + interval: '15m', + currency: 'usd', + }); + }); + + it('does not subscribe when enabled is false', async () => { + renderHook(() => + useOHLCVRealtime({ ...arrangeDefaultOptions(), enabled: false }), + ); + + await act(async () => { + jest.advanceTimersByTime(500); + }); + + expect(mockSubscribe).not.toHaveBeenCalled(); + expect(mockCall).not.toHaveBeenCalled(); + }); + + it('does not subscribe when assetId is empty', async () => { + renderHook(() => + useOHLCVRealtime({ ...arrangeDefaultOptions(), assetId: '' }), + ); + + await act(async () => { + jest.advanceTimersByTime(500); + }); + + expect(mockSubscribe).not.toHaveBeenCalled(); + }); + + it('unsubscribes from all events and calls OHLCVService:unsubscribe on unmount', async () => { + const { unmount } = renderHook(() => + useOHLCVRealtime(arrangeDefaultOptions()), + ); + + await act(async () => { + jest.advanceTimersByTime(500); + }); + + unmount(); + + expect(mockUnsubscribe).toHaveBeenCalledWith( + 'OHLCVService:barUpdated', + expect.any(Function), + ); + expect(mockUnsubscribe).toHaveBeenCalledWith( + 'OHLCVService:subscriptionError', + expect.any(Function), + ); + expect(mockUnsubscribe).toHaveBeenCalledWith( + 'OHLCVService:chainStatusChanged', + expect.any(Function), + ); + expect(mockCall).toHaveBeenCalledWith('OHLCVService:unsubscribe', { + assetId: 'eip155:8453/erc20:0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + interval: '15m', + currency: 'usd', + }); + }); + + it('calls OHLCVService:unsubscribe on unmount even if debounce did not fire', () => { + const { unmount } = renderHook(() => + useOHLCVRealtime(arrangeDefaultOptions()), + ); + + unmount(); + + expect(mockCall).toHaveBeenCalledWith('OHLCVService:unsubscribe', { + assetId: 'eip155:8453/erc20:0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + interval: '15m', + currency: 'usd', + }); + }); + + it('sets latestBar when barUpdated event matches channel', async () => { + const { result } = renderHook(() => + useOHLCVRealtime(arrangeDefaultOptions()), + ); + + expect(result.current.latestBar).toBeNull(); + + const barUpdatedHandler = mockSubscribe.mock.calls.find( + (call) => call[0] === 'OHLCVService:barUpdated', + )?.[1]; + + const bar = { + timestamp: 1700000000, + open: 100, + high: 105, + low: 99, + close: 103, + volume: 5000, + }; + + await act(async () => { + barUpdatedHandler({ + channel: + 'market-data.v1.eip155:8453/erc20:0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913.15m.usd', + bar, + }); + }); + + expect(result.current.latestBar).toEqual(bar); + }); + + it('ignores barUpdated events for different channels', async () => { + const { result } = renderHook(() => + useOHLCVRealtime(arrangeDefaultOptions()), + ); + + const barUpdatedHandler = mockSubscribe.mock.calls.find( + (call) => call[0] === 'OHLCVService:barUpdated', + )?.[1]; + + await act(async () => { + barUpdatedHandler({ + channel: 'market-data.v1.eip155:1/slip44:60.15m.usd', + bar: { + timestamp: 1700000000, + open: 100, + high: 105, + low: 99, + close: 103, + volume: 5000, + }, + }); + }); + + expect(result.current.latestBar).toBeNull(); + }); + + it('resets latestBar to null when options change', async () => { + const { result, rerender } = renderHook( + (props) => useOHLCVRealtime(props), + { initialProps: arrangeDefaultOptions() }, + ); + + const barUpdatedHandler = mockSubscribe.mock.calls.find( + (call) => call[0] === 'OHLCVService:barUpdated', + )?.[1]; + + await act(async () => { + barUpdatedHandler({ + channel: + 'market-data.v1.eip155:8453/erc20:0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913.15m.usd', + bar: { + timestamp: 1700000000, + open: 100, + high: 105, + low: 99, + close: 103, + volume: 5000, + }, + }); + }); + + expect(result.current.latestBar).not.toBeNull(); + + rerender({ ...arrangeDefaultOptions(), interval: '1h' }); + + expect(result.current.latestBar).toBeNull(); + }); + + describe('staleness fallback', () => { + it('polls REST API after 30s of no WS messages', async () => { + mockFetch.mockResolvedValue( + makeFetchResponse({ + timestamp: 1700000000000, + open: 100, + high: 110, + low: 95, + close: 108, + volume: 9000, + }), + ); + + const { result } = renderHook(() => + useOHLCVRealtime(arrangeDefaultOptions()), + ); + + // Fire the debounce so subscribe succeeds (sets lastMessageTime) + await act(async () => { + jest.advanceTimersByTime(500); + }); + + // Advance past staleness threshold (30s) + one check interval (15s) + await act(async () => { + jest.advanceTimersByTime(30_000 + 15_000); + }); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const fetchUrl = mockFetch.mock.calls[0][0] as string; + expect(fetchUrl).toContain('price.api.cx.metamask.io/v3/ohlcv/'); + expect(fetchUrl).toContain('/latest'); + expect(fetchUrl).toContain('timePeriod=1d'); + expect(fetchUrl).toContain('interval=15m'); + expect(fetchUrl).toContain('vsCurrency=usd'); + + // Bar is set with timestamp converted from ms to seconds + expect(result.current.latestBar).toEqual({ + timestamp: 1700000000, + open: 100, + high: 110, + low: 95, + close: 108, + volume: 9000, + }); + }); + + it('does not poll if WS messages are arriving within 30s', async () => { + const { result } = renderHook(() => + useOHLCVRealtime(arrangeDefaultOptions()), + ); + + // Fire debounce + await act(async () => { + jest.advanceTimersByTime(500); + }); + + // Simulate a WS bar arriving at T+20s + const barUpdatedHandler = mockSubscribe.mock.calls.find( + (call) => call[0] === 'OHLCVService:barUpdated', + )?.[1]; + + await act(async () => { + jest.advanceTimersByTime(20_000); + }); + + await act(async () => { + barUpdatedHandler({ + channel: + 'market-data.v1.eip155:8453/erc20:0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913.15m.usd', + bar: { + timestamp: 1700000000, + open: 100, + high: 105, + low: 99, + close: 103, + volume: 5000, + }, + }); + }); + + // Check interval fires at T+30s (only 10s after last message) + await act(async () => { + jest.advanceTimersByTime(10_000); + }); + + expect(mockFetch).not.toHaveBeenCalled(); + expect(result.current.latestBar).not.toBeNull(); + }); + + it('polls REST API when chain status is down', async () => { + mockFetch.mockResolvedValue( + makeFetchResponse({ + timestamp: 1700000000000, + open: 50, + high: 55, + low: 48, + close: 52, + volume: 3000, + }), + ); + + renderHook(() => useOHLCVRealtime(arrangeDefaultOptions())); + + // Fire debounce + await act(async () => { + jest.advanceTimersByTime(500); + }); + + // Trigger chainStatusChanged with 'down' for our chain + const chainStatusHandler = mockSubscribe.mock.calls.find( + (call) => call[0] === 'OHLCVService:chainStatusChanged', + )?.[1]; + + await act(async () => { + chainStatusHandler({ + chainIds: ['eip155:8453'], + status: 'down', + }); + }); + + // Advance to next staleness check + await act(async () => { + jest.advanceTimersByTime(15_000); + }); + + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('does not poll when chainStatusChanged is for a different chain', async () => { + renderHook(() => useOHLCVRealtime(arrangeDefaultOptions())); + + // Fire debounce + await act(async () => { + jest.advanceTimersByTime(500); + }); + + const chainStatusHandler = mockSubscribe.mock.calls.find( + (call) => call[0] === 'OHLCVService:chainStatusChanged', + )?.[1]; + + await act(async () => { + chainStatusHandler({ + chainIds: ['eip155:1'], + status: 'down', + }); + }); + + // Advance but not past staleness threshold from subscribe time + await act(async () => { + jest.advanceTimersByTime(15_000); + }); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('handles REST API failure gracefully', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 500 }); + + const { result } = renderHook(() => + useOHLCVRealtime(arrangeDefaultOptions()), + ); + + // Fire debounce + await act(async () => { + jest.advanceTimersByTime(500); + }); + + // Force staleness + await act(async () => { + jest.advanceTimersByTime(30_000 + 15_000); + }); + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(result.current.latestBar).toBeNull(); + }); + }); +}); diff --git a/app/components/UI/Charts/AdvancedChart/useOHLCVRealtime.ts b/app/components/UI/Charts/AdvancedChart/useOHLCVRealtime.ts new file mode 100644 index 00000000000..29580284247 --- /dev/null +++ b/app/components/UI/Charts/AdvancedChart/useOHLCVRealtime.ts @@ -0,0 +1,276 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { OHLCVBar as WSOHLCVBar } from '@metamask/core-backend'; +import Engine from '../../../../core/Engine'; + +export interface UseOHLCVRealtimeOptions { + /** CAIP-19 asset identifier */ + assetId: string; + /** Candle interval for WS subscription (e.g. "1m", "5m", "1h", "1d") */ + interval: string; + /** Fiat currency code (e.g. "usd") */ + currency: string; + /** Time period for /latest REST fallback (e.g. "1h", "1d", "1w") — lowercase */ + timePeriod: string; + /** When false, skips subscription (e.g. legacy chart fallback) */ + enabled: boolean; +} + +export interface UseOHLCVRealtimeResult { + /** Latest bar from WebSocket stream or HTTP fallback (timestamp in seconds) */ + latestBar: WSOHLCVBar | null; +} + +const DEBOUNCE_MS = 500; + +/** How often we check whether data is stale (ms) */ +const STALENESS_CHECK_INTERVAL_MS = 15_000; + +/** If no WS message arrives within this window, consider the stream stale */ +const STALENESS_THRESHOLD_MS = 30_000; + +const OHLCV_BASE_URL = 'https://price.api.cx.metamask.io/v3/ohlcv'; + +/** + * Fetch the current (latest) candle from the dedicated /latest REST endpoint. + * The API returns a single bar object with `timestamp` in milliseconds. + */ +async function fetchLatestBar( + assetId: string, + timePeriod: string, + wsInterval: string, + currency: string, + signal?: AbortSignal, +): Promise { + const url = new URL(`${OHLCV_BASE_URL}/${assetId}/latest`); + url.searchParams.set('timePeriod', timePeriod); + url.searchParams.set('interval', wsInterval); + url.searchParams.set('vsCurrency', currency); + + const response = await fetch(url.toString(), { signal }); + if (!response.ok) return null; + + const bar = (await response.json()) as { + timestamp: number; + open: number; + high: number; + low: number; + close: number; + volume: number; + } | null; + + if (!bar) return null; + + return { + timestamp: bar.timestamp / 1000, + open: bar.open, + high: bar.high, + low: bar.low, + close: bar.close, + volume: bar.volume, + }; +} + +/** + * Extract the chain portion from a CAIP-19 asset ID. + * e.g. "eip155:8453/erc20:0x..." → "eip155:8453" + */ +function extractChainId(assetId: string): string { + const slashIdx = assetId.indexOf('/'); + return slashIdx > 0 ? assetId.slice(0, slashIdx) : assetId; +} + +/** + * Subscribes to real-time OHLCV candle updates via OHLCVService (WebSocket). + * Uses a 500ms debounce before subscribing to avoid thrashing during rapid + * time-range or asset navigation changes. + * + * Includes a staleness-based HTTP polling fallback: + * - Tracks `lastMessageTime` on every WS bar received. + * - Every 15 seconds checks if no WS message arrived within the last 30 seconds. + * - Also listens to `OHLCVService:chainStatusChanged` for chain-down events. + * - When stale or chain-down, fetches the latest candle via the REST API. + * + * Note: Both WebSocket disconnect and server-side chain-down notifications + * publish the same `OHLCVService:chainStatusChanged` event with + * `status: 'down'`. The hook handles both identically — on the next staleness + * check interval (up to 15s delay) it triggers a REST poll. For immediate + * polling on disconnect/chain-down, call `pollLatest()` directly inside + * `handleChainStatusChanged` when status transitions to 'down'. + */ +export function useOHLCVRealtime({ + assetId, + interval, + currency, + timePeriod, + enabled, +}: UseOHLCVRealtimeOptions): UseOHLCVRealtimeResult { + const [latestBar, setLatestBar] = useState(null); + const subscribedRef = useRef(false); + const cancelledRef = useRef(false); + const debounceTimerRef = useRef | null>(null); + const channelRef = useRef(''); + + // Staleness tracking + const lastMessageTimeRef = useRef(0); + const stalenessTimerRef = useRef | null>(null); + const chainDownRef = useRef(false); + const pollingAbortRef = useRef(null); + + const buildChannel = useCallback( + () => `market-data.v1.${assetId}.${interval}.${currency}`, + [assetId, interval, currency], + ); + + useEffect(() => { + if (!enabled || !assetId || !interval || !currency) { + return; + } + + const channel = buildChannel(); + channelRef.current = channel; + cancelledRef.current = false; + setLatestBar(null); + lastMessageTimeRef.current = 0; + chainDownRef.current = false; + + const handleBarUpdated = (payload: { + channel: string; + bar: WSOHLCVBar; + }) => { + if (payload.channel === channelRef.current) { + lastMessageTimeRef.current = Date.now(); + chainDownRef.current = false; + setLatestBar(payload.bar); + } + }; + + const handleSubscriptionError = (payload: { + channel: string; + error: string; + operation: string; + }) => { + // Error is logged by OHLCVService in core; hook only needs to react if needed. + }; + + const chainId = extractChainId(assetId); + const handleChainStatusChanged = (payload: { + chainIds: string[]; + status: 'up' | 'down'; + timestamp?: number; + }) => { + if (payload.chainIds.includes(chainId)) { + chainDownRef.current = payload.status === 'down'; + } + }; + + // Subscribe to messenger events before triggering the WS subscription + (Engine.controllerMessenger.subscribe as (...args: unknown[]) => void)( + 'OHLCVService:barUpdated', + handleBarUpdated, + ); + (Engine.controllerMessenger.subscribe as (...args: unknown[]) => void)( + 'OHLCVService:subscriptionError', + handleSubscriptionError, + ); + (Engine.controllerMessenger.subscribe as (...args: unknown[]) => void)( + 'OHLCVService:chainStatusChanged', + handleChainStatusChanged, + ); + + // Staleness polling: check periodically if we should fall back to REST + const pollLatest = async () => { + pollingAbortRef.current?.abort(); + const controller = new AbortController(); + pollingAbortRef.current = controller; + + try { + const bar = await fetchLatestBar( + assetId, + timePeriod, + interval, + currency, + controller.signal, + ); + if (bar) { + lastMessageTimeRef.current = Date.now(); + setLatestBar(bar); + } + } catch { + // no-op + } + }; + + stalenessTimerRef.current = setInterval(() => { + const elapsed = Date.now() - lastMessageTimeRef.current; + const isStale = + lastMessageTimeRef.current > 0 && elapsed > STALENESS_THRESHOLD_MS; + + if (isStale || chainDownRef.current) { + pollLatest(); + } + }, STALENESS_CHECK_INTERVAL_MS); + + // Debounce the actual WS subscribe call + debounceTimerRef.current = setTimeout(async () => { + try { + await Engine.controllerMessenger.call('OHLCVService:subscribe', { + assetId, + interval, + currency, + }); + + if (cancelledRef.current) { + await Engine.controllerMessenger.call('OHLCVService:unsubscribe', { + assetId, + interval, + currency, + }); + return; + } + + subscribedRef.current = true; + lastMessageTimeRef.current = Date.now(); + } catch { + // Subscribe failure is handled by OHLCVService (reconnection + subscriptionError event). + } + }, DEBOUNCE_MS); + + return () => { + cancelledRef.current = true; + + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + debounceTimerRef.current = null; + } + + if (stalenessTimerRef.current) { + clearInterval(stalenessTimerRef.current); + stalenessTimerRef.current = null; + } + + pollingAbortRef.current?.abort(); + + (Engine.controllerMessenger.unsubscribe as (...args: unknown[]) => void)( + 'OHLCVService:barUpdated', + handleBarUpdated, + ); + (Engine.controllerMessenger.unsubscribe as (...args: unknown[]) => void)( + 'OHLCVService:subscriptionError', + handleSubscriptionError, + ); + (Engine.controllerMessenger.unsubscribe as (...args: unknown[]) => void)( + 'OHLCVService:chainStatusChanged', + handleChainStatusChanged, + ); + + Engine.controllerMessenger + .call('OHLCVService:unsubscribe', { assetId, interval, currency }) + .catch(() => { + // Non-fatal: grace period in core will handle cleanup. + }); + subscribedRef.current = false; + }; + }, [assetId, interval, currency, timePeriod, enabled, buildChannel]); + + return { latestBar }; +} diff --git a/app/core/Engine/Engine.ts b/app/core/Engine/Engine.ts index ba912aa445c..cb156271318 100644 --- a/app/core/Engine/Engine.ts +++ b/app/core/Engine/Engine.ts @@ -61,6 +61,7 @@ import { notificationServicesPushControllerInit } from './controllers/notificati import { backendWebSocketServiceInit, accountActivityServiceInit, + ohlcvServiceInit, } from './controllers/core-backend'; import { assetsControllerInit } from './controllers/assets-controller/assets-controller-init'; import { AppStateWebSocketManager } from '../AppStateWebSocketManager'; @@ -376,6 +377,7 @@ export class Engine { ///: END:ONLY_INCLUDE_IF BackendWebSocketService: backendWebSocketServiceInit, AccountActivityService: accountActivityServiceInit, + OHLCVService: ohlcvServiceInit, ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) MultichainAssetsController: multichainAssetsControllerInit, MultichainAssetsRatesController: multichainAssetsRatesControllerInit, @@ -529,6 +531,7 @@ export class Engine { ); const accountActivityService = messengerClientsByName.AccountActivityService; + const ohlcvService = messengerClientsByName.OHLCVService; ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) const multichainAssetsController = @@ -607,6 +610,7 @@ export class Engine { ///: END:ONLY_INCLUDE_IF BackendWebSocketService: backendWebSocketService, AccountActivityService: accountActivityService, + OHLCVService: ohlcvService, AccountsController: accountsController, ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) MultichainBalancesController: multichainBalancesController, diff --git a/app/core/Engine/constants.ts b/app/core/Engine/constants.ts index 14eb2fa5e40..b30b89deb0c 100644 --- a/app/core/Engine/constants.ts +++ b/app/core/Engine/constants.ts @@ -14,6 +14,7 @@ export const STATELESS_NON_CONTROLLER_NAMES = [ 'WebSocketService', 'BackendWebSocketService', 'AccountActivityService', + 'OHLCVService', 'MultichainAccountService', 'GeolocationApiService', 'ProfileMetricsService', diff --git a/app/core/Engine/controllers/core-backend/index.ts b/app/core/Engine/controllers/core-backend/index.ts index a3819ad387f..a5ba306eabf 100644 --- a/app/core/Engine/controllers/core-backend/index.ts +++ b/app/core/Engine/controllers/core-backend/index.ts @@ -1,2 +1,3 @@ export * from './backend-websocket-service-init'; export * from './account-activity-service-init'; +export * from './ohlcv-service-init'; diff --git a/app/core/Engine/controllers/core-backend/ohlcv-service-init.ts b/app/core/Engine/controllers/core-backend/ohlcv-service-init.ts new file mode 100644 index 00000000000..357fc8cb845 --- /dev/null +++ b/app/core/Engine/controllers/core-backend/ohlcv-service-init.ts @@ -0,0 +1,32 @@ +import { OHLCVService, OHLCVServiceMessenger } from '@metamask/core-backend'; +import { MessengerClientInitFunction } from '../../types'; +import { trace } from '../../../../util/trace'; +import Logger from '../../../../util/Logger'; + +/** + * Initialize the OHLCV service for real-time candlestick streaming. + * + * @param request - The request object. + * @param request.controllerMessenger - The messenger to use for the service. + * @returns The initialized service. + */ +export const ohlcvServiceInit: MessengerClientInitFunction< + OHLCVService, + OHLCVServiceMessenger +> = ({ controllerMessenger }) => { + Logger.log('Initializing OHLCVService'); + + const controller = new OHLCVService({ + messenger: controllerMessenger, + // @ts-expect-error: Types of `TraceRequest` are not the same. + traceFn: trace, + }); + + controller.init(); + + Logger.log('OHLCVService initialized'); + + return { + controller, + }; +}; diff --git a/app/core/Engine/messengers/core-backend/index.ts b/app/core/Engine/messengers/core-backend/index.ts index fe29c28c4dc..7dad683c691 100644 --- a/app/core/Engine/messengers/core-backend/index.ts +++ b/app/core/Engine/messengers/core-backend/index.ts @@ -1,2 +1,3 @@ export * from './backend-websocket-service-messenger'; export * from './account-activity-service-messenger'; +export * from './ohlcv-service-messenger'; diff --git a/app/core/Engine/messengers/core-backend/ohlcv-service-messenger.ts b/app/core/Engine/messengers/core-backend/ohlcv-service-messenger.ts new file mode 100644 index 00000000000..aee7cc522e3 --- /dev/null +++ b/app/core/Engine/messengers/core-backend/ohlcv-service-messenger.ts @@ -0,0 +1,44 @@ +import { OHLCVServiceMessenger } from '@metamask/core-backend'; +import { RootExtendedMessenger, RootMessenger } from '../../types'; +import { + Messenger, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; + +/** + * Get a messenger for the OHLCV service. This is scoped to the + * actions and events that the OHLCV service is allowed to handle. + * + * @param rootExtendedMessenger - The root extended messenger. + * @returns The OHLCVServiceMessenger. + */ +export function getOHLCVServiceMessenger( + rootExtendedMessenger: RootExtendedMessenger, +): OHLCVServiceMessenger { + const messenger = new Messenger< + 'OHLCVService', + MessengerActions, + MessengerEvents, + RootMessenger + >({ + namespace: 'OHLCVService', + parent: rootExtendedMessenger, + }); + rootExtendedMessenger.delegate({ + actions: [ + 'BackendWebSocketService:connect', + 'BackendWebSocketService:forceReconnection', + 'BackendWebSocketService:subscribe', + 'BackendWebSocketService:getConnectionInfo', + 'BackendWebSocketService:channelHasSubscription', + 'BackendWebSocketService:getSubscriptionsByChannel', + 'BackendWebSocketService:findSubscriptionsByChannelPrefix', + 'BackendWebSocketService:addChannelCallback', + 'BackendWebSocketService:removeChannelCallback', + ], + events: ['BackendWebSocketService:connectionStateChanged'], + messenger, + }); + return messenger; +} diff --git a/app/core/Engine/messengers/index.ts b/app/core/Engine/messengers/index.ts index eb6d972f328..7bbde933efc 100644 --- a/app/core/Engine/messengers/index.ts +++ b/app/core/Engine/messengers/index.ts @@ -16,6 +16,7 @@ import { getBackendWebSocketServiceMessenger, getBackendWebSocketServiceInitMessenger, getAccountActivityServiceMessenger, + getOHLCVServiceMessenger, } from './core-backend'; ///: BEGIN:ONLY_INCLUDE_IF(snaps) import { @@ -468,6 +469,10 @@ export const MESSENGER_FACTORIES = { getMessenger: getAccountActivityServiceMessenger, getInitMessenger: noop, }, + OHLCVService: { + getMessenger: getOHLCVServiceMessenger, + getInitMessenger: noop, + }, ProfileMetricsController: { getMessenger: getProfileMetricsControllerMessenger, getInitMessenger: getProfileMetricsControllerInitMessenger, diff --git a/app/core/Engine/types.ts b/app/core/Engine/types.ts index 39a8a4545a7..18763b1b3ca 100644 --- a/app/core/Engine/types.ts +++ b/app/core/Engine/types.ts @@ -252,6 +252,9 @@ import { AccountActivityService, AccountActivityServiceActions, AccountActivityServiceEvents, + OHLCVService, + OHLCVServiceActions, + OHLCVServiceEvents, } from '@metamask/core-backend'; import { AccountsController, @@ -554,6 +557,7 @@ type GlobalActions = ///: END:ONLY_INCLUDE_IF | BackendWebSocketServiceActions | AccountActivityServiceActions + | OHLCVServiceActions ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) | MultichainBalancesControllerActions | MultichainAssetsControllerActions @@ -640,6 +644,7 @@ type GlobalEvents = ///: END:ONLY_INCLUDE_IF | BackendWebSocketServiceEvents | AccountActivityServiceEvents + | OHLCVServiceEvents ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) | MultichainBalancesControllerEvents | MultichainAssetsControllerEvents @@ -789,6 +794,7 @@ export type MessengerClients = { ///: END:ONLY_INCLUDE_IF BackendWebSocketService: BackendWebSocketService; AccountActivityService: AccountActivityService; + OHLCVService: OHLCVService; ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) MultichainBalancesController: MultichainBalancesController; MultichainAssetsRatesController: MultichainAssetsRatesController; @@ -965,6 +971,7 @@ export type MessengerClientsToInitialize = ///: END:ONLY_INCLUDE_IF | 'BackendWebSocketService' | 'AccountActivityService' + | 'OHLCVService' ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) | 'MultichainAssetsController' | 'MultichainAssetsRatesController' diff --git a/package.json b/package.json index 4c89af5d409..27f98ff182b 100644 --- a/package.json +++ b/package.json @@ -164,6 +164,7 @@ ] }, "resolutions": { + "@metamask/core-backend": "npm:@metamask-previews/core-backend@6.2.2-preview-094d56fb3", "@react-native-community/viewpager": "patch:@react-native-community/viewpager@npm%3A3.3.0#~/.yarn/patches/@react-native-community-viewpager-npm-3.3.0.patch", "@solana-mobile/mobile-wallet-adapter-protocol": "^2.2.5", "@appium/schema/json-schema": "^0.4.0", @@ -246,7 +247,7 @@ "@metamask/compliance-controller": "2.0.0", "@metamask/connectivity-controller": "^0.1.0", "@metamask/controller-utils": "^11.18.0", - "@metamask/core-backend": "^6.2.0", + "@metamask/core-backend": "npm:@metamask-previews/core-backend@6.2.2-preview-094d56fb3", "@metamask/delegation-controller": "^2.0.2", "@metamask/delegation-deployments": "^1.0.0", "@metamask/design-system-react-native": "^0.23.0", diff --git a/yarn.lock b/yarn.lock index 0264c2239a6..aea151fd60e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8203,19 +8203,40 @@ __metadata: languageName: node linkType: hard -"@metamask/core-backend@npm:^6.1.1, @metamask/core-backend@npm:^6.2.0, @metamask/core-backend@npm:^6.2.1": - version: 6.2.1 - resolution: "@metamask/core-backend@npm:6.2.1" +"@metamask/controller-utils@npm:^12.0.0": + version: 12.0.0 + resolution: "@metamask/controller-utils@npm:12.0.0" dependencies: - "@metamask/accounts-controller": "npm:^37.1.0" - "@metamask/controller-utils": "npm:^11.19.0" - "@metamask/keyring-controller": "npm:^25.1.1" - "@metamask/messenger": "npm:^1.0.0" - "@metamask/profile-sync-controller": "npm:^28.0.1" + "@metamask/eth-query": "npm:^4.0.0" + "@metamask/ethjs-unit": "npm:^0.3.0" + "@metamask/utils": "npm:^11.9.0" + "@spruceid/siwe-parser": "npm:2.1.0" + "@types/bn.js": "npm:^5.1.5" + bignumber.js: "npm:^9.1.2" + bn.js: "npm:^5.2.1" + cockatiel: "npm:^3.1.2" + eth-ens-namehash: "npm:^2.0.8" + fast-deep-equal: "npm:^3.1.3" + lodash: "npm:^4.17.21" + peerDependencies: + "@babel/runtime": ^7.0.0 + checksum: 10/7fc73916fcbc530c015b5ea6b92d7a15f4f35185bc360702c94cdc9493085d0e028e69b70bdd8690bf035e566c254c6868d2a9f2914098c3d06feff185ae65cd + languageName: node + linkType: hard + +"@metamask/core-backend@npm:@metamask-previews/core-backend@6.2.2-preview-094d56fb3": + version: 6.2.2-preview-094d56fb3 + resolution: "@metamask-previews/core-backend@npm:6.2.2-preview-094d56fb3" + dependencies: + "@metamask/accounts-controller": "npm:^38.1.0" + "@metamask/controller-utils": "npm:^12.0.0" + "@metamask/keyring-controller": "npm:^25.5.0" + "@metamask/messenger": "npm:^1.2.0" + "@metamask/profile-sync-controller": "npm:^28.0.2" "@metamask/utils": "npm:^11.9.0" "@tanstack/query-core": "npm:^5.62.16" uuid: "npm:^8.3.2" - checksum: 10/ce31c391dd385afd9a7c4a7e79e9df55813cf7c37e590d03df5d9deed75d367621c441e76699e38fd48231a791df63a0119d9d76febb649beb687da1b99cd311 + checksum: 10/1e5d5a9d0e99fcdec5f5b493555a0073ae777db946551ac726b78a661ca9e9b33af7c88f361adc37aa583f4794dc63e96f818a24b437a3ae7f520715957a20bf languageName: node linkType: hard @@ -35257,7 +35278,7 @@ __metadata: "@metamask/compliance-controller": "npm:2.0.0" "@metamask/connectivity-controller": "npm:^0.1.0" "@metamask/controller-utils": "npm:^11.18.0" - "@metamask/core-backend": "npm:^6.2.0" + "@metamask/core-backend": "npm:@metamask-previews/core-backend@6.2.2-preview-094d56fb3" "@metamask/delegation-controller": "npm:^2.0.2" "@metamask/delegation-deployments": "npm:^1.0.0" "@metamask/design-system-react-native": "npm:^0.23.0"