@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 +1 -1
- package/src/__tests__/services/transactionService.test.ts +166 -0
- package/src/__tests__/utils/retryUtils.test.ts +100 -0
- package/src/hooks/useBalance.ts +70 -11
- package/src/hooks/useTransactions.ts +98 -0
- package/src/index.ts +6 -0
- package/src/provider/WdkAppProvider.tsx +13 -1
- package/src/services/transactionService.ts +194 -0
- package/src/store/indexerConfigStore.ts +19 -0
- package/src/types.ts +36 -0
- package/src/utils/constants.ts +22 -0
- package/src/utils/retryUtils.ts +77 -0
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.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
|
+
})
|
package/src/hooks/useBalance.ts
CHANGED
|
@@ -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
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
345
|
-
|
|
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
|
-
|
|
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
|
*
|
package/src/utils/constants.ts
CHANGED
|
@@ -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
|
+
}
|