Files
web/src/components/swap-form.tsx

605 lines
25 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import { useState, useEffect, useRef, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { ArrowDownUp, ChevronDown, Settings, CheckCircle, XCircle, Loader2 } from 'lucide-react';
import { useAccount } from 'wagmi';
import { useTokenDetails, useGetPoolsByToken, type TokenDetails } from '@/hooks/usePartyPlanner';
import { useSwapAmounts, useSwap, selectBestSwapRoute, type ActualSwapAmounts } from '@/hooks/usePartyPool';
import { formatUnits, parseUnits } from 'viem';
import { SwapReviewModal } from './swap-review-modal';
type TransactionStatus = 'idle' | 'pending' | 'success' | 'error';
export function SwapForm() {
const { t } = useTranslation();
const { isConnected, address } = useAccount();
const [fromAmount, setFromAmount] = useState('');
const [toAmount, setToAmount] = useState('');
const [selectedFromToken, setSelectedFromToken] = useState<TokenDetails | null>(null);
const [selectedToToken, setSelectedToToken] = useState<TokenDetails | null>(null);
const [isFromDropdownOpen, setIsFromDropdownOpen] = useState(false);
const [isToDropdownOpen, setIsToDropdownOpen] = useState(false);
const [maxSlippage, setMaxSlippage] = useState<string>('1'); // Default 1%
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [isReviewModalOpen, setIsReviewModalOpen] = useState(false);
const [transactionStatus, setTransactionStatus] = useState<TransactionStatus>('idle');
const [transactionError, setTransactionError] = useState<string | null>(null);
const [actualSwapAmounts, setActualSwapAmounts] = useState<ActualSwapAmounts | null>(null);
const fromDropdownRef = useRef<HTMLDivElement>(null);
const toDropdownRef = useRef<HTMLDivElement>(null);
// Use the custom hook to get all token details with a single batched RPC call
const { tokenDetails, loading } = useTokenDetails(address);
// Get available tokens for the selected "from" token
const { availableTokens, error: poolsError } = useGetPoolsByToken(selectedFromToken?.address);
// Only calculate swap amounts when both tokens are selected
// Use useMemo to prevent creating a new array reference on every render
const filteredAvailableTokens = useMemo(() => {
if (selectedFromToken && selectedToToken && availableTokens) {
return availableTokens.filter(token =>
token.address.toLowerCase() === selectedToToken.address.toLowerCase()
);
}
return null;
}, [selectedFromToken, selectedToToken, availableTokens]);
// Get the current slippage value
const currentSlippage = parseFloat(maxSlippage) || 1;
// Calculate swap amounts for the selected token pair only
const { swapAmounts } = useSwapAmounts(
filteredAvailableTokens,
fromAmount,
selectedFromToken?.decimals || 18,
currentSlippage
);
// Check if user has insufficient balance
const hasInsufficientBalance = useMemo(() => {
if (!selectedFromToken || !fromAmount || fromAmount === '') {
return false;
}
try {
const inputAmount = parseUnits(fromAmount, selectedFromToken.decimals);
return inputAmount > selectedFromToken.balance;
} catch {
return false;
}
}, [selectedFromToken, fromAmount]);
// Check if calculated slippage exceeds 5%
const slippageExceedsLimit = useMemo(() => {
if (!swapAmounts || swapAmounts.length === 0 || swapAmounts[0].calculatedSlippage === undefined) {
return false;
}
return Math.abs(swapAmounts[0].calculatedSlippage) > 5;
}, [swapAmounts]);
// Initialize swap hook
const { executeSwap, estimateGas, isSwapping, gasEstimate, isEstimatingGas } = useSwap();
// Update "You Receive" amount when swap calculation completes
useEffect(() => {
if (hasInsufficientBalance) {
setToAmount('');
return;
}
if (swapAmounts && swapAmounts.length > 0 && selectedToToken) {
const swapResult = swapAmounts[0]; // Get the first (and should be only) result
const formattedAmount = formatUnits(swapResult.amountOut, selectedToToken.decimals);
setToAmount(formattedAmount);
}
}, [swapAmounts, selectedToToken, hasInsufficientBalance]);
// Close dropdowns when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (fromDropdownRef.current && !fromDropdownRef.current.contains(event.target as Node)) {
setIsFromDropdownOpen(false);
}
if (toDropdownRef.current && !toDropdownRef.current.contains(event.target as Node)) {
setIsToDropdownOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleSwap = async () => {
if (!swapAmounts || swapAmounts.length === 0) {
console.error('No swap amounts available');
return;
}
if (!selectedFromToken || !selectedToToken) {
console.error('Tokens not selected');
return;
}
setTransactionStatus('pending');
setTransactionError(null);
try {
// Use the shared helper to select the best swap route
const bestRoute = selectBestSwapRoute(swapAmounts);
if (!bestRoute) {
console.error('No valid swap route found');
setTransactionError('No valid swap route found');
setTransactionStatus('error');
return;
}
// Use the user's input directly as maxAmountIn
const maxAmountIn = parseUnits(fromAmount, selectedFromToken.decimals);
// Execute the swap and capture actual amounts from the transaction
const result = await executeSwap(
bestRoute.poolAddress,
selectedFromToken.address,
bestRoute.inputTokenIndex,
bestRoute.outputTokenIndex,
maxAmountIn,
currentSlippage
);
// Store the actual swap amounts if available
if (result?.actualSwapAmounts) {
setActualSwapAmounts(result.actualSwapAmounts);
}
setTransactionStatus('success');
} catch (err) {
console.error('Swap failed:', err);
setTransactionError(err instanceof Error ? err.message : 'Transaction failed');
setTransactionStatus('error');
}
};
const handleCloseModal = () => {
if (transactionStatus === 'success') {
// Clear the form after successful swap
setFromAmount('');
setToAmount('');
setSelectedFromToken(null);
setSelectedToToken(null);
}
setTransactionStatus('idle');
setTransactionError(null);
};
const switchTokens = () => {
// Switch both tokens and amounts
const tempFromAmount = fromAmount;
const tempFromToken = selectedFromToken;
setFromAmount(toAmount);
setToAmount(tempFromAmount);
setSelectedFromToken(selectedToToken);
setSelectedToToken(tempFromToken);
};
// Estimate gas when swap parameters change
useEffect(() => {
const estimateSwapGas = async () => {
if (!swapAmounts || swapAmounts.length === 0 || !selectedFromToken || !selectedToToken || !fromAmount || !isConnected) {
return;
}
await estimateGas();
};
estimateSwapGas();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [swapAmounts, selectedFromToken, selectedToToken, fromAmount, currentSlippage, isConnected]);
return (
<Card className="w-full max-w-md mx-auto relative">
<CardHeader>
<div className="flex justify-between items-center">
<CardTitle>Swap</CardTitle>
<Button
variant="ghost"
size="icon"
onClick={() => setIsSettingsOpen(true)}
className="h-8 w-8"
>
<Settings className="h-5 w-5" />
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* From Token */}
<div className="space-y-2">
<div className="flex justify-between items-center text-sm">
<label className="text-muted-foreground">{t('swap.youPay')}</label>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={() => {
if (selectedFromToken) {
setFromAmount(formatUnits(selectedFromToken.balance, selectedFromToken.decimals));
}
}}
disabled={!selectedFromToken}
>
MAX
</Button>
</div>
<div className="flex gap-2">
<Input
type="number"
placeholder="0.0"
value={fromAmount}
onChange={(e) => setFromAmount(e.target.value)}
className="text-2xl h-16"
/>
<div className="relative min-w-[160px] space-y-1" ref={fromDropdownRef}>
<Button
variant="secondary"
className="w-full h-16 justify-between"
onClick={() => setIsFromDropdownOpen(!isFromDropdownOpen)}
>
{selectedFromToken ? (
<span className="font-medium">{selectedFromToken.symbol}</span>
) : (
t('swap.selectToken')
)}
<ChevronDown className="h-4 w-4 ml-2" />
</Button>
<div className="text-xs text-muted-foreground text-right">
{t('swap.balance')}: {selectedFromToken ? formatUnits(selectedFromToken.balance, selectedFromToken.decimals) : '0.00'}
</div>
{isFromDropdownOpen && (
<div className="absolute z-50 w-full mt-2 bg-popover border rounded-md shadow-lg max-h-[300px] overflow-y-auto">
{tokenDetails && tokenDetails.length > 0 ? (
tokenDetails.map((token) => (
<button
key={token.address}
className="w-full px-4 py-3 text-left hover:bg-accent focus:bg-accent focus:outline-none"
onClick={() => {
setSelectedFromToken(token);
setIsFromDropdownOpen(false);
}}
>
<span className="font-medium">{token.symbol}</span>
</button>
))
) : (
<div className="px-4 py-3 text-sm text-muted-foreground">
{loading ? 'Loading tokens...' : 'No tokens available'}
</div>
)}
</div>
)}
</div>
</div>
</div>
{/* Switch Button */}
<div className="flex justify-center">
<Button
variant="ghost"
size="icon"
onClick={switchTokens}
className="rounded-full"
>
<ArrowDownUp className="h-4 w-4" />
</Button>
</div>
{/* To Token */}
<div className="space-y-2">
<div className="text-sm">
<label className="text-muted-foreground">{t('swap.youReceive')}</label>
</div>
<div className="flex gap-2">
<Input
type="number"
placeholder="0.0"
value={toAmount}
onChange={(e) => setToAmount(e.target.value)}
className="text-2xl h-16"
disabled={!selectedFromToken}
/>
<div className="relative min-w-[160px] space-y-1" ref={toDropdownRef}>
<Button
variant="secondary"
className="w-full h-16 justify-between"
onClick={() => setIsToDropdownOpen(!isToDropdownOpen)}
disabled={!selectedFromToken}
>
{selectedToToken ? (
<span className="font-medium">{selectedToToken.symbol}</span>
) : (
t('swap.selectToken')
)}
<ChevronDown className="h-4 w-4 ml-2" />
</Button>
<div className="text-xs text-muted-foreground text-right">
{t('swap.balance')}: {selectedToToken ? formatUnits(selectedToToken.balance, selectedToToken.decimals) : '0.00'}
</div>
{isToDropdownOpen && (
<div className="absolute z-50 w-full mt-2 bg-popover border rounded-md shadow-lg max-h-[300px] overflow-y-auto">
{availableTokens && availableTokens.length > 0 && tokenDetails ? (
// Filter tokenDetails to only show tokens in availableTokens
tokenDetails
.filter((token) =>
availableTokens.some((availToken) =>
availToken.address.toLowerCase() === token.address.toLowerCase()
)
)
.map((token) => (
<button
key={token.address}
className="w-full px-4 py-3 text-left hover:bg-accent focus:bg-accent focus:outline-none"
onClick={() => {
setSelectedToToken(token);
setIsToDropdownOpen(false);
}}
>
<span className="font-medium">{token.symbol}</span>
</button>
))
) : selectedFromToken ? (
<div className="px-4 py-3 text-sm text-muted-foreground">
{loading ? 'Loading available tokens...' : poolsError || 'No tokens available for swap'}
</div>
) : (
<div className="px-4 py-3 text-sm text-muted-foreground">
Select a token in "You Pay" first
</div>
)}
</div>
)}
</div>
</div>
</div>
{/* Error message for unsupported tokens */}
{poolsError && selectedFromToken && (
<div className="px-4 py-3 bg-destructive/10 border border-destructive/20 rounded-lg">
<p className="text-sm text-destructive">{poolsError}</p>
</div>
)}
{/* Error message for insufficient balance */}
{hasInsufficientBalance && (
<div className="px-4 py-3 bg-destructive/10 border border-destructive/20 rounded-lg">
<p className="text-sm text-destructive">Insufficient balance</p>
</div>
)}
{/* Error message for slippage exceeding 5% */}
{slippageExceedsLimit && (
<div className="px-4 py-3 bg-destructive/10 border border-destructive/20 rounded-lg">
<p className="text-sm text-destructive font-medium"> Slippage Exceeds 5%</p>
<p className="text-xs text-destructive/80 mt-1">
The estimated slippage for this swap is {Math.abs(swapAmounts![0].calculatedSlippage!).toFixed(2)}%.
We cannot process this swap as you may lose too much money due to the high slippage.
</p>
</div>
)}
{/* High slippage warning - show if calculated slippage exceeds max slippage but is under 5% */}
{!slippageExceedsLimit && swapAmounts && swapAmounts.length > 0 && swapAmounts[0].calculatedSlippage !== undefined && (
Math.abs(swapAmounts[0].calculatedSlippage) > currentSlippage
) && (
<div className="px-4 py-3 bg-yellow-500/10 border border-yellow-500/20 rounded-lg">
<p className="text-sm text-yellow-600 dark:text-yellow-500 font-medium"> High Slippage Warning</p>
<p className="text-xs text-yellow-600/80 dark:text-yellow-500/80 mt-1">
The estimated slippage for this swap is {Math.abs(swapAmounts[0].calculatedSlippage).toFixed(2)}%. You may lose money due to low liquidity in this pool.
</p>
</div>
)}
{/* Gas Estimate, Slippage, and Fees */}
{isConnected && fromAmount && toAmount && (
<div className="px-4 py-2 bg-muted/30 rounded-lg space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Estimated Gas Cost:</span>
<span className="font-medium">
{isEstimatingGas ? 'Calculating...' : gasEstimate ? (gasEstimate.estimatedCostUsd.startsWith('<') ? gasEstimate.estimatedCostUsd : `$${gasEstimate.estimatedCostUsd}`) : '-'}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Max Slippage:</span>
<span className="font-medium">{maxSlippage}%</span>
</div>
{swapAmounts && swapAmounts.length > 0 && selectedFromToken && fromAmount && (
<>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Total Amount In:</span>
<span className="font-medium">
{formatUnits(swapAmounts[0].amountIn, selectedFromToken.decimals)} {selectedFromToken.symbol}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Fee:</span>
<span className="font-medium">
{formatUnits(swapAmounts[0].fee, selectedFromToken.decimals)} {selectedFromToken.symbol}
{' '}
({((Number(swapAmounts[0].fee) / Number(swapAmounts[0].amountIn)) * 100).toFixed(2)}%)
</span>
</div>
</>
)}
</div>
)}
{/* Review Button */}
<Button
className="w-full h-14 text-lg"
onClick={() => setIsReviewModalOpen(true)}
disabled={!isConnected || !fromAmount || !toAmount || !!poolsError || hasInsufficientBalance || slippageExceedsLimit}
>
{!isConnected
? t('swap.connectWalletToSwap')
: hasInsufficientBalance
? 'Insufficient Balance'
: slippageExceedsLimit
? 'Slippage Too High'
: 'Review'}
</Button>
</CardContent>
{/* Settings Modal */}
{isSettingsOpen && (
<>
<div
className="fixed inset-0 z-50 bg-black/80 animate-in fade-in-0"
onClick={() => setIsSettingsOpen(false)}
/>
<div className="fixed left-[50%] top-[50%] z-50 w-full max-w-sm translate-x-[-50%] translate-y-[-50%] animate-in fade-in-0 zoom-in-95">
<div className="bg-background border rounded-lg shadow-lg p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-semibold">Settings</h2>
<button
onClick={() => setIsSettingsOpen(false)}
className="rounded-sm opacity-70 hover:opacity-100 transition-opacity"
>
<ChevronDown className="h-4 w-4" />
</button>
</div>
<div className="space-y-4">
<div>
<label className="text-sm font-medium block mb-2">
Max Slippage
</label>
<div className="relative">
<Input
type="number"
value={maxSlippage}
onChange={(e) => setMaxSlippage(e.target.value)}
className="pr-8"
step="0.1"
min="0"
max="100"
/>
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">
%
</span>
</div>
<p className="text-xs text-muted-foreground mt-2">
Your transaction will revert if the price changes unfavorably by more than this percentage.
</p>
</div>
</div>
</div>
</div>
</>
)}
{/* Review Modal */}
<SwapReviewModal
open={isReviewModalOpen}
onOpenChange={setIsReviewModalOpen}
fromToken={selectedFromToken}
toToken={selectedToToken}
fromAmount={fromAmount}
toAmount={toAmount}
slippage={currentSlippage}
gasEstimate={gasEstimate}
fee={swapAmounts && swapAmounts.length > 0 ? swapAmounts[0].fee : null}
onConfirm={async () => {
await handleSwap();
setIsReviewModalOpen(false);
}}
isSwapping={isSwapping}
/>
{/* Transaction Modal Overlay */}
{transactionStatus !== 'idle' && (
<div className="absolute inset-0 bg-background/80 backdrop-blur-sm flex items-center justify-center z-50 rounded-lg">
<div className="bg-card border rounded-lg p-8 max-w-sm w-full mx-4 shadow-lg">
{transactionStatus === 'pending' && (
<div className="flex flex-col items-center space-y-4">
<Loader2 className="h-16 w-16 animate-spin text-primary" />
<h3 className="text-xl font-semibold text-center">Approving Swap</h3>
<p className="text-sm text-muted-foreground text-center">
Swapping {fromAmount} {selectedFromToken?.symbol} {toAmount} {selectedToToken?.symbol}
</p>
<p className="text-xs text-muted-foreground text-center">
Please confirm the transactions in your wallet
</p>
</div>
)}
{transactionStatus === 'success' && (
<div className="flex flex-col items-center space-y-4">
<CheckCircle className="h-16 w-16 text-green-500" />
<h3 className="text-xl font-semibold text-center">Swap Confirmed!</h3>
{/* Display actual amounts or estimates */}
<div className="w-full space-y-3">
{actualSwapAmounts && selectedFromToken && selectedToToken ? (
// Show actual amounts from transaction
<>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Swapped:</span>
<span className="font-medium">{formatUnits(actualSwapAmounts.amountIn, selectedFromToken.decimals)} {selectedFromToken.symbol}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Received:</span>
<span className="font-medium">{formatUnits(actualSwapAmounts.amountOut, selectedToToken.decimals)} {selectedToToken.symbol}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Fee:</span>
<span className="font-medium">{formatUnits(actualSwapAmounts.lpFee + actualSwapAmounts.protocolFee, selectedFromToken.decimals)} {selectedFromToken.symbol}</span>
</div>
</>
) : (
// Fallback to estimates
<>
<p className="text-sm text-muted-foreground text-center">
Successfully swapped {fromAmount} {selectedFromToken?.symbol} to {toAmount} {selectedToToken?.symbol}
<br />
<span className="text-xs italic opacity-70">
*Disclaimer: This is an estimate from the protocol. The actual amounts might be slightly different due to slippage.
</span>
</p>
</>
)}
</div>
<Button
onClick={handleCloseModal}
className="w-full mt-4"
>
Close
</Button>
</div>
)}
{transactionStatus === 'error' && (
<div className="flex flex-col items-center space-y-4">
<XCircle className="h-16 w-16 text-destructive" />
<h3 className="text-xl font-semibold text-center">Swap Failed</h3>
<p className="text-sm text-muted-foreground text-center break-words">
{transactionError || 'Transaction failed'}
</p>
<Button
onClick={handleCloseModal}
variant="outline"
className="w-full mt-4"
>
Close
</Button>
</div>
)}
</div>
</div>
)}
</Card>
);
}