import React, { useState, useEffect } from 'react'; import { usePezkuwi } from '@/contexts/PezkuwiContext'; import { useWallet } from '@/contexts/WalletContext'; import { ArrowDownUp, AlertCircle, Loader2, Info, Settings, AlertTriangle } 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, getAmountOut, calculatePriceImpact, formatAssetLocation, } from '@pezkuwi/utils/dex'; import { useToast } from '@/hooks/use-toast'; interface SwapInterfaceProps { initialPool?: PoolInfo | null; pools: PoolInfo[]; } type TransactionStatus = 'idle' | 'signing' | 'submitting' | 'success' | 'error'; // User-facing tokens // Native HEZ uses NATIVE_TOKEN_ID (-1) for pool matching const USER_TOKENS = [ { symbol: 'HEZ', emoji: '🟡', assetId: -1, name: 'HEZ', decimals: 12, displaySymbol: 'HEZ' }, // Native HEZ (NATIVE_TOKEN_ID) { symbol: 'PEZ', emoji: '🟣', assetId: 1, name: 'PEZ', decimals: 12, displaySymbol: 'PEZ' }, { symbol: 'USDT', emoji: '💵', assetId: 1000, name: 'USDT', decimals: 6, displaySymbol: 'USDT' }, ] as const; export const SwapInterface: React.FC = ({ pools }) => { // Use Asset Hub API for DEX operations const { assetHubApi, isAssetHubReady } = usePezkuwi(); const { account, signer } = useWallet(); const { toast } = useToast(); const [fromToken, setFromToken] = useState('HEZ'); const [toToken, setToToken] = useState('PEZ'); 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(''); // Get asset IDs (for pool lookup) const getAssetId = (symbol: string) => { const token = USER_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 = USER_TOKENS.find(t => t.symbol === fromToken); const toTokenInfo = USER_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 when input changes useEffect(() => { if (!fromAmount || !activePool || !fromTokenInfo || !toTokenInfo) { setToAmount(''); return; } try { const fromAmountRaw = parseTokenInput(fromAmount, fromTokenInfo.decimals); // Determine direction and calculate output const isForward = activePool.asset1 === fromAssetId; const reserveIn = isForward ? activePool.reserve1 : activePool.reserve2; const reserveOut = isForward ? activePool.reserve2 : activePool.reserve1; const toAmountRaw = getAmountOut(fromAmountRaw, reserveIn, reserveOut, 3); // 0.3% fee const toAmountDisplay = formatTokenBalance(toAmountRaw, toTokenInfo.decimals, 6); setToAmount(toAmountDisplay); } catch (error) { if (import.meta.env.DEV) console.error('Failed to calculate output:', error); setToAmount(''); } }, [fromAmount, activePool, fromTokenInfo, toTokenInfo, fromAssetId, toAssetId]); // Calculate price impact const priceImpact = React.useMemo(() => { if (!fromAmount || !activePool || !fromAssetId || !toAssetId || !fromTokenInfo) { return 0; } try { const fromAmountRaw = parseTokenInput(fromAmount, fromTokenInfo.decimals); const isForward = activePool.asset1 === fromAssetId; const reserveIn = isForward ? activePool.reserve1 : activePool.reserve2; const reserveOut = isForward ? activePool.reserve2 : activePool.reserve1; return parseFloat(calculatePriceImpact(reserveIn, reserveOut, fromAmountRaw)); } catch { return 0; } }, [fromAmount, activePool, fromAssetId, toAssetId, fromTokenInfo]); // 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: 'Error', description: 'Please connect your wallet', variant: 'destructive', }); return; } if (!activePool) { toast({ title: 'Error', description: 'No liquidity pool available for this pair', 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(), }); let tx; // Native HEZ uses NATIVE_TOKEN_ID (-1) for XCM Location // assetConversion pallet expects XCM MultiLocation format for swap paths const nativeLocation = formatAssetLocation(-1); // { parents: 1, interior: 'Here' } const pezLocation = formatAssetLocation(1); // PEZ asset const usdtLocation = formatAssetLocation(1000); // wUSDT asset if (fromToken === 'HEZ' && toToken === 'PEZ') { // HEZ → PEZ: Direct swap using native token tx = assetHubApi.tx.assetConversion.swapExactTokensForTokens( [nativeLocation, pezLocation], amountIn.toString(), minAmountOut.toString(), account, true ); } else if (fromToken === 'PEZ' && toToken === 'HEZ') { // PEZ → HEZ: Direct swap to native token tx = assetHubApi.tx.assetConversion.swapExactTokensForTokens( [pezLocation, nativeLocation], amountIn.toString(), minAmountOut.toString(), account, true ); } else if (fromToken === 'HEZ' && toToken === 'USDT') { // HEZ → USDT: Direct swap using native token tx = assetHubApi.tx.assetConversion.swapExactTokensForTokens( [nativeLocation, usdtLocation], amountIn.toString(), minAmountOut.toString(), account, true ); } else if (fromToken === 'USDT' && toToken === 'HEZ') { // USDT → HEZ: Direct swap to native token tx = assetHubApi.tx.assetConversion.swapExactTokensForTokens( [usdtLocation, nativeLocation], amountIn.toString(), minAmountOut.toString(), account, true ); } else if (fromToken === 'PEZ' && toToken === 'USDT') { // PEZ → USDT: Multi-hop through HEZ (PEZ → HEZ → USDT) tx = assetHubApi.tx.assetConversion.swapExactTokensForTokens( [pezLocation, nativeLocation, usdtLocation], amountIn.toString(), minAmountOut.toString(), account, true ); } else if (fromToken === 'USDT' && toToken === 'PEZ') { // USDT → PEZ: Multi-hop through HEZ (USDT → HEZ → PEZ) tx = assetHubApi.tx.assetConversion.swapExactTokensForTokens( [usdtLocation, nativeLocation, pezLocation], amountIn.toString(), minAmountOut.toString(), account, true ); } else { // Generic swap using XCM Locations const fromLocation = formatAssetLocation(fromAssetId!); const toLocation = formatAssetLocation(toAssetId!); tx = assetHubApi.tx.assetConversion.swapExactTokensForTokens( [fromLocation, toLocation], 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: 'Transaction Failed', description: errorMessage, variant: 'destructive', }); } else { setTxStatus('success'); toast({ title: 'Success!', description: `Swapped ${fromAmount} ${fromToken} for ~${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 : 'Transaction failed'); setTxStatus('error'); toast({ title: 'Error', description: error instanceof Error ? error.message : 'Swap transaction failed', variant: 'destructive', }); } }; const exchangeRate = activePool && fromTokenInfo && toTokenInfo ? ( parseFloat(formatTokenBalance(activePool.reserve2, toTokenInfo.decimals, 6)) / parseFloat(formatTokenBalance(activePool.reserve1, fromTokenInfo.decimals, 6)) ).toFixed(6) : '0'; return (
{/* Transaction Loading Overlay */} {(txStatus === 'signing' || txStatus === 'submitting') && (

{txStatus === 'signing' ? 'Waiting for signature...' : 'Processing swap...'}

)}
Swap Tokens
{!account && ( Please connect your wallet to swap tokens )} {/* From Token */}
From 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 */}
To Balance: {formatTokenBalance(toBalance, toTokenInfo?.decimals ?? 12, 4)} {toToken}
{/* Swap Details */}
Exchange Rate {activePool ? `1 ${fromToken} = ${exchangeRate} ${toToken}` : 'No pool available'}
{fromAmount && parseFloat(fromAmount) > 0 && priceImpact > 0 && (
Price Impact {priceImpact < 0.01 ? '<0.01%' : `${priceImpact.toFixed(2)}%`}
)}
Slippage Tolerance {slippage}%
{/* Warnings */} {hasInsufficientBalance && ( Insufficient {fromToken} balance )} {priceImpact >= 5 && !hasInsufficientBalance && ( High price impact! Consider a smaller amount. )} {/* Swap Button */}
{/* Settings Dialog */} Swap Settings
{[0.1, 0.5, 1.0, 2.0].map(val => ( ))}
{/* Confirm Dialog */} Confirm Swap
You Pay {fromAmount} {fromToken}
You Receive {toAmount} {toToken}
Exchange Rate 1 {fromToken} = {exchangeRate} {toToken}
Slippage {slippage}%
); };