Checkpoint: v10: Bridge页面集成WalletSelector(替代window.ethereum直接调用)、完整Li.Fi交易执行逻辑(USDT Approve+跨链交易)、交易历史记录模块、中英文双语支持;修复WalletSelector连接中状态覆盖层(禁用其他按钮);修复信息卡片"5条以上"文案
This commit is contained in:
parent
889068d7f5
commit
2eff084785
|
|
@ -665,14 +665,34 @@ export function WalletSelector({ lang, onAddressDetected, connectedAddress, comp
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{/* Installed wallets */}
|
||||
{installedWallets.length > 0 && (
|
||||
{/* Connecting overlay — shown when any wallet is connecting */}
|
||||
{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">
|
||||
{installedWallets.map(wallet => (
|
||||
<button
|
||||
key={wallet.id}
|
||||
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]"
|
||||
style={{
|
||||
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 */}
|
||||
{installedWallets.length === 0 && (
|
||||
{!connecting && installedWallets.length === 0 && (
|
||||
<div
|
||||
className="rounded-xl p-4 text-center"
|
||||
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>
|
||||
)}
|
||||
|
||||
{/* Not-installed wallets — only show when NO wallet is installed (UI fix: don't mix installed+uninstalled) */}
|
||||
{!compact && notInstalledWallets.length > 0 && installedWallets.length === 0 && (
|
||||
{/* Not-installed wallets — only show when NO wallet is installed and not connecting */}
|
||||
{!connecting && !compact && notInstalledWallets.length > 0 && installedWallets.length === 0 && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-white/30 mt-2">
|
||||
{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 */}
|
||||
{compact && notInstalledWallets.length > 0 && installedWallets.length === 0 && (
|
||||
{!connecting && compact && notInstalledWallets.length > 0 && installedWallets.length === 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{notInstalledWallets.slice(0, 4).map(wallet => (
|
||||
<a
|
||||
|
|
@ -787,11 +807,12 @@ export function WalletSelector({ lang, onAddressDetected, connectedAddress, comp
|
|||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
{error && !connecting && (
|
||||
<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">
|
||||
<button
|
||||
onClick={() => { setShowManual(!showManual); setManualError(null); }}
|
||||
|
|
@ -840,6 +861,7 @@ export function WalletSelector({ lang, onAddressDetected, connectedAddress, comp
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,136 @@
|
|||
// 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
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { toast } from "sonner";
|
||||
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 { 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 ─────────────────────────────────────────────────────────────
|
||||
const CHAINS = [
|
||||
{ id: 56, name: "BSC", symbol: "BNB", color: "#F0B90B", icon: "🟡", usdtAddress: "0x55d398326f99059fF775485246999027B3197955" },
|
||||
{ id: 1, name: "Ethereum", symbol: "ETH", color: "#627EEA", icon: "🔵", usdtAddress: "0xdAC17F958D2ee523a2206206994597C13D831ec7" },
|
||||
{ id: 137, name: "Polygon", symbol: "MATIC",color: "#8247E5", icon: "🟣", usdtAddress: "0xc2132D05D31c914a87C6611C10748AEb04B58e8F" },
|
||||
{ id: 42161, name: "Arbitrum", symbol: "ETH", color: "#28A0F0", icon: "🔷", usdtAddress: "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9" },
|
||||
{ id: 43114, name: "Avalanche", symbol: "AVAX", color: "#E84142", icon: "🔴", usdtAddress: "0x9702230A8Ea53601f5cD2dc00fDBc13d4dF4A8c7" },
|
||||
{ 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", 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", 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", 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", 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)
|
||||
|
|
@ -30,6 +146,15 @@ const XIC_TOKEN = {
|
|||
// Quick amounts
|
||||
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 ────────────────────────────────────────────────────────────────────
|
||||
interface RouteQuote {
|
||||
id: string;
|
||||
|
|
@ -37,6 +162,13 @@ interface RouteQuote {
|
|||
toAmount: string;
|
||||
toAmountMin: string;
|
||||
estimatedGas: string;
|
||||
transactionRequest?: {
|
||||
to: string;
|
||||
data: string;
|
||||
value: string;
|
||||
gasLimit?: string;
|
||||
gasPrice?: string;
|
||||
};
|
||||
steps: Array<{
|
||||
type: string;
|
||||
tool: string;
|
||||
|
|
@ -65,103 +197,82 @@ async function getRoutes(
|
|||
fromAddress,
|
||||
options: JSON.stringify({ slippage: 0.03, order: "RECOMMENDED" }),
|
||||
});
|
||||
|
||||
const res = await fetch(`${LIFI_API}/quote?${params}`);
|
||||
if (!res.ok) throw new Error(`Li.Fi API error: ${res.status}`);
|
||||
const data = await res.json();
|
||||
return data ? [data] : [];
|
||||
}
|
||||
|
||||
// ─── Wallet Hook (window.ethereum) ───────────────────────────────────────────
|
||||
function useEVMWallet() {
|
||||
const [address, setAddress] = useState<string | null>(null);
|
||||
const [chainId, setChainId] = useState<number | null>(null);
|
||||
const [connecting, setConnecting] = useState(false);
|
||||
// ERC-20 minimal ABI for allowance + approve
|
||||
const ERC20_ABI_APPROVE = [
|
||||
"function allowance(address owner, address spender) view returns (uint256)",
|
||||
"function approve(address spender, uint256 amount) returns (bool)",
|
||||
];
|
||||
|
||||
const connect = useCallback(async () => {
|
||||
if (!window.ethereum) {
|
||||
toast.error("Please install MetaMask or another EVM wallet");
|
||||
return;
|
||||
}
|
||||
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);
|
||||
}
|
||||
}, []);
|
||||
// ─── Chain name helper ────────────────────────────────────────────────────────
|
||||
function getChainName(chainId: number): string {
|
||||
const chain = CHAINS.find(c => c.id === chainId);
|
||||
return chain?.name ?? `Chain ${chainId}`;
|
||||
}
|
||||
|
||||
const switchChain = useCallback(async (targetChainId: number) => {
|
||||
if (!window.ethereum) return;
|
||||
try {
|
||||
await window.ethereum.request({
|
||||
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 };
|
||||
// ─── Explorer URL helper ──────────────────────────────────────────────────────
|
||||
function getTxUrl(chainId: number, txHash: string): string {
|
||||
const chain = CHAINS.find(c => c.id === chainId);
|
||||
return `${chain?.explorerUrl ?? "https://bscscan.com"}/tx/${txHash}`;
|
||||
}
|
||||
|
||||
// ─── Main Bridge Page ─────────────────────────────────────────────────────────
|
||||
export default function Bridge() {
|
||||
const wallet = useEVMWallet();
|
||||
const [fromChain, setFromChain] = useState(CHAINS[0]); // BSC default
|
||||
// ── Language ──
|
||||
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 [quote, setQuote] = useState<RouteQuote | null>(null);
|
||||
const [quoting, setQuoting] = useState(false);
|
||||
const [executing, setExecuting] = useState(false);
|
||||
const [execStep, setExecStep] = useState<"idle" | "approving" | "swapping" | "done">("idle");
|
||||
const [txHash, setTxHash] = useState<string | null>(null);
|
||||
const [completedTxs, setCompletedTxs] = useState(0);
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
|
||||
// ── tRPC mutations/queries ──
|
||||
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 ─────────────────────────────────────────────────────────
|
||||
const fetchQuote = useCallback(async () => {
|
||||
const amount = parseFloat(usdtAmount);
|
||||
if (!amount || amount <= 0) return;
|
||||
if (!wallet.address) return;
|
||||
|
||||
setQuoting(true);
|
||||
setQuote(null);
|
||||
try {
|
||||
|
|
@ -177,80 +288,166 @@ export default function Bridge() {
|
|||
if (routes.length > 0) {
|
||||
setQuote(routes[0]);
|
||||
} else {
|
||||
toast.error("No route found for this pair");
|
||||
toast.error(t.noRoute);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error("Failed to get quote. Please try again.");
|
||||
toast.error(t.quoteFailed);
|
||||
console.error(err);
|
||||
} finally {
|
||||
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(() => {
|
||||
if (!wallet.address || !usdtAmount || parseFloat(usdtAmount) <= 0) return;
|
||||
const timer = setTimeout(fetchQuote, 800);
|
||||
return () => clearTimeout(timer);
|
||||
}, [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 () => {
|
||||
if (!quote || !wallet.address) return;
|
||||
if (!window.ethereum) {
|
||||
toast.error("No wallet detected");
|
||||
if (!quote || !wallet.address || !wallet.signer) {
|
||||
toast.error(lang === "zh" ? "请先连接钱包" : "Please connect wallet first");
|
||||
return;
|
||||
}
|
||||
|
||||
// Switch to correct chain if needed
|
||||
if (wallet.chainId !== fromChain.id) {
|
||||
await wallet.switchChain(fromChain.id);
|
||||
toast.info(`Please confirm network switch to ${fromChain.name}`);
|
||||
return;
|
||||
try {
|
||||
await switchToNetwork(fromChain.id);
|
||||
} catch {
|
||||
toast.error(lang === "zh" ? `请切换到 ${fromChain.name} 网络` : `Please switch to ${fromChain.name} network`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setExecuting(true);
|
||||
setExecStep("idle");
|
||||
|
||||
try {
|
||||
// Get the full route with transaction data from Li.Fi
|
||||
const res = await fetch(`${LIFI_API}/quote`, {
|
||||
method: "GET",
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to get transaction data");
|
||||
const { ethers } = await import("ethers");
|
||||
const usdtContract = new ethers.Contract(fromChain.usdtAddress, ERC20_ABI_APPROVE, wallet.signer);
|
||||
const lifiDiamond = LIFI_DIAMOND[fromChain.id];
|
||||
const amountWei = BigInt(Math.floor(parseFloat(usdtAmount) * 1000000));
|
||||
|
||||
// For now, show the user the transaction details and let them confirm
|
||||
// In production, this would call the Li.Fi SDK's executeRoute
|
||||
toast.success("Route confirmed! Redirecting to Li.Fi for execution...");
|
||||
// Step 1: Check and approve USDT allowance
|
||||
setExecStep("approving");
|
||||
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
|
||||
const hash = `pending-${Date.now()}`;
|
||||
await recordOrder.mutateAsync({
|
||||
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),
|
||||
});
|
||||
// Step 2: Execute the Li.Fi transaction
|
||||
setExecStep("swapping");
|
||||
toast.info(lang === "zh" ? "请在钱包中确认跨链交易..." : "Please confirm the cross-chain transaction in your wallet...");
|
||||
|
||||
setTxHash(hash);
|
||||
setCompletedTxs((c) => c + 1);
|
||||
} catch (err) {
|
||||
toast.error("Transaction failed. Please try again.");
|
||||
console.error(err);
|
||||
if (!quote.transactionRequest) {
|
||||
// Re-fetch quote to get fresh transactionRequest
|
||||
const amountWeiStr = amountWei.toString();
|
||||
const freshRoutes = await getRoutes(
|
||||
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 {
|
||||
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 })
|
||||
: "—";
|
||||
: "—", [quote]);
|
||||
|
||||
const xicMin = quote
|
||||
const xicMin = useMemo(() => quote
|
||||
? (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 (
|
||||
<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">
|
||||
<ArrowLeft size={18} />
|
||||
<span className="text-sm font-medium">Back to Presale</span>
|
||||
<span className="text-sm font-medium">{t.backToPresale}</span>
|
||||
</Link>
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap size={18} className="text-amber-400" />
|
||||
|
|
@ -279,27 +476,35 @@ export default function Bridge() {
|
|||
NAC Cross-Chain Bridge
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-white/40">
|
||||
Powered by <span className="text-amber-400/70">Li.Fi</span>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* 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>
|
||||
</header>
|
||||
|
||||
{/* ── 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 */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="text-center mb-6">
|
||||
<h1
|
||||
className="text-2xl font-bold text-white mb-2"
|
||||
style={{ fontFamily: "'Space Grotesk', sans-serif" }}
|
||||
>
|
||||
Buy XIC from Any Chain
|
||||
{t.title}
|
||||
</h1>
|
||||
<p className="text-sm text-white/50">
|
||||
Use USDT on BSC, ETH, Polygon, Arbitrum or Avalanche to buy XIC tokens
|
||||
</p>
|
||||
<p className="text-sm text-white/50">{t.subtitle}</p>
|
||||
{completedTxs > 0 && (
|
||||
<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>
|
||||
|
|
@ -316,7 +521,7 @@ export default function Bridge() {
|
|||
{/* FROM: Chain Selector */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-semibold text-white/50 uppercase tracking-wider">
|
||||
From Chain
|
||||
{t.fromChain}
|
||||
</label>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{CHAINS.map((chain) => (
|
||||
|
|
@ -344,7 +549,7 @@ export default function Bridge() {
|
|||
{/* FROM: USDT Amount */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-semibold text-white/50 uppercase tracking-wider">
|
||||
You Pay (USDT)
|
||||
{t.youPay}
|
||||
</label>
|
||||
<div
|
||||
className="flex items-center gap-3 rounded-xl px-4 py-3"
|
||||
|
|
@ -405,7 +610,7 @@ export default function Bridge() {
|
|||
{/* TO: XIC */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-semibold text-white/50 uppercase tracking-wider">
|
||||
You Receive (XIC on BSC)
|
||||
{t.youReceive}
|
||||
</label>
|
||||
<div
|
||||
className="flex items-center gap-3 rounded-xl px-4 py-3"
|
||||
|
|
@ -415,7 +620,7 @@ export default function Bridge() {
|
|||
{quoting ? (
|
||||
<div className="flex items-center gap-2 text-white/40">
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
<span className="text-sm">Getting best route...</span>
|
||||
<span className="text-sm">{t.gettingRoute}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span
|
||||
|
|
@ -430,7 +635,7 @@ export default function Bridge() {
|
|||
)}
|
||||
{quote && !quoting && (
|
||||
<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>
|
||||
|
|
@ -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)" }}
|
||||
>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-white/40">Route</span>
|
||||
<span className="text-white/70">
|
||||
{fromChain.name} USDT → BSC XIC
|
||||
</span>
|
||||
<span className="text-white/40">{t.route}</span>
|
||||
<span className="text-white/70">{fromChain.name} USDT → BSC XIC</span>
|
||||
</div>
|
||||
<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">
|
||||
{quote.steps.map((s) => s.toolDetails?.name ?? s.tool).join(" → ")}
|
||||
</span>
|
||||
</div>
|
||||
{quote.gasCostUSD && (
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Button */}
|
||||
{!wallet.address ? (
|
||||
<button
|
||||
onClick={wallet.connect}
|
||||
disabled={wallet.connecting}
|
||||
className="w-full py-4 rounded-xl text-base font-bold transition-all"
|
||||
style={{
|
||||
background: "linear-gradient(135deg, #f0b429, #e09820)",
|
||||
color: "#0a0a0f",
|
||||
boxShadow: "0 4px 20px rgba(240,180,41,0.3)",
|
||||
}}
|
||||
>
|
||||
{wallet.connecting ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
Connecting...
|
||||
</span>
|
||||
{/* ── Wallet Selector (inline, not a popup) ── */}
|
||||
{!wallet.address && (
|
||||
<div className="space-y-3">
|
||||
{!showWalletSelector ? (
|
||||
<button
|
||||
onClick={() => setShowWalletSelector(true)}
|
||||
className="w-full py-4 rounded-xl text-base font-bold transition-all"
|
||||
style={{
|
||||
background: "linear-gradient(135deg, #f0b429, #e09820)",
|
||||
color: "#0a0a0f",
|
||||
boxShadow: "0 4px 20px rgba(240,180,41,0.3)",
|
||||
}}
|
||||
>
|
||||
{t.connectWallet}
|
||||
</button>
|
||||
) : (
|
||||
"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>
|
||||
) : wallet.chainId !== fromChain.id ? (
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Connected: Switch Network or Execute ── */}
|
||||
{wallet.address && wallet.chainId !== fromChain.id && (
|
||||
<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"
|
||||
style={{
|
||||
background: "rgba(240,180,41,0.15)",
|
||||
|
|
@ -508,9 +735,11 @@ export default function Bridge() {
|
|||
color: "#f0b429",
|
||||
}}
|
||||
>
|
||||
Switch to {fromChain.name}
|
||||
{t.switchNetwork} {fromChain.name}
|
||||
</button>
|
||||
) : (
|
||||
)}
|
||||
|
||||
{wallet.address && wallet.chainId === fromChain.id && (
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
onClick={fetchQuote}
|
||||
|
|
@ -523,7 +752,7 @@ export default function Bridge() {
|
|||
}}
|
||||
>
|
||||
<RefreshCw size={14} className={quoting ? "animate-spin" : ""} />
|
||||
{quoting ? "Getting Quote..." : "Refresh Quote"}
|
||||
{quoting ? t.gettingQuote : t.refreshQuote}
|
||||
</button>
|
||||
<button
|
||||
onClick={executeSwap}
|
||||
|
|
@ -541,12 +770,12 @@ export default function Bridge() {
|
|||
{executing ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
Executing...
|
||||
{execLabel}
|
||||
</span>
|
||||
) : !quote ? (
|
||||
"Enter amount to get quote"
|
||||
t.enterAmount
|
||||
) : (
|
||||
`Buy ${xicAmount} XIC`
|
||||
`${t.buyXIC} ${xicAmount} XIC`
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -555,41 +784,149 @@ export default function Bridge() {
|
|||
{/* Connected wallet info */}
|
||||
{wallet.address && (
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-white/30">Connected:</span>
|
||||
<span className="text-white/50 font-mono">
|
||||
{wallet.address.slice(0, 6)}...{wallet.address.slice(-4)}
|
||||
</span>
|
||||
<span className="text-white/30">{t.connected}:</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-white/50 font-mono">
|
||||
{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>
|
||||
|
||||
{/* ── TX Success ── */}
|
||||
{txHash && (
|
||||
{txHash && execStep === "done" && (
|
||||
<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)" }}
|
||||
>
|
||||
<div className="text-green-400 font-semibold mb-1">✓ Transaction Submitted</div>
|
||||
<div className="text-xs text-white/40">
|
||||
Your XIC tokens will arrive on BSC shortly
|
||||
</div>
|
||||
<div className="text-green-400 font-semibold mb-1">✓ {t.txSubmitted}</div>
|
||||
<div className="text-xs text-white/40 mb-2">{t.txDesc}</div>
|
||||
<a
|
||||
href={`https://bscscan.com/address/${wallet.address}`}
|
||||
href={getTxUrl(fromChain.id, txHash)}
|
||||
target="_blank"
|
||||
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>
|
||||
</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 ── */}
|
||||
<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: "Token Price", value: "$0.02", sub: "Presale price" },
|
||||
{ label: "Listing Target", value: "$0.10", sub: "5x potential" },
|
||||
{ label: t.supportedChains, value: "5+", sub: "BSC, ETH, Polygon..." },
|
||||
{ label: t.tokenPrice, value: "$0.02", sub: t.presalePrice },
|
||||
{ label: t.listingTarget, value: "$0.10", sub: t.xPotential },
|
||||
].map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
|
|
@ -607,11 +944,10 @@ export default function Bridge() {
|
|||
|
||||
{/* ── Disclaimer ── */}
|
||||
<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)" }}
|
||||
>
|
||||
Cross-chain swaps are powered by Li.Fi protocol. Always verify transaction details before confirming.
|
||||
Minimum slippage 3% applies. Not financial advice.
|
||||
{t.disclaimer}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
recentOrders: publicProcedure
|
||||
.input(z.object({ limit: z.number().min(1).max(50).default(10) }))
|
||||
|
|
|
|||
34
todo.md
34
todo.md
|
|
@ -89,3 +89,37 @@
|
|||
- [x] 浏览器测试 /bridge 页面(UI渲染、链切换、金额输入正常)
|
||||
- [ ] 去除 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条以上"(支持链数量)
|
||||
|
|
|
|||
Loading…
Reference in New Issue