Checkpoint: v10: Bridge页面集成WalletSelector(替代window.ethereum直接调用)、完整Li.Fi交易执行逻辑(USDT Approve+跨链交易)、交易历史记录模块、中英文双语支持;修复WalletSelector连接中状态覆盖层(禁用其他按钮);修复信息卡片"5条以上"文案

This commit is contained in:
Manus 2026-03-10 04:09:55 -04:00
parent 889068d7f5
commit 2eff084785
4 changed files with 623 additions and 201 deletions

View File

@ -665,14 +665,34 @@ export function WalletSelector({ lang, onAddressDetected, connectedAddress, comp
</button> </button>
</div> </div>
{/* Installed wallets */} {/* Connecting overlay — shown when any wallet is connecting */}
{installedWallets.length > 0 && ( {connecting && (
<div
className="rounded-xl p-4 flex items-center gap-3"
style={{ background: "rgba(0,212,255,0.06)", border: "1px solid rgba(0,212,255,0.2)" }}
>
<svg className="animate-spin w-5 h-5 text-cyan-400 flex-shrink-0" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"/>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
<div>
<p className="text-sm font-semibold text-white/80">
{lang === "zh" ? "正在连接钱包..." : "Connecting wallet..."}
</p>
<p className="text-xs text-white/40 mt-0.5">
{lang === "zh" ? "请在钱包弹窗中确认连接请求" : "Please confirm the connection request in your wallet popup"}
</p>
</div>
</div>
)}
{/* Installed wallets — hidden while connecting to prevent accidental double-clicks */}
{!connecting && installedWallets.length > 0 && (
<div className="space-y-2"> <div className="space-y-2">
{installedWallets.map(wallet => ( {installedWallets.map(wallet => (
<button <button
key={wallet.id} key={wallet.id}
onClick={() => handleConnect(wallet)} onClick={() => handleConnect(wallet)}
disabled={connecting === wallet.id} disabled={!!connecting}
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl transition-all hover:opacity-90 active:scale-[0.98]" className="w-full flex items-center gap-3 px-4 py-3 rounded-xl transition-all hover:opacity-90 active:scale-[0.98]"
style={{ style={{
background: wallet.network === "tron" ? "rgba(255,0,19,0.08)" : "rgba(0,212,255,0.08)", background: wallet.network === "tron" ? "rgba(255,0,19,0.08)" : "rgba(0,212,255,0.08)",
@ -705,7 +725,7 @@ export function WalletSelector({ lang, onAddressDetected, connectedAddress, comp
)} )}
{/* No wallets installed — desktop */} {/* No wallets installed — desktop */}
{installedWallets.length === 0 && ( {!connecting && installedWallets.length === 0 && (
<div <div
className="rounded-xl p-4 text-center" className="rounded-xl p-4 text-center"
style={{ background: "rgba(255,255,255,0.04)", border: "1px dashed rgba(255,255,255,0.15)" }} style={{ background: "rgba(255,255,255,0.04)", border: "1px dashed rgba(255,255,255,0.15)" }}
@ -737,8 +757,8 @@ export function WalletSelector({ lang, onAddressDetected, connectedAddress, comp
</div> </div>
)} )}
{/* Not-installed wallets — only show when NO wallet is installed (UI fix: don't mix installed+uninstalled) */} {/* Not-installed wallets — only show when NO wallet is installed and not connecting */}
{!compact && notInstalledWallets.length > 0 && installedWallets.length === 0 && ( {!connecting && !compact && notInstalledWallets.length > 0 && installedWallets.length === 0 && (
<div className="space-y-1"> <div className="space-y-1">
<p className="text-xs text-white/30 mt-2"> <p className="text-xs text-white/30 mt-2">
{lang === "zh" ? "未安装(点击安装)" : "Not installed (click to install)"} {lang === "zh" ? "未安装(点击安装)" : "Not installed (click to install)"}
@ -765,7 +785,7 @@ export function WalletSelector({ lang, onAddressDetected, connectedAddress, comp
)} )}
{/* In compact mode, show install links inline */} {/* In compact mode, show install links inline */}
{compact && notInstalledWallets.length > 0 && installedWallets.length === 0 && ( {!connecting && compact && notInstalledWallets.length > 0 && installedWallets.length === 0 && (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{notInstalledWallets.slice(0, 4).map(wallet => ( {notInstalledWallets.slice(0, 4).map(wallet => (
<a <a
@ -787,11 +807,12 @@ export function WalletSelector({ lang, onAddressDetected, connectedAddress, comp
</div> </div>
)} )}
{error && ( {error && !connecting && (
<p className="text-xs text-red-400 text-center">{error}</p> <p className="text-xs text-red-400 text-center">{error}</p>
)} )}
{/* Manual address input — divider */} {/* Manual address input — divider — hidden while connecting */}
{!connecting && (
<div className="pt-1"> <div className="pt-1">
<button <button
onClick={() => { setShowManual(!showManual); setManualError(null); }} onClick={() => { setShowManual(!showManual); setManualError(null); }}
@ -840,6 +861,7 @@ export function WalletSelector({ lang, onAddressDetected, connectedAddress, comp
</div> </div>
)} )}
</div> </div>
)}
</div> </div>
); );
} }

View File

@ -1,20 +1,136 @@
// NAC Cross-Chain Bridge — Buy XIC with USDT from any chain // NAC Cross-Chain Bridge — Buy XIC with USDT from any chain
// Uses Li.Fi SDK for cross-chain routing // v2: Integrated WalletSelector (same as Home page), full Li.Fi execution, TX history, bilingual
// Uses Li.Fi API for cross-chain routing
// Supports: BSC, ETH, Polygon, Arbitrum, Avalanche // Supports: BSC, ETH, Polygon, Arbitrum, Avalanche
import { useState, useEffect, useCallback, useMemo } from "react";
import { useState, useEffect, useCallback } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { trpc } from "@/lib/trpc"; import { trpc } from "@/lib/trpc";
import { ArrowDown, ArrowLeft, ExternalLink, Loader2, RefreshCw, Zap } from "lucide-react"; import { ArrowDown, ArrowLeft, ExternalLink, Loader2, RefreshCw, Zap, History, ChevronDown, ChevronUp } from "lucide-react";
import { Link } from "wouter"; import { Link } from "wouter";
import { WalletSelector } from "@/components/WalletSelector";
import { useWallet } from "@/hooks/useWallet";
import type { EthProvider } from "@/hooks/useWallet";
import { switchToNetwork } from "@/lib/contracts";
// ─── Language ─────────────────────────────────────────────────────────────────
type Lang = "zh" | "en";
const T = {
zh: {
title: "从任何链购买 XIC",
subtitle: "使用 BSC、ETH、Polygon、Arbitrum 或 Avalanche 上的 USDT 购买 XIC 代币",
fromChain: "来源链",
youPay: "支付金额 (USDT)",
youReceive: "接收数量 (BSC 上的 XIC)",
gettingRoute: "正在获取最优路由...",
route: "路由",
protocol: "协议",
estGas: "预计 Gas",
slippage: "滑点",
connectWallet: "连接钱包以继续",
connecting: "连接中...",
switchNetwork: "切换到",
refreshQuote: "刷新报价",
gettingQuote: "获取报价中...",
enterAmount: "请输入金额以获取报价",
buyXIC: "购买",
executing: "交易执行中...",
txSubmitted: "交易已提交",
txDesc: "您的 XIC 代币即将到达 BSC 链",
viewBscscan: "在 BSCScan 上查看",
connected: "已连接",
supportedChains: "支持链数",
tokenPrice: "代币价格",
listingTarget: "目标上市",
presalePrice: "预售价格",
xPotential: "5倍潜力",
disclaimer: "跨链交易由 Li.Fi 协议支持。确认交易前请务必核实交易详情。最低滑点为 3%。本信息不构成任何财务建议。",
history: "交易历史",
noHistory: "暂无交易记录",
historyDesc: "连接钱包后可查看您的历史交易",
status_completed: "已完成",
status_pending: "处理中",
status_failed: "失败",
txHash: "交易哈希",
backToPresale: "返回预售",
approving: "授权 USDT 中...",
swapping: "执行跨链交换...",
txSuccess: "交易成功!",
txFailed: "交易失败,请重试",
noRoute: "未找到可用路由",
quoteFailed: "获取报价失败,请重试",
minReceive: "最少获得",
via: "通过",
completedSession: "本次会话已完成",
transactions: "笔交易",
approveFirst: "首先授权 USDT",
approveDesc: "需要授权 USDT 合约以允许 Li.Fi 使用您的代币",
approve: "授权",
executeSwap: "执行跨链交换",
lang: "EN",
},
en: {
title: "Buy XIC from Any Chain",
subtitle: "Use USDT on BSC, ETH, Polygon, Arbitrum or Avalanche to buy XIC tokens",
fromChain: "From Chain",
youPay: "You Pay (USDT)",
youReceive: "You Receive (XIC on BSC)",
gettingRoute: "Getting best route...",
route: "Route",
protocol: "Protocol",
estGas: "Est. Gas",
slippage: "Slippage",
connectWallet: "Connect Wallet to Continue",
connecting: "Connecting...",
switchNetwork: "Switch to",
refreshQuote: "Refresh Quote",
gettingQuote: "Getting Quote...",
enterAmount: "Enter amount to get quote",
buyXIC: "Buy",
executing: "Executing...",
txSubmitted: "Transaction Submitted",
txDesc: "Your XIC tokens will arrive on BSC shortly",
viewBscscan: "View on BSCScan",
connected: "Connected",
supportedChains: "Supported Chains",
tokenPrice: "Token Price",
listingTarget: "Listing Target",
presalePrice: "Presale price",
xPotential: "5x potential",
disclaimer: "Cross-chain swaps are powered by Li.Fi protocol. Always verify transaction details before confirming. Minimum slippage 3% applies. Not financial advice.",
history: "Transaction History",
noHistory: "No transactions yet",
historyDesc: "Connect your wallet to view transaction history",
status_completed: "Completed",
status_pending: "Pending",
status_failed: "Failed",
txHash: "TX Hash",
backToPresale: "Back to Presale",
approving: "Approving USDT...",
swapping: "Executing cross-chain swap...",
txSuccess: "Transaction successful!",
txFailed: "Transaction failed. Please try again.",
noRoute: "No route found for this pair",
quoteFailed: "Failed to get quote. Please try again.",
minReceive: "Min receive",
via: "via",
completedSession: "completed this session",
transactions: "transaction",
approveFirst: "Approve USDT First",
approveDesc: "Allow Li.Fi to use your USDT for the swap",
approve: "Approve",
executeSwap: "Execute Cross-Chain Swap",
lang: "中文",
},
};
// ─── Chain Config ───────────────────────────────────────────────────────────── // ─── Chain Config ─────────────────────────────────────────────────────────────
const CHAINS = [ const CHAINS = [
{ id: 56, name: "BSC", symbol: "BNB", color: "#F0B90B", icon: "🟡", usdtAddress: "0x55d398326f99059fF775485246999027B3197955" }, { id: 56, name: "BSC", symbol: "BNB", color: "#F0B90B", icon: "🟡", usdtAddress: "0x55d398326f99059fF775485246999027B3197955", rpcUrl: "https://bsc-dataseed1.binance.org/", nativeCurrency: { name: "BNB", symbol: "BNB", decimals: 18 }, explorerUrl: "https://bscscan.com" },
{ id: 1, name: "Ethereum", symbol: "ETH", color: "#627EEA", icon: "🔵", usdtAddress: "0xdAC17F958D2ee523a2206206994597C13D831ec7" }, { id: 1, name: "Ethereum", symbol: "ETH", color: "#627EEA", icon: "🔵", usdtAddress: "0xdAC17F958D2ee523a2206206994597C13D831ec7", rpcUrl: "https://eth.llamarpc.com", nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 }, explorerUrl: "https://etherscan.io" },
{ id: 137, name: "Polygon", symbol: "MATIC",color: "#8247E5", icon: "🟣", usdtAddress: "0xc2132D05D31c914a87C6611C10748AEb04B58e8F" }, { id: 137, name: "Polygon", symbol: "MATIC",color: "#8247E5", icon: "🟣", usdtAddress: "0xc2132D05D31c914a87C6611C10748AEb04B58e8F", rpcUrl: "https://polygon-rpc.com/", nativeCurrency: { name: "MATIC", symbol: "MATIC", decimals: 18 }, explorerUrl: "https://polygonscan.com" },
{ id: 42161, name: "Arbitrum", symbol: "ETH", color: "#28A0F0", icon: "🔷", usdtAddress: "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9" }, { id: 42161, name: "Arbitrum", symbol: "ETH", color: "#28A0F0", icon: "🔷", usdtAddress: "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9", rpcUrl: "https://arb1.arbitrum.io/rpc", nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 }, explorerUrl: "https://arbiscan.io" },
{ id: 43114, name: "Avalanche", symbol: "AVAX", color: "#E84142", icon: "🔴", usdtAddress: "0x9702230A8Ea53601f5cD2dc00fDBc13d4dF4A8c7" }, { id: 43114, name: "Avalanche", symbol: "AVAX", color: "#E84142", icon: "🔴", usdtAddress: "0x9702230A8Ea53601f5cD2dc00fDBc13d4dF4A8c7", rpcUrl: "https://api.avax.network/ext/bc/C/rpc", nativeCurrency: { name: "AVAX", symbol: "AVAX", decimals: 18 }, explorerUrl: "https://snowtrace.io" },
]; ];
// XIC Token on BSC (destination) // XIC Token on BSC (destination)
@ -30,6 +146,15 @@ const XIC_TOKEN = {
// Quick amounts // Quick amounts
const QUICK_AMOUNTS = [100, 500, 1000, 5000]; const QUICK_AMOUNTS = [100, 500, 1000, 5000];
// Li.Fi Diamond contract addresses (for USDT approval)
const LIFI_DIAMOND: Record<number, string> = {
56: "0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE",
1: "0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE",
137: "0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE",
42161: "0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE",
43114: "0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE",
};
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
interface RouteQuote { interface RouteQuote {
id: string; id: string;
@ -37,6 +162,13 @@ interface RouteQuote {
toAmount: string; toAmount: string;
toAmountMin: string; toAmountMin: string;
estimatedGas: string; estimatedGas: string;
transactionRequest?: {
to: string;
data: string;
value: string;
gasLimit?: string;
gasPrice?: string;
};
steps: Array<{ steps: Array<{
type: string; type: string;
tool: string; tool: string;
@ -65,103 +197,82 @@ async function getRoutes(
fromAddress, fromAddress,
options: JSON.stringify({ slippage: 0.03, order: "RECOMMENDED" }), options: JSON.stringify({ slippage: 0.03, order: "RECOMMENDED" }),
}); });
const res = await fetch(`${LIFI_API}/quote?${params}`); const res = await fetch(`${LIFI_API}/quote?${params}`);
if (!res.ok) throw new Error(`Li.Fi API error: ${res.status}`); if (!res.ok) throw new Error(`Li.Fi API error: ${res.status}`);
const data = await res.json(); const data = await res.json();
return data ? [data] : []; return data ? [data] : [];
} }
// ─── Wallet Hook (window.ethereum) ─────────────────────────────────────────── // ERC-20 minimal ABI for allowance + approve
function useEVMWallet() { const ERC20_ABI_APPROVE = [
const [address, setAddress] = useState<string | null>(null); "function allowance(address owner, address spender) view returns (uint256)",
const [chainId, setChainId] = useState<number | null>(null); "function approve(address spender, uint256 amount) returns (bool)",
const [connecting, setConnecting] = useState(false); ];
const connect = useCallback(async () => { // ─── Chain name helper ────────────────────────────────────────────────────────
if (!window.ethereum) { function getChainName(chainId: number): string {
toast.error("Please install MetaMask or another EVM wallet"); const chain = CHAINS.find(c => c.id === chainId);
return; return chain?.name ?? `Chain ${chainId}`;
} }
setConnecting(true);
try {
const accounts = await window.ethereum.request({ method: "eth_requestAccounts" }) as string[];
setAddress(accounts[0]);
const cid = await window.ethereum.request({ method: "eth_chainId" }) as string;
setChainId(parseInt(cid, 16));
} catch {
toast.error("Wallet connection cancelled");
} finally {
setConnecting(false);
}
}, []);
const switchChain = useCallback(async (targetChainId: number) => { // ─── Explorer URL helper ──────────────────────────────────────────────────────
if (!window.ethereum) return; function getTxUrl(chainId: number, txHash: string): string {
try { const chain = CHAINS.find(c => c.id === chainId);
await window.ethereum.request({ return `${chain?.explorerUrl ?? "https://bscscan.com"}/tx/${txHash}`;
method: "wallet_switchEthereumChain",
params: [{ chainId: `0x${targetChainId.toString(16)}` }],
});
} catch (err: unknown) {
// Chain not added, try adding it
const chain = CHAINS.find((c) => c.id === targetChainId);
if (chain && (err as { code?: number })?.code === 4902) {
toast.error(`Please add ${chain.name} network to your wallet manually`);
}
}
}, []);
useEffect(() => {
if (!window.ethereum) return;
window.ethereum.request({ method: "eth_accounts" }).then((accounts: unknown) => {
const accs = accounts as string[];
if (accs.length > 0) {
setAddress(accs[0]);
window.ethereum!.request({ method: "eth_chainId" }).then((cid: unknown) => {
setChainId(parseInt(cid as string, 16));
});
}
});
const handleAccountsChanged = (accounts: unknown) => {
const accs = accounts as string[];
setAddress(accs.length > 0 ? accs[0] : null);
};
const handleChainChanged = (cid: unknown) => {
setChainId(parseInt(cid as string, 16));
};
window.ethereum.on("accountsChanged", handleAccountsChanged);
window.ethereum.on("chainChanged", handleChainChanged);
return () => {
window.ethereum?.removeListener("accountsChanged", handleAccountsChanged);
window.ethereum?.removeListener("chainChanged", handleChainChanged);
};
}, []);
return { address, chainId, connecting, connect, switchChain };
} }
// ─── Main Bridge Page ───────────────────────────────────────────────────────── // ─── Main Bridge Page ─────────────────────────────────────────────────────────
export default function Bridge() { export default function Bridge() {
const wallet = useEVMWallet(); // ── Language ──
const [fromChain, setFromChain] = useState(CHAINS[0]); // BSC default const [lang, setLang] = useState<Lang>(() => {
const bl = navigator.language.toLowerCase();
return bl.startsWith("zh") ? "zh" : "en";
});
const t = T[lang];
// ── Wallet (shared hook with Home page) ──
const wallet = useWallet();
const [showWalletSelector, setShowWalletSelector] = useState(false);
// ── Bridge state ──
const [fromChain, setFromChain] = useState(CHAINS[0]);
const [usdtAmount, setUsdtAmount] = useState("100"); const [usdtAmount, setUsdtAmount] = useState("100");
const [quote, setQuote] = useState<RouteQuote | null>(null); const [quote, setQuote] = useState<RouteQuote | null>(null);
const [quoting, setQuoting] = useState(false); const [quoting, setQuoting] = useState(false);
const [executing, setExecuting] = useState(false); const [executing, setExecuting] = useState(false);
const [execStep, setExecStep] = useState<"idle" | "approving" | "swapping" | "done">("idle");
const [txHash, setTxHash] = useState<string | null>(null); const [txHash, setTxHash] = useState<string | null>(null);
const [completedTxs, setCompletedTxs] = useState(0); const [completedTxs, setCompletedTxs] = useState(0);
const [showHistory, setShowHistory] = useState(false);
// ── tRPC mutations/queries ──
const recordOrder = trpc.bridge.recordOrder.useMutation(); const recordOrder = trpc.bridge.recordOrder.useMutation();
const myOrdersQuery = trpc.bridge.myOrders.useQuery(
{ walletAddress: wallet.address ?? "", limit: 20 },
{ enabled: !!wallet.address && showHistory }
);
// ── Handle wallet connection from WalletSelector ──
const handleAddressDetected = useCallback(async (address: string, _network?: "evm" | "tron", rawProvider?: EthProvider) => {
if (rawProvider) {
await wallet.connectWithProvider(rawProvider, address);
}
setShowWalletSelector(false);
toast.success(lang === "zh" ? `钱包已连接: ${address.slice(0,6)}...${address.slice(-4)}` : `Wallet connected: ${address.slice(0,6)}...${address.slice(-4)}`);
}, [wallet, lang]);
// ── Auto-hide wallet selector when connected ──
useEffect(() => {
if (wallet.address) {
setShowWalletSelector(false);
}
}, [wallet.address]);
// ─── Fetch quote ───────────────────────────────────────────────────────── // ─── Fetch quote ─────────────────────────────────────────────────────────
const fetchQuote = useCallback(async () => { const fetchQuote = useCallback(async () => {
const amount = parseFloat(usdtAmount); const amount = parseFloat(usdtAmount);
if (!amount || amount <= 0) return; if (!amount || amount <= 0) return;
if (!wallet.address) return; if (!wallet.address) return;
setQuoting(true); setQuoting(true);
setQuote(null); setQuote(null);
try { try {
@ -177,80 +288,166 @@ export default function Bridge() {
if (routes.length > 0) { if (routes.length > 0) {
setQuote(routes[0]); setQuote(routes[0]);
} else { } else {
toast.error("No route found for this pair"); toast.error(t.noRoute);
} }
} catch (err) { } catch (err) {
toast.error("Failed to get quote. Please try again."); toast.error(t.quoteFailed);
console.error(err); console.error(err);
} finally { } finally {
setQuoting(false); setQuoting(false);
} }
}, [fromChain, usdtAmount, wallet.address]); }, [fromChain, usdtAmount, wallet.address, t]);
// Auto-fetch quote when inputs change // Auto-fetch quote when inputs change (debounced)
useEffect(() => { useEffect(() => {
if (!wallet.address || !usdtAmount || parseFloat(usdtAmount) <= 0) return; if (!wallet.address || !usdtAmount || parseFloat(usdtAmount) <= 0) return;
const timer = setTimeout(fetchQuote, 800); const timer = setTimeout(fetchQuote, 800);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [fetchQuote, wallet.address, usdtAmount]); }, [fetchQuote, wallet.address, usdtAmount]);
// ─── Execute swap ───────────────────────────────────────────────────────── // Re-fetch quote when chain changes
useEffect(() => {
setQuote(null);
}, [fromChain]);
// ─── Execute swap (full Li.Fi flow) ──────────────────────────────────────
const executeSwap = useCallback(async () => { const executeSwap = useCallback(async () => {
if (!quote || !wallet.address) return; if (!quote || !wallet.address || !wallet.signer) {
if (!window.ethereum) { toast.error(lang === "zh" ? "请先连接钱包" : "Please connect wallet first");
toast.error("No wallet detected");
return; return;
} }
// Switch to correct chain if needed
if (wallet.chainId !== fromChain.id) { if (wallet.chainId !== fromChain.id) {
await wallet.switchChain(fromChain.id); try {
toast.info(`Please confirm network switch to ${fromChain.name}`); await switchToNetwork(fromChain.id);
return; } catch {
toast.error(lang === "zh" ? `请切换到 ${fromChain.name} 网络` : `Please switch to ${fromChain.name} network`);
return;
}
} }
setExecuting(true); setExecuting(true);
setExecStep("idle");
try { try {
// Get the full route with transaction data from Li.Fi const { ethers } = await import("ethers");
const res = await fetch(`${LIFI_API}/quote`, { const usdtContract = new ethers.Contract(fromChain.usdtAddress, ERC20_ABI_APPROVE, wallet.signer);
method: "GET", const lifiDiamond = LIFI_DIAMOND[fromChain.id];
}); const amountWei = BigInt(Math.floor(parseFloat(usdtAmount) * 1000000));
if (!res.ok) throw new Error("Failed to get transaction data");
// For now, show the user the transaction details and let them confirm // Step 1: Check and approve USDT allowance
// In production, this would call the Li.Fi SDK's executeRoute setExecStep("approving");
toast.success("Route confirmed! Redirecting to Li.Fi for execution..."); const allowance: bigint = await usdtContract.allowance(wallet.address, lifiDiamond);
if (allowance < amountWei) {
toast.info(lang === "zh" ? "请在钱包中确认 USDT 授权..." : "Please confirm USDT approval in your wallet...");
const approveTx = await usdtContract.approve(lifiDiamond, amountWei * BigInt(10));
await approveTx.wait();
toast.success(lang === "zh" ? "USDT 授权成功" : "USDT approved successfully");
}
// Record the order // Step 2: Execute the Li.Fi transaction
const hash = `pending-${Date.now()}`; setExecStep("swapping");
await recordOrder.mutateAsync({ toast.info(lang === "zh" ? "请在钱包中确认跨链交易..." : "Please confirm the cross-chain transaction in your wallet...");
txHash: hash,
walletAddress: wallet.address,
fromChainId: fromChain.id,
fromToken: "USDT",
fromAmount: usdtAmount,
toChainId: XIC_TOKEN.chainId,
toToken: "XIC",
toAmount: (parseFloat(quote.toAmount) / 1e18).toFixed(2),
});
setTxHash(hash); if (!quote.transactionRequest) {
setCompletedTxs((c) => c + 1); // Re-fetch quote to get fresh transactionRequest
} catch (err) { const amountWeiStr = amountWei.toString();
toast.error("Transaction failed. Please try again."); const freshRoutes = await getRoutes(
console.error(err); fromChain.id,
fromChain.usdtAddress,
XIC_TOKEN.chainId,
XIC_TOKEN.address,
amountWeiStr,
wallet.address
);
if (!freshRoutes[0]?.transactionRequest) {
throw new Error("No transaction data from Li.Fi");
}
const txReq = freshRoutes[0].transactionRequest;
const tx = await wallet.signer.sendTransaction({
to: txReq.to,
data: txReq.data,
value: BigInt(txReq.value || "0"),
gasLimit: txReq.gasLimit ? BigInt(txReq.gasLimit) : undefined,
});
const receipt = await tx.wait();
const hash = receipt?.hash ?? tx.hash;
setTxHash(hash);
setExecStep("done");
setCompletedTxs(c => c + 1);
toast.success(t.txSuccess);
// Record in DB
const xicReceived = (parseFloat(quote.toAmount) / 1e18).toFixed(6);
await recordOrder.mutateAsync({
txHash: hash,
walletAddress: wallet.address!,
fromChainId: fromChain.id,
fromToken: "USDT",
fromAmount: String(parseFloat(usdtAmount)),
toChainId: XIC_TOKEN.chainId,
toToken: "XIC",
toAmount: xicReceived,
});
} else {
const txReq = quote.transactionRequest;
const tx = await wallet.signer.sendTransaction({
to: txReq.to,
data: txReq.data,
value: BigInt(txReq.value || "0"),
gasLimit: txReq.gasLimit ? BigInt(txReq.gasLimit) : undefined,
});
const receipt = await tx.wait();
const hash = receipt?.hash ?? tx.hash;
setTxHash(hash);
setExecStep("done");
setCompletedTxs(c => c + 1);
toast.success(t.txSuccess);
// Record in DB
const xicReceived = (parseFloat(quote.toAmount) / 1e18).toFixed(6);
await recordOrder.mutateAsync({
txHash: hash,
walletAddress: wallet.address!,
fromChainId: fromChain.id,
fromToken: "USDT",
fromAmount: String(parseFloat(usdtAmount)),
toChainId: XIC_TOKEN.chainId,
toToken: "XIC",
toAmount: xicReceived,
});
}
// Refresh history
if (showHistory) myOrdersQuery.refetch();
} catch (err: unknown) {
const error = err as Error;
if (error.message?.includes("user rejected") || error.message?.includes("ACTION_REJECTED")) {
toast.error(lang === "zh" ? "已取消交易" : "Transaction cancelled");
} else {
toast.error(t.txFailed);
console.error(err);
}
setExecStep("idle");
} finally { } finally {
setExecuting(false); setExecuting(false);
} }
}, [quote, wallet, fromChain, usdtAmount, recordOrder]); }, [quote, wallet, fromChain, usdtAmount, t, lang, recordOrder, showHistory, myOrdersQuery]);
const xicAmount = quote const xicAmount = useMemo(() => quote
? (parseFloat(quote.toAmount) / 1e18).toLocaleString(undefined, { maximumFractionDigits: 2 }) ? (parseFloat(quote.toAmount) / 1e18).toLocaleString(undefined, { maximumFractionDigits: 2 })
: "—"; : "—", [quote]);
const xicMin = quote const xicMin = useMemo(() => quote
? (parseFloat(quote.toAmountMin) / 1e18).toLocaleString(undefined, { maximumFractionDigits: 2 }) ? (parseFloat(quote.toAmountMin) / 1e18).toLocaleString(undefined, { maximumFractionDigits: 2 })
: "—"; : "—", [quote]);
// ── Exec step label ──
const execLabel = useMemo(() => {
if (execStep === "approving") return t.approving;
if (execStep === "swapping") return t.swapping;
return `${t.buyXIC} ${xicAmount} XIC`;
}, [execStep, t, xicAmount]);
return ( return (
<div <div
@ -271,7 +468,7 @@ export default function Bridge() {
> >
<Link href="/" className="flex items-center gap-2 text-amber-400 hover:text-amber-300 transition-colors"> <Link href="/" className="flex items-center gap-2 text-amber-400 hover:text-amber-300 transition-colors">
<ArrowLeft size={18} /> <ArrowLeft size={18} />
<span className="text-sm font-medium">Back to Presale</span> <span className="text-sm font-medium">{t.backToPresale}</span>
</Link> </Link>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Zap size={18} className="text-amber-400" /> <Zap size={18} className="text-amber-400" />
@ -279,27 +476,35 @@ export default function Bridge() {
NAC Cross-Chain Bridge NAC Cross-Chain Bridge
</span> </span>
</div> </div>
<div className="text-xs text-white/40"> <div className="flex items-center gap-3">
Powered by <span className="text-amber-400/70">Li.Fi</span> {/* Language toggle */}
<button
onClick={() => setLang(lang === "zh" ? "en" : "zh")}
className="text-xs px-2 py-1 rounded-lg transition-all hover:opacity-80"
style={{ background: "rgba(240,180,41,0.1)", color: "#f0b429", border: "1px solid rgba(240,180,41,0.2)" }}
>
{t.lang}
</button>
<div className="text-xs text-white/40">
Powered by <span className="text-amber-400/70">Li.Fi</span>
</div>
</div> </div>
</header> </header>
{/* ── Main Content ── */} {/* ── Main Content ── */}
<main className="max-w-lg mx-auto px-4 py-8"> <main className="max-w-lg mx-auto px-4 py-8 space-y-4">
{/* Title */} {/* Title */}
<div className="text-center mb-8"> <div className="text-center mb-6">
<h1 <h1
className="text-2xl font-bold text-white mb-2" className="text-2xl font-bold text-white mb-2"
style={{ fontFamily: "'Space Grotesk', sans-serif" }} style={{ fontFamily: "'Space Grotesk', sans-serif" }}
> >
Buy XIC from Any Chain {t.title}
</h1> </h1>
<p className="text-sm text-white/50"> <p className="text-sm text-white/50">{t.subtitle}</p>
Use USDT on BSC, ETH, Polygon, Arbitrum or Avalanche to buy XIC tokens
</p>
{completedTxs > 0 && ( {completedTxs > 0 && (
<div className="mt-2 text-xs text-green-400"> <div className="mt-2 text-xs text-green-400">
{completedTxs} transaction{completedTxs > 1 ? "s" : ""} completed this session {completedTxs} {t.transactions}{completedTxs > 1 && lang === "en" ? "s" : ""} {t.completedSession}
</div> </div>
)} )}
</div> </div>
@ -316,7 +521,7 @@ export default function Bridge() {
{/* FROM: Chain Selector */} {/* FROM: Chain Selector */}
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs font-semibold text-white/50 uppercase tracking-wider"> <label className="text-xs font-semibold text-white/50 uppercase tracking-wider">
From Chain {t.fromChain}
</label> </label>
<div className="grid grid-cols-5 gap-2"> <div className="grid grid-cols-5 gap-2">
{CHAINS.map((chain) => ( {CHAINS.map((chain) => (
@ -344,7 +549,7 @@ export default function Bridge() {
{/* FROM: USDT Amount */} {/* FROM: USDT Amount */}
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs font-semibold text-white/50 uppercase tracking-wider"> <label className="text-xs font-semibold text-white/50 uppercase tracking-wider">
You Pay (USDT) {t.youPay}
</label> </label>
<div <div
className="flex items-center gap-3 rounded-xl px-4 py-3" className="flex items-center gap-3 rounded-xl px-4 py-3"
@ -405,7 +610,7 @@ export default function Bridge() {
{/* TO: XIC */} {/* TO: XIC */}
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs font-semibold text-white/50 uppercase tracking-wider"> <label className="text-xs font-semibold text-white/50 uppercase tracking-wider">
You Receive (XIC on BSC) {t.youReceive}
</label> </label>
<div <div
className="flex items-center gap-3 rounded-xl px-4 py-3" className="flex items-center gap-3 rounded-xl px-4 py-3"
@ -415,7 +620,7 @@ export default function Bridge() {
{quoting ? ( {quoting ? (
<div className="flex items-center gap-2 text-white/40"> <div className="flex items-center gap-2 text-white/40">
<Loader2 size={16} className="animate-spin" /> <Loader2 size={16} className="animate-spin" />
<span className="text-sm">Getting best route...</span> <span className="text-sm">{t.gettingRoute}</span>
</div> </div>
) : ( ) : (
<span <span
@ -430,7 +635,7 @@ export default function Bridge() {
)} )}
{quote && !quoting && ( {quote && !quoting && (
<div className="text-xs text-white/30 mt-0.5"> <div className="text-xs text-white/30 mt-0.5">
Min: {xicMin} XIC · via {quote.steps[0]?.toolDetails?.name ?? quote.steps[0]?.tool} {t.minReceive}: {xicMin} XIC · {t.via} {quote.steps[0]?.toolDetails?.name ?? quote.steps[0]?.tool}
</div> </div>
)} )}
</div> </div>
@ -453,54 +658,76 @@ export default function Bridge() {
style={{ background: "rgba(0,212,255,0.04)", border: "1px solid rgba(0,212,255,0.1)" }} style={{ background: "rgba(0,212,255,0.04)", border: "1px solid rgba(0,212,255,0.1)" }}
> >
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-white/40">Route</span> <span className="text-white/40">{t.route}</span>
<span className="text-white/70"> <span className="text-white/70">{fromChain.name} USDT BSC XIC</span>
{fromChain.name} USDT BSC XIC
</span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-white/40">Protocol</span> <span className="text-white/40">{t.protocol}</span>
<span className="text-white/70"> <span className="text-white/70">
{quote.steps.map((s) => s.toolDetails?.name ?? s.tool).join(" → ")} {quote.steps.map((s) => s.toolDetails?.name ?? s.tool).join(" → ")}
</span> </span>
</div> </div>
{quote.gasCostUSD && ( {quote.gasCostUSD && (
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-white/40">Est. Gas</span> <span className="text-white/40">{t.estGas}</span>
<span className="text-white/70">${quote.gasCostUSD}</span> <span className="text-white/70">${quote.gasCostUSD}</span>
</div> </div>
)} )}
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-white/40">Slippage</span> <span className="text-white/40">{t.slippage}</span>
<span className="text-white/70">3%</span> <span className="text-white/70">3%</span>
</div> </div>
</div> </div>
)} )}
{/* Action Button */} {/* ── Wallet Selector (inline, not a popup) ── */}
{!wallet.address ? ( {!wallet.address && (
<button <div className="space-y-3">
onClick={wallet.connect} {!showWalletSelector ? (
disabled={wallet.connecting} <button
className="w-full py-4 rounded-xl text-base font-bold transition-all" onClick={() => setShowWalletSelector(true)}
style={{ className="w-full py-4 rounded-xl text-base font-bold transition-all"
background: "linear-gradient(135deg, #f0b429, #e09820)", style={{
color: "#0a0a0f", background: "linear-gradient(135deg, #f0b429, #e09820)",
boxShadow: "0 4px 20px rgba(240,180,41,0.3)", color: "#0a0a0f",
}} boxShadow: "0 4px 20px rgba(240,180,41,0.3)",
> }}
{wallet.connecting ? ( >
<span className="flex items-center justify-center gap-2"> {t.connectWallet}
<Loader2 size={18} className="animate-spin" /> </button>
Connecting...
</span>
) : ( ) : (
"Connect Wallet to Continue" <div
className="rounded-xl p-4"
style={{ background: "rgba(0,0,0,0.3)", border: "1px solid rgba(240,180,41,0.2)" }}
>
<WalletSelector
lang={lang}
onAddressDetected={handleAddressDetected}
connectedAddress={wallet.address ?? undefined}
compact={false}
showTron={false}
/>
<button
onClick={() => setShowWalletSelector(false)}
className="w-full mt-3 py-2 rounded-lg text-xs text-white/30 hover:text-white/50 transition-colors"
>
{lang === "zh" ? "取消" : "Cancel"}
</button>
</div>
)} )}
</button> </div>
) : wallet.chainId !== fromChain.id ? ( )}
{/* ── Connected: Switch Network or Execute ── */}
{wallet.address && wallet.chainId !== fromChain.id && (
<button <button
onClick={() => wallet.switchChain(fromChain.id)} onClick={async () => {
try {
await switchToNetwork(fromChain.id);
} catch {
toast.error(lang === "zh" ? `请在钱包中切换到 ${fromChain.name}` : `Please switch to ${fromChain.name} in your wallet`);
}
}}
className="w-full py-4 rounded-xl text-base font-bold transition-all" className="w-full py-4 rounded-xl text-base font-bold transition-all"
style={{ style={{
background: "rgba(240,180,41,0.15)", background: "rgba(240,180,41,0.15)",
@ -508,9 +735,11 @@ export default function Bridge() {
color: "#f0b429", color: "#f0b429",
}} }}
> >
Switch to {fromChain.name} {t.switchNetwork} {fromChain.name}
</button> </button>
) : ( )}
{wallet.address && wallet.chainId === fromChain.id && (
<div className="space-y-2"> <div className="space-y-2">
<button <button
onClick={fetchQuote} onClick={fetchQuote}
@ -523,7 +752,7 @@ export default function Bridge() {
}} }}
> >
<RefreshCw size={14} className={quoting ? "animate-spin" : ""} /> <RefreshCw size={14} className={quoting ? "animate-spin" : ""} />
{quoting ? "Getting Quote..." : "Refresh Quote"} {quoting ? t.gettingQuote : t.refreshQuote}
</button> </button>
<button <button
onClick={executeSwap} onClick={executeSwap}
@ -541,12 +770,12 @@ export default function Bridge() {
{executing ? ( {executing ? (
<span className="flex items-center justify-center gap-2"> <span className="flex items-center justify-center gap-2">
<Loader2 size={18} className="animate-spin" /> <Loader2 size={18} className="animate-spin" />
Executing... {execLabel}
</span> </span>
) : !quote ? ( ) : !quote ? (
"Enter amount to get quote" t.enterAmount
) : ( ) : (
`Buy ${xicAmount} XIC` `${t.buyXIC} ${xicAmount} XIC`
)} )}
</button> </button>
</div> </div>
@ -555,41 +784,149 @@ export default function Bridge() {
{/* Connected wallet info */} {/* Connected wallet info */}
{wallet.address && ( {wallet.address && (
<div className="flex items-center justify-between text-xs"> <div className="flex items-center justify-between text-xs">
<span className="text-white/30">Connected:</span> <span className="text-white/30">{t.connected}:</span>
<span className="text-white/50 font-mono"> <div className="flex items-center gap-2">
{wallet.address.slice(0, 6)}...{wallet.address.slice(-4)} <span className="text-white/50 font-mono">
</span> {wallet.address.slice(0, 6)}...{wallet.address.slice(-4)}
</span>
<button
onClick={() => {
wallet.disconnect();
setShowWalletSelector(false);
setQuote(null);
}}
className="text-white/20 hover:text-red-400 transition-colors text-xs"
>
{lang === "zh" ? "断开" : "Disconnect"}
</button>
</div>
</div> </div>
)} )}
</div> </div>
{/* ── TX Success ── */} {/* ── TX Success ── */}
{txHash && ( {txHash && execStep === "done" && (
<div <div
className="mt-4 rounded-xl p-4 text-center" className="rounded-xl p-4 text-center"
style={{ background: "rgba(0,230,118,0.06)", border: "1px solid rgba(0,230,118,0.2)" }} style={{ background: "rgba(0,230,118,0.06)", border: "1px solid rgba(0,230,118,0.2)" }}
> >
<div className="text-green-400 font-semibold mb-1"> Transaction Submitted</div> <div className="text-green-400 font-semibold mb-1"> {t.txSubmitted}</div>
<div className="text-xs text-white/40"> <div className="text-xs text-white/40 mb-2">{t.txDesc}</div>
Your XIC tokens will arrive on BSC shortly
</div>
<a <a
href={`https://bscscan.com/address/${wallet.address}`} href={getTxUrl(fromChain.id, txHash)}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="mt-2 inline-flex items-center gap-1 text-xs text-blue-400 hover:text-blue-300" className="inline-flex items-center gap-1 text-xs text-blue-400 hover:text-blue-300"
> >
View on BSCScan <ExternalLink size={12} /> {t.viewBscscan} <ExternalLink size={12} />
</a> </a>
</div> </div>
)} )}
{/* ── Transaction History ── */}
<div
className="rounded-2xl overflow-hidden"
style={{
background: "rgba(255,255,255,0.02)",
border: "1px solid rgba(255,255,255,0.06)",
}}
>
<button
onClick={() => setShowHistory(!showHistory)}
className="w-full flex items-center justify-between px-5 py-4 text-left hover:bg-white/5 transition-colors"
>
<div className="flex items-center gap-2">
<History size={16} className="text-amber-400/70" />
<span className="text-sm font-semibold text-white/70">{t.history}</span>
{wallet.address && myOrdersQuery.data && myOrdersQuery.data.length > 0 && (
<span
className="text-xs px-2 py-0.5 rounded-full font-medium"
style={{ background: "rgba(240,180,41,0.15)", color: "#f0b429" }}
>
{myOrdersQuery.data.length}
</span>
)}
</div>
{showHistory ? (
<ChevronUp size={16} className="text-white/30" />
) : (
<ChevronDown size={16} className="text-white/30" />
)}
</button>
{showHistory && (
<div className="px-5 pb-4 space-y-3">
{!wallet.address ? (
<p className="text-xs text-white/30 text-center py-3">{t.historyDesc}</p>
) : myOrdersQuery.isLoading ? (
<div className="flex items-center justify-center py-4 gap-2">
<Loader2 size={14} className="animate-spin text-white/30" />
<span className="text-xs text-white/30">Loading...</span>
</div>
) : !myOrdersQuery.data || myOrdersQuery.data.length === 0 ? (
<p className="text-xs text-white/30 text-center py-3">{t.noHistory}</p>
) : (
<div className="space-y-2">
{myOrdersQuery.data.map((order) => (
<div
key={order.id}
className="rounded-xl p-3 space-y-1"
style={{ background: "rgba(255,255,255,0.03)", border: "1px solid rgba(255,255,255,0.06)" }}
>
<div className="flex items-center justify-between">
<span className="text-xs text-white/50">
{getChainName(order.fromChainId)} BSC
</span>
<span
className="text-xs px-2 py-0.5 rounded-full font-medium"
style={{
background: order.status === "completed"
? "rgba(0,230,118,0.12)"
: order.status === "pending"
? "rgba(240,180,41,0.12)"
: "rgba(255,80,80,0.12)",
color: order.status === "completed"
? "#00e676"
: order.status === "pending"
? "#f0b429"
: "#ff5050",
}}
>
{order.status === "completed" ? t.status_completed
: order.status === "pending" ? t.status_pending
: t.status_failed}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-white">
{order.fromAmount} USDT {Number(order.toAmount).toLocaleString(undefined, { maximumFractionDigits: 2 })} XIC
</span>
<a
href={getTxUrl(order.fromChainId, order.txHash)}
target="_blank"
rel="noopener noreferrer"
className="text-blue-400/60 hover:text-blue-400 transition-colors"
>
<ExternalLink size={12} />
</a>
</div>
<div className="text-xs text-white/20">
{new Date(order.createdAt).toLocaleString()}
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
{/* ── Info Cards ── */} {/* ── Info Cards ── */}
<div className="mt-6 grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
{[ {[
{ label: "Supported Chains", value: "5+", sub: "BSC, ETH, Polygon..." }, { label: t.supportedChains, value: "5+", sub: "BSC, ETH, Polygon..." },
{ label: "Token Price", value: "$0.02", sub: "Presale price" }, { label: t.tokenPrice, value: "$0.02", sub: t.presalePrice },
{ label: "Listing Target", value: "$0.10", sub: "5x potential" }, { label: t.listingTarget, value: "$0.10", sub: t.xPotential },
].map((item) => ( ].map((item) => (
<div <div
key={item.label} key={item.label}
@ -607,11 +944,10 @@ export default function Bridge() {
{/* ── Disclaimer ── */} {/* ── Disclaimer ── */}
<div <div
className="mt-4 rounded-xl p-3 text-xs text-white/30 text-center" className="rounded-xl p-3 text-xs text-white/30 text-center"
style={{ background: "rgba(255,255,255,0.02)", border: "1px solid rgba(255,255,255,0.05)" }} style={{ background: "rgba(255,255,255,0.02)", border: "1px solid rgba(255,255,255,0.05)" }}
> >
Cross-chain swaps are powered by Li.Fi protocol. Always verify transaction details before confirming. {t.disclaimer}
Minimum slippage 3% applies. Not financial advice.
</div> </div>
</main> </main>
</div> </div>

View File

@ -51,6 +51,36 @@ const bridgeRouter = router({
} }
}), }),
// List orders by wallet address
myOrders: publicProcedure
.input(z.object({
walletAddress: z.string().min(1).max(64),
limit: z.number().min(1).max(50).default(20),
}))
.query(async ({ input }) => {
const db = await getDb();
if (!db) return [];
const rows = await db
.select()
.from(bridgeOrders)
.where(eq(bridgeOrders.walletAddress, input.walletAddress.toLowerCase()))
.orderBy(desc(bridgeOrders.createdAt))
.limit(input.limit);
return rows.map(r => ({
id: r.id,
txHash: r.txHash,
walletAddress: r.walletAddress,
fromChainId: r.fromChainId,
fromToken: r.fromToken,
fromAmount: Number(r.fromAmount),
toChainId: r.toChainId,
toToken: r.toToken,
toAmount: Number(r.toAmount),
status: r.status,
createdAt: r.createdAt,
}));
}),
// List recent bridge orders (public) // List recent bridge orders (public)
recentOrders: publicProcedure recentOrders: publicProcedure
.input(z.object({ limit: z.number().min(1).max(50).default(10) })) .input(z.object({ limit: z.number().min(1).max(50).default(10) }))

34
todo.md
View File

@ -89,3 +89,37 @@
- [x] 浏览器测试 /bridge 页面UI渲染、链切换、金额输入正常 - [x] 浏览器测试 /bridge 页面UI渲染、链切换、金额输入正常
- [ ] 去除 MANUS 内联,构建并部署到 AI 服务器 - [ ] 去除 MANUS 内联,构建并部署到 AI 服务器
- [ ] 记录部署日志并交付 - [ ] 记录部署日志并交付
## v10 Bridge完善 + 钱包选择器修复
### Bridge Li.Fi 交易执行逻辑
- [x] 实现完整的 Li.Fi 跨链交易执行USDT Approve + executeLiFiRoute
- [x] 连接钱包后自动获取报价(已有,验证可用)
- [x] 执行交易:先 Approve USDT再发送跨链交易
- [x] 交易状态轮询pending → success/failed
- [x] 成功后记录订单到数据库
### Bridge 交易历史记录模块
- [x] Bridge 页面底部增加"我的交易记录"区域
- [x] 按钱包地址查询历史订单trpc.bridge.myOrders
- [x] 显示时间、来源链、USDT金额、XIC金额、状态、TxHash链接
### v8 钱包选择器 UI 修复
- [ ] 修复图1有已安装钱包时隐藏未安装列表已在代码中但需验证
- [ ] 修复图2连接中状态保持面板展开显示loading不折叠
- [ ] 修复图3未连接钱包时隐藏"添加XIC到钱包"按钮(已有条件判断,需验证)
### 部署
- [ ] 构建并部署到 AI 服务器
- [ ] 浏览器测试验证所有功能
- [ ] 记录部署日志并交付
### Bridge 钱包连接修复(来自截图反馈)
- [ ] Bridge 页面"连接钱包"按钮改为使用 WalletSelector 组件(与主页一致),而非直接调用 window.ethereum
- [x] 连接钱包后自动获取报价,不再显示 WalletSelector
### 视频反馈修复(来自 WhatsApp 视频)
- [ ] Bridge 页面"连接钱包"按钮改为内嵌 WalletSelector 组件(展开显示钱包列表,不弹浏览器原生弹窗)
- [ ] 错误提示"Wallet connection cancelled"改为中英文双语
- [ ] Bridge 页面添加中英文语言切换支持(与主页同步)
- [ ] 信息卡片"5岁以上"应为"5条以上"(支持链数量)