@spacesops/wdk-react-native-core 1.0.0-beta.67 → 1.0.0-beta.69

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spacesops/wdk-react-native-core",
3
- "version": "1.0.0-beta.67",
3
+ "version": "1.0.0-beta.69",
4
4
  "description": "Core functionality for React Native wallets - wallet management, balance fetching, and worklet operations",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Tests for TransactionService
3
+ */
4
+
5
+ import { setIndexerConfig } from '../../store/indexerConfigStore'
6
+ import { TransactionService } from '../../services/transactionService'
7
+
8
+ const mockFetch = jest.fn()
9
+ global.fetch = mockFetch as typeof fetch
10
+
11
+ describe('TransactionService', () => {
12
+ beforeEach(() => {
13
+ jest.clearAllMocks()
14
+ setIndexerConfig({
15
+ baseUrl: 'https://wdk-api.tether.io',
16
+ apiKey: 'test-api-key',
17
+ })
18
+ })
19
+
20
+ afterEach(() => {
21
+ setIndexerConfig(null)
22
+ })
23
+
24
+ it('fetches and parses token transfers', async () => {
25
+ mockFetch.mockResolvedValueOnce({
26
+ ok: true,
27
+ status: 200,
28
+ json: async () => ({
29
+ transfers: [
30
+ {
31
+ blockchain: 'ethereum',
32
+ transactionHash: '0xabc',
33
+ token: 'xaut',
34
+ amount: '1.5',
35
+ timestamp: 1700000000,
36
+ from: '0xfrom',
37
+ to: '0xto',
38
+ },
39
+ ],
40
+ }),
41
+ })
42
+
43
+ const transfers = await TransactionService.fetchTokenTransfers(
44
+ 'ethereum',
45
+ 'xaut',
46
+ '0xWallet'
47
+ )
48
+
49
+ expect(mockFetch).toHaveBeenCalledWith(
50
+ 'https://wdk-api.tether.io/api/v1/ethereum/xaut/0xWallet/token-transfers',
51
+ expect.objectContaining({
52
+ method: 'GET',
53
+ headers: expect.objectContaining({
54
+ 'x-api-key': 'test-api-key',
55
+ }),
56
+ })
57
+ )
58
+ expect(transfers).toHaveLength(1)
59
+ expect(transfers[0].token).toBe('xaut')
60
+ expect(transfers[0].amount).toBe('1.5')
61
+ })
62
+
63
+ it('returns empty list on 404', async () => {
64
+ mockFetch.mockResolvedValueOnce({
65
+ ok: false,
66
+ status: 404,
67
+ text: async () => 'not found',
68
+ })
69
+
70
+ const transfers = await TransactionService.fetchTokenTransfers(
71
+ 'ethereum',
72
+ 'usdt',
73
+ '0xWallet'
74
+ )
75
+ expect(transfers).toEqual([])
76
+ })
77
+
78
+ it('aggregates transfers across networks and dedupes', async () => {
79
+ mockFetch
80
+ .mockResolvedValueOnce({
81
+ ok: true,
82
+ status: 200,
83
+ json: async () => ({ transfers: [] }),
84
+ })
85
+ .mockResolvedValueOnce({
86
+ ok: true,
87
+ status: 200,
88
+ json: async () => ({
89
+ transfers: [
90
+ {
91
+ blockchain: 'ethereum',
92
+ transactionHash: '0x1',
93
+ token: 'usdt',
94
+ amount: '10',
95
+ timestamp: 100,
96
+ from: '0xa',
97
+ to: '0xb',
98
+ },
99
+ ],
100
+ }),
101
+ })
102
+ .mockResolvedValueOnce({
103
+ ok: true,
104
+ status: 200,
105
+ json: async () => ({
106
+ transfers: [
107
+ {
108
+ blockchain: 'ethereum',
109
+ transactionHash: '0x2',
110
+ token: 'xaut',
111
+ amount: '0.5',
112
+ timestamp: 200,
113
+ from: '0xc',
114
+ to: '0xd',
115
+ },
116
+ ],
117
+ }),
118
+ })
119
+
120
+ const tokenConfigs = {
121
+ ethereum: {
122
+ native: { address: null, symbol: 'ETH', name: 'Ethereum', decimals: 18 },
123
+ tokens: [
124
+ {
125
+ address: '0xusdt',
126
+ symbol: 'USDT',
127
+ name: 'Tether USD',
128
+ decimals: 6,
129
+ },
130
+ {
131
+ address: '0xxaut',
132
+ symbol: 'XAUT',
133
+ name: 'Tether Gold',
134
+ decimals: 6,
135
+ },
136
+ ],
137
+ },
138
+ }
139
+
140
+ const list = await TransactionService.fetchWalletTransactions({
141
+ addresses: { ethereum: { 0: '0xWallet' } },
142
+ accountIndex: 0,
143
+ tokenConfigs,
144
+ })
145
+
146
+ expect(list).toHaveLength(2)
147
+ expect(list[0].transactionHash).toBe('0x2')
148
+ expect(list[1].transactionHash).toBe('0x1')
149
+ })
150
+
151
+ it('resolveWalletTransactions returns empty when indexer not configured', async () => {
152
+ setIndexerConfig(null)
153
+ const list = await TransactionService.resolveWalletTransactions({
154
+ addresses: { ethereum: { 0: '0xWallet' } },
155
+ accountIndex: 0,
156
+ tokenConfigs: {
157
+ ethereum: {
158
+ native: { address: null, symbol: 'ETH', name: 'Ethereum', decimals: 18 },
159
+ tokens: [],
160
+ },
161
+ },
162
+ })
163
+ expect(list).toEqual([])
164
+ expect(mockFetch).not.toHaveBeenCalled()
165
+ })
166
+ })
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Tests for retry utilities
3
+ */
4
+
5
+ import {
6
+ delay,
7
+ isTransientBlockchainError,
8
+ withTransientRetry,
9
+ } from '../../utils/retryUtils'
10
+
11
+ describe('retryUtils', () => {
12
+ describe('isTransientBlockchainError', () => {
13
+ it('detects 429 rate limit errors', () => {
14
+ expect(isTransientBlockchainError(new Error('HTTP 429 Too Many Requests'))).toBe(true)
15
+ expect(isTransientBlockchainError(new Error('rate limit exceeded'))).toBe(true)
16
+ })
17
+
18
+ it('detects 5xx server errors', () => {
19
+ expect(isTransientBlockchainError(new Error('upstream returned 502 Bad Gateway'))).toBe(true)
20
+ expect(isTransientBlockchainError(new Error('status 503'))).toBe(true)
21
+ })
22
+
23
+ it('detects network and timeout errors', () => {
24
+ expect(isTransientBlockchainError(new Error('network request failed'))).toBe(true)
25
+ expect(isTransientBlockchainError(new Error('ETIMEDOUT'))).toBe(true)
26
+ expect(isTransientBlockchainError(new Error('408 Request Timeout'))).toBe(true)
27
+ })
28
+
29
+ it('does not treat client errors as transient', () => {
30
+ expect(isTransientBlockchainError(new Error('Invalid balance format: abc'))).toBe(false)
31
+ expect(isTransientBlockchainError(new Error('HTTP 400 Bad Request'))).toBe(false)
32
+ expect(isTransientBlockchainError(new Error('HTTP 404 Not Found'))).toBe(false)
33
+ })
34
+ })
35
+
36
+ describe('withTransientRetry', () => {
37
+ beforeEach(() => {
38
+ jest.useFakeTimers()
39
+ })
40
+
41
+ afterEach(() => {
42
+ jest.useRealTimers()
43
+ })
44
+
45
+ it('returns immediately on success', async () => {
46
+ const fn = jest.fn().mockResolvedValue('ok')
47
+ await expect(withTransientRetry(fn)).resolves.toBe('ok')
48
+ expect(fn).toHaveBeenCalledTimes(1)
49
+ })
50
+
51
+ it('retries transient errors with exponential backoff', async () => {
52
+ const fn = jest
53
+ .fn()
54
+ .mockRejectedValueOnce(new Error('HTTP 429 Too Many Requests'))
55
+ .mockRejectedValueOnce(new Error('HTTP 429 Too Many Requests'))
56
+ .mockResolvedValue('ok')
57
+
58
+ const promise = withTransientRetry(fn, { label: 'test' })
59
+
60
+ await jest.advanceTimersByTimeAsync(1000)
61
+ await jest.advanceTimersByTimeAsync(2000)
62
+
63
+ await expect(promise).resolves.toBe('ok')
64
+ expect(fn).toHaveBeenCalledTimes(3)
65
+ })
66
+
67
+ it('does not retry non-transient errors', async () => {
68
+ const fn = jest.fn().mockRejectedValue(new Error('Invalid balance format'))
69
+ await expect(withTransientRetry(fn)).rejects.toThrow('Invalid balance format')
70
+ expect(fn).toHaveBeenCalledTimes(1)
71
+ })
72
+
73
+ it('throws after exhausting retries', async () => {
74
+ const fn = jest.fn().mockRejectedValue(new Error('HTTP 429'))
75
+ const promise = withTransientRetry(fn, { maxAttempts: 3, initialBackoffMs: 100 })
76
+
77
+ await jest.advanceTimersByTimeAsync(100)
78
+ await jest.advanceTimersByTimeAsync(200)
79
+
80
+ await expect(promise).rejects.toThrow('HTTP 429')
81
+ expect(fn).toHaveBeenCalledTimes(3)
82
+ })
83
+ })
84
+
85
+ describe('delay', () => {
86
+ beforeEach(() => {
87
+ jest.useFakeTimers()
88
+ })
89
+
90
+ afterEach(() => {
91
+ jest.useRealTimers()
92
+ })
93
+
94
+ it('resolves after the specified duration', async () => {
95
+ const promise = delay(500)
96
+ jest.advanceTimersByTime(500)
97
+ await expect(promise).resolves.toBeUndefined()
98
+ })
99
+ })
100
+ })
@@ -54,8 +54,11 @@ import {
54
54
  NATIVE_TOKEN_KEY,
55
55
  DEFAULT_QUERY_STALE_TIME_MS,
56
56
  DEFAULT_QUERY_GC_TIME_MS,
57
+ BALANCE_FETCH_STAGGER_MS,
58
+ BALANCE_FETCH_INTRA_NETWORK_STAGGER_MS,
57
59
  } from '../utils/constants'
58
60
  import { logError } from '../utils/logger'
61
+ import { delay, withTransientRetry } from '../utils/retryUtils'
59
62
  import { validateWalletParams } from '../utils/validation'
60
63
  import type { BalanceFetchResult, TokenConfigProvider } from '../types'
61
64
 
@@ -203,11 +206,17 @@ async function fetchBalance(
203
206
  const methodName = isNative ? ACCOUNT_METHOD_GET_BALANCE : ACCOUNT_METHOD_GET_TOKEN_BALANCE
204
207
  const methodArg = isNative ? null : tokenAddress
205
208
 
206
- const balanceResult = await AccountService.callAccountMethod<string>(
207
- network,
208
- accountIndex,
209
- methodName,
210
- methodArg
209
+ const balanceResult = await withTransientRetry(
210
+ () =>
211
+ AccountService.callAccountMethod<string>(
212
+ network,
213
+ accountIndex,
214
+ methodName,
215
+ methodArg
216
+ ),
217
+ {
218
+ label: `${network} ${methodName}`,
219
+ }
211
220
  )
212
221
 
213
222
  // Convert to string (handles BigInt values)
@@ -335,23 +344,73 @@ function buildBalanceQueryKeys(
335
344
  }
336
345
 
337
346
  /**
338
- * Fetch balances for all query keys
347
+ * Group balance query keys by network, preserving first-seen network order.
348
+ */
349
+ function groupQueryKeysByNetwork(
350
+ queryKeys: ReturnType<typeof balanceQueryKeys.byToken>[]
351
+ ): ReturnType<typeof balanceQueryKeys.byToken>[][] {
352
+ const networkOrder: string[] = []
353
+ const byNetwork = new Map<string, ReturnType<typeof balanceQueryKeys.byToken>[]>()
354
+
355
+ for (const queryKey of queryKeys) {
356
+ const { network } = validateQueryKeyStructure(queryKey)
357
+ if (!byNetwork.has(network)) {
358
+ networkOrder.push(network)
359
+ byNetwork.set(network, [])
360
+ }
361
+ byNetwork.get(network)!.push(queryKey)
362
+ }
363
+
364
+ return networkOrder.map((network) => byNetwork.get(network)!)
365
+ }
366
+
367
+ function balanceQueryKeyId(queryKey: ReturnType<typeof balanceQueryKeys.byToken>): string {
368
+ return queryKey.join('\0')
369
+ }
370
+
371
+ /**
372
+ * Fetch balances for all query keys, staggering per-network to avoid RPC bursts.
339
373
  */
340
374
  async function fetchBalancesForQueryKeys(
341
375
  queryKeys: ReturnType<typeof balanceQueryKeys.byToken>[],
342
376
  walletId: string
343
377
  ): Promise<BalanceFetchResult[]> {
344
- return Promise.all(
345
- queryKeys.map(async (queryKey) => {
378
+ if (queryKeys.length === 0) {
379
+ return []
380
+ }
381
+
382
+ const networkGroups = groupQueryKeysByNetwork(queryKeys)
383
+ const results = new Map<string, BalanceFetchResult>()
384
+ let isFirstNetwork = true
385
+
386
+ for (const keys of networkGroups) {
387
+ if (!isFirstNetwork) {
388
+ await delay(BALANCE_FETCH_STAGGER_MS)
389
+ }
390
+ isFirstNetwork = false
391
+
392
+ const networkResults: Array<{ id: string; result: BalanceFetchResult }> = []
393
+ for (let i = 0; i < keys.length; i++) {
394
+ if (i > 0) {
395
+ await delay(BALANCE_FETCH_INTRA_NETWORK_STAGGER_MS)
396
+ }
397
+ const queryKey = keys[i]
346
398
  const validated = validateQueryKeyStructure(queryKey)
347
- return fetchBalance(
399
+ const result = await fetchBalance(
348
400
  validated.network,
349
401
  validated.accountIndex,
350
402
  validated.tokenAddress,
351
403
  walletId
352
404
  )
353
- })
354
- )
405
+ networkResults.push({ id: balanceQueryKeyId(queryKey), result })
406
+ }
407
+
408
+ for (const { id, result } of networkResults) {
409
+ results.set(id, result)
410
+ }
411
+ }
412
+
413
+ return queryKeys.map((queryKey) => results.get(balanceQueryKeyId(queryKey))!)
355
414
  }
356
415
 
357
416
  /**
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Transaction hooks with TanStack Query
3
+ *
4
+ * Fetches wallet activity from the WDK Indexer (token-transfers per network/token/address).
5
+ * Requires indexerConfig on WdkAppProvider (typically from EXPO_PUBLIC_WDK_INDEXER_* in the app).
6
+ */
7
+
8
+ import { useQuery } from '@tanstack/react-query'
9
+ import { useShallow } from 'zustand/react/shallow'
10
+
11
+ import { TransactionService } from '../services/transactionService'
12
+ import { getWalletStore } from '../store/walletStore'
13
+ import { getWorkletStore } from '../store/workletStore'
14
+ import { isIndexerConfigured } from '../store/indexerConfigStore'
15
+ import { resolveWalletId } from '../utils/storeHelpers'
16
+ import {
17
+ DEFAULT_QUERY_GC_TIME_MS,
18
+ DEFAULT_QUERY_STALE_TIME_MS,
19
+ } from '../utils/constants'
20
+ import type { TokenConfigProvider, WalletTransaction } from '../types'
21
+
22
+ export interface WalletTransactionsQueryOptions {
23
+ enabled?: boolean
24
+ staleTime?: number
25
+ refetchInterval?: number | false
26
+ walletId?: string
27
+ }
28
+
29
+ export const transactionQueryKeys = {
30
+ all: ['transactions'] as const,
31
+ byWallet: (walletId: string, accountIndex: number) =>
32
+ ['transactions', 'wallet', walletId, accountIndex] as const,
33
+ }
34
+
35
+ function resolveTokenConfigs(tokenConfigs: TokenConfigProvider) {
36
+ return typeof tokenConfigs === 'function' ? tokenConfigs() : tokenConfigs
37
+ }
38
+
39
+ function addressesFingerprint(
40
+ addresses: Record<string, Record<number, string>>,
41
+ accountIndex: number
42
+ ): string {
43
+ const parts: string[] = []
44
+ for (const network of Object.keys(addresses).sort()) {
45
+ const address = addresses[network]?.[accountIndex]
46
+ if (address) {
47
+ parts.push(`${network}:${address.toLowerCase()}`)
48
+ }
49
+ }
50
+ return parts.join('|')
51
+ }
52
+
53
+ async function fetchWalletTransactionsForAccount(
54
+ walletId: string,
55
+ accountIndex: number,
56
+ tokenConfigs: TokenConfigProvider
57
+ ): Promise<WalletTransaction[]> {
58
+ const walletStore = getWalletStore()
59
+ const addresses = walletStore.getState().addresses[walletId] ?? {}
60
+
61
+ return TransactionService.resolveWalletTransactions({
62
+ addresses,
63
+ accountIndex,
64
+ tokenConfigs: resolveTokenConfigs(tokenConfigs),
65
+ })
66
+ }
67
+
68
+ /**
69
+ * Fetch aggregated wallet transactions from the WDK Indexer.
70
+ */
71
+ export function useWalletTransactions(
72
+ accountIndex: number,
73
+ tokenConfigs: TokenConfigProvider,
74
+ options?: WalletTransactionsQueryOptions
75
+ ) {
76
+ const workletStore = getWorkletStore()
77
+ const walletStore = getWalletStore()
78
+ const isInitialized = workletStore.getState().isInitialized
79
+ const walletId = resolveWalletId(options?.walletId)
80
+ const addresses = walletStore(
81
+ useShallow((state) => state.addresses[walletId] ?? {})
82
+ )
83
+ const addressKey = addressesFingerprint(addresses, accountIndex)
84
+ const indexerReady = isIndexerConfigured()
85
+
86
+ return useQuery({
87
+ queryKey: [...transactionQueryKeys.byWallet(walletId, accountIndex), addressKey],
88
+ queryFn: () => fetchWalletTransactionsForAccount(walletId, accountIndex, tokenConfigs),
89
+ enabled:
90
+ (options?.enabled !== false) &&
91
+ isInitialized &&
92
+ indexerReady &&
93
+ addressKey.length > 0,
94
+ staleTime: options?.staleTime ?? DEFAULT_QUERY_STALE_TIME_MS,
95
+ gcTime: DEFAULT_QUERY_GC_TIME_MS,
96
+ refetchInterval: options?.refetchInterval,
97
+ })
98
+ }
package/src/index.ts CHANGED
@@ -20,6 +20,9 @@ export type {
20
20
  TokenConfigProvider,
21
21
  TokenHelpers,
22
22
  WalletStore,
23
+ IndexerConfig,
24
+ WalletTransaction,
25
+ IndexerTokenTransfersResponse,
23
26
  } from './types'
24
27
 
25
28
  // HRPC Type Extensions (for extending HRPC functionality)
@@ -38,6 +41,8 @@ export { useWdkApp } from './hooks/useWdkApp'
38
41
  export { useWalletManager } from './hooks/useWalletManager'
39
42
  export type { UseWalletManagerResult, WalletInfo } from './hooks/useWalletManager'
40
43
  export { useBalance, useBalancesForWallet, useBalancesForWallets, useRefreshBalance, balanceQueryKeys } from './hooks/useBalance'
44
+ export { useWalletTransactions, transactionQueryKeys } from './hooks/useTransactions'
45
+ export type { WalletTransactionsQueryOptions } from './hooks/useTransactions'
41
46
  export type { AccountInfo } from './store/walletStore'
42
47
 
43
48
  // Validation Utilities (for validating configs before use)
@@ -84,6 +89,7 @@ export { WorkletLifecycleService } from './services/workletLifecycleService'
84
89
  export { AddressService } from './services/addressService'
85
90
  export { AccountService } from './services/accountService'
86
91
  export { BalanceService } from './services/balanceService'
92
+ export { TransactionService } from './services/transactionService'
87
93
  export { WalletSetupService } from './services/walletSetupService'
88
94
  export { WalletSwitchingService } from './services/walletSwitchingService'
89
95
 
@@ -48,7 +48,8 @@ import { log, logError } from '../utils/logger'
48
48
  import { validateNetworkConfigs, validateTokenConfigs } from '../utils/validation'
49
49
  import { DEFAULT_QUERY_STALE_TIME_MS, DEFAULT_QUERY_GC_TIME_MS } from '../utils/constants'
50
50
  import { InitializationStatus, AppStatus, isAppReadyStatus, isAppInProgressStatus, getCombinedStatus, getWorkletStatus } from '../utils/initializationState'
51
- import type { NetworkConfigs, TokenConfigs } from '../types'
51
+ import type { IndexerConfig, NetworkConfigs, TokenConfigs } from '../types'
52
+ import { setIndexerConfig } from '../store/indexerConfigStore'
52
53
 
53
54
 
54
55
 
@@ -134,6 +135,11 @@ export interface WdkAppProviderProps {
134
135
  networkConfigs: NetworkConfigs
135
136
  /** Token configurations for balance fetching */
136
137
  tokenConfigs: TokenConfigs
138
+ /**
139
+ * WDK Indexer configuration for transaction history.
140
+ * Apps typically pass EXPO_PUBLIC_WDK_INDEXER_BASE_URL and EXPO_PUBLIC_WDK_INDEXER_API_KEY.
141
+ */
142
+ indexerConfig?: IndexerConfig
137
143
  /** Enable automatic wallet initialization on app restart (default: true) */
138
144
  enableAutoInitialization?: boolean
139
145
  /**
@@ -190,6 +196,7 @@ const deepEqualityFn = (a: any, b: any) => {
190
196
  export function WdkAppProvider({
191
197
  networkConfigs,
192
198
  tokenConfigs,
199
+ indexerConfig,
193
200
  enableAutoInitialization = true,
194
201
  currentUserId,
195
202
  children,
@@ -267,6 +274,11 @@ export function WdkAppProvider({
267
274
  }
268
275
  }, [networkConfigs, tokenConfigs])
269
276
 
277
+ useEffect(() => {
278
+ setIndexerConfig(indexerConfig ?? null)
279
+ return () => setIndexerConfig(null)
280
+ }, [indexerConfig])
281
+
270
282
  // Worklet state - read from workletStore via hook
271
283
  const workletHookState = useWorklet()
272
284
  const {
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Transaction Service
3
+ *
4
+ * Fetches wallet transaction history from the WDK Indexer API.
5
+ * Endpoint: GET /api/v1/{network}/{token}/{address}/token-transfers
6
+ */
7
+
8
+ import { getIndexerConfig } from '../store/indexerConfigStore'
9
+ import { BALANCE_FETCH_INTRA_NETWORK_STAGGER_MS, BALANCE_FETCH_STAGGER_MS } from '../utils/constants'
10
+ import { logError, logWarn } from '../utils/logger'
11
+ import { delay, withTransientRetry } from '../utils/retryUtils'
12
+ import type { IndexerTokenTransfersResponse, TokenConfigs, WalletTransaction } from '../types'
13
+
14
+ export interface FetchWalletTransactionsParams {
15
+ addresses: Record<string, Record<number, string>>
16
+ accountIndex: number
17
+ tokenConfigs: TokenConfigs
18
+ }
19
+
20
+ function normalizeBaseUrl(baseUrl: string): string {
21
+ return baseUrl.replace(/\/$/, '')
22
+ }
23
+
24
+ function tokenSlugFromConfig(symbol: string): string {
25
+ return symbol.toLowerCase()
26
+ }
27
+
28
+ function transactionDedupeKey(tx: WalletTransaction): string {
29
+ return [
30
+ tx.blockchain,
31
+ tx.transactionHash,
32
+ tx.token,
33
+ tx.transferIndex ?? '',
34
+ tx.logIndex ?? '',
35
+ tx.from,
36
+ tx.to,
37
+ tx.amount,
38
+ ].join('|')
39
+ }
40
+
41
+ function parseIndexerTransfers(payload: unknown): WalletTransaction[] {
42
+ if (!payload || typeof payload !== 'object') {
43
+ return []
44
+ }
45
+ const transfers = (payload as IndexerTokenTransfersResponse).transfers
46
+ if (!Array.isArray(transfers)) {
47
+ return []
48
+ }
49
+
50
+ const parsed: WalletTransaction[] = []
51
+ for (const item of transfers) {
52
+ if (!item || typeof item !== 'object') continue
53
+ const tx = item as Partial<WalletTransaction>
54
+ if (
55
+ typeof tx.transactionHash !== 'string' ||
56
+ typeof tx.token !== 'string' ||
57
+ typeof tx.amount !== 'string' ||
58
+ typeof tx.timestamp !== 'number' ||
59
+ typeof tx.from !== 'string' ||
60
+ typeof tx.to !== 'string'
61
+ ) {
62
+ continue
63
+ }
64
+ parsed.push({
65
+ blockchain: typeof tx.blockchain === 'string' ? tx.blockchain : '',
66
+ blockNumber: typeof tx.blockNumber === 'number' ? tx.blockNumber : undefined,
67
+ transactionHash: tx.transactionHash,
68
+ transferIndex: typeof tx.transferIndex === 'number' ? tx.transferIndex : undefined,
69
+ token: tx.token.toLowerCase(),
70
+ amount: tx.amount,
71
+ timestamp: tx.timestamp,
72
+ transactionIndex: typeof tx.transactionIndex === 'number' ? tx.transactionIndex : undefined,
73
+ logIndex: typeof tx.logIndex === 'number' ? tx.logIndex : undefined,
74
+ from: tx.from,
75
+ to: tx.to,
76
+ label: typeof tx.label === 'string' ? tx.label : undefined,
77
+ })
78
+ }
79
+ return parsed
80
+ }
81
+
82
+ export class TransactionService {
83
+ /**
84
+ * Fetch token transfers for one network/token/address from the indexer.
85
+ */
86
+ static async fetchTokenTransfers(
87
+ network: string,
88
+ tokenSlug: string,
89
+ address: string
90
+ ): Promise<WalletTransaction[]> {
91
+ const config = getIndexerConfig()
92
+ if (!config?.baseUrl || !config.apiKey) {
93
+ throw new Error('WDK Indexer is not configured')
94
+ }
95
+
96
+ const url = `${normalizeBaseUrl(config.baseUrl)}/api/v1/${encodeURIComponent(network)}/${encodeURIComponent(tokenSlug)}/${encodeURIComponent(address)}/token-transfers`
97
+
98
+ const response = await withTransientRetry(
99
+ async () => {
100
+ const res = await fetch(url, {
101
+ method: 'GET',
102
+ headers: {
103
+ 'x-api-key': config.apiKey,
104
+ Accept: 'application/json',
105
+ },
106
+ })
107
+
108
+ if (res.status === 404) {
109
+ return { ok: true as const, transfers: [] as WalletTransaction[] }
110
+ }
111
+
112
+ if (!res.ok) {
113
+ const text = await res.text().catch(() => '')
114
+ throw new Error(
115
+ `Indexer token-transfers failed (${res.status}) for ${network}/${tokenSlug}: ${text.slice(0, 200)}`
116
+ )
117
+ }
118
+
119
+ const json: unknown = await res.json()
120
+ return { ok: true as const, transfers: parseIndexerTransfers(json) }
121
+ },
122
+ { label: `${network}/${tokenSlug} token-transfers` }
123
+ )
124
+
125
+ return response.transfers
126
+ }
127
+
128
+ /**
129
+ * Fetch transfers for all configured networks/tokens for one account index.
130
+ */
131
+ static async fetchWalletTransactions(
132
+ params: FetchWalletTransactionsParams
133
+ ): Promise<WalletTransaction[]> {
134
+ const { addresses, accountIndex, tokenConfigs } = params
135
+ const networks = Object.keys(tokenConfigs)
136
+ const deduped = new Map<string, WalletTransaction>()
137
+ let isFirstNetwork = true
138
+
139
+ for (const network of networks) {
140
+ const address = addresses[network]?.[accountIndex]
141
+ if (!address) {
142
+ continue
143
+ }
144
+
145
+ if (!isFirstNetwork) {
146
+ await delay(BALANCE_FETCH_STAGGER_MS)
147
+ }
148
+ isFirstNetwork = false
149
+
150
+ const networkTokens = tokenConfigs[network]
151
+ if (!networkTokens) continue
152
+
153
+ const tokens = [networkTokens.native, ...networkTokens.tokens]
154
+ for (let i = 0; i < tokens.length; i++) {
155
+ if (i > 0) {
156
+ await delay(BALANCE_FETCH_INTRA_NETWORK_STAGGER_MS)
157
+ }
158
+
159
+ const token = tokens[i]
160
+ const tokenSlug = tokenSlugFromConfig(token.symbol)
161
+ try {
162
+ const transfers = await this.fetchTokenTransfers(network, tokenSlug, address)
163
+ for (const tx of transfers) {
164
+ const withBlockchain = tx.blockchain
165
+ ? tx
166
+ : { ...tx, blockchain: network }
167
+ deduped.set(transactionDedupeKey(withBlockchain), withBlockchain)
168
+ }
169
+ } catch (error) {
170
+ logError(
171
+ `Failed to fetch token transfers for ${network}/${tokenSlug}:`,
172
+ error
173
+ )
174
+ }
175
+ }
176
+ }
177
+
178
+ return Array.from(deduped.values()).sort((a, b) => b.timestamp - a.timestamp)
179
+ }
180
+
181
+ /**
182
+ * Resolve wallet transactions (alias for fetchWalletTransactions).
183
+ * Matches the legacy resolveWalletTransactions name used by consuming apps.
184
+ */
185
+ static async resolveWalletTransactions(
186
+ params: FetchWalletTransactionsParams
187
+ ): Promise<WalletTransaction[]> {
188
+ if (!getIndexerConfig()) {
189
+ logWarn('[TransactionService] Indexer not configured — returning empty transaction list')
190
+ return []
191
+ }
192
+ return this.fetchWalletTransactions(params)
193
+ }
194
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Runtime WDK Indexer configuration (set by WdkAppProvider from app env).
3
+ */
4
+
5
+ import type { IndexerConfig } from '../types'
6
+
7
+ let indexerConfig: IndexerConfig | null = null
8
+
9
+ export function setIndexerConfig(config: IndexerConfig | null): void {
10
+ indexerConfig = config
11
+ }
12
+
13
+ export function getIndexerConfig(): IndexerConfig | null {
14
+ return indexerConfig
15
+ }
16
+
17
+ export function isIndexerConfigured(): boolean {
18
+ return Boolean(indexerConfig?.baseUrl && indexerConfig?.apiKey)
19
+ }
package/src/types.ts CHANGED
@@ -157,6 +157,42 @@ export interface BalanceFetchResult {
157
157
  */
158
158
  export type TokenConfigProvider = TokenConfigs | (() => TokenConfigs)
159
159
 
160
+ /**
161
+ * WDK Indexer API configuration (balance/transaction history).
162
+ */
163
+ export interface IndexerConfig {
164
+ /** Base URL, e.g. https://wdk-api.tether.io */
165
+ baseUrl: string
166
+ /** API key sent as x-api-key header */
167
+ apiKey: string
168
+ }
169
+
170
+ /**
171
+ * Token transfer from the WDK Indexer token-transfers endpoint.
172
+ */
173
+ export interface WalletTransaction {
174
+ blockchain: string
175
+ blockNumber?: number
176
+ transactionHash: string
177
+ transferIndex?: number
178
+ token: string
179
+ amount: string
180
+ /** Unix timestamp in seconds (indexer convention) */
181
+ timestamp: number
182
+ transactionIndex?: number
183
+ logIndex?: number
184
+ from: string
185
+ to: string
186
+ label?: string
187
+ }
188
+
189
+ /**
190
+ * Raw indexer response for token-transfers.
191
+ */
192
+ export interface IndexerTokenTransfersResponse {
193
+ transfers?: WalletTransaction[]
194
+ }
195
+
160
196
  /**
161
197
  * Token Helpers
162
198
  *
@@ -67,6 +67,28 @@ export const DEFAULT_QUERY_STALE_TIME_MS = 30 * 1000
67
67
  */
68
68
  export const DEFAULT_QUERY_GC_TIME_MS = 5 * 60 * 1000
69
69
 
70
+ /**
71
+ * Delay between per-network balance fetches on wallet load (ms).
72
+ * Spreads RPC load across chains instead of firing every network at once.
73
+ */
74
+ export const BALANCE_FETCH_STAGGER_MS = 400
75
+
76
+ /**
77
+ * Delay between token balance fetches within the same network (ms).
78
+ * TON jetton balances issue multiple RPC calls each; serializing avoids 429 bursts.
79
+ */
80
+ export const BALANCE_FETCH_INTRA_NETWORK_STAGGER_MS = 300
81
+
82
+ /**
83
+ * Max attempts for transient blockchain/RPC errors (429, 5xx, network timeouts).
84
+ */
85
+ export const TRANSIENT_ERROR_MAX_ATTEMPTS = 3
86
+
87
+ /**
88
+ * Initial backoff for transient error retries (ms); doubled on each attempt.
89
+ */
90
+ export const TRANSIENT_ERROR_INITIAL_BACKOFF_MS = 1000
91
+
70
92
  /**
71
93
  * Allowed account methods whitelist
72
94
  * Only these methods can be called through AccountService for security
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Retry helpers for transient blockchain/RPC failures (429, 5xx, network errors).
3
+ */
4
+
5
+ import {
6
+ TRANSIENT_ERROR_INITIAL_BACKOFF_MS,
7
+ TRANSIENT_ERROR_MAX_ATTEMPTS,
8
+ } from './constants'
9
+ import { getErrorMessage } from './errorUtils'
10
+ import { log } from './logger'
11
+
12
+ export function delay(ms: number): Promise<void> {
13
+ return new Promise((resolve) => setTimeout(resolve, ms))
14
+ }
15
+
16
+ /**
17
+ * Whether an error is likely transient and worth retrying with backoff.
18
+ * Matches Spaces API retry rules: 429, 408, 5xx, and common network failures.
19
+ */
20
+ export function isTransientBlockchainError(error: unknown): boolean {
21
+ const message = getErrorMessage(error).toLowerCase()
22
+
23
+ if (
24
+ message.includes('429') ||
25
+ message.includes('too many requests') ||
26
+ message.includes('rate limit') ||
27
+ message.includes('408') ||
28
+ message.includes('request timeout') ||
29
+ message.includes('etimedout') ||
30
+ message.includes('econnreset') ||
31
+ message.includes('econnrefused') ||
32
+ message.includes('network request failed') ||
33
+ message.includes('network error') ||
34
+ message.includes('socket hang up')
35
+ ) {
36
+ return true
37
+ }
38
+
39
+ return /\b5\d{2}\b/.test(message)
40
+ }
41
+
42
+ export interface WithTransientRetryOptions {
43
+ maxAttempts?: number
44
+ initialBackoffMs?: number
45
+ /** Label for retry log messages */
46
+ label?: string
47
+ }
48
+
49
+ /**
50
+ * Run an async function with exponential backoff on transient errors.
51
+ */
52
+ export async function withTransientRetry<T>(
53
+ fn: () => Promise<T>,
54
+ options: WithTransientRetryOptions = {}
55
+ ): Promise<T> {
56
+ const maxAttempts = options.maxAttempts ?? TRANSIENT_ERROR_MAX_ATTEMPTS
57
+ const initialBackoffMs = options.initialBackoffMs ?? TRANSIENT_ERROR_INITIAL_BACKOFF_MS
58
+ const label = options.label ?? 'operation'
59
+
60
+ let lastError: unknown
61
+
62
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
63
+ try {
64
+ return await fn()
65
+ } catch (error) {
66
+ lastError = error
67
+ if (!isTransientBlockchainError(error) || attempt >= maxAttempts) {
68
+ throw error
69
+ }
70
+ const backoffMs = initialBackoffMs * (1 << (attempt - 1))
71
+ log(`[Balance] ${label}: retry in ${backoffMs}ms (${attempt}/${maxAttempts})`)
72
+ await delay(backoffMs)
73
+ }
74
+ }
75
+
76
+ throw lastError
77
+ }