@explorins/pers-sdk-react-native 2.3.7 → 2.3.8
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/LICENSE +21 -0
- package/README.md +1266 -1244
- package/dist/__tests__/PersSDKProvider.test.js +33 -0
- package/dist/hooks/useBusiness.d.ts +2 -2
- package/dist/hooks/useBusiness.d.ts.map +1 -1
- package/dist/hooks/useBusiness.js +2 -2
- package/dist/hooks/useIPFSUrl.d.ts +3 -3
- package/dist/hooks/useIPFSUrl.d.ts.map +1 -1
- package/dist/hooks/useIPFSUrl.js +7 -7
- package/dist/hooks/useWeb3.js +4 -4
- package/dist/index.js +2400 -487
- package/dist/index.js.map +1 -1
- package/dist/providers/PersSDKProvider.d.ts +4 -0
- package/dist/providers/PersSDKProvider.d.ts.map +1 -1
- package/dist/providers/PersSDKProvider.js +14 -0
- package/dist/providers/react-native-http-client.d.ts +2 -0
- package/dist/providers/react-native-http-client.d.ts.map +1 -1
- package/dist/providers/react-native-http-client.js +5 -2
- package/dist/storage/rn-secure-storage.d.ts.map +1 -1
- package/dist/storage/rn-secure-storage.js +21 -8
- package/package.json +3 -3
- package/src/__tests__/PersSDKProvider.test.tsx +484 -420
- package/src/__tests__/setup.ts +40 -40
- package/src/hooks/index.ts +60 -60
- package/src/hooks/useAnalytics.ts +327 -327
- package/src/hooks/useAuth.ts +265 -265
- package/src/hooks/useBookings.ts +191 -191
- package/src/hooks/useBusiness.ts +251 -251
- package/src/hooks/useCampaigns.ts +500 -500
- package/src/hooks/useDonations.ts +66 -66
- package/src/hooks/useEvents.ts +372 -372
- package/src/hooks/useFiles.ts +155 -155
- package/src/hooks/useIPFSUrl.ts +192 -192
- package/src/hooks/usePurchases.ts +65 -65
- package/src/hooks/useRedemptions.ts +467 -467
- package/src/hooks/useTenants.ts +72 -72
- package/src/hooks/useTokenBalances.ts +385 -385
- package/src/hooks/useTokens.ts +313 -313
- package/src/hooks/useTransactionSigner.ts +611 -611
- package/src/hooks/useTransactions.ts +299 -299
- package/src/hooks/useTriggerSources.ts +301 -301
- package/src/hooks/useUserStatus.ts +61 -61
- package/src/hooks/useUsers.ts +167 -167
- package/src/hooks/useWeb3.ts +355 -355
- package/src/index.ts +777 -777
- package/src/polyfills/index.ts +160 -160
- package/src/polyfills/web3-polyfills.ts +97 -97
- package/src/providers/PersSDKProvider.tsx +393 -374
- package/src/providers/react-native-auth-provider.ts +110 -110
- package/src/providers/react-native-http-client.ts +150 -145
- package/src/providers/rn-dpop-provider.ts +190 -190
- package/src/storage/async-storage-token-storage.ts +185 -185
- package/src/storage/rn-secure-storage.ts +182 -170
- package/src/types/react-native-globals.d.ts +45 -45
- package/src/utils/index.ts +8 -8
- package/src/utils/platform-detection.ts +86 -86
- package/dist/index-CzLxqePZ.js +0 -7717
- package/dist/index-CzLxqePZ.js.map +0 -1
|
@@ -1,385 +1,385 @@
|
|
|
1
|
-
import { useCallback, useEffect, useState, useRef } from 'react';
|
|
2
|
-
import { usePersSDK } from '../providers/PersSDKProvider';
|
|
3
|
-
import { useWeb3 } from './useWeb3';
|
|
4
|
-
import { NativeTokenTypes, type TokenDTO } from '@explorins/pers-sdk';
|
|
5
|
-
import type { TokenBalance, TokenBalanceRequest } from '@explorins/pers-sdk/web3';
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Token balance with associated token information for display
|
|
9
|
-
*
|
|
10
|
-
* This composite type combines blockchain balance data with token metadata,
|
|
11
|
-
* making it easy to display token information in UI components without
|
|
12
|
-
* needing to join data from multiple sources.
|
|
13
|
-
*
|
|
14
|
-
* ## CRITICAL: Token ID for Transactions
|
|
15
|
-
*
|
|
16
|
-
* For ERC721 (stamps) and ERC1155 (rewards), use `tokenBalance.tokenId` as the
|
|
17
|
-
* `contractTokenId` when creating transactions. This is the **on-chain token ID**
|
|
18
|
-
* assigned by the blockchain, NOT the metadata definition ID.
|
|
19
|
-
*
|
|
20
|
-
* | Token Type | tokenId meaning | Example |
|
|
21
|
-
* |------------|-----------------|---------|
|
|
22
|
-
* | ERC20 | `null` or `""` | N/A (fungible points) |
|
|
23
|
-
* | ERC721 | NFT instance ID | `"46"` (on-chain ID) |
|
|
24
|
-
* | ERC1155 | Token type ID | `"16"` (semi-fungible type) |
|
|
25
|
-
*
|
|
26
|
-
* @example Correct usage for token burn transaction
|
|
27
|
-
* ```typescript
|
|
28
|
-
* const stampBalance = tokenBalances.find(b => b.token.type === 'ERC721');
|
|
29
|
-
*
|
|
30
|
-
* const request = buildPOSBurnRequest({
|
|
31
|
-
* amount: 1,
|
|
32
|
-
* contractAddress: stampBalance.token.contractAddress,
|
|
33
|
-
* contractTokenId: stampBalance.tokenBalance.tokenId, // ✅ Correct
|
|
34
|
-
* chainId: stampBalance.token.chainId,
|
|
35
|
-
* userId: userId,
|
|
36
|
-
* businessId: businessId
|
|
37
|
-
* });
|
|
38
|
-
* ```
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* */
|
|
42
|
-
//@see {@link https://docs.explorins.com/pers-sdk/troubleshooting#stamp-voucher-transaction-fails | Troubleshooting: Stamp Transaction Fails}
|
|
43
|
-
export interface TokenBalanceWithToken {
|
|
44
|
-
/**
|
|
45
|
-
* The balance data from blockchain
|
|
46
|
-
*
|
|
47
|
-
* **Important:** For NFT operations, use `tokenBalance.tokenId` as the `contractTokenId`
|
|
48
|
-
* in transaction requests. This is the actual on-chain ID, not the metadata definition.
|
|
49
|
-
*/
|
|
50
|
-
tokenBalance: TokenBalance;
|
|
51
|
-
/** The token definition (contract, symbol, metadata, etc.) */
|
|
52
|
-
token: TokenDTO;
|
|
53
|
-
/** Human-readable formatted balance string (e.g., "100 XPL" or "3 stamps") */
|
|
54
|
-
balanceFormatted: string;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Configuration options for useTokenBalances hook
|
|
59
|
-
*/
|
|
60
|
-
export interface UseTokenBalancesOptions {
|
|
61
|
-
/**
|
|
62
|
-
* Wallet address to check balances for (REQUIRED)
|
|
63
|
-
* Apps must explicitly provide the wallet address - no automatic selection
|
|
64
|
-
* Get from: user.wallets[selectedIndex].address
|
|
65
|
-
*/
|
|
66
|
-
accountAddress: string;
|
|
67
|
-
/** Available tokens to check balances for (from SDK or filtered list) */
|
|
68
|
-
availableTokens?: TokenDTO[];
|
|
69
|
-
/** Whether to automatically load balances on mount/auth/token changes */
|
|
70
|
-
autoLoad?: boolean;
|
|
71
|
-
/**
|
|
72
|
-
* Fallback polling interval in milliseconds (0 = disabled)
|
|
73
|
-
* @deprecated Use wallet events instead (refreshOnWalletEvents). Polling kept as fallback only.
|
|
74
|
-
* @default 0
|
|
75
|
-
*/
|
|
76
|
-
refreshInterval?: number;
|
|
77
|
-
/**
|
|
78
|
-
* Auto-refresh balances when wallet events are received (Transfer, Approval, etc.)
|
|
79
|
-
* Requires `captureWalletEvents: true` in SDK config (default)
|
|
80
|
-
* @default true
|
|
81
|
-
*/
|
|
82
|
-
refreshOnWalletEvents?: boolean;
|
|
83
|
-
/**
|
|
84
|
-
* Debounce time for wallet event-triggered refreshes (milliseconds)
|
|
85
|
-
* Prevents rapid refresh calls during batch transfers
|
|
86
|
-
* @default 1000
|
|
87
|
-
*/
|
|
88
|
-
walletEventDebounceMs?: number;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Token balance loading hook return value
|
|
93
|
-
*/
|
|
94
|
-
export interface UseTokenBalancesResult {
|
|
95
|
-
/** Array of token balances with formatted display data */
|
|
96
|
-
tokenBalances: TokenBalanceWithToken[];
|
|
97
|
-
/** Whether balances are currently loading */
|
|
98
|
-
isLoading: boolean;
|
|
99
|
-
/** Error message if loading failed */
|
|
100
|
-
error: string | null;
|
|
101
|
-
/** Manually trigger balance refresh */
|
|
102
|
-
refresh: () => Promise<void>;
|
|
103
|
-
/** Whether the hook is available (SDK initialized, user authenticated, has wallet) */
|
|
104
|
-
isAvailable: boolean;
|
|
105
|
-
/** User's wallet address being queried */
|
|
106
|
-
userAccountAddress: string | null;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* React hook for loading and managing token balances
|
|
111
|
-
*
|
|
112
|
-
* Automatically handles:
|
|
113
|
-
* - Loading balances for ERC20 (fungible tokens/points)
|
|
114
|
-
* - Loading collections for ERC721 (unique NFTs/stamps)
|
|
115
|
-
* - Loading collections for ERC1155 (semi-fungible tokens/rewards)
|
|
116
|
-
* - Parallel loading with error resilience
|
|
117
|
-
* - Auto-refresh on authentication or token list changes
|
|
118
|
-
* - Formatted balance strings for display
|
|
119
|
-
*
|
|
120
|
-
* This hook consolidates complex token balance loading logic that would otherwise
|
|
121
|
-
* need to be duplicated across every app. It handles the differences between
|
|
122
|
-
* fungible and non-fungible tokens, parallel loading, error handling, and
|
|
123
|
-
* provides formatted display strings.
|
|
124
|
-
*
|
|
125
|
-
* @param options - Configuration options
|
|
126
|
-
* @returns Token balances state and actions
|
|
127
|
-
*
|
|
128
|
-
* @example
|
|
129
|
-
* **Basic Usage:**
|
|
130
|
-
* ```typescript
|
|
131
|
-
* import { useTokenBalances, useTokens, usePersSDK } from '@explorins/pers-sdk-react-native';
|
|
132
|
-
* import { useState, useEffect } from 'react';
|
|
133
|
-
*
|
|
134
|
-
* function TokenBalancesScreen() {
|
|
135
|
-
* const { user } = usePersSDK();
|
|
136
|
-
* const { getTokens } = useTokens();
|
|
137
|
-
* const [availableTokens, setAvailableTokens] = useState([]);
|
|
138
|
-
*
|
|
139
|
-
* // Load tokens on mount
|
|
140
|
-
* useEffect(() => {
|
|
141
|
-
* getTokens().then(result => setAvailableTokens(result.data));
|
|
142
|
-
* }, []);
|
|
143
|
-
*
|
|
144
|
-
* // App explicitly selects which wallet to use
|
|
145
|
-
* const walletAddress = user?.wallets?.[0]?.address;
|
|
146
|
-
*
|
|
147
|
-
* const {
|
|
148
|
-
* tokenBalances,
|
|
149
|
-
* isLoading,
|
|
150
|
-
* error,
|
|
151
|
-
* refresh
|
|
152
|
-
* } = useTokenBalances({
|
|
153
|
-
* accountAddress: walletAddress!,
|
|
154
|
-
* availableTokens,
|
|
155
|
-
* autoLoad: true
|
|
156
|
-
* });
|
|
157
|
-
*
|
|
158
|
-
* if (isLoading) return <Text>Loading balances...</Text>;
|
|
159
|
-
* if (error) return <Text>Error: {error}</Text>;
|
|
160
|
-
*
|
|
161
|
-
* return (
|
|
162
|
-
* <View>
|
|
163
|
-
* {tokenBalances.map(({ token, balanceFormatted }) => (
|
|
164
|
-
* <View key={token.id}>
|
|
165
|
-
* <Text>{token.name}: {balanceFormatted}</Text>
|
|
166
|
-
* </View>
|
|
167
|
-
* ))}
|
|
168
|
-
* <Button onPress={refresh} title="Refresh" />
|
|
169
|
-
* </View>
|
|
170
|
-
* );
|
|
171
|
-
* }
|
|
172
|
-
* ```
|
|
173
|
-
*
|
|
174
|
-
* @example
|
|
175
|
-
* **With Wallet Events (Real-time):
|
|
176
|
-
* ```typescript
|
|
177
|
-
* // Auto-refresh on Transfer, Approval, and other blockchain events
|
|
178
|
-
* const { tokenBalances } = useTokenBalances({
|
|
179
|
-
* accountAddress: walletAddress!,
|
|
180
|
-
* availableTokens,
|
|
181
|
-
* refreshOnWalletEvents: true, // Enable real-time refresh (default: true)
|
|
182
|
-
* walletEventDebounceMs: 1000 // Debounce rapid events (default: 1000ms)
|
|
183
|
-
* });
|
|
184
|
-
* ```
|
|
185
|
-
*
|
|
186
|
-
* @example
|
|
187
|
-
* **Multi-Wallet Support:**
|
|
188
|
-
* ```typescript
|
|
189
|
-
* function MultiWalletBalances() {
|
|
190
|
-
* const { user } = usePersSDK();
|
|
191
|
-
* const [selectedWalletIndex, setSelectedWalletIndex] = useState(0);
|
|
192
|
-
*
|
|
193
|
-
* const walletAddress = user?.wallets?.[selectedWalletIndex]?.address;
|
|
194
|
-
*
|
|
195
|
-
* const { tokenBalances } = useTokenBalances({
|
|
196
|
-
* accountAddress: walletAddress!,
|
|
197
|
-
* availableTokens
|
|
198
|
-
* });
|
|
199
|
-
*
|
|
200
|
-
* // UI lets user switch between wallets
|
|
201
|
-
* }
|
|
202
|
-
* ```
|
|
203
|
-
*/
|
|
204
|
-
export function useTokenBalances(options: UseTokenBalancesOptions): UseTokenBalancesResult {
|
|
205
|
-
const {
|
|
206
|
-
accountAddress,
|
|
207
|
-
availableTokens = [],
|
|
208
|
-
autoLoad = true,
|
|
209
|
-
refreshInterval = 0,
|
|
210
|
-
refreshOnWalletEvents = true,
|
|
211
|
-
walletEventDebounceMs = 1000
|
|
212
|
-
} = options;
|
|
213
|
-
|
|
214
|
-
const { isAuthenticated, sdk } = usePersSDK();
|
|
215
|
-
const web3 = useWeb3();
|
|
216
|
-
|
|
217
|
-
const [tokenBalances, setTokenBalances] = useState<TokenBalanceWithToken[]>([]);
|
|
218
|
-
const [isLoading, setIsLoading] = useState(false);
|
|
219
|
-
const [error, setError] = useState<string | null>(null);
|
|
220
|
-
|
|
221
|
-
// Debounce ref for wallet events
|
|
222
|
-
const walletEventDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
223
|
-
|
|
224
|
-
// Check if the hook is available for use
|
|
225
|
-
const isAvailable = web3.isAvailable && isAuthenticated && !!accountAddress;
|
|
226
|
-
|
|
227
|
-
/**
|
|
228
|
-
* Process balance for ERC20 fungible tokens (points/credits)
|
|
229
|
-
*/
|
|
230
|
-
const processPointsBalance = useCallback(async (token: TokenDTO): Promise<TokenBalanceWithToken | null> => {
|
|
231
|
-
if (!web3.getTokenBalance || !accountAddress) return null;
|
|
232
|
-
|
|
233
|
-
try {
|
|
234
|
-
const request: TokenBalanceRequest = {
|
|
235
|
-
accountAddress,
|
|
236
|
-
contractAddress: token.contractAddress,
|
|
237
|
-
abi: token.abi,
|
|
238
|
-
tokenId: '',
|
|
239
|
-
chainId: token.chainId
|
|
240
|
-
};
|
|
241
|
-
|
|
242
|
-
const balanceResult = await web3.getTokenBalance(request);
|
|
243
|
-
|
|
244
|
-
if (!balanceResult || !balanceResult.hasBalance) return null;
|
|
245
|
-
|
|
246
|
-
return {
|
|
247
|
-
tokenBalance: balanceResult,
|
|
248
|
-
token,
|
|
249
|
-
balanceFormatted: `${balanceResult.balance} ${token.symbol || 'tokens'}`
|
|
250
|
-
};
|
|
251
|
-
} catch (err) {
|
|
252
|
-
console.warn(`[useTokenBalances] Failed to load balance for ${token.symbol}:`, err);
|
|
253
|
-
return null;
|
|
254
|
-
}
|
|
255
|
-
}, [web3.getTokenBalance, accountAddress]);
|
|
256
|
-
|
|
257
|
-
/**
|
|
258
|
-
* Process collection for ERC721/ERC1155 non-fungible tokens (stamps/rewards)
|
|
259
|
-
*/
|
|
260
|
-
const processRewardsBalance = useCallback(async (token: TokenDTO): Promise<TokenBalanceWithToken[]> => {
|
|
261
|
-
if (!web3.getAccountOwnedTokensFromContract || !accountAddress) return [];
|
|
262
|
-
|
|
263
|
-
try {
|
|
264
|
-
const result = await web3.getAccountOwnedTokensFromContract(accountAddress, token);
|
|
265
|
-
|
|
266
|
-
if (result.totalOwned === 0) return [];
|
|
267
|
-
|
|
268
|
-
const balanceUnit = token.type === NativeTokenTypes.ERC1155 ? 'reward' : 'stamp';
|
|
269
|
-
|
|
270
|
-
// Return one item per tokenId (for individual display in lists)
|
|
271
|
-
return result.ownedTokens.map(ownedToken => ({
|
|
272
|
-
tokenBalance: ownedToken,
|
|
273
|
-
token,
|
|
274
|
-
balanceFormatted: `${ownedToken.balance} ${balanceUnit}${ownedToken.balance !== 1 ? 's' : ''}`
|
|
275
|
-
}));
|
|
276
|
-
} catch (err) {
|
|
277
|
-
console.warn(`[useTokenBalances] Failed to load collection for ${token.symbol}:`, err);
|
|
278
|
-
return [];
|
|
279
|
-
}
|
|
280
|
-
}, [web3.getAccountOwnedTokensFromContract, accountAddress]);
|
|
281
|
-
|
|
282
|
-
/**
|
|
283
|
-
* Load all token balances
|
|
284
|
-
*/
|
|
285
|
-
const loadBalances = useCallback(async () => {
|
|
286
|
-
if (!isAvailable || availableTokens.length === 0) {
|
|
287
|
-
return;
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
setIsLoading(true);
|
|
291
|
-
setError(null);
|
|
292
|
-
|
|
293
|
-
try {
|
|
294
|
-
// Split by category: points (fungible credits) vs rewards (stamps/collectibles)
|
|
295
|
-
const points = availableTokens.filter(t => t.type === NativeTokenTypes.ERC20);
|
|
296
|
-
const rewards = availableTokens.filter(
|
|
297
|
-
t => t.type === NativeTokenTypes.ERC721 || t.type === NativeTokenTypes.ERC1155
|
|
298
|
-
);
|
|
299
|
-
|
|
300
|
-
// Load all balances in parallel (individual errors handled per-token)
|
|
301
|
-
const [pointsResults, rewardsResults] = await Promise.all([
|
|
302
|
-
Promise.all(points.map(processPointsBalance)),
|
|
303
|
-
Promise.all(rewards.map(processRewardsBalance))
|
|
304
|
-
]);
|
|
305
|
-
|
|
306
|
-
// Combine results, filtering out nulls (failed loads return null/empty)
|
|
307
|
-
const allBalances = [
|
|
308
|
-
...pointsResults.filter(Boolean),
|
|
309
|
-
...rewardsResults.flat()
|
|
310
|
-
] as TokenBalanceWithToken[];
|
|
311
|
-
|
|
312
|
-
setTokenBalances(allBalances);
|
|
313
|
-
|
|
314
|
-
// Set error only if no balances loaded at all
|
|
315
|
-
if (allBalances.length === 0 && availableTokens.length > 0) {
|
|
316
|
-
setError('No token balances could be loaded');
|
|
317
|
-
}
|
|
318
|
-
} catch (err) {
|
|
319
|
-
const errorMessage = err instanceof Error ? err.message : 'Failed to load balances';
|
|
320
|
-
setError(errorMessage);
|
|
321
|
-
console.error('[useTokenBalances] Error loading balances:', err);
|
|
322
|
-
} finally {
|
|
323
|
-
setIsLoading(false);
|
|
324
|
-
}
|
|
325
|
-
}, [isAvailable, availableTokens, processPointsBalance, processRewardsBalance]);
|
|
326
|
-
|
|
327
|
-
// Auto-load balances when dependencies change
|
|
328
|
-
useEffect(() => {
|
|
329
|
-
if (autoLoad && isAvailable && availableTokens.length > 0) {
|
|
330
|
-
loadBalances();
|
|
331
|
-
}
|
|
332
|
-
}, [autoLoad, isAvailable, availableTokens.length, loadBalances]);
|
|
333
|
-
|
|
334
|
-
// Deprecated: Fallback polling interval (prefer wallet events)
|
|
335
|
-
// Kept for backward compatibility and edge cases where events might not work
|
|
336
|
-
useEffect(() => {
|
|
337
|
-
if (refreshInterval <= 0 || !isAvailable) return;
|
|
338
|
-
|
|
339
|
-
const intervalId = setInterval(() => {
|
|
340
|
-
loadBalances();
|
|
341
|
-
}, refreshInterval);
|
|
342
|
-
|
|
343
|
-
return () => {
|
|
344
|
-
clearInterval(intervalId);
|
|
345
|
-
};
|
|
346
|
-
}, [refreshInterval, isAvailable, loadBalances]);
|
|
347
|
-
|
|
348
|
-
// Wallet events refresh: listen for real-time blockchain events
|
|
349
|
-
// Refreshes on ANY wallet domain event (Transfer, TransferSingle, etc.)
|
|
350
|
-
// Debouncing prevents excessive API calls during rapid events
|
|
351
|
-
useEffect(() => {
|
|
352
|
-
if (!sdk || !refreshOnWalletEvents || !isAvailable) return;
|
|
353
|
-
|
|
354
|
-
const unsubscribe = sdk.events.subscribe((event) => {
|
|
355
|
-
if (event.domain === 'wallet') {
|
|
356
|
-
// Debounce rapid events (batch transfers, etc.)
|
|
357
|
-
if (walletEventDebounceRef.current) {
|
|
358
|
-
clearTimeout(walletEventDebounceRef.current);
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
walletEventDebounceRef.current = setTimeout(() => {
|
|
362
|
-
console.log(`[useTokenBalances] Wallet event (${event.type}), refreshing balances...`);
|
|
363
|
-
loadBalances();
|
|
364
|
-
walletEventDebounceRef.current = null;
|
|
365
|
-
}, walletEventDebounceMs);
|
|
366
|
-
}
|
|
367
|
-
}, { domains: ['wallet'] });
|
|
368
|
-
|
|
369
|
-
return () => {
|
|
370
|
-
unsubscribe();
|
|
371
|
-
if (walletEventDebounceRef.current) {
|
|
372
|
-
clearTimeout(walletEventDebounceRef.current);
|
|
373
|
-
}
|
|
374
|
-
};
|
|
375
|
-
}, [sdk, refreshOnWalletEvents, walletEventDebounceMs, isAvailable, loadBalances]);
|
|
376
|
-
|
|
377
|
-
return {
|
|
378
|
-
tokenBalances,
|
|
379
|
-
isLoading,
|
|
380
|
-
error,
|
|
381
|
-
refresh: loadBalances,
|
|
382
|
-
isAvailable,
|
|
383
|
-
userAccountAddress: accountAddress
|
|
384
|
-
};
|
|
385
|
-
}
|
|
1
|
+
import { useCallback, useEffect, useState, useRef } from 'react';
|
|
2
|
+
import { usePersSDK } from '../providers/PersSDKProvider';
|
|
3
|
+
import { useWeb3 } from './useWeb3';
|
|
4
|
+
import { NativeTokenTypes, type TokenDTO } from '@explorins/pers-sdk';
|
|
5
|
+
import type { TokenBalance, TokenBalanceRequest } from '@explorins/pers-sdk/web3';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Token balance with associated token information for display
|
|
9
|
+
*
|
|
10
|
+
* This composite type combines blockchain balance data with token metadata,
|
|
11
|
+
* making it easy to display token information in UI components without
|
|
12
|
+
* needing to join data from multiple sources.
|
|
13
|
+
*
|
|
14
|
+
* ## CRITICAL: Token ID for Transactions
|
|
15
|
+
*
|
|
16
|
+
* For ERC721 (stamps) and ERC1155 (rewards), use `tokenBalance.tokenId` as the
|
|
17
|
+
* `contractTokenId` when creating transactions. This is the **on-chain token ID**
|
|
18
|
+
* assigned by the blockchain, NOT the metadata definition ID.
|
|
19
|
+
*
|
|
20
|
+
* | Token Type | tokenId meaning | Example |
|
|
21
|
+
* |------------|-----------------|---------|
|
|
22
|
+
* | ERC20 | `null` or `""` | N/A (fungible points) |
|
|
23
|
+
* | ERC721 | NFT instance ID | `"46"` (on-chain ID) |
|
|
24
|
+
* | ERC1155 | Token type ID | `"16"` (semi-fungible type) |
|
|
25
|
+
*
|
|
26
|
+
* @example Correct usage for token burn transaction
|
|
27
|
+
* ```typescript
|
|
28
|
+
* const stampBalance = tokenBalances.find(b => b.token.type === 'ERC721');
|
|
29
|
+
*
|
|
30
|
+
* const request = buildPOSBurnRequest({
|
|
31
|
+
* amount: 1,
|
|
32
|
+
* contractAddress: stampBalance.token.contractAddress,
|
|
33
|
+
* contractTokenId: stampBalance.tokenBalance.tokenId, // ✅ Correct
|
|
34
|
+
* chainId: stampBalance.token.chainId,
|
|
35
|
+
* userId: userId,
|
|
36
|
+
* businessId: businessId
|
|
37
|
+
* });
|
|
38
|
+
* ```
|
|
39
|
+
*
|
|
40
|
+
*
|
|
41
|
+
* */
|
|
42
|
+
//@see {@link https://docs.explorins.com/pers-sdk/troubleshooting#stamp-voucher-transaction-fails | Troubleshooting: Stamp Transaction Fails}
|
|
43
|
+
export interface TokenBalanceWithToken {
|
|
44
|
+
/**
|
|
45
|
+
* The balance data from blockchain
|
|
46
|
+
*
|
|
47
|
+
* **Important:** For NFT operations, use `tokenBalance.tokenId` as the `contractTokenId`
|
|
48
|
+
* in transaction requests. This is the actual on-chain ID, not the metadata definition.
|
|
49
|
+
*/
|
|
50
|
+
tokenBalance: TokenBalance;
|
|
51
|
+
/** The token definition (contract, symbol, metadata, etc.) */
|
|
52
|
+
token: TokenDTO;
|
|
53
|
+
/** Human-readable formatted balance string (e.g., "100 XPL" or "3 stamps") */
|
|
54
|
+
balanceFormatted: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Configuration options for useTokenBalances hook
|
|
59
|
+
*/
|
|
60
|
+
export interface UseTokenBalancesOptions {
|
|
61
|
+
/**
|
|
62
|
+
* Wallet address to check balances for (REQUIRED)
|
|
63
|
+
* Apps must explicitly provide the wallet address - no automatic selection
|
|
64
|
+
* Get from: user.wallets[selectedIndex].address
|
|
65
|
+
*/
|
|
66
|
+
accountAddress: string;
|
|
67
|
+
/** Available tokens to check balances for (from SDK or filtered list) */
|
|
68
|
+
availableTokens?: TokenDTO[];
|
|
69
|
+
/** Whether to automatically load balances on mount/auth/token changes */
|
|
70
|
+
autoLoad?: boolean;
|
|
71
|
+
/**
|
|
72
|
+
* Fallback polling interval in milliseconds (0 = disabled)
|
|
73
|
+
* @deprecated Use wallet events instead (refreshOnWalletEvents). Polling kept as fallback only.
|
|
74
|
+
* @default 0
|
|
75
|
+
*/
|
|
76
|
+
refreshInterval?: number;
|
|
77
|
+
/**
|
|
78
|
+
* Auto-refresh balances when wallet events are received (Transfer, Approval, etc.)
|
|
79
|
+
* Requires `captureWalletEvents: true` in SDK config (default)
|
|
80
|
+
* @default true
|
|
81
|
+
*/
|
|
82
|
+
refreshOnWalletEvents?: boolean;
|
|
83
|
+
/**
|
|
84
|
+
* Debounce time for wallet event-triggered refreshes (milliseconds)
|
|
85
|
+
* Prevents rapid refresh calls during batch transfers
|
|
86
|
+
* @default 1000
|
|
87
|
+
*/
|
|
88
|
+
walletEventDebounceMs?: number;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Token balance loading hook return value
|
|
93
|
+
*/
|
|
94
|
+
export interface UseTokenBalancesResult {
|
|
95
|
+
/** Array of token balances with formatted display data */
|
|
96
|
+
tokenBalances: TokenBalanceWithToken[];
|
|
97
|
+
/** Whether balances are currently loading */
|
|
98
|
+
isLoading: boolean;
|
|
99
|
+
/** Error message if loading failed */
|
|
100
|
+
error: string | null;
|
|
101
|
+
/** Manually trigger balance refresh */
|
|
102
|
+
refresh: () => Promise<void>;
|
|
103
|
+
/** Whether the hook is available (SDK initialized, user authenticated, has wallet) */
|
|
104
|
+
isAvailable: boolean;
|
|
105
|
+
/** User's wallet address being queried */
|
|
106
|
+
userAccountAddress: string | null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* React hook for loading and managing token balances
|
|
111
|
+
*
|
|
112
|
+
* Automatically handles:
|
|
113
|
+
* - Loading balances for ERC20 (fungible tokens/points)
|
|
114
|
+
* - Loading collections for ERC721 (unique NFTs/stamps)
|
|
115
|
+
* - Loading collections for ERC1155 (semi-fungible tokens/rewards)
|
|
116
|
+
* - Parallel loading with error resilience
|
|
117
|
+
* - Auto-refresh on authentication or token list changes
|
|
118
|
+
* - Formatted balance strings for display
|
|
119
|
+
*
|
|
120
|
+
* This hook consolidates complex token balance loading logic that would otherwise
|
|
121
|
+
* need to be duplicated across every app. It handles the differences between
|
|
122
|
+
* fungible and non-fungible tokens, parallel loading, error handling, and
|
|
123
|
+
* provides formatted display strings.
|
|
124
|
+
*
|
|
125
|
+
* @param options - Configuration options
|
|
126
|
+
* @returns Token balances state and actions
|
|
127
|
+
*
|
|
128
|
+
* @example
|
|
129
|
+
* **Basic Usage:**
|
|
130
|
+
* ```typescript
|
|
131
|
+
* import { useTokenBalances, useTokens, usePersSDK } from '@explorins/pers-sdk-react-native';
|
|
132
|
+
* import { useState, useEffect } from 'react';
|
|
133
|
+
*
|
|
134
|
+
* function TokenBalancesScreen() {
|
|
135
|
+
* const { user } = usePersSDK();
|
|
136
|
+
* const { getTokens } = useTokens();
|
|
137
|
+
* const [availableTokens, setAvailableTokens] = useState([]);
|
|
138
|
+
*
|
|
139
|
+
* // Load tokens on mount
|
|
140
|
+
* useEffect(() => {
|
|
141
|
+
* getTokens().then(result => setAvailableTokens(result.data));
|
|
142
|
+
* }, []);
|
|
143
|
+
*
|
|
144
|
+
* // App explicitly selects which wallet to use
|
|
145
|
+
* const walletAddress = user?.wallets?.[0]?.address;
|
|
146
|
+
*
|
|
147
|
+
* const {
|
|
148
|
+
* tokenBalances,
|
|
149
|
+
* isLoading,
|
|
150
|
+
* error,
|
|
151
|
+
* refresh
|
|
152
|
+
* } = useTokenBalances({
|
|
153
|
+
* accountAddress: walletAddress!,
|
|
154
|
+
* availableTokens,
|
|
155
|
+
* autoLoad: true
|
|
156
|
+
* });
|
|
157
|
+
*
|
|
158
|
+
* if (isLoading) return <Text>Loading balances...</Text>;
|
|
159
|
+
* if (error) return <Text>Error: {error}</Text>;
|
|
160
|
+
*
|
|
161
|
+
* return (
|
|
162
|
+
* <View>
|
|
163
|
+
* {tokenBalances.map(({ token, balanceFormatted }) => (
|
|
164
|
+
* <View key={token.id}>
|
|
165
|
+
* <Text>{token.name}: {balanceFormatted}</Text>
|
|
166
|
+
* </View>
|
|
167
|
+
* ))}
|
|
168
|
+
* <Button onPress={refresh} title="Refresh" />
|
|
169
|
+
* </View>
|
|
170
|
+
* );
|
|
171
|
+
* }
|
|
172
|
+
* ```
|
|
173
|
+
*
|
|
174
|
+
* @example
|
|
175
|
+
* **With Wallet Events (Real-time):
|
|
176
|
+
* ```typescript
|
|
177
|
+
* // Auto-refresh on Transfer, Approval, and other blockchain events
|
|
178
|
+
* const { tokenBalances } = useTokenBalances({
|
|
179
|
+
* accountAddress: walletAddress!,
|
|
180
|
+
* availableTokens,
|
|
181
|
+
* refreshOnWalletEvents: true, // Enable real-time refresh (default: true)
|
|
182
|
+
* walletEventDebounceMs: 1000 // Debounce rapid events (default: 1000ms)
|
|
183
|
+
* });
|
|
184
|
+
* ```
|
|
185
|
+
*
|
|
186
|
+
* @example
|
|
187
|
+
* **Multi-Wallet Support:**
|
|
188
|
+
* ```typescript
|
|
189
|
+
* function MultiWalletBalances() {
|
|
190
|
+
* const { user } = usePersSDK();
|
|
191
|
+
* const [selectedWalletIndex, setSelectedWalletIndex] = useState(0);
|
|
192
|
+
*
|
|
193
|
+
* const walletAddress = user?.wallets?.[selectedWalletIndex]?.address;
|
|
194
|
+
*
|
|
195
|
+
* const { tokenBalances } = useTokenBalances({
|
|
196
|
+
* accountAddress: walletAddress!,
|
|
197
|
+
* availableTokens
|
|
198
|
+
* });
|
|
199
|
+
*
|
|
200
|
+
* // UI lets user switch between wallets
|
|
201
|
+
* }
|
|
202
|
+
* ```
|
|
203
|
+
*/
|
|
204
|
+
export function useTokenBalances(options: UseTokenBalancesOptions): UseTokenBalancesResult {
|
|
205
|
+
const {
|
|
206
|
+
accountAddress,
|
|
207
|
+
availableTokens = [],
|
|
208
|
+
autoLoad = true,
|
|
209
|
+
refreshInterval = 0,
|
|
210
|
+
refreshOnWalletEvents = true,
|
|
211
|
+
walletEventDebounceMs = 1000
|
|
212
|
+
} = options;
|
|
213
|
+
|
|
214
|
+
const { isAuthenticated, sdk } = usePersSDK();
|
|
215
|
+
const web3 = useWeb3();
|
|
216
|
+
|
|
217
|
+
const [tokenBalances, setTokenBalances] = useState<TokenBalanceWithToken[]>([]);
|
|
218
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
219
|
+
const [error, setError] = useState<string | null>(null);
|
|
220
|
+
|
|
221
|
+
// Debounce ref for wallet events
|
|
222
|
+
const walletEventDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
223
|
+
|
|
224
|
+
// Check if the hook is available for use
|
|
225
|
+
const isAvailable = web3.isAvailable && isAuthenticated && !!accountAddress;
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Process balance for ERC20 fungible tokens (points/credits)
|
|
229
|
+
*/
|
|
230
|
+
const processPointsBalance = useCallback(async (token: TokenDTO): Promise<TokenBalanceWithToken | null> => {
|
|
231
|
+
if (!web3.getTokenBalance || !accountAddress) return null;
|
|
232
|
+
|
|
233
|
+
try {
|
|
234
|
+
const request: TokenBalanceRequest = {
|
|
235
|
+
accountAddress,
|
|
236
|
+
contractAddress: token.contractAddress,
|
|
237
|
+
abi: token.abi,
|
|
238
|
+
tokenId: '',
|
|
239
|
+
chainId: token.chainId
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const balanceResult = await web3.getTokenBalance(request);
|
|
243
|
+
|
|
244
|
+
if (!balanceResult || !balanceResult.hasBalance) return null;
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
tokenBalance: balanceResult,
|
|
248
|
+
token,
|
|
249
|
+
balanceFormatted: `${balanceResult.balance} ${token.symbol || 'tokens'}`
|
|
250
|
+
};
|
|
251
|
+
} catch (err) {
|
|
252
|
+
console.warn(`[useTokenBalances] Failed to load balance for ${token.symbol}:`, err);
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
}, [web3.getTokenBalance, accountAddress]);
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Process collection for ERC721/ERC1155 non-fungible tokens (stamps/rewards)
|
|
259
|
+
*/
|
|
260
|
+
const processRewardsBalance = useCallback(async (token: TokenDTO): Promise<TokenBalanceWithToken[]> => {
|
|
261
|
+
if (!web3.getAccountOwnedTokensFromContract || !accountAddress) return [];
|
|
262
|
+
|
|
263
|
+
try {
|
|
264
|
+
const result = await web3.getAccountOwnedTokensFromContract(accountAddress, token);
|
|
265
|
+
|
|
266
|
+
if (result.totalOwned === 0) return [];
|
|
267
|
+
|
|
268
|
+
const balanceUnit = token.type === NativeTokenTypes.ERC1155 ? 'reward' : 'stamp';
|
|
269
|
+
|
|
270
|
+
// Return one item per tokenId (for individual display in lists)
|
|
271
|
+
return result.ownedTokens.map(ownedToken => ({
|
|
272
|
+
tokenBalance: ownedToken,
|
|
273
|
+
token,
|
|
274
|
+
balanceFormatted: `${ownedToken.balance} ${balanceUnit}${ownedToken.balance !== 1 ? 's' : ''}`
|
|
275
|
+
}));
|
|
276
|
+
} catch (err) {
|
|
277
|
+
console.warn(`[useTokenBalances] Failed to load collection for ${token.symbol}:`, err);
|
|
278
|
+
return [];
|
|
279
|
+
}
|
|
280
|
+
}, [web3.getAccountOwnedTokensFromContract, accountAddress]);
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Load all token balances
|
|
284
|
+
*/
|
|
285
|
+
const loadBalances = useCallback(async () => {
|
|
286
|
+
if (!isAvailable || availableTokens.length === 0) {
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
setIsLoading(true);
|
|
291
|
+
setError(null);
|
|
292
|
+
|
|
293
|
+
try {
|
|
294
|
+
// Split by category: points (fungible credits) vs rewards (stamps/collectibles)
|
|
295
|
+
const points = availableTokens.filter(t => t.type === NativeTokenTypes.ERC20);
|
|
296
|
+
const rewards = availableTokens.filter(
|
|
297
|
+
t => t.type === NativeTokenTypes.ERC721 || t.type === NativeTokenTypes.ERC1155
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
// Load all balances in parallel (individual errors handled per-token)
|
|
301
|
+
const [pointsResults, rewardsResults] = await Promise.all([
|
|
302
|
+
Promise.all(points.map(processPointsBalance)),
|
|
303
|
+
Promise.all(rewards.map(processRewardsBalance))
|
|
304
|
+
]);
|
|
305
|
+
|
|
306
|
+
// Combine results, filtering out nulls (failed loads return null/empty)
|
|
307
|
+
const allBalances = [
|
|
308
|
+
...pointsResults.filter(Boolean),
|
|
309
|
+
...rewardsResults.flat()
|
|
310
|
+
] as TokenBalanceWithToken[];
|
|
311
|
+
|
|
312
|
+
setTokenBalances(allBalances);
|
|
313
|
+
|
|
314
|
+
// Set error only if no balances loaded at all
|
|
315
|
+
if (allBalances.length === 0 && availableTokens.length > 0) {
|
|
316
|
+
setError('No token balances could be loaded');
|
|
317
|
+
}
|
|
318
|
+
} catch (err) {
|
|
319
|
+
const errorMessage = err instanceof Error ? err.message : 'Failed to load balances';
|
|
320
|
+
setError(errorMessage);
|
|
321
|
+
console.error('[useTokenBalances] Error loading balances:', err);
|
|
322
|
+
} finally {
|
|
323
|
+
setIsLoading(false);
|
|
324
|
+
}
|
|
325
|
+
}, [isAvailable, availableTokens, processPointsBalance, processRewardsBalance]);
|
|
326
|
+
|
|
327
|
+
// Auto-load balances when dependencies change
|
|
328
|
+
useEffect(() => {
|
|
329
|
+
if (autoLoad && isAvailable && availableTokens.length > 0) {
|
|
330
|
+
loadBalances();
|
|
331
|
+
}
|
|
332
|
+
}, [autoLoad, isAvailable, availableTokens.length, loadBalances]);
|
|
333
|
+
|
|
334
|
+
// Deprecated: Fallback polling interval (prefer wallet events)
|
|
335
|
+
// Kept for backward compatibility and edge cases where events might not work
|
|
336
|
+
useEffect(() => {
|
|
337
|
+
if (refreshInterval <= 0 || !isAvailable) return;
|
|
338
|
+
|
|
339
|
+
const intervalId = setInterval(() => {
|
|
340
|
+
loadBalances();
|
|
341
|
+
}, refreshInterval);
|
|
342
|
+
|
|
343
|
+
return () => {
|
|
344
|
+
clearInterval(intervalId);
|
|
345
|
+
};
|
|
346
|
+
}, [refreshInterval, isAvailable, loadBalances]);
|
|
347
|
+
|
|
348
|
+
// Wallet events refresh: listen for real-time blockchain events
|
|
349
|
+
// Refreshes on ANY wallet domain event (Transfer, TransferSingle, etc.)
|
|
350
|
+
// Debouncing prevents excessive API calls during rapid events
|
|
351
|
+
useEffect(() => {
|
|
352
|
+
if (!sdk || !refreshOnWalletEvents || !isAvailable) return;
|
|
353
|
+
|
|
354
|
+
const unsubscribe = sdk.events.subscribe((event) => {
|
|
355
|
+
if (event.domain === 'wallet') {
|
|
356
|
+
// Debounce rapid events (batch transfers, etc.)
|
|
357
|
+
if (walletEventDebounceRef.current) {
|
|
358
|
+
clearTimeout(walletEventDebounceRef.current);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
walletEventDebounceRef.current = setTimeout(() => {
|
|
362
|
+
console.log(`[useTokenBalances] Wallet event (${event.type}), refreshing balances...`);
|
|
363
|
+
loadBalances();
|
|
364
|
+
walletEventDebounceRef.current = null;
|
|
365
|
+
}, walletEventDebounceMs);
|
|
366
|
+
}
|
|
367
|
+
}, { domains: ['wallet'] });
|
|
368
|
+
|
|
369
|
+
return () => {
|
|
370
|
+
unsubscribe();
|
|
371
|
+
if (walletEventDebounceRef.current) {
|
|
372
|
+
clearTimeout(walletEventDebounceRef.current);
|
|
373
|
+
}
|
|
374
|
+
};
|
|
375
|
+
}, [sdk, refreshOnWalletEvents, walletEventDebounceMs, isAvailable, loadBalances]);
|
|
376
|
+
|
|
377
|
+
return {
|
|
378
|
+
tokenBalances,
|
|
379
|
+
isLoading,
|
|
380
|
+
error,
|
|
381
|
+
refresh: loadBalances,
|
|
382
|
+
isAvailable,
|
|
383
|
+
userAccountAddress: accountAddress
|
|
384
|
+
};
|
|
385
|
+
}
|