@explorins/pers-sdk-react-native 2.0.5 → 2.1.2

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.
@@ -1 +1 @@
1
- {"version":3,"file":"PersSDKProvider.d.ts","sourceRoot":"","sources":["../../src/providers/PersSDKProvider.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,EAAuC,SAAS,EAA2C,MAAM,OAAO,CAAC;AAEvH,OAAO,EAAE,OAAO,EAAE,UAAU,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAC;AAIpF,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,wBAAwB,CAAC;AAG3D,OAAO,KAAK,EACV,WAAW,EACX,WAAW,EACX,YAAY,EACZ,eAAe,EACf,eAAe,EACf,iBAAiB,EACjB,kBAAkB,EAClB,eAAe,EACf,aAAa,EACb,gBAAgB,EAChB,eAAe,EAChB,MAAM,0BAA0B,CAAC;AAGlC,YAAY,EAAE,UAAU,EAAE,MAAM,0BAA0B,CAAC;AAE3D,MAAM,WAAW,cAAc;IAE7B,GAAG,EAAE,OAAO,GAAG,IAAI,CAAC;IAGpB,IAAI,EAAE,WAAW,GAAG,IAAI,CAAC;IACzB,KAAK,EAAE,WAAW,GAAG,IAAI,CAAC;IAC1B,MAAM,EAAE,YAAY,GAAG,IAAI,CAAC;IAC5B,UAAU,EAAE,eAAe,GAAG,IAAI,CAAC;IACnC,SAAS,EAAE,eAAe,GAAG,IAAI,CAAC;IAClC,WAAW,EAAE,iBAAiB,GAAG,IAAI,CAAC;IACtC,YAAY,EAAE,kBAAkB,GAAG,IAAI,CAAC;IACxC,SAAS,EAAE,eAAe,GAAG,IAAI,CAAC;IAClC,OAAO,EAAE,aAAa,GAAG,IAAI,CAAC;IAC9B,SAAS,EAAE,gBAAgB,GAAG,IAAI,CAAC;IACnC,SAAS,EAAE,eAAe,GAAG,IAAI,CAAC;IAGlC,YAAY,EAAE,mBAAmB,GAAG,IAAI,CAAC;IAGzC,aAAa,EAAE,OAAO,CAAC;IACvB,eAAe,EAAE,OAAO,CAAC;IACzB,IAAI,EAAE,OAAO,GAAG,QAAQ,GAAG,IAAI,CAAC;IAGhC,UAAU,EAAE,CAAC,MAAM,EAAE,UAAU,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAClD,sBAAsB,EAAE,CAAC,IAAI,EAAE,OAAO,GAAG,QAAQ,GAAG,IAAI,EAAE,eAAe,EAAE,OAAO,KAAK,IAAI,CAAC;IAC5F,eAAe,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CACtC;AAMD,eAAO,MAAM,eAAe,EAAE,KAAK,CAAC,EAAE,CAAC;IACrC,QAAQ,EAAE,SAAS,CAAC;IACpB,MAAM,CAAC,EAAE,UAAU,CAAC;CACrB,CAwNA,CAAC;AAGF,eAAO,MAAM,UAAU,QAAO,cAQ7B,CAAC"}
1
+ {"version":3,"file":"PersSDKProvider.d.ts","sourceRoot":"","sources":["../../src/providers/PersSDKProvider.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,EAAuC,SAAS,EAA2C,MAAM,OAAO,CAAC;AAEvH,OAAO,EAAE,OAAO,EAAE,UAAU,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAC;AAIpF,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,wBAAwB,CAAC;AAG3D,OAAO,KAAK,EACV,WAAW,EACX,WAAW,EACX,YAAY,EACZ,eAAe,EACf,eAAe,EACf,iBAAiB,EACjB,kBAAkB,EAClB,eAAe,EACf,aAAa,EACb,gBAAgB,EAChB,eAAe,EAChB,MAAM,0BAA0B,CAAC;AAGlC,YAAY,EAAE,UAAU,EAAE,MAAM,0BAA0B,CAAC;AAE3D,MAAM,WAAW,cAAc;IAE7B,GAAG,EAAE,OAAO,GAAG,IAAI,CAAC;IAGpB,IAAI,EAAE,WAAW,GAAG,IAAI,CAAC;IACzB,KAAK,EAAE,WAAW,GAAG,IAAI,CAAC;IAC1B,MAAM,EAAE,YAAY,GAAG,IAAI,CAAC;IAC5B,UAAU,EAAE,eAAe,GAAG,IAAI,CAAC;IACnC,SAAS,EAAE,eAAe,GAAG,IAAI,CAAC;IAClC,WAAW,EAAE,iBAAiB,GAAG,IAAI,CAAC;IACtC,YAAY,EAAE,kBAAkB,GAAG,IAAI,CAAC;IACxC,SAAS,EAAE,eAAe,GAAG,IAAI,CAAC;IAClC,OAAO,EAAE,aAAa,GAAG,IAAI,CAAC;IAC9B,SAAS,EAAE,gBAAgB,GAAG,IAAI,CAAC;IACnC,SAAS,EAAE,eAAe,GAAG,IAAI,CAAC;IAGlC,YAAY,EAAE,mBAAmB,GAAG,IAAI,CAAC;IAGzC,aAAa,EAAE,OAAO,CAAC;IACvB,eAAe,EAAE,OAAO,CAAC;IACzB,IAAI,EAAE,OAAO,GAAG,QAAQ,GAAG,IAAI,CAAC;IAGhC,UAAU,EAAE,CAAC,MAAM,EAAE,UAAU,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAClD,sBAAsB,EAAE,CAAC,IAAI,EAAE,OAAO,GAAG,QAAQ,GAAG,IAAI,EAAE,eAAe,EAAE,OAAO,KAAK,IAAI,CAAC;IAC5F,eAAe,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CACtC;AAMD,eAAO,MAAM,eAAe,EAAE,KAAK,CAAC,EAAE,CAAC;IACrC,QAAQ,EAAE,SAAS,CAAC;IACpB,MAAM,CAAC,EAAE,UAAU,CAAC;CACrB,CAyMA,CAAC;AAGF,eAAO,MAAM,UAAU,QAAO,cAQ7B,CAAC"}
@@ -68,6 +68,10 @@ export const PersSDKProvider = ({ children, config }) => {
68
68
  initializingRef.current = false;
69
69
  }
70
70
  }, [isInitialized]);
71
+ const setAuthenticationState = useCallback((user, isAuthenticated) => {
72
+ setUser(user);
73
+ setIsAuthenticated(isAuthenticated);
74
+ }, []);
71
75
  // Auto-initialize if config is provided
72
76
  useEffect(() => {
73
77
  if (config && !isInitialized && !initializingRef.current) {
@@ -76,10 +80,6 @@ export const PersSDKProvider = ({ children, config }) => {
76
80
  });
77
81
  }
78
82
  }, [config, isInitialized, initialize]);
79
- const setAuthenticationState = useCallback((user, isAuthenticated) => {
80
- setUser(user);
81
- setIsAuthenticated(isAuthenticated);
82
- }, []);
83
83
  const refreshUserData = useCallback(async () => {
84
84
  if (!sdk || !isAuthenticated || !isInitialized) {
85
85
  throw new Error('SDK not initialized or not authenticated. Cannot refresh user data.');
@@ -93,47 +93,36 @@ export const PersSDKProvider = ({ children, config }) => {
93
93
  throw error;
94
94
  }
95
95
  }, [sdk, isAuthenticated, isInitialized]);
96
- // Listen for authentication status changes and refresh user data when tokens are renewed
96
+ // Listen for authentication events from core SDK
97
+ // Set up immediately when SDK is created (don't wait for isInitialized)
98
+ // to catch session_restored events that fire during SDK initialization
97
99
  useEffect(() => {
98
- if (!authProvider || !isInitialized)
99
- return;
100
- // Access the config object with proper type safety
101
- const providerConfig = authProvider.config;
102
- if (!providerConfig)
100
+ if (!sdk)
103
101
  return;
104
- // Set up auth status change handler
105
- const originalHandler = providerConfig.onAuthStatusChange;
106
- const authStatusHandler = async (status) => {
107
- console.log('[PersSDK] Auth status changed:', status);
108
- // Call original handler first if it exists
109
- if (originalHandler) {
110
- await originalHandler(status);
111
- }
112
- // If token was refreshed successfully and user is authenticated, reload user data
113
- if (status === 'authenticated' && isAuthenticated && sdk) {
114
- try {
115
- console.log('[PersSDK] Token refreshed, reloading user data...');
116
- await refreshUserData();
117
- }
118
- catch (error) {
119
- console.error('[PersSDK] Failed to refresh user data after token renewal:', error);
120
- }
102
+ const unsubscribe = sdk.events.subscribe((event) => {
103
+ if (event.domain !== 'authentication')
104
+ return;
105
+ // Session restored successfully - sync React state
106
+ if (event.type === 'session_restored') {
107
+ console.log('[PersSDK] Session restoration event received, syncing state...');
108
+ sdk.users.getCurrentUser()
109
+ .then(userData => {
110
+ setAuthenticationState(userData, true);
111
+ })
112
+ .catch(error => {
113
+ console.error('[PersSDK] Failed to sync restored session:', error);
114
+ });
121
115
  }
122
- // If authentication failed, clear state
123
- if (status === 'auth_failed') {
124
- console.log('[PersSDK] Authentication failed, clearing state');
116
+ // Session restoration failed or auth error - clear React state
117
+ if (event.type === 'session_restoration_failed' || event.code === 'AUTH_FAILED') {
118
+ console.log('[PersSDK] Authentication failed - clearing session');
125
119
  setAuthenticationState(null, false);
126
120
  }
127
- };
128
- // Inject our handler into the auth provider config
129
- providerConfig.onAuthStatusChange = authStatusHandler;
130
- // Cleanup
121
+ }, { domains: ['authentication'] });
131
122
  return () => {
132
- if (originalHandler) {
133
- providerConfig.onAuthStatusChange = originalHandler;
134
- }
123
+ unsubscribe();
135
124
  };
136
- }, [authProvider, isInitialized, isAuthenticated, sdk, refreshUserData, setAuthenticationState]);
125
+ }, [sdk, setAuthenticationState]);
137
126
  // iOS/Android: Monitor app state and validate tokens when app becomes active
138
127
  useEffect(() => {
139
128
  if (!sdk || Platform.OS === 'web') {
@@ -1 +1 @@
1
- {"version":3,"file":"rn-secure-storage.d.ts","sourceRoot":"","sources":["../../src/storage/rn-secure-storage.ts"],"names":[],"mappings":"AACA,OAAO,EACL,YAAY,EAGb,MAAM,0BAA0B,CAAC;AAalC;;;GAGG;AACH,qBAAa,wBAAyB,YAAW,YAAY;IAc/C,OAAO,CAAC,SAAS;IAZ7B,QAAQ,CAAC,eAAe,SAAS;IAGjC,OAAO,CAAC,QAAQ,CAAC,WAAW,CAKzB;IAEH,OAAO,CAAC,eAAe,CAA2B;gBAE9B,SAAS,GAAE,MAAuB;IAIhD,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC;IA0B/C,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC;IAkCzC,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAkBlC,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAoB5B,OAAO,CAAC,UAAU;IAOlB,OAAO,CAAC,QAAQ;CAOjB"}
1
+ {"version":3,"file":"rn-secure-storage.d.ts","sourceRoot":"","sources":["../../src/storage/rn-secure-storage.ts"],"names":[],"mappings":"AACA,OAAO,EACL,YAAY,EAGb,MAAM,0BAA0B,CAAC;AAalC;;;GAGG;AACH,qBAAa,wBAAyB,YAAW,YAAY;IAc/C,OAAO,CAAC,SAAS;IAZ7B,QAAQ,CAAC,eAAe,SAAS;IAGjC,OAAO,CAAC,QAAQ,CAAC,WAAW,CAKzB;IAEH,OAAO,CAAC,eAAe,CAA2B;gBAE9B,SAAS,GAAE,MAAuB;IAIhD,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC;IA0B/C,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC;IAkCzC,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAkBlC,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAqC5B,OAAO,CAAC,UAAU;IAOlB,OAAO,CAAC,QAAQ;CAOjB"}
@@ -110,23 +110,37 @@ export class ReactNativeSecureStorage {
110
110
  }
111
111
  }
112
112
  async clear() {
113
- // Clear all known secure keys
113
+ const clearPromises = [];
114
+ // Clear all known secure keys with retry logic
114
115
  for (const key of this.SECURE_KEYS) {
115
- try {
116
- if (Keychain) {
117
- await Keychain.resetGenericPassword({ service: this.getKeyName(key) });
116
+ clearPromises.push((async () => {
117
+ try {
118
+ if (Keychain) {
119
+ await Keychain.resetGenericPassword({ service: this.getKeyName(key) });
120
+ }
118
121
  }
119
- }
120
- catch (e) {
121
- console.warn(`[ReactNativeSecureStorage] Failed to clear keychain key ${key}`, e);
122
- }
122
+ catch (e) {
123
+ console.warn(`[ReactNativeSecureStorage] Retry clearing ${key}`);
124
+ // Retry once
125
+ try {
126
+ if (Keychain) {
127
+ await Keychain.resetGenericPassword({ service: this.getKeyName(key) });
128
+ }
129
+ }
130
+ catch (retryError) {
131
+ console.error(`[ReactNativeSecureStorage] Failed to clear ${key} after retry:`, retryError);
132
+ }
133
+ }
134
+ })());
123
135
  }
124
- // Clear AsyncStorage keys related to PERS
136
+ // Wait for all Keychain clearing to complete (or fail)
137
+ await Promise.allSettled(clearPromises);
138
+ // Always clear AsyncStorage fallback
125
139
  try {
126
140
  await this.fallbackStorage.clear();
127
141
  }
128
142
  catch (e) {
129
- console.warn('[ReactNativeSecureStorage] Failed to clear AsyncStorage', e);
143
+ console.error('[ReactNativeSecureStorage] Failed to clear AsyncStorage:', e);
130
144
  }
131
145
  }
132
146
  getKeyName(key) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@explorins/pers-sdk-react-native",
3
- "version": "2.0.5",
3
+ "version": "2.1.2",
4
4
  "description": "React Native SDK for PERS Platform - Tourism Loyalty System with Blockchain Transaction Signing and WebAuthn Authentication",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -37,10 +37,8 @@
37
37
  "author": "eXplorins",
38
38
  "license": "MIT",
39
39
  "dependencies": {
40
- "@explorins/pers-sdk": "^2.0.6",
41
- "@explorins/pers-shared": "^2.1.64",
40
+ "@explorins/pers-sdk": "^2.1.2",
42
41
  "@explorins/pers-signer": "^1.0.33",
43
- "@explorins/web3-ts": "^0.3.77",
44
42
  "buffer": "^6.0.3",
45
43
  "ethers": "^6.15.0",
46
44
  "react-native-get-random-values": "^2.0.0",
@@ -1,6 +1,7 @@
1
1
  // Export all hooks (only hooks, no providers to avoid circular dependency)
2
2
  export { useAuth } from './useAuth';
3
3
  export { useTokens } from './useTokens';
4
+ export { useTokenBalances } from './useTokenBalances';
4
5
  export { useTransactions } from './useTransactions';
5
6
  export { useTransactionSigner, SigningStatus } from './useTransactionSigner';
6
7
  export { useBusiness } from './useBusiness';
@@ -28,4 +29,9 @@ export type {
28
29
  SigningStatus as SigningStatusType
29
30
  } from './useTransactionSigner';
30
31
  export type { AccountOwnedTokensResult, Web3Hook } from './useWeb3';
31
- export type { EventsHook, PersEvent, EventHandler, EventFilter, Unsubscribe } from './useEvents';
32
+ export type { EventsHook, PersEvent, EventHandler, EventFilter, Unsubscribe } from './useEvents';
33
+ export type {
34
+ TokenBalanceWithToken,
35
+ UseTokenBalancesOptions,
36
+ UseTokenBalancesResult
37
+ } from './useTokenBalances';
@@ -0,0 +1,290 @@
1
+ import { useCallback, useEffect, useState } from 'react';
2
+ import { usePersSDK } from '../providers/PersSDKProvider';
3
+ import { useWeb3 } from './useWeb3';
4
+ import { NativeTokenTypes, type TokenDTO } from '@explorins/pers-shared';
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
+ export interface TokenBalanceWithToken {
15
+ /** The balance data from blockchain */
16
+ tokenBalance: TokenBalance;
17
+ /** The token definition (contract, symbol, metadata, etc.) */
18
+ token: TokenDTO;
19
+ /** Human-readable formatted balance string (e.g., "100 XPL" or "3 stamps") */
20
+ balanceFormatted: string;
21
+ }
22
+
23
+ /**
24
+ * Configuration options for useTokenBalances hook
25
+ */
26
+ export interface UseTokenBalancesOptions {
27
+ /**
28
+ * Wallet address to check balances for (REQUIRED)
29
+ * Apps must explicitly provide the wallet address - no automatic selection
30
+ * Get from: user.wallets[selectedIndex].address
31
+ */
32
+ accountAddress: string;
33
+ /** Available tokens to check balances for (from SDK or filtered list) */
34
+ availableTokens?: TokenDTO[];
35
+ /** Whether to automatically load balances on mount/auth/token changes */
36
+ autoLoad?: boolean;
37
+ /** Optional refresh interval in milliseconds (0 = disabled) */
38
+ refreshInterval?: number;
39
+ }
40
+
41
+ /**
42
+ * Token balance loading hook return value
43
+ */
44
+ export interface UseTokenBalancesResult {
45
+ /** Array of token balances with formatted display data */
46
+ tokenBalances: TokenBalanceWithToken[];
47
+ /** Whether balances are currently loading */
48
+ isLoading: boolean;
49
+ /** Error message if loading failed */
50
+ error: string | null;
51
+ /** Manually trigger balance refresh */
52
+ refresh: () => Promise<void>;
53
+ /** Whether the hook is available (SDK initialized, user authenticated, has wallet) */
54
+ isAvailable: boolean;
55
+ /** User's wallet address being queried */
56
+ userAccountAddress: string | null;
57
+ }
58
+
59
+ /**
60
+ * React hook for loading and managing token balances
61
+ *
62
+ * Automatically handles:
63
+ * - Loading balances for ERC20 (fungible tokens/points)
64
+ * - Loading collections for ERC721 (unique NFTs/stamps)
65
+ * - Loading collections for ERC1155 (semi-fungible tokens/rewards)
66
+ * - Parallel loading with error resilience
67
+ * - Auto-refresh on authentication or token list changes
68
+ * - Formatted balance strings for display
69
+ *
70
+ * This hook consolidates complex token balance loading logic that would otherwise
71
+ * need to be duplicated across every app. It handles the differences between
72
+ * fungible and non-fungible tokens, parallel loading, error handling, and
73
+ * provides formatted display strings.
74
+ *
75
+ * @param options - Configuration options
76
+ * @returns Token balances state and actions
77
+ *
78
+ * @example
79
+ * **Basic Usage:**
80
+ * ```typescript
81
+ * import { useTokenBalances, useTokens, usePersSDK } from '@explorins/pers-sdk-react-native';
82
+ *
83
+ * function TokenBalancesScreen() {
84
+ * const { user } = usePersSDK();
85
+ * const { availableTokens } = useTokens();
86
+ *
87
+ * // App explicitly selects which wallet to use
88
+ * const walletAddress = user?.wallets?.[0]?.address;
89
+ *
90
+ * const {
91
+ * tokenBalances,
92
+ * isLoading,
93
+ * error,
94
+ * refresh
95
+ * } = useTokenBalances({
96
+ * accountAddress: walletAddress!,
97
+ * availableTokens,
98
+ * autoLoad: true
99
+ * });
100
+ *
101
+ * if (isLoading) return <Text>Loading balances...</Text>;
102
+ * if (error) return <Text>Error: {error}</Text>;
103
+ *
104
+ * return (
105
+ * <View>
106
+ * {tokenBalances.map(({ token, balanceFormatted }) => (
107
+ * <View key={token.id}>
108
+ * <Text>{token.name}: {balanceFormatted}</Text>
109
+ * </View>
110
+ * ))}
111
+ * <Button onPress={refresh} title="Refresh" />
112
+ * </View>
113
+ * );
114
+ * }
115
+ * ```
116
+ *
117
+ * @example
118
+ * **With Auto-Refresh:**
119
+ * ```typescript
120
+ * const { tokenBalances, isLoading } = useTokenBalances({
121
+ * accountAddress: walletAddress!,
122
+ * availableTokens,
123
+ * autoLoad: true,
124
+ * refreshInterval: 30000 // Refresh every 30 seconds
125
+ * });
126
+ * ```
127
+ *
128
+ * @example
129
+ * **Multi-Wallet Support:**
130
+ * ```typescript
131
+ * function MultiWalletBalances() {
132
+ * const { user } = usePersSDK();
133
+ * const [selectedWalletIndex, setSelectedWalletIndex] = useState(0);
134
+ *
135
+ * const walletAddress = user?.wallets?.[selectedWalletIndex]?.address;
136
+ *
137
+ * const { tokenBalances } = useTokenBalances({
138
+ * accountAddress: walletAddress!,
139
+ * availableTokens
140
+ * });
141
+ *
142
+ * // UI lets user switch between wallets
143
+ * }
144
+ * ```
145
+ */
146
+ export function useTokenBalances(options: UseTokenBalancesOptions): UseTokenBalancesResult {
147
+ const {
148
+ accountAddress,
149
+ availableTokens = [],
150
+ autoLoad = true,
151
+ refreshInterval = 0
152
+ } = options;
153
+
154
+ const { isAuthenticated } = usePersSDK();
155
+ const web3 = useWeb3();
156
+
157
+ const [tokenBalances, setTokenBalances] = useState<TokenBalanceWithToken[]>([]);
158
+ const [isLoading, setIsLoading] = useState(false);
159
+ const [error, setError] = useState<string | null>(null);
160
+
161
+ // Check if the hook is available for use
162
+ const isAvailable = web3.isAvailable && isAuthenticated && !!accountAddress;
163
+
164
+ /**
165
+ * Process balance for ERC20 fungible tokens (points/credits)
166
+ */
167
+ const processPointsBalance = useCallback(async (token: TokenDTO): Promise<TokenBalanceWithToken | null> => {
168
+ if (!web3.getTokenBalance || !accountAddress) return null;
169
+
170
+ try {
171
+ const request: TokenBalanceRequest = {
172
+ accountAddress,
173
+ contractAddress: token.contractAddress,
174
+ abi: token.abi,
175
+ tokenId: '',
176
+ chainId: token.chainId
177
+ };
178
+
179
+ const balanceResult = await web3.getTokenBalance(request);
180
+
181
+ if (!balanceResult || !balanceResult.hasBalance) return null;
182
+
183
+ return {
184
+ tokenBalance: balanceResult,
185
+ token,
186
+ balanceFormatted: `${balanceResult.balance} ${token.symbol || 'tokens'}`
187
+ };
188
+ } catch (err) {
189
+ console.warn(`[useTokenBalances] Failed to load balance for ${token.symbol}:`, err);
190
+ return null;
191
+ }
192
+ }, [web3.getTokenBalance, accountAddress]);
193
+
194
+ /**
195
+ * Process collection for ERC721/ERC1155 non-fungible tokens (stamps/rewards)
196
+ */
197
+ const processRewardsBalance = useCallback(async (token: TokenDTO): Promise<TokenBalanceWithToken[]> => {
198
+ if (!web3.getAccountOwnedTokensFromContract || !accountAddress) return [];
199
+
200
+ try {
201
+ const result = await web3.getAccountOwnedTokensFromContract(accountAddress, token);
202
+
203
+ if (result.totalOwned === 0) return [];
204
+
205
+ const balanceUnit = token.type === NativeTokenTypes.ERC1155 ? 'reward' : 'stamp';
206
+
207
+ // Return one item per tokenId (for individual display in lists)
208
+ return result.ownedTokens.map(ownedToken => ({
209
+ tokenBalance: ownedToken,
210
+ token,
211
+ balanceFormatted: `${ownedToken.balance} ${balanceUnit}${ownedToken.balance !== 1 ? 's' : ''}`
212
+ }));
213
+ } catch (err) {
214
+ console.warn(`[useTokenBalances] Failed to load collection for ${token.symbol}:`, err);
215
+ return [];
216
+ }
217
+ }, [web3.getAccountOwnedTokensFromContract, accountAddress]);
218
+
219
+ /**
220
+ * Load all token balances
221
+ */
222
+ const loadBalances = useCallback(async () => {
223
+ if (!isAvailable || availableTokens.length === 0) {
224
+ return;
225
+ }
226
+
227
+ setIsLoading(true);
228
+ setError(null);
229
+
230
+ try {
231
+ // Split by category: points (fungible credits) vs rewards (stamps/collectibles)
232
+ const points = availableTokens.filter(t => t.type === NativeTokenTypes.ERC20);
233
+ const rewards = availableTokens.filter(
234
+ t => t.type === NativeTokenTypes.ERC721 || t.type === NativeTokenTypes.ERC1155
235
+ );
236
+
237
+ // Load all balances in parallel (individual errors handled per-token)
238
+ const [pointsResults, rewardsResults] = await Promise.all([
239
+ Promise.all(points.map(processPointsBalance)),
240
+ Promise.all(rewards.map(processRewardsBalance))
241
+ ]);
242
+
243
+ // Combine results, filtering out nulls (failed loads return null/empty)
244
+ const allBalances = [
245
+ ...pointsResults.filter(Boolean),
246
+ ...rewardsResults.flat()
247
+ ] as TokenBalanceWithToken[];
248
+
249
+ setTokenBalances(allBalances);
250
+
251
+ // Set error only if no balances loaded at all
252
+ if (allBalances.length === 0 && availableTokens.length > 0) {
253
+ setError('No token balances could be loaded');
254
+ }
255
+ } catch (err) {
256
+ const errorMessage = err instanceof Error ? err.message : 'Failed to load balances';
257
+ setError(errorMessage);
258
+ console.error('[useTokenBalances] Error loading balances:', err);
259
+ } finally {
260
+ setIsLoading(false);
261
+ }
262
+ }, [isAvailable, availableTokens, processPointsBalance, processRewardsBalance]);
263
+
264
+ // Auto-load balances when dependencies change
265
+ useEffect(() => {
266
+ if (autoLoad && isAvailable && availableTokens.length > 0) {
267
+ loadBalances();
268
+ }
269
+ }, [autoLoad, isAvailable, availableTokens.length, loadBalances]);
270
+
271
+ // Optional auto-refresh interval
272
+ useEffect(() => {
273
+ if (refreshInterval > 0 && isAvailable && availableTokens.length > 0) {
274
+ const intervalId = setInterval(() => {
275
+ loadBalances();
276
+ }, refreshInterval);
277
+
278
+ return () => clearInterval(intervalId);
279
+ }
280
+ }, [refreshInterval, isAvailable, availableTokens.length, loadBalances]);
281
+
282
+ return {
283
+ tokenBalances,
284
+ isLoading,
285
+ error,
286
+ refresh: loadBalances,
287
+ isAvailable,
288
+ userAccountAddress: accountAddress
289
+ };
290
+ }