712 lines
28 KiB
TypeScript
712 lines
28 KiB
TypeScript
/**
|
||
* @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 ChainIcon from '../ui/ChainIcon';
|
||
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<ReturnType<typeof useContract>['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<string>('');
|
||
const [recipient, setRecipient] = useState<string>(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<string>('');
|
||
const [recipientError, setRecipientError] = useState<string>('');
|
||
const [ccipFee, setCcipFee] = useState<ethers.BigNumber | null>(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(
|
||
<div>
|
||
<p className="font-semibold">Bridge transaction sent!</p>
|
||
<p className="text-xs mt-1">TX: {txHash.slice(0, 10)}...{txHash.slice(-8)}</p>
|
||
</div>,
|
||
{ 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 (
|
||
<div className="w-full">
|
||
{bridgeContract && amountWei.gt(0) && (
|
||
<CalculateFeeFetcher
|
||
bridgeContract={bridgeContract}
|
||
destinationChainSelector={destinationChainSelector}
|
||
amountWei={amountWei}
|
||
onFee={setCcipFee}
|
||
/>
|
||
)}
|
||
<div className="relative">
|
||
<div className="mb-6">
|
||
<div className="flex items-center justify-between mb-2">
|
||
<h2 className="text-[20px] font-semibold text-white flex items-center gap-2">
|
||
Bridge to
|
||
{destination && (
|
||
<>
|
||
<ChainIcon chainId={destination.chainId} name={destination.name} size={24} />
|
||
<span>{destination.name}</span>
|
||
</>
|
||
)}
|
||
{!destination && <span>Ethereum Mainnet</span>}
|
||
</h2>
|
||
<Tooltip content="Refresh all balances and allowances">
|
||
<button
|
||
onClick={handleRefreshBalances}
|
||
disabled={!address}
|
||
className="p-2 text-teal-400 hover:text-teal-300 hover:bg-teal-500/20 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed border border-white/20"
|
||
aria-label="Refresh balances"
|
||
>
|
||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||
</svg>
|
||
</button>
|
||
</Tooltip>
|
||
</div>
|
||
<p className="text-[#A0A0A0] text-sm mt-1">
|
||
Wrap ETH, approve tokens, and bridge WETH9 via CCIP
|
||
</p>
|
||
</div>
|
||
|
||
<div className="mb-8">
|
||
<label className="flex items-center gap-2 text-[13px] font-medium mb-2 text-[#A0A0A0]">
|
||
<TokenIcon symbol="ETH" size={20} />
|
||
<span>Amount (ETH)</span>
|
||
<Tooltip content="Enter the amount of ETH you want to bridge. This will be wrapped to WETH9 first.">
|
||
<span className="text-white/60 cursor-help text-xs">ℹ️</span>
|
||
</Tooltip>
|
||
</label>
|
||
<div className="relative">
|
||
<input
|
||
type="number"
|
||
step="0.001"
|
||
min="0"
|
||
value={amount}
|
||
onChange={(e) => setAmount(e.target.value)}
|
||
className={`w-full min-h-[48px] pl-4 pr-24 py-3 border rounded-xl focus:outline-none focus:ring-2 focus:ring-teal-500 focus:ring-offset-2 focus:ring-offset-[#252830] transition-all text-lg bg-[#1a1d24] text-white placeholder:text-[#A0A0A0] hover:border-white/30 ${
|
||
amountError
|
||
? 'border-red-500 focus:border-red-500'
|
||
: 'border-white/20 focus:border-teal-500'
|
||
}`}
|
||
placeholder="0.0"
|
||
aria-invalid={!!amountError}
|
||
aria-describedby={amountError ? 'amount-error' : undefined}
|
||
/>
|
||
{ethBalance && (
|
||
<button
|
||
onClick={() => {
|
||
setAmount(ethBalance.displayValue);
|
||
setAmountError('');
|
||
}}
|
||
className="absolute right-3 top-1/2 -translate-y-1/2 px-4 py-2 min-h-[36px] text-sm font-medium bg-teal-600 text-white rounded-lg hover:bg-teal-500 transition-colors"
|
||
>
|
||
MAX
|
||
</button>
|
||
)}
|
||
</div>
|
||
{amountError && (
|
||
<p id="amount-error" className="mt-2 text-sm text-red-600 flex items-center gap-1">
|
||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||
</svg>
|
||
{amountError}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
<div className="mb-8">
|
||
<label htmlFor="bridge-recipient-address" className="block text-[13px] font-medium mb-2 text-[#A0A0A0]">
|
||
Recipient Address
|
||
<Tooltip content="The Ethereum address that will receive the bridged tokens on the destination chain.">
|
||
<span className="ml-2 text-white/60 cursor-help text-xs">ℹ️</span>
|
||
</Tooltip>
|
||
</label>
|
||
<div className="relative">
|
||
<input
|
||
id="bridge-recipient-address"
|
||
name="recipient"
|
||
type="text"
|
||
value={recipient}
|
||
onChange={(e) => setRecipient(e.target.value)}
|
||
className={`w-full min-h-[48px] p-4 border rounded-xl focus:outline-none focus:ring-2 focus:ring-teal-500 focus:ring-offset-2 focus:ring-offset-[#252830] transition-all font-mono text-base bg-[#1a1d24] text-white placeholder:text-[#A0A0A0] hover:border-white/30 ${
|
||
recipientError
|
||
? 'border-red-500 focus:border-red-500'
|
||
: 'border-white/20 focus:border-teal-500'
|
||
}`}
|
||
placeholder="0x..."
|
||
aria-invalid={!!recipientError}
|
||
aria-describedby={recipientError ? 'recipient-error' : undefined}
|
||
/>
|
||
<div className="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-2">
|
||
{address && (
|
||
<button
|
||
onClick={() => {
|
||
setRecipient(address);
|
||
setRecipientError('');
|
||
}}
|
||
className="px-4 py-2 text-sm font-medium bg-[#252830] text-white rounded-lg hover:bg-white/10 transition-colors border border-white/20"
|
||
>
|
||
Use my address
|
||
</button>
|
||
)}
|
||
{recipient && ethers.utils.isAddress(recipient) && (
|
||
<CopyButton text={recipient} />
|
||
)}
|
||
</div>
|
||
</div>
|
||
{recipientError && (
|
||
<p id="recipient-error" className="mt-2 text-sm text-red-600 flex items-center gap-1">
|
||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||
</svg>
|
||
{recipientError}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
<div className="mb-8 p-6 bg-[#1a1d24] rounded-xl border border-white/10">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h3 className="text-[20px] font-semibold text-[#A0A0A0]">Balances & Fees</h3>
|
||
{address && (
|
||
<CopyButton text={address} className="text-xs">
|
||
<span className="text-xs">Copy Address</span>
|
||
</CopyButton>
|
||
)}
|
||
</div>
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
<div className="flex justify-between items-center p-4 bg-[#252830] rounded-lg border border-white/10 font-mono text-sm">
|
||
<span className="font-medium text-[#A0A0A0] flex items-center gap-2">
|
||
<TokenIcon symbol="ETH" size={20} />
|
||
ETH Balance:
|
||
</span>
|
||
<span className="text-white font-medium">
|
||
{ethBalance ? ethBalance.displayValue : <LoadingSkeleton />} <span className="text-[#A0A0A0]">ETH</span>
|
||
</span>
|
||
</div>
|
||
<div className="flex justify-between items-center p-4 bg-[#252830] rounded-lg border border-white/10 font-mono text-sm">
|
||
<span className="font-medium text-[#A0A0A0] flex items-center gap-2">
|
||
<TokenIcon symbol="WETH9" size={20} />
|
||
WETH9 Balance:
|
||
</span>
|
||
<span className="text-white font-medium">
|
||
{address && weth9Balance !== undefined
|
||
? `${ethers.utils.formatEther(weth9Balance.toString())}`
|
||
: address
|
||
? <LoadingSkeleton />
|
||
: '0'} <span className="text-[#A0A0A0]">WETH9</span>
|
||
</span>
|
||
</div>
|
||
<div className="flex justify-between items-center p-4 bg-[#252830] rounded-lg border border-white/10 font-mono text-sm">
|
||
<span className="font-medium text-[#A0A0A0] flex items-center gap-2">
|
||
<TokenIcon symbol="LINK" size={20} />
|
||
LINK Balance:
|
||
</span>
|
||
<span className="text-white font-medium">
|
||
{address && linkBalance !== undefined
|
||
? `${ethers.utils.formatEther(linkBalance.toString())}`
|
||
: address
|
||
? <LoadingSkeleton />
|
||
: '0'} <span className="text-[#A0A0A0]">LINK</span>
|
||
</span>
|
||
</div>
|
||
{ccipFee != null && ccipFee.gt(0) && (
|
||
<div className="flex justify-between items-center p-4 bg-[#252830] rounded-lg border border-teal-500/30 font-mono text-sm">
|
||
<span className="font-medium text-[#A0A0A0] flex items-center gap-2">
|
||
<TokenIcon symbol="LINK" size={20} />
|
||
CCIP Fee:
|
||
</span>
|
||
<span className="text-white font-medium">
|
||
{ethers.utils.formatEther(ccipFee)} <span className="text-[#A0A0A0]">LINK</span>
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||
<button
|
||
onClick={() => setShowWrapModal(true)}
|
||
disabled={!needsWrapping || !address || isWrapping}
|
||
className="px-6 py-4 bg-[#252830] text-white rounded-xl font-semibold hover:bg-white/10 disabled:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors border border-white/20"
|
||
aria-label="Wrap ETH to WETH9"
|
||
>
|
||
{isWrapping ? (
|
||
<span className="flex items-center justify-center gap-2">
|
||
<svg className="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||
</svg>
|
||
Wrapping...
|
||
</span>
|
||
) : (
|
||
'Wrap (Deposit)'
|
||
)}
|
||
</button>
|
||
|
||
<button
|
||
onClick={() => setShowApproveModal(true)}
|
||
disabled={!needsApproval || !address || isApproving}
|
||
className="px-6 py-4 bg-[#252830] text-white rounded-xl font-semibold hover:bg-white/10 disabled:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors border border-white/20"
|
||
aria-label="Approve tokens for bridge"
|
||
>
|
||
{isApproving ? (
|
||
<span className="flex items-center justify-center gap-2">
|
||
<svg className="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||
</svg>
|
||
Approving...
|
||
</span>
|
||
) : (
|
||
'Approve'
|
||
)}
|
||
</button>
|
||
</div>
|
||
<div className="mb-8">
|
||
<button
|
||
onClick={() => setShowBridgeModal(true)}
|
||
disabled={!canBridge || !address || isBridging}
|
||
className="w-full min-h-[56px] py-4 text-lg font-semibold bg-teal-600 text-white rounded-xl hover:bg-teal-500 disabled:bg-[#252830] disabled:opacity-50 disabled:cursor-not-allowed transition-colors focus:outline-none focus:ring-2 focus:ring-teal-500 focus:ring-offset-2 focus:ring-offset-[#252830] shadow-lg"
|
||
aria-label="Start bridge transfer"
|
||
>
|
||
{isBridging ? (
|
||
<span className="flex items-center justify-center gap-2">
|
||
<svg className="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||
</svg>
|
||
Bridging...
|
||
</span>
|
||
) : (
|
||
'Start Bridge Transfer'
|
||
)}
|
||
</button>
|
||
</div>
|
||
|
||
{/* Confirmation Modals */}
|
||
<ConfirmationModal
|
||
isOpen={showWrapModal}
|
||
onClose={() => 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}
|
||
/>
|
||
|
||
<ConfirmationModal
|
||
isOpen={showApproveModal}
|
||
onClose={() => 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}
|
||
/>
|
||
|
||
<ConfirmationModal
|
||
isOpen={showBridgeModal}
|
||
onClose={() => 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}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className="w-full">
|
||
<div className="mb-6">
|
||
<h2 className="text-[20px] font-semibold text-white flex items-center gap-2">
|
||
Bridge to
|
||
{destination && (
|
||
<>
|
||
<ChainIcon chainId={destination.chainId} name={destination.name} size={24} />
|
||
<span>{destination.name}</span>
|
||
</>
|
||
)}
|
||
{!destination && <span>Ethereum Mainnet</span>}
|
||
</h2>
|
||
<p className="text-[#A0A0A0] text-sm mt-1">
|
||
Wrap ETH, approve tokens, and bridge WETH9 via CCIP
|
||
</p>
|
||
</div>
|
||
<div className="mt-8 p-5 bg-amber-500/10 border border-amber-500/30 rounded-xl text-sm text-white font-medium flex items-center gap-4">
|
||
<svg className="h-6 w-6 text-yellow-300 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||
</svg>
|
||
<span>Please connect your wallet to continue</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
return <BridgeButtonsConnected {...props} address={address} />;
|
||
}
|