diff --git a/package.json b/package.json index 62fa6e5875e..2391dfebc8f 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,8 @@ "uuid": "^11.1.0", "viem": "^2.23.10", "vitest": "^3.0.8", - "zustand": "^5.0.3" + "zustand": "^5.0.3", + "recharts": "^2.10.3" }, "devDependencies": { "@tailwindcss/postcss": "^4.0.9", diff --git a/toolbox/src/demo/ToolboxApp.tsx b/toolbox/src/demo/ToolboxApp.tsx index 6c6914ffeeb..22e01ba8b22 100644 --- a/toolbox/src/demo/ToolboxApp.tsx +++ b/toolbox/src/demo/ToolboxApp.tsx @@ -25,6 +25,12 @@ const componentGroups: Record = { label: "Switch Chain", component: lazy(() => import('./examples/Wallet/SwitchChain')), fileNames: ["toolbox/src/demo/examples/Wallet/SwitchChain.tsx"] + }, + { + id: 'addL1s', + label: "Add L1s", + component: lazy(() => import('./examples/Wallet/AddL1s')), + fileNames: [] } ], 'Create an L1': [ diff --git a/toolbox/src/demo/examples/Wallet/AddL1s.tsx b/toolbox/src/demo/examples/Wallet/AddL1s.tsx new file mode 100644 index 00000000000..04a988c7f6b --- /dev/null +++ b/toolbox/src/demo/examples/Wallet/AddL1s.tsx @@ -0,0 +1,534 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useErrorBoundary } from "react-error-boundary"; +import { Button, Select } from "../../ui"; +import { createWalletClient, custom, AddEthereumChainParameter } from 'viem'; +import { useExampleStore } from "../../utils/store"; +import { AvaCloudSDK } from "@avalabs/avacloud-sdk"; +import { ChainInfo } from "@avalabs/avacloud-sdk/models/components"; + +const sdk = new AvaCloudSDK(); + +export default function AddL1s() { + const { showBoundary } = useErrorBoundary(); + const { + walletChainId, + setWalletChainId + } = useExampleStore(); + + const [chains, setChains] = useState([]); + const [filteredChains, setFilteredChains] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [networkType, setNetworkType] = useState("mainnet"); + const [searchTerm, setSearchTerm] = useState(""); + const [addingChainId, setAddingChainId] = useState(null); + const [selectedChain, setSelectedChain] = useState(null); + const [copiedField, setCopiedField] = useState(null); + + useEffect(() => { + fetchChains(); + }, [networkType]); + + async function fetchChains() { + setIsLoading(true); + setError(null); + try { + // Use AvaCloudSDK to get chains + const result = await sdk.data.evm.chains.supportedChains({ + network: networkType as "mainnet" | "testnet" + }); + + const chainsData = result.chains; + + if (chainsData && chainsData.length > 0) { + setChains(chainsData); + } else { + setChains([]); + setError("No chains found for this network type"); + } + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to fetch chains"); + console.error("Error fetching chains:", err); + setChains([]); + } finally { + setIsLoading(false); + } + } + + useEffect(() => { + if (searchTerm.trim() === '') { + setFilteredChains(chains); + } else { + const term = searchTerm.toLowerCase(); + const filtered = chains.filter(chain => + chain.chainName.toLowerCase().includes(term) || + chain.networkToken.symbol.toLowerCase().includes(term) + ); + setFilteredChains(filtered); + } + }, [chains, searchTerm]); + + const handleSearchChange = (e: React.ChangeEvent) => { + setSearchTerm(e.target.value); + }; + + // Copy to clipboard function + const copyToClipboard = (text: string, field: string) => { + navigator.clipboard.writeText(text).then( + () => { + setCopiedField(field); + setTimeout(() => setCopiedField(null), 2000); + }, + (err) => { + console.error('Could not copy text: ', err); + } + ); + }; + + async function switchToChain(chainId: string) { + try { + if (!window.avalanche) { + throw new Error("Core wallet not detected. Please install Core wallet extension."); + } + + const walletClient = createWalletClient({ + transport: custom(window.avalanche!), + }); + + // Convert decimal chainId to hex + const chainIdHex = `0x${parseInt(chainId).toString(16)}`; + + await walletClient.request({ + id: "switch-chain", + method: "wallet_switchEthereumChain", + params: [{ chainId: chainIdHex }], + }); + + // After switching, update the chain ID in our state + const newChainId = await walletClient.getChainId(); + setWalletChainId(Number(newChainId)); + + return true; + } catch (error: any) { + // Check if the error is because the chain has not been added yet + if (error.code === 4902) { + // Chain not added yet, return false to let the caller know + return false; + } + throw error; + } + } + + async function handleAddToWallet(chain: ChainInfo) { + setAddingChainId(chain.chainId); + try { + if (!window.avalanche) { + throw new Error("Core wallet not detected. Please install Core wallet extension."); + } + + // First try to just switch to the chain, in case it's already added + const switched = await switchToChain(chain.chainId).catch(() => false); + + // If switching was successful, we're done + if (switched) { + // Close the modal if it was open + setSelectedChain(null); + return; + } + + // If we get here, the chain needs to be added first + const walletClient = createWalletClient({ + transport: custom(window.avalanche!), + }); + + // Convert decimal chainId to hex + const chainIdHex = `0x${parseInt(chain.chainId).toString(16)}`; + + const chainConfig: AddEthereumChainParameter = { + chainId: chainIdHex, + chainName: chain.chainName, + nativeCurrency: { + name: chain.networkToken.name, + symbol: chain.networkToken.symbol, + decimals: chain.networkToken.decimals, + }, + rpcUrls: [chain.rpcUrl], + }; + + // Add the chain to wallet + await walletClient.request({ + id: "1", + method: "wallet_addEthereumChain", + params: [{ ...chainConfig, isTestnet: chain.isTestnet } as unknown as AddEthereumChainParameter], + }); + + // Then request to switch to the newly added chain (this is already handled by most wallets after adding) + try { + await walletClient.request({ + id: "2", + method: "wallet_switchEthereumChain", + params: [{ chainId: chainIdHex }], + }); + } catch (switchError) { + console.warn("Error switching chain, but chain was added successfully:", switchError); + } + + // After adding chain, check if the current chain changed + const newChainId = await walletClient.getChainId(); + setWalletChainId(Number(newChainId)); + + // Close the modal if it was open + setSelectedChain(null); + } catch (error) { + showBoundary(error); + } finally { + setAddingChainId(null); + } + } + + const handleNetworkTypeChange = (value: string | number) => { + setNetworkType(value.toString()); + }; + + // Function to show chain details modal + const openChainDetails = (chain: ChainInfo) => { + setSelectedChain(chain); + }; + + // Function to close the chain details modal + const closeChainDetails = () => { + setSelectedChain(null); + }; + + // Utility function to abbreviate long strings + const abbreviateString = (str: string, startChars = 6, endChars = 4) => { + if (!str || str.length <= startChars + endChars + 3) return str; + return `${str.substring(0, startChars)}...${str.substring(str.length - endChars)}`; + }; + + // Updated utility function to truncate strings in the middle if needed + const truncateMiddle = (str: string, maxLength = 40) => { + if (!str || str.length <= maxLength) return str; + + // Calculate how many characters to show at the beginning and end + // For very long strings, we want to show more at both ends + const halfLength = Math.floor((maxLength - 3) / 2); // -3 for the ellipsis + + // For platform chain IDs and subnet IDs, we want to show more characters + if (str.length > 50) { + return `${str.substring(0, 25)}...${str.substring(str.length - 25)}`; + } + + return `${str.substring(0, maxLength - halfLength - 3)}...${str.substring(str.length - halfLength)}`; + }; + + // Component for copyable field + const CopyableField = ({ label, value, fieldId }: { label: string, value: string, fieldId: string }) => { + if (!value) return null; + + // Determine if the field should use special display based on field type + const isSpecialId = fieldId === 'platformChainId' || fieldId === 'subnetId'; + // URLs should be displayed in full + const isUrl = fieldId === 'rpcUrl' || fieldId === 'wsUrl' || label.includes('URL'); + // Token names should be handled differently + const isTokenName = fieldId === 'name' || label === 'Name'; + + // Select appropriate display method based on field type + let displayValue = value; + if (isUrl) { + // Display URLs in full without truncation + displayValue = value; + } else if (isSpecialId) { + displayValue = truncateMiddle(value, 60); + } else if (isTokenName && value.length > 20) { + displayValue = value.substring(0, 20) + '...'; + } else if (value.length > 25) { + displayValue = abbreviateString(value, 10, 6); + } + + return ( +
+
{label}:
+
+
copyToClipboard(value, fieldId)} + title={`Click to copy: ${value}`} + > +
+ {displayValue} +
+
+ {copiedField === fieldId ? ( + + + + ) : ( + + + + )} +
+
+
+
+ ); + }; + + // Non-copyable field component for consistent styling + const Field = ({ label, value }: { label: string, value: string | number | boolean }) => { + if (value === undefined || value === null) return null; + + // Handle long text values + const displayValue = typeof value === 'string' && value.length > 25 + ? value.substring(0, 25) + '...' + : value.toString(); + + return ( +
+
{label}:
+
+
+ {displayValue} +
+
+
+ ); + }; + + // URL field with link component + const UrlField = ({ label, url }: { label: string, url: string }) => { + if (!url) return null; + + // Show the full URL without abbreviation + const displayUrl = url; + + return ( +
+
{label}:
+ +
+ ); + }; + + return ( +
+

Add L1 Chains

+ +
+
+
+ + {searchTerm && ( + + )} +
+
+
+ + {error &&
{error}
} + + {isLoading ? ( +
Loading chains...
+ ) : ( +
+ {filteredChains.length > 0 ? ( + filteredChains.map((chain) => ( +
openChainDetails(chain)} + > +
+ {chain.chainLogoUri && ( + {`${chain.chainName} { + // Handle image loading errors + (e.target as HTMLImageElement).style.display = 'none'; + }} + /> + )} +
+
{chain.chainName}
+
{chain.networkToken.symbol}
+
+
+
{ + e.stopPropagation(); // Prevent opening the modal + handleAddToWallet(chain); + }} + > + +
+
+ )) + ) : ( +
+ {searchTerm ? + `No chains found matching "${searchTerm}"` : + "No chains found for this network type" + } +
+ )} +
+ )} +
+ + {/* Chain details modal */} + {selectedChain && ( +
+
+ {/* Modal header */} +
+
+ {selectedChain.chainLogoUri && ( + {`${selectedChain.chainName} { + (e.target as HTMLImageElement).style.display = 'none'; + }} + /> + )} +

{selectedChain.chainName}

+
+ +
+ + {/* Modal content - scrollable */} +
+ {/* Description section */} +
+

Description

+
+ {selectedChain.description || "No description available"} +
+
+ + {/* Chain Details */} +
+

Chain Details

+
+ + + + {selectedChain.platformChainId && ( + + )} + {selectedChain.subnetId && ( + + )} +
+
+ + {/* Network Token */} +
+

Network Token

+
+ + + +
+
+ + {/* URLs */} +
+

URLs

+
+ + {selectedChain.wsUrl && ( + + )} + {selectedChain.explorerUrl && ( + + )} +
+
+
+ + {/* Modal footer - sticky */} +
+ +
+
+
+ )} + + ); +} \ No newline at end of file