@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.
@@ -0,0 +1,110 @@
1
+ import { BATCH_DELAY_MS, MAX_BATCH_SIZE } from '@l.x/prices/src/sources/rest/constants'
2
+ import type { RestPriceClient } from '@l.x/prices/src/sources/rest/types'
3
+ import type { TokenIdentifier, TokenPriceData } from '@l.x/prices/src/types'
4
+ import { createPriceKey } from '@l.x/prices/src/utils/tokenIdentifier'
5
+
6
+ interface PendingRequest {
7
+ token: TokenIdentifier
8
+ resolve: (value: TokenPriceData | undefined) => void
9
+ reject: (error: unknown) => void
10
+ }
11
+
12
+ /**
13
+ * Timer-based batcher for REST price fetches.
14
+ *
15
+ * Individual `fetch(token)` calls are coalesced into a single batch REST
16
+ * request within a short delay window (BATCH_DELAY_MS ≈ one frame). This
17
+ * ensures requests from separate macrotasks (e.g. React Query refetchInterval
18
+ * callbacks) are grouped together. Duplicate tokens share the same result.
19
+ * Batches exceeding MAX_BATCH_SIZE are chunked into parallel requests.
20
+ */
21
+ export class RestPriceBatcher {
22
+ private readonly client: RestPriceClient
23
+ private pending: PendingRequest[] = []
24
+ private flushTimer: ReturnType<typeof setTimeout> | null = null
25
+
26
+ constructor(client: RestPriceClient) {
27
+ this.client = client
28
+ }
29
+
30
+ /**
31
+ * Request a price for a single token. Multiple calls within the same
32
+ * batching window (BATCH_DELAY_MS) are batched into one REST request.
33
+ *
34
+ * @returns The price data, or undefined if the token has no price.
35
+ */
36
+ fetch(token: TokenIdentifier): Promise<TokenPriceData | undefined> {
37
+ return new Promise<TokenPriceData | undefined>((resolve, reject) => {
38
+ this.pending.push({
39
+ token: { chainId: token.chainId, address: token.address.toLowerCase() },
40
+ resolve,
41
+ reject,
42
+ })
43
+
44
+ if (this.flushTimer === null) {
45
+ this.flushTimer = setTimeout(() => this.flush(), BATCH_DELAY_MS)
46
+ }
47
+ })
48
+ }
49
+
50
+ private async flush(): Promise<void> {
51
+ const batch = this.pending
52
+ this.pending = []
53
+ this.flushTimer = null
54
+
55
+ if (batch.length === 0) {
56
+ return
57
+ }
58
+
59
+ // Group resolvers by price key (deduplication)
60
+ const resolversByKey = new Map<string, PendingRequest[]>()
61
+ const uniqueTokens = new Map<string, TokenIdentifier>()
62
+
63
+ for (const request of batch) {
64
+ const key = createPriceKey(request.token.chainId, request.token.address)
65
+ const existing = resolversByKey.get(key)
66
+ if (existing) {
67
+ existing.push(request)
68
+ } else {
69
+ resolversByKey.set(key, [request])
70
+ uniqueTokens.set(key, request.token)
71
+ }
72
+ }
73
+
74
+ const tokens = Array.from(uniqueTokens.values())
75
+
76
+ // Chunk into batches of MAX_BATCH_SIZE
77
+ const chunks: TokenIdentifier[][] = []
78
+ for (let i = 0; i < tokens.length; i += MAX_BATCH_SIZE) {
79
+ chunks.push(tokens.slice(i, i + MAX_BATCH_SIZE))
80
+ }
81
+
82
+ try {
83
+ // Fetch all chunks in parallel
84
+ const results = await Promise.all(chunks.map((chunk) => this.client.getTokenPrices(chunk)))
85
+
86
+ // Merge all chunk results
87
+ const merged = new Map<string, TokenPriceData>()
88
+ for (const chunkResult of results) {
89
+ for (const [key, value] of chunkResult) {
90
+ merged.set(key, value)
91
+ }
92
+ }
93
+
94
+ // Resolve all pending requests
95
+ for (const [key, requests] of resolversByKey) {
96
+ const data = merged.get(key)
97
+ for (const request of requests) {
98
+ request.resolve(data)
99
+ }
100
+ }
101
+ } catch (error) {
102
+ // On error, reject all pending promises (React Query retries individually)
103
+ for (const requests of resolversByKey.values()) {
104
+ for (const request of requests) {
105
+ request.reject(error)
106
+ }
107
+ }
108
+ }
109
+ }
110
+ }
@@ -0,0 +1,9 @@
1
+ /** How often to poll REST as a fallback for fresh prices */
2
+ export const REST_POLL_INTERVAL_MS = 30_000
3
+
4
+ /** Maximum tokens per REST request (backend limit) */
5
+ export const MAX_BATCH_SIZE = 100
6
+
7
+ /** Delay before flushing a batch (~one frame). Allows requests from separate
8
+ * macrotasks (e.g. React Query refetchInterval callbacks) to be grouped. */
9
+ export const BATCH_DELAY_MS = 16
@@ -0,0 +1,13 @@
1
+ import type { TokenIdentifier, TokenPriceData } from '@l.x/prices/src/types'
2
+
3
+ /**
4
+ * Contract for fetching token prices via REST.
5
+ * Implementations handle the actual transport (ConnectRPC, fetch, etc.).
6
+ */
7
+ export interface RestPriceClient {
8
+ /**
9
+ * Fetches prices for a batch of tokens.
10
+ * @returns Map keyed by price key ("chainId-address"), missing tokens omitted.
11
+ */
12
+ getTokenPrices(tokens: TokenIdentifier[]): Promise<Map<string, TokenPriceData>>
13
+ }
@@ -0,0 +1,79 @@
1
+ import type { ConnectionEstablishedMessage, RawTokenPriceMessage, TokenPriceMessage } from '@l.x/prices/src/types'
2
+ import { createPriceKey } from '@l.x/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 '@l.x/api'
2
+ import type { TokenSubscriptionParams } from '@l.x/prices/src/types'
3
+ import type { SubscriptionHandler } from '@l.x/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 @l.x/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('/lx.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('/lx.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('/lx.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('/lx.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('/lx.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 '@luxamm/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,145 @@
1
+ import { Ether, Token } from '@luxamm/sdk-core'
2
+ import type { TokenIdentifier } from '@l.x/prices'
3
+ import {
4
+ createPriceKey,
5
+ createPriceKeyFromToken,
6
+ filterValidTokens,
7
+ isCurrency,
8
+ isTokenIdentifier,
9
+ normalizeToken,
10
+ parsePriceKey,
11
+ toSubscriptionParams,
12
+ } from '@l.x/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 native currency to zero address', () => {
54
+ const eth = Ether.onChain(1)
55
+ const result = normalizeToken(eth)
56
+ expect(result).toEqual({
57
+ chainId: 1,
58
+ address: '0x0000000000000000000000000000000000000000',
59
+ })
60
+ })
61
+
62
+ it('normalizes Token instance', () => {
63
+ const token = new Token(1, WETH_ADDRESS, 18, 'WETH', 'Wrapped Ether')
64
+ const result = normalizeToken(token)
65
+ expect(result).toEqual({
66
+ chainId: 1,
67
+ address: WETH_ADDRESS.toLowerCase(),
68
+ })
69
+ })
70
+ })
71
+
72
+ describe('createPriceKey', () => {
73
+ it('creates key with format chainId-address', () => {
74
+ const key = createPriceKey(1, WETH_ADDRESS)
75
+ expect(key).toBe(`1-${WETH_ADDRESS.toLowerCase()}`)
76
+ })
77
+
78
+ it('lowercases address', () => {
79
+ const key = createPriceKey(1, '0xABC')
80
+ expect(key).toBe('1-0xabc')
81
+ })
82
+ })
83
+
84
+ describe('createPriceKeyFromToken', () => {
85
+ it('creates key from TokenIdentifier', () => {
86
+ const identifier: TokenIdentifier = { chainId: 1, address: WETH_ADDRESS }
87
+ const key = createPriceKeyFromToken(identifier)
88
+ expect(key).toBe(`1-${WETH_ADDRESS.toLowerCase()}`)
89
+ })
90
+
91
+ it('creates key from Token', () => {
92
+ const token = new Token(1, WETH_ADDRESS, 18, 'WETH', 'Wrapped Ether')
93
+ const key = createPriceKeyFromToken(token)
94
+ expect(key).toBe(`1-${WETH_ADDRESS.toLowerCase()}`)
95
+ })
96
+ })
97
+
98
+ describe('parsePriceKey', () => {
99
+ it('parses key back to TokenIdentifier', () => {
100
+ const key = `1-${WETH_ADDRESS.toLowerCase()}`
101
+ const result = parsePriceKey(key)
102
+ expect(result).toEqual({
103
+ chainId: 1,
104
+ address: WETH_ADDRESS.toLowerCase(),
105
+ })
106
+ })
107
+
108
+ it('returns null for missing address', () => {
109
+ expect(parsePriceKey('1')).toBeNull()
110
+ })
111
+
112
+ it('returns null for invalid chainId', () => {
113
+ expect(parsePriceKey('abc-0x123')).toBeNull()
114
+ })
115
+
116
+ it('returns null for empty key', () => {
117
+ expect(parsePriceKey('')).toBeNull()
118
+ })
119
+ })
120
+
121
+ describe('toSubscriptionParams', () => {
122
+ it('converts to subscription params format', () => {
123
+ const identifier: TokenIdentifier = { chainId: 1, address: WETH_ADDRESS }
124
+ const params = toSubscriptionParams(identifier)
125
+ expect(params).toEqual({
126
+ chainId: 1,
127
+ tokenAddress: WETH_ADDRESS.toLowerCase(),
128
+ })
129
+ })
130
+ })
131
+
132
+ describe('filterValidTokens', () => {
133
+ it('filters out invalid addresses', () => {
134
+ const tokens: TokenIdentifier[] = [
135
+ { chainId: 1, address: WETH_ADDRESS },
136
+ { chainId: 1, address: 'invalid' },
137
+ { chainId: 1, address: USDC_ADDRESS },
138
+ ]
139
+ const result = filterValidTokens(tokens)
140
+ expect(result).toHaveLength(2)
141
+ expect(result[0]?.address).toBe(WETH_ADDRESS.toLowerCase())
142
+ expect(result[1]?.address).toBe(USDC_ADDRESS.toLowerCase())
143
+ })
144
+ })
145
+ })
@@ -0,0 +1,111 @@
1
+ import type { Currency, Token } from '@luxamm/sdk-core'
2
+ import type { PriceKey, TokenIdentifier, TokenInput, TokenSubscriptionParams } from '@l.x/prices'
3
+ import { isEVMAddress } from 'utilities/src/addresses/evm/evm'
4
+
5
+ /** Address that represents native currencies on ETH, Arbitrum, etc. */
6
+ const DEFAULT_NATIVE_ADDRESS = '0x0000000000000000000000000000000000000000'
7
+
8
+ /**
9
+ * Type guard to check if input is a Currency object (from @luxamm/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,
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
+ }