@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.
- package/README.md +272 -0
- package/dist/chunk-6JW37N76.mjs +211 -0
- package/dist/chunk-GJBJYQCU.mjs +218 -0
- package/dist/chunk-JHG7XCWW.mjs +218 -0
- package/dist/index.d.mts +765 -0
- package/dist/index.d.ts +765 -0
- package/dist/index.js +2356 -0
- package/dist/index.mjs +2295 -0
- package/dist/useBridge-LDEXWLEC.mjs +10 -0
- package/dist/useBridge-VGN5DMO6.mjs +10 -0
- package/dist/useBridge-WJA4XLLR.mjs +10 -0
- package/package.json +63 -0
- package/src/BridgeWidget.tsx +1133 -0
- package/src/__tests__/BridgeWidget.test.tsx +310 -0
- package/src/__tests__/chains.test.ts +131 -0
- package/src/__tests__/constants.test.ts +77 -0
- package/src/__tests__/hooks.test.ts +127 -0
- package/src/__tests__/icons.test.tsx +159 -0
- package/src/__tests__/setup.ts +8 -0
- package/src/__tests__/theme.test.ts +148 -0
- package/src/__tests__/useBridge.test.ts +133 -0
- package/src/__tests__/utils.test.ts +255 -0
- package/src/chains.ts +209 -0
- package/src/constants.ts +97 -0
- package/src/hooks.ts +349 -0
- package/src/icons.tsx +228 -0
- package/src/index.tsx +111 -0
- package/src/theme.ts +131 -0
- package/src/types.ts +160 -0
- package/src/useBridge.ts +424 -0
- package/src/utils.ts +239 -0
package/src/useBridge.ts
ADDED
|
@@ -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
|
+
}
|