mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-04-22 07:57:55 +00:00
cccf5dec21
- Back to Home button: smaller padding/font on mobile, pinned to corner - PriceChart: increase height to 280px, stack header vertically on mobile - TokenSwap: reduce padding/gaps/fonts on mobile, fix double-colon in balance text
1292 lines
55 KiB
TypeScript
1292 lines
55 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import { ArrowDownUp, Settings, TrendingUp, Clock, AlertCircle, Info, AlertTriangle } from 'lucide-react';
|
|
import { Card } from '@/components/ui/card';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
import { usePezkuwi } from '@/contexts/PezkuwiContext';
|
|
import { useWallet } from '@/contexts/WalletContext';
|
|
import { ASSET_IDS, formatBalance, parseAmount } from '@pezkuwi/lib/wallet';
|
|
import { formatAssetLocation, NATIVE_TOKEN_ID } from '@pezkuwi/utils/dex';
|
|
import { useToast } from '@/hooks/use-toast';
|
|
import { KurdistanSun } from './KurdistanSun';
|
|
import { PriceChart } from './trading/PriceChart';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
// Available tokens for swap
|
|
const AVAILABLE_TOKENS = [
|
|
{ symbol: 'HEZ', emoji: '🟡', assetId: 0, name: 'HEZ', badge: true, displaySymbol: 'HEZ' },
|
|
{ symbol: 'PEZ', emoji: '🟣', assetId: 1, name: 'PEZ', badge: true, displaySymbol: 'PEZ' },
|
|
{ symbol: 'USDT', emoji: '💵', assetId: 1000, name: 'USDT', badge: true, displaySymbol: 'USDT' },
|
|
] as const;
|
|
|
|
const TokenSwap = () => {
|
|
// Use Asset Hub API for DEX operations (assetConversion pallet is on Asset Hub)
|
|
const { assetHubApi, isAssetHubReady, selectedAccount, walletSource } = usePezkuwi();
|
|
const { balances, refreshBalances } = useWallet();
|
|
const { toast } = useToast();
|
|
const { t } = useTranslation();
|
|
|
|
const [fromToken, setFromToken] = useState('PEZ');
|
|
const [toToken, setToToken] = useState('HEZ');
|
|
const [fromAmount, setFromAmount] = useState('');
|
|
const [slippage, setSlippage] = useState('0.5');
|
|
const [showSettings, setShowSettings] = useState(false);
|
|
const [showConfirm, setShowConfirm] = useState(false);
|
|
const [isSwapping, setIsSwapping] = useState(false);
|
|
|
|
// DEX availability check
|
|
const [isDexAvailable, setIsDexAvailable] = useState(false);
|
|
|
|
// Exchange rate and loading states
|
|
const [exchangeRate, setExchangeRate] = useState(0);
|
|
const [isLoadingRate, setIsLoadingRate] = useState(false);
|
|
|
|
// Get balances from wallet context
|
|
if (import.meta.env.DEV) console.log('🔍 TokenSwap balances from context:', balances);
|
|
if (import.meta.env.DEV) console.log('🔍 fromToken:', fromToken, 'toToken:', toToken);
|
|
const fromBalance = balances[fromToken as keyof typeof balances];
|
|
const toBalance = balances[toToken as keyof typeof balances];
|
|
if (import.meta.env.DEV) console.log('🔍 Final balances:', { fromBalance, toBalance });
|
|
|
|
// Liquidity pool data
|
|
interface LiquidityPool {
|
|
key: string;
|
|
data: unknown;
|
|
tvl: number;
|
|
}
|
|
const [liquidityPools, setLiquidityPools] = useState<LiquidityPool[]>([]);
|
|
const [isLoadingPools, setIsLoadingPools] = useState(false);
|
|
|
|
// Transaction history
|
|
interface SwapTransaction {
|
|
blockNumber: number;
|
|
timestamp: number;
|
|
from: string;
|
|
fromToken: string;
|
|
fromAmount: string;
|
|
toToken: string;
|
|
toAmount: string;
|
|
txHash: string;
|
|
}
|
|
const [swapHistory, setSwapHistory] = useState<SwapTransaction[]>([]);
|
|
const [isLoadingHistory, setIsLoadingHistory] = useState(false);
|
|
|
|
// Pool reserves for AMM calculation
|
|
const [poolReserves, setPoolReserves] = useState<{
|
|
reserve0: number;
|
|
reserve1: number;
|
|
asset0: number;
|
|
asset1: number;
|
|
decimals0: number;
|
|
decimals1: number;
|
|
} | null>(null);
|
|
|
|
// Helper: Get display name for token (USDT instead of wUSDT)
|
|
const getTokenDisplayName = (tokenSymbol: string) => {
|
|
const token = AVAILABLE_TOKENS.find(t => t.symbol === tokenSymbol);
|
|
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) {
|
|
return { toAmount: '', priceImpact: 0, minimumReceived: '', lpFee: '' };
|
|
}
|
|
|
|
const amountIn = parseFloat(fromAmount);
|
|
const { reserve0, reserve1, asset0, decimals0, decimals1 } = poolReserves;
|
|
|
|
// Determine which reserve is input and which is output
|
|
// Native HEZ uses NATIVE_TOKEN_ID (-1)
|
|
const fromAssetId = fromToken === 'HEZ' ? NATIVE_TOKEN_ID : ASSET_IDS[fromToken as keyof typeof ASSET_IDS];
|
|
const isAsset0ToAsset1 = fromAssetId === asset0;
|
|
|
|
const reserveIn = isAsset0ToAsset1 ? reserve0 : reserve1;
|
|
const reserveOut = isAsset0ToAsset1 ? reserve1 : reserve0;
|
|
const decimalsIn = isAsset0ToAsset1 ? decimals0 : decimals1;
|
|
const decimalsOut = isAsset0ToAsset1 ? decimals1 : decimals0;
|
|
|
|
// Uniswap V2 AMM formula (matches Substrate runtime exactly)
|
|
// Runtime: amount_in_with_fee = amount_in * (1000 - LPFee) = amount_in * 997
|
|
// LPFee = 3 (0.3% fee - standard DEX fee)
|
|
// Formula: amountOut = (amountIn * 997 * reserveOut) / (reserveIn * 1000 + amountIn * 997)
|
|
const LP_FEE = 3; // 0.3% fee
|
|
const amountInWithFee = amountIn * (1000 - LP_FEE); // = amountIn * 997
|
|
const numerator = amountInWithFee * reserveOut;
|
|
const denominator = reserveIn * 1000 + amountInWithFee;
|
|
const amountOut = numerator / denominator;
|
|
|
|
// Calculate price impact (like Uniswap)
|
|
// Price impact = (amount_in / reserve_in) / (1 + amount_in / reserve_in) * 100
|
|
const priceImpact = (amountIn / (reserveIn + amountIn)) * 100;
|
|
|
|
// Calculate LP fee amount
|
|
const lpFeeAmount = (amountIn * (LP_FEE / 1000)).toFixed(4);
|
|
|
|
// Calculate minimum received with slippage
|
|
// Use appropriate decimal places based on output token
|
|
const outputDecimals = decimalsOut === 6 ? 2 : 4; // USDT: 2 decimals, others: 4
|
|
const minReceived = (amountOut * (1 - parseFloat(slippage) / 100)).toFixed(outputDecimals);
|
|
|
|
if (import.meta.env.DEV) console.log('🔍 Uniswap V2 AMM:', {
|
|
amountIn,
|
|
amountInWithFee,
|
|
reserveIn,
|
|
reserveOut,
|
|
decimalsIn,
|
|
decimalsOut,
|
|
numerator,
|
|
denominator,
|
|
amountOut,
|
|
priceImpact: priceImpact.toFixed(2) + '%',
|
|
lpFeeAmount,
|
|
minReceived,
|
|
feePercent: LP_FEE / 10 + '%'
|
|
});
|
|
|
|
return {
|
|
toAmount: amountOut.toFixed(outputDecimals),
|
|
priceImpact,
|
|
minimumReceived: minReceived,
|
|
lpFee: lpFeeAmount
|
|
};
|
|
}, [fromAmount, poolReserves, fromToken, slippage]);
|
|
|
|
const { toAmount, priceImpact, minimumReceived, lpFee } = swapCalculations;
|
|
|
|
// Check if AssetConversion pallet is available
|
|
useEffect(() => {
|
|
if (import.meta.env.DEV) console.log('🔍 Checking DEX availability...', { assetHubApi: !!assetHubApi, isAssetHubReady });
|
|
if (assetHubApi && isAssetHubReady) {
|
|
const hasAssetConversion = assetHubApi.tx.assetConversion !== undefined;
|
|
if (import.meta.env.DEV) console.log('🔍 AssetConversion pallet check:', hasAssetConversion);
|
|
setIsDexAvailable(hasAssetConversion);
|
|
|
|
if (!hasAssetConversion) {
|
|
if (import.meta.env.DEV) console.warn('⚠️ AssetConversion pallet not available in runtime');
|
|
} else {
|
|
if (import.meta.env.DEV) console.log('✅ AssetConversion pallet is available!');
|
|
}
|
|
}
|
|
}, [assetHubApi, isAssetHubReady]);
|
|
|
|
// Fetch exchange rate from AssetConversion pool
|
|
// Always use wHEZ/PEZ pool (the only valid pool)
|
|
useEffect(() => {
|
|
const fetchExchangeRate = async () => {
|
|
if (import.meta.env.DEV) console.log('🔍 fetchExchangeRate check:', { assetHubApi: !!assetHubApi, isAssetHubReady, isDexAvailable, fromToken, toToken });
|
|
|
|
if (!assetHubApi || !isAssetHubReady || !isDexAvailable) {
|
|
if (import.meta.env.DEV) console.log('⚠️ Skipping fetchExchangeRate:', { assetHubApi: !!assetHubApi, isAssetHubReady, isDexAvailable });
|
|
return;
|
|
}
|
|
|
|
if (import.meta.env.DEV) console.log('✅ Starting fetchExchangeRate...');
|
|
setIsLoadingRate(true);
|
|
try {
|
|
// Map user-selected tokens to actual pool assets
|
|
// HEZ → Native token (NATIVE_TOKEN_ID = -1)
|
|
const getPoolAssetId = (token: string) => {
|
|
if (token === 'HEZ') return NATIVE_TOKEN_ID; // Native HEZ (-1)
|
|
if (token === 'PEZ') return 1;
|
|
if (token === 'USDT') return 1000;
|
|
return ASSET_IDS[token as keyof typeof ASSET_IDS];
|
|
};
|
|
|
|
const fromAssetId = getPoolAssetId(fromToken);
|
|
const toAssetId = getPoolAssetId(toToken);
|
|
|
|
if (import.meta.env.DEV) console.log('🔍 Looking for pool:', { fromToken, toToken, fromAssetId, toAssetId });
|
|
|
|
// IMPORTANT: Pool ID must be sorted (native token first, then by asset ID)
|
|
// Native token (-1) always comes first
|
|
const [asset1, asset2] = fromAssetId === NATIVE_TOKEN_ID
|
|
? [fromAssetId, toAssetId]
|
|
: toAssetId === NATIVE_TOKEN_ID
|
|
? [toAssetId, fromAssetId]
|
|
: fromAssetId < toAssetId
|
|
? [fromAssetId, toAssetId]
|
|
: [toAssetId, fromAssetId];
|
|
|
|
if (import.meta.env.DEV) console.log('🔍 Sorted pool assets:', { asset1, asset2 });
|
|
|
|
// Create pool asset tuple using XCM Location format
|
|
// Native token: { parents: 1, interior: 'Here' }
|
|
// Assets: { parents: 0, interior: { X2: [{ PalletInstance: 50 }, { GeneralIndex: id }] } }
|
|
const poolAssets = [
|
|
formatAssetLocation(asset1),
|
|
formatAssetLocation(asset2)
|
|
];
|
|
|
|
if (import.meta.env.DEV) console.log('🔍 Pool query with XCM Locations:', poolAssets);
|
|
|
|
// Query pool from AssetConversion pallet
|
|
const poolInfo = await assetHubApi.query.assetConversion.pools(poolAssets);
|
|
if (import.meta.env.DEV) console.log('🔍 Pool query result:', poolInfo.toHuman());
|
|
|
|
if (import.meta.env.DEV) console.log('🔍 Pool isEmpty?', poolInfo.isEmpty, 'exists?', !poolInfo.isEmpty);
|
|
|
|
if (poolInfo && !poolInfo.isEmpty) {
|
|
const pool = poolInfo.toJSON() as Record<string, unknown>;
|
|
if (import.meta.env.DEV) console.log('🔍 Pool data:', pool);
|
|
|
|
try {
|
|
// Use Runtime API to get exchange rate (quotePriceExactTokensForTokens)
|
|
// This is more reliable than deriving pool account manually
|
|
// IMPORTANT: HEZ has 12 decimals, USDT has 6 decimals, PEZ has 12 decimals
|
|
const decimals0 = asset1 === 1000 ? 6 : 12; // wUSDT: 6, others: 12
|
|
const decimals1 = asset2 === 1000 ? 6 : 12;
|
|
|
|
const oneUnit = BigInt(Math.pow(10, decimals0));
|
|
|
|
if (import.meta.env.DEV) console.log('🔍 Querying price via runtime API...', { asset1, asset2, decimals0, decimals1 });
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const quote = await (assetHubApi.call as any).assetConversionApi.quotePriceExactTokensForTokens(
|
|
formatAssetLocation(asset1),
|
|
formatAssetLocation(asset2),
|
|
oneUnit.toString(),
|
|
true
|
|
);
|
|
|
|
if (import.meta.env.DEV) console.log('🔍 Quote result:', quote?.toHuman?.() || quote);
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
if (quote && !(quote as any).isNone) {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const priceRaw = (quote as any).unwrap().toString();
|
|
const price = Number(BigInt(priceRaw)) / Math.pow(10, decimals1);
|
|
|
|
if (import.meta.env.DEV) console.log('✅ Price from runtime API:', price, { decimals0, decimals1, priceRaw });
|
|
|
|
// For AMM calculation, estimate reserves from LP supply
|
|
const lpTokenId = pool.lpToken;
|
|
let lpSupply = BigInt(1);
|
|
|
|
if (assetHubApi.query.poolAssets?.asset) {
|
|
const lpAssetDetails = await assetHubApi.query.poolAssets.asset(lpTokenId);
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
if ((lpAssetDetails as any).isSome) {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
lpSupply = BigInt(((lpAssetDetails as any).unwrap() as any).supply.toString());
|
|
}
|
|
}
|
|
|
|
// Estimate reserves using correct decimals for each asset
|
|
// LP = sqrt(r0_raw * r1_raw) where r0/r1 are in smallest units
|
|
//
|
|
// Mathematical derivation for mixed-decimal pools:
|
|
// r0_raw = r0_human * 10^d0, r1_raw = r1_human * 10^d1
|
|
// LP_raw = sqrt(r0_human * 10^d0 * r1_human * 10^d1)
|
|
// = sqrt(r0_human * r1_human) * 10^((d0+d1)/2)
|
|
//
|
|
// LP tokens have 12 decimals, so:
|
|
// LP_human = LP_raw / 10^12 = sqrt(r0_human * r1_human) * 10^((d0+d1)/2 - 12)
|
|
//
|
|
// Therefore: sqrt(r0_human * r1_human) = LP_human * 10^(12 - (d0+d1)/2)
|
|
//
|
|
// For HEZ-USDT (d0=12, d1=6): factor = 10^(12 - 9) = 1000
|
|
// For HEZ-PEZ (d0=12, d1=12): factor = 10^(12 - 12) = 1
|
|
|
|
const lpHuman = Number(lpSupply) / Math.pow(10, 12); // LP tokens have 12 decimals
|
|
const decimalCorrectionFactor = Math.pow(10, 12 - (decimals0 + decimals1) / 2);
|
|
|
|
// Now estimate reserves:
|
|
// sqrt(r0_human * r1_human) = LP_human * decimalCorrectionFactor
|
|
// price = r1_human / r0_human
|
|
// r0_human = sqrt(r0_human * r1_human) / sqrt(price) = LP_human * factor / sqrt(price)
|
|
// r1_human = sqrt(r0_human * r1_human) * sqrt(price) = LP_human * factor * sqrt(price)
|
|
const sqrtPrice = Math.sqrt(price);
|
|
const reserve0 = lpHuman * decimalCorrectionFactor / sqrtPrice;
|
|
const reserve1 = lpHuman * decimalCorrectionFactor * sqrtPrice;
|
|
|
|
if (import.meta.env.DEV) console.log('✅ Estimated reserves (human units):', {
|
|
reserve0,
|
|
reserve1,
|
|
price,
|
|
lpSupply: lpSupply.toString(),
|
|
lpHuman,
|
|
decimals0,
|
|
decimals1
|
|
});
|
|
|
|
// Store pool reserves for AMM calculation
|
|
setPoolReserves({
|
|
reserve0,
|
|
reserve1,
|
|
asset0: asset1,
|
|
asset1: asset2,
|
|
decimals0,
|
|
decimals1
|
|
});
|
|
|
|
// Calculate exchange rate based on direction
|
|
const rate = fromAssetId === asset1 ? price : 1 / price;
|
|
|
|
if (import.meta.env.DEV) console.log('✅ Exchange rate:', rate);
|
|
setExchangeRate(rate);
|
|
} else {
|
|
if (import.meta.env.DEV) console.warn('⚠️ No price quote available');
|
|
setExchangeRate(0);
|
|
}
|
|
} catch (err) {
|
|
if (import.meta.env.DEV) console.error('❌ Error fetching price:', err);
|
|
setExchangeRate(0);
|
|
}
|
|
} else {
|
|
if (import.meta.env.DEV) console.warn('No liquidity pool found for this pair');
|
|
setExchangeRate(0);
|
|
}
|
|
} catch (error) {
|
|
if (import.meta.env.DEV) console.error('Failed to fetch exchange rate:', error);
|
|
setExchangeRate(0);
|
|
} finally {
|
|
setIsLoadingRate(false);
|
|
}
|
|
};
|
|
|
|
fetchExchangeRate();
|
|
}, [assetHubApi, isAssetHubReady, isDexAvailable, fromToken, toToken]);
|
|
|
|
// Fetch liquidity pools
|
|
useEffect(() => {
|
|
const fetchLiquidityPools = async () => {
|
|
if (!assetHubApi || !isAssetHubReady || !isDexAvailable) {
|
|
return;
|
|
}
|
|
|
|
setIsLoadingPools(true);
|
|
try {
|
|
// Query all pools from AssetConversion pallet
|
|
const poolsEntries = await assetHubApi.query.assetConversion.pools.entries();
|
|
|
|
if (poolsEntries && poolsEntries.length > 0) {
|
|
const pools = poolsEntries.map(([key, value]: [unknown, unknown]) => {
|
|
const poolData = value.toJSON();
|
|
const poolKey = key.toHuman();
|
|
|
|
// Calculate TVL from reserves
|
|
const tvl = poolData && poolData[0] && poolData[1]
|
|
? ((parseFloat(poolData[0]) + parseFloat(poolData[1])) / 1e12).toFixed(2)
|
|
: '0';
|
|
|
|
// Parse asset IDs from pool key
|
|
const assets = poolKey?.[0] || [];
|
|
//const _asset1 = assets[0]?.NativeOrAsset?.Asset || '?';
|
|
const asset2 = assets[1]?.NativeOrAsset?.Asset || '?';
|
|
|
|
return {
|
|
pool: `Asset ${asset1} / Asset ${asset2}`,
|
|
tvl: `$${tvl}M`,
|
|
apr: 'TBD', // Requires historical data
|
|
volume: 'TBD', // Requires event indexing
|
|
};
|
|
});
|
|
|
|
setLiquidityPools(pools.slice(0, 3));
|
|
} else {
|
|
setLiquidityPools([]);
|
|
}
|
|
} catch (error) {
|
|
if (import.meta.env.DEV) console.error('Failed to fetch liquidity pools:', error);
|
|
setLiquidityPools([]);
|
|
} finally {
|
|
setIsLoadingPools(false);
|
|
}
|
|
};
|
|
|
|
fetchLiquidityPools();
|
|
}, [assetHubApi, isAssetHubReady, isDexAvailable]);
|
|
|
|
// Fetch swap transaction history
|
|
useEffect(() => {
|
|
const fetchSwapHistory = async () => {
|
|
if (!assetHubApi || !isAssetHubReady || !isDexAvailable || !selectedAccount) {
|
|
return;
|
|
}
|
|
|
|
setIsLoadingHistory(true);
|
|
try {
|
|
// Get recent finalized blocks (last 100 blocks)
|
|
const finalizedHead = await assetHubApi.rpc.chain.getFinalizedHead();
|
|
const finalizedBlock = await assetHubApi.rpc.chain.getBlock(finalizedHead);
|
|
const currentBlockNumber = finalizedBlock.block.header.number.toNumber();
|
|
|
|
const startBlock = Math.max(0, currentBlockNumber - 100);
|
|
|
|
if (import.meta.env.DEV) console.log('🔍 Fetching swap history from block', startBlock, 'to', currentBlockNumber);
|
|
|
|
const transactions: SwapTransaction[] = [];
|
|
|
|
// Query block by block for SwapExecuted events
|
|
for (let blockNum = currentBlockNumber; blockNum >= startBlock && transactions.length < 10; blockNum--) {
|
|
try {
|
|
const blockHash = await assetHubApi.rpc.chain.getBlockHash(blockNum);
|
|
const apiAt = await assetHubApi.at(blockHash);
|
|
const events = await apiAt.query.system.events();
|
|
//const block = await assetHubApi.rpc.chain.getBlock(blockHash);
|
|
const timestamp = Date.now() - ((currentBlockNumber - blockNum) * 6000); // Estimate 6s per block
|
|
|
|
events.forEach((record: { event: { data: unknown[] } }) => {
|
|
const { event } = record;
|
|
|
|
// Check for AssetConversion::SwapExecuted event
|
|
if (assetHubApi.events.assetConversion?.SwapExecuted?.is(event)) {
|
|
// SwapExecuted has 5 fields: (who, send_to, amountIn, amountOut, path)
|
|
const [who, , amountIn, amountOut, path] = event.data;
|
|
|
|
// Parse path to get token symbols - path is Vec<MultiAsset>
|
|
let fromAssetId = 0;
|
|
let toAssetId = 0;
|
|
|
|
try {
|
|
// Path structure is: [[assetId, amount], [assetId, amount]]
|
|
const pathArray = path.toJSON ? path.toJSON() : path;
|
|
|
|
if (Array.isArray(pathArray) && pathArray.length >= 2) {
|
|
// Extract asset IDs from path
|
|
const asset0 = pathArray[0];
|
|
//const _asset1 = pathArray[1];
|
|
|
|
// Each element is a tuple where index 0 is the asset ID
|
|
if (Array.isArray(asset0) && asset0.length >= 1) {
|
|
fromAssetId = typeof asset0[0] === 'number' ? asset0[0] : parseInt(asset0[0]) || 0;
|
|
}
|
|
if (Array.isArray(asset1) && asset1.length >= 1) {
|
|
toAssetId = typeof asset1[0] === 'number' ? asset1[0] : parseInt(asset1[0]) || 0;
|
|
}
|
|
}
|
|
} catch (err) {
|
|
if (import.meta.env.DEV) console.warn('Failed to parse swap path:', err);
|
|
}
|
|
|
|
const fromTokenSymbol = fromAssetId === 0 ? 'wHEZ' : fromAssetId === 1 ? 'PEZ' : fromAssetId === 1000 ? 'USDT' : `Asset${fromAssetId}`;
|
|
const toTokenSymbol = toAssetId === 0 ? 'wHEZ' : toAssetId === 1 ? 'PEZ' : toAssetId === 1000 ? 'USDT' : `Asset${toAssetId}`;
|
|
|
|
// Only show transactions from current user
|
|
if (who.toString() === selectedAccount.address) {
|
|
transactions.push({
|
|
blockNumber: blockNum,
|
|
timestamp,
|
|
from: who.toString(),
|
|
fromToken: fromTokenSymbol === 'wHEZ' ? 'HEZ' : fromTokenSymbol,
|
|
fromAmount: formatBalance(amountIn.toString()),
|
|
toToken: toTokenSymbol === 'wHEZ' ? 'HEZ' : toTokenSymbol,
|
|
toAmount: formatBalance(amountOut.toString()),
|
|
txHash: blockHash.toHex()
|
|
});
|
|
}
|
|
}
|
|
});
|
|
} catch (err) {
|
|
if (import.meta.env.DEV) console.warn(`Failed to fetch block ${blockNum}:`, err);
|
|
}
|
|
}
|
|
|
|
if (import.meta.env.DEV) console.log('✅ Swap history fetched:', transactions.length, 'transactions');
|
|
setSwapHistory(transactions.slice(0, 10)); // Show max 10
|
|
} catch (error) {
|
|
if (import.meta.env.DEV) console.error('Failed to fetch swap history:', error);
|
|
setSwapHistory([]);
|
|
} finally {
|
|
setIsLoadingHistory(false);
|
|
}
|
|
};
|
|
|
|
fetchSwapHistory();
|
|
}, [assetHubApi, isAssetHubReady, isDexAvailable, selectedAccount]);
|
|
|
|
const handleSwap = () => {
|
|
setFromToken(toToken);
|
|
setToToken(fromToken);
|
|
setFromAmount('');
|
|
};
|
|
|
|
const handleConfirmSwap = async () => {
|
|
if (!assetHubApi || !selectedAccount) {
|
|
toast({
|
|
title: t('common.error'),
|
|
description: t('common.connectWalletAlert'),
|
|
variant: 'destructive',
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (!isDexAvailable) {
|
|
toast({
|
|
title: t('tokenSwap.dexNotAvailable'),
|
|
description: t('tokenSwap.dexNotAvailableDesc'),
|
|
variant: 'destructive',
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (!exchangeRate || exchangeRate === 0) {
|
|
toast({
|
|
title: t('common.error'),
|
|
description: t('swap.noPool'),
|
|
variant: 'destructive',
|
|
});
|
|
return;
|
|
}
|
|
|
|
// ✅ BALANCE VALIDATION - Check if user has sufficient balance
|
|
const fromAmountNum = parseFloat(fromAmount);
|
|
const fromBalanceNum = parseFloat(fromBalance?.toString() || '0');
|
|
|
|
if (fromAmountNum > fromBalanceNum) {
|
|
toast({
|
|
title: t('swap.insufficientBalanceBtn', { token: getTokenDisplayName(fromToken) }),
|
|
description: t('tokenSwap.insufficientBalanceToast', { balance: fromBalanceNum.toFixed(4), token: getTokenDisplayName(fromToken), amount: fromAmountNum }),
|
|
variant: 'destructive',
|
|
});
|
|
return;
|
|
}
|
|
|
|
setIsSwapping(true);
|
|
setShowConfirm(false); // Close dialog before transaction starts
|
|
try {
|
|
// Get correct decimals for each token
|
|
const getTokenDecimals = (token: string) => {
|
|
if (token === 'USDT') return 6; // USDT 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(),
|
|
toDecimals
|
|
);
|
|
|
|
if (import.meta.env.DEV) console.log('💰 Swap amounts:', {
|
|
fromToken,
|
|
toToken,
|
|
fromAmount,
|
|
toAmount,
|
|
fromDecimals,
|
|
toDecimals,
|
|
amountIn: amountIn.toString(),
|
|
minAmountOut: minAmountOut.toString()
|
|
});
|
|
|
|
// Get signer (extension or WalletConnect)
|
|
const { getSigner } = await import('@/lib/get-signer');
|
|
const injector = await getSigner(selectedAccount.address, walletSource, assetHubApi);
|
|
|
|
// Build transaction based on token types
|
|
let tx;
|
|
|
|
// Use XCM MultiLocation format for swap paths
|
|
// Native HEZ: { parents: 1, interior: 'Here' }
|
|
// Assets: { parents: 0, interior: { X2: [{ PalletInstance: 50 }, { GeneralIndex: id }] } }
|
|
const nativeLocation = formatAssetLocation(NATIVE_TOKEN_ID);
|
|
const pezLocation = formatAssetLocation(1);
|
|
const usdtLocation = formatAssetLocation(1000);
|
|
|
|
if (fromToken === 'HEZ' && toToken === 'PEZ') {
|
|
// HEZ → PEZ: Direct swap using native token pool
|
|
tx = assetHubApi.tx.assetConversion.swapExactTokensForTokens(
|
|
[nativeLocation, pezLocation],
|
|
amountIn.toString(),
|
|
minAmountOut.toString(),
|
|
selectedAccount.address,
|
|
true
|
|
);
|
|
|
|
} else if (fromToken === 'PEZ' && toToken === 'HEZ') {
|
|
// PEZ → HEZ: Direct swap to native token
|
|
tx = assetHubApi.tx.assetConversion.swapExactTokensForTokens(
|
|
[pezLocation, nativeLocation],
|
|
amountIn.toString(),
|
|
minAmountOut.toString(),
|
|
selectedAccount.address,
|
|
true
|
|
);
|
|
|
|
} else if (fromToken === 'HEZ' && toToken === 'USDT') {
|
|
// HEZ → USDT: Direct swap using native token pool
|
|
tx = assetHubApi.tx.assetConversion.swapExactTokensForTokens(
|
|
[nativeLocation, usdtLocation],
|
|
amountIn.toString(),
|
|
minAmountOut.toString(),
|
|
selectedAccount.address,
|
|
true
|
|
);
|
|
|
|
} else if (fromToken === 'USDT' && toToken === 'HEZ') {
|
|
// USDT → HEZ: Direct swap to native token
|
|
tx = assetHubApi.tx.assetConversion.swapExactTokensForTokens(
|
|
[usdtLocation, nativeLocation],
|
|
amountIn.toString(),
|
|
minAmountOut.toString(),
|
|
selectedAccount.address,
|
|
true
|
|
);
|
|
|
|
} else if (fromToken === 'PEZ' && toToken === 'USDT') {
|
|
// PEZ → USDT: Multi-hop through HEZ (PEZ → HEZ → USDT)
|
|
tx = assetHubApi.tx.assetConversion.swapExactTokensForTokens(
|
|
[pezLocation, nativeLocation, usdtLocation],
|
|
amountIn.toString(),
|
|
minAmountOut.toString(),
|
|
selectedAccount.address,
|
|
true
|
|
);
|
|
|
|
} else if (fromToken === 'USDT' && toToken === 'PEZ') {
|
|
// USDT → PEZ: Multi-hop through HEZ (USDT → HEZ → PEZ)
|
|
tx = assetHubApi.tx.assetConversion.swapExactTokensForTokens(
|
|
[usdtLocation, nativeLocation, pezLocation],
|
|
amountIn.toString(),
|
|
minAmountOut.toString(),
|
|
selectedAccount.address,
|
|
true
|
|
);
|
|
|
|
} else {
|
|
// Generic swap using XCM Locations
|
|
const getAssetLocation = (token: string) => {
|
|
if (token === 'HEZ') return nativeLocation;
|
|
if (token === 'PEZ') return pezLocation;
|
|
if (token === 'USDT') return usdtLocation;
|
|
const assetId = ASSET_IDS[token as keyof typeof ASSET_IDS];
|
|
return formatAssetLocation(assetId);
|
|
};
|
|
|
|
tx = assetHubApi.tx.assetConversion.swapExactTokensForTokens(
|
|
[getAssetLocation(fromToken), getAssetLocation(toToken)],
|
|
amountIn.toString(),
|
|
minAmountOut.toString(),
|
|
selectedAccount.address,
|
|
true
|
|
);
|
|
}
|
|
|
|
// Sign and send transaction
|
|
await tx.signAndSend(
|
|
selectedAccount.address,
|
|
{ signer: injector.signer },
|
|
async ({ status, events, dispatchError }) => {
|
|
if (import.meta.env.DEV) console.log('🔍 Transaction status:', status.toHuman());
|
|
|
|
if (status.isInBlock) {
|
|
if (import.meta.env.DEV) console.log('✅ Transaction in block:', status.asInBlock.toHex());
|
|
|
|
toast({
|
|
title: t('tokenSwap.txSubmitted'),
|
|
description: t('tokenSwap.processingInBlock', { hash: status.asInBlock.toHex().slice(0, 10) }),
|
|
});
|
|
}
|
|
|
|
if (status.isFinalized) {
|
|
if (import.meta.env.DEV) console.log('✅ Transaction finalized:', status.asFinalized.toHex());
|
|
if (import.meta.env.DEV) console.log('🔍 All events:', events.map(({ event }) => event.toHuman()));
|
|
if (import.meta.env.DEV) console.log('🔍 dispatchError:', dispatchError?.toHuman());
|
|
|
|
// Check for errors
|
|
if (dispatchError) {
|
|
let errorMessage = 'Transaction failed';
|
|
|
|
if (dispatchError.isModule) {
|
|
const decoded = assetHubApi.registry.findMetaError(dispatchError.asModule);
|
|
errorMessage = `${decoded.section}.${decoded.name}: ${decoded.docs}`;
|
|
}
|
|
|
|
toast({
|
|
title: t('common.error'),
|
|
description: errorMessage,
|
|
variant: 'destructive',
|
|
});
|
|
setIsSwapping(false);
|
|
return;
|
|
}
|
|
|
|
// Success - check for swap event
|
|
const hasSwapEvent = events.some(({ event }) =>
|
|
assetHubApi.events.assetConversion?.SwapExecuted?.is(event)
|
|
);
|
|
|
|
if (hasSwapEvent || fromToken === 'HEZ' || toToken === 'HEZ') {
|
|
toast({
|
|
title: t('common.success'),
|
|
description: t('swap.swapped', { fromAmount, fromToken, toAmount, toToken }),
|
|
});
|
|
|
|
setFromAmount('');
|
|
|
|
// Refresh balances and history without page reload
|
|
await refreshBalances();
|
|
if (import.meta.env.DEV) console.log('✅ Balances refreshed after swap');
|
|
|
|
// Refresh swap history after 3 seconds (wait for block finalization)
|
|
setTimeout(async () => {
|
|
if (import.meta.env.DEV) console.log('🔄 Refreshing swap history...');
|
|
const fetchSwapHistory = async () => {
|
|
if (!assetHubApi || !isAssetHubReady || !isDexAvailable || !selectedAccount) return;
|
|
setIsLoadingHistory(true);
|
|
try {
|
|
const finalizedHead = await assetHubApi.rpc.chain.getFinalizedHead();
|
|
const finalizedBlock = await assetHubApi.rpc.chain.getBlock(finalizedHead);
|
|
const currentBlockNumber = finalizedBlock.block.header.number.toNumber();
|
|
const startBlock = Math.max(0, currentBlockNumber - 100);
|
|
const transactions: SwapTransaction[] = [];
|
|
for (let blockNum = currentBlockNumber; blockNum >= startBlock && transactions.length < 10; blockNum--) {
|
|
try {
|
|
const blockHash = await assetHubApi.rpc.chain.getBlockHash(blockNum);
|
|
const apiAt = await assetHubApi.at(blockHash);
|
|
const events = await apiAt.query.system.events();
|
|
const timestamp = Date.now() - ((currentBlockNumber - blockNum) * 6000);
|
|
events.forEach((record: { event: { data: unknown[] } }) => {
|
|
const { event } = record;
|
|
if (assetHubApi.events.assetConversion?.SwapExecuted?.is(event)) {
|
|
// SwapExecuted has 5 fields: (who, send_to, amountIn, amountOut, path)
|
|
const [who, , amountIn, amountOut, path] = event.data;
|
|
|
|
// Parse path (same logic as main history fetch)
|
|
let fromAssetId = 0;
|
|
let toAssetId = 0;
|
|
try {
|
|
// Path structure is: [[assetId, amount], [assetId, amount]]
|
|
const pathArray = path.toJSON ? path.toJSON() : path;
|
|
|
|
if (Array.isArray(pathArray) && pathArray.length >= 2) {
|
|
const asset0 = pathArray[0];
|
|
const asset1 = pathArray[1];
|
|
|
|
// Each element is a tuple where index 0 is the asset ID
|
|
if (Array.isArray(asset0) && asset0.length >= 1) {
|
|
fromAssetId = typeof asset0[0] === 'number' ? asset0[0] : parseInt(asset0[0]) || 0;
|
|
}
|
|
if (Array.isArray(asset1) && asset1.length >= 1) {
|
|
toAssetId = typeof asset1[0] === 'number' ? asset1[0] : parseInt(asset1[0]) || 0;
|
|
}
|
|
}
|
|
} catch (err) {
|
|
if (import.meta.env.DEV) console.warn('Failed to parse swap path in refresh:', err);
|
|
}
|
|
|
|
const fromTokenSymbol = fromAssetId === 0 ? 'wHEZ' : fromAssetId === 1 ? 'PEZ' : fromAssetId === 1000 ? 'USDT' : `Asset${fromAssetId}`;
|
|
const toTokenSymbol = toAssetId === 0 ? 'wHEZ' : toAssetId === 1 ? 'PEZ' : toAssetId === 1000 ? 'USDT' : `Asset${toAssetId}`;
|
|
|
|
if (who.toString() === selectedAccount.address) {
|
|
transactions.push({
|
|
blockNumber: blockNum,
|
|
timestamp,
|
|
from: who.toString(),
|
|
fromToken: fromTokenSymbol === 'wHEZ' ? 'HEZ' : fromTokenSymbol,
|
|
fromAmount: formatBalance(amountIn.toString()),
|
|
toToken: toTokenSymbol === 'wHEZ' ? 'HEZ' : toTokenSymbol,
|
|
toAmount: formatBalance(amountOut.toString()),
|
|
txHash: blockHash.toHex()
|
|
});
|
|
}
|
|
}
|
|
});
|
|
} catch (err) {
|
|
if (import.meta.env.DEV) console.warn(`Failed to fetch block ${blockNum}:`, err);
|
|
}
|
|
}
|
|
setSwapHistory(transactions.slice(0, 10));
|
|
} catch (error) {
|
|
if (import.meta.env.DEV) console.error('Failed to refresh swap history:', error);
|
|
} finally {
|
|
setIsLoadingHistory(false);
|
|
}
|
|
};
|
|
await fetchSwapHistory();
|
|
}, 3000);
|
|
} else {
|
|
toast({
|
|
title: t('common.error'),
|
|
description: t('swap.swapFailed'),
|
|
variant: 'destructive',
|
|
});
|
|
}
|
|
|
|
setIsSwapping(false);
|
|
}
|
|
}
|
|
);
|
|
} catch (error) {
|
|
if (import.meta.env.DEV) console.error('Swap failed:', error);
|
|
toast({
|
|
title: t('common.error'),
|
|
description: error instanceof Error ? error.message : t('swap.swapFailed'),
|
|
variant: 'destructive',
|
|
});
|
|
setIsSwapping(false);
|
|
}
|
|
};
|
|
|
|
// Show DEX unavailable message
|
|
if (!isDexAvailable && isAssetHubReady) {
|
|
return (
|
|
<div className="max-w-4xl mx-auto">
|
|
<Card className="p-8">
|
|
<div className="text-center space-y-6">
|
|
<div className="flex justify-center">
|
|
<div className="p-4 bg-yellow-500/10 rounded-full">
|
|
<AlertCircle className="w-12 h-12 text-yellow-500" />
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<h2 className="text-2xl font-bold mb-2">{t('tokenSwap.dexComingSoon')}</h2>
|
|
<p className="text-gray-300 max-w-md mx-auto">
|
|
{t('tokenSwap.dexComingDesc')}
|
|
</p>
|
|
</div>
|
|
|
|
<Badge variant="outline" className="text-yellow-500 border-yellow-500/30">
|
|
{t('tokenSwap.scheduledUpgrade')}
|
|
</Badge>
|
|
|
|
<div className="pt-4">
|
|
<Button variant="outline" onClick={() => window.location.href = '/'}>
|
|
{t('tokenSwap.backToDashboard')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 sm:gap-6">
|
|
{/* Kurdistan Sun Animation Overlay during swap (only after confirm dialog is closed) */}
|
|
{isSwapping && !showConfirm && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm">
|
|
<div className="flex flex-col items-center gap-4">
|
|
<KurdistanSun size={300} />
|
|
<p className="text-white text-xl font-semibold animate-pulse">
|
|
{t('tokenSwap.processingSwap')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="lg:col-span-2 space-y-6">
|
|
{/* Price Chart */}
|
|
{exchangeRate > 0 && (
|
|
<PriceChart
|
|
fromToken={fromToken}
|
|
toToken={toToken}
|
|
currentPrice={exchangeRate}
|
|
/>
|
|
)}
|
|
|
|
<Card className="p-3 sm:p-6">
|
|
<div className="flex justify-between items-center mb-3 sm:mb-6">
|
|
<h2 className="text-lg sm:text-2xl font-bold">{t('tokenSwap.tokenSwap')}</h2>
|
|
<Button variant="ghost" size="icon" onClick={() => setShowSettings(true)}>
|
|
<Settings className="h-5 w-5" />
|
|
</Button>
|
|
</div>
|
|
|
|
{!selectedAccount && (
|
|
<Alert className="mb-4 bg-yellow-500/10 border-yellow-500/30">
|
|
<AlertCircle className="h-4 w-4 text-yellow-500" />
|
|
<AlertDescription className="text-yellow-300">
|
|
{t('tokenSwap.connectWalletAlert')}
|
|
</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
<div className="space-y-4">
|
|
<div className="bg-gray-800 border border-gray-700 rounded-lg p-3 sm:p-4">
|
|
<div className="flex justify-between mb-2">
|
|
<span className="text-sm text-gray-400">{t('tokenSwap.from')}</span>
|
|
<span className="text-sm text-gray-400">
|
|
{t('common.balance')} {fromBalance} {getTokenDisplayName(fromToken)}
|
|
</span>
|
|
</div>
|
|
<div className="flex gap-3">
|
|
<Input
|
|
type="number"
|
|
value={fromAmount}
|
|
onChange={(e) => setFromAmount(e.target.value)}
|
|
placeholder="Amount"
|
|
className="text-lg sm:text-2xl font-bold border-0 bg-transparent text-white placeholder:text-gray-500 placeholder:opacity-50"
|
|
disabled={!selectedAccount}
|
|
/>
|
|
<Select
|
|
value={fromToken}
|
|
onValueChange={(value) => {
|
|
setFromToken(value);
|
|
// Prevent selecting same token for both sides
|
|
if (value === toToken) {
|
|
const otherToken = AVAILABLE_TOKENS.find(t => t.symbol !== value);
|
|
if (otherToken) setToToken(otherToken.symbol);
|
|
}
|
|
}}
|
|
disabled={!selectedAccount}
|
|
>
|
|
<SelectTrigger className="min-w-[100px] sm:min-w-[140px] border-gray-600 hover:border-gray-500">
|
|
<SelectValue>
|
|
{(() => {
|
|
const token = AVAILABLE_TOKENS.find(t => t.symbol === fromToken);
|
|
return (
|
|
<span className="flex items-center gap-1.5 relative">
|
|
{token?.emoji} {token?.displaySymbol || token?.name}
|
|
{token?.badge && (
|
|
<span className="w-2 h-2 bg-gradient-to-br from-red-500 via-yellow-400 to-green-500 rounded-sm absolute -bottom-0.5 -right-0.5"></span>
|
|
)}
|
|
</span>
|
|
);
|
|
})()}
|
|
</SelectValue>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{AVAILABLE_TOKENS.map((token) => (
|
|
<SelectItem key={token.symbol} value={token.symbol}>
|
|
<span className="flex items-center gap-2">
|
|
<span>{token.emoji} {token.name}</span>
|
|
{token.badge && (
|
|
<span className="w-2.5 h-2.5 bg-gradient-to-br from-red-500 via-yellow-400 to-green-500 rounded-sm ml-1"></span>
|
|
)}
|
|
</span>
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex justify-center -my-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={handleSwap}
|
|
className="rounded-full bg-gray-800 border-2 border-gray-700 hover:bg-gray-700 hover:border-gray-600"
|
|
disabled={!selectedAccount}
|
|
>
|
|
<ArrowDownUp className="h-5 w-5 text-gray-300" />
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="bg-gray-800 border border-gray-700 rounded-lg p-3 sm:p-4">
|
|
<div className="flex justify-between mb-2">
|
|
<span className="text-sm text-gray-400">{t('tokenSwap.to')}</span>
|
|
<span className="text-sm text-gray-400">
|
|
{t('common.balance')} {toBalance} {getTokenDisplayName(toToken)}
|
|
</span>
|
|
</div>
|
|
<div className="flex gap-3">
|
|
<Input
|
|
type="text"
|
|
value={toAmount}
|
|
readOnly
|
|
placeholder="Amount"
|
|
className="text-lg sm:text-2xl font-bold border-0 bg-transparent text-white placeholder:text-gray-500 placeholder:opacity-50"
|
|
/>
|
|
<Select
|
|
value={toToken}
|
|
onValueChange={(value) => {
|
|
setToToken(value);
|
|
// Prevent selecting same token for both sides
|
|
if (value === fromToken) {
|
|
const otherToken = AVAILABLE_TOKENS.find(t => t.symbol !== value);
|
|
if (otherToken) setFromToken(otherToken.symbol);
|
|
}
|
|
}}
|
|
disabled={!selectedAccount}
|
|
>
|
|
<SelectTrigger className="min-w-[100px] sm:min-w-[140px] border-gray-600 hover:border-gray-500">
|
|
<SelectValue>
|
|
{(() => {
|
|
const token = AVAILABLE_TOKENS.find(t => t.symbol === toToken);
|
|
return (
|
|
<span className="flex items-center gap-1.5 relative">
|
|
{token?.emoji} {token?.displaySymbol || token?.name}
|
|
{token?.badge && (
|
|
<span className="w-2 h-2 bg-gradient-to-br from-red-500 via-yellow-400 to-green-500 rounded-sm absolute -bottom-0.5 -right-0.5"></span>
|
|
)}
|
|
</span>
|
|
);
|
|
})()}
|
|
</SelectValue>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{AVAILABLE_TOKENS.map((token) => (
|
|
<SelectItem key={token.symbol} value={token.symbol}>
|
|
<span className="flex items-center gap-2">
|
|
<span>{token.emoji} {token.name}</span>
|
|
{token.badge && (
|
|
<span className="w-2.5 h-2.5 bg-gradient-to-br from-red-500 via-yellow-400 to-green-500 rounded-sm ml-1"></span>
|
|
)}
|
|
</span>
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Swap Details - Uniswap Style */}
|
|
<div className="bg-gray-800 border border-gray-700 rounded-lg p-4 space-y-2">
|
|
<div className="flex justify-between text-sm">
|
|
<span className="text-gray-400 flex items-center gap-1">
|
|
<Info className="w-3 h-3" />
|
|
{t('common.exchangeRate')}
|
|
</span>
|
|
<span className="font-semibold text-white">
|
|
{isLoadingRate ? (
|
|
t('common.loading')
|
|
) : exchangeRate > 0 ? (
|
|
`1 ${getTokenDisplayName(fromToken)} = ${exchangeRate.toFixed(4)} ${getTokenDisplayName(toToken)}`
|
|
) : (
|
|
t('tokenSwap.noPoolAvailable')
|
|
)}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Price Impact Indicator (Uniswap style) */}
|
|
{fromAmount && parseFloat(fromAmount) > 0 && priceImpact > 0 && (
|
|
<div className="flex justify-between text-sm">
|
|
<span className="text-gray-400 flex items-center gap-1">
|
|
<AlertTriangle className={`w-3 h-3 ${
|
|
priceImpact < 1 ? 'text-green-500' :
|
|
priceImpact < 5 ? 'text-yellow-500' :
|
|
'text-red-500'
|
|
}`} />
|
|
{t('tokenSwap.priceImpact')}
|
|
</span>
|
|
<span className={`font-semibold ${
|
|
priceImpact < 1 ? 'text-green-400' :
|
|
priceImpact < 5 ? 'text-yellow-400' :
|
|
'text-red-400'
|
|
}`}>
|
|
{priceImpact < 0.01 ? '<0.01%' : `${priceImpact.toFixed(2)}%`}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* LP Fee */}
|
|
{fromAmount && parseFloat(fromAmount) > 0 && lpFee && (
|
|
<div className="flex justify-between text-sm">
|
|
<span className="text-gray-400">{t('tokenSwap.lpFee')}</span>
|
|
<span className="text-gray-300">{lpFee} {getTokenDisplayName(fromToken)}</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Minimum Received */}
|
|
{fromAmount && parseFloat(fromAmount) > 0 && minimumReceived && (
|
|
<div className="flex justify-between text-sm">
|
|
<span className="text-gray-400">{t('tokenSwap.minimumReceived')}</span>
|
|
<span className="text-gray-300">{minimumReceived} {getTokenDisplayName(toToken)}</span>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex justify-between text-sm pt-2 border-t border-gray-700">
|
|
<span className="text-gray-400">{t('common.slippageTolerance')}</span>
|
|
<span className="font-semibold text-blue-400">{slippage}%</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Insufficient Balance Warning */}
|
|
{hasInsufficientBalance && (
|
|
<Alert className="bg-red-900/20 border-red-500/30">
|
|
<AlertTriangle className="h-4 w-4 text-red-500" />
|
|
<AlertDescription className="text-red-300 text-sm">
|
|
{t('tokenSwap.insufficientWarning', { token: getTokenDisplayName(fromToken), balance: fromBalance, amount: fromAmount })}
|
|
</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
{/* High Price Impact Warning (>5%) */}
|
|
{priceImpact >= 5 && !hasInsufficientBalance && (
|
|
<Alert className="bg-red-900/20 border-red-500/30">
|
|
<AlertTriangle className="h-4 w-4 text-red-500" />
|
|
<AlertDescription className="text-red-300 text-sm">
|
|
{t('tokenSwap.highPriceImpact')}
|
|
</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
<Button
|
|
className="w-full h-12 text-lg"
|
|
onClick={() => setShowConfirm(true)}
|
|
disabled={!fromAmount || parseFloat(fromAmount) <= 0 || !selectedAccount || exchangeRate === 0 || hasInsufficientBalance}
|
|
>
|
|
{!selectedAccount
|
|
? t('tokenSwap.connectWallet')
|
|
: hasInsufficientBalance
|
|
? t('tokenSwap.insufficientBalance', { token: getTokenDisplayName(fromToken) })
|
|
: exchangeRate === 0
|
|
? t('tokenSwap.noPool')
|
|
: t('tokenSwap.swapTokens')}
|
|
</Button>
|
|
</div>
|
|
</Card>
|
|
|
|
<Card className="p-6 hidden md:block">
|
|
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
|
<TrendingUp className="h-5 w-5" />
|
|
{t('tokenSwap.liquidityPools')}
|
|
</h3>
|
|
|
|
{isLoadingPools ? (
|
|
<div className="text-center text-gray-400 py-8">{t('tokenSwap.loadingPools')}</div>
|
|
) : liquidityPools.length > 0 ? (
|
|
<div className="space-y-3">
|
|
{liquidityPools.map((pool, idx) => (
|
|
<div key={idx} className="flex justify-between items-center p-3 bg-gray-800 border border-gray-700 rounded-lg hover:border-gray-600 transition-colors">
|
|
<div>
|
|
<div className="font-semibold text-gray-200">{pool.pool}</div>
|
|
<div className="text-sm text-gray-400">TVL: {pool.tvl}</div>
|
|
</div>
|
|
<div className="text-right">
|
|
<div className="text-green-400 font-semibold">{pool.apr} APR</div>
|
|
<div className="text-sm text-gray-400">Vol: {pool.volume}</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="text-center text-gray-400 py-8">
|
|
{t('tokenSwap.noPoolsAvailable')}
|
|
</div>
|
|
)}
|
|
</Card>
|
|
</div>
|
|
|
|
<div className="hidden md:block">
|
|
<Card className="p-6">
|
|
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
|
<Clock className="h-5 w-5" />
|
|
{t('tokenSwap.recentSwaps')}
|
|
</h3>
|
|
|
|
{!selectedAccount ? (
|
|
<div className="text-center text-gray-400 py-8">
|
|
{t('tokenSwap.connectForHistory')}
|
|
</div>
|
|
) : isLoadingHistory ? (
|
|
<div className="text-center text-gray-400 py-8">
|
|
{t('tokenSwap.loadingHistory')}
|
|
</div>
|
|
) : swapHistory.length > 0 ? (
|
|
<div className="space-y-3">
|
|
{swapHistory.map((tx, idx) => (
|
|
<div key={idx} className="p-3 bg-gray-800 border border-gray-700 rounded-lg hover:border-gray-600 transition-colors">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<div className="flex items-center gap-2">
|
|
<ArrowDownUp className="w-4 h-4 text-blue-400" />
|
|
<span className="text-sm font-semibold text-white">
|
|
{getTokenDisplayName(tx.fromToken)} → {getTokenDisplayName(tx.toToken)}
|
|
</span>
|
|
</div>
|
|
<span className="text-xs text-gray-500">
|
|
#{tx.blockNumber}
|
|
</span>
|
|
</div>
|
|
<div className="text-sm text-gray-400 space-y-1">
|
|
<div className="flex justify-between">
|
|
<span>{t('tokenSwap.sent')}</span>
|
|
<span className="text-red-400">-{tx.fromAmount} {getTokenDisplayName(tx.fromToken)}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span>{t('tokenSwap.received')}</span>
|
|
<span className="text-green-400">+{tx.toAmount} {getTokenDisplayName(tx.toToken)}</span>
|
|
</div>
|
|
<div className="flex justify-between text-xs pt-1 border-t border-gray-700">
|
|
<span>{new Date(tx.timestamp).toLocaleDateString()}</span>
|
|
<span>{new Date(tx.timestamp).toLocaleTimeString()}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="text-center text-gray-400 py-8">
|
|
{t('tokenSwap.noSwapHistory')}
|
|
</div>
|
|
)}
|
|
</Card>
|
|
</div>
|
|
|
|
<Dialog open={showSettings} onOpenChange={setShowSettings}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>{t('tokenSwap.swapSettings')}</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="text-sm font-medium">{t('common.slippageTolerance')}</label>
|
|
<div className="flex gap-2 mt-2">
|
|
{['0.1', '0.5', '1.0'].map(val => (
|
|
<Button
|
|
key={val}
|
|
variant={slippage === val ? 'default' : 'outline'}
|
|
onClick={() => setSlippage(val)}
|
|
className="flex-1"
|
|
>
|
|
{val}%
|
|
</Button>
|
|
))}
|
|
<Input
|
|
type="number"
|
|
value={slippage}
|
|
onChange={(e) => setSlippage(e.target.value)}
|
|
className="w-20"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<Dialog open={showConfirm} onOpenChange={setShowConfirm}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>{t('tokenSwap.confirmSwap')}</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-4">
|
|
<div className="p-4 bg-gray-800 border border-gray-700 rounded-lg">
|
|
<div className="flex justify-between mb-2">
|
|
<span className="text-gray-300">{t('tokenSwap.youPay')}</span>
|
|
<span className="font-bold text-white">{fromAmount} {getTokenDisplayName(fromToken)}</span>
|
|
</div>
|
|
<div className="flex justify-between mb-2">
|
|
<span className="text-gray-300">{t('tokenSwap.youReceive')}</span>
|
|
<span className="font-bold text-white">{toAmount} {getTokenDisplayName(toToken)}</span>
|
|
</div>
|
|
<div className="flex justify-between mt-3 pt-3 border-t border-gray-700 text-sm">
|
|
<span className="text-gray-400">{t('common.exchangeRate')}</span>
|
|
<span className="text-gray-400">1 {getTokenDisplayName(fromToken)} = {exchangeRate.toFixed(4)} {getTokenDisplayName(toToken)}</span>
|
|
</div>
|
|
<div className="flex justify-between text-sm">
|
|
<span className="text-gray-400">{t('swap.slippage')}</span>
|
|
<span className="text-gray-400">{slippage}%</span>
|
|
</div>
|
|
</div>
|
|
<Button
|
|
className="w-full"
|
|
onClick={handleConfirmSwap}
|
|
disabled={isSwapping}
|
|
>
|
|
{isSwapping ? t('tokenSwap.swapping') : t('tokenSwap.confirmSwap')}
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default TokenSwap; |