/** * Tokens Card Component * Token management with search, add/remove, show/hide functionality * Fetches live data from blockchain and CoinGecko prices */ import { useState, useEffect, useCallback } from 'react'; import { Coins, Search, Plus, Settings, Eye, EyeOff, X, ChevronDown, ChevronUp, RefreshCw, Send, TrendingUp, TrendingDown, Fuel, } from 'lucide-react'; import { useWallet } from '@/contexts/WalletContext'; import { useTelegram } from '@/hooks/useTelegram'; import { subscribeToConnection, getLastError, getAssetHubAPI, getPeopleAPI, getConnectionState, } from '@/lib/rpc-manager'; import { FundFeesModal } from './FundFeesModal'; // Asset IDs matching pwap/web configuration const ASSET_IDS = { WHEZ: 0, // Wrapped HEZ (12 decimals) PEZ: 1, // PEZ token (12 decimals) WUSDT: 1000, // Wrapped USDT (6 decimals) - displayed as USDT BTC: 4, // wBTC ETH: 5, // wETH DOT: 6, // wDOT BNB: 7, // wBNB (assumed) }; // LP Token IDs (from poolAssets pallet) const LP_TOKEN_IDS = { HEZ_PEZ: 0, // HEZ-PEZ LP (12 decimals) HEZ_USDT: 1, // HEZ-USDT LP (12 decimals) }; // CoinGecko ID mapping for tokens const COINGECKO_IDS: Record = { DOT: 'polkadot', BTC: 'bitcoin', ETH: 'ethereum', BNB: 'binancecoin', USDT: 'tether', HEZ: 'hez-token', // Will fallback to DOT/3 if not found PEZ: 'pez-token', // Will fallback to DOT/10 if not found }; interface PriceData { usd: number; usd_24h_change: number; } // Token configurations interface TokenConfig { assetId: number; symbol: string; displaySymbol: string; name: string; decimals: number; logo: string; isDefault: boolean; priority: number; // Lower = higher in list } const DEFAULT_TOKENS: TokenConfig[] = [ { assetId: -1, // Special: native token symbol: 'HEZ', displaySymbol: 'HEZ', name: 'HEZ Native Token', decimals: 12, logo: '/tokens/HEZ.png', isDefault: true, priority: 0, }, { assetId: ASSET_IDS.PEZ, symbol: 'PEZ', displaySymbol: 'PEZ', name: 'PEZ Governance Token', decimals: 12, logo: '/tokens/PEZ.png', isDefault: true, priority: 1, }, { assetId: ASSET_IDS.WUSDT, symbol: 'wUSDT', displaySymbol: 'USDT', // User sees USDT, backend uses wUSDT name: 'Tether USD', decimals: 6, logo: '/tokens/USDT.png', isDefault: true, priority: 2, }, { assetId: ASSET_IDS.DOT, symbol: 'wDOT', displaySymbol: 'DOT', name: 'Polkadot', decimals: 12, logo: '/tokens/DOT.png', isDefault: true, priority: 3, }, { assetId: ASSET_IDS.BTC, symbol: 'wBTC', displaySymbol: 'BTC', name: 'Bitcoin', decimals: 12, logo: '/tokens/BTC.png', isDefault: true, priority: 4, }, { assetId: ASSET_IDS.ETH, symbol: 'wETH', displaySymbol: 'ETH', name: 'Ethereum', decimals: 12, logo: '/tokens/ETH.png', isDefault: true, priority: 5, }, { assetId: ASSET_IDS.BNB, symbol: 'wBNB', displaySymbol: 'BNB', name: 'BNB', decimals: 12, logo: '/tokens/BNB.png', isDefault: true, priority: 6, }, ]; // LP Token configurations (separate from regular tokens) const LP_TOKENS: TokenConfig[] = [ { assetId: -100 - LP_TOKEN_IDS.HEZ_PEZ, // Use negative IDs starting from -100 to distinguish from regular tokens symbol: 'HEZ-PEZ-LP', displaySymbol: 'HEZ-PEZ LP', name: 'HEZ-PEZ Liquidity Pool', decimals: 12, logo: '', // Uses initials fallback isDefault: true, priority: 10, }, { assetId: -100 - LP_TOKEN_IDS.HEZ_USDT, // -101 symbol: 'HEZ-USDT-LP', displaySymbol: 'HEZ-USDT LP', name: 'HEZ-USDT Liquidity Pool', decimals: 12, logo: '', // Uses initials fallback isDefault: true, priority: 11, }, ]; interface TokenBalance extends TokenConfig { balance: string; isHidden: boolean; priceUsd?: number; priceChange24h?: number; valueUsd?: number; } interface Props { onSendToken?: (token: TokenBalance) => void; } export function TokensCard({ onSendToken }: Props) { const { address, balance: hezBalance } = useWallet(); const { hapticImpact } = useTelegram(); const [rpcConnected, setRpcConnected] = useState(false); const [endpointName, setEndpointName] = useState(null); // Track RPC connection state useEffect(() => { // Get initial state const initialState = getConnectionState(); setRpcConnected(initialState.isConnected); setEndpointName(initialState.endpoint?.name || null); // Subscribe to changes const unsubscribe = subscribeToConnection((connected, endpoint) => { setRpcConnected(connected); setEndpointName(endpoint?.name || null); }); return () => unsubscribe(); }, []); // Fetch multi-chain HEZ balances (Asset Hub & People Chain) useEffect(() => { if (!address) return; const fetchMultiChainBalances = async () => { // Asset Hub HEZ balance const assetHubApi = getAssetHubAPI(); if (assetHubApi) { try { // eslint-disable-next-line @typescript-eslint/no-explicit-any const accountInfo = (await (assetHubApi.query.system as any).account(address)) as { data: { free: { toString(): string } }; }; const free = accountInfo.data.free.toString(); const balanceNum = Number(free) / 1e12; setAssetHubHezBalance(balanceNum.toFixed(4)); } catch (err) { console.error('Error fetching Asset Hub HEZ balance:', err); setAssetHubHezBalance('0.0000'); } } // People Chain HEZ balance const peopleApi = getPeopleAPI(); if (peopleApi) { try { // eslint-disable-next-line @typescript-eslint/no-explicit-any const accountInfo = (await (peopleApi.query.system as any).account(address)) as { data: { free: { toString(): string } }; }; const free = accountInfo.data.free.toString(); const balanceNum = Number(free) / 1e12; setPeopleHezBalance(balanceNum.toFixed(4)); } catch (err) { console.error('Error fetching People Chain HEZ balance:', err); setPeopleHezBalance('0.0000'); } } }; fetchMultiChainBalances(); // Refresh every 30 seconds const interval = setInterval(fetchMultiChainBalances, 30000); return () => clearInterval(interval); }, [address, rpcConnected]); // Initialize with default tokens immediately (no API required) const [tokens, setTokens] = useState(() => DEFAULT_TOKENS.map((config) => ({ ...config, balance: '--', // Placeholder until connected isHidden: false, })) ); const [isLoading, setIsLoading] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [showSettings, setShowSettings] = useState(false); const [showAddToken, setShowAddToken] = useState(false); const [newAssetId, setNewAssetId] = useState(''); const [isExpanded, setIsExpanded] = useState(true); const [hiddenTokens, setHiddenTokens] = useState(() => { const stored = localStorage.getItem('hiddenTokens'); return stored ? JSON.parse(stored) : []; }); const [customTokenIds, setCustomTokenIds] = useState(() => { const stored = localStorage.getItem('customTokenIds'); return stored ? JSON.parse(stored) : []; }); const [prices, setPrices] = useState>({}); const [isPriceLoading, setIsPriceLoading] = useState(false); // Multi-chain HEZ balances const [assetHubHezBalance, setAssetHubHezBalance] = useState('--'); const [peopleHezBalance, setPeopleHezBalance] = useState('--'); const [showFundFeesModal, setShowFundFeesModal] = useState(false); // Fetch prices from CoinGecko const fetchPrices = useCallback(async () => { setIsPriceLoading(true); try { // Fetch prices for known tokens const ids = Object.values(COINGECKO_IDS).join(','); const response = await fetch( `https://api.coingecko.com/api/v3/simple/price?ids=${ids}&vs_currencies=usd&include_24hr_change=true` ); if (!response.ok) { throw new Error('CoinGecko API error'); } const data = await response.json(); // Map CoinGecko response to our token symbols const priceMap: Record = {}; for (const [symbol, geckoId] of Object.entries(COINGECKO_IDS)) { if (data[geckoId]) { priceMap[symbol] = { usd: data[geckoId].usd, usd_24h_change: data[geckoId].usd_24h_change || 0, }; } } // Calculate HEZ and PEZ prices from DOT if not available on CoinGecko const dotPrice = priceMap['DOT']; if (dotPrice) { // HEZ = DOT / 3 (if not found on CoinGecko) if (!priceMap['HEZ']) { priceMap['HEZ'] = { usd: dotPrice.usd / 3, usd_24h_change: dotPrice.usd_24h_change, // Use DOT's change }; } // PEZ = DOT / 10 (if not found on CoinGecko) if (!priceMap['PEZ']) { priceMap['PEZ'] = { usd: dotPrice.usd / 10, usd_24h_change: dotPrice.usd_24h_change, // Use DOT's change }; } } setPrices(priceMap); } catch (err) { console.error('Failed to fetch prices:', err); } finally { setIsPriceLoading(false); } }, []); // Fetch prices on mount and every 60 seconds useEffect(() => { fetchPrices(); const interval = setInterval(fetchPrices, 60000); return () => clearInterval(interval); }, [fetchPrices]); // Update tokens with price data (works without API) const updateTokensWithPrices = useCallback(() => { setTokens((prev) => prev.map((token) => { const priceData = prices[token.displaySymbol]; const numBalance = parseFloat(token.balance) || 0; return { ...token, isHidden: hiddenTokens.includes(token.assetId), priceUsd: priceData?.usd, priceChange24h: priceData?.usd_24h_change, valueUsd: priceData?.usd && token.balance !== '--' ? numBalance * priceData.usd : undefined, }; }) ); }, [prices, hiddenTokens]); // Fetch token balances from blockchain (only when API is available) const fetchTokenBalances = useCallback(async () => { // Update prices even without API updateTokensWithPrices(); // Get Asset Hub API from rpc-manager (PEZ, USDT etc are on Asset Hub) // Note: Native HEZ balance comes from WalletContext (hezBalance), not directly from API const assetHubApi = getAssetHubAPI(); // If no address, keep showing "--" for balances if (!address) { return; } setIsLoading(true); try { // Fetch all tokens (default + custom + LP tokens) const allTokenConfigs = [ ...DEFAULT_TOKENS, ...LP_TOKENS, // Include LP tokens ...customTokenIds .filter((id) => !DEFAULT_TOKENS.find((t) => t.assetId === id)) .map((id) => ({ assetId: id, symbol: `Token #${id}`, displaySymbol: `Token #${id}`, name: `Custom Token`, decimals: 12, logo: '', isDefault: false, priority: 100 + id, })), ]; const tokenBalances: TokenBalance[] = []; for (const config of allTokenConfigs) { let balance = '0'; if (config.assetId === -1) { // Native HEZ balance (from relay chain) balance = hezBalance ?? '0.0000'; } else if (config.assetId <= -100) { // LP Token balance (from poolAssets pallet) // Convert back to pool asset ID: -100 - assetId const poolAssetId = -100 - config.assetId; if (!assetHubApi) { balance = '--'; } else { try { // eslint-disable-next-line @typescript-eslint/no-explicit-any const lpBalance = await (assetHubApi.query.poolAssets as any).account( poolAssetId, address ); if (lpBalance && lpBalance.isSome) { const data = lpBalance.unwrap(); const rawBalance = data.balance.toString(); const numBalance = parseInt(rawBalance) / Math.pow(10, config.decimals); // Show more decimals for LP tokens to catch small amounts balance = numBalance < 0.0001 && numBalance > 0 ? numBalance.toExponential(2) : numBalance.toFixed(4); } else { balance = '0.0000'; } } catch { balance = '0.0000'; } } } else { // Asset balance - PEZ, USDT etc are on Asset Hub! // Use Asset Hub API instead of relay chain API if (!assetHubApi) { balance = '--'; // Asset Hub not connected } else { try { // eslint-disable-next-line @typescript-eslint/no-explicit-any const assetBalance = await (assetHubApi.query.assets as any).account( config.assetId, address ); if (assetBalance && assetBalance.isSome) { const data = assetBalance.unwrap(); const rawBalance = data.balance.toString(); const formatted = (parseInt(rawBalance) / Math.pow(10, config.decimals)).toFixed( config.decimals === 6 ? 2 : 4 ); balance = formatted; } else { // User has no balance for this asset balance = config.decimals === 6 ? '0.00' : '0.0000'; } } catch { // Token might not exist or user has no balance balance = config.decimals === 6 ? '0.00' : '0.0000'; } } } // Add price data const priceData = prices[config.displaySymbol]; const numBalance = parseFloat(balance) || 0; tokenBalances.push({ ...config, balance, isHidden: hiddenTokens.includes(config.assetId), priceUsd: priceData?.usd, priceChange24h: priceData?.usd_24h_change, valueUsd: priceData?.usd ? numBalance * priceData.usd : undefined, }); } // Sort by priority tokenBalances.sort((a, b) => a.priority - b.priority); setTokens(tokenBalances); } catch (err) { console.error('Failed to fetch token balances:', err); } finally { setIsLoading(false); } }, [address, hezBalance, hiddenTokens, customTokenIds, prices, updateTokensWithPrices]); useEffect(() => { fetchTokenBalances(); }, [fetchTokenBalances]); // Toggle token visibility const toggleTokenVisibility = (assetId: number) => { hapticImpact('light'); setHiddenTokens((prev) => { const newHidden = prev.includes(assetId) ? prev.filter((id) => id !== assetId) : [...prev, assetId]; localStorage.setItem('hiddenTokens', JSON.stringify(newHidden)); return newHidden; }); }; // Add custom token const handleAddToken = () => { const id = parseInt(newAssetId); if (isNaN(id) || id < 0) return; if (customTokenIds.includes(id) || DEFAULT_TOKENS.find((t) => t.assetId === id)) { return; // Already exists } hapticImpact('medium'); const newIds = [...customTokenIds, id]; setCustomTokenIds(newIds); localStorage.setItem('customTokenIds', JSON.stringify(newIds)); setNewAssetId(''); setShowAddToken(false); fetchTokenBalances(); }; // Remove custom token const removeCustomToken = (assetId: number) => { hapticImpact('medium'); const newIds = customTokenIds.filter((id) => id !== assetId); setCustomTokenIds(newIds); localStorage.setItem('customTokenIds', JSON.stringify(newIds)); setTokens((prev) => prev.filter((t) => t.assetId !== assetId)); }; // Filter tokens based on search const filteredTokens = tokens.filter((token) => { if (!searchQuery) return !token.isHidden || showSettings; const query = searchQuery.toLowerCase(); return ( token.displaySymbol.toLowerCase().includes(query) || token.name.toLowerCase().includes(query) || token.symbol.toLowerCase().includes(query) ); }); // Get token gradient based on symbol const getTokenGradient = (symbol: string) => { const gradients: Record = { HEZ: 'from-green-500/20 to-yellow-500/20 border-green-500/30', PEZ: 'from-blue-500/20 to-purple-500/20 border-blue-500/30', USDT: 'from-emerald-500/20 to-teal-500/20 border-emerald-500/30', DOT: 'from-pink-500/20 to-purple-500/20 border-pink-500/30', BTC: 'from-orange-500/20 to-yellow-500/20 border-orange-500/30', ETH: 'from-blue-500/20 to-indigo-500/20 border-blue-500/30', BNB: 'from-yellow-500/20 to-orange-500/20 border-yellow-500/30', 'HEZ-PEZ LP': 'from-green-500/20 to-blue-500/20 border-cyan-500/30', 'HEZ-USDT LP': 'from-green-500/20 to-emerald-500/20 border-teal-500/30', }; return gradients[symbol] || 'from-gray-500/20 to-gray-600/20 border-gray-500/30'; }; return (
{/* Header */}
setIsExpanded(!isExpanded)} >

Tokens

({tokens.filter((t) => !t.isHidden).length})
{isExpanded ? ( ) : ( )}
{isExpanded && ( <> {/* Search & Add */}
setSearchQuery(e.target.value)} placeholder="Token bigere..." className="w-full pl-9 pr-4 py-2 bg-background rounded-lg text-sm" />
{showSettings && ( )} {showAddToken && (
setNewAssetId(e.target.value)} placeholder="Asset ID binivîse (mînak: 3)" className="w-full px-3 py-2 bg-muted rounded-lg text-sm" min="0" />
)}
{/* Connection Status Banner */} {rpcConnected ? (

Pezkuwichain Girêdayî

{endpointName &&

{endpointName}

}
) : (

Girêdana Blockchain...

{getLastError() || 'RPC serverê tê girêdan...'}

)} {/* Token List */}
{filteredTokens.length === 0 ? (

Token nehat dîtin

) : ( filteredTokens.map((token) => // Special rendering for HEZ (multi-chain) token.assetId === -1 ? (
{/* HEZ Header */}
{token.displaySymbol}
{token.displaySymbol} Multi-Chain
{token.priceUsd !== undefined ? ( <> ${token.priceUsd.toFixed(token.priceUsd < 1 ? 4 : 2)} {token.priceChange24h !== undefined && ( = 0 ? 'text-green-400' : 'text-red-400' }`} > {token.priceChange24h >= 0 ? ( ) : ( )} {Math.abs(token.priceChange24h).toFixed(2)}% )} ) : ( {token.name} )}
{/* Multi-chain balances */}
{/* Relay Chain */}
Relay Chain
{token.balance} HEZ
{/* Asset Hub */}
Asset Hub {parseFloat(assetHubHezBalance) < 0.1 && assetHubHezBalance !== '--' && ( ⚠️ )}
{assetHubHezBalance} HEZ
{/* People Chain */}
People Chain {parseFloat(peopleHezBalance) < 0.1 && peopleHezBalance !== '--' && ( ⚠️ )}
{peopleHezBalance} HEZ
{/* Total Value */} {token.valueUsd !== undefined && token.balance !== '--' && (
Toplam ≈ $ {( (parseFloat(token.balance) + parseFloat(assetHubHezBalance === '--' ? '0' : assetHubHezBalance) + parseFloat(peopleHezBalance === '--' ? '0' : peopleHezBalance)) * (token.priceUsd || 0) ).toFixed(2)}
)}
) : ( // Regular token card
{token.logo ? ( {token.displaySymbol} ) : (
{token.displaySymbol.slice(0, 2)}
)}
{token.displaySymbol} {token.assetId <= -100 && ( LP )} {!token.isDefault && ( Custom )}
{token.priceUsd !== undefined ? ( <> ${token.priceUsd.toFixed(token.priceUsd < 1 ? 4 : 2)} {token.priceChange24h !== undefined && ( = 0 ? 'text-green-400' : 'text-red-400' }`} > {token.priceChange24h >= 0 ? ( ) : ( )} {Math.abs(token.priceChange24h).toFixed(2)}% )} ) : ( {token.name} )}

{token.balance}

{token.balance === '--' ? (

Tê barkirin...

) : token.valueUsd !== undefined ? (

≈ ${token.valueUsd.toFixed(2)}

) : (

{token.displaySymbol}

)}
{showSettings ? (
{!token.isDefault && ( )}
) : ( onSendToken && token.balance !== '--' && parseFloat(token.balance) > 0 && ( ) )}
) ) )}
)} {/* Fund Fees Modal for XCM Teleport */} setShowFundFeesModal(false)} />
); }