import React, { useState, useEffect, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { usePezkuwi } from '@/contexts/PezkuwiContext'; import { useWallet } from '@/contexts/WalletContext'; import { ArrowDownUp, AlertCircle, Loader2, Info, Settings, AlertTriangle, RefreshCw } from 'lucide-react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { PoolInfo } from '@/types/dex'; import { parseTokenInput, formatTokenBalance, formatAssetLocation, } from '@pezkuwi/utils/dex'; import { getAllPrices, calculateOracleSwap, formatUsdPrice } from '@pezkuwi/lib/priceOracle'; import { useToast } from '@/hooks/use-toast'; interface SwapInterfaceProps { initialPool?: PoolInfo | null; pools: PoolInfo[]; } type TransactionStatus = 'idle' | 'signing' | 'submitting' | 'success' | 'error'; // All supported tokens - filtered dynamically based on available pools const ALL_TOKENS = [ { symbol: 'HEZ', emoji: '🟡', assetId: -1, name: 'HEZ', decimals: 12, displaySymbol: 'HEZ', logo: '/tokens/HEZ.png' }, { symbol: 'PEZ', emoji: '🔵', assetId: 1, name: 'PEZ', decimals: 12, displaySymbol: 'PEZ', logo: '/tokens/PEZ.png' }, { symbol: 'USDT', emoji: '💵', assetId: 1000, name: 'USDT', decimals: 6, displaySymbol: 'USDT', logo: '/tokens/USDT.png' }, { symbol: 'DOT', emoji: '🔴', assetId: 1001, name: 'DOT', decimals: 10, displaySymbol: 'DOT', logo: '/tokens/DOT.png' }, { symbol: 'ETH', emoji: '💎', assetId: 1002, name: 'ETH', decimals: 18, displaySymbol: 'ETH', logo: '/tokens/ETH.png' }, { symbol: 'BTC', emoji: '🟠', assetId: 1003, name: 'BTC', decimals: 8, displaySymbol: 'BTC', logo: '/tokens/BTC.png' }, ] as const; // Helper to get tokens that have available pools const getAvailableTokens = (pools: PoolInfo[]) => { if (!pools || pools.length === 0) return ALL_TOKENS; // Get unique asset IDs from pools const assetIds = new Set(); pools.forEach(pool => { assetIds.add(pool.asset1); assetIds.add(pool.asset2); }); // Filter tokens that exist in pools return ALL_TOKENS.filter(token => assetIds.has(token.assetId)); }; export const SwapInterface: React.FC = ({ pools }) => { const { t } = useTranslation(); // Use Asset Hub API for DEX operations const { assetHubApi, isAssetHubReady } = usePezkuwi(); const { account, signer } = useWallet(); const { toast } = useToast(); // Get tokens that have available pools const availableTokens = React.useMemo(() => getAvailableTokens(pools), [pools]); // Set initial tokens based on available pools const [fromToken, setFromToken] = useState(() => { const tokens = getAvailableTokens(pools); return tokens.find(t => t.symbol === 'HEZ')?.symbol || tokens[0]?.symbol || 'HEZ'; }); const [toToken, setToToken] = useState(() => { const tokens = getAvailableTokens(pools); // Find first token that's different from HEZ return tokens.find(t => t.symbol !== 'HEZ')?.symbol || tokens[1]?.symbol || 'USDT'; }); const [fromAmount, setFromAmount] = useState(''); const [toAmount, setToAmount] = useState(''); const [slippage, setSlippage] = useState(0.5); // 0.5% default const [showSettings, setShowSettings] = useState(false); const [showConfirm, setShowConfirm] = useState(false); const [fromBalance, setFromBalance] = useState('0'); const [toBalance, setToBalance] = useState('0'); const [txStatus, setTxStatus] = useState('idle'); const [errorMessage, setErrorMessage] = useState(''); // Oracle prices state const [prices, setPrices] = useState>({}); const [pricesLoading, setPricesLoading] = useState(true); const [swapRoute, setSwapRoute] = useState([]); // Fetch oracle prices const fetchPrices = useCallback(async () => { setPricesLoading(true); try { const fetchedPrices = await getAllPrices(); setPrices(fetchedPrices); } catch (error) { console.error('Failed to fetch prices:', error); } setPricesLoading(false); }, []); useEffect(() => { fetchPrices(); // Refresh prices every 30 seconds const interval = setInterval(fetchPrices, 30000); return () => clearInterval(interval); }, [fetchPrices]); // Get asset IDs (for pool lookup) const getAssetId = (symbol: string) => { const token = ALL_TOKENS.find(t => t.symbol === symbol); return token?.assetId ?? null; }; const fromAssetId = getAssetId(fromToken); const toAssetId = getAssetId(toToken); // Find active pool for selected pair const activePool = pools.find( (p) => (p.asset1 === fromAssetId && p.asset2 === toAssetId) || (p.asset1 === toAssetId && p.asset2 === fromAssetId) ); // Get token info const fromTokenInfo = ALL_TOKENS.find(t => t.symbol === fromToken); const toTokenInfo = ALL_TOKENS.find(t => t.symbol === toToken); // Fetch balances useEffect(() => { const fetchBalances = async () => { if (!assetHubApi || !isAssetHubReady || !account) return; // For HEZ, fetch native balance (not wHEZ asset balance) if (fromToken === 'HEZ') { try { const balance = await assetHubApi.query.system.account(account); const freeBalance = balance.data.free.toString(); setFromBalance(freeBalance); } catch (error) { if (import.meta.env.DEV) console.error('Failed to fetch HEZ balance:', error); setFromBalance('0'); } } else if (fromAssetId !== null) { try { const balanceData = await assetHubApi.query.assets.account(fromAssetId, account); setFromBalance(balanceData.isSome ? balanceData.unwrap().balance.toString() : '0'); } catch (error) { if (import.meta.env.DEV) console.error('Failed to fetch from balance:', error); setFromBalance('0'); } } // For HEZ, fetch native balance if (toToken === 'HEZ') { try { const balance = await assetHubApi.query.system.account(account); const freeBalance = balance.data.free.toString(); setToBalance(freeBalance); } catch (error) { if (import.meta.env.DEV) console.error('Failed to fetch HEZ balance:', error); setToBalance('0'); } } else if (toAssetId !== null) { try { const balanceData = await assetHubApi.query.assets.account(toAssetId, account); setToBalance(balanceData.isSome ? balanceData.unwrap().balance.toString() : '0'); } catch (error) { if (import.meta.env.DEV) console.error('Failed to fetch to balance:', error); setToBalance('0'); } } }; fetchBalances(); }, [assetHubApi, isAssetHubReady, account, fromToken, toToken, fromAssetId, toAssetId]); // Calculate output amount using Oracle prices useEffect(() => { const calculateSwap = async () => { if (!fromAmount || !fromTokenInfo || !toTokenInfo || parseFloat(fromAmount) <= 0) { setToAmount(''); setSwapRoute([]); return; } try { const result = await calculateOracleSwap( fromToken, toToken, parseFloat(fromAmount), 0.3 // 0.3% fee per hop ); if (result) { // Format output based on decimals const formattedOutput = result.toAmount.toFixed( toTokenInfo.decimals > 6 ? 6 : toTokenInfo.decimals ); setToAmount(formattedOutput); setSwapRoute(result.route); } else { setToAmount(''); setSwapRoute([]); } } catch (error) { if (import.meta.env.DEV) console.error('Failed to calculate swap:', error); setToAmount(''); setSwapRoute([]); } }; calculateSwap(); }, [fromAmount, fromToken, toToken, fromTokenInfo, toTokenInfo, prices]); // Get oracle exchange rate const oracleRate = React.useMemo(() => { const fromPrice = prices[fromToken]; const toPrice = prices[toToken]; if (!fromPrice || !toPrice) return null; return fromPrice / toPrice; }, [prices, fromToken, toToken]); // Check if user has insufficient balance const hasInsufficientBalance = React.useMemo(() => { const fromAmountNum = parseFloat(fromAmount || '0'); const fromBalanceNum = parseFloat(formatTokenBalance(fromBalance, fromTokenInfo?.decimals ?? 12, 6)); return fromAmountNum > 0 && fromAmountNum > fromBalanceNum; }, [fromAmount, fromBalance, fromTokenInfo]); const handleSwapDirection = () => { const tempToken = fromToken; const tempBalance = fromBalance; setFromToken(toToken); setToToken(tempToken); setFromAmount(toAmount); setFromBalance(toBalance); setToBalance(tempBalance); }; const handleMaxClick = () => { if (fromTokenInfo) { const maxAmount = formatTokenBalance(fromBalance, fromTokenInfo.decimals, 6); setFromAmount(maxAmount); } }; const handleConfirmSwap = async () => { if (!assetHubApi || !signer || !account || !fromTokenInfo || !toTokenInfo) { toast({ title: t('common.error'), description: t('common.connectWalletAlert'), variant: 'destructive', }); return; } if (!activePool) { toast({ title: t('common.error'), description: t('swap.noPool'), variant: 'destructive', }); return; } setTxStatus('signing'); setShowConfirm(false); setErrorMessage(''); try { const amountIn = parseTokenInput(fromAmount, fromTokenInfo.decimals); const minAmountOut = parseTokenInput( (parseFloat(toAmount) * (1 - slippage / 100)).toString(), toTokenInfo.decimals ); if (import.meta.env.DEV) console.log('💰 Swap transaction:', { from: fromToken, to: toToken, amount: fromAmount, minOut: minAmountOut.toString(), }); // XCM Locations for all supported tokens const nativeLocation = formatAssetLocation(-1); // HEZ (native) const usdtLocation = formatAssetLocation(1000); // wUSDT const wdotLocation = formatAssetLocation(1001); // wDOT const wethLocation = formatAssetLocation(1002); // wETH const wbtcLocation = formatAssetLocation(1003); // wBTC // Build swap path - all pairs go through USDT const getLocation = (symbol: string) => { switch (symbol) { case 'HEZ': return nativeLocation; case 'USDT': return usdtLocation; case 'DOT': return wdotLocation; case 'ETH': return wethLocation; case 'BTC': return wbtcLocation; default: return formatAssetLocation(fromAssetId!); } }; const fromLocation = getLocation(fromToken); const toLocation = getLocation(toToken); // Determine swap path based on route let swapPath: unknown[]; if (fromToken === 'USDT' || toToken === 'USDT') { // Direct swap with USDT swapPath = [fromLocation, toLocation]; } else { // Multi-hop through USDT: X → USDT → Y swapPath = [fromLocation, usdtLocation, toLocation]; } if (import.meta.env.DEV) console.log('Swap path:', swapRoute, swapPath); const tx = assetHubApi.tx.assetConversion.swapExactTokensForTokens( swapPath, amountIn.toString(), minAmountOut.toString(), account, true ); setTxStatus('submitting'); await tx.signAndSend( account, { signer }, ({ status, dispatchError }) => { if (status.isInBlock) { if (dispatchError) { if (dispatchError.isModule) { const decoded = assetHubApi.registry.findMetaError(dispatchError.asModule); setErrorMessage(`${decoded.section}.${decoded.name}: ${decoded.docs}`); } else { setErrorMessage(dispatchError.toString()); } setTxStatus('error'); toast({ title: t('swap.swapFailed'), description: errorMessage, variant: 'destructive', }); } else { setTxStatus('success'); toast({ title: t('common.success'), description: t('swap.swapped', { fromAmount, fromToken, toAmount, toToken }), }); setTimeout(() => { setFromAmount(''); setToAmount(''); setTxStatus('idle'); }, 2000); } } } ); } catch (error) { if (import.meta.env.DEV) console.error('Swap failed:', error); setErrorMessage(error instanceof Error ? error.message : t('common.txFailed')); setTxStatus('error'); toast({ title: t('common.error'), description: error instanceof Error ? error.message : t('swap.swapFailed'), variant: 'destructive', }); } }; // Exchange rate from oracle const exchangeRate = oracleRate ? oracleRate.toFixed(6) : '0'; return (
{/* Transaction Loading Overlay */} {(txStatus === 'signing' || txStatus === 'submitting') && (

{txStatus === 'signing' ? t('swap.waitingSignature') : t('swap.processingSwap')}

)}
{t('swap.title')}
{!account && ( {t('swap.connectWalletAlert')} )} {/* From Token */}
{t('swap.from')} {t('common.balance')} {formatTokenBalance(fromBalance, fromTokenInfo?.decimals ?? 12, 4)} {fromToken}
setFromAmount(e.target.value)} placeholder="0.0" className="text-2xl font-bold border-0 bg-transparent text-white placeholder:text-gray-600 focus-visible:ring-0" disabled={!account} />
{/* Swap Direction Button */}
{/* To Token */}
{t('swap.to')} {t('common.balance')} {formatTokenBalance(toBalance, toTokenInfo?.decimals ?? 12, 4)} {toToken}
{/* Swap Details - Oracle Prices */}
{t('common.exchangeRate')} {t('swap.coinGecko')}
{oracleRate ? `1 ${fromToken} = ${exchangeRate} ${toToken}` : t('common.loading')}
{/* USD Prices */}
{fromToken}: {prices[fromToken] ? formatUsdPrice(prices[fromToken]) : '...'} {toToken}: {prices[toToken] ? formatUsdPrice(prices[toToken]) : '...'}
{/* Route */} {swapRoute.length > 0 && (
{t('swap.route')} {swapRoute.join(' → ')}
)} {/* Fees */}
{t('swap.swapFee')} {swapRoute.length > 2 ? '0.6%' : '0.3%'} {swapRoute.length > 2 && {t('swap.twoHops')}}
{t('common.slippageTolerance')} {slippage}%
{/* Warnings */} {hasInsufficientBalance && ( {t('swap.insufficientBalance', { token: fromToken })} )} {swapRoute.length > 2 && !hasInsufficientBalance && ( {t('swap.multiHopWarning', { route: swapRoute.join(' → ') })} )} {/* Swap Button */}
{/* Settings Dialog */} {t('swap.settings')}
{[0.1, 0.5, 1.0, 2.0].map(val => ( ))}
{/* Confirm Dialog */} {t('swap.confirmSwap')}
{t('swap.youPay')} {fromAmount} {fromToken}
{t('swap.youReceive')} {toAmount} {toToken}
{t('common.exchangeRate')} 1 {fromToken} = {exchangeRate} {toToken}
{t('swap.slippage')} {slippage}%
); };