@spacesops/wdk-react-native-core 1.0.0-beta.66 → 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 +1 -1
- package/src/__tests__/utils/retryUtils.test.ts +100 -0
- package/src/__tests__/utils/typeGuards.test.ts +9 -0
- package/src/hooks/useBalance.ts +72 -17
- package/src/index.ts +3 -0
- package/src/utils/constants.ts +16 -0
- package/src/utils/retryUtils.ts +77 -0
- package/src/utils/schemas.ts +30 -2
- package/src/utils/typeGuards.ts +3 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spacesops/wdk-react-native-core",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
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
|
+
})
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
isValidAccountIndex,
|
|
14
14
|
isValidNetworkName,
|
|
15
15
|
isValidBalanceString,
|
|
16
|
+
isValidAddress,
|
|
16
17
|
} from '../../utils/typeGuards'
|
|
17
18
|
import type { NetworkConfig, NetworkConfigs, TokenConfig, TokenConfigs } from '../../types'
|
|
18
19
|
|
|
@@ -160,6 +161,14 @@ describe('typeGuards', () => {
|
|
|
160
161
|
})
|
|
161
162
|
})
|
|
162
163
|
|
|
164
|
+
describe('isValidAddress', () => {
|
|
165
|
+
it('should accept TON, Tron, and Solana wallet addresses', () => {
|
|
166
|
+
expect(isValidAddress('UQCt4XDZ_rgMu14lE5ENox6yqHxx3c9df-gHLynhEEKbTRV0')).toBe(true)
|
|
167
|
+
expect(isValidAddress('TVKDsaESudgCQReXPNvStw2n2BXnmodyeC')).toBe(true)
|
|
168
|
+
expect(isValidAddress('Ed9f9Koh6rqMvFXccVJp5bNuxFs8rYdbVibW9JXRskLU')).toBe(true)
|
|
169
|
+
})
|
|
170
|
+
})
|
|
171
|
+
|
|
163
172
|
describe('isValidAccountIndex', () => {
|
|
164
173
|
it('should return true for valid account indices', () => {
|
|
165
174
|
expect(isValidAccountIndex(0)).toBe(true)
|
package/src/hooks/useBalance.ts
CHANGED
|
@@ -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
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
/**
|
package/src/index.ts
CHANGED
package/src/utils/constants.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/utils/schemas.ts
CHANGED
|
@@ -49,9 +49,37 @@ export const bitcoinAddressSchema = z.string().regex(
|
|
|
49
49
|
)
|
|
50
50
|
|
|
51
51
|
/**
|
|
52
|
-
*
|
|
52
|
+
* TON user-friendly wallet address (EQ/UQ + base64url).
|
|
53
53
|
*/
|
|
54
|
-
export const
|
|
54
|
+
export const tonAddressSchema = z.string().regex(/^(EQ|UQ)[A-Za-z0-9_-]{46}$/, {
|
|
55
|
+
message: 'Must be a valid TON address (EQ/UQ user-friendly format)',
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Tron Base58Check wallet address.
|
|
60
|
+
*/
|
|
61
|
+
export const tronAddressSchema = z.string().regex(/^T[1-9A-HJ-NP-Za-km-z]{33}$/, {
|
|
62
|
+
message: 'Must be a valid Tron address',
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Solana Base58 wallet address.
|
|
67
|
+
*/
|
|
68
|
+
export const solanaAddressSchema = z.string().regex(/^[1-9A-HJ-NP-Za-km-z]{32,44}$/, {
|
|
69
|
+
message: 'Must be a valid Solana address',
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Address schema (Ethereum, Spark, Bitcoin, TON, Tron, or Solana)
|
|
74
|
+
*/
|
|
75
|
+
export const addressSchema = z.union([
|
|
76
|
+
ethereumAddressSchema,
|
|
77
|
+
sparkAddressSchema,
|
|
78
|
+
bitcoinAddressSchema,
|
|
79
|
+
tonAddressSchema,
|
|
80
|
+
tronAddressSchema,
|
|
81
|
+
solanaAddressSchema,
|
|
82
|
+
])
|
|
55
83
|
|
|
56
84
|
/**
|
|
57
85
|
* Network configuration schema
|
package/src/utils/typeGuards.ts
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
accountIndexSchema,
|
|
22
22
|
networkNameSchema,
|
|
23
23
|
balanceStringSchema,
|
|
24
|
+
addressSchema,
|
|
24
25
|
} from './schemas'
|
|
25
26
|
import type {
|
|
26
27
|
NetworkConfig,
|
|
@@ -104,10 +105,10 @@ export function isBitcoinAddress(value: unknown): value is string {
|
|
|
104
105
|
}
|
|
105
106
|
|
|
106
107
|
/**
|
|
107
|
-
* Type guard to check if a value is a valid address (Ethereum, Spark, or
|
|
108
|
+
* Type guard to check if a value is a valid address (Ethereum, Spark, Bitcoin, TON, Tron, or Solana)
|
|
108
109
|
*/
|
|
109
110
|
export function isValidAddress(value: unknown): value is string {
|
|
110
|
-
return
|
|
111
|
+
return addressSchema.safeParse(value).success
|
|
111
112
|
}
|
|
112
113
|
|
|
113
114
|
/**
|