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

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.68",
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,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,10 @@ 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,
57
58
  } from '../utils/constants'
58
59
  import { logError } from '../utils/logger'
60
+ import { delay, withTransientRetry } from '../utils/retryUtils'
59
61
  import { validateWalletParams } from '../utils/validation'
60
62
  import type { BalanceFetchResult, TokenConfigProvider } from '../types'
61
63
 
@@ -203,11 +205,17 @@ async function fetchBalance(
203
205
  const methodName = isNative ? ACCOUNT_METHOD_GET_BALANCE : ACCOUNT_METHOD_GET_TOKEN_BALANCE
204
206
  const methodArg = isNative ? null : tokenAddress
205
207
 
206
- const balanceResult = await AccountService.callAccountMethod<string>(
207
- network,
208
- accountIndex,
209
- methodName,
210
- methodArg
208
+ const balanceResult = await withTransientRetry(
209
+ () =>
210
+ AccountService.callAccountMethod<string>(
211
+ network,
212
+ accountIndex,
213
+ methodName,
214
+ methodArg
215
+ ),
216
+ {
217
+ label: `${network} ${methodName}`,
218
+ }
211
219
  )
212
220
 
213
221
  // Convert to string (handles BigInt values)
@@ -335,23 +343,70 @@ function buildBalanceQueryKeys(
335
343
  }
336
344
 
337
345
  /**
338
- * Fetch balances for all query keys
346
+ * Group balance query keys by network, preserving first-seen network order.
347
+ */
348
+ function groupQueryKeysByNetwork(
349
+ queryKeys: ReturnType<typeof balanceQueryKeys.byToken>[]
350
+ ): ReturnType<typeof balanceQueryKeys.byToken>[][] {
351
+ const networkOrder: string[] = []
352
+ const byNetwork = new Map<string, ReturnType<typeof balanceQueryKeys.byToken>[]>()
353
+
354
+ for (const queryKey of queryKeys) {
355
+ const { network } = validateQueryKeyStructure(queryKey)
356
+ if (!byNetwork.has(network)) {
357
+ networkOrder.push(network)
358
+ byNetwork.set(network, [])
359
+ }
360
+ byNetwork.get(network)!.push(queryKey)
361
+ }
362
+
363
+ return networkOrder.map((network) => byNetwork.get(network)!)
364
+ }
365
+
366
+ function balanceQueryKeyId(queryKey: ReturnType<typeof balanceQueryKeys.byToken>): string {
367
+ return queryKey.join('\0')
368
+ }
369
+
370
+ /**
371
+ * Fetch balances for all query keys, staggering per-network to avoid RPC bursts.
339
372
  */
340
373
  async function fetchBalancesForQueryKeys(
341
374
  queryKeys: ReturnType<typeof balanceQueryKeys.byToken>[],
342
375
  walletId: string
343
376
  ): Promise<BalanceFetchResult[]> {
344
- return Promise.all(
345
- queryKeys.map(async (queryKey) => {
346
- const validated = validateQueryKeyStructure(queryKey)
347
- return fetchBalance(
348
- validated.network,
349
- validated.accountIndex,
350
- validated.tokenAddress,
351
- walletId
352
- )
353
- })
354
- )
377
+ if (queryKeys.length === 0) {
378
+ return []
379
+ }
380
+
381
+ const networkGroups = groupQueryKeysByNetwork(queryKeys)
382
+ const results = new Map<string, BalanceFetchResult>()
383
+ let isFirstNetwork = true
384
+
385
+ for (const keys of networkGroups) {
386
+ if (!isFirstNetwork) {
387
+ await delay(BALANCE_FETCH_STAGGER_MS)
388
+ }
389
+ isFirstNetwork = false
390
+
391
+ const networkResults = await Promise.all(
392
+ keys.map(async (queryKey) => {
393
+ const validated = validateQueryKeyStructure(queryKey)
394
+ const result = await fetchBalance(
395
+ validated.network,
396
+ validated.accountIndex,
397
+ validated.tokenAddress,
398
+ walletId
399
+ )
400
+ return { id: balanceQueryKeyId(queryKey), result }
401
+ })
402
+ )
403
+
404
+ for (const { id, result } of networkResults) {
405
+ results.set(id, result)
406
+ }
407
+ }
408
+
409
+ return queryKeys.map((queryKey) => results.get(balanceQueryKeyId(queryKey))!)
355
410
  }
356
411
 
357
412
  /**
@@ -67,6 +67,22 @@ 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
+ * Max attempts for transient blockchain/RPC errors (429, 5xx, network timeouts).
78
+ */
79
+ export const TRANSIENT_ERROR_MAX_ATTEMPTS = 3
80
+
81
+ /**
82
+ * Initial backoff for transient error retries (ms); doubled on each attempt.
83
+ */
84
+ export const TRANSIENT_ERROR_INITIAL_BACKOFF_MS = 1000
85
+
70
86
  /**
71
87
  * Allowed account methods whitelist
72
88
  * 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
+ }