@luxexchange/prices 1.0.0
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 +45 -0
- package/project.json +26 -0
- package/src/context/PriceServiceContext.test.tsx +63 -0
- package/src/context/PriceServiceContext.tsx +31 -0
- package/src/hooks/useConnectionStatus.ts +11 -0
- package/src/hooks/usePrice.ts +44 -0
- package/src/index.ts +41 -0
- package/src/queries/priceKeys.test.ts +23 -0
- package/src/queries/priceKeys.ts +6 -0
- package/src/queries/tokenPriceQueryOptions.ts +15 -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 +136 -0
- package/src/utils/tokenIdentifier.ts +111 -0
- package/tsconfig.json +22 -0
- package/tsconfig.lint.json +8 -0
- package/vitest.config.ts +22 -0
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: ['@luxfi/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: '@universe/prices',
|
|
15
|
+
},
|
|
16
|
+
],
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
],
|
|
20
|
+
}
|
package/README.md
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@luxexchange/prices",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"dependencies": {
|
|
5
|
+
"@tanstack/react-query": "5.90.20",
|
|
6
|
+
"@uniswap/sdk-core": "7.10.1",
|
|
7
|
+
"@luxexchange/api": "^1.0.0",
|
|
8
|
+
"@luxexchange/websocket": "^1.0.0",
|
|
9
|
+
"@luxfi/utilities": "^1.0.0"
|
|
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.20260108.1",
|
|
18
|
+
"@luxfi/eslint-config": "^1.0.0",
|
|
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
|
+
"test:coverage": "nx test:coverage prices",
|
|
29
|
+
"typecheck": "nx typecheck prices",
|
|
30
|
+
"typecheck:tsgo": "nx typecheck:tsgo prices",
|
|
31
|
+
"lint": "nx lint prices",
|
|
32
|
+
"lint:fix": "nx lint:fix prices",
|
|
33
|
+
"lint:biome": "nx lint:biome prices",
|
|
34
|
+
"lint:biome:fix": "nx lint:biome:fix prices",
|
|
35
|
+
"lint:eslint": "nx lint:eslint prices",
|
|
36
|
+
"lint:eslint:fix": "nx lint:eslint:fix prices",
|
|
37
|
+
"check:deps:usage": "nx check:deps:usage prices"
|
|
38
|
+
},
|
|
39
|
+
"nx": {
|
|
40
|
+
"includedScripts": []
|
|
41
|
+
},
|
|
42
|
+
"main": "src/index.ts",
|
|
43
|
+
"private": false,
|
|
44
|
+
"sideEffects": false
|
|
45
|
+
}
|
package/project.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@universe/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
|
+
"test:coverage": {
|
|
13
|
+
"command": "vitest run --coverage --passWithNoTests",
|
|
14
|
+
"options": { "cwd": "{projectRoot}" }
|
|
15
|
+
},
|
|
16
|
+
"typecheck": {},
|
|
17
|
+
"typecheck:tsgo": {},
|
|
18
|
+
"lint:biome": {},
|
|
19
|
+
"lint:biome:fix": {},
|
|
20
|
+
"lint:eslint": {},
|
|
21
|
+
"lint:eslint:fix": {},
|
|
22
|
+
"lint": {},
|
|
23
|
+
"lint:fix": {},
|
|
24
|
+
"check:deps:usage": {}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -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 '@luxexchange/prices/src/context/PriceServiceContext'
|
|
6
|
+
import type { TokenPriceMessage, TokenSubscriptionParams } from '@luxexchange/prices/src/types'
|
|
7
|
+
import type { WebSocketClient } from '@luxexchange/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,31 @@
|
|
|
1
|
+
import type { QueryClient } from '@tanstack/react-query'
|
|
2
|
+
import type { TokenPriceMessage, TokenSubscriptionParams } from '@luxexchange/prices/src/types'
|
|
3
|
+
import type { WebSocketClient } from '@luxexchange/websocket'
|
|
4
|
+
import { createContext, type ReactElement, type ReactNode, useContext } from 'react'
|
|
5
|
+
|
|
6
|
+
interface PricesContextValue {
|
|
7
|
+
wsClient: WebSocketClient<TokenSubscriptionParams, TokenPriceMessage['data']>
|
|
8
|
+
queryClient: QueryClient
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const PricesContext = createContext<PricesContextValue | null>(null)
|
|
12
|
+
|
|
13
|
+
export function PriceServiceProvider({
|
|
14
|
+
wsClient,
|
|
15
|
+
queryClient,
|
|
16
|
+
children,
|
|
17
|
+
}: {
|
|
18
|
+
wsClient: WebSocketClient<TokenSubscriptionParams, TokenPriceMessage['data']>
|
|
19
|
+
queryClient: QueryClient
|
|
20
|
+
children: ReactNode
|
|
21
|
+
}): ReactElement {
|
|
22
|
+
return <PricesContext.Provider value={{ wsClient, queryClient }}>{children}</PricesContext.Provider>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function usePricesContext(): PricesContextValue {
|
|
26
|
+
const context = useContext(PricesContext)
|
|
27
|
+
if (!context) {
|
|
28
|
+
throw new Error('usePricesContext must be used within a PriceServiceProvider')
|
|
29
|
+
}
|
|
30
|
+
return context
|
|
31
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { usePricesContext } from '@luxexchange/prices/src/context/PriceServiceContext'
|
|
2
|
+
import type { ConnectionStatus } from '@luxexchange/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,44 @@
|
|
|
1
|
+
import { queryOptions, skipToken, useQuery } from '@tanstack/react-query'
|
|
2
|
+
import { usePricesContext } from '@luxexchange/prices/src/context/PriceServiceContext'
|
|
3
|
+
import { priceKeys } from '@luxexchange/prices/src/queries/priceKeys'
|
|
4
|
+
import { tokenPriceQueryOptions } from '@luxexchange/prices/src/queries/tokenPriceQueryOptions'
|
|
5
|
+
import type { TokenPriceData } from '@luxexchange/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
|
+
*
|
|
18
|
+
* Requires a PriceServiceProvider in the tree.
|
|
19
|
+
*/
|
|
20
|
+
export function usePrice(options: UsePriceOptions): number | undefined {
|
|
21
|
+
const { chainId, address, live = true } = options
|
|
22
|
+
const { wsClient } = usePricesContext()
|
|
23
|
+
|
|
24
|
+
const enabled = chainId !== undefined && !!address
|
|
25
|
+
|
|
26
|
+
// Data is populated externally via queryClient.setQueryData from WS messages.
|
|
27
|
+
const { data } = useQuery(
|
|
28
|
+
enabled
|
|
29
|
+
? tokenPriceQueryOptions(chainId, address)
|
|
30
|
+
: queryOptions<TokenPriceData>({ queryKey: priceKeys.all, queryFn: skipToken, enabled: false }),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
if (!enabled || !live) {
|
|
35
|
+
return undefined
|
|
36
|
+
}
|
|
37
|
+
return wsClient.subscribe({
|
|
38
|
+
channel: 'token_price',
|
|
39
|
+
params: { chainId, tokenAddress: address.toLowerCase() },
|
|
40
|
+
})
|
|
41
|
+
}, [enabled, live, chainId, address, wsClient])
|
|
42
|
+
|
|
43
|
+
return enabled ? (data?.price ?? undefined) : undefined
|
|
44
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Types
|
|
2
|
+
|
|
3
|
+
export {
|
|
4
|
+
isConnectionEstablishedMessage,
|
|
5
|
+
isRawTokenPriceMessage,
|
|
6
|
+
parseConnectionMessage,
|
|
7
|
+
parseTokenPriceMessage,
|
|
8
|
+
} from '@luxexchange/prices/src/sources/websocket/messageParser'
|
|
9
|
+
// Internals (testing / custom setups)
|
|
10
|
+
export { createPriceSubscriptionHandler } from '@luxexchange/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 '@luxexchange/prices/src/types'
|
|
23
|
+
export {
|
|
24
|
+
createPriceKey,
|
|
25
|
+
createPriceKeyFromToken,
|
|
26
|
+
filterValidTokens,
|
|
27
|
+
isCurrency,
|
|
28
|
+
isTokenIdentifier,
|
|
29
|
+
normalizeToken,
|
|
30
|
+
parsePriceKey,
|
|
31
|
+
toSubscriptionParams,
|
|
32
|
+
} from '@luxexchange/prices/src/utils/tokenIdentifier'
|
|
33
|
+
export type { ConnectionStatus } from '@luxexchange/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'
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { priceKeys } from '@luxexchange/prices/src/queries/priceKeys'
|
|
2
|
+
import { ReactQueryCacheKey } from '@luxfi/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 '@luxfi/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,15 @@
|
|
|
1
|
+
import { queryOptions, skipToken } from '@tanstack/react-query'
|
|
2
|
+
import { priceKeys } from '@luxexchange/prices/src/queries/priceKeys'
|
|
3
|
+
import type { TokenPriceData } from '@luxexchange/prices/src/types'
|
|
4
|
+
|
|
5
|
+
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
|
|
6
|
+
export function tokenPriceQueryOptions(chainId: number, address: string) {
|
|
7
|
+
return queryOptions<TokenPriceData>({
|
|
8
|
+
queryKey: priceKeys.token(chainId, address),
|
|
9
|
+
queryFn: skipToken,
|
|
10
|
+
staleTime: Infinity,
|
|
11
|
+
refetchOnWindowFocus: false,
|
|
12
|
+
refetchOnMount: false,
|
|
13
|
+
structuralSharing: false,
|
|
14
|
+
})
|
|
15
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { ConnectionEstablishedMessage, RawTokenPriceMessage, TokenPriceMessage } from '@luxexchange/prices/src/types'
|
|
2
|
+
import { createPriceKey } from '@luxexchange/prices/src/utils/tokenIdentifier'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Type guard for RawTokenPriceMessage
|
|
6
|
+
*/
|
|
7
|
+
export function isRawTokenPriceMessage(message: unknown): message is RawTokenPriceMessage {
|
|
8
|
+
return (
|
|
9
|
+
typeof message === 'object' &&
|
|
10
|
+
message !== null &&
|
|
11
|
+
'type' in message &&
|
|
12
|
+
message.type === 'token_price_update' &&
|
|
13
|
+
'payload' in message &&
|
|
14
|
+
typeof message.payload === 'object' &&
|
|
15
|
+
message.payload !== null
|
|
16
|
+
)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Type guard for ConnectionEstablishedMessage
|
|
21
|
+
*/
|
|
22
|
+
export function isConnectionEstablishedMessage(message: unknown): message is ConnectionEstablishedMessage {
|
|
23
|
+
return (
|
|
24
|
+
typeof message === 'object' &&
|
|
25
|
+
message !== null &&
|
|
26
|
+
'connectionEstablished' in message &&
|
|
27
|
+
typeof message.connectionEstablished === 'object' &&
|
|
28
|
+
message.connectionEstablished !== null &&
|
|
29
|
+
'connectionId' in message.connectionEstablished
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Parses a raw WebSocket message into a typed price update.
|
|
35
|
+
* Returns null if the message is not a price update.
|
|
36
|
+
*
|
|
37
|
+
* Used both as the `parseMessage` config for createWebSocketClient
|
|
38
|
+
* and in the `onRawMessage` callback to write to React Query cache.
|
|
39
|
+
*/
|
|
40
|
+
export function parseTokenPriceMessage(raw: unknown): TokenPriceMessage | null {
|
|
41
|
+
if (!isRawTokenPriceMessage(raw)) {
|
|
42
|
+
return null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const { chainId, tokenAddress, priceUsd } = raw.payload
|
|
46
|
+
const price = parseFloat(priceUsd)
|
|
47
|
+
|
|
48
|
+
if (Number.isNaN(price)) {
|
|
49
|
+
return null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const timestamp = new Date(raw.timestamp).getTime()
|
|
53
|
+
const key = createPriceKey(chainId, tokenAddress)
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
channel: 'token_price',
|
|
57
|
+
key,
|
|
58
|
+
data: {
|
|
59
|
+
chainId,
|
|
60
|
+
tokenAddress: tokenAddress.toLowerCase(),
|
|
61
|
+
priceUsd: price,
|
|
62
|
+
timestamp,
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Parses a raw WebSocket message for connection establishment.
|
|
69
|
+
* Returns null if the message is not a connection message.
|
|
70
|
+
*/
|
|
71
|
+
export function parseConnectionMessage(raw: unknown): { connectionId: string } | null {
|
|
72
|
+
if (!isConnectionEstablishedMessage(raw)) {
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
connectionId: raw.connectionEstablished.connectionId,
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { FetchClient } from '@luxexchange/api'
|
|
2
|
+
import type { TokenSubscriptionParams } from '@luxexchange/prices/src/types'
|
|
3
|
+
import type { SubscriptionHandler } from '@luxexchange/websocket'
|
|
4
|
+
|
|
5
|
+
const EVENT_SUBSCRIPTION_TYPE_TOKEN_PRICE = 'EVENT_SUBSCRIPTION_TYPE_TOKEN_PRICE'
|
|
6
|
+
|
|
7
|
+
export interface SubscriptionApiOptions {
|
|
8
|
+
client: FetchClient
|
|
9
|
+
onError?: (error: unknown, operation: string) => void
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Creates a subscription handler for token price subscriptions.
|
|
14
|
+
* This implements the SubscriptionHandler interface from @luxexchange/websocket.
|
|
15
|
+
*/
|
|
16
|
+
export function createPriceSubscriptionHandler(
|
|
17
|
+
options: SubscriptionApiOptions,
|
|
18
|
+
): SubscriptionHandler<TokenSubscriptionParams> {
|
|
19
|
+
const { client, onError } = options
|
|
20
|
+
|
|
21
|
+
async function subscribe(connectionId: string, params: TokenSubscriptionParams): Promise<void> {
|
|
22
|
+
try {
|
|
23
|
+
await client.post('/lux.notificationservice.v1.EventSubscriptionService/Subscribe', {
|
|
24
|
+
body: JSON.stringify({
|
|
25
|
+
eventSubscriptionType: EVENT_SUBSCRIPTION_TYPE_TOKEN_PRICE,
|
|
26
|
+
connectionId,
|
|
27
|
+
events: [{ token: { chainId: params.chainId, tokenAddress: params.tokenAddress } }],
|
|
28
|
+
}),
|
|
29
|
+
})
|
|
30
|
+
} catch (error) {
|
|
31
|
+
onError?.(error, 'subscribe')
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function unsubscribe(connectionId: string, params: TokenSubscriptionParams): Promise<void> {
|
|
36
|
+
try {
|
|
37
|
+
await client.post('/lux.notificationservice.v1.EventSubscriptionService/Unsubscribe', {
|
|
38
|
+
body: JSON.stringify({
|
|
39
|
+
eventSubscriptionType: EVENT_SUBSCRIPTION_TYPE_TOKEN_PRICE,
|
|
40
|
+
connectionId,
|
|
41
|
+
events: [{ token: { chainId: params.chainId, tokenAddress: params.tokenAddress } }],
|
|
42
|
+
}),
|
|
43
|
+
})
|
|
44
|
+
} catch (error) {
|
|
45
|
+
onError?.(error, 'unsubscribe')
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function subscribeBatch(connectionId: string, paramsList: TokenSubscriptionParams[]): Promise<void> {
|
|
50
|
+
if (paramsList.length === 0) {
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
await client.post('/lux.notificationservice.v1.EventSubscriptionService/Subscribe', {
|
|
56
|
+
body: JSON.stringify({
|
|
57
|
+
eventSubscriptionType: EVENT_SUBSCRIPTION_TYPE_TOKEN_PRICE,
|
|
58
|
+
connectionId,
|
|
59
|
+
events: paramsList.map((p) => ({ token: { chainId: p.chainId, tokenAddress: p.tokenAddress } })),
|
|
60
|
+
}),
|
|
61
|
+
})
|
|
62
|
+
} catch (error) {
|
|
63
|
+
onError?.(error, 'subscribeBatch')
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function unsubscribeBatch(connectionId: string, paramsList: TokenSubscriptionParams[]): Promise<void> {
|
|
68
|
+
if (paramsList.length === 0) {
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
await client.post('/lux.notificationservice.v1.EventSubscriptionService/Unsubscribe', {
|
|
74
|
+
body: JSON.stringify({
|
|
75
|
+
eventSubscriptionType: EVENT_SUBSCRIPTION_TYPE_TOKEN_PRICE,
|
|
76
|
+
connectionId,
|
|
77
|
+
events: paramsList.map((p) => ({ token: { chainId: p.chainId, tokenAddress: p.tokenAddress } })),
|
|
78
|
+
}),
|
|
79
|
+
})
|
|
80
|
+
} catch (error) {
|
|
81
|
+
onError?.(error, 'unsubscribeBatch')
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function refreshSession(connectionId: string): Promise<void> {
|
|
86
|
+
try {
|
|
87
|
+
await client.post('/lux.notificationservice.v1.EventSubscriptionService/RefreshSession', {
|
|
88
|
+
body: JSON.stringify({
|
|
89
|
+
eventSubscriptionType: EVENT_SUBSCRIPTION_TYPE_TOKEN_PRICE,
|
|
90
|
+
connectionId,
|
|
91
|
+
}),
|
|
92
|
+
})
|
|
93
|
+
} catch (error) {
|
|
94
|
+
onError?.(error, 'refreshSession')
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
subscribe,
|
|
100
|
+
unsubscribe,
|
|
101
|
+
subscribeBatch,
|
|
102
|
+
unsubscribeBatch,
|
|
103
|
+
refreshSession,
|
|
104
|
+
}
|
|
105
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { Currency } from '@uniswap/sdk-core'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A token identifier with chain and address.
|
|
5
|
+
* Can be used instead of a full Currency object.
|
|
6
|
+
*/
|
|
7
|
+
export interface TokenIdentifier {
|
|
8
|
+
chainId: number
|
|
9
|
+
address: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Token price data with timestamp.
|
|
14
|
+
*/
|
|
15
|
+
export interface TokenPrice {
|
|
16
|
+
price: number
|
|
17
|
+
timestamp: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Shape stored in the React Query cache for each token price.
|
|
22
|
+
*/
|
|
23
|
+
export interface TokenPriceData {
|
|
24
|
+
price: number
|
|
25
|
+
timestamp: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Key used to identify a token price in the store.
|
|
30
|
+
* Format: "chainId-address" (e.g., "1-0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2")
|
|
31
|
+
* Matches CurrencyId convention from lx/src/utils/currencyId.ts
|
|
32
|
+
*/
|
|
33
|
+
export type PriceKey = string
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Token identifier format used by the subscription API.
|
|
37
|
+
*/
|
|
38
|
+
export interface TokenSubscriptionParams {
|
|
39
|
+
chainId: number
|
|
40
|
+
tokenAddress: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Parsed WebSocket message for token price updates.
|
|
45
|
+
* Wraps channel, key, and the inner data payload.
|
|
46
|
+
*/
|
|
47
|
+
export interface TokenPriceMessage {
|
|
48
|
+
channel: string
|
|
49
|
+
key: string
|
|
50
|
+
data: {
|
|
51
|
+
chainId: number
|
|
52
|
+
tokenAddress: string
|
|
53
|
+
priceUsd: number
|
|
54
|
+
timestamp: number
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Raw WebSocket message format from server (strings, optional fields).
|
|
60
|
+
* Parsed into {@link TokenPriceMessage} by messageParser before app consumption.
|
|
61
|
+
*/
|
|
62
|
+
export interface RawTokenPriceMessage {
|
|
63
|
+
type: 'token_price_update'
|
|
64
|
+
payload: {
|
|
65
|
+
chainId: number
|
|
66
|
+
tokenAddress: string
|
|
67
|
+
priceUsd: string
|
|
68
|
+
symbol?: string
|
|
69
|
+
timestamp?: string
|
|
70
|
+
}
|
|
71
|
+
timestamp: string
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Connection established message from server.
|
|
76
|
+
*/
|
|
77
|
+
export interface ConnectionEstablishedMessage {
|
|
78
|
+
connectionEstablished: {
|
|
79
|
+
connectionId: string
|
|
80
|
+
timestamp: string
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Logger interface for optional logging.
|
|
86
|
+
*/
|
|
87
|
+
export interface Logger {
|
|
88
|
+
debug: (tag: string, context: string, message: string, data?: unknown) => void
|
|
89
|
+
warn: (tag: string, context: string, message: string, data?: unknown) => void
|
|
90
|
+
error: (tag: string, context: string, message: string, data?: unknown) => void
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Type guard input - any token that can provide chain and address.
|
|
95
|
+
*/
|
|
96
|
+
export type TokenInput = TokenIdentifier | Currency
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { Token } from '@uniswap/sdk-core'
|
|
2
|
+
import type { TokenIdentifier } from '@luxexchange/prices'
|
|
3
|
+
import {
|
|
4
|
+
createPriceKey,
|
|
5
|
+
createPriceKeyFromToken,
|
|
6
|
+
filterValidTokens,
|
|
7
|
+
isCurrency,
|
|
8
|
+
isTokenIdentifier,
|
|
9
|
+
normalizeToken,
|
|
10
|
+
parsePriceKey,
|
|
11
|
+
toSubscriptionParams,
|
|
12
|
+
} from '@luxexchange/prices'
|
|
13
|
+
import { describe, expect, it } from 'vitest'
|
|
14
|
+
|
|
15
|
+
const WETH_ADDRESS = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'
|
|
16
|
+
const USDC_ADDRESS = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'
|
|
17
|
+
|
|
18
|
+
describe('tokenIdentifier utilities', () => {
|
|
19
|
+
describe('isCurrency', () => {
|
|
20
|
+
it('returns true for Token instances', () => {
|
|
21
|
+
const token = new Token(1, WETH_ADDRESS, 18, 'WETH', 'Wrapped Ether')
|
|
22
|
+
expect(isCurrency(token)).toBe(true)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('returns false for TokenIdentifier', () => {
|
|
26
|
+
const identifier: TokenIdentifier = { chainId: 1, address: WETH_ADDRESS }
|
|
27
|
+
expect(isCurrency(identifier)).toBe(false)
|
|
28
|
+
})
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
describe('isTokenIdentifier', () => {
|
|
32
|
+
it('returns true for TokenIdentifier objects', () => {
|
|
33
|
+
const identifier: TokenIdentifier = { chainId: 1, address: WETH_ADDRESS }
|
|
34
|
+
expect(isTokenIdentifier(identifier)).toBe(true)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('returns false for Token instances', () => {
|
|
38
|
+
const token = new Token(1, WETH_ADDRESS, 18, 'WETH', 'Wrapped Ether')
|
|
39
|
+
expect(isTokenIdentifier(token)).toBe(false)
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
describe('normalizeToken', () => {
|
|
44
|
+
it('normalizes TokenIdentifier with lowercase address', () => {
|
|
45
|
+
const identifier: TokenIdentifier = { chainId: 1, address: WETH_ADDRESS }
|
|
46
|
+
const result = normalizeToken(identifier)
|
|
47
|
+
expect(result).toEqual({
|
|
48
|
+
chainId: 1,
|
|
49
|
+
address: WETH_ADDRESS.toLowerCase(),
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('normalizes Token instance', () => {
|
|
54
|
+
const token = new Token(1, WETH_ADDRESS, 18, 'WETH', 'Wrapped Ether')
|
|
55
|
+
const result = normalizeToken(token)
|
|
56
|
+
expect(result).toEqual({
|
|
57
|
+
chainId: 1,
|
|
58
|
+
address: WETH_ADDRESS.toLowerCase(),
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
describe('createPriceKey', () => {
|
|
64
|
+
it('creates key with format chainId-address', () => {
|
|
65
|
+
const key = createPriceKey(1, WETH_ADDRESS)
|
|
66
|
+
expect(key).toBe(`1-${WETH_ADDRESS.toLowerCase()}`)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('lowercases address', () => {
|
|
70
|
+
const key = createPriceKey(1, '0xABC')
|
|
71
|
+
expect(key).toBe('1-0xabc')
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
describe('createPriceKeyFromToken', () => {
|
|
76
|
+
it('creates key from TokenIdentifier', () => {
|
|
77
|
+
const identifier: TokenIdentifier = { chainId: 1, address: WETH_ADDRESS }
|
|
78
|
+
const key = createPriceKeyFromToken(identifier)
|
|
79
|
+
expect(key).toBe(`1-${WETH_ADDRESS.toLowerCase()}`)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('creates key from Token', () => {
|
|
83
|
+
const token = new Token(1, WETH_ADDRESS, 18, 'WETH', 'Wrapped Ether')
|
|
84
|
+
const key = createPriceKeyFromToken(token)
|
|
85
|
+
expect(key).toBe(`1-${WETH_ADDRESS.toLowerCase()}`)
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
describe('parsePriceKey', () => {
|
|
90
|
+
it('parses key back to TokenIdentifier', () => {
|
|
91
|
+
const key = `1-${WETH_ADDRESS.toLowerCase()}`
|
|
92
|
+
const result = parsePriceKey(key)
|
|
93
|
+
expect(result).toEqual({
|
|
94
|
+
chainId: 1,
|
|
95
|
+
address: WETH_ADDRESS.toLowerCase(),
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('returns null for missing address', () => {
|
|
100
|
+
expect(parsePriceKey('1')).toBeNull()
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('returns null for invalid chainId', () => {
|
|
104
|
+
expect(parsePriceKey('abc-0x123')).toBeNull()
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('returns null for empty key', () => {
|
|
108
|
+
expect(parsePriceKey('')).toBeNull()
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
describe('toSubscriptionParams', () => {
|
|
113
|
+
it('converts to subscription params format', () => {
|
|
114
|
+
const identifier: TokenIdentifier = { chainId: 1, address: WETH_ADDRESS }
|
|
115
|
+
const params = toSubscriptionParams(identifier)
|
|
116
|
+
expect(params).toEqual({
|
|
117
|
+
chainId: 1,
|
|
118
|
+
tokenAddress: WETH_ADDRESS.toLowerCase(),
|
|
119
|
+
})
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
describe('filterValidTokens', () => {
|
|
124
|
+
it('filters out invalid addresses', () => {
|
|
125
|
+
const tokens: TokenIdentifier[] = [
|
|
126
|
+
{ chainId: 1, address: WETH_ADDRESS },
|
|
127
|
+
{ chainId: 1, address: 'invalid' },
|
|
128
|
+
{ chainId: 1, address: USDC_ADDRESS },
|
|
129
|
+
]
|
|
130
|
+
const result = filterValidTokens(tokens)
|
|
131
|
+
expect(result).toHaveLength(2)
|
|
132
|
+
expect(result[0]?.address).toBe(WETH_ADDRESS.toLowerCase())
|
|
133
|
+
expect(result[1]?.address).toBe(USDC_ADDRESS.toLowerCase())
|
|
134
|
+
})
|
|
135
|
+
})
|
|
136
|
+
})
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { Currency, Token } from '@uniswap/sdk-core'
|
|
2
|
+
import type { PriceKey, TokenIdentifier, TokenInput, TokenSubscriptionParams } from '@luxexchange/prices'
|
|
3
|
+
import { isEVMAddress } from '@luxfi/utilities/src/addresses/evm/evm'
|
|
4
|
+
|
|
5
|
+
/** Address that represents native currencies on ETH, Arbitrum, etc. */
|
|
6
|
+
const DEFAULT_NATIVE_ADDRESS_LEGACY = '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Type guard to check if input is a Currency object (from @uniswap/sdk-core).
|
|
10
|
+
* Currency objects have isNative and isToken properties from the SDK.
|
|
11
|
+
*/
|
|
12
|
+
export function isCurrency(token: TokenInput): token is Currency {
|
|
13
|
+
return (
|
|
14
|
+
typeof token === 'object' &&
|
|
15
|
+
'chainId' in token &&
|
|
16
|
+
typeof (token as Currency).chainId === 'number' &&
|
|
17
|
+
// Currency from SDK always has isNative property (true for native, false for tokens)
|
|
18
|
+
'isNative' in token &&
|
|
19
|
+
typeof (token as Currency).isNative === 'boolean'
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Type guard to check if input is a TokenIdentifier.
|
|
25
|
+
*/
|
|
26
|
+
export function isTokenIdentifier(token: TokenInput): token is TokenIdentifier {
|
|
27
|
+
return (
|
|
28
|
+
typeof token === 'object' &&
|
|
29
|
+
'chainId' in token &&
|
|
30
|
+
'address' in token &&
|
|
31
|
+
typeof (token as TokenIdentifier).chainId === 'number' &&
|
|
32
|
+
typeof (token as TokenIdentifier).address === 'string' &&
|
|
33
|
+
!('isNative' in token)
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Normalizes any token input to a TokenIdentifier.
|
|
39
|
+
*/
|
|
40
|
+
export function normalizeToken(token: TokenInput): TokenIdentifier {
|
|
41
|
+
if (isTokenIdentifier(token)) {
|
|
42
|
+
return {
|
|
43
|
+
chainId: token.chainId,
|
|
44
|
+
address: token.address.toLowerCase(),
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// It's a Currency
|
|
49
|
+
const currency = token as Currency
|
|
50
|
+
if (currency.isNative) {
|
|
51
|
+
return {
|
|
52
|
+
chainId: currency.chainId,
|
|
53
|
+
address: DEFAULT_NATIVE_ADDRESS_LEGACY,
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// It's a Token
|
|
58
|
+
const tokenCurrency = currency as Token
|
|
59
|
+
return {
|
|
60
|
+
chainId: tokenCurrency.chainId,
|
|
61
|
+
address: tokenCurrency.address.toLowerCase(),
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Creates a unique price key from chainId and address.
|
|
67
|
+
* Format matches CurrencyId convention: "chainId-address"
|
|
68
|
+
*/
|
|
69
|
+
export function createPriceKey(chainId: number, address: string): PriceKey {
|
|
70
|
+
return `${chainId}-${address.toLowerCase()}`
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Creates a price key from a token input.
|
|
75
|
+
*/
|
|
76
|
+
export function createPriceKeyFromToken(token: TokenInput): PriceKey {
|
|
77
|
+
const { chainId, address } = normalizeToken(token)
|
|
78
|
+
return createPriceKey(chainId, address)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Parses a price key back to chainId and address.
|
|
83
|
+
* Returns null if the key is malformed (missing chainId or address).
|
|
84
|
+
*/
|
|
85
|
+
export function parsePriceKey(key: PriceKey): TokenIdentifier | null {
|
|
86
|
+
const [chainIdStr, address] = key.split('-')
|
|
87
|
+
const chainId = Number(chainIdStr)
|
|
88
|
+
|
|
89
|
+
if (Number.isNaN(chainId) || !address) {
|
|
90
|
+
return null
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return { chainId, address }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Converts a TokenIdentifier to the format expected by the subscription API.
|
|
98
|
+
*/
|
|
99
|
+
export function toSubscriptionParams(token: TokenIdentifier): TokenSubscriptionParams {
|
|
100
|
+
return {
|
|
101
|
+
chainId: token.chainId,
|
|
102
|
+
tokenAddress: token.address.toLowerCase(),
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Filters tokens to only those valid for subscription.
|
|
108
|
+
*/
|
|
109
|
+
export function filterValidTokens(tokens: TokenInput[]): TokenIdentifier[] {
|
|
110
|
+
return tokens.map(normalizeToken).filter((t) => isEVMAddress(t.address))
|
|
111
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../../config/tsconfig/app.json",
|
|
3
|
+
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.json", "src/global.d.ts"],
|
|
4
|
+
"exclude": ["src/**/*.spec.ts", "src/**/*.spec.tsx", "src/**/*.test.ts", "src/**/*.test.tsx"],
|
|
5
|
+
"compilerOptions": {
|
|
6
|
+
"noEmit": false,
|
|
7
|
+
"emitDeclarationOnly": true,
|
|
8
|
+
"types": ["node", "vitest/globals"],
|
|
9
|
+
"paths": {}
|
|
10
|
+
},
|
|
11
|
+
"references": [
|
|
12
|
+
{
|
|
13
|
+
"path": "../utilities"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"path": "../websocket"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"path": "../api"
|
|
20
|
+
}
|
|
21
|
+
]
|
|
22
|
+
}
|
package/vitest.config.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { defineConfig } from 'vitest/config'
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
test: {
|
|
5
|
+
environment: 'node',
|
|
6
|
+
server: {
|
|
7
|
+
deps: {
|
|
8
|
+
inline: ['@tanstack/react-query', 'partysocket'],
|
|
9
|
+
},
|
|
10
|
+
},
|
|
11
|
+
coverage: {
|
|
12
|
+
exclude: [
|
|
13
|
+
'**/__generated__/**',
|
|
14
|
+
'**/node_modules/**',
|
|
15
|
+
'**/dist/**',
|
|
16
|
+
'**/*.config.*',
|
|
17
|
+
'**/scripts/**',
|
|
18
|
+
'**/.eslintrc.*',
|
|
19
|
+
],
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
})
|