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>
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
34
todo.md
|
|
@ -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条以上"(支持链数量)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue