diff --git a/src/abacus-ts/lib/mapLoadable.ts b/src/abacus-ts/lib/mapLoadable.ts new file mode 100644 index 000000000..b41c2e3ec --- /dev/null +++ b/src/abacus-ts/lib/mapLoadable.ts @@ -0,0 +1,8 @@ +import { Loadable } from './loadable'; + +export function mapLoadableData(load: Loadable, map: (obj: T) => R): Loadable { + return { + ...load, + data: load.data != null ? map(load.data) : undefined, + } as Loadable; +} diff --git a/src/abacus-ts/rest/height.ts b/src/abacus-ts/rest/height.ts new file mode 100644 index 000000000..3e9ea745e --- /dev/null +++ b/src/abacus-ts/rest/height.ts @@ -0,0 +1,59 @@ +import { timeUnits } from '@/constants/time'; + +import { type RootStore } from '@/state/_store'; +import { setIndexerHeightRaw, setValidatorHeightRaw } from '@/state/raw'; + +import { loadableIdle } from '../lib/loadable'; +import { mapLoadableData } from '../lib/mapLoadable'; +import { + createIndexerQueryStoreEffect, + createValidatorQueryStoreEffect, +} from './lib/indexerQueryStoreEffect'; +import { queryResultToLoadable } from './lib/queryResultToLoadable'; + +export function setUpIndexerHeightQuery(store: RootStore) { + const cleanupEffect = createIndexerQueryStoreEffect(store, { + selector: () => true, + getQueryKey: () => ['height'], + getQueryFn: (indexerClient) => { + return () => indexerClient.utility.getHeight(); + }, + onResult: (height) => { + store.dispatch(setIndexerHeightRaw(queryResultToLoadable(height))); + }, + onNoQuery: () => store.dispatch(setIndexerHeightRaw(loadableIdle())), + refetchInterval: timeUnits.second * 10, + staleTime: timeUnits.second * 10, + }); + return () => { + cleanupEffect(); + store.dispatch(setIndexerHeightRaw(loadableIdle())); + }; +} + +export function setUpValidatorHeightQuery(store: RootStore) { + const cleanupEffect = createValidatorQueryStoreEffect(store, { + selector: () => true, + getQueryKey: () => ['height'], + getQueryFn: (compositeClient) => { + return () => compositeClient.validatorClient.get.latestBlock(); + }, + onResult: (height) => { + store.dispatch( + setValidatorHeightRaw( + mapLoadableData(queryResultToLoadable(height), (d) => ({ + height: d.header.height, + time: d.header.time, + })) + ) + ); + }, + onNoQuery: () => store.dispatch(setValidatorHeightRaw(loadableIdle())), + refetchInterval: timeUnits.second * 10, + staleTime: timeUnits.second * 10, + }); + return () => { + cleanupEffect(); + store.dispatch(setValidatorHeightRaw(loadableIdle())); + }; +} diff --git a/src/abacus-ts/rest/lib/compositeClientManager.ts b/src/abacus-ts/rest/lib/compositeClientManager.ts new file mode 100644 index 000000000..e0b60914e --- /dev/null +++ b/src/abacus-ts/rest/lib/compositeClientManager.ts @@ -0,0 +1,149 @@ +import { createStoreEffect } from '@/abacus-ts/lib/createStoreEffect'; +import { ResourceCacheManager } from '@/abacus-ts/lib/resourceCacheManager'; +import { + CompositeClient, + IndexerClient, + IndexerConfig, + Network, + NetworkOptimizer, + ValidatorConfig, +} from '@dydxprotocol/v4-client-js'; + +import { DEFAULT_TRANSACTION_MEMO } from '@/constants/analytics'; +import { + DydxChainId, + DydxNetwork, + ENVIRONMENT_CONFIG_MAP, + TOKEN_CONFIG_MAP, +} from '@/constants/networks'; + +import { type RootStore } from '@/state/_store'; +import { getSelectedNetwork } from '@/state/appSelectors'; +import { setNetworkStateRaw } from '@/state/raw'; + +import { getStatsigConfigAsync } from '@/lib/statsig'; + +type CompositeClientWrapper = { + dead?: boolean; + compositeClient?: CompositeClient; + indexer?: IndexerClient; + tearDown: () => void; +}; + +function makeCompositeClient({ + network, + store, +}: { + network: DydxNetwork; + store: RootStore; +}): CompositeClientWrapper { + const networkConfig = ENVIRONMENT_CONFIG_MAP[network]; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (!networkConfig) { + throw new Error(`Unknown network: ${network}`); + } + const chainId = networkConfig.dydxChainId as DydxChainId; + const tokens = TOKEN_CONFIG_MAP[chainId]; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (tokens == null) { + throw new Error(`Unknown chain id: ${chainId}`); + } + + const clientWrapper: CompositeClientWrapper = { + tearDown: () => { + clientWrapper.dead = true; + store.dispatch( + setNetworkStateRaw({ + networkId: network, + stateToMerge: { compositeClientReady: false, indexerClientReady: false }, + }) + ); + }, + }; + + store.dispatch( + setNetworkStateRaw({ + networkId: network, + stateToMerge: { compositeClientReady: false, indexerClientReady: false }, + }) + ); + + (async () => { + const networkOptimizer = new NetworkOptimizer(); + const indexerUrl = networkConfig.endpoints.indexers[0]; + if (indexerUrl == null) { + throw new Error('No indexer urls found'); + } + const validatorUrl = await networkOptimizer.findOptimalNode( + networkConfig.endpoints.validators, + chainId + ); + if (clientWrapper.dead) { + return; + } + const indexerConfig = new IndexerConfig(indexerUrl.api, indexerUrl.socket); + clientWrapper.indexer = new IndexerClient(indexerConfig); + store.dispatch( + setNetworkStateRaw({ + networkId: network, + stateToMerge: { indexerClientReady: true }, + }) + ); + const statsigFlags = await getStatsigConfigAsync(); + const compositeClient = await CompositeClient.connect( + new Network( + chainId, + indexerConfig, + new ValidatorConfig( + validatorUrl, + chainId, + { + USDC_DENOM: tokens.usdc.denom, + USDC_DECIMALS: tokens.usdc.decimals, + USDC_GAS_DENOM: tokens.usdc.gasDenom, + CHAINTOKEN_DENOM: tokens.chain.denom, + CHAINTOKEN_DECIMALS: tokens.chain.decimals, + }, + { + broadcastPollIntervalMs: 3_000, + broadcastTimeoutMs: 60_000, + }, + DEFAULT_TRANSACTION_MEMO, + statsigFlags.ff_enable_timestamp_nonce + ) + ) + ); + // this shouldn't be necessary - can actually be false + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (clientWrapper.dead) { + return; + } + clientWrapper.compositeClient = compositeClient; + store.dispatch( + setNetworkStateRaw({ + networkId: network, + stateToMerge: { compositeClientReady: true }, + }) + ); + })(); + return clientWrapper; +} + +export const CompositeClientManager = new ResourceCacheManager({ + constructor: (config: { network: DydxNetwork; store: RootStore }) => makeCompositeClient(config), + destroyer: (instance) => { + instance.tearDown(); + }, + // store not part of serialization, assumed immutable + keySerializer: ({ network }) => network, +}); + +// this just makes things simpler +export function alwaysUseCurrentNetworkClient(store: RootStore) { + return createStoreEffect(store, getSelectedNetwork, (network) => { + CompositeClientManager.use({ network, store }); + return () => { + CompositeClientManager.markDone({ network, store }); + }; + }); +} diff --git a/src/abacus-ts/rest/lib/indexerClientManager.ts b/src/abacus-ts/rest/lib/indexerClientManager.ts deleted file mode 100644 index a0bd54fde..000000000 --- a/src/abacus-ts/rest/lib/indexerClientManager.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { IndexerClient, IndexerConfig } from '@dydxprotocol/v4-client-js'; - -import { ResourceCacheManager } from '../../lib/resourceCacheManager'; - -export const IndexerClientManager = new ResourceCacheManager({ - constructor: ({ wsUrl, url }: { url: string; wsUrl: string }) => - new IndexerClient(new IndexerConfig(url, wsUrl)), - destroyer: () => null, - keySerializer: ({ url, wsUrl }) => `${url}/////////${wsUrl}`, -}); diff --git a/src/abacus-ts/rest/lib/indexerQueryStoreEffect.ts b/src/abacus-ts/rest/lib/indexerQueryStoreEffect.ts index 22783a72b..b345a7e2d 100644 --- a/src/abacus-ts/rest/lib/indexerQueryStoreEffect.ts +++ b/src/abacus-ts/rest/lib/indexerQueryStoreEffect.ts @@ -1,16 +1,17 @@ import { logAbacusTsError } from '@/abacus-ts/logs'; -import { IndexerClient } from '@dydxprotocol/v4-client-js'; +import { selectCompositeClientReady, selectIndexerReady } from '@/abacus-ts/socketSelectors'; +import { CompositeClient, IndexerClient } from '@dydxprotocol/v4-client-js'; import { QueryObserver, QueryObserverOptions, QueryObserverResult } from '@tanstack/react-query'; import { timeUnits } from '@/constants/time'; import { type RootState, type RootStore } from '@/state/_store'; import { appQueryClient } from '@/state/appQueryClient'; +import { getSelectedNetwork } from '@/state/appSelectors'; import { createAppSelector } from '@/state/appTypes'; import { createStoreEffect } from '../../lib/createStoreEffect'; -import { selectIndexerUrl, selectWebsocketUrl } from '../../socketSelectors'; -import { IndexerClientManager } from './indexerClientManager'; +import { CompositeClientManager } from './compositeClientManager'; type PassedQueryOptions = Pick< QueryObserverOptions, @@ -23,10 +24,10 @@ type PassedQueryOptions = Pick< | 'refetchOnMount' >; -type QuerySetupConfig = { +type QuerySetupConfig = { selector: (state: RootState) => T; getQueryKey: (selectorResult: NoInfer) => any[]; - getQueryFn: (client: IndexerClient, selectorResult: NoInfer) => (() => Promise) | null; + getQueryFn: (client: ClientType, selectorResult: NoInfer) => (() => Promise) | null; onResult: (result: NoInfer>) => void; onNoQuery: () => void; } & PassedQueryOptions; @@ -38,12 +39,12 @@ const baseOptions: PassedQueryOptions = { export function createIndexerQueryStoreEffect( store: RootStore, - config: QuerySetupConfig + config: QuerySetupConfig ) { const fullSelector = createAppSelector( - [selectWebsocketUrl, selectIndexerUrl, config.selector], - (wsUrl, indexerUrl, selectorResult) => ({ - infrastructure: { wsUrl, indexerUrl }, + [getSelectedNetwork, selectIndexerReady, config.selector], + (network, indexerReady, selectorResult) => ({ + infrastructure: { network, indexerReady }, queryData: selectorResult, }) ); @@ -51,15 +52,20 @@ export function createIndexerQueryStoreEffect( return createStoreEffect(store, fullSelector, (fullResult) => { const { infrastructure, queryData } = fullResult; - const indexerClientConfig = { - url: infrastructure.indexerUrl, - wsUrl: infrastructure.wsUrl, + if (!infrastructure.indexerReady) { + config.onNoQuery(); + return undefined; + } + + const clientConfig = { + network: infrastructure.network, + store, }; - const indexerClient = IndexerClientManager.use(indexerClientConfig); + const indexerClient = CompositeClientManager.use(clientConfig).indexer!; const queryFn = config.getQueryFn(indexerClient, queryData); if (!queryFn) { - IndexerClientManager.markDone(indexerClientConfig); + CompositeClientManager.markDone(clientConfig); config.onNoQuery(); return undefined; } @@ -67,7 +73,7 @@ export function createIndexerQueryStoreEffect( // eslint-disable-next-line @typescript-eslint/no-unused-vars const { selector, getQueryKey, getQueryFn, onResult, ...otherOpts } = config; const observer = new QueryObserver(appQueryClient, { - queryKey: ['indexer', ...config.getQueryKey(queryData), indexerClientConfig], + queryKey: ['indexer', ...config.getQueryKey(queryData), clientConfig.network], queryFn, ...baseOptions, ...otherOpts, @@ -88,7 +94,68 @@ export function createIndexerQueryStoreEffect( return () => { unsubscribe(); - IndexerClientManager.markDone(indexerClientConfig); + CompositeClientManager.markDone(clientConfig); + }; + }); +} + +export function createValidatorQueryStoreEffect( + store: RootStore, + config: QuerySetupConfig +) { + const fullSelector = createAppSelector( + [getSelectedNetwork, selectCompositeClientReady, config.selector], + (network, compositeClientReady, selectorResult) => ({ + infrastructure: { network, compositeClientReady }, + queryData: selectorResult, + }) + ); + + return createStoreEffect(store, fullSelector, (fullResult) => { + const { infrastructure, queryData } = fullResult; + + if (!infrastructure.compositeClientReady) { + config.onNoQuery(); + return undefined; + } + const clientConfig = { + network: infrastructure.network, + store, + }; + const compositeClient = CompositeClientManager.use(clientConfig).compositeClient!; + + const queryFn = config.getQueryFn(compositeClient, queryData); + if (!queryFn) { + CompositeClientManager.markDone(clientConfig); + config.onNoQuery(); + return undefined; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { selector, getQueryKey, getQueryFn, onResult, ...otherOpts } = config; + const observer = new QueryObserver(appQueryClient, { + queryKey: ['validator', ...config.getQueryKey(queryData), clientConfig.network], + queryFn, + ...baseOptions, + ...otherOpts, + }); + + const unsubscribe = observer.subscribe((result) => { + try { + config.onResult(result); + } catch (e) { + logAbacusTsError( + 'ValidatorQueryStoreEffect', + 'Error handling result from react query store effect', + e, + result + ); + } + }); + + return () => { + unsubscribe(); + CompositeClientManager.markDone(clientConfig); }; }); } diff --git a/src/abacus-ts/rest/lib/queryResultToLoadable.ts b/src/abacus-ts/rest/lib/queryResultToLoadable.ts new file mode 100644 index 000000000..59ace962c --- /dev/null +++ b/src/abacus-ts/rest/lib/queryResultToLoadable.ts @@ -0,0 +1,10 @@ +import { Loadable } from '@/abacus-ts/lib/loadable'; +import { QueryObserverResult } from '@tanstack/react-query'; + +export function queryResultToLoadable(arg: QueryObserverResult): Loadable { + return { + status: arg.status, + data: arg.data, + error: arg.error, + } as Loadable; +} diff --git a/src/abacus-ts/socketSelectors.ts b/src/abacus-ts/socketSelectors.ts index 2cc5da442..36e72193a 100644 --- a/src/abacus-ts/socketSelectors.ts +++ b/src/abacus-ts/socketSelectors.ts @@ -2,6 +2,7 @@ import { ENVIRONMENT_CONFIG_MAP } from '@/constants/networks'; import { EndpointsConfig } from '@/hooks/useEndpointsConfig'; +import { type RootState } from '@/state/_store'; import { getUserSubaccountNumber, getUserWalletAddress } from '@/state/accountSelectors'; import { getSelectedNetwork } from '@/state/appSelectors'; import { createAppSelector } from '@/state/appTypes'; @@ -21,3 +22,17 @@ export const selectParentSubaccountInfo = createAppSelector( [getUserWalletAddress, getUserSubaccountNumber], (wallet, subaccount) => ({ wallet, subaccount }) ); + +export const selectIndexerReady = createAppSelector( + [getSelectedNetwork, (state: RootState) => state.raw.network], + (network, networks) => { + return !!networks[network]?.indexerClientReady; + } +); + +export const selectCompositeClientReady = createAppSelector( + [getSelectedNetwork, (state: RootState) => state.raw.network], + (network, networks) => { + return !!networks[network]?.compositeClientReady; + } +); diff --git a/src/abacus-ts/storeLifecycles.ts b/src/abacus-ts/storeLifecycles.ts index 92e86673b..426d09338 100644 --- a/src/abacus-ts/storeLifecycles.ts +++ b/src/abacus-ts/storeLifecycles.ts @@ -1,5 +1,7 @@ import { setUpBlockTradingRewardsQuery } from './rest/blockTradingRewards'; import { setUpFillsQuery } from './rest/fills'; +import { setUpIndexerHeightQuery, setUpValidatorHeightQuery } from './rest/height'; +import { alwaysUseCurrentNetworkClient } from './rest/lib/compositeClientManager'; import { setUpOrdersQuery } from './rest/orders'; import { setUpTransfersQuery } from './rest/transfers'; import { setUpMarkets } from './websocket/markets'; @@ -7,6 +9,7 @@ import { setUpOrderbook } from './websocket/orderbook'; import { setUpParentSubaccount } from './websocket/parentSubaccount'; export const storeLifecycles = [ + alwaysUseCurrentNetworkClient, setUpMarkets, setUpParentSubaccount, setUpFillsQuery, @@ -14,4 +17,6 @@ export const storeLifecycles = [ setUpTransfersQuery, setUpBlockTradingRewardsQuery, setUpOrderbook, + setUpIndexerHeightQuery, + setUpValidatorHeightQuery, ] as const; diff --git a/src/state/raw.ts b/src/state/raw.ts index a514699f7..863db2c76 100644 --- a/src/state/raw.ts +++ b/src/state/raw.ts @@ -8,8 +8,16 @@ import { IndexerCompositeFillResponse, IndexerCompositeOrderObject, } from '@/types/indexer/indexerManual'; +import { HeightResponse } from '@dydxprotocol/v4-client-js'; import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { DydxNetwork } from '@/constants/networks'; + +interface NetworkState { + indexerClientReady: boolean; + compositeClientReady: boolean; +} + export interface RawDataState { markets: { allMarkets: Loadable; @@ -22,6 +30,13 @@ export interface RawDataState { transfers: Loadable; blockTradingRewards: Loadable; }; + network: { + [networkId: string]: NetworkState; + }; + heights: { + indexerHeight: Loadable; + validatorHeight: Loadable; + }; } const initialState: RawDataState = { @@ -33,6 +48,11 @@ const initialState: RawDataState = { transfers: loadableIdle(), blockTradingRewards: loadableIdle(), }, + network: {}, + heights: { + indexerHeight: loadableIdle(), + validatorHeight: loadableIdle(), + }, }; export const rawSlice = createSlice({ @@ -72,6 +92,22 @@ export const rawSlice = createSlice({ ) => { state.account.orders = action.payload; }, + setNetworkStateRaw: ( + state, + action: PayloadAction<{ networkId: DydxNetwork; stateToMerge: Partial }> + ) => { + const { networkId, stateToMerge } = action.payload; + state.network[networkId] = { + ...(state.network[networkId] ?? { compositeClientReady: false, indexerClientReady: false }), + ...stateToMerge, + }; + }, + setIndexerHeightRaw: (state, action: PayloadAction>) => { + state.heights.indexerHeight = action.payload; + }, + setValidatorHeightRaw: (state, action: PayloadAction>) => { + state.heights.validatorHeight = action.payload; + }, }, }); @@ -83,4 +119,7 @@ export const { setAccountOrdersRaw, setAccountTransfersRaw, setAccountBlockTradingRewardsRaw, + setNetworkStateRaw, + setIndexerHeightRaw, + setValidatorHeightRaw, } = rawSlice.actions;