From 4825ac383946616c2a48512e125db9025c33aa4f Mon Sep 17 00:00:00 2001 From: Kurdistan Tech Ministry Date: Wed, 5 Nov 2025 10:56:44 +0300 Subject: [PATCH] feat: Add USDT support, dynamic pricing, and balance validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major improvements to wallet dashboard and DEX functionality: ## USDT Integration - Add wUSDT (Asset ID 2) support with 6 decimal precision - Display USDT balances in wallet dashboard - Integrate USDT into swap and pool interfaces ## Dynamic Token Pricing - Fetch real-time HEZ and PEZ prices from liquidity pools - Calculate USD values using pool reserve ratios - Display live USD equivalent for token balances ## User Experience Improvements - Hide wrapped tokens (wHEZ, wUSDT) from user interface - Show only HEZ, PEZ, USDT as user-facing tokens - Handle wrapping/unwrapping transparently in backend - Add balance validation before swap transactions - Prevent insufficient balance swaps with clear warnings ## Pool Dashboard Enhancements - Support multiple pool pairs (HEZ/PEZ, HEZ/USDT, PEZ/USDT) - Dynamic pool selection interface - User-friendly token names throughout pool interface ## Technical Improvements - Correct decimal handling (6 for USDT, 12 for others) - Proper pool account ID derivation using blake2 hash - Balance subscriptions for real-time updates - Custom token support with Add Token modal 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/components/AccountBalance.tsx | 425 +++++++++++++++++++++++++++++- src/components/AddTokenModal.tsx | 117 ++++++++ src/components/PoolDashboard.tsx | 164 +++++++++--- src/components/TokenSwap.tsx | 112 ++++++-- src/components/TransferModal.tsx | 95 ++++--- src/contexts/WalletContext.tsx | 4 +- 6 files changed, 822 insertions(+), 95 deletions(-) create mode 100644 src/components/AddTokenModal.tsx diff --git a/src/components/AccountBalance.tsx b/src/components/AccountBalance.tsx index 10d0921a..fa15a2d8 100644 --- a/src/components/AccountBalance.tsx +++ b/src/components/AccountBalance.tsx @@ -1,8 +1,20 @@ import React, { useEffect, useState } from 'react'; import { usePolkadot } from '@/contexts/PolkadotContext'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { Wallet, TrendingUp, ArrowUpRight, ArrowDownRight, RefreshCw, Award } from 'lucide-react'; +import { Wallet, TrendingUp, ArrowUpRight, ArrowDownRight, RefreshCw, Award, Plus, Coins, Send } from 'lucide-react'; import { Button } from '@/components/ui/button'; +import { ASSET_IDS, getAssetSymbol } from '@/lib/wallet'; +import { AddTokenModal } from './AddTokenModal'; +import { TransferModal } from './TransferModal'; + +interface TokenBalance { + assetId: number; + symbol: string; + name: string; + balance: string; + decimals: number; + usdValue: number; +} export const AccountBalance: React.FC = () => { const { api, isApiReady, selectedAccount } = usePolkadot(); @@ -16,8 +28,189 @@ export const AccountBalance: React.FC = () => { total: '0', }); const [pezBalance, setPezBalance] = useState('0'); + const [usdtBalance, setUsdtBalance] = useState('0'); + const [hezUsdPrice, setHezUsdPrice] = useState(0); + const [pezUsdPrice, setPezUsdPrice] = useState(0); const [trustScore, setTrustScore] = useState('-'); const [isLoading, setIsLoading] = useState(false); + const [otherTokens, setOtherTokens] = useState([]); + const [isAddTokenModalOpen, setIsAddTokenModalOpen] = useState(false); + const [isTransferModalOpen, setIsTransferModalOpen] = useState(false); + const [selectedTokenForTransfer, setSelectedTokenForTransfer] = useState(null); + const [customTokenIds, setCustomTokenIds] = useState(() => { + const stored = localStorage.getItem('customTokenIds'); + return stored ? JSON.parse(stored) : []; + }); + + // Helper function to get asset decimals + const getAssetDecimals = (assetId: number): number => { + if (assetId === ASSET_IDS.WUSDT) return 6; // wUSDT has 6 decimals + return 12; // wHEZ, PEZ and others have 12 decimals by default + }; + + // Helper to decode hex string to UTF-8 + const hexToString = (hex: string): string => { + if (!hex || hex === '0x') return ''; + try { + const hexStr = hex.startsWith('0x') ? hex.slice(2) : hex; + const bytes = new Uint8Array(hexStr.match(/.{1,2}/g)?.map(byte => parseInt(byte, 16)) || []); + return new TextDecoder('utf-8').decode(bytes).replace(/\0/g, ''); + } catch { + return ''; + } + }; + + // Get token color based on assetId + const getTokenColor = (assetId: number) => { + const colors = { + [ASSET_IDS.WHEZ]: { bg: 'from-green-500/20 to-yellow-500/20', text: 'text-green-400', border: 'border-green-500/30' }, + [ASSET_IDS.WUSDT]: { bg: 'from-emerald-500/20 to-teal-500/20', text: 'text-emerald-400', border: 'border-emerald-500/30' }, + }; + return colors[assetId] || { bg: 'from-cyan-500/20 to-blue-500/20', text: 'text-cyan-400', border: 'border-cyan-500/30' }; + }; + + // Fetch token prices from pools using pool account ID + const fetchTokenPrices = async () => { + if (!api || !isApiReady) return; + + try { + console.log('💰 Fetching token prices from pools...'); + + // Import utilities for pool account derivation + const { stringToU8a } = await import('@polkadot/util'); + const { blake2AsU8a } = await import('@polkadot/util-crypto'); + const PALLET_ID = stringToU8a('py/ascon'); + + // Fetch wHEZ/wUSDT pool reserves (Asset 0 / Asset 2) + const whezPoolId = api.createType('(u32, u32)', [0, 2]); + const whezPalletIdType = api.createType('[u8; 8]', PALLET_ID); + const whezFullTuple = api.createType('([u8; 8], (u32, u32))', [whezPalletIdType, whezPoolId]); + const whezAccountHash = blake2AsU8a(whezFullTuple.toU8a(), 256); + const whezPoolAccountId = api.createType('AccountId32', whezAccountHash); + + const whezReserve0Query = await api.query.assets.account(0, whezPoolAccountId); + const whezReserve1Query = await api.query.assets.account(2, whezPoolAccountId); + + if (whezReserve0Query.isSome && whezReserve1Query.isSome) { + const reserve0Data = whezReserve0Query.unwrap(); + const reserve1Data = whezReserve1Query.unwrap(); + + const reserve0 = BigInt(reserve0Data.balance.toString()); // wHEZ (12 decimals) + const reserve1 = BigInt(reserve1Data.balance.toString()); // wUSDT (6 decimals) + + // Calculate price: 1 HEZ = ? USD + const hezPrice = Number(reserve1 * BigInt(10 ** 12)) / Number(reserve0 * BigInt(10 ** 6)); + console.log('✅ HEZ price:', hezPrice, 'USD'); + setHezUsdPrice(hezPrice); + } else { + console.warn('⚠️ wHEZ/wUSDT pool has no reserves'); + } + + // Fetch PEZ/wUSDT pool reserves (Asset 1 / Asset 2) + const pezPoolId = api.createType('(u32, u32)', [1, 2]); + const pezPalletIdType = api.createType('[u8; 8]', PALLET_ID); + const pezFullTuple = api.createType('([u8; 8], (u32, u32))', [pezPalletIdType, pezPoolId]); + const pezAccountHash = blake2AsU8a(pezFullTuple.toU8a(), 256); + const pezPoolAccountId = api.createType('AccountId32', pezAccountHash); + + const pezReserve0Query = await api.query.assets.account(1, pezPoolAccountId); + const pezReserve1Query = await api.query.assets.account(2, pezPoolAccountId); + + if (pezReserve0Query.isSome && pezReserve1Query.isSome) { + const reserve0Data = pezReserve0Query.unwrap(); + const reserve1Data = pezReserve1Query.unwrap(); + + const reserve0 = BigInt(reserve0Data.balance.toString()); // PEZ (12 decimals) + const reserve1 = BigInt(reserve1Data.balance.toString()); // wUSDT (6 decimals) + + // Calculate price: 1 PEZ = ? USD + const pezPrice = Number(reserve1 * BigInt(10 ** 12)) / Number(reserve0 * BigInt(10 ** 6)); + console.log('✅ PEZ price:', pezPrice, 'USD'); + setPezUsdPrice(pezPrice); + } else { + console.warn('⚠️ PEZ/wUSDT pool has no reserves'); + } + } catch (error) { + console.error('❌ Failed to fetch token prices:', error); + } + }; + + // Fetch other tokens (only custom tokens - wrapped tokens are backend-only) + const fetchOtherTokens = async () => { + if (!api || !isApiReady || !selectedAccount) return; + + try { + const tokens: TokenBalance[] = []; + + // IMPORTANT: Only show custom tokens added by user + // Wrapped tokens (wHEZ, wUSDT) are for backend operations only + // Core tokens (HEZ, PEZ) are shown in their own dedicated cards + const assetIdsToCheck = customTokenIds.filter((id) => + id !== ASSET_IDS.WHEZ && // Exclude wrapped tokens + id !== ASSET_IDS.WUSDT && + id !== ASSET_IDS.PEZ // Exclude core tokens + ); + + for (const assetId of assetIdsToCheck) { + try { + const assetBalance = await api.query.assets.account(assetId, selectedAccount.address); + const assetMetadata = await api.query.assets.metadata(assetId); + + if (assetBalance.isSome) { + const assetData = assetBalance.unwrap(); + const balance = assetData.balance.toString(); + + const metadata = assetMetadata.toJSON() as any; + + // Decode hex strings properly + let symbol = metadata.symbol || ''; + let name = metadata.name || ''; + + if (typeof symbol === 'string' && symbol.startsWith('0x')) { + symbol = hexToString(symbol); + } + if (typeof name === 'string' && name.startsWith('0x')) { + name = hexToString(name); + } + + // Fallback to known symbols if metadata is empty + if (!symbol || symbol.trim() === '') { + symbol = getAssetSymbol(assetId); + } + if (!name || name.trim() === '') { + name = symbol; + } + + const decimals = metadata.decimals || getAssetDecimals(assetId); + const balanceFormatted = (parseInt(balance) / Math.pow(10, decimals)).toFixed(6); + + // Simple USD calculation (would use real price feed in production) + let usdValue = 0; + if (assetId === ASSET_IDS.WUSDT) { + usdValue = parseFloat(balanceFormatted); // 1 wUSDT = 1 USD + } else if (assetId === ASSET_IDS.WHEZ) { + usdValue = parseFloat(balanceFormatted) * 0.5; // Placeholder price + } + + tokens.push({ + assetId, + symbol: symbol.trim(), + name: name.trim(), + balance: balanceFormatted, + decimals, + usdValue + }); + } + } catch (error) { + console.error(`Failed to fetch token ${assetId}:`, error); + } + } + + setOtherTokens(tokens); + } catch (error) { + console.error('Failed to fetch other tokens:', error); + } + }; const fetchBalance = async () => { if (!api || !isApiReady || !selectedAccount) return; @@ -26,14 +219,14 @@ export const AccountBalance: React.FC = () => { try { // Fetch HEZ balance const { data: balanceData } = await api.query.system.account(selectedAccount.address); - + const free = balanceData.free.toString(); const reserved = balanceData.reserved.toString(); - + // Convert from plancks to tokens (12 decimals) const decimals = 12; const divisor = Math.pow(10, decimals); - + const freeTokens = (parseInt(free) / divisor).toFixed(4); const reservedTokens = (parseInt(reserved) / divisor).toFixed(4); const totalTokens = ((parseInt(free) + parseInt(reserved)) / divisor).toFixed(4); @@ -47,7 +240,7 @@ export const AccountBalance: React.FC = () => { // Fetch PEZ balance (Asset ID: 1) try { const pezAssetBalance = await api.query.assets.account(1, selectedAccount.address); - + if (pezAssetBalance.isSome) { const assetData = pezAssetBalance.unwrap(); const pezAmount = assetData.balance.toString(); @@ -60,6 +253,31 @@ export const AccountBalance: React.FC = () => { console.error('Failed to fetch PEZ balance:', error); setPezBalance('0'); } + + // Fetch USDT balance (wUSDT - Asset ID: 2) + try { + const usdtAssetBalance = await api.query.assets.account(2, selectedAccount.address); + + if (usdtAssetBalance.isSome) { + const assetData = usdtAssetBalance.unwrap(); + const usdtAmount = assetData.balance.toString(); + const usdtDecimals = 6; // wUSDT has 6 decimals + const usdtDivisor = Math.pow(10, usdtDecimals); + const usdtTokens = (parseInt(usdtAmount) / usdtDivisor).toFixed(2); + setUsdtBalance(usdtTokens); + } else { + setUsdtBalance('0'); + } + } catch (error) { + console.error('Failed to fetch USDT balance:', error); + setUsdtBalance('0'); + } + + // Fetch token prices from pools + await fetchTokenPrices(); + + // Fetch other tokens + await fetchOtherTokens(); } catch (error) { console.error('Failed to fetch balance:', error); } finally { @@ -67,8 +285,36 @@ export const AccountBalance: React.FC = () => { } }; + // Add custom token handler + const handleAddToken = async (assetId: number) => { + if (customTokenIds.includes(assetId)) { + alert('Token already added!'); + return; + } + + // Update custom tokens list + const updatedTokenIds = [...customTokenIds, assetId]; + setCustomTokenIds(updatedTokenIds); + localStorage.setItem('customTokenIds', JSON.stringify(updatedTokenIds)); + + // Fetch the new token + await fetchOtherTokens(); + setIsAddTokenModalOpen(false); + }; + + // Remove token handler + const handleRemoveToken = (assetId: number) => { + const updatedTokenIds = customTokenIds.filter(id => id !== assetId); + setCustomTokenIds(updatedTokenIds); + localStorage.setItem('customTokenIds', JSON.stringify(updatedTokenIds)); + + // Remove from displayed tokens + setOtherTokens(prev => prev.filter(t => t.assetId !== assetId)); + }; + useEffect(() => { fetchBalance(); + fetchTokenPrices(); // Fetch token USD prices from pools // Fetch Trust Score const fetchTrustScore = async () => { @@ -91,6 +337,7 @@ export const AccountBalance: React.FC = () => { // Subscribe to HEZ balance updates let unsubscribeHez: () => void; let unsubscribePez: () => void; + let unsubscribeUsdt: () => void; const subscribeBalance = async () => { if (!api || !isApiReady || !selectedAccount) return; @@ -101,10 +348,10 @@ export const AccountBalance: React.FC = () => { ({ data: balanceData }) => { const free = balanceData.free.toString(); const reserved = balanceData.reserved.toString(); - + const decimals = 12; const divisor = Math.pow(10, decimals); - + const freeTokens = (parseInt(free) / divisor).toFixed(4); const reservedTokens = (parseInt(reserved) / divisor).toFixed(4); const totalTokens = ((parseInt(free) + parseInt(reserved)) / divisor).toFixed(4); @@ -138,6 +385,28 @@ export const AccountBalance: React.FC = () => { } catch (error) { console.error('Failed to subscribe to PEZ balance:', error); } + + // Subscribe to USDT balance (wUSDT - Asset ID: 2) + try { + unsubscribeUsdt = await api.query.assets.account( + 2, + selectedAccount.address, + (assetBalance) => { + if (assetBalance.isSome) { + const assetData = assetBalance.unwrap(); + const usdtAmount = assetData.balance.toString(); + const decimals = 6; // wUSDT has 6 decimals + const divisor = Math.pow(10, decimals); + const usdtTokens = (parseInt(usdtAmount) / divisor).toFixed(2); + setUsdtBalance(usdtTokens); + } else { + setUsdtBalance('0'); + } + } + ); + } catch (error) { + console.error('Failed to subscribe to USDT balance:', error); + } }; subscribeBalance(); @@ -145,6 +414,7 @@ export const AccountBalance: React.FC = () => { return () => { if (unsubscribeHez) unsubscribeHez(); if (unsubscribePez) unsubscribePez(); + if (unsubscribeUsdt) unsubscribeUsdt(); }; }, [api, isApiReady, selectedAccount]); @@ -189,7 +459,9 @@ export const AccountBalance: React.FC = () => { HEZ
- ≈ ${(parseFloat(balance.total) * 0.5).toFixed(2)} USD + {hezUsdPrice > 0 + ? `≈ $${(parseFloat(balance.total) * hezUsdPrice).toFixed(2)} USD` + : 'Price loading...'}
@@ -232,12 +504,37 @@ export const AccountBalance: React.FC = () => { PEZ
+ {pezUsdPrice > 0 + ? `≈ $${(parseFloat(pezBalance) * pezUsdPrice).toFixed(2)} USD` + : 'Price loading...'} +
+
Governance & Rewards Token
+ {/* USDT Balance Card */} + + + + USDT Balance + + + +
+
+ {isLoading ? '...' : usdtBalance} + USDT +
+
+ ≈ ${usdtBalance} USD • Stablecoin +
+
+
+
+ {/* Account Info */} @@ -266,6 +563,118 @@ export const AccountBalance: React.FC = () => { + + {/* Other Tokens */} + + +
+
+ + + Other Assets + +
+ +
+
+ + {otherTokens.length === 0 ? ( +
+ +

No custom tokens yet

+

+ Add custom tokens to track additional assets +

+
+ ) : ( +
+ {otherTokens.map((token) => { + const tokenColor = getTokenColor(token.assetId); + return ( +
+
+ {/* Token Logo */} +
+ + {token.symbol.slice(0, 2).toUpperCase()} + +
+ + {/* Token Info */} +
+
+ + {token.symbol} + + + #{token.assetId} + +
+
+ {token.name} +
+
+
+ + {/* Balance & Actions */} +
+
+
+ {parseFloat(token.balance).toFixed(4)} +
+
+ ${token.usdValue.toFixed(2)} USD +
+
+ + {/* Send Button */} + +
+
+ ); + })} +
+ )} +
+
+ + {/* Add Token Modal */} + setIsAddTokenModalOpen(false)} + onAddToken={handleAddToken} + /> + + {/* Transfer Modal */} + { + setIsTransferModalOpen(false); + setSelectedTokenForTransfer(null); + }} + selectedAsset={selectedTokenForTransfer} + /> ); }; diff --git a/src/components/AddTokenModal.tsx b/src/components/AddTokenModal.tsx new file mode 100644 index 00000000..6a139073 --- /dev/null +++ b/src/components/AddTokenModal.tsx @@ -0,0 +1,117 @@ +import React, { useState } from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { AlertCircle } from 'lucide-react'; + +interface AddTokenModalProps { + isOpen: boolean; + onClose: () => void; + onAddToken: (assetId: number) => Promise; +} + +export const AddTokenModal: React.FC = ({ + isOpen, + onClose, + onAddToken, +}) => { + const [assetId, setAssetId] = useState(''); + const [error, setError] = useState(''); + const [isLoading, setIsLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + + const id = parseInt(assetId); + if (isNaN(id) || id < 0) { + setError('Please enter a valid asset ID (number)'); + return; + } + + setIsLoading(true); + try { + await onAddToken(id); + setAssetId(''); + setError(''); + } catch (err) { + setError('Failed to add token. Please check the asset ID and try again.'); + } finally { + setIsLoading(false); + } + }; + + const handleClose = () => { + setAssetId(''); + setError(''); + onClose(); + }; + + return ( + + + + Add Custom Token + + Enter the asset ID of the token you want to track. + Note: Core tokens (HEZ, PEZ) are already displayed separately. + + + +
+
+ + setAssetId(e.target.value)} + placeholder="e.g., 3" + className="bg-gray-800 border-gray-700 text-white placeholder:text-gray-500" + min="0" + required + /> +

+ Each token on the network has a unique asset ID +

+
+ + {error && ( +
+ +

{error}

+
+ )} + +
+ + +
+
+
+
+ ); +}; diff --git a/src/components/PoolDashboard.tsx b/src/components/PoolDashboard.tsx index aa28aa10..6b5be52a 100644 --- a/src/components/PoolDashboard.tsx +++ b/src/components/PoolDashboard.tsx @@ -5,12 +5,28 @@ import { Button } from '@/components/ui/button'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { Badge } from '@/components/ui/badge'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { usePolkadot } from '@/contexts/PolkadotContext'; import { useWallet } from '@/contexts/WalletContext'; -import { ASSET_IDS } from '@/lib/wallet'; +import { ASSET_IDS, getAssetSymbol } from '@/lib/wallet'; import { AddLiquidityModal } from '@/components/AddLiquidityModal'; import { RemoveLiquidityModal } from '@/components/RemoveLiquidityModal'; +// Helper function to convert asset IDs to user-friendly display names +// Users should only see HEZ, PEZ, USDT - wrapped tokens are backend details +const getDisplayTokenName = (assetId: number): string => { + if (assetId === ASSET_IDS.WHEZ || assetId === 0) return 'HEZ'; + if (assetId === ASSET_IDS.PEZ || assetId === 1) return 'PEZ'; + if (assetId === ASSET_IDS.WUSDT || assetId === 2) return 'USDT'; + return getAssetSymbol(assetId); // Fallback for other assets +}; + +// Helper function to get decimals for each asset +const getAssetDecimals = (assetId: number): number => { + if (assetId === ASSET_IDS.WUSDT) return 6; // wUSDT has 6 decimals + return 12; // wHEZ, PEZ have 12 decimals +}; + interface PoolData { asset0: number; asset1: number; @@ -38,18 +54,66 @@ const PoolDashboard = () => { const [isAddLiquidityModalOpen, setIsAddLiquidityModalOpen] = useState(false); const [isRemoveLiquidityModalOpen, setIsRemoveLiquidityModalOpen] = useState(false); - // Fetch pool data + // Pool selection state + const [availablePools, setAvailablePools] = useState>([]); + const [selectedPool, setSelectedPool] = useState('1-2'); // Default: PEZ/wUSDT + + // Discover available pools useEffect(() => { if (!api || !isApiReady) return; + const discoverPools = async () => { + try { + // Check all possible pool combinations + const possiblePools: Array<[number, number]> = [ + [ASSET_IDS.WHEZ, ASSET_IDS.PEZ], // wHEZ/PEZ + [ASSET_IDS.WHEZ, ASSET_IDS.WUSDT], // wHEZ/wUSDT + [ASSET_IDS.PEZ, ASSET_IDS.WUSDT], // PEZ/wUSDT + ]; + + const existingPools: Array<[number, number]> = []; + + for (const [asset0, asset1] of possiblePools) { + const poolInfo = await api.query.assetConversion.pools([asset0, asset1]); + if (poolInfo.isSome) { + existingPools.push([asset0, asset1]); + } + } + + setAvailablePools(existingPools); + + // Set default pool to first available if current selection doesn't exist + if (existingPools.length > 0) { + const currentPoolKey = selectedPool; + const poolExists = existingPools.some( + ([a0, a1]) => `${a0}-${a1}` === currentPoolKey + ); + if (!poolExists) { + const [firstAsset0, firstAsset1] = existingPools[0]; + setSelectedPool(`${firstAsset0}-${firstAsset1}`); + } + } + } catch (err) { + console.error('Error discovering pools:', err); + } + }; + + discoverPools(); + }, [api, isApiReady]); + + // Fetch pool data + useEffect(() => { + if (!api || !isApiReady || !selectedPool) return; + const fetchPoolData = async () => { setIsLoading(true); setError(null); try { - // Query PEZ/wUSDT pool - const asset1 = 1; // PEZ - const asset2 = 2; // wUSDT + // Parse selected pool (e.g., "1-2" -> [1, 2]) + const [asset1Str, asset2Str] = selectedPool.split('-'); + const asset1 = parseInt(asset1Str); + const asset2 = parseInt(asset2Str); const poolId = [asset1, asset2]; const poolInfo = await api.query.assetConversion.pools(poolId); @@ -78,25 +142,29 @@ const PoolDashboard = () => { const poolAccount = poolAccountId.toString(); // Get reserves - const pezBalanceData = await api.query.assets.account(asset1, poolAccountId); - const wusdtBalanceData = await api.query.assets.account(asset2, poolAccountId); + const asset0BalanceData = await api.query.assets.account(asset1, poolAccountId); + const asset1BalanceData = await api.query.assets.account(asset2, poolAccountId); let reserve0 = 0; let reserve1 = 0; - if (pezBalanceData.isSome) { - const pezData = pezBalanceData.unwrap().toJSON() as any; - reserve0 = Number(pezData.balance) / 1e12; + // Use dynamic decimals for each asset + const asset1Decimals = getAssetDecimals(asset1); + const asset2Decimals = getAssetDecimals(asset2); + + if (asset0BalanceData.isSome) { + const asset0Data = asset0BalanceData.unwrap().toJSON() as any; + reserve0 = Number(asset0Data.balance) / Math.pow(10, asset1Decimals); } - if (wusdtBalanceData.isSome) { - const wusdtData = wusdtBalanceData.unwrap().toJSON() as any; - reserve1 = Number(wusdtData.balance) / 1e6; // wUSDT has 6 decimals + if (asset1BalanceData.isSome) { + const asset1Data = asset1BalanceData.unwrap().toJSON() as any; + reserve1 = Number(asset1Data.balance) / Math.pow(10, asset2Decimals); } setPoolData({ - asset0: 1, - asset1: 2, + asset0: asset1, + asset1: asset2, reserve0, reserve1, lpTokenId, @@ -162,7 +230,7 @@ const PoolDashboard = () => { const interval = setInterval(fetchPoolData, 30000); return () => clearInterval(interval); - }, [api, isApiReady, selectedAccount]); + }, [api, isApiReady, selectedAccount, selectedPool]); // Calculate metrics const constantProduct = poolData ? poolData.reserve0 * poolData.reserve1 : 0; @@ -221,15 +289,34 @@ const PoolDashboard = () => { ); } + // Get asset symbols for the selected pool (using display names) + const asset0Symbol = poolData ? getDisplayTokenName(poolData.asset0) : ''; + const asset1Symbol = poolData ? getDisplayTokenName(poolData.asset1) : ''; + return (
+ {/* Pool Selector */}
-
-

- - PEZ/wUSDT Pool Dashboard -

-

Monitor liquidity pool metrics and your position

+
+
+

Pool Dashboards

+ +
@@ -237,6 +324,17 @@ const PoolDashboard = () => {
+ {/* Pool Dashboard Title */} +
+
+

+ + {asset0Symbol}/{asset1Symbol} Pool Dashboard +

+

Monitor liquidity pool metrics and your position

+
+
+ {/* Key Metrics Grid */}
{/* Total Liquidity */} @@ -248,7 +346,7 @@ const PoolDashboard = () => { ${totalLiquidityUSD.toLocaleString('en-US', { maximumFractionDigits: 0 })}

- {poolData.reserve0.toLocaleString()} PEZ + {poolData.reserve1.toLocaleString()} wUSDT + {poolData.reserve0.toLocaleString()} {asset0Symbol} + {poolData.reserve1.toLocaleString()} {asset1Symbol}

@@ -259,12 +357,12 @@ const PoolDashboard = () => {
-

PEZ Price

+

{asset0Symbol} Price

${currentPrice.toFixed(4)}

- 1 wUSDT = {(1 / currentPrice).toFixed(4)} PEZ + 1 {asset1Symbol} = {(1 / currentPrice).toFixed(4)} {asset0Symbol}

@@ -319,7 +417,7 @@ const PoolDashboard = () => {
-

PEZ Reserve

+

{asset0Symbol} Reserve

{poolData.reserve0.toLocaleString('en-US', { maximumFractionDigits: 2 })}

Asset 1 @@ -327,7 +425,7 @@ const PoolDashboard = () => {
-

wUSDT Reserve

+

{asset1Symbol} Reserve

{poolData.reserve1.toLocaleString('en-US', { maximumFractionDigits: 2 })}

Asset 2 @@ -387,11 +485,11 @@ const PoolDashboard = () => {

Your Position Value

- PEZ: + {asset0Symbol}: {lpPosition.asset0Amount.toFixed(4)}
- wUSDT: + {asset1Symbol}: {lpPosition.asset1Amount.toFixed(4)}
@@ -402,15 +500,15 @@ const PoolDashboard = () => {
Daily: - ~{((lpPosition.asset0Amount * 2 * estimateAPR()) / 365 / 100).toFixed(4)} HEZ + ~{((lpPosition.asset0Amount * 2 * estimateAPR()) / 365 / 100).toFixed(4)} {asset0Symbol}
Monthly: - ~{((lpPosition.asset0Amount * 2 * estimateAPR()) / 12 / 100).toFixed(4)} HEZ + ~{((lpPosition.asset0Amount * 2 * estimateAPR()) / 12 / 100).toFixed(4)} {asset0Symbol}
Yearly: - ~{((lpPosition.asset0Amount * 2 * estimateAPR()) / 100).toFixed(4)} HEZ + ~{((lpPosition.asset0Amount * 2 * estimateAPR()) / 100).toFixed(4)} {asset0Symbol}
@@ -442,7 +540,7 @@ const PoolDashboard = () => {
-

If PEZ price changes by:

+

If {asset0Symbol} price changes by:

{[10, 25, 50, 100, 200].map((change) => { diff --git a/src/components/TokenSwap.tsx b/src/components/TokenSwap.tsx index 93c34923..d6b47ffd 100644 --- a/src/components/TokenSwap.tsx +++ b/src/components/TokenSwap.tsx @@ -76,6 +76,13 @@ const TokenSwap = () => { return token?.displaySymbol || tokenSymbol; }; + // Check if user has insufficient balance + const hasInsufficientBalance = React.useMemo(() => { + const fromAmountNum = parseFloat(fromAmount || '0'); + const fromBalanceNum = parseFloat(fromBalance?.toString() || '0'); + return fromAmountNum > 0 && fromAmountNum > fromBalanceNum; + }, [fromAmount, fromBalance]); + // Calculate toAmount and price impact using AMM constant product formula const swapCalculations = React.useMemo(() => { if (!fromAmount || !poolReserves || parseFloat(fromAmount) <= 0) { @@ -489,15 +496,48 @@ const TokenSwap = () => { return; } + // ✅ BALANCE VALIDATION - Check if user has sufficient balance + const fromAmountNum = parseFloat(fromAmount); + const fromBalanceNum = parseFloat(fromBalance?.toString() || '0'); + + if (fromAmountNum > fromBalanceNum) { + toast({ + title: 'Insufficient Balance', + description: `You only have ${fromBalanceNum.toFixed(4)} ${getTokenDisplayName(fromToken)}. Cannot swap ${fromAmountNum} ${getTokenDisplayName(fromToken)}.`, + variant: 'destructive', + }); + return; + } + setIsSwapping(true); setShowConfirm(false); // Close dialog before transaction starts try { - const amountIn = parseAmount(fromAmount, 12); + // Get correct decimals for each token + const getTokenDecimals = (token: string) => { + if (token === 'wUSDT') return 6; // wUSDT has 6 decimals + return 12; // HEZ, wHEZ, PEZ all have 12 decimals + }; + + const fromDecimals = getTokenDecimals(fromToken); + const toDecimals = getTokenDecimals(toToken); + + const amountIn = parseAmount(fromAmount, fromDecimals); const minAmountOut = parseAmount( (parseFloat(toAmount) * (1 - parseFloat(slippage) / 100)).toString(), - 12 + toDecimals ); + console.log('💰 Swap amounts:', { + fromToken, + toToken, + fromAmount, + toAmount, + fromDecimals, + toDecimals, + amountIn: amountIn.toString(), + minAmountOut: minAmountOut.toString() + }); + // Get signer from extension const { web3FromAddress } = await import('@polkadot/extension-dapp'); const injector = await web3FromAddress(selectedAccount.address); @@ -533,18 +573,42 @@ const TokenSwap = () => { const unwrapTx = api.tx.tokenWrapper.unwrap(minAmountOut.toString()); tx = api.tx.utility.batchAll([swapTx, unwrapTx]); - } else { - // Direct swap between assets (should not happen with HEZ/PEZ only) - const getPoolAssetId = (token: string) => { - if (token === 'HEZ') return 0; // wHEZ - return ASSET_IDS[token as keyof typeof ASSET_IDS]; - }; + } else if (fromToken === 'HEZ') { + // HEZ → Any Asset: wrap(HEZ→wHEZ) then swap(wHEZ→Asset) + const wrapTx = api.tx.tokenWrapper.wrap(amountIn.toString()); + // Map token symbol to asset ID + const toAssetId = toToken === 'PEZ' ? 1 : toToken === 'wUSDT' ? 2 : ASSET_IDS[toToken as keyof typeof ASSET_IDS]; + const swapPath = [0, toAssetId]; // wHEZ → target asset + const swapTx = api.tx.assetConversion.swapExactTokensForTokens( + swapPath, + amountIn.toString(), + minAmountOut.toString(), + selectedAccount.address, + true + ); + tx = api.tx.utility.batchAll([wrapTx, swapTx]); - // AssetKind = u32, so swap path is just [assetId1, assetId2] - const swapPath = [ - getPoolAssetId(fromToken), - getPoolAssetId(toToken) - ]; + } else if (toToken === 'HEZ') { + // Any Asset → HEZ: swap(Asset→wHEZ) then unwrap(wHEZ→HEZ) + // Map token symbol to asset ID + const fromAssetId = fromToken === 'PEZ' ? 1 : fromToken === 'wUSDT' ? 2 : ASSET_IDS[fromToken as keyof typeof ASSET_IDS]; + const swapPath = [fromAssetId, 0]; // source asset → wHEZ + const swapTx = api.tx.assetConversion.swapExactTokensForTokens( + swapPath, + amountIn.toString(), + minAmountOut.toString(), + selectedAccount.address, + true + ); + const unwrapTx = api.tx.tokenWrapper.unwrap(minAmountOut.toString()); + tx = api.tx.utility.batchAll([swapTx, unwrapTx]); + + } else { + // Direct swap between assets (PEZ ↔ wUSDT, etc.) + // Map token symbols to asset IDs + const fromAssetId = fromToken === 'PEZ' ? 1 : fromToken === 'wUSDT' ? 2 : ASSET_IDS[fromToken as keyof typeof ASSET_IDS]; + const toAssetId = toToken === 'PEZ' ? 1 : toToken === 'wUSDT' ? 2 : ASSET_IDS[toToken as keyof typeof ASSET_IDS]; + const swapPath = [fromAssetId, toAssetId]; tx = api.tx.assetConversion.swapExactTokensForTokens( swapPath, @@ -978,8 +1042,18 @@ const TokenSwap = () => {
+ {/* Insufficient Balance Warning */} + {hasInsufficientBalance && ( + + + + Insufficient {getTokenDisplayName(fromToken)} balance. You have {fromBalance} {getTokenDisplayName(fromToken)} but trying to swap {fromAmount} {getTokenDisplayName(fromToken)}. + + + )} + {/* High Price Impact Warning (>5%) */} - {priceImpact >= 5 && ( + {priceImpact >= 5 && !hasInsufficientBalance && ( @@ -991,9 +1065,15 @@ const TokenSwap = () => {
diff --git a/src/components/TransferModal.tsx b/src/components/TransferModal.tsx index 87dae011..01445b2c 100644 --- a/src/components/TransferModal.tsx +++ b/src/components/TransferModal.tsx @@ -20,9 +20,19 @@ import { import { ArrowRight, Loader2, CheckCircle, XCircle } from 'lucide-react'; import { useToast } from '@/hooks/use-toast'; +interface TokenBalance { + assetId: number; + symbol: string; + name: string; + balance: string; + decimals: number; + usdValue: number; +} + interface TransferModalProps { isOpen: boolean; onClose: () => void; + selectedAsset?: TokenBalance | null; } type TokenType = 'HEZ' | 'PEZ' | 'USDT' | 'BTC' | 'ETH' | 'DOT'; @@ -44,10 +54,10 @@ const TOKENS: Token[] = [ { symbol: 'DOT', name: 'Polkadot', assetId: 5, decimals: 10, color: 'from-pink-500 to-red-500' }, ]; -export const TransferModal: React.FC = ({ isOpen, onClose }) => { +export const TransferModal: React.FC = ({ isOpen, onClose, selectedAsset }) => { const { api, isApiReady, selectedAccount } = usePolkadot(); const { toast } = useToast(); - + const [selectedToken, setSelectedToken] = useState('HEZ'); const [recipient, setRecipient] = useState(''); const [amount, setAmount] = useState(''); @@ -55,7 +65,16 @@ export const TransferModal: React.FC = ({ isOpen, onClose }) const [txStatus, setTxStatus] = useState<'idle' | 'signing' | 'pending' | 'success' | 'error'>('idle'); const [txHash, setTxHash] = useState(''); - const currentToken = TOKENS.find(t => t.symbol === selectedToken) || TOKENS[0]; + // Use the provided selectedAsset or fall back to token selection + const currentToken = selectedAsset ? { + symbol: selectedAsset.symbol as TokenType, + name: selectedAsset.name, + assetId: selectedAsset.assetId, + decimals: selectedAsset.decimals, + color: selectedAsset.assetId === 0 ? 'from-green-600 to-yellow-400' : + selectedAsset.assetId === 2 ? 'from-emerald-500 to-teal-500' : + 'from-cyan-500 to-blue-500', + } : TOKENS.find(t => t.symbol === selectedToken) || TOKENS[0]; const handleTransfer = async () => { if (!api || !isApiReady || !selectedAccount) { @@ -90,14 +109,12 @@ export const TransferModal: React.FC = ({ isOpen, onClose }) let transfer; // Create appropriate transfer transaction based on token type - if (selectedToken === 'HEZ') { - // Native token transfer + // wHEZ uses native token transfer (balances pallet), all others use assets pallet + if (currentToken.assetId === undefined || (selectedToken === 'HEZ' && !selectedAsset)) { + // Native HEZ token transfer transfer = api.tx.balances.transferKeepAlive(recipient, amountInSmallestUnit.toString()); } else { - // Asset token transfer (PEZ, USDT, BTC, ETH, DOT) - if (!currentToken.assetId) { - throw new Error('Asset ID not configured'); - } + // Asset token transfer (wHEZ, PEZ, wUSDT, etc.) transfer = api.tx.assets.transfer(currentToken.assetId, recipient, amountInSmallestUnit.toString()); } @@ -135,7 +152,7 @@ export const TransferModal: React.FC = ({ isOpen, onClose }) setTxStatus('success'); toast({ title: "Transfer Successful!", - description: `Sent ${amount} ${selectedToken} to ${recipient.slice(0, 8)}...${recipient.slice(-6)}`, + description: `Sent ${amount} ${currentToken.symbol} to ${recipient.slice(0, 8)}...${recipient.slice(-6)}`, }); // Reset form after 2 seconds @@ -181,9 +198,13 @@ export const TransferModal: React.FC = ({ isOpen, onClose }) - Send Tokens + + {selectedAsset ? `Send ${selectedAsset.symbol}` : 'Send Tokens'} + - Transfer tokens to another account + {selectedAsset + ? `Transfer ${selectedAsset.name} to another account` + : 'Transfer tokens to another account'} @@ -215,30 +236,32 @@ export const TransferModal: React.FC = ({ isOpen, onClose })
) : (
- {/* Token Selection */} -
- - -
+ {/* Token Selection - Only show if no asset is pre-selected */} + {!selectedAsset && ( +
+ + +
+ )}
diff --git a/src/contexts/WalletContext.tsx b/src/contexts/WalletContext.tsx index 834a6a25..8a7bf9a0 100644 --- a/src/contexts/WalletContext.tsx +++ b/src/contexts/WalletContext.tsx @@ -98,7 +98,7 @@ export const WalletProvider: React.FC<{ children: React.ReactNode }> = ({ childr console.error('❌ Failed to fetch wHEZ balance:', err); } - // Fetch wUSDT (Asset ID: 2) + // Fetch wUSDT (Asset ID: 2) - IMPORTANT: wUSDT has 6 decimals, not 12! let wusdtBalance = '0'; try { const wusdtData = await polkadot.api.query.assets.account(ASSET_IDS.WUSDT, address); @@ -107,7 +107,7 @@ export const WalletProvider: React.FC<{ children: React.ReactNode }> = ({ childr if (wusdtData.isSome) { const assetData = wusdtData.unwrap(); const wusdtAmount = assetData.balance.toString(); - wusdtBalance = formatBalance(wusdtAmount); + wusdtBalance = formatBalance(wusdtAmount, 6); // wUSDT uses 6 decimals! console.log('✅ wUSDT balance found:', wusdtBalance); } else { console.warn('⚠️ wUSDT asset not found for this account');