/** * @file BridgeButtons.tsx * @notice Custom bridge UI with Wrap, Approve, and Bridge buttons using thirdweb */ import { useContract, useContractWrite, useContractRead, useAddress, useBalance, } from '@thirdweb-dev/react'; import { useState, useEffect } from 'react'; import { ethers } from 'ethers'; import toast from 'react-hot-toast'; import { CONTRACTS, CHAIN_SELECTORS, BRIDGE_ABI, WETH9_ABI, ERC20_ABI, CCIP_DESTINATIONS, } from '../../config/bridge'; import CopyButton from '../ui/CopyButton'; import ConfirmationModal from '../ui/ConfirmationModal'; import Tooltip from '../ui/Tooltip'; import LoadingSkeleton from '../ui/LoadingSkeleton'; import TokenIcon from '../ui/TokenIcon'; interface BridgeButtonsProps { destinationChainSelector?: string; // Defaults to Ethereum Mainnet recipientAddress?: string; // Defaults to connected wallet } function getErrorMessage(error: unknown): string { if (typeof error === 'object' && error !== null) { const maybeMessage = 'message' in error ? error.message : undefined const maybeReason = 'reason' in error ? error.reason : undefined if (typeof maybeMessage === 'string' && maybeMessage.length > 0) return maybeMessage if (typeof maybeReason === 'string' && maybeReason.length > 0) return maybeReason } return 'Unknown error' } /** Fetches CCIP fee only when amount > 0 (bridge reverts on zero). Reports result to parent via onFee. */ function CalculateFeeFetcher({ bridgeContract, destinationChainSelector, amountWei, onFee, }: { bridgeContract: NonNullable['contract']>; destinationChainSelector: string; amountWei: ethers.BigNumber; onFee: (value: ethers.BigNumber | null) => void; }) { const { data } = useContractRead(bridgeContract, 'calculateFee', [ destinationChainSelector, amountWei, ]); useEffect(() => { if (data != null) onFee(ethers.BigNumber.from(data.toString())); return () => {}; }, [data, onFee]); return null; } /** Inner bridge form: only mounted when address is set so balance/allowance are never called with zero address. */ function BridgeButtonsConnected({ address, destinationChainSelector = CHAIN_SELECTORS.ETHEREUM_MAINNET, recipientAddress, }: BridgeButtonsProps & { address: string }) { const [amount, setAmount] = useState(''); const [recipient, setRecipient] = useState(recipientAddress || address || ''); const [isWrapping, setIsWrapping] = useState(false); const [isApproving, setIsApproving] = useState(false); const [isBridging, setIsBridging] = useState(false); const [showWrapModal, setShowWrapModal] = useState(false); const [showApproveModal, setShowApproveModal] = useState(false); const [showBridgeModal, setShowBridgeModal] = useState(false); const [amountError, setAmountError] = useState(''); const [recipientError, setRecipientError] = useState(''); const [ccipFee, setCcipFee] = useState(null); const { contract: weth9Contract } = useContract(CONTRACTS.WETH9, WETH9_ABI); const { contract: bridgeContract } = useContract(CONTRACTS.WETH9_BRIDGE, BRIDGE_ABI); const { contract: linkContract } = useContract(CONTRACTS.LINK_TOKEN, ERC20_ABI); const { data: ethBalance, refetch: refetchEthBalance } = useBalance(); const { data: weth9Balance, refetch: refetchWeth9Balance } = useContractRead( weth9Contract, 'balanceOf', weth9Contract ? [address] : undefined ); const { data: linkBalance, refetch: refetchLinkBalance } = useContractRead( linkContract, 'balanceOf', linkContract ? [address] : undefined ); const { data: weth9Allowance, refetch: refetchWeth9Allowance } = useContractRead( weth9Contract, 'allowance', weth9Contract && bridgeContract ? [address, CONTRACTS.WETH9_BRIDGE] : undefined ); const { data: linkAllowance, refetch: refetchLinkAllowance } = useContractRead( linkContract, 'allowance', linkContract && bridgeContract ? [address, CONTRACTS.WETH9_BRIDGE] : undefined ); const amountWei = amount && !isNaN(parseFloat(amount)) && parseFloat(amount) > 0 ? ethers.utils.parseEther(amount) : ethers.BigNumber.from(0); useEffect(() => { if (!recipientAddress) setRecipient((r) => r || address); }, [address, recipientAddress]); useEffect(() => { if (!amount) { setAmountError(''); return; } const numAmount = parseFloat(amount); if (isNaN(numAmount) || numAmount <= 0) { setAmountError('Amount must be greater than 0'); } else if (ethBalance && numAmount > parseFloat(ethBalance.displayValue)) { setAmountError('Insufficient ETH balance'); } else { setAmountError(''); } }, [amount, ethBalance]); useEffect(() => { if (!recipient) { setRecipientError(''); return; } setRecipientError(ethers.utils.isAddress(recipient) ? '' : 'Invalid Ethereum address'); }, [recipient]); useEffect(() => { if (!amountWei.gt(0)) setCcipFee(null); }, [amountWei]); // Write operations const { mutateAsync: wrapETH } = useContractWrite(weth9Contract, 'deposit'); const { mutateAsync: approveWETH9 } = useContractWrite(weth9Contract, 'approve'); const { mutateAsync: approveLINK } = useContractWrite(linkContract, 'approve'); const { mutateAsync: sendCrossChain } = useContractWrite(bridgeContract, 'sendCrossChain'); const handleRefreshBalances = async () => { try { await Promise.all([ refetchEthBalance().catch(() => {}), refetchWeth9Balance().catch(() => {}), refetchLinkBalance().catch(() => {}), refetchWeth9Allowance().catch(() => {}), refetchLinkAllowance().catch(() => {}), ]); toast.success('Balances refreshed'); } catch (error) { // Silently handle errors - some refetches may fail if contracts aren't ready toast.success('Balances refreshed'); } }; const handleWrap = async () => { if (!address) { toast.error('Please connect your wallet'); return; } if (!amount || parseFloat(amount) <= 0) { toast.error('Please enter an amount'); return; } if (amountError) { toast.error(amountError); return; } if (!ethBalance || parseFloat(ethBalance.displayValue) < parseFloat(amount)) { toast.error('Insufficient ETH balance'); return; } setIsWrapping(true); const toastId = toast.loading('Wrapping ETH...'); try { const tx = await wrapETH({ overrides: { value: ethers.utils.parseEther(amount), }, }); console.log('Wrap transaction:', tx); toast.success('ETH wrapped successfully!', { id: toastId }); setAmount(''); await handleRefreshBalances(); } catch (error: unknown) { console.error('Wrap error:', error); toast.error(`Wrap failed: ${getErrorMessage(error)}`, { id: toastId }); } finally { setIsWrapping(false); } }; const handleApprove = async () => { if (!address) { toast.error('Please connect your wallet'); return; } if (!amount || parseFloat(amount) <= 0) { toast.error('Please enter an amount'); return; } setIsApproving(true); const toastId = toast.loading('Approving tokens...'); try { const amountWei = ethers.utils.parseEther(amount); const maxApproval = ethers.constants.MaxUint256; // Approve WETH9 const weth9AllowanceBN = weth9Allowance ? ethers.BigNumber.from(weth9Allowance.toString()) : ethers.BigNumber.from(0); if (weth9AllowanceBN.lt(amountWei)) { const weth9Tx = await approveWETH9({ args: [CONTRACTS.WETH9_BRIDGE, maxApproval], }); console.log('WETH9 approval transaction:', weth9Tx); toast.success('WETH9 approved', { id: toastId }); } // Approve LINK for fees (if needed) if (ccipFee?.gt(0)) { const linkAllowanceBN = linkAllowance ? ethers.BigNumber.from(linkAllowance.toString()) : ethers.BigNumber.from(0); if (linkAllowanceBN.lt(ccipFee)) { const linkTx = await approveLINK({ args: [CONTRACTS.WETH9_BRIDGE, maxApproval], }); console.log('LINK approval transaction:', linkTx); toast.success('LINK approved', { id: toastId }); } } toast.success('All approvals successful!', { id: toastId }); await handleRefreshBalances(); } catch (error: unknown) { console.error('Approve error:', error); toast.error(`Approval failed: ${getErrorMessage(error)}`, { id: toastId }); } finally { setIsApproving(false); } }; const handleBridge = async () => { if (!address) { toast.error('Please connect your wallet'); return; } if (!amount || parseFloat(amount) <= 0) { toast.error('Please enter an amount'); return; } if (amountError || recipientError) { toast.error('Please fix the errors before bridging'); return; } if (!recipient || !ethers.utils.isAddress(recipient)) { toast.error('Please enter a valid recipient address'); return; } const amountWei = ethers.utils.parseEther(amount); const weth9BalanceBN = weth9Balance ? ethers.BigNumber.from(weth9Balance.toString()) : ethers.BigNumber.from(0); if (weth9BalanceBN.lt(amountWei)) { toast.error('Insufficient WETH9 balance. Please wrap ETH first.'); return; } const weth9AllowanceBN = weth9Allowance ? ethers.BigNumber.from(weth9Allowance.toString()) : ethers.BigNumber.from(0); if (weth9AllowanceBN.lt(amountWei)) { toast.error('Insufficient WETH9 allowance. Please approve first.'); return; } if (ccipFee?.gt(0)) { const linkBalanceBN = linkBalance ? ethers.BigNumber.from(linkBalance.toString()) : ethers.BigNumber.from(0); if (linkBalanceBN.lt(ccipFee)) { const feeFormatted = ethers.utils.formatEther(ccipFee); toast.error(`Insufficient LINK for fees. Required: ${feeFormatted} LINK`); return; } } setIsBridging(true); const toastId = toast.loading('Bridging tokens...'); try { const result = await sendCrossChain({ args: [ destinationChainSelector, // destinationChainSelector recipient, // recipient amountWei, // amount ], }); console.log('Bridge transaction:', result); const txHash = result.receipt?.transactionHash || 'N/A'; toast.success(

Bridge transaction sent!

TX: {txHash.slice(0, 10)}...{txHash.slice(-8)}

, { id: toastId, duration: 6000 } ); setAmount(''); await handleRefreshBalances(); } catch (error: unknown) { console.error('Bridge error:', error); toast.error(`Bridge failed: ${getErrorMessage(error)}`, { id: toastId }); } finally { setIsBridging(false); } }; // Button state calculations const needsWrapping = ethBalance && amount && parseFloat(amount) > 0 && parseFloat(ethBalance.displayValue) >= parseFloat(amount) && !amountError; const needsApproval = amount && parseFloat(amount) > 0 && weth9Allowance && ethers.BigNumber.from(weth9Allowance.toString()).lt(ethers.utils.parseEther(amount)) && !amountError; const canBridge = amount && parseFloat(amount) > 0 && !amountError && !recipientError && weth9Balance && ethers.BigNumber.from(weth9Balance.toString()).gte(ethers.utils.parseEther(amount)) && weth9Allowance && ethers.BigNumber.from(weth9Allowance.toString()).gte(ethers.utils.parseEther(amount)) && recipient && ethers.utils.isAddress(recipient); const destination = CCIP_DESTINATIONS.find((d) => d.selector === destinationChainSelector) ?? null; return (
{bridgeContract && amountWei.gt(0) && ( )}

Wrap ETH, approve tokens, and bridge WETH9 via CCIP to{' '} {destination?.name ?? 'Ethereum Mainnet'}

setAmount(e.target.value)} className={`ui-input pl-4 pr-24 text-lg ${ amountError ? '!border-red-500 focus:!border-red-500' : '' }`} placeholder="0.0" aria-invalid={!!amountError} aria-describedby={amountError ? 'amount-error' : undefined} /> {ethBalance && ( )}
{amountError && (

{amountError}

)}
setRecipient(e.target.value)} className={`ui-input font-mono pr-36 ${ recipientError ? '!border-red-500 focus:!border-red-500' : '' }`} placeholder="0x..." aria-invalid={!!recipientError} aria-describedby={recipientError ? 'recipient-error' : undefined} />
{address && ( )} {recipient && ethers.utils.isAddress(recipient) && ( )}
{recipientError && (

{recipientError}

)}

Balances & Fees

{address && ( Copy Address )}
ETH Balance: {ethBalance ? ethBalance.displayValue : } ETH
WETH9 Balance: {address && weth9Balance !== undefined ? `${ethers.utils.formatEther(weth9Balance.toString())}` : address ? : '0'} WETH9
LINK Balance: {address && linkBalance !== undefined ? `${ethers.utils.formatEther(linkBalance.toString())}` : address ? : '0'} LINK
{ccipFee != null && ccipFee.gt(0) && (
CCIP Fee: {ethers.utils.formatEther(ccipFee)} LINK
)}
{/* Confirmation Modals */} setShowWrapModal(false)} onConfirm={() => { setShowWrapModal(false); handleWrap(); }} title="Wrap ETH to WETH9" message={`You are about to wrap ${amount} ETH to WETH9. This action cannot be undone.`} confirmText="Wrap ETH" confirmColor="blue" isLoading={isWrapping} /> setShowApproveModal(false)} onConfirm={() => { setShowApproveModal(false); handleApprove(); }} title="Approve Tokens" message={`You are about to approve ${amount} WETH9 and required LINK tokens for the bridge contract. This will allow the bridge to transfer your tokens.`} confirmText="Approve" confirmColor="green" isLoading={isApproving} /> setShowBridgeModal(false)} onConfirm={() => { setShowBridgeModal(false); handleBridge(); }} title="Bridge Tokens" message={`You are about to bridge ${amount} WETH9 to ${recipient.slice(0, 6)}...${recipient.slice(-4)} on Ethereum Mainnet. This action cannot be undone.`} confirmText="Bridge" confirmColor="purple" isLoading={isBridging} />
); } export default function BridgeButtons(props: BridgeButtonsProps) { const address = useAddress(); if (!address) { const destinationChainSelector = props.destinationChainSelector ?? CHAIN_SELECTORS.ETHEREUM_MAINNET; const destination = CCIP_DESTINATIONS.find((d) => d.selector === destinationChainSelector); return (
Connect your wallet to bridge to {destination?.name ?? 'Ethereum Mainnet'}
); } return ; }