/** * Swap Modal Component * Mobile-optimized token swap interface for Telegram miniapp */ import { useState, useEffect, useCallback } from 'react'; import { X, ArrowDownUp, RefreshCw, AlertCircle, Check } from 'lucide-react'; import { useWallet } from '@/contexts/WalletContext'; import { useTelegram } from '@/hooks/useTelegram'; import { KurdistanSun } from '@/components/KurdistanSun'; interface SwapModalProps { isOpen: boolean; onClose: () => void; } // Token configuration const TOKENS = [ { symbol: 'HEZ', name: 'Hezkurd', assetId: -1, decimals: 12, icon: '/tokens/HEZ.png' }, { symbol: 'PEZ', name: 'Pezkuwi', assetId: 1, decimals: 12, icon: '/tokens/PEZ.png' }, { symbol: 'USDT', name: 'Tether', assetId: 1000, decimals: 6, icon: '/tokens/USDT.png' }, { symbol: 'DOT', name: 'Polkadot', assetId: 1001, decimals: 10, icon: '/tokens/DOT.png' }, ]; // Native token ID for relay chain HEZ const NATIVE_TOKEN_ID = -1; // Helper to convert asset ID to XCM Location format const formatAssetLocation = (id: number) => { if (id === NATIVE_TOKEN_ID) { return { parents: 1, interior: 'Here' }; } return { parents: 0, interior: { X2: [{ PalletInstance: 50 }, { GeneralIndex: id }] } }; }; export function SwapModal({ isOpen, onClose }: SwapModalProps) { const { assetHubApi, keypair } = useWallet(); const { hapticImpact, hapticNotification } = useTelegram(); const [fromToken, setFromToken] = useState(TOKENS[0]); // HEZ const [toToken, setToToken] = useState(TOKENS[1]); // PEZ const [fromAmount, setFromAmount] = useState(''); const [toAmount, setToAmount] = useState(''); const [exchangeRate, setExchangeRate] = useState(null); const [isLoadingRate, setIsLoadingRate] = useState(false); const [isSwapping, setIsSwapping] = useState(false); const [error, setError] = useState(''); const [success, setSuccess] = useState(false); // Token balances const [balances, setBalances] = useState>({ HEZ: '0', PEZ: '0', USDT: '0', DOT: '0', }); // Fetch balances from Asset Hub (where swaps happen) useEffect(() => { if (!isOpen || !assetHubApi || !keypair) return; // Reset state when modal opens setError(''); setFromAmount(''); setToAmount(''); const fetchBalances = async () => { try { // HEZ balance from Asset Hub (native token for fees and swaps) // eslint-disable-next-line @typescript-eslint/no-explicit-any const hezAccount = await (assetHubApi.query.system as any).account(keypair.address); const hezFree = hezAccount.data.free.toString(); const hezBalance = (parseInt(hezFree) / 1e12).toFixed(4); // Helper to extract balance from asset query result const getAssetBalance = (result: any, decimals: number, fractionDigits: number): string => { if (!result || result.isEmpty) return '0'.padEnd(fractionDigits + 2, '0'); const data = result.isSome ? result.unwrap().toJSON() : result.toJSON(); if (data && data.balance) { return (parseInt(data.balance.toString()) / Math.pow(10, decimals)).toFixed( fractionDigits ); } return '0'.padEnd(fractionDigits + 2, '0'); }; // PEZ balance (Asset 1) // eslint-disable-next-line @typescript-eslint/no-explicit-any const pezResult = await (assetHubApi.query.assets as any).account(1, keypair.address); const pezBalance = getAssetBalance(pezResult, 12, 4); // USDT balance (Asset 1000) // eslint-disable-next-line @typescript-eslint/no-explicit-any const usdtResult = await (assetHubApi.query.assets as any).account(1000, keypair.address); const usdtBalance = getAssetBalance(usdtResult, 6, 2); // DOT balance (Asset 1001, 10 decimals) // eslint-disable-next-line @typescript-eslint/no-explicit-any const dotResult = await (assetHubApi.query.assets as any).account(1001, keypair.address); const dotBalance = getAssetBalance(dotResult, 10, 4); // Update all balances at once setBalances({ HEZ: hezBalance, PEZ: pezBalance, USDT: usdtBalance, DOT: dotBalance, }); } catch (err) { console.error('Failed to fetch balances:', err); } }; fetchBalances(); }, [assetHubApi, keypair, isOpen]); // Fetch exchange rate const fetchExchangeRate = useCallback(async () => { if (!assetHubApi || fromToken.symbol === toToken.symbol) { setExchangeRate(null); return; } setIsLoadingRate(true); try { // Sort assets for pool query (native token first) const fromId = fromToken.assetId; const toId = toToken.assetId; const [asset1, asset2] = fromId === NATIVE_TOKEN_ID ? [fromId, toId] : toId === NATIVE_TOKEN_ID ? [toId, fromId] : fromId < toId ? [fromId, toId] : [toId, fromId]; const poolKey = [formatAssetLocation(asset1), formatAssetLocation(asset2)]; // Check if pool exists const poolInfo = await assetHubApi.query.assetConversion.pools(poolKey); if (poolInfo && !poolInfo.isEmpty) { // Get quote from runtime API // USDT has 6 decimals, DOT has 10 decimals, others have 12 const getDecimals = (id: number) => { if (id === 1000) return 6; // USDT if (id === 1001) return 10; // DOT return 12; // HEZ, PEZ }; const decimals1 = getDecimals(asset1); const decimals2 = getDecimals(asset2); const oneUnit = BigInt(Math.pow(10, decimals1)); const quote = await ( assetHubApi.call as any ).assetConversionApi.quotePriceExactTokensForTokens( formatAssetLocation(asset1), formatAssetLocation(asset2), oneUnit.toString(), true ); if (quote && !quote.isNone) { const priceRaw = quote.unwrap().toString(); const price = Number(BigInt(priceRaw)) / Math.pow(10, decimals2); // Calculate rate based on direction const rate = fromId === asset1 ? price : 1 / price; setExchangeRate(rate); } else { setExchangeRate(null); } } else { setExchangeRate(null); } } catch (err) { console.error('Failed to fetch exchange rate:', err); setExchangeRate(null); } finally { setIsLoadingRate(false); } }, [assetHubApi, fromToken, toToken]); // Fetch rate when tokens change useEffect(() => { if (isOpen) { fetchExchangeRate(); } }, [isOpen, fetchExchangeRate]); // Calculate toAmount when fromAmount or rate changes useEffect(() => { if (fromAmount && exchangeRate) { const calculated = parseFloat(fromAmount) * exchangeRate; setToAmount(calculated.toFixed(toToken.decimals === 6 ? 2 : 4)); } else { setToAmount(''); } }, [fromAmount, exchangeRate, toToken.decimals]); // Swap tokens const handleSwapTokens = () => { hapticImpact('light'); const temp = fromToken; setFromToken(toToken); setToToken(temp); setFromAmount(''); setToAmount(''); }; // Execute swap const handleSwap = async () => { if (!assetHubApi || !keypair || !fromAmount || !exchangeRate) return; const fromBalance = parseFloat(balances[fromToken.symbol] || '0'); const swapAmount = parseFloat(fromAmount); if (swapAmount > fromBalance) { setError('Bakiye têrê nake'); hapticNotification('error'); return; } setIsSwapping(true); setError(''); try { const amountIn = BigInt(Math.floor(swapAmount * Math.pow(10, fromToken.decimals))); const minAmountOut = BigInt( Math.floor(parseFloat(toAmount) * 0.95 * Math.pow(10, toToken.decimals)) ); // 5% slippage // Build swap path using XCM Locations const fromLocation = formatAssetLocation(fromToken.assetId); const toLocation = formatAssetLocation(toToken.assetId); // Check if we need multi-hop (e.g., PEZ -> USDT needs PEZ -> HEZ -> USDT) let path; if (fromToken.assetId !== NATIVE_TOKEN_ID && toToken.assetId !== NATIVE_TOKEN_ID) { // Multi-hop through native token const nativeLocation = formatAssetLocation(NATIVE_TOKEN_ID); path = [fromLocation, nativeLocation, toLocation]; } else { path = [fromLocation, toLocation]; } // eslint-disable-next-line @typescript-eslint/no-explicit-any const tx = (assetHubApi.tx.assetConversion as any).swapExactTokensForTokens( path, amountIn.toString(), minAmountOut.toString(), keypair.address, true ); // Wait for transaction to be finalized await new Promise((resolve, reject) => { tx.signAndSend( keypair, ({ status, dispatchError }: { status: any; dispatchError: any }) => { if (status.isFinalized) { if (dispatchError) { let errorMsg = 'Swap neserketî'; if (dispatchError.isModule) { const decoded = assetHubApi.registry.findMetaError(dispatchError.asModule); errorMsg = `${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`; } else if (dispatchError.toString) { errorMsg = dispatchError.toString(); } console.error('Swap error:', errorMsg); reject(new Error(errorMsg)); } else { resolve(); } } } ).catch(reject); }); setSuccess(true); hapticNotification('success'); // Reset after success setTimeout(() => { setSuccess(false); setFromAmount(''); setToAmount(''); onClose(); }, 2000); } catch (err) { console.error('Swap failed:', err); setError(err instanceof Error ? err.message : 'Swap neserketî'); hapticNotification('error'); } finally { setIsSwapping(false); } }; if (!isOpen) return null; if (success) { return (

Swap Serketî!

{fromAmount} {fromToken.symbol} → {toAmount} {toToken.symbol}

); } return (
{/* Header */}

Token Swap

{/* Content */}
{/* From Token */}
Ji (From)
setFromAmount(e.target.value)} placeholder="0.00" className="w-full bg-transparent text-2xl font-bold outline-none" />
Bakiye: {balances[fromToken.symbol]} {fromToken.symbol}
{/* Swap Button */}
{/* To Token */}
Bo (To)
Bakiye: {balances[toToken.symbol]} {toToken.symbol}
{/* Exchange Rate */}
Rêjeya Guherandinê {isLoadingRate ? ( ) : exchangeRate ? ( `1 ${fromToken.symbol} = ${exchangeRate.toFixed(4)} ${toToken.symbol}` ) : ( Pool tune )}
Slippage 5%
{/* Error */} {error && (
{error}
)} {/* Swap Button */} {/* Swap Button or Loading Animation */} {isSwapping ? (

Tê guhertin...

) : ( )}
); }