@hongming-wang/usdc-bridge-widget 0.1.0

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.
@@ -0,0 +1,1133 @@
1
+ import React, { useState, useEffect, useCallback, useRef, useId, useMemo } from "react";
2
+ import {
3
+ useAccount,
4
+ useChainId,
5
+ useSwitchChain,
6
+ useWaitForTransactionReceipt,
7
+ useConnect,
8
+ } from "wagmi";
9
+ import type {
10
+ BridgeWidgetProps,
11
+ BridgeWidgetTheme,
12
+ BridgeChainConfig,
13
+ } from "./types";
14
+ import { useUSDCBalance, useUSDCAllowance, useAllUSDCBalances } from "./hooks";
15
+ import { useBridge } from "./useBridge";
16
+ import { DEFAULT_CHAIN_CONFIGS } from "./chains";
17
+ import { USDC_BRAND_COLOR } from "./constants";
18
+ import { formatNumber, getErrorMessage, validateAmountInput, validateChainConfigs } from "./utils";
19
+ import { mergeTheme } from "./theme";
20
+ import { ChevronDownIcon, SwapIcon } from "./icons";
21
+
22
+ // Chain icon with fallback
23
+ function ChainIcon({
24
+ chainConfig,
25
+ theme,
26
+ size = 24,
27
+ }: {
28
+ chainConfig: BridgeChainConfig;
29
+ theme: Required<BridgeWidgetTheme>;
30
+ size?: number;
31
+ }) {
32
+ const [hasError, setHasError] = useState(false);
33
+
34
+ if (!chainConfig.iconUrl || hasError) {
35
+ return (
36
+ <div
37
+ style={{
38
+ width: `${size}px`,
39
+ height: `${size}px`,
40
+ borderRadius: "50%",
41
+ display: "flex",
42
+ alignItems: "center",
43
+ justifyContent: "center",
44
+ fontSize: `${size * 0.5}px`,
45
+ fontWeight: "bold",
46
+ color: theme.textColor,
47
+ background: `linear-gradient(135deg, ${theme.primaryColor}, ${theme.secondaryColor})`,
48
+ }}
49
+ aria-hidden="true"
50
+ >
51
+ {chainConfig.chain.name.charAt(0)}
52
+ </div>
53
+ );
54
+ }
55
+
56
+ return (
57
+ <img
58
+ src={chainConfig.iconUrl}
59
+ alt=""
60
+ aria-hidden="true"
61
+ style={{ width: `${size}px`, height: `${size}px`, borderRadius: "50%" }}
62
+ onError={() => setHasError(true)}
63
+ />
64
+ );
65
+ }
66
+
67
+ // Small loading spinner for balance display
68
+ function BalanceSpinner({ size = 12 }: { size?: number }) {
69
+ return (
70
+ <svg
71
+ width={size}
72
+ height={size}
73
+ viewBox="0 0 24 24"
74
+ fill="none"
75
+ stroke="currentColor"
76
+ strokeWidth="2"
77
+ style={{
78
+ animation: "cc-balance-spin 1s linear infinite",
79
+ opacity: 0.6,
80
+ }}
81
+ aria-hidden="true"
82
+ >
83
+ <style>{`@keyframes cc-balance-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }`}</style>
84
+ <circle cx="12" cy="12" r="10" strokeOpacity="0.25" />
85
+ <path d="M12 2a10 10 0 0 1 10 10" strokeLinecap="round" />
86
+ </svg>
87
+ );
88
+ }
89
+
90
+ // Chain Selector Component
91
+ function ChainSelector({
92
+ label,
93
+ chains,
94
+ selectedChain,
95
+ onSelect,
96
+ excludeChainId,
97
+ theme,
98
+ id,
99
+ balances,
100
+ isLoadingBalances,
101
+ disabled,
102
+ }: {
103
+ label: string;
104
+ chains: BridgeChainConfig[];
105
+ selectedChain: BridgeChainConfig;
106
+ onSelect: (chain: BridgeChainConfig) => void;
107
+ excludeChainId?: number;
108
+ theme: Required<BridgeWidgetTheme>;
109
+ id: string;
110
+ balances?: Record<number, { balance: bigint; formatted: string }>;
111
+ isLoadingBalances?: boolean;
112
+ disabled?: boolean;
113
+ }) {
114
+ const [isOpen, setIsOpen] = useState(false);
115
+ const [focusedIndex, setFocusedIndex] = useState(-1);
116
+ const [typeAhead, setTypeAhead] = useState("");
117
+ const typeAheadTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
118
+ const buttonRef = useRef<HTMLButtonElement>(null);
119
+ const listRef = useRef<HTMLUListElement>(null);
120
+ const availableChains = chains.filter(
121
+ (c) => c.chain.id !== excludeChainId
122
+ );
123
+
124
+ // Clear type-ahead timer on unmount
125
+ useEffect(() => {
126
+ return () => {
127
+ if (typeAheadTimeoutRef.current) {
128
+ clearTimeout(typeAheadTimeoutRef.current);
129
+ }
130
+ };
131
+ }, []);
132
+
133
+ // Handle keyboard navigation on button
134
+ const handleButtonKeyDown = useCallback(
135
+ (e: React.KeyboardEvent) => {
136
+ if (e.key === "Escape") {
137
+ setIsOpen(false);
138
+ buttonRef.current?.focus();
139
+ } else if (e.key === "ArrowDown" || e.key === "Enter" || e.key === " ") {
140
+ if (!isOpen) {
141
+ e.preventDefault();
142
+ setIsOpen(true);
143
+ setFocusedIndex(0);
144
+ }
145
+ }
146
+ },
147
+ [isOpen]
148
+ );
149
+
150
+ // Handle keyboard navigation in listbox with type-ahead search
151
+ const handleListKeyDown = useCallback(
152
+ (e: React.KeyboardEvent) => {
153
+ if (e.key === "Escape") {
154
+ setIsOpen(false);
155
+ setTypeAhead("");
156
+ buttonRef.current?.focus();
157
+ } else if (e.key === "ArrowDown") {
158
+ e.preventDefault();
159
+ setFocusedIndex((prev) =>
160
+ prev < availableChains.length - 1 ? prev + 1 : 0
161
+ );
162
+ } else if (e.key === "ArrowUp") {
163
+ e.preventDefault();
164
+ setFocusedIndex((prev) =>
165
+ prev > 0 ? prev - 1 : availableChains.length - 1
166
+ );
167
+ } else if (e.key === "Enter" || e.key === " ") {
168
+ e.preventDefault();
169
+ if (focusedIndex >= 0 && focusedIndex < availableChains.length) {
170
+ onSelect(availableChains[focusedIndex]);
171
+ setIsOpen(false);
172
+ setTypeAhead("");
173
+ buttonRef.current?.focus();
174
+ }
175
+ } else if (e.key === "Home") {
176
+ e.preventDefault();
177
+ setFocusedIndex(0);
178
+ } else if (e.key === "End") {
179
+ e.preventDefault();
180
+ setFocusedIndex(availableChains.length - 1);
181
+ } else if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
182
+ // Type-ahead search
183
+ e.preventDefault();
184
+ const newTypeAhead = typeAhead + e.key.toLowerCase();
185
+ setTypeAhead(newTypeAhead);
186
+
187
+ // Clear previous timeout
188
+ if (typeAheadTimeoutRef.current) {
189
+ clearTimeout(typeAheadTimeoutRef.current);
190
+ }
191
+
192
+ // Reset type-ahead after 1 second of inactivity
193
+ typeAheadTimeoutRef.current = setTimeout(() => {
194
+ setTypeAhead("");
195
+ }, 1000);
196
+
197
+ // Find matching chain
198
+ const matchIndex = availableChains.findIndex((chain) =>
199
+ chain.chain.name.toLowerCase().startsWith(newTypeAhead)
200
+ );
201
+ if (matchIndex !== -1) {
202
+ setFocusedIndex(matchIndex);
203
+ }
204
+ }
205
+ },
206
+ [availableChains, focusedIndex, onSelect, typeAhead]
207
+ );
208
+
209
+ // Focus the list when opened
210
+ useEffect(() => {
211
+ if (isOpen && listRef.current) {
212
+ listRef.current.focus();
213
+ }
214
+ }, [isOpen]);
215
+
216
+ // Close on escape key globally when open
217
+ useEffect(() => {
218
+ if (!isOpen) return;
219
+
220
+ const handleGlobalKeyDown = (e: KeyboardEvent) => {
221
+ if (e.key === "Escape") {
222
+ setIsOpen(false);
223
+ setTypeAhead("");
224
+ buttonRef.current?.focus();
225
+ }
226
+ };
227
+
228
+ document.addEventListener("keydown", handleGlobalKeyDown);
229
+ return () => document.removeEventListener("keydown", handleGlobalKeyDown);
230
+ }, [isOpen]);
231
+
232
+ // Reset type-ahead when dropdown closes
233
+ useEffect(() => {
234
+ if (!isOpen) {
235
+ setTypeAhead("");
236
+ if (typeAheadTimeoutRef.current) {
237
+ clearTimeout(typeAheadTimeoutRef.current);
238
+ typeAheadTimeoutRef.current = null;
239
+ }
240
+ }
241
+ }, [isOpen]);
242
+
243
+ const buttonId = `${id}-button`;
244
+ const listboxId = `${id}-listbox`;
245
+
246
+ // Get selected chain balance
247
+ const selectedBalance = balances?.[selectedChain.chain.id];
248
+
249
+ return (
250
+ <div style={{ position: "relative", flex: 1 }}>
251
+ <label
252
+ id={`${id}-label`}
253
+ htmlFor={buttonId}
254
+ style={{
255
+ display: "block",
256
+ fontSize: "10px",
257
+ color: theme.mutedTextColor,
258
+ textTransform: "uppercase",
259
+ letterSpacing: "0.05em",
260
+ fontWeight: 500,
261
+ marginBottom: "4px",
262
+ }}
263
+ >
264
+ {label}
265
+ </label>
266
+ <button
267
+ ref={buttonRef}
268
+ id={buttonId}
269
+ onClick={() => !disabled && setIsOpen(!isOpen)}
270
+ onKeyDown={disabled ? undefined : handleButtonKeyDown}
271
+ disabled={disabled}
272
+ aria-haspopup="listbox"
273
+ aria-expanded={isOpen}
274
+ aria-labelledby={`${id}-label`}
275
+ aria-controls={isOpen ? listboxId : undefined}
276
+ aria-disabled={disabled}
277
+ style={{
278
+ width: "100%",
279
+ display: "flex",
280
+ alignItems: "center",
281
+ justifyContent: "space-between",
282
+ padding: "10px 12px",
283
+ borderRadius: `${theme.borderRadius}px`,
284
+ background: theme.cardBackgroundColor,
285
+ border: `1px solid ${theme.borderColor}`,
286
+ cursor: disabled ? "not-allowed" : "pointer",
287
+ opacity: disabled ? 0.6 : 1,
288
+ transition: "all 0.2s",
289
+ }}
290
+ >
291
+ <div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
292
+ <ChainIcon chainConfig={selectedChain} theme={theme} />
293
+ <div style={{ display: "flex", flexDirection: "column", alignItems: "flex-start" }}>
294
+ <span
295
+ style={{
296
+ fontSize: "14px",
297
+ fontWeight: 500,
298
+ color: theme.textColor,
299
+ }}
300
+ >
301
+ {selectedChain.chain.name}
302
+ </span>
303
+ {isLoadingBalances ? (
304
+ <span
305
+ style={{
306
+ fontSize: "10px",
307
+ color: theme.mutedTextColor,
308
+ display: "flex",
309
+ alignItems: "center",
310
+ gap: "4px",
311
+ }}
312
+ >
313
+ <BalanceSpinner size={10} /> Loading...
314
+ </span>
315
+ ) : selectedBalance ? (
316
+ <span
317
+ style={{
318
+ fontSize: "10px",
319
+ color: theme.mutedTextColor,
320
+ }}
321
+ >
322
+ {formatNumber(selectedBalance.formatted, 2)} USDC
323
+ </span>
324
+ ) : null}
325
+ </div>
326
+ </div>
327
+ <ChevronDownIcon
328
+ size={16}
329
+ color={theme.mutedTextColor}
330
+ style={{
331
+ transition: "transform 0.2s",
332
+ transform: isOpen ? "rotate(180deg)" : "rotate(0deg)",
333
+ }}
334
+ />
335
+ </button>
336
+
337
+ {isOpen && (
338
+ <>
339
+ <div
340
+ style={{
341
+ position: "fixed",
342
+ inset: 0,
343
+ zIndex: 10,
344
+ }}
345
+ onClick={() => setIsOpen(false)}
346
+ aria-hidden="true"
347
+ />
348
+ <ul
349
+ ref={listRef}
350
+ id={listboxId}
351
+ role="listbox"
352
+ aria-labelledby={`${id}-label`}
353
+ aria-activedescendant={
354
+ focusedIndex >= 0
355
+ ? `${id}-option-${availableChains[focusedIndex]?.chain.id}`
356
+ : undefined
357
+ }
358
+ tabIndex={0}
359
+ onKeyDown={handleListKeyDown}
360
+ style={{
361
+ position: "absolute",
362
+ zIndex: 20,
363
+ width: "100%",
364
+ marginTop: "8px",
365
+ borderRadius: `${theme.borderRadius}px`,
366
+ boxShadow: "0 10px 40px rgba(0,0,0,0.3)",
367
+ background: theme.cardBackgroundColor,
368
+ backdropFilter: "blur(10px)",
369
+ border: `1px solid ${theme.borderColor}`,
370
+ maxHeight: "300px",
371
+ overflowY: "auto",
372
+ overflowX: "hidden",
373
+ padding: 0,
374
+ margin: 0,
375
+ listStyle: "none",
376
+ outline: "none",
377
+ }}
378
+ >
379
+ {availableChains.map((chainConfig, index) => {
380
+ const chainBalance = balances?.[chainConfig.chain.id];
381
+ const isFocused = index === focusedIndex;
382
+ const isSelected = chainConfig.chain.id === selectedChain.chain.id;
383
+
384
+ return (
385
+ <li
386
+ key={chainConfig.chain.id}
387
+ id={`${id}-option-${chainConfig.chain.id}`}
388
+ role="option"
389
+ aria-selected={isSelected}
390
+ onClick={() => {
391
+ onSelect(chainConfig);
392
+ setIsOpen(false);
393
+ buttonRef.current?.focus();
394
+ }}
395
+ style={{
396
+ width: "100%",
397
+ display: "flex",
398
+ alignItems: "center",
399
+ gap: "8px",
400
+ padding: "10px 12px",
401
+ background: isFocused ? theme.hoverColor : "transparent",
402
+ border: "none",
403
+ cursor: "pointer",
404
+ transition: "background 0.2s",
405
+ }}
406
+ onMouseEnter={() => setFocusedIndex(index)}
407
+ >
408
+ <ChainIcon chainConfig={chainConfig} theme={theme} />
409
+ <div style={{ flex: 1, display: "flex", flexDirection: "column" }}>
410
+ <span
411
+ style={{
412
+ fontSize: "14px",
413
+ color: isSelected ? theme.textColor : theme.mutedTextColor,
414
+ fontWeight: isSelected ? 500 : 400,
415
+ }}
416
+ >
417
+ {chainConfig.chain.name}
418
+ </span>
419
+ {isLoadingBalances ? (
420
+ <span
421
+ style={{
422
+ fontSize: "10px",
423
+ color: theme.mutedTextColor,
424
+ display: "flex",
425
+ alignItems: "center",
426
+ gap: "4px",
427
+ }}
428
+ >
429
+ <BalanceSpinner size={10} />
430
+ </span>
431
+ ) : chainBalance ? (
432
+ <span
433
+ style={{
434
+ fontSize: "10px",
435
+ color: parseFloat(chainBalance.formatted) > 0 ? theme.successColor : theme.mutedTextColor,
436
+ }}
437
+ >
438
+ {formatNumber(chainBalance.formatted, 2)} USDC
439
+ </span>
440
+ ) : (
441
+ <span
442
+ style={{
443
+ fontSize: "10px",
444
+ color: theme.mutedTextColor,
445
+ }}
446
+ >
447
+ 0.00 USDC
448
+ </span>
449
+ )}
450
+ </div>
451
+ </li>
452
+ );
453
+ })}
454
+ </ul>
455
+ </>
456
+ )}
457
+ </div>
458
+ );
459
+ }
460
+
461
+ // Swap Button Component
462
+ function SwapButton({
463
+ onClick,
464
+ theme,
465
+ disabled,
466
+ }: {
467
+ onClick: () => void;
468
+ theme: Required<BridgeWidgetTheme>;
469
+ disabled?: boolean;
470
+ }) {
471
+ const [isHovered, setIsHovered] = useState(false);
472
+
473
+ return (
474
+ <button
475
+ onClick={onClick}
476
+ disabled={disabled}
477
+ aria-label="Swap source and destination chains"
478
+ style={{
479
+ padding: "8px",
480
+ borderRadius: `${theme.borderRadius}px`,
481
+ background: `${theme.primaryColor}15`,
482
+ border: `1px solid ${theme.primaryColor}40`,
483
+ cursor: disabled ? "not-allowed" : "pointer",
484
+ opacity: disabled ? 0.5 : 1,
485
+ transition: "all 0.2s",
486
+ display: "flex",
487
+ alignItems: "center",
488
+ justifyContent: "center",
489
+ alignSelf: "flex-end",
490
+ marginBottom: "4px",
491
+ transform: isHovered && !disabled ? "scale(1.1)" : "scale(1)",
492
+ }}
493
+ onMouseEnter={() => setIsHovered(true)}
494
+ onMouseLeave={() => setIsHovered(false)}
495
+ >
496
+ <SwapIcon size={20} color={theme.primaryColor} />
497
+ </button>
498
+ );
499
+ }
500
+
501
+ // Amount Input Component
502
+ function AmountInput({
503
+ value,
504
+ onChange,
505
+ balance,
506
+ onMaxClick,
507
+ theme,
508
+ id,
509
+ disabled,
510
+ }: {
511
+ value: string;
512
+ onChange: (value: string) => void;
513
+ balance: string;
514
+ onMaxClick: () => void;
515
+ theme: Required<BridgeWidgetTheme>;
516
+ id: string;
517
+ disabled?: boolean;
518
+ }) {
519
+ const inputId = `${id}-input`;
520
+ const labelId = `${id}-label`;
521
+
522
+ // Handle input change with comprehensive validation
523
+ const handleInputChange = useCallback(
524
+ (e: React.ChangeEvent<HTMLInputElement>) => {
525
+ if (disabled) return;
526
+ const result = validateAmountInput(e.target.value);
527
+ if (result.isValid) {
528
+ onChange(result.sanitized);
529
+ } else if (result.sanitized) {
530
+ // Use sanitized value (e.g., truncated decimals)
531
+ onChange(result.sanitized);
532
+ }
533
+ // Invalid input is rejected silently
534
+ },
535
+ [onChange, disabled]
536
+ );
537
+
538
+ // Prevent 'e' key from being entered
539
+ const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
540
+ if (e.key === "e" || e.key === "E" || e.key === "+" || e.key === "-") {
541
+ e.preventDefault();
542
+ }
543
+ }, []);
544
+
545
+ return (
546
+ <div>
547
+ <div
548
+ style={{
549
+ display: "flex",
550
+ justifyContent: "space-between",
551
+ marginBottom: "4px",
552
+ }}
553
+ >
554
+ <label
555
+ id={labelId}
556
+ htmlFor={inputId}
557
+ style={{
558
+ fontSize: "10px",
559
+ color: theme.mutedTextColor,
560
+ textTransform: "uppercase",
561
+ letterSpacing: "0.05em",
562
+ fontWeight: 500,
563
+ }}
564
+ >
565
+ Amount
566
+ </label>
567
+ <span
568
+ style={{ fontSize: "10px", color: theme.mutedTextColor }}
569
+ aria-live="polite"
570
+ >
571
+ Balance:{" "}
572
+ <span style={{ color: theme.textColor }}>
573
+ {formatNumber(balance)} USDC
574
+ </span>
575
+ </span>
576
+ </div>
577
+ <div
578
+ style={{
579
+ display: "flex",
580
+ alignItems: "center",
581
+ borderRadius: `${theme.borderRadius}px`,
582
+ overflow: "hidden",
583
+ background: theme.cardBackgroundColor,
584
+ border: `1px solid ${theme.borderColor}`,
585
+ opacity: disabled ? 0.6 : 1,
586
+ }}
587
+ >
588
+ <input
589
+ id={inputId}
590
+ type="text"
591
+ inputMode="decimal"
592
+ pattern="[0-9]*\.?[0-9]*"
593
+ value={value}
594
+ onChange={handleInputChange}
595
+ onKeyDown={handleKeyDown}
596
+ placeholder="0.00"
597
+ disabled={disabled}
598
+ aria-labelledby={labelId}
599
+ aria-describedby={`${id}-currency`}
600
+ aria-disabled={disabled}
601
+ style={{
602
+ flex: 1,
603
+ background: "transparent",
604
+ border: "none",
605
+ padding: "12px",
606
+ fontSize: "18px",
607
+ color: theme.textColor,
608
+ fontWeight: 500,
609
+ outline: "none",
610
+ minWidth: 0,
611
+ fontFamily: theme.fontFamily,
612
+ cursor: disabled ? "not-allowed" : "text",
613
+ }}
614
+ />
615
+ <div
616
+ style={{
617
+ display: "flex",
618
+ alignItems: "center",
619
+ gap: "8px",
620
+ paddingRight: "12px",
621
+ }}
622
+ >
623
+ <button
624
+ onClick={onMaxClick}
625
+ disabled={disabled}
626
+ aria-label="Set maximum amount"
627
+ style={{
628
+ padding: "4px 8px",
629
+ fontSize: "10px",
630
+ fontWeight: 600,
631
+ borderRadius: "4px",
632
+ background: `${theme.primaryColor}20`,
633
+ color: theme.primaryColor,
634
+ border: "none",
635
+ cursor: disabled ? "not-allowed" : "pointer",
636
+ opacity: disabled ? 0.5 : 1,
637
+ }}
638
+ >
639
+ MAX
640
+ </button>
641
+ <div
642
+ id={`${id}-currency`}
643
+ style={{ display: "flex", alignItems: "center", gap: "4px" }}
644
+ >
645
+ <div
646
+ style={{
647
+ width: "20px",
648
+ height: "20px",
649
+ borderRadius: "50%",
650
+ background: USDC_BRAND_COLOR,
651
+ display: "flex",
652
+ alignItems: "center",
653
+ justifyContent: "center",
654
+ }}
655
+ aria-hidden="true"
656
+ >
657
+ <span
658
+ style={{
659
+ fontSize: "10px",
660
+ fontWeight: "bold",
661
+ color: "#fff",
662
+ }}
663
+ >
664
+ $
665
+ </span>
666
+ </div>
667
+ <span
668
+ style={{
669
+ fontSize: "14px",
670
+ fontWeight: 500,
671
+ color: theme.textColor,
672
+ }}
673
+ >
674
+ USDC
675
+ </span>
676
+ </div>
677
+ </div>
678
+ </div>
679
+ </div>
680
+ );
681
+ }
682
+
683
+ // Main Bridge Widget Component
684
+ export function BridgeWidget({
685
+ chains = DEFAULT_CHAIN_CONFIGS,
686
+ defaultSourceChainId,
687
+ defaultDestinationChainId,
688
+ onBridgeStart,
689
+ onBridgeSuccess,
690
+ onBridgeError,
691
+ onConnectWallet,
692
+ theme: themeOverrides,
693
+ className,
694
+ style,
695
+ }: BridgeWidgetProps) {
696
+ const theme = mergeTheme(themeOverrides);
697
+ const { address, isConnected } = useAccount();
698
+ const currentChainId = useChainId();
699
+ const { switchChainAsync } = useSwitchChain();
700
+ const { connect, connectors } = useConnect();
701
+
702
+ // Validate chain configs on mount/change
703
+ const [configError, setConfigError] = useState<string | null>(null);
704
+ useEffect(() => {
705
+ const validation = validateChainConfigs(chains);
706
+ if (!validation.isValid) {
707
+ const errorMsg = validation.errors.join("; ");
708
+ console.error("[BridgeWidget] Invalid chain configuration:", errorMsg);
709
+ setConfigError(errorMsg);
710
+ } else {
711
+ setConfigError(null);
712
+ }
713
+ }, [chains]);
714
+
715
+ // Generate unique IDs for accessibility
716
+ const baseId = useId();
717
+ const sourceChainId = `${baseId}-source`;
718
+ const destChainId = `${baseId}-dest`;
719
+ const amountId = `${baseId}-amount`;
720
+
721
+ // Find initial chains
722
+ const getChainConfig = useCallback(
723
+ (chainId?: number) => {
724
+ if (!chainId) return chains[0];
725
+ return chains.find((c) => c.chain.id === chainId) || chains[0];
726
+ },
727
+ [chains]
728
+ );
729
+
730
+ const [sourceChainConfig, setSourceChainConfig] = useState<BridgeChainConfig>(
731
+ () => getChainConfig(defaultSourceChainId)
732
+ );
733
+ const [destChainConfig, setDestChainConfig] = useState<BridgeChainConfig>(
734
+ () =>
735
+ getChainConfig(defaultDestinationChainId) ||
736
+ chains.find((c) => c.chain.id !== sourceChainConfig.chain.id) ||
737
+ chains[1] ||
738
+ chains[0]
739
+ );
740
+ const [amount, setAmount] = useState("");
741
+ const [txHash, setTxHash] = useState<`0x${string}` | undefined>();
742
+ const [error, setError] = useState<string | null>(null);
743
+
744
+ // Hooks - fetch balances for all chains
745
+ const { balances: allBalances, isLoading: isLoadingAllBalances, refetch: refetchAllBalances } = useAllUSDCBalances(chains);
746
+ const { balanceFormatted, refetch: refetchBalance } = useUSDCBalance(
747
+ sourceChainConfig
748
+ );
749
+ const { needsApproval, approve, isApproving } = useUSDCAllowance(
750
+ sourceChainConfig
751
+ );
752
+
753
+ // Combined refetch for all balances
754
+ const refetchBalances = useCallback(() => {
755
+ refetchBalance();
756
+ refetchAllBalances();
757
+ }, [refetchBalance, refetchAllBalances]);
758
+
759
+ // Bridge hook
760
+ const { bridge: executeBridge, state: bridgeState, reset: resetBridge } = useBridge();
761
+
762
+ const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({
763
+ hash: txHash,
764
+ });
765
+
766
+ // Bridge operation states
767
+ const isBridging = bridgeState.status === "loading" ||
768
+ bridgeState.status === "approving" ||
769
+ bridgeState.status === "burning" ||
770
+ bridgeState.status === "fetching-attestation" ||
771
+ bridgeState.status === "minting";
772
+
773
+ // Disable inputs when any operation is pending to prevent race conditions
774
+ const isOperationPending = isBridging || isConfirming || isApproving;
775
+
776
+ // Store callbacks in refs to avoid useEffect dependency issues
777
+ const onBridgeSuccessRef = useRef(onBridgeSuccess);
778
+ onBridgeSuccessRef.current = onBridgeSuccess;
779
+ const onBridgeErrorRef = useRef(onBridgeError);
780
+ onBridgeErrorRef.current = onBridgeError;
781
+
782
+ // Check if we need to switch chains
783
+ const needsChainSwitch =
784
+ isConnected && currentChainId !== sourceChainConfig.chain.id;
785
+
786
+ // Swap chains
787
+ const handleSwapChains = useCallback(() => {
788
+ setSourceChainConfig((prev) => {
789
+ setDestChainConfig(prev);
790
+ return destChainConfig;
791
+ });
792
+ }, [destChainConfig]);
793
+
794
+ // Handle max click
795
+ const handleMaxClick = useCallback(() => {
796
+ setAmount(balanceFormatted);
797
+ }, [balanceFormatted]);
798
+
799
+ // Handle chain switch
800
+ const handleSwitchChain = useCallback(async () => {
801
+ try {
802
+ await switchChainAsync({ chainId: sourceChainConfig.chain.id });
803
+ } catch (err) {
804
+ setError(getErrorMessage(err));
805
+ }
806
+ }, [switchChainAsync, sourceChainConfig.chain.id]);
807
+
808
+ // Handle bridge
809
+ const handleBridge = useCallback(async () => {
810
+ if (!address || !amount || parseFloat(amount) <= 0) return;
811
+
812
+ setError(null);
813
+ resetBridge();
814
+
815
+ try {
816
+ // Notify that bridge is starting
817
+ onBridgeStart?.({
818
+ sourceChainId: sourceChainConfig.chain.id,
819
+ destChainId: destChainConfig.chain.id,
820
+ amount,
821
+ });
822
+
823
+ if (needsApproval(amount)) {
824
+ // Store pending bridge info for after approval
825
+ pendingBridgeRef.current = {
826
+ amount,
827
+ sourceChainConfig,
828
+ destChainConfig,
829
+ };
830
+ // First approve, then bridge will be triggered after approval
831
+ const approveTx = await approve(amount);
832
+ setTxHash(approveTx);
833
+ } else {
834
+ // Already approved, execute bridge directly
835
+ await executeBridge({
836
+ sourceChainConfig,
837
+ destChainConfig,
838
+ amount,
839
+ });
840
+ }
841
+ } catch (err: unknown) {
842
+ const errorMessage = getErrorMessage(err);
843
+ setError(errorMessage);
844
+ onBridgeErrorRef.current?.(
845
+ err instanceof Error ? err : new Error(errorMessage)
846
+ );
847
+ }
848
+ }, [
849
+ address,
850
+ amount,
851
+ needsApproval,
852
+ approve,
853
+ executeBridge,
854
+ resetBridge,
855
+ onBridgeStart,
856
+ sourceChainConfig,
857
+ destChainConfig,
858
+ ]);
859
+
860
+ // Store amount in ref for bridge execution after approval
861
+ const pendingBridgeRef = useRef<{
862
+ amount: string;
863
+ sourceChainConfig: BridgeChainConfig;
864
+ destChainConfig: BridgeChainConfig;
865
+ } | null>(null);
866
+
867
+ // After approval success, execute the bridge
868
+ useEffect(() => {
869
+ if (isSuccess && txHash && pendingBridgeRef.current) {
870
+ const { amount: pendingAmount, sourceChainConfig: pendingSource, destChainConfig: pendingDest } = pendingBridgeRef.current;
871
+ pendingBridgeRef.current = null;
872
+ setTxHash(undefined);
873
+
874
+ // Execute bridge after approval
875
+ void executeBridge({
876
+ sourceChainConfig: pendingSource,
877
+ destChainConfig: pendingDest,
878
+ amount: pendingAmount,
879
+ }).catch((err) => {
880
+ setError(getErrorMessage(err));
881
+ });
882
+ }
883
+ }, [isSuccess, txHash, executeBridge]);
884
+
885
+ // Handle bridge state changes
886
+ useEffect(() => {
887
+ if (bridgeState.status === "success") {
888
+ const currentAmount = amount;
889
+ const currentSourceChainId = sourceChainConfig.chain.id;
890
+ const currentDestChainId = destChainConfig.chain.id;
891
+ const currentTxHash = bridgeState.txHash;
892
+
893
+ setAmount("");
894
+ refetchBalances();
895
+
896
+ if (currentTxHash) {
897
+ onBridgeSuccessRef.current?.({
898
+ sourceChainId: currentSourceChainId,
899
+ destChainId: currentDestChainId,
900
+ amount: currentAmount,
901
+ txHash: currentTxHash,
902
+ });
903
+ }
904
+ } else if (bridgeState.status === "error" && bridgeState.error) {
905
+ setError(bridgeState.error.message);
906
+ }
907
+ }, [
908
+ bridgeState.status,
909
+ bridgeState.txHash,
910
+ bridgeState.error,
911
+ refetchBalances,
912
+ amount,
913
+ sourceChainConfig.chain.id,
914
+ destChainConfig.chain.id,
915
+ ]);
916
+
917
+ // Computed disabled state
918
+ const isButtonDisabled =
919
+ !isConnected ||
920
+ needsChainSwitch ||
921
+ !amount ||
922
+ parseFloat(amount) <= 0 ||
923
+ parseFloat(amount) > parseFloat(balanceFormatted) ||
924
+ isConfirming ||
925
+ isApproving ||
926
+ isBridging;
927
+
928
+ const isButtonActuallyDisabled =
929
+ isButtonDisabled && !needsChainSwitch && isConnected;
930
+
931
+ const getButtonText = useCallback(() => {
932
+ if (!isConnected) return "Connect Wallet";
933
+ if (needsChainSwitch) return `Switch to ${sourceChainConfig.chain.name}`;
934
+
935
+ // Bridge states
936
+ if (bridgeState.status === "loading") return "Preparing Bridge...";
937
+ if (bridgeState.status === "approving") return "Approving...";
938
+ if (bridgeState.status === "burning") return "Burning USDC...";
939
+ if (bridgeState.status === "fetching-attestation") return "Fetching Attestation...";
940
+ if (bridgeState.status === "minting") return "Minting on Destination...";
941
+
942
+ // Approval states
943
+ if (isConfirming || isApproving) {
944
+ return "Approving...";
945
+ }
946
+
947
+ if (!amount || parseFloat(amount) <= 0) return "Enter Amount";
948
+ if (parseFloat(amount) > parseFloat(balanceFormatted)) {
949
+ return "Insufficient Balance";
950
+ }
951
+ if (needsApproval(amount)) return "Approve & Bridge USDC";
952
+ return "Bridge USDC";
953
+ }, [
954
+ isConnected,
955
+ needsChainSwitch,
956
+ sourceChainConfig.chain.name,
957
+ bridgeState.status,
958
+ isConfirming,
959
+ isApproving,
960
+ amount,
961
+ balanceFormatted,
962
+ needsApproval,
963
+ ]);
964
+
965
+ const handleButtonClick = useCallback(() => {
966
+ if (!isConnected) {
967
+ if (onConnectWallet) {
968
+ onConnectWallet();
969
+ } else if (connectors.length > 0) {
970
+ // Use the first injected connector if available, otherwise first connector
971
+ const injectedConnector = connectors.find(
972
+ (c) => c.type === "injected"
973
+ );
974
+ connect({ connector: injectedConnector || connectors[0] });
975
+ }
976
+ return;
977
+ }
978
+ if (needsChainSwitch) {
979
+ handleSwitchChain();
980
+ return;
981
+ }
982
+ handleBridge();
983
+ }, [
984
+ isConnected,
985
+ onConnectWallet,
986
+ connectors,
987
+ connect,
988
+ needsChainSwitch,
989
+ handleSwitchChain,
990
+ handleBridge,
991
+ ]);
992
+
993
+ // Memoize button styles to avoid recalculation on every render
994
+ const buttonStyles = useMemo(
995
+ () => ({
996
+ width: "100%",
997
+ padding: "14px",
998
+ borderRadius: `${theme.borderRadius}px`,
999
+ fontSize: "14px",
1000
+ fontWeight: 600,
1001
+ border: "none",
1002
+ cursor: isButtonActuallyDisabled ? "not-allowed" : "pointer",
1003
+ transition: "all 0.2s",
1004
+ color: isButtonActuallyDisabled ? theme.mutedTextColor : theme.textColor,
1005
+ background: isButtonActuallyDisabled
1006
+ ? "rgba(255,255,255,0.1)"
1007
+ : `linear-gradient(135deg, ${theme.primaryColor} 0%, ${theme.secondaryColor} 100%)`,
1008
+ boxShadow: isButtonActuallyDisabled
1009
+ ? "none"
1010
+ : `0 4px 14px ${theme.primaryColor}60, inset 0 1px 0 rgba(255,255,255,0.2)`,
1011
+ }),
1012
+ [
1013
+ theme.borderRadius,
1014
+ theme.mutedTextColor,
1015
+ theme.textColor,
1016
+ theme.primaryColor,
1017
+ theme.secondaryColor,
1018
+ isButtonActuallyDisabled,
1019
+ ]
1020
+ );
1021
+
1022
+ return (
1023
+ <div
1024
+ className={className}
1025
+ role="region"
1026
+ aria-label="USDC Bridge Widget"
1027
+ style={{
1028
+ fontFamily: theme.fontFamily,
1029
+ maxWidth: "480px",
1030
+ width: "100%",
1031
+ borderRadius: `${theme.borderRadius}px`,
1032
+ padding: "16px",
1033
+ background: theme.backgroundColor,
1034
+ border: `1px solid ${theme.borderColor}`,
1035
+ boxShadow: "0 4px 24px rgba(0,0,0,0.3)",
1036
+ ...style,
1037
+ }}
1038
+ >
1039
+ {/* Chain Selectors */}
1040
+ <div
1041
+ style={{
1042
+ display: "flex",
1043
+ alignItems: "flex-end",
1044
+ gap: "12px",
1045
+ marginBottom: "16px",
1046
+ }}
1047
+ >
1048
+ <ChainSelector
1049
+ id={sourceChainId}
1050
+ label="From"
1051
+ chains={chains}
1052
+ selectedChain={sourceChainConfig}
1053
+ onSelect={setSourceChainConfig}
1054
+ excludeChainId={destChainConfig.chain.id}
1055
+ theme={theme}
1056
+ balances={allBalances}
1057
+ isLoadingBalances={isLoadingAllBalances}
1058
+ disabled={isOperationPending}
1059
+ />
1060
+ <SwapButton onClick={handleSwapChains} theme={theme} disabled={isOperationPending} />
1061
+ <ChainSelector
1062
+ id={destChainId}
1063
+ label="To"
1064
+ chains={chains}
1065
+ selectedChain={destChainConfig}
1066
+ onSelect={setDestChainConfig}
1067
+ excludeChainId={sourceChainConfig.chain.id}
1068
+ theme={theme}
1069
+ balances={allBalances}
1070
+ isLoadingBalances={isLoadingAllBalances}
1071
+ disabled={isOperationPending}
1072
+ />
1073
+ </div>
1074
+
1075
+ {/* Amount Input */}
1076
+ <div style={{ marginBottom: "16px" }}>
1077
+ <AmountInput
1078
+ id={amountId}
1079
+ value={amount}
1080
+ onChange={setAmount}
1081
+ balance={balanceFormatted}
1082
+ onMaxClick={handleMaxClick}
1083
+ theme={theme}
1084
+ disabled={isOperationPending}
1085
+ />
1086
+ </div>
1087
+
1088
+ {/* Config Error */}
1089
+ {configError && (
1090
+ <div
1091
+ role="alert"
1092
+ style={{
1093
+ fontSize: "12px",
1094
+ color: theme.errorColor,
1095
+ background: `${theme.errorColor}15`,
1096
+ padding: "8px 12px",
1097
+ borderRadius: `${theme.borderRadius}px`,
1098
+ marginBottom: "16px",
1099
+ }}
1100
+ >
1101
+ Configuration Error: {configError}
1102
+ </div>
1103
+ )}
1104
+
1105
+ {/* Error */}
1106
+ {error && !configError && (
1107
+ <div
1108
+ role="alert"
1109
+ style={{
1110
+ fontSize: "12px",
1111
+ color: theme.errorColor,
1112
+ background: `${theme.errorColor}15`,
1113
+ padding: "8px 12px",
1114
+ borderRadius: `${theme.borderRadius}px`,
1115
+ marginBottom: "16px",
1116
+ }}
1117
+ >
1118
+ {error}
1119
+ </div>
1120
+ )}
1121
+
1122
+ {/* Action Button */}
1123
+ <button
1124
+ onClick={handleButtonClick}
1125
+ disabled={isButtonActuallyDisabled}
1126
+ aria-busy={isConfirming || isApproving || isBridging}
1127
+ style={buttonStyles}
1128
+ >
1129
+ {getButtonText()}
1130
+ </button>
1131
+ </div>
1132
+ );
1133
+ }