@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,424 @@
1
+ import { useState, useCallback, useEffect, useRef } from "react";
2
+ import { useAccount } from "wagmi";
3
+ import { BridgeKit, BridgeChain } from "@circle-fin/bridge-kit";
4
+ import { createViemAdapterFromProvider } from "@circle-fin/adapter-viem-v2";
5
+ import type { BridgeChainConfig } from "./types";
6
+ import type { EIP1193Provider } from "viem";
7
+
8
+ // Maximum number of events to retain to prevent memory growth
9
+ const MAX_EVENTS = 100;
10
+
11
+ // Chain ID to BridgeChain enum mapping
12
+ // Bridge Kit uses specific chain identifiers from the BridgeChain enum
13
+ // NOTE: This mapping must be kept in sync with Circle's Bridge Kit SDK updates.
14
+ // When Circle adds new chains, add the corresponding mapping here.
15
+ const CHAIN_ID_TO_BRIDGE_CHAIN: Record<number, BridgeChain> = {
16
+ 1: BridgeChain.Ethereum,
17
+ 42161: BridgeChain.Arbitrum,
18
+ 43114: BridgeChain.Avalanche,
19
+ 8453: BridgeChain.Base,
20
+ 10: BridgeChain.Optimism,
21
+ 137: BridgeChain.Polygon,
22
+ 59144: BridgeChain.Linea,
23
+ 130: BridgeChain.Unichain,
24
+ 146: BridgeChain.Sonic,
25
+ 480: BridgeChain.World_Chain,
26
+ // Note: Monad (10200) not yet supported in Circle Bridge Kit
27
+ 1329: BridgeChain.Sei,
28
+ 50: BridgeChain.XDC,
29
+ 999: BridgeChain.HyperEVM,
30
+ 57073: BridgeChain.Ink,
31
+ 98866: BridgeChain.Plume,
32
+ 81224: BridgeChain.Codex,
33
+ };
34
+
35
+ // Type guard for Bridge Kit event with txHash
36
+ interface BridgeEventWithTxHash {
37
+ values?: {
38
+ txHash?: string;
39
+ };
40
+ }
41
+
42
+ function isBridgeEventWithTxHash(event: unknown): event is BridgeEventWithTxHash {
43
+ return (
44
+ typeof event === "object" &&
45
+ event !== null &&
46
+ ("values" in event ? typeof (event as BridgeEventWithTxHash).values === "object" : true)
47
+ );
48
+ }
49
+
50
+ function extractTxHash(event: unknown): `0x${string}` | undefined {
51
+ if (isBridgeEventWithTxHash(event) && event.values?.txHash) {
52
+ const hash = event.values.txHash;
53
+ if (typeof hash === "string" && hash.startsWith("0x")) {
54
+ return hash as `0x${string}`;
55
+ }
56
+ }
57
+ return undefined;
58
+ }
59
+
60
+ // Type guard for EIP-1193 provider
61
+ function isEIP1193Provider(provider: unknown): provider is EIP1193Provider {
62
+ return (
63
+ typeof provider === "object" &&
64
+ provider !== null &&
65
+ "request" in provider &&
66
+ typeof (provider as EIP1193Provider).request === "function"
67
+ );
68
+ }
69
+
70
+ export function getBridgeChain(chainId: number): BridgeChain | undefined {
71
+ return CHAIN_ID_TO_BRIDGE_CHAIN[chainId];
72
+ }
73
+
74
+ export function getChainName(chainId: number): string {
75
+ const bridgeChain = CHAIN_ID_TO_BRIDGE_CHAIN[chainId];
76
+ return bridgeChain || `Chain_${chainId}`;
77
+ }
78
+
79
+ export interface BridgeParams {
80
+ sourceChainConfig: BridgeChainConfig;
81
+ destChainConfig: BridgeChainConfig;
82
+ amount: string;
83
+ recipientAddress?: `0x${string}`;
84
+ }
85
+
86
+ export interface BridgeState {
87
+ status: "idle" | "loading" | "approving" | "burning" | "fetching-attestation" | "minting" | "success" | "error";
88
+ txHash?: `0x${string}`;
89
+ error?: Error;
90
+ events: BridgeEvent[];
91
+ }
92
+
93
+ export interface BridgeEvent {
94
+ type: string;
95
+ timestamp: number;
96
+ data?: unknown;
97
+ }
98
+
99
+ export interface UseBridgeResult {
100
+ bridge: (params: BridgeParams) => Promise<void>;
101
+ state: BridgeState;
102
+ reset: () => void;
103
+ }
104
+
105
+ /**
106
+ * Hook to execute USDC bridge transfers using Circle's Bridge Kit
107
+ */
108
+ export function useBridge(): UseBridgeResult {
109
+ const { connector, isConnected } = useAccount();
110
+ const [state, setState] = useState<BridgeState>({
111
+ status: "idle",
112
+ events: [],
113
+ });
114
+
115
+ // Track if component is mounted to avoid state updates after unmount
116
+ const isMountedRef = useRef(true);
117
+
118
+ // Track current bridge operation for cancellation
119
+ const currentBridgeRef = useRef<{
120
+ aborted: boolean;
121
+ kit: BridgeKit | null;
122
+ } | null>(null);
123
+
124
+ useEffect(() => {
125
+ isMountedRef.current = true;
126
+ return () => {
127
+ isMountedRef.current = false;
128
+ // Mark any ongoing operation as aborted
129
+ if (currentBridgeRef.current) {
130
+ currentBridgeRef.current.aborted = true;
131
+ }
132
+ };
133
+ }, []);
134
+
135
+ const addEvent = useCallback((type: string, data?: unknown) => {
136
+ if (!isMountedRef.current) return;
137
+ setState((prev) => {
138
+ const newEvents = [...prev.events, { type, timestamp: Date.now(), data }];
139
+ // Limit events to prevent memory growth
140
+ if (newEvents.length > MAX_EVENTS) {
141
+ newEvents.splice(0, newEvents.length - MAX_EVENTS);
142
+ }
143
+ return { ...prev, events: newEvents };
144
+ });
145
+ }, []);
146
+
147
+ const reset = useCallback(() => {
148
+ // Abort any ongoing operation
149
+ if (currentBridgeRef.current) {
150
+ currentBridgeRef.current.aborted = true;
151
+ }
152
+ currentBridgeRef.current = null;
153
+ setState({ status: "idle", events: [] });
154
+ }, []);
155
+
156
+ const bridge = useCallback(
157
+ async (params: BridgeParams) => {
158
+ const { sourceChainConfig, destChainConfig, amount, recipientAddress } = params;
159
+
160
+ // Abort any previous operation
161
+ if (currentBridgeRef.current) {
162
+ currentBridgeRef.current.aborted = true;
163
+ }
164
+
165
+ // Create new operation tracking
166
+ const operation = { aborted: false, kit: null as BridgeKit | null };
167
+ currentBridgeRef.current = operation;
168
+
169
+ if (!isConnected || !connector) {
170
+ setState({
171
+ status: "error",
172
+ error: new Error("Wallet not connected"),
173
+ events: [],
174
+ });
175
+ return;
176
+ }
177
+
178
+ setState({ status: "loading", events: [] });
179
+ addEvent("start", { amount, sourceChain: sourceChainConfig.chain.id, destChain: destChainConfig.chain.id });
180
+
181
+ // Cleanup function - will be assigned once handlers are set up
182
+ let cleanupListeners: (() => void) | null = null;
183
+
184
+ try {
185
+ // Get the EIP-1193 provider from the wagmi connector
186
+ const provider = await connector.getProvider();
187
+
188
+ if (!provider) {
189
+ throw new Error("Could not get wallet provider from connector");
190
+ }
191
+
192
+ // Validate provider is EIP-1193 compatible
193
+ if (!isEIP1193Provider(provider)) {
194
+ throw new Error("Wallet provider is not EIP-1193 compatible");
195
+ }
196
+
197
+ // Create adapter from the connected wallet's provider
198
+ const adapter = await createViemAdapterFromProvider({
199
+ provider,
200
+ });
201
+
202
+ // Check if aborted before continuing
203
+ if (operation.aborted) {
204
+ return;
205
+ }
206
+
207
+ // Initialize Bridge Kit
208
+ const kit = new BridgeKit();
209
+ operation.kit = kit;
210
+
211
+ // Event handlers with abort checks
212
+ const handleApprove = (event: unknown) => {
213
+ if (operation.aborted || !isMountedRef.current) return;
214
+ addEvent("approve", event);
215
+ setState((prev) => ({
216
+ ...prev,
217
+ status: "approving",
218
+ txHash: extractTxHash(event),
219
+ }));
220
+ };
221
+
222
+ const handleBurn = (event: unknown) => {
223
+ if (operation.aborted || !isMountedRef.current) return;
224
+ addEvent("burn", event);
225
+ setState((prev) => ({
226
+ ...prev,
227
+ status: "burning",
228
+ txHash: extractTxHash(event),
229
+ }));
230
+ };
231
+
232
+ const handleFetchAttestation = (event: unknown) => {
233
+ if (operation.aborted || !isMountedRef.current) return;
234
+ addEvent("fetchAttestation", event);
235
+ setState((prev) => ({
236
+ ...prev,
237
+ status: "fetching-attestation",
238
+ }));
239
+ };
240
+
241
+ const handleMint = (event: unknown) => {
242
+ if (operation.aborted || !isMountedRef.current) return;
243
+ addEvent("mint", event);
244
+ setState((prev) => ({
245
+ ...prev,
246
+ status: "minting",
247
+ txHash: extractTxHash(event),
248
+ }));
249
+ };
250
+
251
+ // Assign cleanup function now that handlers are created
252
+ cleanupListeners = () => {
253
+ try {
254
+ kit.off("approve", handleApprove);
255
+ kit.off("burn", handleBurn);
256
+ kit.off("fetchAttestation", handleFetchAttestation);
257
+ kit.off("mint", handleMint);
258
+ } catch {
259
+ // Ignore errors during cleanup (kit may not support off)
260
+ }
261
+ };
262
+
263
+ // Subscribe to events
264
+ kit.on("approve", handleApprove);
265
+ kit.on("burn", handleBurn);
266
+ kit.on("fetchAttestation", handleFetchAttestation);
267
+ kit.on("mint", handleMint);
268
+
269
+ // Get BridgeChain identifiers for Bridge Kit
270
+ const sourceBridgeChain = getBridgeChain(sourceChainConfig.chain.id);
271
+ const destBridgeChain = getBridgeChain(destChainConfig.chain.id);
272
+
273
+ if (!sourceBridgeChain) {
274
+ throw new Error(`Unsupported source chain: ${sourceChainConfig.chain.name} (${sourceChainConfig.chain.id})`);
275
+ }
276
+ if (!destBridgeChain) {
277
+ throw new Error(`Unsupported destination chain: ${destChainConfig.chain.name} (${destChainConfig.chain.id})`);
278
+ }
279
+
280
+ // Execute the bridge transfer
281
+ // According to Circle docs, recipientAddress goes inside the 'to' object
282
+ const result = await kit.bridge({
283
+ from: { adapter, chain: sourceBridgeChain },
284
+ to: recipientAddress
285
+ ? { adapter, chain: destBridgeChain, recipientAddress }
286
+ : { adapter, chain: destBridgeChain },
287
+ amount,
288
+ });
289
+
290
+ // Clean up event listeners after bridge completes
291
+ cleanupListeners();
292
+
293
+ // Check if aborted before processing result
294
+ if (operation.aborted || !isMountedRef.current) {
295
+ return;
296
+ }
297
+
298
+ addEvent("complete", result);
299
+ setState((prev) => ({
300
+ ...prev,
301
+ status: "success",
302
+ txHash: extractTxHash(result),
303
+ }));
304
+ } catch (error) {
305
+ // Clean up event listeners on error if they were set up
306
+ if (cleanupListeners) {
307
+ cleanupListeners();
308
+ }
309
+
310
+ // Don't update state if operation was aborted
311
+ if (operation.aborted || !isMountedRef.current) {
312
+ return;
313
+ }
314
+
315
+ addEvent("error", error);
316
+ setState((prev) => ({
317
+ ...prev,
318
+ status: "error",
319
+ error: error instanceof Error ? error : new Error("Bridge transfer failed"),
320
+ }));
321
+ throw error;
322
+ }
323
+ },
324
+ [isConnected, connector, addEvent]
325
+ );
326
+
327
+ return { bridge, state, reset };
328
+ }
329
+
330
+ export interface BridgeQuote {
331
+ estimatedGasFee: string;
332
+ bridgeFee: string;
333
+ totalFee: string;
334
+ estimatedTime: string;
335
+ expiresAt?: number;
336
+ }
337
+
338
+ /**
339
+ * Hook to get a quote for a bridge transfer.
340
+ *
341
+ * **Note:** This hook currently returns static CCTP standard estimates because
342
+ * Circle's `kit.estimate()` requires an adapter with an active wallet connection.
343
+ * For accurate gas estimates, the wallet will provide them during transaction signing.
344
+ *
345
+ * @param sourceChainId - Source chain ID
346
+ * @param destChainId - Destination chain ID
347
+ * @param amount - Amount to bridge (as string)
348
+ * @returns Quote with estimated fees and timing (static values)
349
+ *
350
+ * @example
351
+ * const { quote, isLoading, error } = useBridgeQuote(1, 8453, "100");
352
+ * // quote.estimatedTime -> "~15-20 minutes"
353
+ * // quote.bridgeFee -> "0-14 bps (FAST) / 0 (SLOW)"
354
+ */
355
+ export function useBridgeQuote(
356
+ sourceChainId: number | undefined,
357
+ destChainId: number | undefined,
358
+ amount: string
359
+ ) {
360
+ const [quote, setQuote] = useState<BridgeQuote | null>(null);
361
+ const [isLoading, setIsLoading] = useState(false);
362
+ const [error, setError] = useState<Error | null>(null);
363
+
364
+ useEffect(() => {
365
+ if (!sourceChainId || !destChainId || !amount || parseFloat(amount) <= 0) {
366
+ setQuote(null);
367
+ setError(null);
368
+ return;
369
+ }
370
+
371
+ const fetchQuote = async () => {
372
+ setIsLoading(true);
373
+ setError(null);
374
+
375
+ try {
376
+ const sourceBridgeChain = getBridgeChain(sourceChainId);
377
+ const destBridgeChain = getBridgeChain(destChainId);
378
+
379
+ // If chains are not supported, return basic estimate
380
+ if (!sourceBridgeChain || !destBridgeChain) {
381
+ setQuote({
382
+ estimatedGasFee: "Estimated by wallet",
383
+ bridgeFee: "0.00",
384
+ totalFee: "Gas only",
385
+ estimatedTime: "~15-20 minutes",
386
+ });
387
+ return;
388
+ }
389
+
390
+ // Note: kit.estimate() requires an adapter with wallet connection
391
+ // For pre-bridge quotes without wallet, we return CCTP standard estimates
392
+ // CCTP V2 FAST transfers: 1-14 bps fee, SLOW transfers: 0 bps
393
+ // Gas fees are estimated by the wallet during actual transaction
394
+ setQuote({
395
+ estimatedGasFee: "Estimated by wallet",
396
+ bridgeFee: "0-14 bps (FAST) / 0 (SLOW)",
397
+ totalFee: "Gas + protocol fee",
398
+ estimatedTime: "~15-20 minutes",
399
+ });
400
+ } catch (err) {
401
+ setError(err instanceof Error ? err : new Error("Failed to get quote"));
402
+ setQuote(null);
403
+ } finally {
404
+ setIsLoading(false);
405
+ }
406
+ };
407
+
408
+ // Track if this effect is still active
409
+ let isActive = true;
410
+
411
+ const debounceTimer = setTimeout(() => {
412
+ if (isActive) {
413
+ fetchQuote();
414
+ }
415
+ }, 500);
416
+
417
+ return () => {
418
+ isActive = false;
419
+ clearTimeout(debounceTimer);
420
+ };
421
+ }, [sourceChainId, destChainId, amount]);
422
+
423
+ return { quote, isLoading, error };
424
+ }
package/src/utils.ts ADDED
@@ -0,0 +1,239 @@
1
+ import { USDC_DECIMALS, MAX_USDC_AMOUNT, DEFAULT_LOCALE } from "./constants";
2
+ import { parseUnits, isAddress } from "viem";
3
+ import type { BridgeChainConfig } from "./types";
4
+
5
+ // Re-export amount constants for backwards compatibility
6
+ export { MAX_USDC_AMOUNT, MIN_USDC_AMOUNT } from "./constants";
7
+
8
+ /**
9
+ * Format a number for display with locale-aware formatting
10
+ *
11
+ * @param value - The number or string to format
12
+ * @param decimals - Number of decimal places (default: 2)
13
+ * @param locale - Optional locale string (e.g., 'en-US', 'de-DE'). Defaults to 'en-US' for consistent financial display.
14
+ * @returns Formatted number string
15
+ *
16
+ * @example
17
+ * formatNumber(1234.567) // "1,234.57"
18
+ * formatNumber(1234.567, 4) // "1,234.5670"
19
+ * formatNumber(1234.567, 2, 'de-DE') // "1.234,57"
20
+ */
21
+ export function formatNumber(
22
+ value: string | number,
23
+ decimals: number = 2,
24
+ locale: string = DEFAULT_LOCALE
25
+ ): string {
26
+ const num = typeof value === "string" ? parseFloat(value) : value;
27
+ if (isNaN(num)) return "0";
28
+ return num.toLocaleString(locale, {
29
+ minimumFractionDigits: decimals,
30
+ maximumFractionDigits: decimals,
31
+ });
32
+ }
33
+
34
+ /**
35
+ * Validate and sanitize an amount input string.
36
+ * Ensures the value is a valid positive decimal with max 6 decimal places.
37
+ *
38
+ * @param value - The input string to validate
39
+ * @returns Object with isValid flag and sanitized value
40
+ */
41
+ export function validateAmountInput(value: string): {
42
+ isValid: boolean;
43
+ sanitized: string;
44
+ error?: string;
45
+ } {
46
+ // Empty is valid (user clearing input)
47
+ if (value === "") {
48
+ return { isValid: true, sanitized: "" };
49
+ }
50
+
51
+ // Reject scientific notation
52
+ if (/[eE]/.test(value)) {
53
+ return { isValid: false, sanitized: "", error: "Scientific notation not allowed" };
54
+ }
55
+
56
+ // Reject negative signs
57
+ if (value.includes("-")) {
58
+ return { isValid: false, sanitized: "", error: "Negative values not allowed" };
59
+ }
60
+
61
+ // Allow partial input like "." or "0."
62
+ if (value === "." || value === "0.") {
63
+ return { isValid: true, sanitized: value };
64
+ }
65
+
66
+ // Check for valid decimal format
67
+ if (!/^[0-9]*\.?[0-9]*$/.test(value)) {
68
+ return { isValid: false, sanitized: "", error: "Invalid characters" };
69
+ }
70
+
71
+ // Check decimal places (max 6 for USDC)
72
+ const parts = value.split(".");
73
+ if (parts.length === 2 && parts[1].length > USDC_DECIMALS) {
74
+ return {
75
+ isValid: false,
76
+ sanitized: `${parts[0]}.${parts[1].slice(0, USDC_DECIMALS)}`,
77
+ error: `Maximum ${USDC_DECIMALS} decimal places`,
78
+ };
79
+ }
80
+
81
+ // Check max value
82
+ const num = parseFloat(value);
83
+ if (!isNaN(num) && num > parseFloat(MAX_USDC_AMOUNT)) {
84
+ return { isValid: false, sanitized: value, error: "Amount exceeds maximum" };
85
+ }
86
+
87
+ // Sanitize leading zeros (except for "0." case)
88
+ let sanitized = value;
89
+ if (sanitized.length > 1 && sanitized.startsWith("0") && sanitized[1] !== ".") {
90
+ sanitized = sanitized.replace(/^0+/, "") || "0";
91
+ }
92
+
93
+ return { isValid: true, sanitized };
94
+ }
95
+
96
+ /**
97
+ * Safely parse a USDC amount string to bigint
98
+ * Returns null if parsing fails
99
+ */
100
+ export function parseUSDCAmount(amount: string): bigint | null {
101
+ try {
102
+ if (!amount || parseFloat(amount) < 0) return null;
103
+ return parseUnits(amount, USDC_DECIMALS);
104
+ } catch {
105
+ return null;
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Check if a string is a valid positive number
111
+ */
112
+ export function isValidPositiveAmount(amount: string): boolean {
113
+ if (!amount) return false;
114
+ const num = parseFloat(amount);
115
+ return !isNaN(num) && num > 0;
116
+ }
117
+
118
+ /**
119
+ * Extract error message from unknown error type
120
+ */
121
+ export function getErrorMessage(error: unknown): string {
122
+ if (error instanceof Error) {
123
+ return error.message;
124
+ }
125
+ if (typeof error === "string") {
126
+ return error;
127
+ }
128
+ if (
129
+ error !== null &&
130
+ typeof error === "object" &&
131
+ "message" in error &&
132
+ typeof error.message === "string"
133
+ ) {
134
+ return error.message;
135
+ }
136
+ return "An unknown error occurred";
137
+ }
138
+
139
+ /**
140
+ * Validation result for chain config
141
+ */
142
+ export interface ChainConfigValidationResult {
143
+ isValid: boolean;
144
+ errors: string[];
145
+ }
146
+
147
+ /**
148
+ * Validate a single chain configuration.
149
+ * Checks that required fields are present and addresses are valid.
150
+ *
151
+ * @param config - The chain configuration to validate
152
+ * @returns Validation result with errors if any
153
+ */
154
+ export function validateChainConfig(
155
+ config: BridgeChainConfig
156
+ ): ChainConfigValidationResult {
157
+ const errors: string[] = [];
158
+
159
+ // Check required chain object
160
+ if (!config.chain) {
161
+ errors.push("Chain object is required");
162
+ return { isValid: false, errors };
163
+ }
164
+
165
+ // Check chain ID
166
+ if (typeof config.chain.id !== "number" || config.chain.id <= 0) {
167
+ errors.push(`Invalid chain ID: ${config.chain.id}`);
168
+ }
169
+
170
+ // Check chain name
171
+ if (!config.chain.name || typeof config.chain.name !== "string") {
172
+ errors.push("Chain name is required");
173
+ }
174
+
175
+ // Check USDC address
176
+ if (!config.usdcAddress) {
177
+ errors.push(`USDC address is required for chain ${config.chain.name || config.chain.id}`);
178
+ } else if (!isAddress(config.usdcAddress)) {
179
+ errors.push(`Invalid USDC address for chain ${config.chain.name}: ${config.usdcAddress}`);
180
+ }
181
+
182
+ // Check TokenMessenger address (optional but validate if provided)
183
+ if (config.tokenMessengerAddress && !isAddress(config.tokenMessengerAddress)) {
184
+ errors.push(
185
+ `Invalid TokenMessenger address for chain ${config.chain.name}: ${config.tokenMessengerAddress}`
186
+ );
187
+ }
188
+
189
+ return {
190
+ isValid: errors.length === 0,
191
+ errors,
192
+ };
193
+ }
194
+
195
+ /**
196
+ * Validate an array of chain configurations.
197
+ * Returns the first error found or success if all configs are valid.
198
+ *
199
+ * @param configs - Array of chain configurations to validate
200
+ * @returns Validation result with all errors
201
+ */
202
+ export function validateChainConfigs(
203
+ configs: BridgeChainConfig[]
204
+ ): ChainConfigValidationResult {
205
+ const allErrors: string[] = [];
206
+
207
+ if (!Array.isArray(configs)) {
208
+ return { isValid: false, errors: ["Chain configs must be an array"] };
209
+ }
210
+
211
+ if (configs.length === 0) {
212
+ return { isValid: false, errors: ["At least one chain configuration is required"] };
213
+ }
214
+
215
+ if (configs.length < 2) {
216
+ return { isValid: false, errors: ["At least two chains are required for bridging"] };
217
+ }
218
+
219
+ // Check for duplicate chain IDs
220
+ const chainIds = new Set<number>();
221
+ for (const config of configs) {
222
+ if (config.chain?.id) {
223
+ if (chainIds.has(config.chain.id)) {
224
+ allErrors.push(`Duplicate chain ID: ${config.chain.id}`);
225
+ }
226
+ chainIds.add(config.chain.id);
227
+ }
228
+
229
+ const result = validateChainConfig(config);
230
+ if (!result.isValid) {
231
+ allErrors.push(...result.errors);
232
+ }
233
+ }
234
+
235
+ return {
236
+ isValid: allErrors.length === 0,
237
+ errors: allErrors,
238
+ };
239
+ }