@spacesops/wdk-react-native-core 1.0.0-beta.68 → 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/hooks/useBalance.ts +16 -12
- 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 +6 -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
|
+
})
|
package/src/hooks/useBalance.ts
CHANGED
|
@@ -55,6 +55,7 @@ import {
|
|
|
55
55
|
DEFAULT_QUERY_STALE_TIME_MS,
|
|
56
56
|
DEFAULT_QUERY_GC_TIME_MS,
|
|
57
57
|
BALANCE_FETCH_STAGGER_MS,
|
|
58
|
+
BALANCE_FETCH_INTRA_NETWORK_STAGGER_MS,
|
|
58
59
|
} from '../utils/constants'
|
|
59
60
|
import { logError } from '../utils/logger'
|
|
60
61
|
import { delay, withTransientRetry } from '../utils/retryUtils'
|
|
@@ -388,18 +389,21 @@ async function fetchBalancesForQueryKeys(
|
|
|
388
389
|
}
|
|
389
390
|
isFirstNetwork = false
|
|
390
391
|
|
|
391
|
-
const networkResults =
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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]
|
|
398
|
+
const validated = validateQueryKeyStructure(queryKey)
|
|
399
|
+
const result = await fetchBalance(
|
|
400
|
+
validated.network,
|
|
401
|
+
validated.accountIndex,
|
|
402
|
+
validated.tokenAddress,
|
|
403
|
+
walletId
|
|
404
|
+
)
|
|
405
|
+
networkResults.push({ id: balanceQueryKeyId(queryKey), result })
|
|
406
|
+
}
|
|
403
407
|
|
|
404
408
|
for (const { id, result } of networkResults) {
|
|
405
409
|
results.set(id, result)
|
|
@@ -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
|
@@ -73,6 +73,12 @@ export const DEFAULT_QUERY_GC_TIME_MS = 5 * 60 * 1000
|
|
|
73
73
|
*/
|
|
74
74
|
export const BALANCE_FETCH_STAGGER_MS = 400
|
|
75
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
|
+
|
|
76
82
|
/**
|
|
77
83
|
* Max attempts for transient blockchain/RPC errors (429, 5xx, network timeouts).
|
|
78
84
|
*/
|