@l.x/prices 1.0.3 → 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.depcheckrc +14 -0
- package/.eslintrc.js +20 -0
- package/README.md +3 -0
- package/package.json +44 -1
- package/project.json +22 -0
- package/src/context/PriceServiceContext.test.tsx +63 -0
- package/src/context/PriceServiceContext.tsx +36 -0
- package/src/hooks/useConnectionStatus.ts +11 -0
- package/src/hooks/usePrice.ts +48 -0
- package/src/index.ts +45 -0
- package/src/queries/priceKeys.test.ts +23 -0
- package/src/queries/priceKeys.ts +6 -0
- package/src/queries/tokenPriceQueryOptions.ts +41 -0
- package/src/sources/rest/RestPriceBatcher.test.ts +223 -0
- package/src/sources/rest/RestPriceBatcher.ts +110 -0
- package/src/sources/rest/constants.ts +9 -0
- package/src/sources/rest/types.ts +13 -0
- package/src/sources/websocket/messageParser.ts +79 -0
- package/src/sources/websocket/subscriptionApi.ts +105 -0
- package/src/types.ts +96 -0
- package/src/utils/tokenIdentifier.test.ts +145 -0
- package/src/utils/tokenIdentifier.ts +111 -0
- package/tsconfig.json +35 -0
- package/tsconfig.lint.json +8 -0
- package/vitest.config.ts +22 -0
- package/index.d.ts +0 -1
- package/index.js +0 -1
package/.depcheckrc
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
ignores: [
|
|
2
|
+
# Dependencies that depcheck incorrectly marks as unused
|
|
3
|
+
"typescript",
|
|
4
|
+
"@typescript/native-preview",
|
|
5
|
+
"depcheck",
|
|
6
|
+
|
|
7
|
+
# Internal packages / workspaces
|
|
8
|
+
"prices",
|
|
9
|
+
"@universe/prices",
|
|
10
|
+
|
|
11
|
+
# TODO: remove once used in tests
|
|
12
|
+
"@testing-library/react",
|
|
13
|
+
"@vitest/coverage-v8",
|
|
14
|
+
]
|
package/.eslintrc.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module.exports = {
|
|
2
|
+
extends: ['@luxamm/eslint-config/lib'],
|
|
3
|
+
parserOptions: {
|
|
4
|
+
tsconfigRootDir: __dirname,
|
|
5
|
+
},
|
|
6
|
+
overrides: [
|
|
7
|
+
{
|
|
8
|
+
files: ['*.ts', '*.tsx'],
|
|
9
|
+
rules: {
|
|
10
|
+
'no-relative-import-paths/no-relative-import-paths': [
|
|
11
|
+
'error',
|
|
12
|
+
{
|
|
13
|
+
allowSameFolder: false,
|
|
14
|
+
prefix: '@luxexchange/prices',
|
|
15
|
+
},
|
|
16
|
+
],
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
],
|
|
20
|
+
}
|
package/README.md
ADDED
package/package.json
CHANGED
|
@@ -1 +1,44 @@
|
|
|
1
|
-
{
|
|
1
|
+
{
|
|
2
|
+
"name": "@l.x/prices",
|
|
3
|
+
"version": "1.0.4",
|
|
4
|
+
"dependencies": {
|
|
5
|
+
"@tanstack/react-query": "5.90.20",
|
|
6
|
+
"@luxamm/sdk-core": "7.12.3",
|
|
7
|
+
"@l.x/api": "workspace:^",
|
|
8
|
+
"@l.x/websocket": "workspace:^",
|
|
9
|
+
"@luxfi/utilities": "workspace:^"
|
|
10
|
+
},
|
|
11
|
+
"peerDependencies": {
|
|
12
|
+
"react": "19.0.3"
|
|
13
|
+
},
|
|
14
|
+
"devDependencies": {
|
|
15
|
+
"@testing-library/react": "16.3.0",
|
|
16
|
+
"@types/node": "22.13.1",
|
|
17
|
+
"@typescript/native-preview": "7.0.0-dev.20260311.1",
|
|
18
|
+
"@luxfi/eslint-config": "workspace:^",
|
|
19
|
+
"@vitest/coverage-v8": "3.2.1",
|
|
20
|
+
"depcheck": "1.4.7",
|
|
21
|
+
"eslint": "8.57.1",
|
|
22
|
+
"react": "19.0.3",
|
|
23
|
+
"typescript": "5.8.3",
|
|
24
|
+
"vitest": "3.2.1"
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"test": "nx test prices",
|
|
28
|
+
"typecheck": "nx typecheck prices",
|
|
29
|
+
"typecheck:tsgo": "nx typecheck:tsgo prices",
|
|
30
|
+
"lint": "nx lint prices",
|
|
31
|
+
"lint:fix": "nx lint:fix prices",
|
|
32
|
+
"lint:biome": "nx lint:biome prices",
|
|
33
|
+
"lint:biome:fix": "nx lint:biome:fix prices",
|
|
34
|
+
"lint:eslint": "nx lint:eslint prices",
|
|
35
|
+
"lint:eslint:fix": "nx lint:eslint:fix prices",
|
|
36
|
+
"check:deps:usage": "nx check:deps:usage prices"
|
|
37
|
+
},
|
|
38
|
+
"nx": {
|
|
39
|
+
"includedScripts": []
|
|
40
|
+
},
|
|
41
|
+
"main": "src/index.ts",
|
|
42
|
+
"private": false,
|
|
43
|
+
"sideEffects": false
|
|
44
|
+
}
|
package/project.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@l.x/prices",
|
|
3
|
+
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
|
4
|
+
"sourceRoot": "pkgs/prices/src",
|
|
5
|
+
"projectType": "library",
|
|
6
|
+
"tags": [],
|
|
7
|
+
"targets": {
|
|
8
|
+
"test": {
|
|
9
|
+
"command": "vitest run --passWithNoTests",
|
|
10
|
+
"options": { "cwd": "{projectRoot}" }
|
|
11
|
+
},
|
|
12
|
+
"typecheck": {},
|
|
13
|
+
"typecheck:tsgo": {},
|
|
14
|
+
"lint:biome": {},
|
|
15
|
+
"lint:biome:fix": {},
|
|
16
|
+
"lint:eslint": {},
|
|
17
|
+
"lint:eslint:fix": {},
|
|
18
|
+
"lint": {},
|
|
19
|
+
"lint:fix": {},
|
|
20
|
+
"check:deps:usage": {}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import type { QueryClient } from '@tanstack/react-query'
|
|
4
|
+
import { renderHook } from '@testing-library/react'
|
|
5
|
+
import { PriceServiceProvider, usePricesContext } from '@l.x/prices/src/context/PriceServiceContext'
|
|
6
|
+
import type { TokenPriceMessage, TokenSubscriptionParams } from '@l.x/prices/src/types'
|
|
7
|
+
import type { WebSocketClient } from '@l.x/websocket'
|
|
8
|
+
import type { ReactNode } from 'react'
|
|
9
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
10
|
+
|
|
11
|
+
function createMockWsClient(): WebSocketClient<TokenSubscriptionParams, TokenPriceMessage['data']> {
|
|
12
|
+
return {
|
|
13
|
+
isConnected: vi.fn(() => false),
|
|
14
|
+
getConnectionStatus: vi.fn(() => 'disconnected' as const),
|
|
15
|
+
getConnectionId: vi.fn(() => null),
|
|
16
|
+
subscribe: vi.fn(() => vi.fn()),
|
|
17
|
+
onStatusChange: vi.fn(() => vi.fn()),
|
|
18
|
+
onConnectionEstablished: vi.fn(() => vi.fn()),
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function createMockQueryClient(): QueryClient {
|
|
23
|
+
return { setQueryData: vi.fn() } as unknown as QueryClient
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('PriceServiceContext', () => {
|
|
27
|
+
it('usePricesContext throws when used outside PriceServiceProvider', () => {
|
|
28
|
+
expect(() => {
|
|
29
|
+
renderHook(() => usePricesContext())
|
|
30
|
+
}).toThrow('usePricesContext must be used within a PriceServiceProvider')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('usePricesContext returns wsClient and queryClient when inside PriceServiceProvider', () => {
|
|
34
|
+
const mockWsClient = createMockWsClient()
|
|
35
|
+
const mockQueryClient = createMockQueryClient()
|
|
36
|
+
|
|
37
|
+
const wrapper = ({ children }: { children: ReactNode }): ReactNode => (
|
|
38
|
+
<PriceServiceProvider wsClient={mockWsClient} queryClient={mockQueryClient}>
|
|
39
|
+
{children}
|
|
40
|
+
</PriceServiceProvider>
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
const { result } = renderHook(() => usePricesContext(), { wrapper })
|
|
44
|
+
|
|
45
|
+
expect(result.current.wsClient).toBe(mockWsClient)
|
|
46
|
+
expect(result.current.queryClient).toBe(mockQueryClient)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('PriceServiceProvider renders children', () => {
|
|
50
|
+
const mockWsClient = createMockWsClient()
|
|
51
|
+
const mockQueryClient = createMockQueryClient()
|
|
52
|
+
|
|
53
|
+
const wrapper = ({ children }: { children: ReactNode }): ReactNode => (
|
|
54
|
+
<PriceServiceProvider wsClient={mockWsClient} queryClient={mockQueryClient}>
|
|
55
|
+
{children}
|
|
56
|
+
</PriceServiceProvider>
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
const { result } = renderHook(() => 'rendered', { wrapper })
|
|
60
|
+
|
|
61
|
+
expect(result.current).toBe('rendered')
|
|
62
|
+
})
|
|
63
|
+
})
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { QueryClient } from '@tanstack/react-query'
|
|
2
|
+
import type { RestPriceBatcher } from '@l.x/prices/src/sources/rest/RestPriceBatcher'
|
|
3
|
+
import type { TokenPriceMessage, TokenSubscriptionParams } from '@l.x/prices/src/types'
|
|
4
|
+
import type { WebSocketClient } from '@l.x/websocket'
|
|
5
|
+
import { createContext, type ReactElement, type ReactNode, useContext, useMemo } from 'react'
|
|
6
|
+
|
|
7
|
+
interface PricesContextValue {
|
|
8
|
+
wsClient: WebSocketClient<TokenSubscriptionParams, TokenPriceMessage['data']>
|
|
9
|
+
queryClient: QueryClient
|
|
10
|
+
restBatcher?: RestPriceBatcher
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const PricesContext = createContext<PricesContextValue | null>(null)
|
|
14
|
+
|
|
15
|
+
export function PriceServiceProvider({
|
|
16
|
+
wsClient,
|
|
17
|
+
queryClient,
|
|
18
|
+
restBatcher,
|
|
19
|
+
children,
|
|
20
|
+
}: {
|
|
21
|
+
wsClient: WebSocketClient<TokenSubscriptionParams, TokenPriceMessage['data']>
|
|
22
|
+
queryClient: QueryClient
|
|
23
|
+
restBatcher?: RestPriceBatcher
|
|
24
|
+
children: ReactNode
|
|
25
|
+
}): ReactElement {
|
|
26
|
+
const value = useMemo(() => ({ wsClient, queryClient, restBatcher }), [wsClient, queryClient, restBatcher])
|
|
27
|
+
return <PricesContext.Provider value={value}>{children}</PricesContext.Provider>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function usePricesContext(): PricesContextValue {
|
|
31
|
+
const context = useContext(PricesContext)
|
|
32
|
+
if (!context) {
|
|
33
|
+
throw new Error('usePricesContext must be used within a PriceServiceProvider')
|
|
34
|
+
}
|
|
35
|
+
return context
|
|
36
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { usePricesContext } from '@l.x/prices/src/context/PriceServiceContext'
|
|
2
|
+
import type { ConnectionStatus } from '@l.x/websocket'
|
|
3
|
+
import { useSyncExternalStore } from 'react'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Hook to get the current WebSocket connection status.
|
|
7
|
+
*/
|
|
8
|
+
export function useConnectionStatus(): ConnectionStatus {
|
|
9
|
+
const { wsClient } = usePricesContext()
|
|
10
|
+
return useSyncExternalStore(wsClient.onStatusChange, wsClient.getConnectionStatus, wsClient.getConnectionStatus)
|
|
11
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { queryOptions, skipToken, useQuery, useQueryClient } from '@tanstack/react-query'
|
|
2
|
+
import { usePricesContext } from '@l.x/prices/src/context/PriceServiceContext'
|
|
3
|
+
import { priceKeys } from '@l.x/prices/src/queries/priceKeys'
|
|
4
|
+
import { tokenPriceQueryOptions } from '@l.x/prices/src/queries/tokenPriceQueryOptions'
|
|
5
|
+
import type { TokenPriceData } from '@l.x/prices/src/types'
|
|
6
|
+
import { useEffect } from 'react'
|
|
7
|
+
|
|
8
|
+
interface UsePriceOptions {
|
|
9
|
+
chainId: number | undefined
|
|
10
|
+
address: string | undefined
|
|
11
|
+
live?: boolean
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Hook to get the live price for a token.
|
|
16
|
+
* Reads from React Query cache and auto-subscribes via websocket.
|
|
17
|
+
* Falls back to REST polling when WS data goes stale (if restBatcher is provided).
|
|
18
|
+
*
|
|
19
|
+
* Requires a PriceServiceProvider in the tree.
|
|
20
|
+
*/
|
|
21
|
+
export function usePrice(options: UsePriceOptions): number | undefined {
|
|
22
|
+
const { chainId, address, live = true } = options
|
|
23
|
+
const { wsClient, restBatcher } = usePricesContext()
|
|
24
|
+
const queryClient = useQueryClient()
|
|
25
|
+
|
|
26
|
+
const enabled = chainId !== undefined && !!address
|
|
27
|
+
|
|
28
|
+
// Data is populated externally via queryClient.setQueryData from WS messages.
|
|
29
|
+
// When restBatcher is provided, queryFn fires as a fallback when WS data goes stale.
|
|
30
|
+
const { data } = useQuery(
|
|
31
|
+
enabled
|
|
32
|
+
? tokenPriceQueryOptions({ chainId, address, restBatcher, queryClient })
|
|
33
|
+
: queryOptions<TokenPriceData | null>({ queryKey: priceKeys.all, queryFn: skipToken, enabled: false }),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
if (!enabled || !live) {
|
|
38
|
+
return undefined
|
|
39
|
+
}
|
|
40
|
+
return wsClient.subscribe({
|
|
41
|
+
channel: 'token_price',
|
|
42
|
+
params: { chainId, tokenAddress: address.toLowerCase() },
|
|
43
|
+
})
|
|
44
|
+
}, [enabled, live, chainId, address, wsClient])
|
|
45
|
+
|
|
46
|
+
const price: number | undefined = enabled ? (data?.price ?? undefined) : undefined
|
|
47
|
+
return price
|
|
48
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Types
|
|
2
|
+
|
|
3
|
+
export {
|
|
4
|
+
isConnectionEstablishedMessage,
|
|
5
|
+
isRawTokenPriceMessage,
|
|
6
|
+
parseConnectionMessage,
|
|
7
|
+
parseTokenPriceMessage,
|
|
8
|
+
} from '@l.x/prices/src/sources/websocket/messageParser'
|
|
9
|
+
// Internals (testing / custom setups)
|
|
10
|
+
export { createPriceSubscriptionHandler } from '@l.x/prices/src/sources/websocket/subscriptionApi'
|
|
11
|
+
export type {
|
|
12
|
+
ConnectionEstablishedMessage,
|
|
13
|
+
Logger,
|
|
14
|
+
PriceKey,
|
|
15
|
+
RawTokenPriceMessage,
|
|
16
|
+
TokenIdentifier,
|
|
17
|
+
TokenInput,
|
|
18
|
+
TokenPrice,
|
|
19
|
+
TokenPriceData,
|
|
20
|
+
TokenPriceMessage,
|
|
21
|
+
TokenSubscriptionParams,
|
|
22
|
+
} from '@l.x/prices/src/types'
|
|
23
|
+
export {
|
|
24
|
+
createPriceKey,
|
|
25
|
+
createPriceKeyFromToken,
|
|
26
|
+
filterValidTokens,
|
|
27
|
+
isCurrency,
|
|
28
|
+
isTokenIdentifier,
|
|
29
|
+
normalizeToken,
|
|
30
|
+
parsePriceKey,
|
|
31
|
+
toSubscriptionParams,
|
|
32
|
+
} from '@l.x/prices/src/utils/tokenIdentifier'
|
|
33
|
+
export type { ConnectionStatus } from '@l.x/websocket'
|
|
34
|
+
export { PriceServiceProvider, usePricesContext } from './context/PriceServiceContext'
|
|
35
|
+
export { useConnectionStatus } from './hooks/useConnectionStatus'
|
|
36
|
+
// Consumer hooks
|
|
37
|
+
// Backward-compat alias
|
|
38
|
+
export { usePrice, usePrice as useLivePrice } from './hooks/usePrice'
|
|
39
|
+
// Query utilities (advanced / UDL)
|
|
40
|
+
export { priceKeys } from './queries/priceKeys'
|
|
41
|
+
export { tokenPriceQueryOptions } from './queries/tokenPriceQueryOptions'
|
|
42
|
+
export { REST_POLL_INTERVAL_MS } from './sources/rest/constants'
|
|
43
|
+
// REST fallback
|
|
44
|
+
export { RestPriceBatcher } from './sources/rest/RestPriceBatcher'
|
|
45
|
+
export type { RestPriceClient } from './sources/rest/types'
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { priceKeys } from '@l.x/prices/src/queries/priceKeys'
|
|
2
|
+
import { ReactQueryCacheKey } from 'utilities/src/reactQuery/cache'
|
|
3
|
+
import { describe, expect, it } from 'vitest'
|
|
4
|
+
|
|
5
|
+
describe('priceKeys', () => {
|
|
6
|
+
describe('all', () => {
|
|
7
|
+
it('returns array with TokenPrice cache key', () => {
|
|
8
|
+
expect(priceKeys.all).toEqual([ReactQueryCacheKey.TokenPrice])
|
|
9
|
+
})
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
describe('token', () => {
|
|
13
|
+
it('returns key with chainId and lowercased address', () => {
|
|
14
|
+
const key = priceKeys.token(1, '0xABC')
|
|
15
|
+
expect(key).toEqual([ReactQueryCacheKey.TokenPrice, 1, '0xabc'])
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('handles already lowercased address', () => {
|
|
19
|
+
const key = priceKeys.token(42161, '0xdef')
|
|
20
|
+
expect(key).toEqual([ReactQueryCacheKey.TokenPrice, 42161, '0xdef'])
|
|
21
|
+
})
|
|
22
|
+
})
|
|
23
|
+
})
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { ReactQueryCacheKey } from 'utilities/src/reactQuery/cache'
|
|
2
|
+
|
|
3
|
+
export const priceKeys = {
|
|
4
|
+
all: [ReactQueryCacheKey.TokenPrice] as const,
|
|
5
|
+
token: (chainId: number, address: string) => [ReactQueryCacheKey.TokenPrice, chainId, address.toLowerCase()] as const,
|
|
6
|
+
} as const
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { type QueryClient, queryOptions, skipToken } from '@tanstack/react-query'
|
|
2
|
+
import { priceKeys } from '@l.x/prices/src/queries/priceKeys'
|
|
3
|
+
import { REST_POLL_INTERVAL_MS } from '@l.x/prices/src/sources/rest/constants'
|
|
4
|
+
import type { RestPriceBatcher } from '@l.x/prices/src/sources/rest/RestPriceBatcher'
|
|
5
|
+
import type { TokenPriceData } from '@l.x/prices/src/types'
|
|
6
|
+
|
|
7
|
+
export interface TokenPriceQueryOptionsParams {
|
|
8
|
+
chainId: number
|
|
9
|
+
address: string
|
|
10
|
+
restBatcher?: RestPriceBatcher
|
|
11
|
+
queryClient?: QueryClient
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
|
|
15
|
+
export function tokenPriceQueryOptions({ chainId, address, restBatcher, queryClient }: TokenPriceQueryOptionsParams) {
|
|
16
|
+
const key = priceKeys.token(chainId, address)
|
|
17
|
+
return queryOptions<TokenPriceData | null>({
|
|
18
|
+
queryKey: key,
|
|
19
|
+
queryFn: restBatcher
|
|
20
|
+
? async (): Promise<TokenPriceData | null> => {
|
|
21
|
+
const fresh = await restBatcher.fetch({ chainId, address: address.toLowerCase() })
|
|
22
|
+
if (!fresh) {
|
|
23
|
+
return null
|
|
24
|
+
}
|
|
25
|
+
// Guard against REST responses overwriting newer WebSocket data.
|
|
26
|
+
// refetchInterval fires independently of staleTime, so without this
|
|
27
|
+
// check a 30s-old REST response could clobber a sub-second WS update.
|
|
28
|
+
const existing = queryClient?.getQueryData<TokenPriceData>(key)
|
|
29
|
+
if (existing && existing.timestamp >= fresh.timestamp) {
|
|
30
|
+
return existing
|
|
31
|
+
}
|
|
32
|
+
return fresh
|
|
33
|
+
}
|
|
34
|
+
: skipToken,
|
|
35
|
+
staleTime: Infinity,
|
|
36
|
+
refetchOnWindowFocus: false,
|
|
37
|
+
refetchOnMount: false,
|
|
38
|
+
structuralSharing: false,
|
|
39
|
+
refetchInterval: restBatcher ? REST_POLL_INTERVAL_MS : false,
|
|
40
|
+
})
|
|
41
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { BATCH_DELAY_MS, MAX_BATCH_SIZE } from '@l.x/prices/src/sources/rest/constants'
|
|
2
|
+
import { RestPriceBatcher } from '@l.x/prices/src/sources/rest/RestPriceBatcher'
|
|
3
|
+
import type { RestPriceClient } from '@l.x/prices/src/sources/rest/types'
|
|
4
|
+
import type { TokenPriceData } from '@l.x/prices/src/types'
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
6
|
+
|
|
7
|
+
function createMockClient(
|
|
8
|
+
handler: (tokens: { chainId: number; address: string }[]) => Promise<Map<string, TokenPriceData>>,
|
|
9
|
+
): RestPriceClient {
|
|
10
|
+
return { getTokenPrices: vi.fn(handler) }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function priceData(price: number, timestamp = Date.now()): TokenPriceData {
|
|
14
|
+
return { price, timestamp }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('RestPriceBatcher', () => {
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
vi.useFakeTimers()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
vi.useRealTimers()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('batches multiple fetch calls within the batching window', async () => {
|
|
27
|
+
const client = createMockClient(async (tokens) => {
|
|
28
|
+
const result = new Map<string, TokenPriceData>()
|
|
29
|
+
for (const t of tokens) {
|
|
30
|
+
result.set(`${t.chainId}-${t.address}`, priceData(100 + t.chainId))
|
|
31
|
+
}
|
|
32
|
+
return result
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
const batcher = new RestPriceBatcher(client)
|
|
36
|
+
|
|
37
|
+
// Fire multiple fetches synchronously (same batching window)
|
|
38
|
+
const p1 = batcher.fetch({ chainId: 1, address: '0xaaa' })
|
|
39
|
+
const p2 = batcher.fetch({ chainId: 42161, address: '0xbbb' })
|
|
40
|
+
|
|
41
|
+
// Advance past the batch delay to trigger flush
|
|
42
|
+
vi.advanceTimersByTime(BATCH_DELAY_MS)
|
|
43
|
+
|
|
44
|
+
const [r1, r2] = await Promise.all([p1, p2])
|
|
45
|
+
|
|
46
|
+
expect(r1).toEqual(priceData(101, r1!.timestamp))
|
|
47
|
+
expect(r2).toEqual(priceData(42261, r2!.timestamp))
|
|
48
|
+
expect(client.getTokenPrices).toHaveBeenCalledTimes(1)
|
|
49
|
+
expect(client.getTokenPrices).toHaveBeenCalledWith([
|
|
50
|
+
{ chainId: 1, address: '0xaaa' },
|
|
51
|
+
{ chainId: 42161, address: '0xbbb' },
|
|
52
|
+
])
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('deduplicates tokens within the same batch', async () => {
|
|
56
|
+
const client = createMockClient(async (tokens) => {
|
|
57
|
+
const result = new Map<string, TokenPriceData>()
|
|
58
|
+
for (const t of tokens) {
|
|
59
|
+
result.set(`${t.chainId}-${t.address}`, priceData(50))
|
|
60
|
+
}
|
|
61
|
+
return result
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
const batcher = new RestPriceBatcher(client)
|
|
65
|
+
|
|
66
|
+
const p1 = batcher.fetch({ chainId: 1, address: '0xAAA' })
|
|
67
|
+
const p2 = batcher.fetch({ chainId: 1, address: '0xaaa' }) // Same after lowercase
|
|
68
|
+
|
|
69
|
+
vi.advanceTimersByTime(BATCH_DELAY_MS)
|
|
70
|
+
|
|
71
|
+
const [r1, r2] = await Promise.all([p1, p2])
|
|
72
|
+
|
|
73
|
+
expect(r1).toEqual(priceData(50, r1!.timestamp))
|
|
74
|
+
expect(r2).toEqual(priceData(50, r2!.timestamp))
|
|
75
|
+
// Only 1 unique token sent to the client
|
|
76
|
+
expect(client.getTokenPrices).toHaveBeenCalledWith([{ chainId: 1, address: '0xaaa' }])
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('chunks large batches to respect MAX_BATCH_SIZE', async () => {
|
|
80
|
+
const client = createMockClient(async (tokens) => {
|
|
81
|
+
const result = new Map<string, TokenPriceData>()
|
|
82
|
+
for (const t of tokens) {
|
|
83
|
+
result.set(`${t.chainId}-${t.address}`, priceData(1))
|
|
84
|
+
}
|
|
85
|
+
return result
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
const batcher = new RestPriceBatcher(client)
|
|
89
|
+
|
|
90
|
+
// Create MAX_BATCH_SIZE + 10 unique tokens
|
|
91
|
+
const count = MAX_BATCH_SIZE + 10
|
|
92
|
+
const promises = Array.from({ length: count }, (_, i) =>
|
|
93
|
+
batcher.fetch({ chainId: 1, address: `0x${i.toString(16).padStart(40, '0')}` }),
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
vi.advanceTimersByTime(BATCH_DELAY_MS)
|
|
97
|
+
|
|
98
|
+
const results = await Promise.all(promises)
|
|
99
|
+
|
|
100
|
+
expect(results).toHaveLength(count)
|
|
101
|
+
expect(results.every((r) => r !== undefined)).toBe(true)
|
|
102
|
+
// Should have been split into 2 REST calls
|
|
103
|
+
expect(client.getTokenPrices).toHaveBeenCalledTimes(2)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('propagates errors to all pending promises', async () => {
|
|
107
|
+
const error = new Error('network failure')
|
|
108
|
+
const client = createMockClient(async () => {
|
|
109
|
+
throw error
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
const batcher = new RestPriceBatcher(client)
|
|
113
|
+
|
|
114
|
+
const p1 = batcher.fetch({ chainId: 1, address: '0xaaa' })
|
|
115
|
+
const p2 = batcher.fetch({ chainId: 42161, address: '0xbbb' })
|
|
116
|
+
|
|
117
|
+
vi.advanceTimersByTime(BATCH_DELAY_MS)
|
|
118
|
+
|
|
119
|
+
await expect(p1).rejects.toThrow('network failure')
|
|
120
|
+
await expect(p2).rejects.toThrow('network failure')
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('returns undefined for tokens not in REST response', async () => {
|
|
124
|
+
const client = createMockClient(async () => {
|
|
125
|
+
// Return empty map (no prices available)
|
|
126
|
+
return new Map<string, TokenPriceData>()
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
const batcher = new RestPriceBatcher(client)
|
|
130
|
+
|
|
131
|
+
const promise = batcher.fetch({ chainId: 1, address: '0xaaa' })
|
|
132
|
+
|
|
133
|
+
vi.advanceTimersByTime(BATCH_DELAY_MS)
|
|
134
|
+
|
|
135
|
+
const result = await promise
|
|
136
|
+
|
|
137
|
+
expect(result).toBeUndefined()
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('processes sequential batches independently', async () => {
|
|
141
|
+
let callCount = 0
|
|
142
|
+
const client = createMockClient(async (tokens) => {
|
|
143
|
+
callCount++
|
|
144
|
+
const result = new Map<string, TokenPriceData>()
|
|
145
|
+
for (const t of tokens) {
|
|
146
|
+
result.set(`${t.chainId}-${t.address}`, priceData(callCount * 100))
|
|
147
|
+
}
|
|
148
|
+
return result
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
const batcher = new RestPriceBatcher(client)
|
|
152
|
+
|
|
153
|
+
// First batch
|
|
154
|
+
const p1 = batcher.fetch({ chainId: 1, address: '0xaaa' })
|
|
155
|
+
vi.advanceTimersByTime(BATCH_DELAY_MS)
|
|
156
|
+
const r1 = await p1
|
|
157
|
+
expect(r1?.price).toBe(100)
|
|
158
|
+
|
|
159
|
+
// Second batch (new timer window)
|
|
160
|
+
const p2 = batcher.fetch({ chainId: 1, address: '0xbbb' })
|
|
161
|
+
vi.advanceTimersByTime(BATCH_DELAY_MS)
|
|
162
|
+
const r2 = await p2
|
|
163
|
+
expect(r2?.price).toBe(200)
|
|
164
|
+
|
|
165
|
+
expect(client.getTokenPrices).toHaveBeenCalledTimes(2)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('lowercases addresses for deduplication', async () => {
|
|
169
|
+
const client = createMockClient(async (tokens) => {
|
|
170
|
+
const result = new Map<string, TokenPriceData>()
|
|
171
|
+
for (const t of tokens) {
|
|
172
|
+
result.set(`${t.chainId}-${t.address}`, priceData(42))
|
|
173
|
+
}
|
|
174
|
+
return result
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
const batcher = new RestPriceBatcher(client)
|
|
178
|
+
|
|
179
|
+
const promise = batcher.fetch({ chainId: 1, address: '0xAbCdEf' })
|
|
180
|
+
|
|
181
|
+
vi.advanceTimersByTime(BATCH_DELAY_MS)
|
|
182
|
+
|
|
183
|
+
const result = await promise
|
|
184
|
+
|
|
185
|
+
expect(result?.price).toBe(42)
|
|
186
|
+
expect(client.getTokenPrices).toHaveBeenCalledWith([{ chainId: 1, address: '0xabcdef' }])
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('batches requests arriving in separate macrotasks within the delay window', async () => {
|
|
190
|
+
const client = createMockClient(async (tokens) => {
|
|
191
|
+
const result = new Map<string, TokenPriceData>()
|
|
192
|
+
for (const t of tokens) {
|
|
193
|
+
result.set(`${t.chainId}-${t.address}`, priceData(99))
|
|
194
|
+
}
|
|
195
|
+
return result
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
const batcher = new RestPriceBatcher(client)
|
|
199
|
+
|
|
200
|
+
// First request starts the timer
|
|
201
|
+
const p1 = batcher.fetch({ chainId: 1, address: '0xaaa' })
|
|
202
|
+
|
|
203
|
+
// Advance partway through the delay (simulating a second macrotask arriving)
|
|
204
|
+
vi.advanceTimersByTime(BATCH_DELAY_MS / 2)
|
|
205
|
+
|
|
206
|
+
// Second request arrives before the timer fires
|
|
207
|
+
const p2 = batcher.fetch({ chainId: 10, address: '0xbbb' })
|
|
208
|
+
|
|
209
|
+
// Now advance past the delay to flush
|
|
210
|
+
vi.advanceTimersByTime(BATCH_DELAY_MS)
|
|
211
|
+
|
|
212
|
+
const [r1, r2] = await Promise.all([p1, p2])
|
|
213
|
+
|
|
214
|
+
expect(r1?.price).toBe(99)
|
|
215
|
+
expect(r2?.price).toBe(99)
|
|
216
|
+
// Both should be in a single batch
|
|
217
|
+
expect(client.getTokenPrices).toHaveBeenCalledTimes(1)
|
|
218
|
+
expect(client.getTokenPrices).toHaveBeenCalledWith([
|
|
219
|
+
{ chainId: 1, address: '0xaaa' },
|
|
220
|
+
{ chainId: 10, address: '0xbbb' },
|
|
221
|
+
])
|
|
222
|
+
})
|
|
223
|
+
})
|