import React, { useState, useEffect } from 'react'; import { X, Plus, Info, AlertCircle } from 'lucide-react'; import { web3FromAddress } from '@pezkuwi/extension-dapp'; import { usePezkuwi } from '@/contexts/PezkuwiContext'; import { useWallet } from '@/contexts/WalletContext'; import { Button } from '@/components/ui/button'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { ASSET_IDS, getAssetSymbol } from '@pezkuwi/lib/wallet'; interface AddLiquidityModalProps { isOpen: boolean; onClose: () => void; asset0?: number; // Pool's first asset ID asset1?: number; // Pool's second asset ID } interface AssetDetails { minBalance?: string | number; } interface Balances { [key: string]: number; } // Helper to get display name for tokens const getDisplayName = (assetId: number): string => { if (assetId === -1) return 'HEZ'; // Native HEZ from relay chain if (assetId === ASSET_IDS.WHEZ || assetId === 2 || assetId === 0) return 'wHEZ'; // Wrapped HEZ (asset 0 or 2) if (assetId === ASSET_IDS.PEZ || assetId === 1) return 'PEZ'; if (assetId === ASSET_IDS.WUSDT || assetId === 1000) return 'USDT'; if (assetId === 1001) return 'DOT'; if (assetId === 1002) return 'ETH'; if (assetId === 1003) return 'BTC'; return getAssetSymbol(assetId); }; // Helper to get balance key for the asset const getBalanceKey = (assetId: number): string => { if (assetId === -1) return 'HEZ'; // Native HEZ if (assetId === ASSET_IDS.WHEZ || assetId === 2 || assetId === 0) return 'wHEZ'; // Wrapped HEZ (asset 0 or 2) if (assetId === ASSET_IDS.PEZ || assetId === 1) return 'PEZ'; if (assetId === ASSET_IDS.WUSDT || assetId === 1000) return 'USDT'; if (assetId === 1001) return 'DOT'; if (assetId === 1002) return 'ETH'; if (assetId === 1003) return 'BTC'; return getAssetSymbol(assetId); }; // Helper to get decimals for asset const getAssetDecimals = (assetId: number): number => { if (assetId === ASSET_IDS.WUSDT || assetId === 1000) return 6; // wUSDT has 6 decimals if (assetId === 1001) return 10; // wDOT has 10 decimals if (assetId === 1002) return 18; // wETH has 18 decimals if (assetId === 1003) return 8; // wBTC has 8 decimals return 12; // wHEZ, PEZ have 12 decimals }; export const AddLiquidityModal: React.FC = ({ isOpen, onClose, asset0 = 0, // Default to wHEZ asset1 = 1 // Default to PEZ }) => { // Use Asset Hub API for DEX operations (assetConversion pallet is on Asset Hub) const { assetHubApi, selectedAccount, isAssetHubReady } = usePezkuwi(); const { balances, refreshBalances } = useWallet(); const [amount0, setAmount0] = useState(''); const [amount1, setAmount1] = useState(''); const [currentPrice, setCurrentPrice] = useState(null); const [isPoolEmpty, setIsPoolEmpty] = useState(true); // Track if pool has meaningful liquidity const [minDeposit0, setMinDeposit0] = useState(0.01); // Dynamic minimum deposit for asset0 const [minDeposit1, setMinDeposit1] = useState(0.01); // Dynamic minimum deposit for asset1 const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [success, setSuccess] = useState(false); // Asset Hub native balance for native token (-1) - needed because DEX is on Asset Hub const [assetHubNativeBalance, setAssetHubNativeBalance] = useState(0); // Asset Hub asset balances for DOT/ETH/BTC (not tracked in WalletContext) const [assetHubBalances, setAssetHubBalances] = useState>({}); // Get asset details const asset0Name = getDisplayName(asset0); const asset1Name = getDisplayName(asset1); const asset0BalanceKey = getBalanceKey(asset0); const asset1BalanceKey = getBalanceKey(asset1); const asset0Decimals = getAssetDecimals(asset0); const asset1Decimals = getAssetDecimals(asset1); // Reset form when modal is closed useEffect(() => { if (!isOpen) { setAmount0(''); setAmount1(''); setError(null); setSuccess(false); } }, [isOpen]); // Fetch Asset Hub native balance for native token (-1) // This is needed because the DEX (assetConversion) is on Asset Hub, // but WalletContext fetches HEZ balance from the relay chain useEffect(() => { if (!assetHubApi || !isAssetHubReady || !selectedAccount || !isOpen) return; // Only fetch if one of the assets is the native token (-1) if (asset0 !== -1 && asset1 !== -1) { setAssetHubNativeBalance(0); return; } const fetchAssetHubNativeBalance = async () => { try { const { data: accountInfo } = await assetHubApi.query.system.account(selectedAccount.address); const freeBalance = accountInfo.free.toString(); const humanBalance = Number(freeBalance) / Math.pow(10, 12); // Native token has 12 decimals if (import.meta.env.DEV) { console.log('💰 Asset Hub native balance:', { raw: freeBalance, human: humanBalance, address: selectedAccount.address }); } setAssetHubNativeBalance(humanBalance); } catch (err) { if (import.meta.env.DEV) console.error('❌ Failed to fetch Asset Hub native balance:', err); setAssetHubNativeBalance(0); } }; fetchAssetHubNativeBalance(); }, [assetHubApi, isAssetHubReady, selectedAccount, isOpen, asset0, asset1]); // Fetch Asset Hub asset balances for DOT/ETH/BTC (assets not tracked in WalletContext) useEffect(() => { if (!assetHubApi || !isAssetHubReady || !selectedAccount || !isOpen) return; const assetHubOnlyAssets = [1001, 1002, 1003]; // DOT, ETH, BTC const assetsToFetch = [asset0, asset1].filter(id => assetHubOnlyAssets.includes(id)); if (assetsToFetch.length === 0) return; const fetchAssetHubBalances = async () => { const newBalances: Record = {}; for (const assetId of assetsToFetch) { try { const accountInfo = await assetHubApi.query.assets.account(assetId, selectedAccount.address); if (accountInfo && accountInfo.isSome) { const data = accountInfo.unwrap(); const rawBalance = data.balance.toString(); const decimals = getAssetDecimals(assetId); const humanBalance = Number(rawBalance) / Math.pow(10, decimals); newBalances[assetId] = humanBalance; if (import.meta.env.DEV) { console.log(`💰 Asset Hub balance for asset ${assetId}:`, { raw: rawBalance, human: humanBalance, decimals }); } } else { newBalances[assetId] = 0; } } catch (err) { if (import.meta.env.DEV) console.error(`❌ Failed to fetch Asset Hub balance for asset ${assetId}:`, err); newBalances[assetId] = 0; } } setAssetHubBalances(prev => ({ ...prev, ...newBalances })); }; fetchAssetHubBalances(); }, [assetHubApi, isAssetHubReady, selectedAccount, isOpen, asset0, asset1]); // Note: Minimum deposits are calculated in the fetchPoolReserves effect below // based on the actual pool ratio and asset minBalances // Helper to convert asset ID to XCM Location format const formatAssetLocation = (id: number) => { if (id === -1) { // Native token from relay chain return { parents: 1, interior: 'Here' }; } // Asset on Asset Hub return { parents: 0, interior: { X2: [{ PalletInstance: 50 }, { GeneralIndex: id }] } }; }; // Fetch current pool reserves and calculate ratio + minimums useEffect(() => { if (!assetHubApi || !isAssetHubReady || !isOpen) return; const fetchPoolReserves = async () => { try { // Get asset1 minBalance from chain (e.g., DOT = 0.1) let asset1MinBalance = 0.1; // default if (asset1 >= 0) { try { const assetDetails = await assetHubApi.query.assets.asset(asset1); if (assetDetails.isSome) { const details = assetDetails.unwrap().toJSON() as AssetDetails; const minBalRaw = details.minBalance || '0'; asset1MinBalance = Math.max(Number(minBalRaw) / Math.pow(10, asset1Decimals), 0.1); if (import.meta.env.DEV) console.log(`Asset ${asset1} minBalance:`, asset1MinBalance); } } catch { if (import.meta.env.DEV) console.log('Could not fetch asset1 minBalance'); } } const asset0Location = formatAssetLocation(asset0); const asset1Location = formatAssetLocation(asset1); const poolKey = [asset0Location, asset1Location]; const poolInfo = await assetHubApi.query.assetConversion.pools(poolKey); if (poolInfo.isSome) { // Pool exists - get reserves for exact ratio try { const reserves = await assetHubApi.call.assetConversionApi.getReserves( asset0Location, asset1Location ); if (reserves && !reserves.isNone) { const [reserve0Raw, reserve1Raw] = reserves.unwrap(); const reserve0 = Number(reserve0Raw.toString()) / Math.pow(10, asset0Decimals); const reserve1 = Number(reserve1Raw.toString()) / Math.pow(10, asset1Decimals); if (reserve0 > 0 && reserve1 > 0) { // Use exact reserve ratio for liquidity (no slippage/fees) const ratio = reserve1 / reserve0; // asset1 per asset0 setCurrentPrice(ratio); setIsPoolEmpty(false); // Calculate minimums based on ratio // Pool ratio: 3 HEZ = 1 DOT, so ratio = 1/3 = 0.333 // If DOT min is 0.1, then HEZ min = 0.1 / 0.333 = 0.3 HEZ const requiredAsset0Min = asset1MinBalance / ratio; const finalMin0 = Math.max(0.1, requiredAsset0Min); const finalMin1 = asset1MinBalance; setMinDeposit0(finalMin0); setMinDeposit1(finalMin1); if (import.meta.env.DEV) { console.log('📊 Pool reserves:', { reserve0, reserve1 }); console.log('📊 Ratio (asset1/asset0):', ratio, `(1 ${asset0Name} = ${ratio.toFixed(4)} ${asset1Name})`); console.log('📊 Final minimums:', { [asset0Name]: finalMin0, [asset1Name]: finalMin1 }); } } else { setIsPoolEmpty(true); setCurrentPrice(null); setMinDeposit0(0.1); setMinDeposit1(asset1MinBalance); } } else { setIsPoolEmpty(true); setCurrentPrice(null); setMinDeposit0(0.1); setMinDeposit1(asset1MinBalance); if (import.meta.env.DEV) console.log('Pool exists but no reserves'); } } catch (reserveErr) { if (import.meta.env.DEV) console.error('Error getting reserves:', reserveErr); setIsPoolEmpty(true); setCurrentPrice(null); setMinDeposit0(0.1); setMinDeposit1(asset1MinBalance); } } else { setCurrentPrice(null); setIsPoolEmpty(true); setMinDeposit0(0.1); setMinDeposit1(asset1MinBalance); if (import.meta.env.DEV) console.log('Pool does not exist yet'); } } catch (err) { if (import.meta.env.DEV) console.error('Error fetching pool:', err); setCurrentPrice(null); setIsPoolEmpty(true); } }; fetchPoolReserves(); }, [assetHubApi, isAssetHubReady, isOpen, asset0, asset1, asset0Decimals, asset1Decimals, asset0Name, asset1Name]); // Auto-calculate asset1 amount based on asset0 input (only if pool has liquidity) useEffect(() => { if (!isPoolEmpty && amount0 && currentPrice) { const calculated = parseFloat(amount0) * currentPrice; setAmount1(calculated.toFixed(asset1Decimals === 6 ? 2 : 4)); } else if (!amount0 && !isPoolEmpty) { setAmount1(''); } // If pool is empty, don't auto-calculate - let user input both amounts }, [amount0, currentPrice, asset1Decimals, isPoolEmpty]); const handleAddLiquidity = async () => { if (!assetHubApi || !selectedAccount || !amount0 || !amount1) return; setIsLoading(true); setError(null); try { // Validate amounts if (parseFloat(amount0) <= 0 || parseFloat(amount1) <= 0) { setError('Please enter valid amounts'); setIsLoading(false); return; } // Check minimum deposit requirements from runtime if (parseFloat(amount0) < minDeposit0) { setError(`${asset0Name} amount must be at least ${minDeposit0.toFixed(asset0Decimals === 6 ? 2 : 4)} (minimum deposit requirement)`); setIsLoading(false); return; } if (parseFloat(amount1) < minDeposit1) { setError(`${asset1Name} amount must be at least ${minDeposit1.toFixed(asset1Decimals === 6 ? 2 : 4)} (minimum deposit requirement)`); setIsLoading(false); return; } // For native token (-1), use Asset Hub native balance // For DOT/ETH/BTC (1001, 1002, 1003), use Asset Hub asset balances // For others, use WalletContext const getBalanceForValidation = (assetId: number, balanceKey: string): number => { if (assetId === -1) { return assetHubNativeBalance; } // DOT, ETH, BTC are only on Asset Hub if ([1001, 1002, 1003].includes(assetId)) { return assetHubBalances[assetId] || 0; } const walletBalance = (balances as Balances)[balanceKey]; return typeof walletBalance === 'string' ? parseFloat(walletBalance) || 0 : walletBalance || 0; }; const bal0 = getBalanceForValidation(asset0, asset0BalanceKey); const bal1 = getBalanceForValidation(asset1, asset1BalanceKey); if (parseFloat(amount0) > bal0) { setError(`Insufficient ${asset0Name} balance on Asset Hub`); setIsLoading(false); return; } if (parseFloat(amount1) > bal1) { setError(`Insufficient ${asset1Name} balance`); setIsLoading(false); return; } // Get the signer from the extension const injector = await web3FromAddress(selectedAccount.address); // Convert amounts to proper decimals const amount0BN = BigInt(Math.floor(parseFloat(amount0) * Math.pow(10, asset0Decimals))); const amount1BN = BigInt(Math.floor(parseFloat(amount1) * Math.pow(10, asset1Decimals))); // Min amounts (90% of desired to account for slippage) const minAmount0BN = (amount0BN * BigInt(90)) / BigInt(100); const minAmount1BN = (amount1BN * BigInt(90)) / BigInt(100); // Build transaction using XCM Location format for assets const asset0Location = formatAssetLocation(asset0); const asset1Location = formatAssetLocation(asset1); const tx = assetHubApi.tx.assetConversion.addLiquidity( asset0Location, asset1Location, amount0BN.toString(), amount1BN.toString(), minAmount0BN.toString(), minAmount1BN.toString(), selectedAccount.address ); await tx.signAndSend( selectedAccount.address, { signer: injector.signer }, ({ status, events, dispatchError }) => { if (status.isInBlock) { if (import.meta.env.DEV) console.log('Transaction in block:', status.asInBlock.toHex()); } else if (status.isFinalized) { if (import.meta.env.DEV) console.log('Transaction finalized:', status.asFinalized.toHex()); // Check for errors const hasError = events.some(({ event }) => assetHubApi.events.system.ExtrinsicFailed.is(event) ); if (hasError || dispatchError) { let errorMessage = 'Transaction failed'; if (dispatchError) { if (dispatchError.isModule) { const decoded = assetHubApi.registry.findMetaError(dispatchError.asModule); const { docs, name, section } = decoded; errorMessage = `${section}.${name}: ${docs.join(' ')}`; if (import.meta.env.DEV) console.error('Dispatch error:', errorMessage); } else { errorMessage = dispatchError.toString(); if (import.meta.env.DEV) console.error('Dispatch error:', errorMessage); } } events.forEach(({ event }) => { if (assetHubApi.events.system.ExtrinsicFailed.is(event)) { if (import.meta.env.DEV) console.error('ExtrinsicFailed event:', event.toHuman()); } }); setError(errorMessage); setIsLoading(false); } else { if (import.meta.env.DEV) console.log('Transaction successful'); setSuccess(true); setIsLoading(false); setAmount0(''); setAmount1(''); refreshBalances(); setTimeout(() => { setSuccess(false); onClose(); }, 2000); } } } ); } catch (err) { if (import.meta.env.DEV) console.error('Error adding liquidity:', err); setError(err instanceof Error ? err.message : 'Failed to add liquidity'); setIsLoading(false); } }; if (!isOpen) return null; // For native token (-1), use Asset Hub native balance // For DOT/ETH/BTC (1001, 1002, 1003), use Asset Hub asset balances // For other assets, use WalletContext balances (parse string to number) const getBalance = (assetId: number, balanceKey: string): number => { if (assetId === -1) { return assetHubNativeBalance; } // DOT, ETH, BTC are only on Asset Hub - use directly fetched balances if ([1001, 1002, 1003].includes(assetId)) { return assetHubBalances[assetId] || 0; } const walletBalance = (balances as Balances)[balanceKey]; return typeof walletBalance === 'string' ? parseFloat(walletBalance) || 0 : walletBalance || 0; }; const balance0 = getBalance(asset0, asset0BalanceKey); const balance1 = getBalance(asset1, asset1BalanceKey); return (

Add Liquidity

{error && ( {error} )} {success && ( Liquidity added successfully! )} {/* Warning when using native HEZ and Asset Hub balance is low */} {asset0 === -1 && assetHubNativeBalance < 0.1 && ( Low Asset Hub Balance: You have {assetHubNativeBalance.toFixed(4)} HEZ on Asset Hub. The DEX requires HEZ on Asset Hub. Use XCM transfer from relay chain if needed. )} {isPoolEmpty ? ( First Liquidity Provider: Pool is empty! You are setting the initial price ratio. Minimum deposit: {minDeposit0.toFixed(asset0Decimals === 6 ? 2 : 4)} {asset0Name} and {minDeposit1.toFixed(asset1Decimals === 6 ? 2 : 4)} {asset1Name}. {(asset0 === 0 || asset0 === ASSET_IDS.WHEZ) && ' Your HEZ will be automatically wrapped to wHEZ.'} ) : ( Add liquidity to earn 0.3% fees from all swaps. Amounts are auto-calculated based on current pool ratio. Minimum deposit: {minDeposit0.toFixed(asset0Decimals === 6 ? 2 : 4)} {asset0Name} and {minDeposit1.toFixed(asset1Decimals === 6 ? 2 : 4)} {asset1Name}. {(asset0 === 0 || asset0 === ASSET_IDS.WHEZ) && ' Your HEZ will be automatically wrapped to wHEZ.'} )}
{/* Asset 0 Input */}
setAmount0(e.target.value)} placeholder={`${minDeposit0.toFixed(asset0Decimals === 6 ? 2 : 4)} or more`} min={minDeposit0} step={minDeposit0 < 1 ? minDeposit0 : 0.01} className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-3 text-white focus:outline-none focus:border-blue-500" disabled={isLoading} />
{asset0Name}
Balance: {balance0.toLocaleString()}
{/* Asset 1 Input */}
setAmount1(e.target.value)} placeholder={isPoolEmpty ? `${minDeposit1.toFixed(asset1Decimals === 6 ? 2 : 4)} or more` : "Auto-calculated"} min={isPoolEmpty ? minDeposit1 : undefined} step={isPoolEmpty ? (minDeposit1 < 1 ? minDeposit1 : 0.01) : undefined} className={`w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-3 focus:outline-none ${ isPoolEmpty ? 'text-white focus:border-blue-500' : 'text-gray-400 cursor-not-allowed' }`} disabled={!isPoolEmpty || isLoading} readOnly={!isPoolEmpty} />
{asset1Name}
Balance: {balance1.toLocaleString()} {isPoolEmpty ? ( ) : ( currentPrice && Rate: 1 {asset0Name} = {currentPrice.toFixed(asset1Decimals === 6 ? 2 : 4)} {asset1Name} )}
{/* Price Info */} {amount0 && amount1 && (
{isPoolEmpty && (
Initial Price 1 {asset0Name} = {(parseFloat(amount1) / parseFloat(amount0)).toFixed(asset1Decimals === 6 ? 2 : 4)} {asset1Name}
)}
Share of Pool {isPoolEmpty ? '100%' : '~0.1%'}
Slippage Tolerance 10%
)}
); };