"use client"; import { useState, useEffect, useCallback } from "react"; import Image from "next/image"; import { Button } from "@heroui/react"; import { useApp } from "@/contexts/AppContext"; import { buttonStyles } from "@/lib/buttonStyles"; import { useAccount, useReadContract, useGasPrice } from 'wagmi'; import { formatUnits, parseUnits } from 'viem'; import { useTokenBalance } from '@/hooks/useBalance'; import { useSwap } from '@/hooks/useSwap'; import { abis, getContractAddress } from '@/lib/contracts'; import { useTokenDecimalsFromAddress } from '@/hooks/useTokenDecimals'; import Toast from "@/components/common/Toast"; import TokenSelector from '@/components/common/TokenSelector'; import { Token } from '@/lib/api/tokens'; interface TradePanelProps { showHeader?: boolean; title?: string; subtitle?: string; } export default function TradePanel({ showHeader = false, title = "", subtitle = "", }: TradePanelProps) { const { t } = useApp(); const [sellAmount, setSellAmount] = useState(""); const [buyAmount, setBuyAmount] = useState(""); const [mounted, setMounted] = useState(false); const [tokenIn, setTokenIn] = useState(''); const [tokenInObj, setTokenInObj] = useState(); const [tokenOut, setTokenOut] = useState(''); const [tokenOutObj, setTokenOutObj] = useState(); const [toast, setToast] = useState<{ message: string; type: "success" | "error" | "info" | "warning" } | null>(null); const [slippage, setSlippage] = useState(0.5); const SLIPPAGE_OPTIONS = [0.3, 0.5, 1, 3]; useEffect(() => { setMounted(true); }, []); const { address, isConnected, chainId } = useAccount(); // 从合约地址读取精度(tokenIn / tokenOut) const sellInputDecimals = useTokenDecimalsFromAddress( tokenInObj?.contractAddress, tokenInObj?.decimals ?? 18 ); const buyInputDecimals = useTokenDecimalsFromAddress( tokenOutObj?.contractAddress, tokenOutObj?.decimals ?? 18 ); const sellDisplayDecimals = Math.min(sellInputDecimals, 6); const truncateDecimals = (s: string, d: number) => { const p = s.split('.'); return p.length > 1 ? `${p[0]}.${p[1].slice(0, d)}` : s; }; const isValidSellAmount = (v: string) => v !== '' && !isNaN(parseFloat(v)) && parseFloat(v) > 0; const handleSellAmountChange = (value: string) => { if (value === '') { setSellAmount(value); return; } if (!/^\d*\.?\d*$/.test(value)) return; const parts = value.split('.'); if (parts.length > 1 && parts[1].length > sellDisplayDecimals) return; const maxBalance = truncateDecimals(tokenInBalance, sellDisplayDecimals); if (parseFloat(value) > parseFloat(maxBalance)) { setSellAmount(maxBalance); return; } setSellAmount(value); }; // 余额:通过合约地址动态查询 const { formattedBalance: tokenInBalance, isLoading: isBalanceLoading, refetch: refetchTokenIn } = useTokenBalance( tokenInObj?.contractAddress, sellInputDecimals ); const { formattedBalance: tokenOutBalance, refetch: refetchTokenOut } = useTokenBalance( tokenOutObj?.contractAddress, buyInputDecimals ); // 查询池子流动性:YTVault.usdyAmounts(tokenOutAddress) const vaultAddress = chainId ? getContractAddress('YTVault', chainId) : undefined; const tokenInAddress = tokenInObj?.contractAddress; const tokenOutAddress = tokenOutObj?.contractAddress; const { data: poolLiquidityRaw, refetch: refetchPool } = useReadContract({ address: vaultAddress, abi: abis.YTVault, functionName: 'usdyAmounts', args: tokenOutAddress ? [tokenOutAddress as `0x${string}`] : undefined, query: { enabled: !!vaultAddress && !!tokenOutAddress, } }); const poolLiquidityOut = poolLiquidityRaw ? formatUnits(poolLiquidityRaw as bigint, buyInputDecimals) : '0'; // 查询实际兑换数量:getSwapAmountOut(tokenIn, tokenOut, amountIn) const sellAmountWei = (() => { if (!sellAmount || !isValidSellAmount(sellAmount)) return undefined; try { return parseUnits(sellAmount, sellInputDecimals); } catch { return undefined; } })(); const { data: swapAmountOutRaw } = useReadContract({ address: vaultAddress, abi: abis.YTVault, functionName: 'getSwapAmountOut', args: tokenInAddress && tokenOutAddress && sellAmountWei !== undefined ? [tokenInAddress as `0x${string}`, tokenOutAddress as `0x${string}`, sellAmountWei] : undefined, query: { enabled: !!vaultAddress && !!tokenInAddress && !!tokenOutAddress && sellAmountWei !== undefined && tokenIn !== tokenOut, }, }); // 返回 [amountOut, amountOutAfterFees, feeBasisPoints] const swapRaw = swapAmountOutRaw as [bigint, bigint, bigint] | undefined; const swapAmountOut = swapRaw ? formatUnits(swapRaw[1], buyInputDecimals) : ''; const swapFeeBps = swapRaw ? Number(swapRaw[2]) : 0; // 读取 vault 基础 swap fee (bps) const { data: baseFeeRaw } = useReadContract({ address: vaultAddress, abi: abis.YTVault, functionName: 'swapFeeBasisPoints', query: { enabled: !!vaultAddress }, }); const baseBps = baseFeeRaw ? Number(baseFeeRaw as bigint) : 0; // Gas price for network cost estimate const { data: gasPriceWei } = useGasPrice({ chainId }); const GAS_ESTIMATE = 500_000n; const networkCostWei = gasPriceWei ? GAS_ESTIMATE * gasPriceWei : 0n; const networkCostNative = networkCostWei > 0n ? parseFloat(formatUnits(networkCostWei, 18)) : 0; const nativeToken = chainId === 97 ? 'BNB' : 'ETH'; // Fee & Price Impact 计算 const feeAmountBigInt = swapRaw ? swapRaw[0] - swapRaw[1] : 0n; const feeAmountFormatted = feeAmountBigInt > 0n ? truncateDecimals(formatUnits(feeAmountBigInt, buyInputDecimals), 6) : '0'; const feePercent = swapFeeBps / 100; const impactBps = Math.max(0, swapFeeBps - baseBps); const impactPercent = impactBps / 100; const { status: swapStatus, error: swapError, isLoading: isSwapLoading, executeApproveAndSwap, reset: resetSwap, } = useSwap(); // 买入数量由合约 getSwapAmountOut 决定 useEffect(() => { if (swapAmountOut && isValidSellAmount(sellAmount)) { setBuyAmount(truncateDecimals(swapAmountOut, 6)); } else { setBuyAmount(""); } }, [swapAmountOut, sellAmount]); // 交易成功后刷新余额 useEffect(() => { if (swapStatus === 'success') { setToast({ message: `${t('swap.successMsg')} ${tokenIn} ${t('swap.to')} ${tokenOut}!`, type: "success" }); refetchTokenIn(); refetchTokenOut(); refetchPool(); setTimeout(() => { resetSwap(); setSellAmount(""); setBuyAmount(""); }, 3000); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [swapStatus]); // 显示错误提示 useEffect(() => { if (swapError) { setToast({ message: swapError, type: swapError === 'Transaction cancelled' ? "warning" : "error" }); refetchTokenIn(); refetchTokenOut(); refetchPool(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [swapError]); // 处理 tokenIn 选择 const handleTokenInSelect = useCallback((token: Token) => { if (tokenInObj && token.symbol === tokenIn) return; if (token.symbol === tokenOut) { setTokenOut(tokenIn); setTokenOutObj(tokenInObj); } setTokenInObj(token); setTokenIn(token.symbol); setSellAmount(""); setBuyAmount(""); }, [tokenIn, tokenOut, tokenInObj]); // 处理 tokenOut 选择 const handleTokenOutSelect = useCallback((token: Token) => { if (tokenOutObj && token.symbol === tokenOut) return; if (token.symbol === tokenIn) { setTokenIn(tokenOut); setTokenInObj(tokenOutObj); } setTokenOutObj(token); setTokenOut(token.symbol); setSellAmount(""); setBuyAmount(""); }, [tokenIn, tokenOut, tokenOutObj]); // 切换交易方向 const handleSwapDirection = () => { const tempSymbol = tokenIn; const tempObj = tokenInObj; setTokenIn(tokenOut); setTokenInObj(tokenOutObj); setTokenOut(tempSymbol); setTokenOutObj(tempObj); setSellAmount(""); setBuyAmount(""); }; // 百分比按钮处理 const handlePercentage = (percentage: number) => { const balance = parseFloat(tokenInBalance); if (balance > 0) { const raw = (balance * percentage / 100).toString(); setSellAmount(truncateDecimals(raw, sellDisplayDecimals)); } }; return (
{/* Header Section - Optional */} {showHeader && (

{title}

{subtitle}

)} {/* Trade Panel */}
{/* SELL and BUY Container with Exchange Icon */}
{/* SELL Section */}
{/* Label and Buttons */}
{t("alp.sell")}
{[25, 50, 75].map(pct => ( ))}
{/* Input Row */}
handleSellAmountChange(e.target.value)} className="w-full text-left text-2xl md:text-[32px] font-bold leading-[130%] tracking-[-0.01em] text-text-primary dark:text-white placeholder:text-[#d1d5db] dark:placeholder:text-gray-500 bg-transparent border-none outline-none" /> {sellAmount ? `≈ $${sellAmount}` : '--'}
{!mounted ? `0 ${tokenIn}` : (isBalanceLoading ? '...' : `${parseFloat(truncateDecimals(tokenInBalance, sellDisplayDecimals)).toLocaleString('en-US', { maximumFractionDigits: sellDisplayDecimals })} ${tokenIn}`)}
{/* Exchange Icon */}
{/* BUY Section */}
{/* Label */}
{t("alp.buy")}
{!mounted ? `0 ${tokenOut}` : `${parseFloat(tokenOutBalance).toLocaleString()} ${tokenOut}`}
{t('swap.pool')}: {!mounted ? '...' : `${parseFloat(poolLiquidityOut).toLocaleString()} ${tokenOut}`}
{/* Input Row */}
{buyAmount ? `≈ $${buyAmount}` : '--'}
{/* Slippage Tolerance */}
{t("swap.slippageTolerance")}
{SLIPPAGE_OPTIONS.map((opt) => ( ))}
{/* Submit Button */} {(() => { const inputAmt = parseFloat(sellAmount) || 0; const balance = parseFloat(tokenInBalance) || 0; const poolAmt = parseFloat(poolLiquidityOut) || 0; const insufficientBalance = inputAmt > 0 && inputAmt > balance; const insufficientPool = inputAmt > 0 && poolAmt > 0 && inputAmt > poolAmt; const buttonDisabled = !mounted || !isConnected || !isValidSellAmount(sellAmount) || isSwapLoading || tokenIn === tokenOut || insufficientBalance || insufficientPool || !tokenInObj || !tokenOutObj; const buttonLabel = (() => { if (!mounted || !isConnected) return t('common.connectWallet'); if (swapStatus === 'idle' && !!sellAmount && !isValidSellAmount(sellAmount)) return t('common.invalidAmount'); if (insufficientBalance) return t('supply.insufficientBalance'); if (insufficientPool) return t('swap.insufficientLiquidity'); if (swapStatus === 'approving') return t('common.approving'); if (swapStatus === 'approved') return t('swap.approved'); if (swapStatus === 'swapping') return t('swap.swapping'); if (swapStatus === 'success') return t('common.success'); if (swapStatus === 'error') return t('common.failed'); return `${t('swap.swapBtn')} ${tokenIn} ${t('swap.to')} ${tokenOut}`; })(); const minOut = swapAmountOut && isValidSellAmount(sellAmount) ? (parseFloat(swapAmountOut) * (1 - slippage / 100)).toFixed(buyInputDecimals) : '0'; return ( ); })()} {/* Rate & Info Section */}
{/* Exchange Rate */}
{swapAmountOut && isValidSellAmount(sellAmount) ? `1 ${tokenIn} ≈ ${truncateDecimals((parseFloat(swapAmountOut) / parseFloat(sellAmount)).toString(), 6)} ${tokenOut}` : `1 ${tokenIn} = -- ${tokenOut}`}
{/* Divider */}
{/* Network Cost */}
{t('swap.networkCost')} {networkCostNative > 0 ? `~${networkCostNative.toFixed(6)} ${nativeToken}` : '--'}
{/* Max Slippage */}
{t('swap.maxSlippage')} {slippage}%
{/* Price Impact */}
{t('swap.priceImpact')} 1 ? 'text-red-500' : impactPercent > 0.3 ? 'text-amber-500' : 'text-text-secondary dark:text-gray-300' : 'text-text-secondary dark:text-gray-300' }`}> {isValidSellAmount(sellAmount) && swapAmountOut ? `${impactPercent.toFixed(2)}%` : '--'}
{/* Fee */}
{t('swap.fee')} {swapFeeBps > 0 && isValidSellAmount(sellAmount) && swapAmountOut ? `${feePercent.toFixed(2)}% (≈${feeAmountFormatted} ${tokenOut})` : '--'}
{/* Toast Notification */} {toast && ( setToast(null)} /> )}
); }