/** * 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'; import { useTranslation } from '@/i18n'; 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' }, 1001: { symbol: 'DOT', decimals: 10, icon: '/tokens/DOT.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 }] } }; }; // Minimal shapes for the dynamic @pezkuwi/api results we read here. These // pallets/runtime-calls are not in the base typings, so we describe just the // fields actually used and cast via `as unknown as`. type AccountInfoResult = { data: { free: { toString(): string } } }; type AssetAccountResult = { isSome: boolean; unwrap(): { balance: { toString(): string } }; }; type QuoteResult = { isNone: boolean; unwrap(): { toString(): string } }; type AssetConversionCall = { assetConversionApi: { quotePriceExactTokensForTokens: ( asset0: ReturnType, asset1: ReturnType, amount: string, includeFee: boolean ) => Promise; }; }; export function PoolsModal({ isOpen, onClose }: PoolsModalProps) { const { assetHubApi, keypair } = useWallet(); const { hapticImpact, hapticNotification } = useTelegram(); const { t } = useTranslation(); 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.account(keypair.address), 10000 )) as unknown as AccountInfoResult; 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.account(1, keypair.address), 10000 )) as unknown as AssetAccountResult; 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.account(1000, keypair.address), 10000 )) as unknown as AssetAccountResult; 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 [NATIVE_TOKEN_ID, 1001], // HEZ-DOT ]; 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 unknown as AssetConversionCall ).assetConversionApi.quotePriceExactTokensForTokens( formatAssetLocation(asset0), formatAssetLocation(asset1), oneUnit.toString(), true ), 10000 ); if (quote && !quote.isNone) { price = Number(BigInt(quote.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(t('pools.connectionError')); } } finally { if (!isCancelled) { setIsLoading(false); } } }; fetchData(); return () => { isCancelled = true; }; // `t` is intentionally omitted: it is only used for error messages and its // identity changes only on language switch — including it would re-run the // blockchain fetch (extra RPC calls) on every language change. // eslint-disable-next-line react-hooks/exhaustive-deps }, [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 ); // Minimal shape of the signAndSend result fields we read. `asModule` // is typed from the registry call so it stays type-safe. type TxResult = { status: { isFinalized: boolean }; dispatchError?: { isModule: boolean; asModule: Parameters[0]; toString(): string; }; }; // Wait for transaction to be finalized await new Promise((resolve, reject) => { tx.signAndSend(keypair, ({ status, dispatchError }: TxResult) => { if (status.isFinalized) { if (dispatchError) { let errorMsg = t('pools.addFailed'); 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( t('pools.addedLiquidity', { amount0, token0: selectedPool.asset0Symbol, amount1, token1: selectedPool.asset1Symbol, }) ); 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 : t('pools.addFailed')); 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(t('pools.invalidLpAmount')); 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 ); // Minimal shape of the signAndSend result fields we read. `asModule` // is typed from the registry call so it stays type-safe. type TxResult = { status: { isFinalized: boolean }; dispatchError?: { isModule: boolean; asModule: Parameters[0]; toString(): string; }; }; // Wait for transaction to be finalized await new Promise((resolve, reject) => { tx.signAndSend(keypair, ({ status, dispatchError }: TxResult) => { if (status.isFinalized) { if (dispatchError) { let errorMsg = t('pools.removeFailed'); 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(t('pools.removedLiquidity', { amount: lpAmountToRemove })); 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 : t('pools.removeFailed')); hapticNotification('error'); } finally { setIsSubmitting(false); } }; if (!isOpen) return null; // Success screen if (success) { return (

{t('pools.success')}

{successMessage}

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

{t('pools.addLiquidity')}

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

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

{/* Form */}
{/* Amount 0 */}
{selectedPool.asset0Symbol} Mîqdar {t('swap.balanceLabel')} {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 */}
{t('pools.amountAuto', { token: selectedPool.asset1Symbol })} {t('swap.balanceLabel')} {balances[selectedPool.asset1Symbol]}
{/* Error */} {error && (
{error}
)} {/* Submit Button or Loading Animation */} {isSubmitting ? (

{t('pools.adding')}

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

{t('pools.removeLiquidity')}

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

{t('pools.lpBalance')} {selectedPool.userLpBalance?.toFixed(4) || '0'} LP

{/* Form */}
{/* LP Amount */}
{t('pools.lpTokenAmount')} 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 && (

{t('pools.estimatedReturn')}

{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('pools.removing')}

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

{t('pools.title')}

{/* Content */}
{isLoading ? (

{t('pools.loadingPools')}

) : pools.length === 0 ? (

{t('pools.noPools')}

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

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

{t('pools.reserve')} {pool.asset1Symbol}

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

{/* User Position */} {pool.userLpBalance && pool.userLpBalance > 0 && (
{t('pools.yourPosition')} {pool.userShare?.toFixed(2)}%
LP Token: {pool.userLpBalance.toFixed(4)}
)} {/* Action Buttons */}
{pool.userLpBalance && pool.userLpBalance > 0 && ( )}
)) )}
); }