@naculus/connect-appkit-react 0.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.
Files changed (90) hide show
  1. package/LICENSE +21 -0
  2. package/api-extractor.json +21 -0
  3. package/dist/client.cjs +2 -0
  4. package/dist/client.cjs.map +1 -0
  5. package/dist/client.js +2 -0
  6. package/dist/client.js.map +1 -0
  7. package/dist/index.cjs +2 -0
  8. package/dist/index.cjs.map +1 -0
  9. package/dist/index.js +2 -0
  10. package/dist/index.js.map +1 -0
  11. package/package.json +39 -0
  12. package/src/chain-abstraction/index.ts +8 -0
  13. package/src/chain-abstraction/useCompareCosts.ts +137 -0
  14. package/src/chain-abstraction/useExecuteRoute.ts +129 -0
  15. package/src/chain-abstraction/useRouteQuote.ts +168 -0
  16. package/src/client.test.ts +242 -0
  17. package/src/client.ts +265 -0
  18. package/src/hooks/useAccount.test.ts +67 -0
  19. package/src/hooks/useAccount.ts +37 -0
  20. package/src/hooks/useBalance.test.ts +459 -0
  21. package/src/hooks/useBalance.ts +170 -0
  22. package/src/hooks/useChain.test.ts +160 -0
  23. package/src/hooks/useChain.ts +53 -0
  24. package/src/hooks/useConnect.test.ts +112 -0
  25. package/src/hooks/useConnect.ts +29 -0
  26. package/src/hooks/useDisconnect.test.ts +75 -0
  27. package/src/hooks/useDisconnect.ts +32 -0
  28. package/src/hooks/useERC20Allowance.ts +93 -0
  29. package/src/hooks/useERC20Approve.ts +220 -0
  30. package/src/hooks/useERC20Transfer.ts +134 -0
  31. package/src/hooks/useERC20TransferSimulation.ts +147 -0
  32. package/src/hooks/useEmbeddedWallet.test.ts +266 -0
  33. package/src/hooks/useEmbeddedWallet.ts +181 -0
  34. package/src/hooks/useLastTx.test.ts +117 -0
  35. package/src/hooks/useLastTx.ts +73 -0
  36. package/src/hooks/useLookupAddress.ts +131 -0
  37. package/src/hooks/useNotification.ts +308 -0
  38. package/src/hooks/useResolveName.ts +128 -0
  39. package/src/hooks/useSIWxLogin.test.ts +307 -0
  40. package/src/hooks/useSIWxLogin.ts +163 -0
  41. package/src/hooks/useSIWxSession.test.ts +481 -0
  42. package/src/hooks/useSIWxSession.ts +290 -0
  43. package/src/hooks/useSendCalls.ts +103 -0
  44. package/src/hooks/useSendTransaction.test.ts +175 -0
  45. package/src/hooks/useSendTransaction.ts +80 -0
  46. package/src/hooks/useSendUserOperation.ts +313 -0
  47. package/src/hooks/useSession.ts +96 -0
  48. package/src/hooks/useSessionKeys.ts +356 -0
  49. package/src/hooks/useSignInWithEthereum.test.ts +224 -0
  50. package/src/hooks/useSignInWithEthereum.ts +46 -0
  51. package/src/hooks/useSignInWithX.test.ts +210 -0
  52. package/src/hooks/useSignInWithX.ts +141 -0
  53. package/src/hooks/useSignMessage.test.ts +151 -0
  54. package/src/hooks/useSignMessage.ts +65 -0
  55. package/src/hooks/useSimulateTransfer.ts +165 -0
  56. package/src/hooks/useSiwxAuthSession.test.ts +456 -0
  57. package/src/hooks/useSiwxAuthSession.ts +163 -0
  58. package/src/hooks/useSmartAccount.ts +194 -0
  59. package/src/hooks/useSwitchChain.ts +68 -0
  60. package/src/hooks/useTokenBalance.test.ts +337 -0
  61. package/src/hooks/useTokenBalance.ts +210 -0
  62. package/src/hooks/useTransactionSimulation.ts +401 -0
  63. package/src/hooks/useTxHistory.test.ts +108 -0
  64. package/src/hooks/useTxHistory.ts +83 -0
  65. package/src/hooks/useTxMonitor.test.ts +127 -0
  66. package/src/hooks/useTxMonitor.ts +178 -0
  67. package/src/hooks/useUserOpStatus.ts +245 -0
  68. package/src/hooks/useValidateDestination.test.ts +96 -0
  69. package/src/hooks/useValidateDestination.ts +72 -0
  70. package/src/hooks/useViemClient.test.ts +155 -0
  71. package/src/hooks/useViemClient.ts +65 -0
  72. package/src/hooks/useWallet.test.ts +133 -0
  73. package/src/hooks/useWallet.ts +39 -0
  74. package/src/hooks/useWeb3ErrorHandler.ts +102 -0
  75. package/src/index.ts +186 -0
  76. package/src/provider/Web3ConnectProvider.test.tsx +178 -0
  77. package/src/provider/Web3ConnectProvider.tsx +530 -0
  78. package/src/token-list/TokenSelector.tsx +351 -0
  79. package/src/token-list/defaults.ts +39 -0
  80. package/src/token-list/index.ts +14 -0
  81. package/src/token-list/useTokenList.ts +119 -0
  82. package/src/token-list/useTokenSearch.ts +99 -0
  83. package/src/types/index.ts +99 -0
  84. package/src/utils/chains.ts +115 -0
  85. package/src/utils/errorMessages.ts +142 -0
  86. package/src/utils/i18n.ts +90 -0
  87. package/src/wc-generated/components.ts +377 -0
  88. package/test/setup.ts +17 -0
  89. package/test/test-utils.tsx +78 -0
  90. package/tsup.config.ts +18 -0
@@ -0,0 +1,351 @@
1
+ /**
2
+ * TokenSelector — chain-aware, searchable token selection component.
3
+ *
4
+ * Features:
5
+ * - Loads token list for the given chain
6
+ * - Search by symbol, name, or address
7
+ * - Displays token logo, symbol, name, and decimals
8
+ * - Calls onSelect when a token is chosen
9
+ */
10
+
11
+ import React, { useCallback } from "react";
12
+ import type { TokenListEntry } from "@naculus/connect-core";
13
+ import { useTokenList } from "./useTokenList";
14
+ import { useTokenSearch } from "./useTokenSearch";
15
+
16
+ export interface TokenSelectorProps {
17
+ /** Chain ID in CAIP-2 format (e.g. "eip155:1") */
18
+ chainId: string;
19
+ /** Called when a token is selected */
20
+ onSelect: (token: TokenListEntry) => void;
21
+ /** Called to close/dismiss the selector */
22
+ onClose: () => void;
23
+ /** Optional configuration */
24
+ options?: {
25
+ /** Show popular/recommended tokens (default: true) */
26
+ showRecommendations?: boolean;
27
+ /** Custom filter for tokens */
28
+ filter?: (token: TokenListEntry) => boolean;
29
+ };
30
+ }
31
+
32
+ /**
33
+ * TokenSelector — a dropdown/modal component for selecting tokens.
34
+ *
35
+ * @example
36
+ * <TokenSelector
37
+ * chainId="eip155:1"
38
+ * onSelect={(token) => setSelectedToken(token)}
39
+ * onClose={() => setOpen(false)}
40
+ * />
41
+ */
42
+ export function TokenSelector({
43
+ chainId,
44
+ onSelect,
45
+ onClose,
46
+ options,
47
+ }: TokenSelectorProps) {
48
+ const [searchQuery, setSearchQuery] = React.useState("");
49
+
50
+ const {
51
+ tokens: allTokens,
52
+ isLoading,
53
+ error,
54
+ } = useTokenList(chainId);
55
+
56
+ const { results, isSearching } = useTokenSearch(
57
+ searchQuery,
58
+ searchQuery ? chainId : undefined,
59
+ );
60
+
61
+ const showRecommendations = options?.showRecommendations ?? true;
62
+
63
+ // Display tokens: when searching, show results; otherwise show the full list
64
+ const displayTokens = React.useMemo(() => {
65
+ if (searchQuery.trim()) {
66
+ const combined = [...results.exact, ...results.fuzzy].map((m) => m.token);
67
+ return options?.filter
68
+ ? combined.filter(options.filter)
69
+ : combined;
70
+ }
71
+
72
+ const tokens = allTokens;
73
+ return options?.filter
74
+ ? tokens.filter(options.filter)
75
+ : tokens;
76
+ }, [searchQuery, results, allTokens, options?.filter]);
77
+
78
+ // Popular tokens for recommendation section
79
+ const popularTokens = React.useMemo(() => {
80
+ if (!showRecommendations || searchQuery.trim()) return [];
81
+ return allTokens.slice(0, 6); // Top 6
82
+ }, [allTokens, showRecommendations, searchQuery]);
83
+
84
+ const handleSelect = useCallback(
85
+ (token: TokenListEntry) => {
86
+ onSelect(token);
87
+ onClose();
88
+ },
89
+ [onSelect, onClose],
90
+ );
91
+
92
+ return (
93
+ <div
94
+ style={{
95
+ background: "hsl(var(--background))",
96
+ borderRadius: "12px",
97
+ padding: "16px",
98
+ color: "hsl(var(--foreground))",
99
+ minWidth: "320px",
100
+ maxWidth: "400px",
101
+ fontFamily: "system-ui, sans-serif",
102
+ }}
103
+ >
104
+ {/* Header */}
105
+ <div
106
+ style={{
107
+ display: "flex",
108
+ justifyContent: "space-between",
109
+ alignItems: "center",
110
+ marginBottom: "12px",
111
+ }}
112
+ >
113
+ <h3 style={{ margin: 0, fontSize: "16px", fontWeight: 600 }}>
114
+ Select Token
115
+ </h3>
116
+ <button
117
+ onClick={onClose}
118
+ style={{
119
+ background: "none",
120
+ border: "none",
121
+ color: "hsl(var(--muted-foreground))",
122
+ cursor: "pointer",
123
+ fontSize: "18px",
124
+ padding: "4px 8px",
125
+ }}
126
+ >
127
+ ✕
128
+ </button>
129
+ </div>
130
+
131
+ {/* Search Input */}
132
+ <input
133
+ type="text"
134
+ placeholder="Search token name or paste address"
135
+ value={searchQuery}
136
+ onChange={(e) => setSearchQuery(e.target.value)}
137
+ style={{
138
+ width: "100%",
139
+ padding: "10px 12px",
140
+ borderRadius: "8px",
141
+ border: "1px solid #333",
142
+ background: "hsl(var(--card))",
143
+ color: "hsl(var(--foreground))",
144
+ fontSize: "14px",
145
+ outline: "none",
146
+ boxSizing: "border-box",
147
+ marginBottom: "12px",
148
+ }}
149
+ autoFocus
150
+ />
151
+
152
+ {/* Loading / Error States */}
153
+ {isLoading && !isSearching && (
154
+ <div style={{ textAlign: "center", padding: "20px", color: "hsl(var(--muted-foreground))" }}>
155
+ Loading tokens...
156
+ </div>
157
+ )}
158
+ {error && (
159
+ <div
160
+ style={{
161
+ textAlign: "center",
162
+ padding: "12px",
163
+ color: "hsl(var(--destructive))",
164
+ fontSize: "13px",
165
+ }}
166
+ >
167
+ Error: {error.message}
168
+ </div>
169
+ )}
170
+
171
+ {/* Popular / Recommended Section */}
172
+ {showRecommendations && popularTokens.length > 0 && !searchQuery.trim() && (
173
+ <div style={{ marginBottom: "12px" }}>
174
+ <div
175
+ style={{
176
+ fontSize: "11px",
177
+ color: "hsl(var(--muted-foreground))",
178
+ textTransform: "uppercase",
179
+ letterSpacing: "0.5px",
180
+ marginBottom: "8px",
181
+ }}
182
+ >
183
+ Popular
184
+ </div>
185
+ <div style={{ display: "flex", gap: "6px", flexWrap: "wrap" }}>
186
+ {popularTokens.map((token) => (
187
+ <button
188
+ key={`${token.chainId}:${token.address}`}
189
+ onClick={() => handleSelect(token)}
190
+ style={{
191
+ background: "hsl(var(--card))",
192
+ border: "1px solid #333",
193
+ borderRadius: "20px",
194
+ padding: "6px 14px",
195
+ color: "hsl(var(--foreground))",
196
+ cursor: "pointer",
197
+ fontSize: "13px",
198
+ display: "flex",
199
+ alignItems: "center",
200
+ gap: "4px",
201
+ }}
202
+ >
203
+ {token.logoURI ? (
204
+ <img
205
+ src={token.logoURI}
206
+ alt=""
207
+ style={{ width: "16px", height: "16px", borderRadius: "50%" }}
208
+ onError={(e) => {
209
+ (e.target as HTMLImageElement).style.display = "none";
210
+ }}
211
+ />
212
+ ) : null}
213
+ {token.symbol}
214
+ </button>
215
+ ))}
216
+ </div>
217
+ </div>
218
+ )}
219
+
220
+ {/* Search Result Count */}
221
+ {searchQuery.trim() && !isLoading && (
222
+ <div
223
+ style={{
224
+ fontSize: "11px",
225
+ color: "hsl(var(--muted-foreground))",
226
+ marginBottom: "8px",
227
+ }}
228
+ >
229
+ {displayTokens.length > 0 ? (
230
+ <>
231
+ {results.exact.length} exact, {results.fuzzy.length} fuzzy
232
+ {results.hasMore ? " (more available)" : ""}
233
+ </>
234
+ ) : (
235
+ "No results found"
236
+ )}
237
+ </div>
238
+ )}
239
+
240
+ {/* Token List */}
241
+ <div
242
+ style={{
243
+ maxHeight: "320px",
244
+ overflowY: "auto",
245
+ display: "flex",
246
+ flexDirection: "column",
247
+ gap: "2px",
248
+ }}
249
+ >
250
+ {displayTokens.map((token) => (
251
+ <button
252
+ key={`${token.chainId}:${token.address}`}
253
+ onClick={() => handleSelect(token)}
254
+ style={{
255
+ display: "flex",
256
+ alignItems: "center",
257
+ gap: "10px",
258
+ padding: "10px 8px",
259
+ background: "transparent",
260
+ border: "none",
261
+ borderRadius: "8px",
262
+ color: "hsl(var(--foreground))",
263
+ cursor: "pointer",
264
+ textAlign: "left",
265
+ width: "100%",
266
+ fontSize: "14px",
267
+ }}
268
+ onMouseEnter={(e) => {
269
+ (e.target as HTMLElement).style.background = "hsl(var(--card))";
270
+ }}
271
+ onMouseLeave={(e) => {
272
+ (e.target as HTMLElement).style.background = "transparent";
273
+ }}
274
+ >
275
+ {/* Logo */}
276
+ <div
277
+ style={{
278
+ width: "32px",
279
+ height: "32px",
280
+ borderRadius: "50%",
281
+ background: "hsl(var(--border))",
282
+ display: "flex",
283
+ alignItems: "center",
284
+ justifyContent: "center",
285
+ fontSize: "12px",
286
+ fontWeight: 600,
287
+ flexShrink: 0,
288
+ overflow: "hidden",
289
+ }}
290
+ >
291
+ {token.logoURI ? (
292
+ <img
293
+ src={token.logoURI}
294
+ alt={token.symbol}
295
+ style={{ width: "100%", height: "100%" }}
296
+ onError={(e) => {
297
+ const img = e.target as HTMLImageElement;
298
+ img.style.display = "none";
299
+ (img.parentElement as HTMLElement).textContent =
300
+ token.symbol.slice(0, 2);
301
+ }}
302
+ />
303
+ ) : (
304
+ token.symbol.slice(0, 2)
305
+ )}
306
+ </div>
307
+
308
+ {/* Token info */}
309
+ <div style={{ flex: 1, minWidth: 0 }}>
310
+ <div
311
+ style={{
312
+ fontWeight: 600,
313
+ fontSize: "14px",
314
+ overflow: "hidden",
315
+ textOverflow: "ellipsis",
316
+ whiteSpace: "nowrap",
317
+ }}
318
+ >
319
+ {token.symbol}
320
+ </div>
321
+ <div
322
+ style={{
323
+ fontSize: "12px",
324
+ color: "hsl(var(--muted-foreground))",
325
+ overflow: "hidden",
326
+ textOverflow: "ellipsis",
327
+ whiteSpace: "nowrap",
328
+ }}
329
+ >
330
+ {token.name}
331
+ </div>
332
+ </div>
333
+
334
+ {/* Decimals & Chain Info */}
335
+ <div
336
+ style={{
337
+ fontSize: "11px",
338
+ color: "#666",
339
+ textAlign: "right",
340
+ flexShrink: 0,
341
+ }}
342
+ >
343
+ <div>{token.decimals} decimals</div>
344
+ <div>Chain {token.chainId}</div>
345
+ </div>
346
+ </button>
347
+ ))}
348
+ </div>
349
+ </div>
350
+ );
351
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Default token list sources for React hooks.
3
+ */
4
+
5
+ import type { TokenListSource } from "@naculus/connect-core";
6
+ import {
7
+ DEFAULT_SOURCES,
8
+ ETHEREUM_MAINNET_TOKENS,
9
+ POLYGON_TOKENS,
10
+ OPTIMISM_TOKENS,
11
+ ARBITRUM_TOKENS,
12
+ BASE_TOKENS,
13
+ } from "@naculus/connect-core";
14
+
15
+ /**
16
+ * Get default sources for useTokenList / useTokenSearch hooks.
17
+ * Includes built-in tokens and remote source URLs.
18
+ */
19
+ export function getDefaultSources(): TokenListSource[] {
20
+ return [
21
+ {
22
+ name: "built-in",
23
+ tokens: [
24
+ ...ETHEREUM_MAINNET_TOKENS,
25
+ ...POLYGON_TOKENS,
26
+ ...OPTIMISM_TOKENS,
27
+ ...ARBITRUM_TOKENS,
28
+ ...BASE_TOKENS,
29
+ ],
30
+ enabled: true,
31
+ },
32
+ {
33
+ name: "uniswap-default",
34
+ url: "https://tokens.uniswap.org",
35
+ enabled: true,
36
+ refreshInterval: 24 * 60 * 60 * 1000,
37
+ },
38
+ ];
39
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Token Discovery — React hooks and components.
3
+ *
4
+ * @see docs/features/token-discovery.md
5
+ */
6
+
7
+ export { useTokenList } from "./useTokenList";
8
+ export type { UseTokenListOptions, UseTokenListReturn } from "./useTokenList";
9
+
10
+ export { useTokenSearch } from "./useTokenSearch";
11
+ export type { UseTokenSearchReturn } from "./useTokenSearch";
12
+
13
+ export { TokenSelector } from "./TokenSelector";
14
+ export type { TokenSelectorProps } from "./TokenSelector";
@@ -0,0 +1,119 @@
1
+ /**
2
+ * useTokenList — React hook for loading token lists.
3
+ *
4
+ * Manages TokenListManager lifecycle and provides reactive
5
+ * token data scoped to a specific chain.
6
+ */
7
+
8
+ import { useState, useEffect, useCallback, useRef } from "react";
9
+ import { TokenListManager } from "@naculus/connect-core";
10
+ import type { TokenListEntry, TokenListSource, TokenListManagerConfig } from "@naculus/connect-core";
11
+
12
+ export interface UseTokenListOptions {
13
+ /** Auto-load on mount (default: true) */
14
+ autoLoad?: boolean;
15
+ /** Custom sources (overrides defaults) */
16
+ sources?: TokenListSource[];
17
+ }
18
+
19
+ export interface UseTokenListReturn {
20
+ /** Loaded tokens for the specified chain */
21
+ tokens: TokenListEntry[];
22
+ /** Whether the initial load is in progress */
23
+ isLoading: boolean;
24
+ /** Whether tokens have been loaded at least once */
25
+ isLoaded: boolean;
26
+ /** Last error, if any */
27
+ error: Error | null;
28
+ /** Force a full refresh from all sources */
29
+ refetch: () => Promise<void>;
30
+ }
31
+
32
+ /**
33
+ * React hook for accessing token lists for a specific chain.
34
+ *
35
+ * @example
36
+ * const { tokens, isLoading } = useTokenList("eip155:1");
37
+ * // tokens → [WETH, USDC, USDT, DAI, ...]
38
+ */
39
+ export function useTokenList(
40
+ chainId?: string,
41
+ options?: UseTokenListOptions,
42
+ ): UseTokenListReturn {
43
+ const managerRef = useRef<TokenListManager | null>(null);
44
+ const [tokens, setTokens] = useState<TokenListEntry[]>([]);
45
+ const [isLoading, setIsLoading] = useState(options?.autoLoad !== false);
46
+ const [isLoaded, setIsLoaded] = useState(false);
47
+ const [error, setError] = useState<Error | null>(null);
48
+
49
+ // Initialize manager once
50
+ if (!managerRef.current) {
51
+ const config: Partial<TokenListManagerConfig> = {};
52
+ if (options?.sources) {
53
+ config.sources = options.sources;
54
+ }
55
+ managerRef.current = new TokenListManager(config);
56
+ }
57
+
58
+ const load = useCallback(async () => {
59
+ const manager = managerRef.current;
60
+ if (!manager) return;
61
+
62
+ setIsLoading(true);
63
+ setError(null);
64
+
65
+ try {
66
+ await manager.load();
67
+ const numericChainId = chainId ? parseChainId(chainId) : undefined;
68
+ const loaded = manager.getTokens(
69
+ numericChainId !== undefined ? { chainId: numericChainId } : undefined,
70
+ );
71
+ setTokens(loaded);
72
+ setIsLoaded(true);
73
+ } catch (err) {
74
+ setError(err instanceof Error ? err : new Error(String(err)));
75
+ } finally {
76
+ setIsLoading(false);
77
+ }
78
+ }, [chainId]);
79
+
80
+ const refetch = useCallback(async () => {
81
+ const manager = managerRef.current;
82
+ if (!manager) return;
83
+
84
+ setIsLoading(true);
85
+ setError(null);
86
+
87
+ try {
88
+ await manager.refresh();
89
+ const numericChainId = chainId ? parseChainId(chainId) : undefined;
90
+ const loaded = manager.getTokens(
91
+ numericChainId !== undefined ? { chainId: numericChainId } : undefined,
92
+ );
93
+ setTokens(loaded);
94
+ setIsLoaded(true);
95
+ } catch (err) {
96
+ setError(err instanceof Error ? err : new Error(String(err)));
97
+ } finally {
98
+ setIsLoading(false);
99
+ }
100
+ }, [chainId]);
101
+
102
+ useEffect(() => {
103
+ if (options?.autoLoad !== false) {
104
+ load();
105
+ }
106
+ }, [load, options?.autoLoad]);
107
+
108
+ return { tokens, isLoading, isLoaded, error, refetch };
109
+ }
110
+
111
+ /**
112
+ * Parse a CAIP-2 chain ID like "eip155:1" into a numeric chain ID (1).
113
+ * Returns undefined for non-numeric chains.
114
+ */
115
+ function parseChainId(chainId: string): number | undefined {
116
+ const parts = chainId.split(":");
117
+ const numeric = parseInt(parts[parts.length - 1], 10);
118
+ return isNaN(numeric) ? undefined : numeric;
119
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * useTokenSearch — React hook for searching tokens.
3
+ *
4
+ * Debounced search over loaded token lists.
5
+ * Returns structured results (exact + fuzzy matches).
6
+ */
7
+
8
+ import { useState, useEffect, useRef, useMemo } from "react";
9
+ import { TokenListManager } from "@naculus/connect-core";
10
+ import type { TokenSearchResult, TokenListSource, TokenListManagerConfig } from "@naculus/connect-core";
11
+ import { getDefaultSources } from "./defaults";
12
+
13
+ const DEBOUNCE_MS = 300;
14
+
15
+ export interface UseTokenSearchReturn {
16
+ /** Structured search results */
17
+ results: TokenSearchResult;
18
+ /** Whether a search is currently running */
19
+ isSearching: boolean;
20
+ }
21
+
22
+ // Shared manager instance across hooks
23
+ let sharedManager: TokenListManager | null = null;
24
+
25
+ function getManager(): TokenListManager {
26
+ if (!sharedManager) {
27
+ sharedManager = new TokenListManager({
28
+ sources: getDefaultSources(),
29
+ });
30
+ // Start loading in background
31
+ sharedManager.load().catch(() => {});
32
+ }
33
+ return sharedManager;
34
+ }
35
+
36
+ /**
37
+ * React hook for searching tokens by symbol, name, or address.
38
+ *
39
+ * Debounced 300ms to avoid excessive re-renders during typing.
40
+ *
41
+ * @example
42
+ * const { results, isSearching } = useTokenSearch("USDC");
43
+ * // results.exact → [USDC on ETH]
44
+ * // results.fuzzy → [USDC.e on AVAX]
45
+ */
46
+ export function useTokenSearch(
47
+ query: string,
48
+ chainId?: string,
49
+ ): UseTokenSearchReturn {
50
+ const [results, setResults] = useState<TokenSearchResult>({
51
+ exact: [],
52
+ fuzzy: [],
53
+ hasMore: false,
54
+ });
55
+ const [isSearching, setIsSearching] = useState(false);
56
+ const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
57
+
58
+ const trimmedQuery = query.trim();
59
+
60
+ useEffect(() => {
61
+ if (!trimmedQuery) {
62
+ setResults({ exact: [], fuzzy: [], hasMore: false });
63
+ setIsSearching(false);
64
+ return;
65
+ }
66
+
67
+ // Debounce
68
+ if (debounceRef.current) {
69
+ clearTimeout(debounceRef.current);
70
+ }
71
+
72
+ setIsSearching(true);
73
+
74
+ debounceRef.current = setTimeout(() => {
75
+ const manager = getManager();
76
+ const numericChainId = chainId ? parseChainId(chainId) : undefined;
77
+ const searchResults = manager.search(trimmedQuery, {
78
+ chainId: numericChainId,
79
+ limit: 20,
80
+ });
81
+ setResults(searchResults);
82
+ setIsSearching(false);
83
+ }, DEBOUNCE_MS);
84
+
85
+ return () => {
86
+ if (debounceRef.current) {
87
+ clearTimeout(debounceRef.current);
88
+ }
89
+ };
90
+ }, [trimmedQuery, chainId]);
91
+
92
+ return { results, isSearching };
93
+ }
94
+
95
+ function parseChainId(chainId: string): number | undefined {
96
+ const parts = chainId.split(":");
97
+ const numeric = parseInt(parts[parts.length - 1], 10);
98
+ return isNaN(numeric) ? undefined : numeric;
99
+ }