/** * Pools Modal Component * Mobile-optimized liquidity pools interface for Telegram miniapp */ import { useState, useEffect } from 'react'; import { X, Droplets, Plus, Minus, AlertCircle, Check } from 'lucide-react'; import { useWallet } from '@/contexts/WalletContext'; import { useTelegram } from '@/hooks/useTelegram'; import { KurdistanSun } from '@/components/KurdistanSun'; interface PoolsModalProps { isOpen: boolean; onClose: () => void; } interface Pool { id: string; asset0: number; asset1: number; asset0Symbol: string; asset1Symbol: string; asset0Decimals: number; asset1Decimals: number; lpTokenId: number; reserve0: number; reserve1: number; price: number; userLpBalance?: number; userShare?: number; } // Native token ID const NATIVE_TOKEN_ID = -1; // Token info mapping const TOKEN_INFO: Record = { [-1]: { symbol: 'HEZ', decimals: 12, icon: '/tokens/HEZ.png' }, 1: { symbol: 'PEZ', decimals: 12, icon: '/tokens/PEZ.png' }, 1000: { symbol: 'USDT', decimals: 6, icon: '/tokens/USDT.png' }, }; // 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 PoolsModal({ isOpen, onClose }: PoolsModalProps) { const { assetHubApi, keypair } = useWallet(); const { hapticImpact, hapticNotification } = useTelegram(); const [pools, setPools] = useState([]); const [isLoading, setIsLoading] = useState(false); const [selectedPool, setSelectedPool] = useState(null); const [isAddingLiquidity, setIsAddingLiquidity] = useState(false); const [isRemovingLiquidity, setIsRemovingLiquidity] = useState(false); const [amount0, setAmount0] = useState(''); const [amount1, setAmount1] = useState(''); const [lpAmountToRemove, setLpAmountToRemove] = useState(''); const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(''); const [success, setSuccess] = useState(false); const [successMessage, setSuccessMessage] = useState(''); // Token balances const [balances, setBalances] = useState>({ HEZ: '0', PEZ: '0', USDT: '0', }); // Fetch balances and pools useEffect(() => { if (!isOpen || !assetHubApi || !keypair) return; // Reset state when modal opens setError(''); setAmount0(''); setAmount1(''); setLpAmountToRemove(''); let isCancelled = false; const fetchData = async () => { setIsLoading(true); try { // Add timeout wrapper for API calls const withTimeout = (promise: Promise, ms: number): Promise => { return Promise.race([ promise, new Promise((_, reject) => setTimeout(() => reject(new Error('API call timeout')), ms) ), ]); }; // Fetch HEZ balance from Asset Hub (native token) const hezAccount = (await withTimeout( (assetHubApi.query.system as any).account(keypair.address), 10000 )) as any; if (isCancelled) return; const hezFree = hezAccount.data.free.toString(); setBalances((prev) => ({ ...prev, HEZ: (parseInt(hezFree) / 1e12).toFixed(4) })); const pezResult = (await withTimeout( (assetHubApi.query.assets as any).account(1, keypair.address), 10000 )) as any; if (isCancelled) return; if (pezResult.isSome) { setBalances((prev) => ({ ...prev, PEZ: (parseInt(pezResult.unwrap().balance.toString()) / 1e12).toFixed(4), })); } else { setBalances((prev) => ({ ...prev, PEZ: '0.0000' })); } const usdtResult = (await withTimeout( (assetHubApi.query.assets as any).account(1000, keypair.address), 10000 )) as any; if (isCancelled) return; if (usdtResult.isSome) { setBalances((prev) => ({ ...prev, USDT: (parseInt(usdtResult.unwrap().balance.toString()) / 1e6).toFixed(2), })); } else { setBalances((prev) => ({ ...prev, USDT: '0.00' })); } // Fetch pools const poolPairs: [number, number][] = [ [NATIVE_TOKEN_ID, 1], // HEZ-PEZ [NATIVE_TOKEN_ID, 1000], // HEZ-USDT ]; const fetchedPools: Pool[] = []; for (const [asset0, asset1] of poolPairs) { if (isCancelled) return; try { const poolKey = [formatAssetLocation(asset0), formatAssetLocation(asset1)]; const poolInfo = await withTimeout( assetHubApi.query.assetConversion.pools(poolKey), 10000 ); // eslint-disable-next-line @typescript-eslint/no-explicit-any if (poolInfo && !(poolInfo as any).isEmpty) { // eslint-disable-next-line @typescript-eslint/no-explicit-any const poolData = (poolInfo as any).unwrap().toJSON() as { lpToken: number }; const lpTokenId = poolData.lpToken; const token0 = TOKEN_INFO[asset0]; const token1 = TOKEN_INFO[asset1]; // Get price quote let price = 0; let reserve0 = 0; let reserve1 = 0; try { const oneUnit = BigInt(Math.pow(10, token0.decimals)); const quote = await withTimeout( (assetHubApi.call as any).assetConversionApi.quotePriceExactTokensForTokens( formatAssetLocation(asset0), formatAssetLocation(asset1), oneUnit.toString(), true ), 10000 ); // eslint-disable-next-line @typescript-eslint/no-explicit-any if (quote && !(quote as any).isNone) { price = Number(BigInt((quote as any).unwrap().toString())) / Math.pow(10, token1.decimals); // Estimate reserves from LP supply const lpAsset = await withTimeout( assetHubApi.query.poolAssets.asset(lpTokenId), 10000 ); // eslint-disable-next-line @typescript-eslint/no-explicit-any if ((lpAsset as any).isSome) { // eslint-disable-next-line @typescript-eslint/no-explicit-any const lpSupply = Number((lpAsset as any).unwrap().toJSON().supply) / 1e12; // Decimal correction factor for mixed-decimal pools const decimalFactor = Math.pow( 10, 12 - (token0.decimals + token1.decimals) / 2 ); const sqrtPrice = Math.sqrt(price); reserve0 = (lpSupply * decimalFactor) / sqrtPrice; reserve1 = lpSupply * decimalFactor * sqrtPrice; } } } catch (err) { console.warn('Could not fetch price for pool:', err); } // Get user's LP balance let userLpBalance = 0; let userShare = 0; try { const userLp = await withTimeout( assetHubApi.query.poolAssets.account(lpTokenId, keypair.address), 10000 ); // eslint-disable-next-line @typescript-eslint/no-explicit-any if ((userLp as any).isSome) { // eslint-disable-next-line @typescript-eslint/no-explicit-any userLpBalance = Number((userLp as any).unwrap().toJSON().balance) / 1e12; const lpAsset = await withTimeout( assetHubApi.query.poolAssets.asset(lpTokenId), 10000 ); // eslint-disable-next-line @typescript-eslint/no-explicit-any if ((lpAsset as any).isSome) { // eslint-disable-next-line @typescript-eslint/no-explicit-any const totalSupply = Number((lpAsset as any).unwrap().toJSON().supply) / 1e12; userShare = totalSupply > 0 ? (userLpBalance / totalSupply) * 100 : 0; } } } catch (err) { console.warn('Could not fetch user LP balance:', err); } fetchedPools.push({ id: `${asset0}-${asset1}`, asset0, asset1, asset0Symbol: token0.symbol, asset1Symbol: token1.symbol, asset0Decimals: token0.decimals, asset1Decimals: token1.decimals, lpTokenId, reserve0, reserve1, price, userLpBalance, userShare, }); } } catch (err) { console.warn('Pool not found:', err); } } if (!isCancelled) { setPools(fetchedPools); } } catch (err) { console.error('Failed to fetch pools:', err); if (!isCancelled) { setError('Bağlantı hatası - tekrar deneyin'); } } finally { if (!isCancelled) { setIsLoading(false); } } }; fetchData(); return () => { isCancelled = true; }; }, [isOpen, assetHubApi, keypair]); // Auto-calculate amount1 based on pool price useEffect(() => { if (selectedPool && amount0 && selectedPool.price > 0) { const calculated = parseFloat(amount0) * selectedPool.price; setAmount1(calculated.toFixed(selectedPool.asset1Decimals === 6 ? 2 : 4)); } else { setAmount1(''); } }, [amount0, selectedPool]); // Add liquidity const handleAddLiquidity = async () => { if (!assetHubApi || !keypair || !selectedPool || !amount0 || !amount1) return; setIsSubmitting(true); setError(''); try { const amt0 = BigInt( Math.floor(parseFloat(amount0) * Math.pow(10, selectedPool.asset0Decimals)) ); const amt1 = BigInt( Math.floor(parseFloat(amount1) * Math.pow(10, selectedPool.asset1Decimals)) ); const minAmt0 = (amt0 * BigInt(90)) / BigInt(100); // 10% slippage const minAmt1 = (amt1 * BigInt(90)) / BigInt(100); const asset0Location = formatAssetLocation(selectedPool.asset0); const asset1Location = formatAssetLocation(selectedPool.asset1); // eslint-disable-next-line @typescript-eslint/no-explicit-any const tx = (assetHubApi.tx.assetConversion as any).addLiquidity( asset0Location, asset1Location, amt0.toString(), amt1.toString(), minAmt0.toString(), minAmt1.toString(), keypair.address ); // 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 = 'Zêdekirin 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('Add liquidity error:', errorMsg); reject(new Error(errorMsg)); } else { resolve(); } } } ).catch(reject); }); setSuccessMessage( `${amount0} ${selectedPool.asset0Symbol} + ${amount1} ${selectedPool.asset1Symbol} hate zêdekirin` ); setSuccess(true); hapticNotification('success'); setTimeout(() => { setSuccess(false); setIsAddingLiquidity(false); setSelectedPool(null); setAmount0(''); setAmount1(''); }, 2000); } catch (err) { console.error('Add liquidity failed:', err); setError(err instanceof Error ? err.message : 'Zêdekirin neserketî'); hapticNotification('error'); } finally { setIsSubmitting(false); } }; // Remove liquidity const handleRemoveLiquidity = async () => { if (!assetHubApi || !keypair || !selectedPool || !lpAmountToRemove) return; const lpAmount = parseFloat(lpAmountToRemove); if (lpAmount <= 0 || lpAmount > (selectedPool.userLpBalance || 0)) { setError('Mîqdara LP ne derbasdar e'); hapticNotification('error'); return; } setIsSubmitting(true); setError(''); try { const lpAmountRaw = BigInt(Math.floor(lpAmount * 1e12)); // Calculate minimum amounts to receive (with 10% slippage) const userShare = ((lpAmount / (selectedPool.userLpBalance || 1)) * (selectedPool.userShare || 0)) / 100; const expectedAmt0 = selectedPool.reserve0 * userShare; const expectedAmt1 = selectedPool.reserve1 * userShare; const minAmt0 = BigInt( Math.floor(expectedAmt0 * 0.9 * Math.pow(10, selectedPool.asset0Decimals)) ); const minAmt1 = BigInt( Math.floor(expectedAmt1 * 0.9 * Math.pow(10, selectedPool.asset1Decimals)) ); const asset0Location = formatAssetLocation(selectedPool.asset0); const asset1Location = formatAssetLocation(selectedPool.asset1); // eslint-disable-next-line @typescript-eslint/no-explicit-any const tx = (assetHubApi.tx.assetConversion as any).removeLiquidity( asset0Location, asset1Location, lpAmountRaw.toString(), minAmt0.toString(), minAmt1.toString(), keypair.address ); // 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 = 'Derxistin 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('Remove liquidity error:', errorMsg); reject(new Error(errorMsg)); } else { resolve(); } } } ).catch(reject); }); setSuccessMessage(`${lpAmountToRemove} LP token hate vegerandin`); setSuccess(true); hapticNotification('success'); setTimeout(() => { setSuccess(false); setIsRemovingLiquidity(false); setSelectedPool(null); setLpAmountToRemove(''); }, 2000); } catch (err) { console.error('Remove liquidity failed:', err); setError(err instanceof Error ? err.message : 'Derxistin neserketî'); hapticNotification('error'); } finally { setIsSubmitting(false); } }; if (!isOpen) return null; // Success screen if (success) { return (

Serketî!

{successMessage}

); } // Add liquidity form if (isAddingLiquidity && selectedPool) { return (
{/* Header */}

Liquidity Zêde Bike

{/* Pool Info */}
{selectedPool.asset0Symbol}/{selectedPool.asset1Symbol}

1 {selectedPool.asset0Symbol} = {selectedPool.price.toFixed(4)}{' '} {selectedPool.asset1Symbol}

{/* Form */}
{/* Amount 0 */}
{selectedPool.asset0Symbol} Mîqdar Bakiye: {balances[selectedPool.asset0Symbol]}
setAmount0(e.target.value)} placeholder="0.00" className="flex-1 px-4 py-3 bg-muted rounded-xl text-lg font-mono" />
{/* Amount 1 */}
{selectedPool.asset1Symbol} Mîqdar (otomatîk) Bakiye: {balances[selectedPool.asset1Symbol]}
{/* Error */} {error && (
{error}
)} {/* Submit Button or Loading Animation */} {isSubmitting ? (

Tê zêdekirin...

) : ( )}
); } // Remove liquidity form if (isRemovingLiquidity && selectedPool) { return (
{/* Header */}

Liquidity Derxe

{/* Pool Info */}
{selectedPool.asset0Symbol}/{selectedPool.asset1Symbol}

LP Bakiye: {selectedPool.userLpBalance?.toFixed(4) || '0'} LP

{/* Form */}
{/* LP Amount */}
LP Token Mîqdar Max: {selectedPool.userLpBalance?.toFixed(4) || '0'}
setLpAmountToRemove(e.target.value)} placeholder="0.00" className="flex-1 px-4 py-3 bg-muted rounded-xl text-lg font-mono" />
{/* Estimated Returns */} {lpAmountToRemove && parseFloat(lpAmountToRemove) > 0 && (

Texmînî vegerandin:

{selectedPool.asset0Symbol} ~ {( (((parseFloat(lpAmountToRemove) / (selectedPool.userLpBalance || 1)) * (selectedPool.userShare || 0)) / 100) * selectedPool.reserve0 ).toFixed(4)}
{selectedPool.asset1Symbol} ~ {( (((parseFloat(lpAmountToRemove) / (selectedPool.userLpBalance || 1)) * (selectedPool.userShare || 0)) / 100) * selectedPool.reserve1 ).toFixed(selectedPool.asset1Decimals === 6 ? 2 : 4)}
)} {/* Error */} {error && (
{error}
)} {/* Submit Button or Loading Animation */} {isSubmitting ? (

Tê derxistin...

) : ( )}
); } // Pool list return (
{/* Header */}

Liquidity Pools

{/* Content */}
{isLoading ? (

Tê barkirin...

) : pools.length === 0 ? (

Pool tune

) : ( pools.map((pool) => (
{/* Pool Header */}
{pool.asset0Symbol} {pool.asset1Symbol}
{pool.asset0Symbol}/{pool.asset1Symbol}
{/* Pool Stats */}
Rezerv {pool.asset0Symbol}

{pool.reserve0.toLocaleString('en-US', { maximumFractionDigits: 0 })}

Rezerv {pool.asset1Symbol}

{pool.reserve1.toLocaleString('en-US', { maximumFractionDigits: 0 })}

{/* User Position */} {pool.userLpBalance && pool.userLpBalance > 0 && (
Pozîsyona Te {pool.userShare?.toFixed(2)}%
LP Token: {pool.userLpBalance.toFixed(4)}
)} {/* Action Buttons */}
{pool.userLpBalance && pool.userLpBalance > 0 && ( )}
)) )}
); }