Compare commits

...

7 Commits

Author SHA1 Message Date
Manus 1576303898 v12: WalletSelector重写 - 增加错误诊断、MetaMask权限重置引导、连接状态优化
主要改进:
- ErrorHelpPanel: 分类错误处理(user_rejected/wallet_pending/not_initialized/unknown)
- MetaMask 4001错误时显示5步权限重置操作指南
- 连接中状态显示'等待钱包授权...'提示
- 错误后保留重试按钮和其他可用钱包选项
- 增加eth_accounts静默检查(先检查是否已连接)
- Bridge: 确认所有链USDT->XIC路由(BSC/ETH/Polygon/Arbitrum/Avalanche)
2026-03-10 05:14:24 -04:00
Manus 4bdb118cb2 Checkpoint: v11: Bridge页面增强 - Gas费估算显示(含原生代币说明)、交易历史复制哈希+区块浏览器快捷按钮、交易成功弹窗(含到账时间、复制哈希、查看详情) 2026-03-10 04:53:41 -04:00
Manus 2eff084785 Checkpoint: v10: Bridge页面集成WalletSelector(替代window.ethereum直接调用)、完整Li.Fi交易执行逻辑(USDT Approve+跨链交易)、交易历史记录模块、中英文双语支持;修复WalletSelector连接中状态覆盖层(禁用其他按钮);修复信息卡片"5条以上"文案 2026-03-10 04:09:55 -04:00
Manus 889068d7f5 Checkpoint: Checkpoint saved: v9 跨链桥 /bridge 页面完成:
1. 创建 Bridge.tsx 自定义跨链桥UI(深色科技风格,与预售网站一致)
2. 使用 Li.Fi API 获取跨链报价(支持BSC/ETH/Polygon/Arbitrum/Avalanche → BSC XIC)
3. 支持5条链的USDT输入,快速金额按钮($100/$500/$1000/$5000)
4. 导航栏添加  Bridge 高亮入口链接(中英文双语)
5. 后端 bridge_orders 表记录跨链订单
6. 浏览器测试通过:UI渲染正常、链切换正常、金额输入正常
2026-03-10 02:52:42 -04:00
Manus 1d0e293bdb Checkpoint: 修复钱包连接Modal问题:1) 将showWalletModal状态提升到Home主组件;2) Modal通过Portal渲染到document.body,脱离导航栏backdropFilter层叠上下文;3) EVMPurchasePanel内嵌WalletSelector替换为统一的Connect Wallet按钮,触发同一个顶层Modal;4) 停止旧预售合约0xc65e7a27... 2026-03-10 02:12:35 -04:00
Manus a7aa132b71 Checkpoint: 修复手机端钱包连接弹出框问题:
1. Modal 改为手机端底部弹出(items-end),加 maxHeight 85vh + overflow-y-auto,防止内容超出屏幕
2. MobileDeepLinkPanel 精简:MetaMask 单独突出显示在最上方,其他钱包(Trust/OKX/TokenPocket)默认折叠
3. 手机端在钱包内置浏览器中(window.ethereum 已注入)时,直接调起连接,不弹选择列表
4. 手机端隐藏桌面专用的 MetaMask 扩展初始化提示
另外:停止了旧预售合约 0xc65e7a2738ed884db8d26a6eb2fecf7daca2e90c(调用 endPresale(),TX: 0x286e35...)
2026-03-10 01:48:13 -04:00
Manus dd24e6ba13 Checkpoint: 修复预售网站三个关键问题:1) 购买按钮永远禁用(maxPurchaseUSDT=0导致),2) 新增Add XIC to Wallet按钮,3) 完整重写useWallet.ts支持TokenPocket/OKX/Bitget等中国钱包 2026-03-09 23:14:34 -04:00
17 changed files with 7402 additions and 667 deletions

View File

@ -7,6 +7,7 @@ import { ThemeProvider } from "./contexts/ThemeContext";
import Home from "./pages/Home";
import Tutorial from "./pages/Tutorial";
import Admin from "./pages/Admin";
import Bridge from "./pages/Bridge";
function Router() {
// make sure to consider if you need authentication for certain routes
@ -15,6 +16,7 @@ function Router() {
<Route path={"/"} component={Home} />
<Route path={"/tutorial"} component={Tutorial} />
<Route path={"/admin"} component={Admin} />
<Route path={"/bridge"} component={Bridge} />
<Route path={"/404"} component={NotFound} />
{/* Final fallback route */}
<Route component={NotFound} />

View File

@ -1,8 +1,9 @@
// NAC XIC Presale — Wallet Selector Component
// Detects installed EVM wallets and shows connect/install buttons for each
// v4: added TronLink support (desktop window.tronLink + mobile DeepLink)
// v5: improved error handling, MetaMask permission reset guide, connection diagnostics
import { useState, useEffect, useCallback } from "react";
import type { EthProvider } from "@/hooks/useWallet";
type Lang = "zh" | "en";
@ -11,18 +12,18 @@ interface WalletInfo {
name: string;
icon: React.ReactNode;
installUrl: string;
mobileDeepLink?: string; // DeepLink to open current page in wallet's in-app browser
mobileDeepLink?: string;
isInstalled: () => boolean;
connect: () => Promise<string | null>;
network: "evm" | "tron"; // wallet network type
connect: () => Promise<{ address: string; rawProvider: EthProvider } | null>;
network: "evm" | "tron";
}
interface WalletSelectorProps {
lang: Lang;
onAddressDetected: (address: string, network?: "evm" | "tron") => void;
onAddressDetected: (address: string, network?: "evm" | "tron", rawProvider?: EthProvider) => void;
connectedAddress?: string;
compact?: boolean; // compact mode for BSC/ETH panel
showTron?: boolean; // whether to show TRON wallets (for TRC20 panel)
compact?: boolean;
showTron?: boolean;
}
// ── Wallet Icons ──────────────────────────────────────────────────────────────
@ -83,7 +84,6 @@ const BitgetIcon = () => (
</svg>
);
// TronLink Icon — official TRON red color
const TronLinkIcon = () => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="12" fill="#FF0013"/>
@ -100,7 +100,6 @@ function isMobileBrowser(): boolean {
return /Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
}
// Check if running inside a wallet's in-app browser
function isInWalletBrowser(): boolean {
if (typeof window === "undefined") return false;
const ua = navigator.userAgent.toLowerCase();
@ -125,20 +124,6 @@ function isInWalletBrowser(): boolean {
// ── Provider detection helpers ────────────────────────────────────────────────
type EthProvider = {
isMetaMask?: boolean;
isTrust?: boolean;
isTrustWallet?: boolean;
isOKExWallet?: boolean;
isOkxWallet?: boolean;
isCoinbaseWallet?: boolean;
isTokenPocket?: boolean;
isBitkeep?: boolean;
isBitgetWallet?: boolean;
providers?: EthProvider[];
request: (args: { method: string; params?: unknown[] }) => Promise<unknown>;
};
type TronLinkProvider = {
ready: boolean;
tronWeb?: {
@ -167,9 +152,7 @@ function getBitget(): EthProvider | null {
function getTronLink(): TronLinkProvider | null {
if (typeof window === "undefined") return null;
const w = window as unknown as { tronLink?: TronLinkProvider; tronWeb?: TronLinkProvider["tronWeb"] };
// TronLink injects window.tronLink; tronWeb is also available when connected
if (w.tronLink?.ready) return w.tronLink;
// Some versions only inject tronWeb
if (w.tronWeb) {
return {
ready: true,
@ -180,7 +163,6 @@ function getTronLink(): TronLinkProvider | null {
return null;
}
// Find a specific provider from the providers array or direct injection
function findProvider(predicate: (p: EthProvider) => boolean): EthProvider | null {
const eth = getEth();
if (!eth) return null;
@ -190,30 +172,49 @@ function findProvider(predicate: (p: EthProvider) => boolean): EthProvider | nul
return predicate(eth) ? eth : null;
}
async function requestAccounts(provider: EthProvider): Promise<string | null> {
// ── Improved requestAccounts with better error classification ─────────────────
async function requestAccounts(provider: EthProvider): Promise<{ address: string; rawProvider: EthProvider } | null> {
try {
// First try eth_accounts (silent) to check if already connected
const existingAccounts = await provider.request({ method: "eth_accounts" }) as string[];
if (existingAccounts && existingAccounts.length > 0) {
return { address: existingAccounts[0], rawProvider: provider };
}
} catch {
// Ignore - proceed to eth_requestAccounts
}
try {
const accounts = await provider.request({ method: "eth_requestAccounts" }) as string[];
return accounts?.[0] ?? null;
const address = accounts?.[0] ?? null;
if (!address) return null;
return { address, rawProvider: provider };
} catch (err: unknown) {
const error = err as { code?: number; message?: string };
console.log("[WalletSelector] requestAccounts error:", error.code, error.message);
if (error?.code === 4001) throw new Error("user_rejected");
if (error?.code === -32002) throw new Error("wallet_pending");
// Some wallets throw with message instead of code
if (error?.message?.toLowerCase().includes("user rejected") ||
error?.message?.toLowerCase().includes("user denied") ||
error?.message?.toLowerCase().includes("cancelled")) {
throw new Error("user_rejected");
}
throw err;
}
}
async function requestTronAccounts(provider: TronLinkProvider): Promise<string | null> {
async function requestTronAccounts(provider: TronLinkProvider): Promise<{ address: string; rawProvider: EthProvider } | null> {
try {
// TronLink v1: use tronWeb.defaultAddress
if (provider.tronWeb?.defaultAddress?.base58) {
return provider.tronWeb.defaultAddress.base58;
return { address: provider.tronWeb.defaultAddress.base58, rawProvider: provider as unknown as EthProvider };
}
// TronLink v2+: use request method
const result = await provider.request({ method: "tron_requestAccounts" }) as { code?: number; message?: string };
if (result?.code === 200) {
// After approval, tronWeb.defaultAddress should be populated
const w = window as unknown as { tronWeb?: { defaultAddress?: { base58?: string } } };
return w.tronWeb?.defaultAddress?.base58 ?? null;
const address = w.tronWeb?.defaultAddress?.base58 ?? null;
if (!address) return null;
return { address, rawProvider: provider as unknown as EthProvider };
}
if (result?.code === 4001) throw new Error("user_rejected");
return null;
@ -314,7 +315,6 @@ function buildWallets(showTron: boolean): WalletInfo[] {
name: "TronLink",
icon: <TronLinkIcon />,
installUrl: "https://www.tronlink.org/",
// TronLink mobile DeepLink — opens current URL in TronLink's built-in browser
mobileDeepLink: `tronlinkoutside://pull.activity?param=${encodeURIComponent(JSON.stringify({ url: currentUrl, action: "open", protocol: "tronlink", version: "1.0" }))}`,
isInstalled: () => !!getTronLink(),
connect: async () => {
@ -339,44 +339,29 @@ function isValidTronAddress(addr: string): boolean {
return /^T[1-9A-HJ-NP-Za-km-z]{33}$/.test(addr);
}
function isValidAddress(addr: string): boolean {
return isValidEthAddress(addr) || isValidTronAddress(addr);
}
// ── Mobile DeepLink Panel ─────────────────────────────────────────────────────
function MobileDeepLinkPanel({ lang, showTron }: { lang: Lang; showTron: boolean }) {
const currentUrl = typeof window !== "undefined" ? window.location.href : "https://pre-sale.newassetchain.io";
const urlWithoutProtocol = currentUrl.replace(/^https?:\/\//, "");
const [showMore, setShowMore] = useState(false);
const tronWallets = [
{
const metamaskDeepLink = `https://metamask.app.link/dapp/${urlWithoutProtocol}`;
const otherWallets = [
...( showTron ? [{
id: "tronlink",
name: "TronLink",
icon: <TronLinkIcon />,
deepLink: `tronlinkoutside://pull.activity?param=${encodeURIComponent(JSON.stringify({ url: currentUrl, action: "open", protocol: "tronlink", version: "1.0" }))}`,
installUrl: "https://www.tronlink.org/",
badge: "TRON",
badgeColor: "#FF0013",
},
];
const evmWallets = [
{
id: "metamask",
name: "MetaMask",
icon: <MetaMaskIcon />,
deepLink: `https://metamask.app.link/dapp/${urlWithoutProtocol}`,
installUrl: "https://metamask.io/download/",
badge: "EVM",
badgeColor: "#E27625",
},
}] : []),
{
id: "trust",
name: "Trust Wallet",
icon: <TrustWalletIcon />,
deepLink: `https://link.trustwallet.com/open_url?coin_id=60&url=${encodeURIComponent(currentUrl)}`,
installUrl: "https://trustwallet.com/download",
badge: "EVM",
badgeColor: "#3375BB",
},
@ -385,7 +370,6 @@ function MobileDeepLinkPanel({ lang, showTron }: { lang: Lang; showTron: boolean
name: "OKX Wallet",
icon: <OKXIcon />,
deepLink: `okx://wallet/dapp/url?dappUrl=${encodeURIComponent(currentUrl)}`,
installUrl: "https://www.okx.com/web3",
badge: "EVM",
badgeColor: "#00F0FF",
},
@ -394,90 +378,236 @@ function MobileDeepLinkPanel({ lang, showTron }: { lang: Lang; showTron: boolean
name: "TokenPocket",
icon: <TokenPocketIcon />,
deepLink: `tpdapp://open?params=${encodeURIComponent(JSON.stringify({ url: currentUrl, chain: "ETH", source: "NAC-Presale" }))}`,
installUrl: "https://www.tokenpocket.pro/en/download/app",
badge: "EVM/TRON",
badgeColor: "#2980FE",
},
];
const walletList = showTron ? [...tronWallets, ...evmWallets] : evmWallets;
return (
<div className="space-y-3">
{/* Mobile guidance header */}
<div
className="rounded-xl p-4"
style={{ background: "rgba(240,180,41,0.08)", border: "1px solid rgba(240,180,41,0.25)" }}
<a
href={metamaskDeepLink}
className="w-full flex items-center gap-3 px-4 py-4 rounded-2xl transition-all hover:opacity-90 active:scale-[0.98] block"
style={{
background: "linear-gradient(135deg, rgba(226,118,37,0.18) 0%, rgba(240,180,41,0.12) 100%)",
border: "1.5px solid rgba(226,118,37,0.5)",
boxShadow: "0 0 20px rgba(226,118,37,0.15)",
}}
>
<div className="flex items-start gap-3">
<span className="text-xl flex-shrink-0">📱</span>
<MetaMaskIcon />
<div className="flex-1">
<p className="text-sm font-bold text-white">MetaMask</p>
<p className="text-xs text-white/40">
{lang === "zh" ? "在 MetaMask 内置浏览器中打开" : "Open in MetaMask browser"}
</p>
</div>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="rgba(226,118,37,0.8)" strokeWidth="2" className="flex-shrink-0">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15 3 21 3 21 9"/>
<line x1="10" y1="14" x2="21" y2="3"/>
</svg>
</a>
<button
onClick={() => setShowMore(v => !v)}
className="w-full text-xs text-white/35 hover:text-white/55 transition-colors py-1 flex items-center justify-center gap-1"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
style={{ transform: showMore ? "rotate(180deg)" : "none", transition: "transform 0.2s" }}>
<path d="M6 9l6 6 6-6"/>
</svg>
{showMore
? (lang === "zh" ? "收起其他钱包" : "Hide other wallets")
: (lang === "zh" ? "其他钱包Trust / OKX / TokenPocket" : "Other wallets (Trust / OKX / TokenPocket)")}
</button>
{showMore && (
<div className="space-y-2">
{otherWallets.map(wallet => (
<a
key={wallet.id}
href={wallet.deepLink}
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl transition-all hover:opacity-90 active:scale-[0.98] block"
style={{
background: "rgba(0,212,255,0.05)",
border: "1px solid rgba(0,212,255,0.18)",
}}
>
<span className="flex-shrink-0">{wallet.icon}</span>
<span className="flex-1 text-sm font-semibold text-white">{wallet.name}</span>
<span
className="text-xs px-2 py-0.5 rounded-full font-medium flex-shrink-0"
style={{ background: `${wallet.badgeColor}22`, color: wallet.badgeColor, border: `1px solid ${wallet.badgeColor}44` }}
>
{wallet.badge}
</span>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="rgba(0,212,255,0.5)" strokeWidth="2" className="flex-shrink-0">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15 3 21 3 21 9"/>
<line x1="10" y1="14" x2="21" y2="3"/>
</svg>
</a>
))}
</div>
)}
</div>
);
}
// ── Error Help Panel ──────────────────────────────────────────────────────────
// Shows specific troubleshooting steps based on error type
function ErrorHelpPanel({ errorType, walletName, lang, onRetry }: {
errorType: "user_rejected" | "wallet_pending" | "not_initialized" | "unknown";
walletName: string;
lang: Lang;
onRetry: () => void;
}) {
const isZh = lang === "zh";
if (errorType === "user_rejected") {
return (
<div
className="rounded-xl p-4 space-y-3"
style={{ background: "rgba(255,80,80,0.06)", border: "1px solid rgba(255,80,80,0.2)" }}
>
<div className="flex items-start gap-2">
<span className="text-red-400 text-base flex-shrink-0"></span>
<div>
<p className="text-sm font-semibold text-amber-300 mb-1">
{lang === "zh" ? "手机端连接钱包" : "Connect Wallet on Mobile"}
<p className="text-sm font-semibold text-red-400">
{isZh ? "连接被拒绝" : "Connection Rejected"}
</p>
<p className="text-xs text-white/50 leading-relaxed">
{lang === "zh"
? "手机浏览器不支持钱包扩展。请选择以下任一钱包 App在其内置浏览器中打开本页面即可连接钱包。"
: "Mobile browsers don't support wallet extensions. Open this page in a wallet app's built-in browser to connect."}
<p className="text-xs text-white/50 mt-1">
{isZh
? `${walletName} 拒绝了连接请求。可能原因:`
: `${walletName} rejected the connection. Possible reasons:`}
</p>
</div>
</div>
</div>
{/* Wallet DeepLink buttons */}
<div className="space-y-2">
<p className="text-xs text-white/40 text-center">
{lang === "zh" ? "选择钱包 App 打开本页面" : "Choose a wallet app to open this page"}
</p>
{walletList.map(wallet => (
<a
key={wallet.id}
href={wallet.deepLink}
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl transition-all hover:opacity-90 active:scale-[0.98] block"
style={{
background: "rgba(0,212,255,0.06)",
border: "1px solid rgba(0,212,255,0.2)",
}}
<div className="space-y-1.5 text-xs text-white/40 pl-4">
<p>
{isZh
? "1. 您在钱包弹窗中点击了「拒绝」"
: "1. You clicked \"Reject\" in the wallet popup"}
</p>
<p>
{isZh
? "2. 该网站之前被您在 MetaMask 中屏蔽(最常见原因)"
: "2. This site was previously blocked in your wallet (most common)"}
</p>
<p>
{isZh
? "3. 钱包弹窗未显示(被浏览器拦截)"
: "3. Wallet popup was blocked by browser"}
</p>
</div>
{walletName === "MetaMask" && (
<div
className="rounded-lg p-3 space-y-1.5"
style={{ background: "rgba(240,180,41,0.06)", border: "1px solid rgba(240,180,41,0.15)" }}
>
<span className="flex-shrink-0">{wallet.icon}</span>
<span className="flex-1 text-sm font-semibold text-white">{wallet.name}</span>
<span
className="text-xs px-2 py-0.5 rounded-full font-medium flex-shrink-0"
style={{ background: `${wallet.badgeColor}22`, color: wallet.badgeColor, border: `1px solid ${wallet.badgeColor}44` }}
>
{wallet.badge}
</span>
<span
className="text-xs px-2 py-0.5 rounded-full font-medium flex-shrink-0"
style={{ background: "rgba(0,212,255,0.15)", color: "#00d4ff" }}
>
{lang === "zh" ? "在 App 中打开" : "Open in App"}
</span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="rgba(0,212,255,0.6)" strokeWidth="2" className="flex-shrink-0">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15 3 21 3 21 9"/>
<line x1="10" y1="14" x2="21" y2="3"/>
</svg>
</a>
))}
<p className="text-xs font-semibold text-amber-400">
{isZh ? "🔧 MetaMask 权限重置步骤:" : "🔧 MetaMask Permission Reset:"}
</p>
<div className="space-y-1 text-xs text-white/40">
<p>{isZh ? "① 点击 MetaMask 图标打开扩展" : "① Click MetaMask icon to open extension"}</p>
<p>{isZh ? "② 点击右上角菜单(三个点)" : "② Click top-right menu (three dots)"}</p>
<p>{isZh ? "③ 选择「已连接的网站」" : "③ Select \"Connected Sites\""}</p>
<p>{isZh ? "④ 找到本网站并删除" : "④ Find this site and remove it"}</p>
<p>{isZh ? "⑤ 回到此页面重新点击连接" : "⑤ Return here and try connecting again"}</p>
</div>
</div>
)}
<button
onClick={onRetry}
className="w-full py-2 rounded-lg text-xs font-semibold transition-all hover:opacity-90"
style={{ background: "rgba(0,212,255,0.12)", color: "#00d4ff", border: "1px solid rgba(0,212,255,0.25)" }}
>
{isZh ? "重试连接" : "Retry Connection"}
</button>
</div>
);
}
{/* Step guide */}
if (errorType === "wallet_pending") {
return (
<div
className="rounded-xl p-3 space-y-2"
style={{ background: "rgba(255,255,255,0.03)", border: "1px solid rgba(255,255,255,0.08)" }}
className="rounded-xl p-4 space-y-3"
style={{ background: "rgba(240,180,41,0.06)", border: "1px solid rgba(240,180,41,0.2)" }}
>
<p className="text-xs font-semibold text-white/50 mb-2">
{lang === "zh" ? "操作步骤" : "How it works"}
</p>
{[
lang === "zh" ? "1. 点击上方任一钱包 App 按钮" : "1. Tap any wallet app button above",
lang === "zh" ? "2. 在钱包 App 的内置浏览器中打开本页面" : "2. Page opens in the wallet app's browser",
lang === "zh" ? "3. 点击「连接钱包」即可自动连接" : "3. Tap 'Connect Wallet' to connect automatically",
].map((step, i) => (
<p key={i} className="text-xs text-white/35 leading-relaxed">{step}</p>
))}
<div className="flex items-start gap-2">
<span className="text-amber-400 text-base flex-shrink-0"></span>
<div>
<p className="text-sm font-semibold text-amber-400">
{isZh ? "钱包有待处理的请求" : "Wallet Has Pending Request"}
</p>
<p className="text-xs text-white/50 mt-1">
{isZh
? "请查看钱包弹窗并处理待处理的请求,然后重试"
: "Please check your wallet popup and handle the pending request, then retry"}
</p>
</div>
</div>
<button
onClick={onRetry}
className="w-full py-2 rounded-lg text-xs font-semibold transition-all hover:opacity-90"
style={{ background: "rgba(240,180,41,0.12)", color: "#f0b429", border: "1px solid rgba(240,180,41,0.25)" }}
>
{isZh ? "重试" : "Retry"}
</button>
</div>
);
}
if (errorType === "not_initialized") {
return (
<div
className="rounded-xl p-4 space-y-3"
style={{ background: "rgba(0,212,255,0.06)", border: "1px solid rgba(0,212,255,0.2)" }}
>
<div className="flex items-start gap-2">
<span className="text-cyan-400 text-base flex-shrink-0"></span>
<div>
<p className="text-sm font-semibold text-cyan-400">
{isZh ? "钱包未完成初始化" : "Wallet Not Initialized"}
</p>
<p className="text-xs text-white/50 mt-1">
{isZh
? "请先完成钱包设置(创建或导入钱包),然后刷新页面重试"
: "Please complete wallet setup (create or import wallet), then refresh the page"}
</p>
</div>
</div>
<button
onClick={() => window.location.reload()}
className="w-full py-2 rounded-lg text-xs font-semibold transition-all hover:opacity-90"
style={{ background: "rgba(0,212,255,0.12)", color: "#00d4ff", border: "1px solid rgba(0,212,255,0.25)" }}
>
{isZh ? "刷新页面" : "Refresh Page"}
</button>
</div>
);
}
// Unknown error
return (
<div
className="rounded-xl p-4 space-y-2"
style={{ background: "rgba(255,80,80,0.06)", border: "1px solid rgba(255,80,80,0.2)" }}
>
<p className="text-sm font-semibold text-red-400">
{isZh ? "连接失败" : "Connection Failed"}
</p>
<p className="text-xs text-white/40">
{isZh ? "请刷新页面后重试,或尝试其他钱包" : "Please refresh the page and try again, or try another wallet"}
</p>
<button
onClick={onRetry}
className="w-full py-2 rounded-lg text-xs font-semibold transition-all hover:opacity-90"
style={{ background: "rgba(0,212,255,0.12)", color: "#00d4ff", border: "1px solid rgba(0,212,255,0.25)" }}
>
{isZh ? "重试" : "Retry"}
</button>
</div>
);
}
@ -487,17 +617,19 @@ function MobileDeepLinkPanel({ lang, showTron }: { lang: Lang; showTron: boolean
export function WalletSelector({ lang, onAddressDetected, connectedAddress, compact = false, showTron = false }: WalletSelectorProps) {
const [wallets, setWallets] = useState<WalletInfo[]>([]);
const [connecting, setConnecting] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [errorType, setErrorType] = useState<"user_rejected" | "wallet_pending" | "not_initialized" | "unknown" | null>(null);
const [errorWalletName, setErrorWalletName] = useState<string>("");
const [detecting, setDetecting] = useState(true);
const [showManual, setShowManual] = useState(false);
const [manualAddress, setManualAddress] = useState("");
const [manualError, setManualError] = useState<string | null>(null);
const [isMobile] = useState(() => isMobileBrowser());
const [inWalletBrowser] = useState(() => isInWalletBrowser());
const [lastConnectedWallet, setLastConnectedWallet] = useState<WalletInfo | null>(null);
const detectWallets = useCallback(() => {
setDetecting(true);
setError(null);
setErrorType(null);
// Wait for wallet extensions to fully inject (up to 1500ms)
const timer = setTimeout(() => {
setWallets(buildWallets(showTron));
@ -513,32 +645,40 @@ export function WalletSelector({ lang, onAddressDetected, connectedAddress, comp
const handleConnect = async (wallet: WalletInfo) => {
setConnecting(wallet.id);
setError(null);
setErrorType(null);
setLastConnectedWallet(wallet);
try {
const address = await wallet.connect();
if (address) {
onAddressDetected(address, wallet.network);
const result = await wallet.connect();
if (result) {
onAddressDetected(result.address, wallet.network, result.rawProvider);
} else {
setError(lang === "zh" ? "未获取到地址,请重试" : "No address returned, please try again");
setErrorType("unknown");
setErrorWalletName(wallet.name);
}
} catch (err: unknown) {
const error = err as Error;
setErrorWalletName(wallet.name);
if (error.message === "user_rejected") {
setError(lang === "zh" ? "已取消连接" : "Connection cancelled");
setErrorType("user_rejected");
} else if (error.message === "wallet_pending") {
setError(lang === "zh" ? "钱包请求处理中,请检查钱包弹窗" : "Wallet request pending, please check your wallet popup");
setErrorType("wallet_pending");
} else if (error.message?.includes("not initialized") || error.message?.includes("setup")) {
setError(lang === "zh"
? "请先完成钱包初始化设置,然后刷新页面重试"
: "Please complete wallet setup first, then refresh the page");
setErrorType("not_initialized");
} else {
setError(lang === "zh" ? "连接失败,请重试" : "Connection failed, please try again");
setErrorType("unknown");
}
} finally {
setConnecting(null);
}
};
const handleRetry = useCallback(() => {
setErrorType(null);
if (lastConnectedWallet) {
handleConnect(lastConnectedWallet);
}
}, [lastConnectedWallet]); // eslint-disable-line react-hooks/exhaustive-deps
const handleManualSubmit = () => {
const addr = manualAddress.trim();
if (!addr) {
@ -583,65 +723,61 @@ export function WalletSelector({ lang, onAddressDetected, connectedAddress, comp
// ── Mobile browser (not in wallet app) — show DeepLink guide ──────────────
if (isMobile && !inWalletBrowser && !detecting) {
const hasInstalledWallet = installedWallets.length > 0;
return (
<div className="space-y-3">
<MobileDeepLinkPanel lang={lang} showTron={showTron} />
if (!hasInstalledWallet) {
return (
<div className="space-y-3">
<MobileDeepLinkPanel lang={lang} showTron={showTron} />
{/* Manual address fallback */}
<div className="pt-1">
<button
onClick={() => { setShowManual(!showManual); setManualError(null); }}
className="w-full text-xs text-white/30 hover:text-white/50 transition-colors py-1 flex items-center justify-center gap-1"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
{showManual
? (lang === "zh" ? "收起手动输入" : "Hide manual input")
: (lang === "zh" ? "手动输入钱包地址" : "Enter address manually")}
</button>
{/* Manual address fallback */}
<div className="pt-1">
<button
onClick={() => { setShowManual(!showManual); setManualError(null); }}
className="w-full text-xs text-white/30 hover:text-white/50 transition-colors py-1 flex items-center justify-center gap-1"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
{showManual
? (lang === "zh" ? "收起手动输入" : "Hide manual input")
: (lang === "zh" ? "手动输入钱包地址" : "Enter address manually")}
</button>
{showManual && (
<div className="mt-2 space-y-2">
<p className="text-xs text-white/40 text-center">
{lang === "zh"
? "输入 EVM 地址0x 开头)或 TRON 地址T 开头)"
: "Enter EVM address (0x...) or TRON address (T...)"}
</p>
<div className="flex gap-2">
<input
type="text"
value={manualAddress}
onChange={e => { setManualAddress(e.target.value); setManualError(null); }}
placeholder={lang === "zh" ? "0x... 或 T..." : "0x... or T..."}
className="flex-1 px-3 py-2 rounded-lg text-xs font-mono text-white/80 outline-none focus:ring-1"
style={{
background: "rgba(255,255,255,0.06)",
border: manualError ? "1px solid rgba(255,80,80,0.5)" : "1px solid rgba(255,255,255,0.12)",
}}
onKeyDown={e => e.key === "Enter" && handleManualSubmit()}
/>
<button
onClick={handleManualSubmit}
className="px-3 py-2 rounded-lg text-xs font-semibold transition-all hover:opacity-90 active:scale-95 whitespace-nowrap"
style={{ background: "rgba(0,212,255,0.15)", color: "#00d4ff", border: "1px solid rgba(0,212,255,0.3)" }}
>
{lang === "zh" ? "确认" : "Confirm"}
</button>
</div>
{manualError && (
<p className="text-xs text-red-400">{manualError}</p>
)}
{showManual && (
<div className="mt-2 space-y-2">
<p className="text-xs text-white/40 text-center">
{lang === "zh"
? "输入 EVM 地址0x 开头)或 TRON 地址T 开头)"
: "Enter EVM address (0x...) or TRON address (T...)"}
</p>
<div className="flex gap-2">
<input
type="text"
value={manualAddress}
onChange={e => { setManualAddress(e.target.value); setManualError(null); }}
placeholder={lang === "zh" ? "0x... 或 T..." : "0x... or T..."}
className="flex-1 px-3 py-2 rounded-lg text-xs font-mono text-white/80 outline-none focus:ring-1"
style={{
background: "rgba(255,255,255,0.06)",
border: manualError ? "1px solid rgba(255,80,80,0.5)" : "1px solid rgba(255,255,255,0.12)",
}}
onKeyDown={e => e.key === "Enter" && handleManualSubmit()}
/>
<button
onClick={handleManualSubmit}
className="px-3 py-2 rounded-lg text-xs font-semibold transition-all hover:opacity-90 active:scale-95 whitespace-nowrap"
style={{ background: "rgba(0,212,255,0.15)", color: "#00d4ff", border: "1px solid rgba(0,212,255,0.3)" }}
>
{lang === "zh" ? "确认" : "Confirm"}
</button>
</div>
)}
</div>
{manualError && (
<p className="text-xs text-red-400">{manualError}</p>
)}
</div>
)}
</div>
);
}
</div>
);
}
// ── Loading state ─────────────────────────────────────────────────────────
@ -650,7 +786,7 @@ export function WalletSelector({ lang, onAddressDetected, connectedAddress, comp
<div className="space-y-3">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold text-white/60 uppercase tracking-wider">
{lang === "zh" ? "选择钱包自动填充地址" : "Select wallet to auto-fill address"}
{lang === "zh" ? "选择钱包连接" : "Select Wallet to Connect"}
</p>
</div>
<div className="flex items-center justify-center py-4 gap-2">
@ -670,7 +806,7 @@ export function WalletSelector({ lang, onAddressDetected, connectedAddress, comp
<div className="space-y-3">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold text-white/60 uppercase tracking-wider">
{lang === "zh" ? "选择钱包自动填充地址" : "Select wallet to auto-fill address"}
{lang === "zh" ? "选择钱包连接" : "Select Wallet to Connect"}
</p>
{/* Refresh detection button */}
<button
@ -693,14 +829,45 @@ export function WalletSelector({ lang, onAddressDetected, connectedAddress, comp
</button>
</div>
{/* Connecting overlay */}
{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" ? "等待钱包授权..." : "Waiting for wallet authorization..."}
</p>
<p className="text-xs text-white/40 mt-0.5">
{lang === "zh" ? "请查看钱包弹窗并点击「连接」" : "Please check your wallet popup and click \"Connect\""}
</p>
</div>
</div>
)}
{/* Error panel */}
{!connecting && errorType && (
<ErrorHelpPanel
errorType={errorType}
walletName={errorWalletName}
lang={lang}
onRetry={handleRetry}
/>
)}
{/* Installed wallets */}
{installedWallets.length > 0 && (
{!connecting && !errorType && 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)",
@ -717,23 +884,43 @@ export function WalletSelector({ lang, onAddressDetected, connectedAddress, comp
>
{lang === "zh" ? "已安装" : "Installed"}
</span>
{connecting === wallet.id ? (
<svg className="animate-spin w-4 h-4 text-white/60 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>
) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="rgba(0,212,255,0.7)" strokeWidth="2" className="flex-shrink-0">
<path d="M5 12h14M12 5l7 7-7 7"/>
</svg>
)}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="rgba(0,212,255,0.7)" strokeWidth="2" className="flex-shrink-0">
<path d="M5 12h14M12 5l7 7-7 7"/>
</svg>
</button>
))}
</div>
)}
{/* Show installed wallets even when there's an error (for retry) */}
{!connecting && errorType && installedWallets.length > 0 && (
<div className="space-y-2">
<p className="text-xs text-white/30 text-center">
{lang === "zh" ? "或选择其他钱包" : "Or try another wallet"}
</p>
{installedWallets.map(wallet => (
<button
key={wallet.id}
onClick={() => handleConnect(wallet)}
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: "rgba(255,255,255,0.04)",
border: "1px solid rgba(255,255,255,0.1)",
}}
>
<span className="flex-shrink-0">{wallet.icon}</span>
<span className="flex-1 text-left text-sm font-semibold text-white/70">{wallet.name}</span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.3)" strokeWidth="2" className="flex-shrink-0">
<path d="M5 12h14M12 5l7 7-7 7"/>
</svg>
</button>
))}
</div>
)}
{/* 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)" }}
@ -748,13 +935,6 @@ export function WalletSelector({ lang, onAddressDetected, connectedAddress, comp
? "请安装以下任一钱包,完成设置后点击上方「刷新」按钮"
: "Install any wallet below, then click Refresh above after setup"}
</p>
{showTron && (
<p className="text-xs text-amber-400/70">
{lang === "zh"
? "💡 TRC20 支付请安装 TronLinkBSC/ETH 支付请安装 MetaMask"
: "💡 For TRC20 install TronLink; for BSC/ETH install MetaMask"}
</p>
)}
{!showTron && (
<p className="text-xs text-amber-400/70">
{lang === "zh"
@ -765,61 +945,43 @@ export function WalletSelector({ lang, onAddressDetected, connectedAddress, comp
</div>
)}
{/* Not-installed wallets — show install links */}
{!compact && notInstalledWallets.length > 0 && (
<div className="space-y-1">
<p className="text-xs text-white/30 mt-2">
{lang === "zh" ? "未安装(点击安装)" : "Not installed (click to install)"}
{/* Not installed wallets */}
{!connecting && notInstalledWallets.length > 0 && (
<div className="space-y-1.5">
<p className="text-xs text-white/25 uppercase tracking-wider">
{lang === "zh" ? "未安装" : "Not installed"}
</p>
<div className="grid grid-cols-3 gap-2">
{notInstalledWallets.map(wallet => (
<a
key={wallet.id}
href={wallet.installUrl}
target="_blank"
rel="noopener noreferrer"
className="flex flex-col items-center gap-1.5 p-2.5 rounded-xl transition-all hover:opacity-80"
style={{
background: "rgba(255,255,255,0.04)",
border: "1px solid rgba(255,255,255,0.08)",
}}
>
<span className="opacity-40">{wallet.icon}</span>
<span className="text-xs text-white/30 text-center leading-tight">{wallet.name}</span>
</a>
))}
</div>
</div>
)}
{/* In compact mode, show install links inline */}
{compact && notInstalledWallets.length > 0 && installedWallets.length === 0 && (
<div className="flex flex-wrap gap-2">
{notInstalledWallets.slice(0, 4).map(wallet => (
{notInstalledWallets.map(wallet => (
<a
key={wallet.id}
href={wallet.installUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs transition-all hover:opacity-80"
className="w-full flex items-center gap-3 px-4 py-2.5 rounded-xl transition-all hover:opacity-80"
style={{
background: "rgba(255,255,255,0.05)",
border: "1px solid rgba(255,255,255,0.1)",
color: "rgba(255,255,255,0.4)",
background: "rgba(255,255,255,0.02)",
border: "1px dashed rgba(255,255,255,0.1)",
}}
>
<span className="opacity-50">{wallet.icon}</span>
{lang === "zh" ? `安装 ${wallet.name}` : `Install ${wallet.name}`}
<span className="flex-shrink-0 opacity-50">{wallet.icon}</span>
<span className="flex-1 text-left text-sm text-white/40">{wallet.name}</span>
<span
className="text-xs px-2 py-0.5 rounded-full font-medium"
style={{ background: "rgba(255,255,255,0.05)", color: "rgba(255,255,255,0.3)" }}
>
{lang === "zh" ? "点击安装" : "Install"}
</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.2)" strokeWidth="2" className="flex-shrink-0">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15 3 21 3 21 9"/>
<line x1="10" y1="14" x2="21" y2="3"/>
</svg>
</a>
))}
</div>
)}
{error && (
<p className="text-xs text-red-400 text-center">{error}</p>
)}
{/* Manual address input — divider */}
{/* Manual address input */}
<div className="pt-1">
<button
onClick={() => { setShowManual(!showManual); setManualError(null); }}

View File

@ -1,6 +1,6 @@
// NAC XIC Presale — Wallet Connection Hook
// Supports MetaMask, Trust Wallet, OKX Wallet, Coinbase Wallet, and all EVM-compatible wallets
// v3: improved error handling, MetaMask initialization detection, toast notifications
// Supports MetaMask, TokenPocket, OKX, Bitget, Trust Wallet, imToken, SafePal, and all EVM wallets
// v5: added connectWithProvider() to fix state sync when WalletSelector connects via external provider
import { useState, useEffect, useCallback, useRef } from "react";
import { BrowserProvider, JsonRpcSigner, Eip1193Provider } from "ethers";
@ -30,58 +30,65 @@ const INITIAL_STATE: WalletState = {
error: null,
};
export type EthProvider = Eip1193Provider & {
isMetaMask?: boolean;
isTrust?: boolean;
isTrustWallet?: boolean;
isOKExWallet?: boolean;
isOkxWallet?: boolean;
isCoinbaseWallet?: boolean;
isTokenPocket?: boolean;
isBitkeep?: boolean;
isBitgetWallet?: boolean;
providers?: EthProvider[];
};
// Detect the best available EVM provider across all major wallets
export function detectProvider(): Eip1193Provider | null {
// Priority: TokenPocket > OKX > Bitget > Trust Wallet > MetaMask > others
export function detectProvider(): EthProvider | null {
if (typeof window === "undefined") return null;
const w = window as unknown as Record<string, unknown>;
const eth = w.ethereum as (Eip1193Provider & {
providers?: Eip1193Provider[];
isMetaMask?: boolean;
isTrust?: boolean;
isOKExWallet?: boolean;
isCoinbaseWallet?: boolean;
}) | undefined;
if (!eth) {
// Fallback: check wallet-specific globals
if (w.okxwallet) return w.okxwallet as Eip1193Provider;
if (w.coinbaseWalletExtension) return w.coinbaseWalletExtension as Eip1193Provider;
return null;
}
// If multiple providers are injected (common when multiple extensions installed)
if (eth.providers && Array.isArray(eth.providers) && eth.providers.length > 0) {
const metamask = eth.providers.find((p: Eip1193Provider & { isMetaMask?: boolean }) => p.isMetaMask);
return metamask ?? eth.providers[0];
}
return eth;
}
// Check if MetaMask is installed but not yet initialized (no wallet created/imported)
export async function checkWalletReady(rawProvider: Eip1193Provider): Promise<{ ready: boolean; reason?: string }> {
try {
// eth_accounts is silent — if it returns empty array, wallet is installed but locked or not initialized
const accounts = await (rawProvider as { request: (args: { method: string }) => Promise<string[]> }).request({
method: "eth_accounts",
});
// If we get here, the wallet is at least initialized (even if locked / no accounts)
return { ready: true };
} catch (err: unknown) {
const error = err as { code?: number; message?: string };
// -32002: Request already pending (MetaMask not initialized or another request pending)
if (error?.code === -32002) {
return { ready: false, reason: "pending" };
// 1. Check window.ethereum (most wallets inject here)
const eth = w.ethereum as EthProvider | undefined;
if (eth) {
// Check providers array first (multiple extensions installed)
if (eth.providers && Array.isArray(eth.providers) && eth.providers.length > 0) {
// Priority order for Chinese users
const tp = eth.providers.find((p: EthProvider) => p.isTokenPocket);
if (tp) return tp;
const okx = eth.providers.find((p: EthProvider) => p.isOKExWallet || p.isOkxWallet);
if (okx) return okx;
const bitget = eth.providers.find((p: EthProvider) => p.isBitkeep || p.isBitgetWallet);
if (bitget) return bitget;
const trust = eth.providers.find((p: EthProvider) => p.isTrust || p.isTrustWallet);
if (trust) return trust;
const metamask = eth.providers.find((p: EthProvider) => p.isMetaMask);
if (metamask) return metamask;
return eth.providers[0];
}
// Any other error — treat as not ready
return { ready: false, reason: error?.message || "unknown" };
// Single provider — return it directly
return eth;
}
// 2. OKX Wallet — sometimes injects window.okxwallet separately
if (w.okxwallet) return w.okxwallet as EthProvider;
// 3. Bitget Wallet — sometimes injects window.bitkeep.ethereum
const bitkeep = w.bitkeep as { ethereum?: EthProvider } | undefined;
if (bitkeep?.ethereum) return bitkeep.ethereum;
// 4. Coinbase Wallet
if (w.coinbaseWalletExtension) return w.coinbaseWalletExtension as EthProvider;
return null;
}
// Build wallet state from a provider and accounts
async function buildWalletState(
rawProvider: Eip1193Provider,
rawProvider: EthProvider,
address: string
): Promise<Partial<WalletState>> {
const provider = new BrowserProvider(rawProvider);
@ -118,7 +125,14 @@ async function buildWalletState(
};
}
export function useWallet() {
export type WalletHookReturn = WalletState & {
connect: () => Promise<{ success: boolean; error?: string }>;
connectWithProvider: (rawProvider: EthProvider, address: string) => Promise<void>;
disconnect: () => void;
switchNetwork: (chainId: number) => Promise<void>;
};
export function useWallet(): WalletHookReturn {
const [state, setState] = useState<WalletState>(INITIAL_STATE);
const retryRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const mountedRef = useRef(true);
@ -131,12 +145,36 @@ export function useWallet() {
};
}, []);
// ── Connect (explicit user action) ─────────────────────────────────────────
// ── Connect via external provider (called from WalletSelector) ─────────────
// KEY FIX: WalletSelector already has the provider and address from the wallet popup.
// We update state directly without calling connect() again (which would use detectProvider()
// and might pick the wrong wallet or fail if the wallet injects to a different window property).
const connectWithProvider = useCallback(async (rawProvider: EthProvider, address: string) => {
if (!mountedRef.current) return;
setState(s => ({ ...s, isConnecting: true, error: null }));
try {
const partial = await buildWalletState(rawProvider, address);
if (mountedRef.current) setState({ ...INITIAL_STATE, ...partial });
} catch {
// Fallback: at minimum set address and isConnected = true
if (mountedRef.current) {
setState({
...INITIAL_STATE,
address,
shortAddress: shortenAddress(address),
isConnected: true,
isConnecting: false,
});
}
}
}, []);
// ── Connect (explicit user action via detectProvider) ──────────────────────
const connect = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
const rawProvider = detectProvider();
if (!rawProvider) {
const msg = "未检测到钱包插件。请安装 MetaMask 或其他 EVM 兼容钱包后刷新页面。";
const msg = "未检测到钱包插件。请安装 TokenPocket、MetaMask 或其他 EVM 兼容钱包后刷新页面。";
if (mountedRef.current) setState(s => ({ ...s, error: msg }));
return { success: false, error: msg };
}
@ -165,19 +203,11 @@ export function useWallet() {
let msg: string;
if (error?.code === 4001) {
// User rejected
msg = "已取消连接 / Connection cancelled";
} else if (error?.code === -32002) {
// MetaMask has a pending request — usually means it's not initialized or popup is already open
msg = "钱包请求处理中,请检查 MetaMask 弹窗。如未弹出,请先完成 MetaMask 初始化设置(创建或导入钱包),然后刷新页面重试。";
msg = "钱包请求处理中,请检查钱包弹窗。如未弹出,请先完成钱包初始化设置,然后刷新页面重试。";
} else if (error?.message === "no_accounts") {
msg = "未获取到账户,请确认钱包已解锁并授权此网站。";
} else if (
error?.message?.toLowerCase().includes("not initialized") ||
error?.message?.toLowerCase().includes("setup") ||
error?.message?.toLowerCase().includes("onboarding")
) {
msg = "MetaMask 尚未完成初始化。请先打开 MetaMask 扩展,创建或导入钱包,然后刷新页面重试。";
} else {
msg = `连接失败: ${error?.message || "未知错误"}。请刷新页面重试。`;
}
@ -226,8 +256,8 @@ export function useWallet() {
const rawProvider = detectProvider();
if (!rawProvider) {
if (attempt < 3) {
retryRef.current = setTimeout(() => tryAutoDetect(attempt + 1), 800 * attempt);
if (attempt < 5) {
retryRef.current = setTimeout(() => tryAutoDetect(attempt + 1), 600 * attempt);
}
return;
}
@ -242,11 +272,13 @@ export function useWallet() {
if (!cancelled && mountedRef.current) {
setState({ ...INITIAL_STATE, ...partial });
}
} else if (attempt < 3) {
retryRef.current = setTimeout(() => tryAutoDetect(attempt + 1), 1000 * attempt);
} else if (attempt < 5) {
retryRef.current = setTimeout(() => tryAutoDetect(attempt + 1), 800 * attempt);
}
} catch {
// Silently ignore — user hasn't connected yet
if (attempt < 3) {
retryRef.current = setTimeout(() => tryAutoDetect(attempt + 1), 1000);
}
}
};
@ -322,5 +354,5 @@ export function useWallet() {
};
}, []);
return { ...state, connect, disconnect, switchNetwork };
return { ...state, connect, connectWithProvider, disconnect, switchNetwork };
}

View File

@ -13,7 +13,7 @@ export const CONTRACTS = {
rpcUrl: "https://bsc-dataseed1.binance.org/",
explorerUrl: "https://bscscan.com",
nativeCurrency: { name: "BNB", symbol: "BNB", decimals: 18 },
presale: "0xc65e7a2738ed884db8d26a6eb2fecf7daca2e90c",
presale: "0x5953c025dA734e710886916F2d739A3A78f8bbc4", // XICPresale v2 — 购买即时发放
token: "0x59FF34dD59680a7125782b1f6df2A86ed46F5A24",
usdt: "0x55d398326f99059fF775485246999027B3197955",
},
@ -50,9 +50,9 @@ export const PRESALE_CONFIG = {
tokenName: "New AssetChain Token",
tokenDecimals: 18,
minPurchaseUSDT: 0, // No minimum purchase limit
maxPurchaseUSDT: 50000, // Maximum $50,000 USDT
maxPurchaseUSDT: 50000, // Max $50,000 USDT per purchase
totalSupply: 100_000_000_000, // 100 billion XIC
presaleAllocation: 30_000_000_000, // 30 billion for presale
presaleAllocation: 2_500_000_000, // 2.5 billion for presale (25亿)
// TRC20 memo format
trc20Memo: "XIC_PRESALE",
};

1197
client/src/pages/Bridge.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@ -4,6 +4,7 @@
// Colors: Amber Gold #f0b429 | Quantum Blue #00d4ff | Deep Black #0a0a0f
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { createPortal } from "react-dom";
import { toast } from "sonner";
import { Link } from "wouter";
import { useWallet } from "@/hooks/useWallet";
@ -159,12 +160,12 @@ function TRC20Panel({ usdtAmount, lang, connectedAddress, onConnectWallet }: { u
<div className="flex items-center gap-2">
<span className="text-amber-400 text-sm"></span>
<p className="text-sm font-semibold text-amber-300">
{lang === "zh" ? "必填您的XIC接收地址BSC/ETH包地址)" : "Required: Your XIC Receiving Address (BSC/ETH wallet address)"}
{lang === "zh" ? "必填您的XIC接收地址BSC/ETH包地址)" : "Required: Your XIC Receiving Address (BSC/ETH wallet address)"}
</p>
</div>
<p className="text-xs text-white/50">
{lang === "zh"
? "XIC代币将发放到您的BSC/ETH包地址0x开头。请确保填写正确的地址否则无法收到代币。"
? "XIC代币将发放到您的BSC/ETH包地址0x开头。请确保填写正确的地址否则无法收到代币。"
: "XIC tokens will be sent to your BSC/ETH wallet address (starts with 0x). Please make sure to enter the correct address."}
</p>
<div className="space-y-2">
@ -315,7 +316,7 @@ function TRC20Panel({ usdtAmount, lang, connectedAddress, onConnectWallet }: { u
}
// ─── EVM Purchase Panel ─────────────────────────────────────────────────────
function EVMPurchasePanel({ network, lang, wallet }: { network: "BSC" | "ETH"; lang: Lang; wallet: WalletHookReturn }) {
function EVMPurchasePanel({ network, lang, wallet, onOpenWalletModal }: { network: "BSC" | "ETH"; lang: Lang; wallet: WalletHookReturn; onOpenWalletModal?: () => void }) {
const { t } = useTranslation(lang);
const { purchaseState, buyWithUSDT, reset, calcTokens, getUsdtBalance } = usePresale(wallet, network);
const [usdtInput, setUsdtInput] = useState("100");
@ -334,7 +335,8 @@ function EVMPurchasePanel({ network, lang, wallet }: { network: "BSC" | "ETH"; l
const usdtAmount = parseFloat(usdtInput) || 0;
const tokenAmount = calcTokens(usdtAmount);
const isValidAmount = usdtAmount > 0 && usdtAmount <= PRESALE_CONFIG.maxPurchaseUSDT;
// maxPurchaseUSDT=0 means no limit; otherwise check against the limit
const isValidAmount = usdtAmount > 0 && (PRESALE_CONFIG.maxPurchaseUSDT === 0 || usdtAmount <= PRESALE_CONFIG.maxPurchaseUSDT);
const handleBuy = async () => {
if (!isValidAmount) {
@ -360,16 +362,18 @@ function EVMPurchasePanel({ network, lang, wallet }: { network: "BSC" | "ETH"; l
return (
<div className="space-y-4">
<p className="text-sm text-white/60 text-center">{t("buy_connect_msg")}</p>
<WalletSelector
lang={lang}
connectedAddress={wallet.address ?? undefined}
onAddressDetected={(addr) => {
// WalletSelector already called eth_requestAccounts and got the address
// Just show success toast; wallet state will auto-update via accountsChanged event
toast.success(lang === "zh" ? `已连接: ${addr.slice(0, 6)}...${addr.slice(-4)}` : `Connected: ${addr.slice(0, 6)}...${addr.slice(-4)}`);
}}
compact
/>
<button
onClick={() => onOpenWalletModal?.()}
className="w-full py-3 rounded-xl text-base font-bold transition-all hover:opacity-90 flex items-center justify-center gap-2"
style={{ background: "linear-gradient(135deg, rgba(240,180,41,0.9) 0%, rgba(255,215,0,0.9) 100%)", color: "#0a0a0f", fontFamily: "'Space Grotesk', sans-serif", boxShadow: "0 0 16px rgba(240,180,41,0.3)" }}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M21 12V7H5a2 2 0 0 1 0-4h14v4"/>
<path d="M3 5v14a2 2 0 0 0 2 2h16v-5"/>
<path d="M18 12a2 2 0 0 0 0 4h4v-4z"/>
</svg>
{lang === "zh" ? "连接钱包" : "Connect Wallet"}
</button>
<div className="text-xs text-white/40 text-center">{t("buy_connect_hint")}</div>
</div>
);
@ -516,8 +520,56 @@ function EVMPurchasePanel({ network, lang, wallet }: { network: "BSC" | "ETH"; l
</button>
<p className="text-xs text-center text-white/30">
{t("buy_no_min_max")} ${PRESALE_CONFIG.maxPurchaseUSDT.toLocaleString()} USDT
{PRESALE_CONFIG.maxPurchaseUSDT > 0
? `${t("buy_no_min_max")} $${PRESALE_CONFIG.maxPurchaseUSDT.toLocaleString()} USDT`
: (lang === "zh" ? "无最低/最高购买限制" : "No minimum or maximum purchase limit")}
</p>
{/* Add XIC to Wallet button — only show on BSC where token address is known AND wallet is connected */}
{network === "BSC" && CONTRACTS.BSC.token && wallet.isConnected && (
<button
onClick={async () => {
try {
// Use wallet.provider (ethers BrowserProvider) which wraps the connected wallet's provider
// This works regardless of which wallet is connected (MetaMask, OKX, TokenPocket, etc.)
if (!wallet.provider) {
toast.error(lang === "zh" ? "钱包未连接,请先连接钱包" : "Wallet not connected. Please connect your wallet first.");
return;
}
await wallet.provider.send("wallet_watchAsset", {
type: "ERC20",
options: {
address: CONTRACTS.BSC.token,
symbol: PRESALE_CONFIG.tokenSymbol,
decimals: PRESALE_CONFIG.tokenDecimals,
image: "https://d2xsxph8kpxj0f.cloudfront.net/310519663287655625/Ngki3MumDNGduV3xJt3mga/nac-token-icon_382e5c30.png",
},
});
toast.success(lang === "zh" ? "XIC 代币已添加到钱包!" : "XIC token added to wallet!");
} catch (err: unknown) {
const error = err as { code?: number; message?: string };
if (error?.code === 4001) {
// User rejected — not an error
return;
}
toast.error(lang === "zh" ? "添加失败,请手动添加代币" : "Failed to add token. Please add manually.");
}
}}
className="w-full py-2.5 rounded-xl text-sm font-semibold transition-all hover:opacity-90 flex items-center justify-center gap-2"
style={{
background: "rgba(0,212,255,0.08)",
border: "1px solid rgba(0,212,255,0.25)",
color: "rgba(0,212,255,0.9)",
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 12V7H5a2 2 0 0 1 0-4h14v4"/>
<path d="M3 5v14a2 2 0 0 0 2 2h16v-5"/>
<path d="M18 12a2 2 0 0 0 0 4h4v-4z"/>
</svg>
{lang === "zh" ? "添加 XIC 到钱包" : "Add XIC to Wallet"}
</button>
)}
</div>
);
}
@ -754,10 +806,9 @@ function ChatSupport({ lang }: { lang: Lang }) {
// ─── Navbar Wallet Button ─────────────────────────────────────────────────────
type WalletHookReturn = ReturnType<typeof useWallet>;
function NavWalletButton({ lang, wallet }: { lang: Lang; wallet: WalletHookReturn }) {
function NavWalletButton({ lang, wallet, showWalletModal, setShowWalletModal }: { lang: Lang; wallet: WalletHookReturn; showWalletModal: boolean; setShowWalletModal: (v: boolean) => void }) {
const { t } = useTranslation(lang);
const [showMenu, setShowMenu] = useState(false);
const [showWalletModal, setShowWalletModal] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
@ -768,24 +819,10 @@ function NavWalletButton({ lang, wallet }: { lang: Lang; wallet: WalletHookRetur
return () => document.removeEventListener("mousedown", handleClick);
}, []);
// Detect mobile browser
const isMobile = typeof window !== "undefined" && /Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
// Handle connect button click — show wallet selector modal
const handleConnectClick = async () => {
// On mobile browsers, skip direct connect attempt and show modal immediately
// (mobile browsers don't support wallet extensions)
if (isMobile) {
setShowWalletModal(true);
return;
}
// On desktop: first try direct connect (works if wallet is already set up and locked)
const result = await wallet.connect();
if (!result.success && result.error) {
// If direct connect failed, show the wallet selector modal for guided setup
setShowWalletModal(true);
toast.error(result.error, { duration: 6000 });
}
// Handle connect button click — always show wallet selector modal
// Both desktop and mobile users see the modal to choose their wallet
const handleConnectClick = () => {
setShowWalletModal(true);
};
if (!wallet.isConnected) {
@ -805,67 +842,6 @@ function NavWalletButton({ lang, wallet }: { lang: Lang; wallet: WalletHookRetur
{wallet.isConnecting ? t("nav_connecting") : t("nav_connect")}
</button>
{/* Wallet Connection Modal */}
{showWalletModal && (
<div
className="fixed inset-0 z-[100] flex items-center justify-center p-4"
style={{ background: "rgba(0,0,0,0.85)", backdropFilter: "blur(8px)" }}
onClick={(e) => { if (e.target === e.currentTarget) setShowWalletModal(false); }}
>
<div
className="w-full max-w-md rounded-2xl p-6 relative"
style={{ background: "rgba(10,10,20,0.98)", border: "1px solid rgba(240,180,41,0.3)", boxShadow: "0 0 40px rgba(240,180,41,0.15)" }}
>
{/* Close button */}
<button
onClick={() => setShowWalletModal(false)}
className="absolute top-4 right-4 text-white/40 hover:text-white/80 transition-colors"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M18 6L6 18M6 6l12 12"/>
</svg>
</button>
<h3 className="text-lg font-bold text-white mb-1" style={{ fontFamily: "'Space Grotesk', sans-serif" }}>
{lang === "zh" ? "连接钱包" : "Connect Wallet"}
</h3>
<p className="text-xs text-white/40 mb-4">
{lang === "zh"
? "选择您的钱包进行连接,或手动输入地址"
: "Select your wallet to connect, or enter address manually"}
</p>
{/* MetaMask initialization guide */}
<div
className="rounded-xl p-3 mb-4"
style={{ background: "rgba(240,180,41,0.06)", border: "1px solid rgba(240,180,41,0.2)" }}
>
<p className="text-xs text-amber-300/80 leading-relaxed">
{lang === "zh"
? "💡 首次使用 MetaMask请先打开 MetaMask 扩展完成初始化(创建或导入钱包),完成后点击下方「刷新」按钮重新检测。"
: "💡 First time using MetaMask? Open the MetaMask extension and complete setup (create or import a wallet), then click Refresh below to re-detect."}
</p>
</div>
<WalletSelector
lang={lang}
connectedAddress={wallet.address ?? undefined}
onAddressDetected={async (addr) => {
// After address detected from WalletSelector, sync wallet state
const result = await wallet.connect();
if (result.success) {
setShowWalletModal(false);
toast.success(lang === "zh" ? `钱包已连接: ${addr.slice(0, 6)}...${addr.slice(-4)}` : `Wallet connected: ${addr.slice(0, 6)}...${addr.slice(-4)}`);
} else {
// Even if connect() failed, we have the address — close modal
setShowWalletModal(false);
toast.success(lang === "zh" ? `地址已确认: ${addr.slice(0, 6)}...${addr.slice(-4)}` : `Address confirmed: ${addr.slice(0, 6)}...${addr.slice(-4)}`);
}
}}
/>
</div>
</div>
)}
</>
);
}
@ -964,6 +940,8 @@ export default function Home() {
// 钱包状态提升到顶层共享给NavWalletButton和EVMPurchasePanel
const wallet = useWallet();
// showWalletModal提升到顶层供NavWalletButton和EVMPurchasePanel共用
const [showWalletModal, setShowWalletModal] = useState(false);
const networks: NetworkTab[] = ["BSC", "ETH", "TRON"];
@ -1002,8 +980,13 @@ export default function Home() {
{lang === "zh" ? "📖 购买教程" : "📖 Tutorial"}
</span>
</Link>
<Link href="/bridge">
<span className="text-sm font-semibold cursor-pointer transition-colors hidden md:block px-2 py-1 rounded-lg" style={{ color: "#f0b429", background: "rgba(240,180,41,0.1)", border: "1px solid rgba(240,180,41,0.3)" }}>
{lang === "zh" ? "⚡ 跨链桥" : "⚡ Bridge"}
</span>
</Link>
<LangToggle lang={lang} setLang={setLang} />
<NavWalletButton lang={lang} wallet={wallet} />
<NavWalletButton lang={lang} wallet={wallet} showWalletModal={showWalletModal} setShowWalletModal={setShowWalletModal} />
</div>
</nav>
@ -1188,8 +1171,8 @@ export default function Home() {
</p>
</div>
)}
{activeNetwork === "BSC" && <EVMPurchasePanel network="BSC" lang={lang} wallet={wallet} />}
{activeNetwork === "ETH" && <EVMPurchasePanel network="ETH" lang={lang} wallet={wallet} />}
{activeNetwork === "BSC" && <EVMPurchasePanel network="BSC" lang={lang} wallet={wallet} onOpenWalletModal={() => setShowWalletModal(true)} />}
{activeNetwork === "ETH" && <EVMPurchasePanel network="ETH" lang={lang} wallet={wallet} onOpenWalletModal={() => setShowWalletModal(true)} />}
{activeNetwork === "TRON" && (
<div className="space-y-4">
<div className="space-y-2">
@ -1308,6 +1291,64 @@ export default function Home() {
{/* ── Chat Support Widget ── */}
<ChatSupport lang={lang} />
{/* ── Global Wallet Connection Modal (Portal) ── */}
{showWalletModal && createPortal(
<div
className="fixed inset-0 z-[9999] flex items-end sm:items-center justify-center sm:p-4"
style={{ background: "rgba(0,0,0,0.85)", backdropFilter: "blur(8px)" }}
onClick={(e) => { if (e.target === e.currentTarget) setShowWalletModal(false); }}
>
<div
className="w-full sm:max-w-md rounded-t-2xl sm:rounded-2xl p-5 relative overflow-y-auto"
style={{ background: "rgba(10,10,20,0.98)", border: "1px solid rgba(240,180,41,0.3)", boxShadow: "0 0 40px rgba(240,180,41,0.15)", maxHeight: "85vh" }}
>
<button
onClick={() => setShowWalletModal(false)}
className="absolute top-4 right-4 text-white/40 hover:text-white/80 transition-colors"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M18 6L6 18M6 6l12 12"/>
</svg>
</button>
<h3 className="text-lg font-bold text-white mb-1" style={{ fontFamily: "'Space Grotesk', sans-serif" }}>
{lang === "zh" ? "连接钱包" : "Connect Wallet"}
</h3>
{!/Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) && (
<>
<p className="text-xs text-white/40 mb-4">
{lang === "zh" ? "选择您的钱包进行连接,或手动输入地址" : "Select your wallet to connect, or enter address manually"}
</p>
<div className="rounded-xl p-3 mb-4" style={{ background: "rgba(240,180,41,0.06)", border: "1px solid rgba(240,180,41,0.2)" }}>
<p className="text-xs text-amber-300/80 leading-relaxed">
{lang === "zh"
? "💡 首次使用 MetaMask请先打开 MetaMask 扩展完成初始化(创建或导入钱包),完成后点击下方「刷新」按鈕重新检测。"
: "💡 First time using MetaMask? Open the MetaMask extension and complete setup (create or import a wallet), then click Refresh below to re-detect."}
</p>
</div>
</>
)}
<WalletSelector
lang={lang}
compact={false}
showTron={false}
connectedAddress={undefined}
onAddressDetected={async (addr, _network, rawProvider) => {
if (rawProvider) {
await wallet.connectWithProvider(rawProvider, addr);
} else {
const result = await wallet.connect();
if (!result.success) {
await wallet.connectWithProvider({ request: async () => [] } as unknown as import("@/hooks/useWallet").EthProvider, addr);
}
}
setShowWalletModal(false);
toast.success(lang === "zh" ? `钱包已连接: ${addr.slice(0, 6)}...${addr.slice(-4)}` : `Wallet connected: ${addr.slice(0, 6)}...${addr.slice(-4)}`);
}}
/>
</div>
</div>
, document.body)}
<style>{`
@keyframes fadeInDown {
from { opacity: 0; transform: translateY(-10px); }

View File

@ -0,0 +1,15 @@
CREATE TABLE `bridge_orders` (
`id` int AUTO_INCREMENT NOT NULL,
`txHash` varchar(128) NOT NULL,
`walletAddress` varchar(64) NOT NULL,
`fromChainId` int NOT NULL,
`fromToken` varchar(32) NOT NULL,
`fromAmount` decimal(30,6) NOT NULL,
`toChainId` int NOT NULL,
`toToken` varchar(32) NOT NULL,
`toAmount` decimal(30,6) NOT NULL,
`status` enum('pending','completed','failed') NOT NULL DEFAULT 'completed',
`createdAt` timestamp NOT NULL DEFAULT (now()),
CONSTRAINT `bridge_orders_id` PRIMARY KEY(`id`),
CONSTRAINT `bridge_orders_txHash_unique` UNIQUE(`txHash`)
);

View File

@ -0,0 +1,532 @@
{
"version": "5",
"dialect": "mysql",
"id": "f2da11d5-2ee3-40ce-9180-11a9480a5b91",
"prevId": "6b25cb51-fd4a-43ff-9411-e1efd553f304",
"tables": {
"bridge_orders": {
"name": "bridge_orders",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"txHash": {
"name": "txHash",
"type": "varchar(128)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"walletAddress": {
"name": "walletAddress",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"fromChainId": {
"name": "fromChainId",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"fromToken": {
"name": "fromToken",
"type": "varchar(32)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"fromAmount": {
"name": "fromAmount",
"type": "decimal(30,6)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"toChainId": {
"name": "toChainId",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"toToken": {
"name": "toToken",
"type": "varchar(32)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"toAmount": {
"name": "toAmount",
"type": "decimal(30,6)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "enum('pending','completed','failed')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'completed'"
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"bridge_orders_id": {
"name": "bridge_orders_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {
"bridge_orders_txHash_unique": {
"name": "bridge_orders_txHash_unique",
"columns": [
"txHash"
]
}
},
"checkConstraint": {}
},
"presale_config": {
"name": "presale_config",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"key": {
"name": "key",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"label": {
"name": "label",
"type": "varchar(128)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"description": {
"name": "description",
"type": "varchar(256)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"type": {
"name": "type",
"type": "varchar(32)",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'text'"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"presale_config_id": {
"name": "presale_config_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {
"presale_config_key_unique": {
"name": "presale_config_key_unique",
"columns": [
"key"
]
}
},
"checkConstraint": {}
},
"presale_stats_cache": {
"name": "presale_stats_cache",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"chain": {
"name": "chain",
"type": "varchar(16)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"usdtRaised": {
"name": "usdtRaised",
"type": "decimal(30,6)",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'0'"
},
"tokensSold": {
"name": "tokensSold",
"type": "decimal(30,6)",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'0'"
},
"weiRaised": {
"name": "weiRaised",
"type": "decimal(30,6)",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'0'"
},
"lastUpdated": {
"name": "lastUpdated",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"presale_stats_cache_id": {
"name": "presale_stats_cache_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"trc20_intents": {
"name": "trc20_intents",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"tronAddress": {
"name": "tronAddress",
"type": "varchar(64)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"evmAddress": {
"name": "evmAddress",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expectedUsdt": {
"name": "expectedUsdt",
"type": "decimal(20,6)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"matched": {
"name": "matched",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"matchedPurchaseId": {
"name": "matchedPurchaseId",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"trc20_intents_id": {
"name": "trc20_intents_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"trc20_purchases": {
"name": "trc20_purchases",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"txHash": {
"name": "txHash",
"type": "varchar(128)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"fromAddress": {
"name": "fromAddress",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"usdtAmount": {
"name": "usdtAmount",
"type": "decimal(20,6)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"xicAmount": {
"name": "xicAmount",
"type": "decimal(30,6)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"blockNumber": {
"name": "blockNumber",
"type": "bigint",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "enum('pending','confirmed','distributed','failed')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'pending'"
},
"distributedAt": {
"name": "distributedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"distributeTxHash": {
"name": "distributeTxHash",
"type": "varchar(128)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"evmAddress": {
"name": "evmAddress",
"type": "varchar(64)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"trc20_purchases_id": {
"name": "trc20_purchases_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {
"trc20_purchases_txHash_unique": {
"name": "trc20_purchases_txHash_unique",
"columns": [
"txHash"
]
}
},
"checkConstraint": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"openId": {
"name": "openId",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email": {
"name": "email",
"type": "varchar(320)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"loginMethod": {
"name": "loginMethod",
"type": "varchar(64)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"role": {
"name": "role",
"type": "enum('user','admin')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'user'"
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
},
"lastSignedIn": {
"name": "lastSignedIn",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"users_id": {
"name": "users_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {
"users_openId_unique": {
"name": "users_openId_unique",
"columns": [
"openId"
]
}
},
"checkConstraint": {}
}
},
"views": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"tables": {},
"indexes": {}
}
}

View File

@ -36,6 +36,13 @@
"when": 1772955197567,
"tag": "0004_parallel_unus",
"breakpoints": true
},
{
"idx": 5,
"version": "5",
"when": 1773124399358,
"tag": "0005_certain_betty_ross",
"breakpoints": true
}
]
}

View File

@ -96,4 +96,21 @@ export const presaleConfig = mysqlTable("presale_config", {
});
export type PresaleConfig = typeof presaleConfig.$inferSelect;
export type InsertPresaleConfig = typeof presaleConfig.$inferInsert;
export type InsertPresaleConfig = typeof presaleConfig.$inferInsert;
// Cross-chain bridge orders — recorded when user completes a Li.Fi cross-chain purchase
export const bridgeOrders = mysqlTable("bridge_orders", {
id: int("id").autoincrement().primaryKey(),
txHash: varchar("txHash", { length: 128 }).notNull().unique(),
walletAddress: varchar("walletAddress", { length: 64 }).notNull(),
fromChainId: int("fromChainId").notNull(),
fromToken: varchar("fromToken", { length: 32 }).notNull(),
fromAmount: decimal("fromAmount", { precision: 30, scale: 6 }).notNull(),
toChainId: int("toChainId").notNull(),
toToken: varchar("toToken", { length: 32 }).notNull(),
toAmount: decimal("toAmount", { precision: 30, scale: 6 }).notNull(),
status: mysqlEnum("status", ["pending", "completed", "failed"]).default("completed").notNull(),
createdAt: timestamp("createdAt").defaultNow().notNull(),
});
export type BridgeOrder = typeof bridgeOrders.$inferSelect;
export type InsertBridgeOrder = typeof bridgeOrders.$inferInsert;

View File

@ -16,6 +16,8 @@
"@aws-sdk/client-s3": "^3.693.0",
"@aws-sdk/s3-request-presigner": "^3.693.0",
"@hookform/resolvers": "^5.2.2",
"@lifi/sdk": "^3.16.0",
"@lifi/wallet-management": "^3.22.7",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-aspect-ratio": "^1.1.7",
@ -42,7 +44,7 @@
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.2",
"@tanstack/react-query": "^5.90.21",
"@trpc/client": "^11.6.0",
"@trpc/react-query": "^11.6.0",
"@trpc/server": "^11.6.0",
@ -79,6 +81,7 @@
"tronweb": "^6.2.2",
"vaul": "^1.1.2",
"viem": "^2.47.0",
"wagmi": "^3.5.0",
"wouter": "^3.3.5",
"zod": "^4.1.12"
},

File diff suppressed because it is too large Load Diff

View File

@ -38,7 +38,7 @@ const RPC_POOLS = {
// ─── Contract Addresses ────────────────────────────────────────────────────────
export const CONTRACTS = {
BSC: {
presale: "0xc65e7a2738ed884db8d26a6eb2fecf7daca2e90c",
presale: "0x5953c025dA734e710886916F2d739A3A78f8bbc4", // XICPresale v2
token: "0x59ff34dd59680a7125782b1f6df2a86ed46f5a24",
rpc: RPC_POOLS.BSC[0],
chainId: 56,

View File

@ -5,7 +5,7 @@ import { publicProcedure, protectedProcedure, router } from "./_core/trpc";
import { getCombinedStats, getPresaleStats } from "./onchain";
import { getRecentPurchases } from "./trc20Monitor";
import { getDb } from "./db";
import { trc20Purchases, trc20Intents } from "../drizzle/schema";
import { trc20Purchases, trc20Intents, bridgeOrders } from "../drizzle/schema";
import { eq, desc, sql } from "drizzle-orm";
import { z } from "zod";
import { TRPCError } from "@trpc/server";
@ -15,8 +15,102 @@ import { getAllConfig, getConfig, setConfig, seedDefaultConfig, DEFAULT_CONFIG }
// Admin password from env (fallback for development)
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || "NACadmin2026!";
// ─── Bridge Router ───────────────────────────────────────────────────────────
const bridgeRouter = router({
// Record a completed Li.Fi cross-chain order
recordOrder: publicProcedure
.input(z.object({
txHash: z.string().min(1).max(128),
walletAddress: z.string().min(1).max(64),
fromChainId: z.number().int(),
fromToken: z.string().max(32),
fromAmount: z.string(),
toChainId: z.number().int(),
toToken: z.string().max(32),
toAmount: z.string(),
}))
.mutation(async ({ input }) => {
const db = await getDb();
if (!db) return { success: false, message: "DB unavailable" };
try {
await db.insert(bridgeOrders).values({
txHash: input.txHash,
walletAddress: input.walletAddress,
fromChainId: input.fromChainId,
fromToken: input.fromToken,
fromAmount: input.fromAmount,
toChainId: input.toChainId,
toToken: input.toToken,
toAmount: input.toAmount,
status: "completed",
});
return { success: true };
} catch (e: any) {
if (e?.code === "ER_DUP_ENTRY") return { success: true };
throw e;
}
}),
// 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) }))
.query(async ({ input }) => {
const db = await getDb();
if (!db) return [];
const rows = await db
.select()
.from(bridgeOrders)
.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,
}));
}),
});
export const appRouter = router({
system: systemRouter,
bridge: bridgeRouter,
auth: router({
me: publicProcedure.query(opts => opts.ctx.user),
logout: publicProcedure.mutation(({ ctx }) => {

113
test-onchain.mjs Normal file
View File

@ -0,0 +1,113 @@
/**
* 测试链上数据读取
* 直接调用BSC和ETH合约查看能读到哪些数据
*/
import { ethers } from "ethers";
const BSC_PRESALE = "0xc65e7a2738ed884db8d26a6eb2fecf7daca2e90c";
const ETH_PRESALE = "0x85AB2F2d9f7ca7ecB272b5E8726c70f3fd45D1E3";
// 尝试多种可能的函数名
const TEST_ABI = [
"function totalUSDTRaised() view returns (uint256)",
"function totalTokensSold() view returns (uint256)",
"function weiRaised() view returns (uint256)",
"function tokensSold() view returns (uint256)",
"function usdtRaised() view returns (uint256)",
"function totalRaised() view returns (uint256)",
"function amountRaised() view returns (uint256)",
"function hardCap() view returns (uint256)",
"function cap() view returns (uint256)",
"function owner() view returns (address)",
"function paused() view returns (bool)",
];
async function testContract(name, address, rpcUrl) {
console.log(`\n=== 测试 ${name} 合约 ===`);
console.log(`地址: ${address}`);
console.log(`RPC: ${rpcUrl}`);
try {
const provider = new ethers.JsonRpcProvider(rpcUrl, undefined, {
staticNetwork: true,
polling: false,
});
// 先检查合约是否存在
const code = await provider.getCode(address);
if (code === "0x") {
console.log("❌ 该地址没有合约代码!合约地址可能错误。");
return;
}
console.log(`✅ 合约存在,字节码长度: ${code.length} 字符`);
const contract = new ethers.Contract(address, TEST_ABI, provider);
// 逐个测试函数
const functions = [
"totalUSDTRaised",
"totalTokensSold",
"weiRaised",
"tokensSold",
"usdtRaised",
"totalRaised",
"amountRaised",
"hardCap",
"cap",
"owner",
"paused",
];
for (const fn of functions) {
try {
const result = await contract[fn]();
console.log(`${fn}() = ${result}`);
} catch (e) {
console.log(`${fn}() 不存在或调用失败`);
}
}
// 获取合约事件日志最近100个块
const latestBlock = await provider.getBlockNumber();
console.log(`\n当前区块高度: ${latestBlock}`);
// 查找Transfer事件USDT转入
const usdtAddress = name === "BSC"
? "0x55d398326f99059fF775485246999027B3197955"
: "0xdAC17F958D2ee523a2206206994597C13D831ec7";
const usdtAbi = ["event Transfer(address indexed from, address indexed to, uint256 value)"];
const usdtContract = new ethers.Contract(usdtAddress, usdtAbi, provider);
console.log(`\n查询最近1000个块内转入预售合约的USDT...`);
const fromBlock = latestBlock - 1000;
const filter = usdtContract.filters.Transfer(null, address);
try {
const events = await usdtContract.queryFilter(filter, fromBlock, latestBlock);
console.log(`找到 ${events.length} 笔USDT转入记录`);
let totalUsdt = 0n;
for (const event of events.slice(-5)) {
const args = event.args;
const amount = args[2];
totalUsdt += amount;
const decimals = name === "BSC" ? 18 : 6;
console.log(` ${args[0]}${ethers.formatUnits(amount, decimals)} USDT`);
}
} catch (e) {
console.log(`查询事件失败: ${e}`);
}
} catch (e) {
console.error(`测试失败: ${e}`);
}
}
// 测试BSC
await testContract("BSC", BSC_PRESALE, "https://bsc-dataseed1.binance.org/");
// 测试ETH
await testContract("ETH", ETH_PRESALE, "https://eth.llamarpc.com");
console.log("\n=== 测试完成 ===");

81
todo.md
View File

@ -71,3 +71,84 @@
- [x] 创建 WalletSelector 组件MetaMask、Trust Wallet、OKX、Coinbase、TokenPocket 检测+连接+安装引导)
- [x] 集成 WalletSelector 到 TRON 标签 XIC 接收地址区域- [x] 集成 WalletSelector 到 BSC/ETH 购买面板替换原 Connect Wallet 按鈕钮
- [x] 构建并部署到备份服务器
## v8 UI设计错误修复
- [ ] 修复图1钱包选择器弹窗同时显示"已安装"和"未安装"钱包,界面混乱 → 有已安装钱包时隐藏未安装列表
- [ ] 修复图2点击钱包后选择器面板折叠缩小 → 连接中状态应保持面板展开显示loading状态
- [ ] 修复图3"添加XIC到钱包"按钮在未连接钱包时显示并报错 → 未连接时隐藏该按钮
- [ ] 构建并部署到备份服务器
- [ ] 同步到Gitea代码库
## v9 跨链桥 /bridge 页面
- [x] 安装 @lifi/sdk 依赖使用SDK替代Widget避免@mysten/sui冲突
- [x] 创建 Bridge.tsx 页面组件(深色主题,与预售网站风格一致)
- [x] 集成 Li.Fi API锁定目标链 BSC + 目标代币 XIC
- [x] 在 App.tsx 注册 /bridge 路由
- [x] 导航栏添加 Bridge 入口链接(⚡ Bridge 黄色高亮按钮)
- [x] 后端添加跨链订单记录bridge_orders 表)
- [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条以上"(支持链数量)
## v11 Bridge增强功能
- [ ] Gas费估算显示在"YOU RECEIVE"区域下方显示预估Gas费源链原生代币和预计到账时间
- [ ] Gas费说明文案说明Gas用源链原生代币支付BSC用BNBETH用ETHPolygon用MATIC等
- [ ] 交易历史"复制交易哈希"快捷按钮
- [ ] 交易历史"在区块浏览器中查看"快捷按钮
- [ ] 交易成功弹窗提示(附查看交易详情链接)
- [ ] 浏览器全流程测试
- [ ] 构建并部署到AI服务器
- [ ] 记录部署日志
## v11 钱包连接卡死修复(来自用户反馈)
- [ ] 修复WalletSelector连接卡死连接超时30s自动重置状态
- [ ] 修复用户取消钱包弹窗后状态不重置error code 4001/4100处理
- [ ] 修复连接成功后回调不触发accounts事件监听改为直接返回值处理
- [ ] 确保每次点击钱包按钮都能重新触发钱包弹窗
## v12 Bridge跨链桥完善 + 钱包连接深度修复
- [ ] WalletSelector v5ErrorHelpPanel组件分类错误处理+MetaMask权限重置5步指南
- [ ] WalletSelector v5连接中状态改为"等待钱包授权"提示
- [ ] WalletSelector v5错误后显示"重试"按钮和其他可用钱包
- [ ] Bridge页面确认所有链(BSC/ETH/Polygon/Arbitrum/Avalanche)USDT→XIC路由逻辑
- [ ] Bridge页面Gas费说明每条链原生代币BNB/ETH/MATIC/ETH/AVAX
- [ ] 构建v12并部署到AI服务器(43.224.155.27)
- [ ] 同步代码到备份Git库(git.newassetchain.io)
- [ ] 记录部署日志

View File

@ -168,6 +168,9 @@ export default defineConfig({
outDir: path.resolve(import.meta.dirname, "dist/public"),
emptyOutDir: true,
},
optimizeDeps: {
exclude: ["@mysten/sui", "@mysten/wallet-standard", "@solana/web3.js", "@solana/wallet-adapter-base"],
},
server: {
host: true,
allowedHosts: [