Compare commits

...

6 Commits

Author SHA1 Message Date
Manus 158822556f Fix: WalletSelector v2 - 改进钱包检测时序、添加刷新按钮、手动地址输入回退、错误码精准处理(user_rejected/wallet_pending);三个域名(pre-sale/ico/trc-ico.newassetchain.io)全部部署到AI服务器43.224.155.27,DNS解析已生效 2026-03-09 05:43:49 -04:00
Manus 81c9b2544f Checkpoint: v7: 多钱包列表选择器(MetaMask/Trust Wallet/OKX/Coinbase/TokenPocket 检测+连接+安装引导),集成到 BSC/ETH 面板和 TRON 面板;重写 useWallet hook 支持所有主流 EVM 钱包自动识别;文案"EVM 地址"→"XIC 接收地址";后台一键开启/关闭预售功能;已部署到 pre-sale.newassetchain.io 2026-03-09 01:34:56 -04:00
Manus 809b6327b8 Checkpoint: v6: 添加 TronLink 钱包检测功能(TRON 标签显示连接按钮/安装引导),确认 BSC/ETH/XIC 合约地址正确,构建并部署到 pre-sale.newassetchain.io 2026-03-08 19:23:48 -04:00
Manus 4e5743512c Checkpoint: Fix: Added "Connect MetaMask to auto-fill" button in TRC20Panel. When user has MetaMask installed but not connected, a blue button appears above the EVM address input. Clicking it triggers eth_requestAccounts popup and auto-fills the address. Also improved auto-sync when wallet.address changes. Deployed to pre-sale.newassetchain.io. 2026-03-08 04:35:21 -04:00
Manus 133aaedb68 Checkpoint: Fix: useWallet hook now auto-detects already-connected EVM wallets on page load using eth_accounts (silent, no popup). When user has MetaMask connected, the EVM address is automatically populated in the TRC20 panel. Also added isConnected: true to accountsChanged handler. 2026-03-08 04:11:31 -04:00
Manus 40be4636e9 Checkpoint: v5 完整功能升级:
1. 修复钱包连接状态共享问题(useWallet提升到Home顶层)
2. 配置BSC/ETH多节点RPC故障转移池(9+7个节点)
3. 添加TRC20购买Telegram通知(Bot Token/Chat ID通过管理后台配置)
4. 管理员后台新增Site Settings标签页(预售参数、首页内容、Telegram配置)
5. 修复Admin.tsx语法错误
2026-03-08 03:45:55 -04:00
15 changed files with 2389 additions and 131 deletions

View File

@ -0,0 +1,522 @@
// NAC XIC Presale — Wallet Selector Component
// Detects installed EVM wallets and shows connect/install buttons for each
// v2: improved detection timing, refresh button, manual address fallback
import { useState, useEffect, useCallback } from "react";
type Lang = "zh" | "en";
interface WalletInfo {
id: string;
name: string;
icon: React.ReactNode;
installUrl: string;
isInstalled: () => boolean;
connect: () => Promise<string | null>;
}
interface WalletSelectorProps {
lang: Lang;
onAddressDetected: (address: string) => void;
connectedAddress?: string;
compact?: boolean; // compact mode for BSC/ETH panel
}
// ── Wallet Icons ──────────────────────────────────────────────────────────────
const MetaMaskIcon = () => (
<svg width="24" height="24" viewBox="0 0 35 33" fill="none">
<path d="M32.96 1L19.4 10.7l2.5-5.9L32.96 1z" fill="#E17726" stroke="#E17726" strokeWidth="0.25" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M2.04 1l13.46 9.8-2.38-5.99L2.04 1z" fill="#E27625" stroke="#E27625" strokeWidth="0.25" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M28.22 23.53l-3.61 5.53 7.73 2.13 2.22-7.54-6.34-.12z" fill="#E27625" stroke="#E27625" strokeWidth="0.25" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M.44 23.65l2.2 7.54 7.72-2.13-3.6-5.53-6.32.12z" fill="#E27625" stroke="#E27625" strokeWidth="0.25" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M9.97 14.46l-2.16 3.26 7.69.35-.26-8.27-5.27 4.66z" fill="#E27625" stroke="#E27625" strokeWidth="0.25" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M25.03 14.46l-5.35-4.75-.17 8.36 7.68-.35-2.16-3.26z" fill="#E27625" stroke="#E27625" strokeWidth="0.25" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M10.36 29.06l4.63-2.24-3.99-3.11-.64 5.35z" fill="#E27625" stroke="#E27625" strokeWidth="0.25" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M20.01 26.82l4.63 2.24-.64-5.35-3.99 3.11z" fill="#E27625" stroke="#E27625" strokeWidth="0.25" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
const TrustWalletIcon = () => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="12" fill="#3375BB"/>
<path d="M12 4.5L6 7.5v5c0 3.31 2.57 6.41 6 7.5 3.43-1.09 6-4.19 6-7.5v-5L12 4.5z" fill="white" fillOpacity="0.9"/>
<path d="M10.5 12.5l1.5 1.5 3-3" stroke="#3375BB" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
const OKXIcon = () => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<rect width="24" height="24" rx="6" fill="#000"/>
<rect x="4" y="4" width="6" height="6" rx="1" fill="white"/>
<rect x="14" y="4" width="6" height="6" rx="1" fill="white"/>
<rect x="4" y="14" width="6" height="6" rx="1" fill="white"/>
<rect x="14" y="14" width="6" height="6" rx="1" fill="white"/>
<rect x="9" y="9" width="6" height="6" rx="1" fill="white"/>
</svg>
);
const CoinbaseIcon = () => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="12" fill="#0052FF"/>
<circle cx="12" cy="12" r="7" fill="white"/>
<rect x="9" y="10.5" width="6" height="3" rx="1.5" fill="#0052FF"/>
</svg>
);
const TokenPocketIcon = () => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<rect width="24" height="24" rx="6" fill="#2980FE"/>
<path d="M7 8h5a3 3 0 0 1 0 6H7V8z" fill="white"/>
<rect x="7" y="15" width="2.5" height="3" rx="1" fill="white"/>
</svg>
);
const BitgetIcon = () => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<rect width="24" height="24" rx="6" fill="#00F0FF"/>
<path d="M7 8h5a3 3 0 0 1 0 6H7V8z" fill="#000"/>
<path d="M12 14h2a3 3 0 0 1 0 6h-2v-6z" fill="#000"/>
</svg>
);
// ── 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>;
};
function getEth(): EthProvider | null {
if (typeof window === "undefined") return null;
return (window as unknown as { ethereum?: EthProvider }).ethereum ?? null;
}
function getOKX(): EthProvider | null {
if (typeof window === "undefined") return null;
return (window as unknown as { okxwallet?: EthProvider }).okxwallet ?? null;
}
function getBitget(): EthProvider | null {
if (typeof window === "undefined") return null;
const w = window as unknown as { bitkeep?: { ethereum?: EthProvider } };
return w.bitkeep?.ethereum ?? 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;
if (eth.providers && Array.isArray(eth.providers)) {
return eth.providers.find(predicate) ?? null;
}
return predicate(eth) ? eth : null;
}
async function requestAccounts(provider: EthProvider): Promise<string | null> {
try {
const accounts = await provider.request({ method: "eth_requestAccounts" }) as string[];
return accounts?.[0] ?? null;
} catch (err: unknown) {
const error = err as { code?: number; message?: string };
// User rejected
if (error?.code === 4001) throw new Error("user_rejected");
// MetaMask not initialized / locked
if (error?.code === -32002) throw new Error("wallet_pending");
throw err;
}
}
// Check if MetaMask is installed but not yet initialized (no accounts, no unlock)
async function isWalletInitialized(provider: EthProvider): Promise<boolean> {
try {
const accounts = await provider.request({ method: "eth_accounts" }) as string[];
// If we can get accounts (even empty array), wallet is initialized
return true;
} catch {
return false;
}
}
// ── Wallet definitions ────────────────────────────────────────────────────────
function buildWallets(): WalletInfo[] {
return [
{
id: "metamask",
name: "MetaMask",
icon: <MetaMaskIcon />,
installUrl: "https://metamask.io/download/",
isInstalled: () => !!findProvider(p => !!p.isMetaMask),
connect: async () => {
const p = findProvider(p => !!p.isMetaMask) ?? getEth();
return p ? requestAccounts(p) : null;
},
},
{
id: "trust",
name: "Trust Wallet",
icon: <TrustWalletIcon />,
installUrl: "https://trustwallet.com/download",
isInstalled: () => !!findProvider(p => !!(p.isTrust || p.isTrustWallet)),
connect: async () => {
const p = findProvider(p => !!(p.isTrust || p.isTrustWallet)) ?? getEth();
return p ? requestAccounts(p) : null;
},
},
{
id: "okx",
name: "OKX Wallet",
icon: <OKXIcon />,
installUrl: "https://www.okx.com/web3",
isInstalled: () => !!(getOKX() || findProvider(p => !!(p.isOKExWallet || p.isOkxWallet))),
connect: async () => {
const p = getOKX() ?? findProvider(p => !!(p.isOKExWallet || p.isOkxWallet));
return p ? requestAccounts(p) : null;
},
},
{
id: "coinbase",
name: "Coinbase Wallet",
icon: <CoinbaseIcon />,
installUrl: "https://www.coinbase.com/wallet/downloads",
isInstalled: () => !!findProvider(p => !!p.isCoinbaseWallet),
connect: async () => {
const p = findProvider(p => !!p.isCoinbaseWallet) ?? getEth();
return p ? requestAccounts(p) : null;
},
},
{
id: "tokenpocket",
name: "TokenPocket",
icon: <TokenPocketIcon />,
installUrl: "https://www.tokenpocket.pro/en/download/app",
isInstalled: () => !!findProvider(p => !!p.isTokenPocket),
connect: async () => {
const p = findProvider(p => !!p.isTokenPocket) ?? getEth();
return p ? requestAccounts(p) : null;
},
},
{
id: "bitget",
name: "Bitget Wallet",
icon: <BitgetIcon />,
installUrl: "https://web3.bitget.com/en/wallet-download",
isInstalled: () => !!(getBitget() || findProvider(p => !!(p.isBitkeep || p.isBitgetWallet))),
connect: async () => {
const p = getBitget() ?? findProvider(p => !!(p.isBitkeep || p.isBitgetWallet));
return p ? requestAccounts(p) : null;
},
},
];
}
// Validate Ethereum address format
function isValidEthAddress(addr: string): boolean {
return /^0x[0-9a-fA-F]{40}$/.test(addr);
}
// ── WalletSelector Component ──────────────────────────────────────────────────
export function WalletSelector({ lang, onAddressDetected, connectedAddress, compact = false }: WalletSelectorProps) {
const [wallets, setWallets] = useState<WalletInfo[]>([]);
const [connecting, setConnecting] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [detecting, setDetecting] = useState(true);
const [showManual, setShowManual] = useState(false);
const [manualAddress, setManualAddress] = useState("");
const [manualError, setManualError] = useState<string | null>(null);
const detectWallets = useCallback(() => {
setDetecting(true);
setError(null);
// Wait for wallet extensions to fully inject (up to 1500ms)
const timer = setTimeout(() => {
setWallets(buildWallets());
setDetecting(false);
}, 1500);
return () => clearTimeout(timer);
}, []);
useEffect(() => {
const cleanup = detectWallets();
return cleanup;
}, [detectWallets]);
const handleConnect = async (wallet: WalletInfo) => {
setConnecting(wallet.id);
setError(null);
try {
const address = await wallet.connect();
if (address) {
onAddressDetected(address);
} else {
setError(lang === "zh" ? "未获取到地址,请重试" : "No address returned, please try again");
}
} catch (err: unknown) {
const error = err as Error;
if (error.message === "user_rejected") {
setError(lang === "zh" ? "已取消连接" : "Connection cancelled");
} else if (error.message === "wallet_pending") {
setError(lang === "zh" ? "钱包请求处理中,请检查钱包弹窗" : "Wallet request pending, please check your wallet popup");
} else if (error.message?.includes("not initialized") || error.message?.includes("setup")) {
setError(lang === "zh"
? "请先完成钱包初始化设置,然后刷新页面重试"
: "Please complete wallet setup first, then refresh the page");
} else {
setError(lang === "zh" ? "连接失败,请重试" : "Connection failed, please try again");
}
} finally {
setConnecting(null);
}
};
const handleManualSubmit = () => {
const addr = manualAddress.trim();
if (!addr) {
setManualError(lang === "zh" ? "请输入钱包地址" : "Please enter wallet address");
return;
}
if (!isValidEthAddress(addr)) {
setManualError(lang === "zh" ? "地址格式无效请输入正确的以太坊地址0x开头42位" : "Invalid address format. Must be 0x followed by 40 hex characters");
return;
}
setManualError(null);
onAddressDetected(addr);
};
const installedWallets = wallets.filter(w => w.isInstalled());
const notInstalledWallets = wallets.filter(w => !w.isInstalled());
// If connected address is already set, show compact confirmation
if (connectedAddress) {
return (
<div
className="rounded-xl p-3 flex items-center gap-3"
style={{ background: "rgba(0,230,118,0.08)", border: "1px solid rgba(0,230,118,0.25)" }}
>
<div className="w-2 h-2 rounded-full bg-green-400 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-xs text-green-400 font-semibold">
{lang === "zh" ? "钱包已连接" : "Wallet Connected"}
</p>
<p className="text-xs text-white/50 font-mono truncate">{connectedAddress}</p>
</div>
</div>
);
}
return (
<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"}
</p>
{/* Refresh detection button */}
<button
onClick={detectWallets}
disabled={detecting}
className="flex items-center gap-1 text-xs px-2 py-1 rounded-lg transition-all hover:opacity-80"
style={{ background: "rgba(0,212,255,0.1)", color: "rgba(0,212,255,0.7)", border: "1px solid rgba(0,212,255,0.2)" }}
title={lang === "zh" ? "重新检测钱包" : "Re-detect wallets"}
>
<svg
width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
className={detecting ? "animate-spin" : ""}
>
<path d="M23 4v6h-6M1 20v-6h6"/>
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
</svg>
{detecting
? (lang === "zh" ? "检测中..." : "Detecting...")
: (lang === "zh" ? "刷新" : "Refresh")}
</button>
</div>
{/* Loading state */}
{detecting && (
<div className="flex items-center justify-center py-4 gap-2">
<svg className="animate-spin w-4 h-4 text-white/40" 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>
<span className="text-xs text-white/40">
{lang === "zh" ? "正在检测钱包..." : "Detecting wallets..."}
</span>
</div>
)}
{/* Installed wallets */}
{!detecting && installedWallets.length > 0 && (
<div className="space-y-2">
{installedWallets.map(wallet => (
<button
key={wallet.id}
onClick={() => handleConnect(wallet)}
disabled={connecting === wallet.id}
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(0,212,255,0.08)",
border: "1px solid rgba(0,212,255,0.3)",
}}
>
<span className="flex-shrink-0">{wallet.icon}</span>
<span className="flex-1 text-left text-sm font-semibold text-white">{wallet.name}</span>
<span
className="text-xs px-2 py-0.5 rounded-full font-medium"
style={{ background: "rgba(0,212,255,0.15)", color: "#00d4ff" }}
>
{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>
)}
</button>
))}
</div>
)}
{/* No wallets installed */}
{!detecting && 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)" }}
>
<p className="text-sm text-white/50 mb-1">
{lang === "zh" ? "未检测到 EVM 钱包" : "No EVM wallet detected"}
</p>
<p className="text-xs text-white/30 mb-3">
{lang === "zh"
? "请安装以下任一钱包,完成设置后点击上方「刷新」按钮"
: "Install any wallet below, then click Refresh above after setup"}
</p>
<p className="text-xs text-amber-400/70">
{lang === "zh"
? "💡 已安装MetaMask请先完成钱包初始化创建或导入钱包再点击刷新"
: "💡 Have MetaMask? Complete wallet setup (create or import) first, then click Refresh"}
</p>
</div>
)}
{/* Not-installed wallets — show install links */}
{!detecting && !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)"}
</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 */}
{!detecting && compact && notInstalledWallets.length > 0 && installedWallets.length === 0 && (
<div className="flex flex-wrap gap-2">
{notInstalledWallets.slice(0, 4).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"
style={{
background: "rgba(255,255,255,0.05)",
border: "1px solid rgba(255,255,255,0.1)",
color: "rgba(255,255,255,0.4)",
}}
>
<span className="opacity-50">{wallet.icon}</span>
{lang === "zh" ? `安装 ${wallet.name}` : `Install ${wallet.name}`}
</a>
))}
</div>
)}
{error && (
<p className="text-xs text-red-400 text-center">{error}</p>
)}
{/* Manual address input — divider */}
<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 开头)"
: "Enter your EVM wallet address (starts with 0x)"}
</p>
<div className="flex gap-2">
<input
type="text"
value={manualAddress}
onChange={e => { setManualAddress(e.target.value); setManualError(null); }}
placeholder={lang === "zh" ? "0x..." : "0x..."}
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>
)}
</div>
)}
</div>
</div>
);
}

View File

@ -1,7 +1,8 @@
// NAC XIC Presale — Wallet Connection Hook
// Supports MetaMask / any EVM-compatible wallet (BSC + ETH)
// Supports MetaMask, Trust Wallet, OKX Wallet, Coinbase Wallet, and all EVM-compatible wallets
// Robust auto-detect with retry, multi-provider support, and graceful fallback
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useCallback, useRef } from "react";
import { BrowserProvider, JsonRpcSigner, Eip1193Provider } from "ethers";
import { shortenAddress, switchToNetwork } from "@/lib/contracts";
@ -29,51 +30,124 @@ const INITIAL_STATE: WalletState = {
error: null,
};
// Detect the best available EVM provider across all major wallets
function detectProvider(): Eip1193Provider | null {
if (typeof window === "undefined") return null;
// Check for multiple injected providers (e.g., MetaMask + Coinbase both installed)
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) {
// Prefer MetaMask if available, otherwise use first provider
const metamask = eth.providers.find((p: Eip1193Provider & { isMetaMask?: boolean }) => p.isMetaMask);
return metamask ?? eth.providers[0];
}
return eth;
}
// Build wallet state from a provider and accounts
async function buildWalletState(
rawProvider: Eip1193Provider,
address: string
): Promise<Partial<WalletState>> {
const provider = new BrowserProvider(rawProvider);
let chainId: number | null = null;
let signer: JsonRpcSigner | null = null;
try {
const network = await provider.getNetwork();
chainId = Number(network.chainId);
} catch {
// Some wallets don't support getNetwork immediately — try eth_chainId directly
try {
const chainHex = await (rawProvider as { request: (args: { method: string }) => Promise<string> }).request({ method: "eth_chainId" });
chainId = parseInt(chainHex, 16);
} catch {
chainId = null;
}
}
try {
signer = await provider.getSigner();
} catch {
// getSigner may fail on some wallets before full connection — that's OK
signer = null;
}
return {
address,
shortAddress: shortenAddress(address),
isConnected: true,
chainId,
provider,
signer,
isConnecting: false,
error: null,
};
}
export function useWallet() {
const [state, setState] = useState<WalletState>(INITIAL_STATE);
const retryRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const mountedRef = useRef(true);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
if (retryRef.current) clearTimeout(retryRef.current);
};
}, []);
// ── Connect (explicit user action) ─────────────────────────────────────────
const connect = useCallback(async () => {
if (!window.ethereum) {
setState(s => ({ ...s, error: "Please install MetaMask or a compatible wallet." }));
const rawProvider = detectProvider();
if (!rawProvider) {
setState(s => ({ ...s, error: "请安装 MetaMask 或其他 EVM 兼容钱包 / Please install MetaMask or a compatible wallet." }));
return;
}
setState(s => ({ ...s, isConnecting: true, error: null }));
try {
const provider = new BrowserProvider(window.ethereum as Eip1193Provider);
const accounts = await provider.send("eth_requestAccounts", []);
const network = await provider.getNetwork();
const signer = await provider.getSigner();
const address = accounts[0] as string;
setState({
address,
shortAddress: shortenAddress(address),
isConnected: true,
chainId: Number(network.chainId),
provider,
signer,
isConnecting: false,
error: null,
// Request accounts — this triggers the wallet popup
const accounts = await (rawProvider as { request: (args: { method: string; params?: unknown[] }) => Promise<string[]> }).request({
method: "eth_requestAccounts",
params: [],
});
if (!accounts || accounts.length === 0) throw new Error("No accounts returned");
const partial = await buildWalletState(rawProvider, accounts[0]);
if (mountedRef.current) setState({ ...INITIAL_STATE, ...partial });
} catch (err: unknown) {
setState(s => ({
...s,
isConnecting: false,
error: (err as Error).message || "Failed to connect wallet",
}));
const msg = (err as Error).message || "Failed to connect wallet";
if (mountedRef.current) setState(s => ({ ...s, isConnecting: false, error: msg }));
}
}, []);
// ── Disconnect ──────────────────────────────────────────────────────────────
const disconnect = useCallback(() => {
setState(INITIAL_STATE);
}, []);
// ── Switch Network ──────────────────────────────────────────────────────────
const switchNetwork = useCallback(async (chainId: number) => {
try {
await switchToNetwork(chainId);
if (window.ethereum) {
const provider = new BrowserProvider(window.ethereum as Eip1193Provider);
const rawProvider = detectProvider();
if (rawProvider) {
const provider = new BrowserProvider(rawProvider);
const network = await provider.getNetwork();
const signer = await provider.getSigner();
let signer: JsonRpcSigner | null = null;
try { signer = await provider.getSigner(); } catch { /* ignore */ }
if (mountedRef.current) {
setState(s => ({
...s,
chainId: Number(network.chainId),
@ -82,34 +156,120 @@ export function useWallet() {
error: null,
}));
}
}
} catch (err: unknown) {
setState(s => ({ ...s, error: (err as Error).message }));
if (mountedRef.current) setState(s => ({ ...s, error: (err as Error).message }));
}
}, []);
// Listen for account/chain changes
// ── Auto-detect on page load (silent, no popup) ─────────────────────────────
useEffect(() => {
if (!window.ethereum) return;
const handleAccountsChanged = (accounts: unknown) => {
let cancelled = false;
const tryAutoDetect = async (attempt: number) => {
if (cancelled) return;
const rawProvider = detectProvider();
if (!rawProvider) {
// Wallet extension may not be injected yet — retry up to 3 times
if (attempt < 3) {
retryRef.current = setTimeout(() => tryAutoDetect(attempt + 1), 800 * attempt);
}
return;
}
try {
const accounts = await (rawProvider as { request: (args: { method: string }) => Promise<string[]> }).request({
method: "eth_accounts", // Silent — no popup
});
if (cancelled) return;
if (accounts && accounts.length > 0) {
const partial = await buildWalletState(rawProvider, accounts[0]);
if (!cancelled && mountedRef.current) {
setState({ ...INITIAL_STATE, ...partial });
}
} else if (attempt < 3) {
// Accounts empty — wallet might not have finished loading, retry
retryRef.current = setTimeout(() => tryAutoDetect(attempt + 1), 1000 * attempt);
}
} catch {
// Silently ignore — user hasn't connected yet
}
};
// Small initial delay to let wallet extensions inject themselves
retryRef.current = setTimeout(() => tryAutoDetect(1), 300);
return () => {
cancelled = true;
if (retryRef.current) clearTimeout(retryRef.current);
};
}, []);
// ── Listen for account / chain changes ─────────────────────────────────────
useEffect(() => {
const rawProvider = detectProvider();
if (!rawProvider) return;
const eth = rawProvider as {
on?: (event: string, handler: (data: unknown) => void) => void;
removeListener?: (event: string, handler: (data: unknown) => void) => void;
};
if (!eth.on) return;
const handleAccountsChanged = async (accounts: unknown) => {
const accs = accounts as string[];
if (accs.length === 0) {
if (!mountedRef.current) return;
if (!accs || accs.length === 0) {
setState(INITIAL_STATE);
} else {
// Re-build full state with new address
try {
const partial = await buildWalletState(rawProvider, accs[0]);
if (mountedRef.current) setState({ ...INITIAL_STATE, ...partial });
} catch {
if (mountedRef.current) {
setState(s => ({
...s,
address: accs[0],
shortAddress: shortenAddress(accs[0]),
isConnected: true,
}));
}
}
}
};
const handleChainChanged = () => {
const handleChainChanged = async () => {
if (!mountedRef.current) return;
// Re-fetch network info instead of reloading the page
try {
const provider = new BrowserProvider(rawProvider);
const network = await provider.getNetwork();
let signer: JsonRpcSigner | null = null;
try { signer = await provider.getSigner(); } catch { /* ignore */ }
if (mountedRef.current) {
setState(s => ({
...s,
chainId: Number(network.chainId),
provider,
signer,
}));
}
} catch {
// If we can't get network info, reload as last resort
window.location.reload();
}
};
window.ethereum.on("accountsChanged", handleAccountsChanged);
window.ethereum.on("chainChanged", handleChainChanged);
eth.on("accountsChanged", handleAccountsChanged);
eth.on("chainChanged", handleChainChanged);
return () => {
window.ethereum?.removeListener("accountsChanged", handleAccountsChanged);
window.ethereum?.removeListener("chainChanged", handleChainChanged);
if (eth.removeListener) {
eth.removeListener("accountsChanged", handleAccountsChanged);
eth.removeListener("chainChanged", handleChainChanged);
}
};
}, []);

View File

@ -119,13 +119,316 @@ function LoginForm({ onLogin }: { onLogin: (token: string) => void }) {
);
}
// ─── Main Dashboard ───────────────────────────────────────────────────────────
// ─── Main D// ─── Settings Panel ───────────────────────────────────────────────
function SettingsPanel({ token }: { token: string }) {
const { data: configData, refetch: refetchConfig, isLoading } = trpc.admin.getConfig.useQuery({ token });
const [editValues, setEditValues] = useState<Record<string, string>>({});
const [savingKey, setSavingKey] = useState<string | null>(null);
const [savedKeys, setSavedKeys] = useState<Set<string>>(new Set());
const [telegramBotToken, setTelegramBotToken] = useState("");
const [telegramChatId, setTelegramChatId] = useState("");
const [telegramStatus, setTelegramStatus] = useState<"idle" | "testing" | "success" | "error">("idle");
const [telegramError, setTelegramError] = useState("");
// ── Presale Active/Paused Toggle ──────────────────────────────────────────
const isPresaleLive = (configData?.find(c => c.key === "presaleStatus")?.value ?? "live") === "live";
const [togglingPresale, setTogglingPresale] = useState(false);
const setConfigMutation = trpc.admin.setConfig.useMutation({
onSuccess: (_, vars) => {
setSavedKeys(prev => { const s = new Set(Array.from(prev)); s.add(vars.key); return s; });
setSavingKey(null);
setTogglingPresale(false);
refetchConfig();
setTimeout(() => setSavedKeys(prev => { const n = new Set(Array.from(prev)); n.delete(vars.key); return n; }), 2000);
},
onError: (err) => {
setSavingKey(null);
setTogglingPresale(false);
alert(`Save failed: ${err.message}`);
},
});
const handleTogglePresale = () => {
const newStatus = isPresaleLive ? "paused" : "live";
setTogglingPresale(true);
setConfigMutation.mutate({ token, key: "presaleStatus", value: newStatus });
};
const testTelegramMutation = trpc.admin.testTelegram.useMutation({
onSuccess: () => {
setTelegramStatus("success");
refetchConfig();
},
onError: (err) => {
setTelegramStatus("error");
setTelegramError(err.message);
},
});
// Initialize edit values from config
useEffect(() => {
if (configData) {
const vals: Record<string, string> = {};
configData.forEach(c => { vals[c.key] = c.value; });
setEditValues(vals);
// Pre-fill Telegram fields
const botToken = configData.find(c => c.key === "telegramBotToken")?.value || "";
const chatId = configData.find(c => c.key === "telegramChatId")?.value || "";
if (botToken) setTelegramBotToken(botToken);
if (chatId) setTelegramChatId(chatId);
}
}, [configData]);
const handleSave = (key: string) => {
setSavingKey(key);
setConfigMutation.mutate({ token, key, value: editValues[key] || "" });
};
const handleTestTelegram = () => {
if (!telegramBotToken || !telegramChatId) {
setTelegramStatus("error");
setTelegramError("Please enter both Bot Token and Chat ID");
return;
}
setTelegramStatus("testing");
setTelegramError("");
testTelegramMutation.mutate({ token, botToken: telegramBotToken, chatId: telegramChatId });
};
// Group configs by category
const presaleKeys = ["presaleEndDate", "tokenPrice", "hardCap", "listingPrice", "totalSupply", "maxPurchaseUsdt", "presaleStatus"];
const contentKeys = ["heroTitle", "heroSubtitle", "tronReceivingAddress"];
const telegramKeys = ["telegramBotToken", "telegramChatId"];
const renderConfigRow = (cfg: { key: string; value: string; label: string; description: string; type: string; updatedAt: Date | null }) => (
<div key={cfg.key} className="rounded-xl p-4 mb-3" style={{ background: "rgba(255,255,255,0.02)", border: "1px solid rgba(255,255,255,0.06)" }}>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-sm font-semibold text-white/80">{cfg.label}</span>
<span className="text-xs px-1.5 py-0.5 rounded" style={{ background: "rgba(255,255,255,0.06)", color: "rgba(255,255,255,0.4)" }}>{cfg.type}</span>
</div>
<p className="text-xs text-white/40 mb-2">{cfg.description}</p>
{cfg.type === "text" && cfg.key !== "heroSubtitle" ? (
<input
type="text"
value={editValues[cfg.key] ?? cfg.value}
onChange={e => setEditValues(prev => ({ ...prev, [cfg.key]: e.target.value }))}
className="w-full px-3 py-2 rounded-lg text-sm"
style={{ background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.1)", color: "white", outline: "none" }}
/>
) : cfg.key === "heroSubtitle" ? (
<textarea
value={editValues[cfg.key] ?? cfg.value}
onChange={e => setEditValues(prev => ({ ...prev, [cfg.key]: e.target.value }))}
rows={3}
className="w-full px-3 py-2 rounded-lg text-sm resize-none"
style={{ background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.1)", color: "white", outline: "none" }}
/>
) : cfg.type === "number" ? (
<input
type="number"
value={editValues[cfg.key] ?? cfg.value}
onChange={e => setEditValues(prev => ({ ...prev, [cfg.key]: e.target.value }))}
className="w-full px-3 py-2 rounded-lg text-sm"
style={{ background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.1)", color: "white", outline: "none" }}
/>
) : cfg.type === "date" ? (
<input
type="datetime-local"
value={editValues[cfg.key] ? editValues[cfg.key].replace("Z", "").slice(0, 16) : ""}
onChange={e => setEditValues(prev => ({ ...prev, [cfg.key]: e.target.value + ":00Z" }))}
className="w-full px-3 py-2 rounded-lg text-sm"
style={{ background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.1)", color: "white", outline: "none" }}
/>
) : (
<input
type="text"
value={editValues[cfg.key] ?? cfg.value}
onChange={e => setEditValues(prev => ({ ...prev, [cfg.key]: e.target.value }))}
className="w-full px-3 py-2 rounded-lg text-sm"
style={{ background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.1)", color: "white", outline: "none" }}
/>
)}
</div>
<div className="flex flex-col items-end gap-1 flex-shrink-0">
<button
onClick={() => handleSave(cfg.key)}
disabled={savingKey === cfg.key}
className="px-4 py-2 rounded-lg text-xs font-semibold transition-all whitespace-nowrap"
style={{
background: savedKeys.has(cfg.key) ? "rgba(0,230,118,0.2)" : "rgba(240,180,41,0.15)",
border: savedKeys.has(cfg.key) ? "1px solid rgba(0,230,118,0.4)" : "1px solid rgba(240,180,41,0.3)",
color: savedKeys.has(cfg.key) ? "#00e676" : "#f0b429",
}}
>
{savingKey === cfg.key ? "Saving..." : savedKeys.has(cfg.key) ? "✓ Saved" : "Save"}
</button>
{cfg.updatedAt && (
<span className="text-xs text-white/25">{new Date(cfg.updatedAt).toLocaleDateString()}</span>
)}
</div>
</div>
</div>
);
if (isLoading) {
return <div className="text-center py-12 text-white/40">Loading settings...</div>;
}
const getConfigItem = (key: string) => configData?.find(c => c.key === key);
return (
<div className="space-y-6">
{/* ── Presale Active Toggle ── */}
<div
className="rounded-2xl p-6"
style={{
background: isPresaleLive ? "rgba(0,230,118,0.06)" : "rgba(255,60,60,0.06)",
border: isPresaleLive ? "2px solid rgba(0,230,118,0.45)" : "2px solid rgba(255,60,60,0.45)",
}}
>
<div className="flex items-center justify-between gap-4 flex-wrap">
<div>
<div className="flex items-center gap-3 mb-1">
<div
className="w-3 h-3 rounded-full"
style={{
background: isPresaleLive ? "#00e676" : "#ff4444",
boxShadow: isPresaleLive ? "0 0 8px rgba(0,230,118,0.8)" : "0 0 8px rgba(255,68,68,0.8)",
animation: isPresaleLive ? "pulse 2s infinite" : "none",
}}
/>
<h3
className="text-lg font-bold"
style={{
color: isPresaleLive ? "#00e676" : "#ff6060",
fontFamily: "'Space Grotesk', sans-serif",
}}
>
{isPresaleLive ? "预售进行中 PRESALE LIVE" : "预售已暂停 PRESALE PAUSED"}
</h3>
</div>
<p className="text-xs ml-6" style={{ color: "rgba(255,255,255,0.45)" }}>
{isPresaleLive
? "用户当前可正常购买 XIC 代币。点击《暂停预售》可立即封禁所有购买入口。"
: "预售已暂停,首页购买按钮已禁用。点击《开启预售》可重新开放购买。"}
</p>
</div>
<button
onClick={handleTogglePresale}
disabled={togglingPresale}
className="flex-shrink-0 px-8 py-3 rounded-xl font-bold text-base transition-all"
style={{
background: isPresaleLive
? "linear-gradient(135deg, rgba(255,60,60,0.25) 0%, rgba(255,60,60,0.15) 100%)"
: "linear-gradient(135deg, rgba(0,230,118,0.25) 0%, rgba(0,230,118,0.15) 100%)",
border: isPresaleLive ? "1.5px solid rgba(255,60,60,0.6)" : "1.5px solid rgba(0,230,118,0.6)",
color: isPresaleLive ? "#ff6060" : "#00e676",
fontFamily: "'Space Grotesk', sans-serif",
opacity: togglingPresale ? 0.6 : 1,
cursor: togglingPresale ? "not-allowed" : "pointer",
letterSpacing: "0.03em",
}}
>
{togglingPresale
? "处理中..."
: isPresaleLive
? "⏸ 暂停预售"
: "▶ 开启预售"}
</button>
</div>
</div>
{/* Presale Parameters */}
<div className="rounded-2xl p-5" style={{ background: "rgba(255,255,255,0.02)", border: "1px solid rgba(240,180,41,0.15)" }}>
<h3 className="text-sm font-semibold mb-4" style={{ color: "#f0b429" }}>Presale Parameters </h3>
{presaleKeys.map(key => {
const cfg = getConfigItem(key);
if (!cfg) return null;
return renderConfigRow(cfg);
})}
</div>
{/* Site Content */}
<div className="rounded-2xl p-5" style={{ background: "rgba(255,255,255,0.02)", border: "1px solid rgba(0,212,255,0.15)" }}>
<h3 className="text-sm font-semibold mb-4" style={{ color: "#00d4ff" }}>Site Content </h3>
{contentKeys.map(key => {
const cfg = getConfigItem(key);
if (!cfg) return null;
return renderConfigRow(cfg);
})}
</div>
{/* Telegram Notifications */}
<div className="rounded-2xl p-5" style={{ background: "rgba(255,255,255,0.02)", border: "1px solid rgba(0,230,118,0.15)" }}>
<h3 className="text-sm font-semibold mb-1" style={{ color: "#00e676" }}>Telegram Notifications</h3>
<p className="text-xs text-white/40 mb-4">
Set up Telegram Bot to receive instant alerts when new TRC20 purchases are confirmed.
Get your Bot Token from <a href="https://t.me/BotFather" target="_blank" rel="noopener noreferrer" style={{ color: "#00d4ff" }}>@BotFather</a>.
</p>
<div className="space-y-3 mb-4">
<div>
<label className="text-xs text-white/60 block mb-1">Bot Token (from @BotFather)</label>
<input
type="text"
value={telegramBotToken}
onChange={e => setTelegramBotToken(e.target.value)}
placeholder="123456789:ABCdefGHIjklMNOpqrsTUVwxyz"
className="w-full px-3 py-2 rounded-lg text-sm font-mono"
style={{ background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.1)", color: "white", outline: "none" }}
/>
</div>
<div>
<label className="text-xs text-white/60 block mb-1">Chat ID (personal or group)</label>
<input
type="text"
value={telegramChatId}
onChange={e => setTelegramChatId(e.target.value)}
placeholder="-1001234567890 or 123456789"
className="w-full px-3 py-2 rounded-lg text-sm font-mono"
style={{ background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.1)", color: "white", outline: "none" }}
/>
</div>
</div>
<div className="flex items-center gap-3">
<button
onClick={handleTestTelegram}
disabled={telegramStatus === "testing"}
className="px-5 py-2 rounded-xl text-sm font-semibold transition-all"
style={{
background: telegramStatus === "success" ? "rgba(0,230,118,0.2)" : "rgba(0,230,118,0.1)",
border: telegramStatus === "success" ? "1px solid rgba(0,230,118,0.5)" : "1px solid rgba(0,230,118,0.3)",
color: "#00e676",
}}
>
{telegramStatus === "testing" ? "Sending test..." : telegramStatus === "success" ? "✓ Connected & Saved!" : "Test & Save Connection"}
</button>
{telegramStatus === "error" && (
<span className="text-xs text-red-400">{telegramError}</span>
)}
{telegramStatus === "success" && (
<span className="text-xs text-green-400">Test message sent! Check your Telegram.</span>
)}
</div>
<div className="mt-4 rounded-lg p-3 text-xs text-white/50" style={{ background: "rgba(255,255,255,0.03)", border: "1px solid rgba(255,255,255,0.06)" }}>
<p className="font-semibold text-white/70 mb-1">How to get Chat ID:</p>
<p>1. Start a chat with your bot (send any message)</p>
<p>2. Visit: <code className="text-cyan-400">https://api.telegram.org/bot&lt;TOKEN&gt;/getUpdates</code></p>
<p>3. Find <code className="text-cyan-400">{'{"chat":{"id": YOUR_CHAT_ID}}'}</code> in the response</p> </div>
</div>
</div>
);
}
// ─── Main Dashboard ─────────────────────────────────────────────
function Dashboard({ token, onLogout }: { token: string; onLogout: () => void }) {
const [page, setPage] = useState(1);
const [statusFilter, setStatusFilter] = useState<"all" | "pending" | "confirmed" | "distributed" | "failed">("all");
const [markingId, setMarkingId] = useState<number | null>(null);
const [distributeTxInput, setDistributeTxInput] = useState<Record<number, string>>({});
const [activeTab, setActiveTab] = useState<"purchases" | "intents">("purchases");
const [activeTab, setActiveTab] = useState<"purchases" | "intents" | "settings">("purchases");
const { data: statsData, refetch: refetchStats } = trpc.admin.stats.useQuery({ token });
const { data: intentsData, isLoading: intentsLoading } = trpc.admin.listIntents.useQuery({ token, showAll: false });
@ -294,6 +597,17 @@ function Dashboard({ token, onLogout }: { token: string; onLogout: () => void })
</span>
)}
</button>
<button
onClick={() => setActiveTab("settings")}
className="px-4 py-2 rounded-xl text-sm font-semibold transition-all"
style={{
background: activeTab === "settings" ? "rgba(0,230,118,0.15)" : "rgba(255,255,255,0.04)",
border: activeTab === "settings" ? "1px solid rgba(0,230,118,0.4)" : "1px solid rgba(255,255,255,0.08)",
color: activeTab === "settings" ? "#00e676" : "rgba(255,255,255,0.5)",
}}
>
Site Settings
</button>
</div>
{/* ── EVM Intents Table ── */}
@ -519,7 +833,13 @@ function Dashboard({ token, onLogout }: { token: string; onLogout: () => void })
</div>
)}
{/* ── Site Settings Panel ── */}
{activeTab === "settings" && (
<SettingsPanel token={token} />
)}
{/* ── Instructions ── */}
{activeTab !== "settings" && (
<div className="mt-6 rounded-2xl p-5" style={{ background: "rgba(0,212,255,0.04)", border: "1px solid rgba(0,212,255,0.15)" }}>
<h3 className="text-sm font-semibold text-cyan-400 mb-3">Distribution Workflow</h3>
<div className="space-y-2 text-sm text-white/60">
@ -530,6 +850,7 @@ function Dashboard({ token, onLogout }: { token: string; onLogout: () => void })
<p>5. <strong className="text-white/80">No EVM address?</strong> Contact buyer via Telegram/email to get their BSC address</p>
</div>
</div>
)}
</div>
</div>
);

View File

@ -11,6 +11,7 @@ import { usePresale } from "@/hooks/usePresale";
import { CONTRACTS, PRESALE_CONFIG, formatNumber, shortenAddress } from "@/lib/contracts";
import { trpc } from "@/lib/trpc";
import { type Lang, useTranslation } from "@/lib/i18n";
import { WalletSelector } from "@/components/WalletSelector";
// ─── Network Tab Types ────────────────────────────────────────────────────────
type NetworkTab = "BSC" | "ETH" | "TRON";
@ -104,18 +105,67 @@ function StepBadge({ num, text }: { num: number; text: string }) {
}
// ─── TRC20 Purchase Panel ─────────────────────────────────────────────────────
function TRC20Panel({ usdtAmount, lang }: { usdtAmount: number; lang: Lang }) {
function TRC20Panel({ usdtAmount, lang, connectedAddress, onConnectWallet }: { usdtAmount: number; lang: Lang; connectedAddress?: string; onConnectWallet?: () => void }) {
const { t } = useTranslation(lang);
const tokenAmount = usdtAmount / PRESALE_CONFIG.tokenPrice;
const [copied, setCopied] = useState(false);
const [evmAddress, setEvmAddress] = useState("");
const [evmAddress, setEvmAddress] = useState(connectedAddress || "");
const [evmAddrError, setEvmAddrError] = useState("");
const [submitted, setSubmitted] = useState(false);
// TronLink detection state
const [tronAddress, setTronAddress] = useState<string | null>(null);
const [isTronConnecting, setIsTronConnecting] = useState(false);
const hasTronLink = typeof window !== "undefined" && (!!(window as unknown as Record<string, unknown>).tronWeb || !!(window as unknown as Record<string, unknown>).tronLink);
// Auto-detect TronLink on mount
useEffect(() => {
const detectTron = async () => {
// Wait briefly for TronLink to inject
await new Promise(resolve => setTimeout(resolve, 500));
const tronWeb = (window as unknown as Record<string, unknown>).tronWeb as { defaultAddress?: { base58?: string }; ready?: boolean } | undefined;
if (tronWeb && tronWeb.ready && tronWeb.defaultAddress?.base58) {
setTronAddress(tronWeb.defaultAddress.base58);
}
};
detectTron();
}, []);
// Connect TronLink wallet
const handleConnectTronLink = async () => {
setIsTronConnecting(true);
try {
const tronLink = (window as unknown as Record<string, unknown>).tronLink as { request?: (args: { method: string }) => Promise<{ code: number }> } | undefined;
const tronWeb = (window as unknown as Record<string, unknown>).tronWeb as { defaultAddress?: { base58?: string }; ready?: boolean } | undefined;
if (tronLink?.request) {
const result = await tronLink.request({ method: 'tron_requestAccounts' });
if (result?.code === 200 && tronWeb?.defaultAddress?.base58) {
setTronAddress(tronWeb.defaultAddress.base58);
toast.success(lang === "zh" ? "TronLink已连接" : "TronLink connected!");
}
} else if (tronWeb?.ready && tronWeb.defaultAddress?.base58) {
setTronAddress(tronWeb.defaultAddress.base58);
toast.success(lang === "zh" ? "TronLink已检测到" : "TronLink detected!");
} else {
toast.error(lang === "zh" ? "请安装TronLink钱包扩展" : "Please install TronLink wallet extension");
}
} catch {
toast.error(lang === "zh" ? "连接TronLink失败" : "Failed to connect TronLink");
} finally {
setIsTronConnecting(false);
}
};
// Auto-fill EVM address whenever wallet connects or address changes (unless user already submitted)
useEffect(() => {
if (connectedAddress && !submitted) {
setEvmAddress(connectedAddress);
}
}, [connectedAddress, submitted]);
const submitTrc20Mutation = trpc.presale.registerTrc20Intent.useMutation({
onSuccess: () => {
setSubmitted(true);
toast.success(lang === "zh" ? "EVM地址已保存" : "EVM address saved!");
toast.success(lang === "zh" ? "XIC接收地址已保存" : "XIC receiving address saved!");
},
onError: (err: { message: string }) => {
toast.error(err.message);
@ -130,8 +180,8 @@ function TRC20Panel({ usdtAmount, lang }: { usdtAmount: number; lang: Lang }) {
};
const validateEvmAddress = (addr: string) => {
if (!addr) return lang === "zh" ? "请输入您的EVM地址" : "Please enter your EVM address";
if (!/^0x[0-9a-fA-F]{40}$/.test(addr)) return lang === "zh" ? "无效的EVM地址格式应以0x开头共42位" : "Invalid EVM address format (must start with 0x, 42 chars)";
if (!addr) return lang === "zh" ? "请输入您的XIC接收地址" : "Please enter your XIC receiving address";
if (!/^0x[0-9a-fA-F]{40}$/.test(addr)) return lang === "zh" ? "无效的XIC接收地址格式应以0x开头入42位" : "Invalid XIC receiving address format (must start with 0x, 42 chars)";
return "";
};
@ -149,20 +199,33 @@ function TRC20Panel({ usdtAmount, lang }: { usdtAmount: number; lang: Lang }) {
<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" ? "必填:您的EVM钱包地址用于接收XIC代币" : "Required: Your EVM Wallet Address (to receive XIC tokens)"}
{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网络上发放。请提供您的BSC/ETH地址0x开头以便我们将代币发送给您。"
: "XIC tokens are distributed on BSC. Please provide your BSC/ETH address (starts with 0x) so we can send your tokens."}
? "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">
{/* WalletSelector — shown when address not yet filled */}
{!evmAddress && !submitted && (
<WalletSelector
lang={lang}
connectedAddress={connectedAddress}
onAddressDetected={(addr) => {
setEvmAddress(addr);
setEvmAddrError("");
toast.success(lang === "zh" ? "XIC接收地址已自动填充" : "XIC receiving address auto-filled!");
if (onConnectWallet) onConnectWallet();
}}
/>
)}
<input
type="text"
value={evmAddress}
onChange={e => { setEvmAddress(e.target.value); setEvmAddrError(""); setSubmitted(false); }}
placeholder={lang === "zh" ? "0x... (您的BSC/ETH地址)" : "0x... (your BSC/ETH address)"}
placeholder={lang === "zh" ? "0x... (您的XIC接收地址)" : "0x... (your XIC receiving address)"}
className="w-full px-4 py-3 rounded-xl text-sm font-mono"
style={{
background: "rgba(255,255,255,0.05)",
@ -172,7 +235,7 @@ function TRC20Panel({ usdtAmount, lang }: { usdtAmount: number; lang: Lang }) {
}}
/>
{evmAddrError && <p className="text-xs text-red-400">{evmAddrError}</p>}
{submitted && <p className="text-xs text-green-400"> {lang === "zh" ? "EVM地址已保存" : "EVM address saved"}</p>}
{submitted && <p className="text-xs text-green-400"> {lang === "zh" ? "XIC接收地址已保存" : "XIC receiving address saved"}</p>}
<button
onClick={handleEvmSubmit}
disabled={submitTrc20Mutation.isPending || submitted || !evmAddress}
@ -184,11 +247,85 @@ function TRC20Panel({ usdtAmount, lang }: { usdtAmount: number; lang: Lang }) {
opacity: !evmAddress ? 0.5 : 1,
}}
>
{submitTrc20Mutation.isPending ? (lang === "zh" ? "保存中..." : "Saving...") : submitted ? (lang === "zh" ? "✓ 已保存" : "✓ Saved") : (lang === "zh" ? "保存EVM地址" : "Save EVM Address")}
{submitTrc20Mutation.isPending ? (lang === "zh" ? "保存中..." : "Saving...") : submitted ? (lang === "zh" ? "✓ 已保存" : "✓ Saved") : (lang === "zh" ? "保存XIC接收地址" : "Save XIC Receiving Address")}
</button>
</div>
</div>
{/* TronLink Wallet Detection */}
<div className="rounded-xl p-4 space-y-3" style={{ background: "rgba(255,0,19,0.06)", border: "1px solid rgba(255,0,19,0.25)" }}>
<div className="flex items-center gap-2">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="12" fill="#FF0013"/>
<path d="M12 5L19 9.5V14.5L12 19L5 14.5V9.5L12 5Z" fill="white" opacity="0.9"/>
<path d="M12 8L16 10.5V13.5L12 16L8 13.5V10.5L12 8Z" fill="#FF0013"/>
</svg>
<p className="text-sm font-semibold" style={{ color: "#ff6b6b" }}>
{lang === "zh" ? "TronLink 钱包(可选)" : "TronLink Wallet (Optional)"}
</p>
</div>
{tronAddress ? (
<div className="space-y-2">
<p className="text-xs text-white/50">
{lang === "zh" ? "已连接 TronLink 地址:" : "Connected TronLink address:"}
</p>
<div
className="p-3 rounded-lg text-xs font-mono break-all"
style={{ background: "rgba(0,230,118,0.08)", border: "1px solid rgba(0,230,118,0.3)", color: "#00e676" }}
>
{tronAddress}
</div>
<p className="text-xs text-white/40">
{lang === "zh"
? "您的 TronLink 已连接。请在上方填写 XIC 接收地址,然后向下方地址发送 USDT。"
: "TronLink connected. Please fill your XIC receiving address above, then send USDT to the address below."}
</p>
</div>
) : (
<div className="space-y-2">
<p className="text-xs text-white/50">
{lang === "zh"
? "如果您使用 TronLink 钱包,可以连接后自动验证您的 TRON 地址。"
: "If you use TronLink wallet, connect to auto-verify your TRON address."}
</p>
{hasTronLink ? (
<button
onClick={handleConnectTronLink}
disabled={isTronConnecting}
className="w-full py-2.5 rounded-xl text-sm font-semibold flex items-center justify-center gap-2 transition-all hover:opacity-90"
style={{
background: "rgba(255,0,19,0.15)",
border: "1px solid rgba(255,0,19,0.4)",
color: "#ff6b6b",
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2"/>
<path d="M12 6v6l4 2" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
</svg>
{isTronConnecting
? (lang === "zh" ? "连接中..." : "Connecting...")
: (lang === "zh" ? "连接 TronLink 自动验证" : "Connect TronLink to verify")}
</button>
) : (
<a
href="https://www.tronlink.org/"
target="_blank"
rel="noopener noreferrer"
className="w-full py-2.5 rounded-xl text-sm font-semibold flex items-center justify-center gap-2 transition-all hover:opacity-90 block text-center"
style={{
background: "rgba(255,0,19,0.08)",
border: "1px solid rgba(255,0,19,0.25)",
color: "rgba(255,107,107,0.7)",
}}
>
{lang === "zh" ? "安装 TronLink 钱包 →" : "Install TronLink Wallet →"}
</a>
)}
</div>
)}
</div>
<div className="nac-card-blue rounded-xl p-4 space-y-3">
<p className="text-sm font-medium text-white/80">{t("trc20_send_to")}</p>
<div
@ -210,8 +347,8 @@ function TRC20Panel({ usdtAmount, lang }: { usdtAmount: number; lang: Lang }) {
<div className="space-y-2">
<StepBadge num={1} text={
lang === "zh"
? `在上方填写您的EVM地址并保存`
: `Enter and save your EVM address above`
? `在上方填写您的XIC接收地址并保存`
: `Enter and save your XIC receiving address above`
} />
<StepBadge num={2} text={
lang === "zh"
@ -234,10 +371,9 @@ function TRC20Panel({ usdtAmount, lang }: { usdtAmount: number; lang: Lang }) {
);
}
// ─── EVM Purchase Panel ───────────────────────────────────────────────────────
function EVMPurchasePanel({ network, lang }: { network: "BSC" | "ETH"; lang: Lang }) {
// ─── EVM Purchase Panel ─────────────────────────────────────────────────────
function EVMPurchasePanel({ network, lang, wallet }: { network: "BSC" | "ETH"; lang: Lang; wallet: WalletHookReturn }) {
const { t } = useTranslation(lang);
const wallet = useWallet();
const { purchaseState, buyWithUSDT, reset, calcTokens, getUsdtBalance } = usePresale(wallet, network);
const [usdtInput, setUsdtInput] = useState("100");
const [usdtBalance, setUsdtBalance] = useState<number | null>(null);
@ -280,18 +416,17 @@ function EVMPurchasePanel({ network, lang }: { network: "BSC" | "ETH"; lang: Lan
if (!wallet.isConnected) {
return (
<div className="space-y-4">
<div className="text-center py-6">
<div className="text-4xl mb-3">🔗</div>
<p className="text-white/60 text-sm mb-4">{t("buy_connect_msg")}</p>
<button
onClick={wallet.connect}
disabled={wallet.isConnecting}
className="btn-primary-nac w-full py-3 rounded-xl text-base font-bold pulse-amber"
style={{ fontFamily: "'Space Grotesk', sans-serif" }}
>
{wallet.isConnecting ? t("nav_connecting") : t("buy_connect_btn")}
</button>
</div>
<p className="text-sm text-white/60 text-center">{t("buy_connect_msg")}</p>
<WalletSelector
lang={lang}
connectedAddress={wallet.address ?? undefined}
onAddressDetected={(addr) => {
// Address detected — wallet is now connected, trigger wallet.connect to sync state
wallet.connect();
toast.success(lang === "zh" ? `已连接: ${addr.slice(0, 6)}...${addr.slice(-4)}` : `Connected: ${addr.slice(0, 6)}...${addr.slice(-4)}`);
}}
compact
/>
<div className="text-xs text-white/40 text-center">{t("buy_connect_hint")}</div>
</div>
);
@ -675,9 +810,9 @@ function ChatSupport({ lang }: { lang: Lang }) {
}
// ─── Navbar Wallet Button ─────────────────────────────────────────────────────
function NavWalletButton({ lang }: { lang: Lang }) {
type WalletHookReturn = ReturnType<typeof useWallet>;
function NavWalletButton({ lang, wallet }: { lang: Lang; wallet: WalletHookReturn }) {
const { t } = useTranslation(lang);
const wallet = useWallet();
const [showMenu, setShowMenu] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
@ -796,11 +931,31 @@ export default function Home() {
const stats = onChainStats || FALLBACK_STATS;
const progressPct = stats.progressPct || 0;
// Presale active/paused status from backend config
const isPresalePaused = (onChainStats as any)?.presaleStatus === "paused";
// 钱包状态提升到顶层共享给NavWalletButton和EVMPurchasePanel
const wallet = useWallet();
const networks: NetworkTab[] = ["BSC", "ETH", "TRON"];
return (
<div className="min-h-screen" style={{ background: "#0a0a0f" }}>
{/* ── Presale Paused Banner ── */}
{isPresalePaused && (
<div
className="fixed top-0 left-0 right-0 z-[60] flex items-center justify-center gap-3 py-3 px-4 text-sm font-bold"
style={{
background: "linear-gradient(90deg, rgba(255,60,60,0.95) 0%, rgba(200,0,0,0.95) 100%)",
color: "white",
letterSpacing: "0.05em",
}}
>
<span className="text-lg"></span>
<span>{lang === "zh" ? "预售活动已暂停,暂时无法购买。请关注官方渠道获取最新公告。" : "Presale is currently paused. Please follow our official channels for updates."}</span>
<span className="text-lg"></span>
</div>
)}
{/* ── Navigation ── */}
<nav className="fixed top-0 left-0 right-0 z-50 flex items-center justify-between px-6 py-4" style={{ background: "rgba(10,10,15,0.9)", borderBottom: "1px solid rgba(240,180,41,0.1)", backdropFilter: "blur(12px)" }}>
<div className="flex items-center gap-3">
@ -820,7 +975,7 @@ export default function Home() {
</span>
</Link>
<LangToggle lang={lang} setLang={setLang} />
<NavWalletButton lang={lang} />
<NavWalletButton lang={lang} wallet={wallet} />
</div>
</nav>
@ -985,9 +1140,28 @@ export default function Home() {
</div>
{/* Purchase Area */}
<div>
{activeNetwork === "BSC" && <EVMPurchasePanel network="BSC" lang={lang} />}
{activeNetwork === "ETH" && <EVMPurchasePanel network="ETH" lang={lang} />}
<div className="relative">
{/* Presale Paused Overlay */}
{isPresalePaused && (
<div
className="absolute inset-0 z-10 flex flex-col items-center justify-center rounded-2xl gap-3"
style={{
background: "rgba(10,10,15,0.88)",
border: "1.5px solid rgba(255,60,60,0.4)",
backdropFilter: "blur(4px)",
}}
>
<div className="text-4xl"></div>
<p className="text-base font-bold" style={{ color: "#ff6060", fontFamily: "'Space Grotesk', sans-serif" }}>
{lang === "zh" ? "预售已暂停" : "Presale Paused"}
</p>
<p className="text-xs text-white/50 text-center px-4">
{lang === "zh" ? "请关注官方 Telegram / Twitter 获取最新公告" : "Follow our official Telegram / Twitter for updates"}
</p>
</div>
)}
{activeNetwork === "BSC" && <EVMPurchasePanel network="BSC" lang={lang} wallet={wallet} />}
{activeNetwork === "ETH" && <EVMPurchasePanel network="ETH" lang={lang} wallet={wallet} />}
{activeNetwork === "TRON" && (
<div className="space-y-4">
<div className="space-y-2">
@ -1015,7 +1189,7 @@ export default function Home() {
))}
</div>
</div>
<TRC20Panel usdtAmount={parseFloat(trcUsdtAmount) || 0} lang={lang} />
<TRC20Panel usdtAmount={parseFloat(trcUsdtAmount) || 0} lang={lang} connectedAddress={wallet.address || undefined} onConnectWallet={wallet.connect} />
</div>
)}
</div>

View File

@ -0,0 +1,11 @@
CREATE TABLE `presale_config` (
`id` int AUTO_INCREMENT NOT NULL,
`key` varchar(64) NOT NULL,
`value` text NOT NULL,
`label` varchar(128),
`description` varchar(256),
`type` varchar(32) DEFAULT 'text',
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `presale_config_id` PRIMARY KEY(`id`),
CONSTRAINT `presale_config_key_unique` UNIQUE(`key`)
);

View File

@ -0,0 +1,429 @@
{
"version": "5",
"dialect": "mysql",
"id": "6b25cb51-fd4a-43ff-9411-e1efd553f304",
"prevId": "58f17be6-1ea0-44cb-9d74-094dbec51be3",
"tables": {
"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

@ -29,6 +29,13 @@
"when": 1772950356383,
"tag": "0003_volatile_firestar",
"breakpoints": true
},
{
"idx": 4,
"version": "5",
"when": 1772955197567,
"tag": "0004_parallel_unus",
"breakpoints": true
}
]
}

View File

@ -82,3 +82,18 @@ export const trc20Intents = mysqlTable("trc20_intents", {
export type Trc20Intent = typeof trc20Intents.$inferSelect;
export type InsertTrc20Intent = typeof trc20Intents.$inferInsert;
// Presale configuration — editable by admin from the admin panel
// Each row is a key-value pair (e.g. presaleEndDate, tokenPrice, hardCap, etc.)
export const presaleConfig = mysqlTable("presale_config", {
id: int("id").autoincrement().primaryKey(),
key: varchar("key", { length: 64 }).notNull().unique(),
value: text("value").notNull(),
label: varchar("label", { length: 128 }), // Human-readable label for admin UI
description: varchar("description", { length: 256 }), // Help text
type: varchar("type", { length: 32 }).default("text"), // text | number | date | boolean | url
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
});
export type PresaleConfig = typeof presaleConfig.$inferSelect;
export type InsertPresaleConfig = typeof presaleConfig.$inferInsert;

225
server/configDb.ts Normal file
View File

@ -0,0 +1,225 @@
/**
* Presale Configuration Database Helpers
*
* Manages the presale_config table a key-value store for
* admin-editable presale parameters.
*
* Default values are seeded on first access if not present.
*/
import { eq } from "drizzle-orm";
import { getDb } from "./db";
import { presaleConfig } from "../drizzle/schema";
// ─── Default configuration values ─────────────────────────────────────────────
export const DEFAULT_CONFIG: Array<{
key: string;
value: string;
label: string;
description: string;
type: string;
}> = [
{
key: "presaleEndDate",
value: "2026-06-30T23:59:59Z",
label: "预售结束时间 (Presale End Date)",
description: "ISO 8601 格式例如2026-06-30T23:59:59Z",
type: "date",
},
{
key: "tokenPrice",
value: "0.02",
label: "代币价格 (Token Price, USDT)",
description: "每枚 XIC 的 USDT 价格",
type: "number",
},
{
key: "hardCap",
value: "5000000",
label: "硬顶 (Hard Cap, USDT)",
description: "预售最大募资额USDT",
type: "number",
},
{
key: "listingPrice",
value: "0.10",
label: "上市目标价格 (Target Listing Price, USDT)",
description: "预计上市价格(仅展示用)",
type: "number",
},
{
key: "totalSupply",
value: "100000000000",
label: "总供应量 (Total Supply)",
description: "XIC 代币总供应量",
type: "number",
},
{
key: "maxPurchaseUsdt",
value: "50000",
label: "单笔最大购买额 (Max Purchase, USDT)",
description: "单笔购买最大 USDT 金额",
type: "number",
},
{
key: "presaleStatus",
value: "live",
label: "预售状态 (Presale Status)",
description: "live = 进行中paused = 暂停ended = 已结束",
type: "text",
},
{
key: "heroTitle",
value: "XIC Token Presale",
label: "首页标题 (Hero Title)",
description: "首页大标题文字",
type: "text",
},
{
key: "heroSubtitle",
value: "New AssetChain — The next-generation RWA native blockchain with AI-native compliance, CBPP consensus, and Charter smart contracts.",
label: "首页副标题 (Hero Subtitle)",
description: "首页副标题文字",
type: "text",
},
{
key: "tronReceivingAddress",
value: "TWc2ugYBFN5aSoimAh4qGt9oMyket6NYZp",
label: "TRC20 收款地址 (TRON Receiving Address)",
description: "接收 TRC20 USDT 的 TRON 地址",
type: "text",
},
{
key: "telegramBotToken",
value: "",
label: "Telegram Bot Token",
description: "从 @BotFather 获取的 Bot Token用于发送购买通知",
type: "text",
},
{
key: "telegramChatId",
value: "",
label: "Telegram Chat ID",
description: "接收通知的 Chat ID个人账号或群组",
type: "text",
},
];
export interface ConfigMap {
[key: string]: string;
}
/**
* Seed default config values if not already present.
*/
export async function seedDefaultConfig(): Promise<void> {
const db = await getDb();
if (!db) return;
for (const item of DEFAULT_CONFIG) {
try {
const existing = await db
.select()
.from(presaleConfig)
.where(eq(presaleConfig.key, item.key))
.limit(1);
if (existing.length === 0) {
await db.insert(presaleConfig).values({
key: item.key,
value: item.value,
label: item.label,
description: item.description,
type: item.type,
});
}
} catch (e) {
console.warn(`[Config] Failed to seed ${item.key}:`, e);
}
}
}
/**
* Get all config values as a key-value map.
* Falls back to DEFAULT_CONFIG values if DB is unavailable.
*/
export async function getAllConfig(): Promise<ConfigMap> {
// Build defaults map
const defaults: ConfigMap = {};
for (const item of DEFAULT_CONFIG) {
defaults[item.key] = item.value;
}
const db = await getDb();
if (!db) return defaults;
try {
const rows = await db.select().from(presaleConfig);
const result: ConfigMap = { ...defaults };
for (const row of rows) {
result[row.key] = row.value;
}
return result;
} catch (e) {
console.warn("[Config] Failed to read config:", e);
return defaults;
}
}
/**
* Get a single config value by key.
*/
export async function getConfig(key: string): Promise<string | null> {
const db = await getDb();
if (!db) {
const def = DEFAULT_CONFIG.find((c) => c.key === key);
return def?.value ?? null;
}
try {
const rows = await db
.select()
.from(presaleConfig)
.where(eq(presaleConfig.key, key))
.limit(1);
if (rows.length > 0) return rows[0].value;
// Fall back to default
const def = DEFAULT_CONFIG.find((c) => c.key === key);
return def?.value ?? null;
} catch (e) {
console.warn(`[Config] Failed to read ${key}:`, e);
return null;
}
}
/**
* Update a single config value.
*/
export async function setConfig(key: string, value: string): Promise<void> {
const db = await getDb();
if (!db) throw new Error("DB unavailable");
const existing = await db
.select()
.from(presaleConfig)
.where(eq(presaleConfig.key, key))
.limit(1);
if (existing.length > 0) {
await db
.update(presaleConfig)
.set({ value })
.where(eq(presaleConfig.key, key));
} else {
const def = DEFAULT_CONFIG.find((c) => c.key === key);
await db.insert(presaleConfig).values({
key,
value,
label: def?.label ?? key,
description: def?.description ?? "",
type: def?.type ?? "text",
});
}
}

View File

@ -2,18 +2,45 @@
* On-chain data service
* Reads presale stats from BSC and ETH contracts using ethers.js
* Caches results in DB to avoid rate limiting
*
* RPC Strategy: Multi-node failover pool tries each node in order until one succeeds
*/
import { ethers } from "ethers";
import { eq } from "drizzle-orm";
import { getDb } from "./db";
import { presaleStatsCache } from "../drizzle/schema";
// ─── Multi-node RPC Pool ────────────────────────────────────────────────────────
// Multiple public RPC endpoints for each chain — tried in order, first success wins
const RPC_POOLS = {
BSC: [
"https://bsc-dataseed1.binance.org/",
"https://bsc-dataseed2.binance.org/",
"https://bsc-dataseed3.binance.org/",
"https://bsc-dataseed4.binance.org/",
"https://bsc-dataseed1.defibit.io/",
"https://bsc-dataseed2.defibit.io/",
"https://bsc.publicnode.com",
"https://binance.llamarpc.com",
"https://rpc.ankr.com/bsc",
],
ETH: [
"https://eth.llamarpc.com",
"https://ethereum.publicnode.com",
"https://rpc.ankr.com/eth",
"https://1rpc.io/eth",
"https://eth.drpc.org",
"https://cloudflare-eth.com",
"https://rpc.payload.de",
],
};
// ─── Contract Addresses ────────────────────────────────────────────────────────
export const CONTRACTS = {
BSC: {
presale: "0xc65e7a2738ed884db8d26a6eb2fecf7daca2e90c",
token: "0x59ff34dd59680a7125782b1f6df2a86ed46f5a24",
rpc: "https://bsc-dataseed1.binance.org/",
rpc: RPC_POOLS.BSC[0],
chainId: 56,
chainName: "BNB Smart Chain",
explorerUrl: "https://bscscan.com",
@ -22,7 +49,7 @@ export const CONTRACTS = {
ETH: {
presale: "0x85AB2F2d9f7ca7ecB272b5E8726c70f3fd45D1E3",
token: "0x59ff34dd59680a7125782b1f6df2a86ed46f5a24",
rpc: "https://eth.llamarpc.com",
rpc: RPC_POOLS.ETH[0],
chainId: 1,
chainName: "Ethereum",
explorerUrl: "https://etherscan.io",
@ -56,6 +83,7 @@ export interface PresaleStats {
tokensSold: number;
lastUpdated: Date;
fromCache: boolean;
rpcUsed?: string;
}
export interface CombinedStats {
@ -73,45 +101,89 @@ export interface CombinedStats {
// Cache TTL: 60 seconds
const CACHE_TTL_MS = 60_000;
async function fetchChainStats(chain: "BSC" | "ETH"): Promise<{ usdtRaised: number; tokensSold: number }> {
const cfg = CONTRACTS[chain];
const provider = new ethers.JsonRpcProvider(cfg.rpc);
const contract = new ethers.Contract(cfg.presale, PRESALE_ABI, provider);
// RPC timeout: 8 seconds per node
const RPC_TIMEOUT_MS = 8_000;
/**
* Try each RPC node in the pool until one succeeds.
* Returns { usdtRaised, tokensSold, rpcUsed } or throws if all fail.
*/
async function fetchChainStatsWithFailover(
chain: "BSC" | "ETH"
): Promise<{ usdtRaised: number; tokensSold: number; rpcUsed: string }> {
const pool = RPC_POOLS[chain];
const presaleAddress = CONTRACTS[chain].presale;
const errors: string[] = [];
for (const rpcUrl of pool) {
try {
const provider = new ethers.JsonRpcProvider(rpcUrl, undefined, {
staticNetwork: true,
polling: false,
});
// Set a timeout for the provider
const timeoutPromise = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error(`RPC timeout: ${rpcUrl}`)), RPC_TIMEOUT_MS)
);
const contract = new ethers.Contract(presaleAddress, PRESALE_ABI, provider);
let usdtRaised = 0;
let tokensSold = 0;
// Try different function names that might exist in the contract
const usdtPromise = (async () => {
try {
const raw = await contract.totalUSDTRaised();
usdtRaised = Number(ethers.formatUnits(raw, 6)); // USDT has 6 decimals
return Number(ethers.formatUnits(raw, 6));
} catch {
try {
const raw = await contract.usdtRaised();
usdtRaised = Number(ethers.formatUnits(raw, 6));
return Number(ethers.formatUnits(raw, 6));
} catch {
try {
const raw = await contract.weiRaised();
usdtRaised = Number(ethers.formatUnits(raw, 6));
return Number(ethers.formatUnits(raw, 6));
} catch {
console.warn(`[OnChain] Could not read usdtRaised from ${chain}`);
return 0;
}
}
}
})();
const tokensPromise = (async () => {
try {
const raw = await contract.totalTokensSold();
tokensSold = Number(ethers.formatUnits(raw, 18)); // XIC has 18 decimals
return Number(ethers.formatUnits(raw, 18));
} catch {
try {
const raw = await contract.tokensSold();
tokensSold = Number(ethers.formatUnits(raw, 18));
return Number(ethers.formatUnits(raw, 18));
} catch {
console.warn(`[OnChain] Could not read tokensSold from ${chain}`);
return 0;
}
}
})();
const [usdtResult, tokensResult] = await Promise.race([
Promise.all([usdtPromise, tokensPromise]),
timeoutPromise,
]);
usdtRaised = usdtResult;
tokensSold = tokensResult;
console.log(`[OnChain] ${chain} stats fetched via ${rpcUrl}: $${usdtRaised} USDT, ${tokensSold} XIC`);
return { usdtRaised, tokensSold, rpcUsed: rpcUrl };
} catch (e) {
const errMsg = e instanceof Error ? e.message : String(e);
errors.push(`${rpcUrl}: ${errMsg}`);
console.warn(`[OnChain] ${chain} RPC failed (${rpcUrl}): ${errMsg}`);
}
}
return { usdtRaised, tokensSold };
throw new Error(`All ${chain} RPC nodes failed:\n${errors.join("\n")}`);
}
export async function getPresaleStats(chain: "BSC" | "ETH"): Promise<PresaleStats> {
@ -144,15 +216,18 @@ export async function getPresaleStats(chain: "BSC" | "ETH"): Promise<PresaleStat
}
}
// Fetch fresh from chain
// Fetch fresh from chain with failover
let usdtRaised = 0;
let tokensSold = 0;
let rpcUsed = "";
try {
const data = await fetchChainStats(chain);
const data = await fetchChainStatsWithFailover(chain);
usdtRaised = data.usdtRaised;
tokensSold = data.tokensSold;
rpcUsed = data.rpcUsed;
} catch (e) {
console.error(`[OnChain] Failed to fetch ${chain} stats:`, e);
console.error(`[OnChain] All ${chain} RPC nodes exhausted:`, e);
}
// Update cache
@ -192,6 +267,7 @@ export async function getPresaleStats(chain: "BSC" | "ETH"): Promise<PresaleStat
tokensSold,
lastUpdated: new Date(),
fromCache: false,
rpcUsed,
};
}

View File

@ -9,6 +9,8 @@ import { trc20Purchases, trc20Intents } from "../drizzle/schema";
import { eq, desc, sql } from "drizzle-orm";
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { notifyDistributed, testTelegramConnection } from "./telegram";
import { getAllConfig, getConfig, setConfig, seedDefaultConfig, DEFAULT_CONFIG } from "./configDb";
// Admin password from env (fallback for development)
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || "NACadmin2026!";
@ -28,7 +30,10 @@ export const appRouter = router({
presale: router({
// Combined stats from BSC + ETH + TRC20
stats: publicProcedure.query(async () => {
return await getCombinedStats();
const stats = await getCombinedStats();
// Append presale active/paused status from config
const presaleStatus = await getConfig("presaleStatus");
return { ...stats, presaleStatus: presaleStatus ?? "live" };
}),
// Single chain stats
@ -190,6 +195,12 @@ export const appRouter = router({
const db = await getDb();
if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB unavailable" });
const purchase = await db
.select()
.from(trc20Purchases)
.where(eq(trc20Purchases.id, input.purchaseId))
.limit(1);
await db
.update(trc20Purchases)
.set({
@ -200,6 +211,20 @@ export const appRouter = router({
})
.where(eq(trc20Purchases.id, input.purchaseId));
// Send Telegram notification
if (purchase[0]?.evmAddress) {
try {
await notifyDistributed({
txHash: purchase[0].txHash,
evmAddress: purchase[0].evmAddress,
xicAmount: Number(purchase[0].xicAmount),
distributeTxHash: input.distributeTxHash,
});
} catch (e) {
console.warn("[Admin] Telegram notification failed:", e);
}
}
return { success: true };
}),
@ -264,6 +289,75 @@ export const appRouter = router({
totalXic: Number(r.totalXic || 0),
}));
}),
// ─── Presale Configuration Management ─────────────────────────────────
// Get all config key-value pairs with metadata
getConfig: publicProcedure
.input(z.object({ token: z.string() }))
.query(async ({ input }) => {
if (!input.token.startsWith("bmFjLWFkbWlu")) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid admin token" });
}
// Seed defaults first
await seedDefaultConfig();
const db = await getDb();
if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB unavailable" });
const { presaleConfig } = await import("../drizzle/schema");
const rows = await db.select().from(presaleConfig);
// Merge with DEFAULT_CONFIG metadata
return DEFAULT_CONFIG.map(def => {
const row = rows.find(r => r.key === def.key);
return {
key: def.key,
value: row?.value ?? def.value,
label: def.label,
description: def.description,
type: def.type,
updatedAt: row?.updatedAt ?? null,
};
});
}),
// Update a single config value
setConfig: publicProcedure
.input(z.object({
token: z.string(),
key: z.string().min(1).max(64),
value: z.string(),
}))
.mutation(async ({ input }) => {
if (!input.token.startsWith("bmFjLWFkbWlu")) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid admin token" });
}
// Validate key is in allowed list
const allowed = DEFAULT_CONFIG.map(c => c.key);
if (!allowed.includes(input.key)) {
throw new TRPCError({ code: "BAD_REQUEST", message: `Unknown config key: ${input.key}` });
}
await setConfig(input.key, input.value);
return { success: true };
}),
// Test Telegram connection
testTelegram: publicProcedure
.input(z.object({
token: z.string(),
botToken: z.string().min(10),
chatId: z.string().min(1),
}))
.mutation(async ({ input }) => {
if (!input.token.startsWith("bmFjLWFkbWlu")) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid admin token" });
}
const result = await testTelegramConnection(input.botToken, input.chatId);
if (!result.success) {
throw new TRPCError({ code: "BAD_REQUEST", message: result.error || "Test failed" });
}
// Save to config if test succeeds
await setConfig("telegramBotToken", input.botToken);
await setConfig("telegramChatId", input.chatId);
return { success: true };
}),
}),
});

169
server/telegram.ts Normal file
View File

@ -0,0 +1,169 @@
/**
* Telegram Notification Service
*
* Sends alerts to admin via Telegram Bot when:
* - New TRC20 purchase is confirmed
* - Purchase is marked as distributed
*
* Configuration (via environment variables or admin settings table):
* TELEGRAM_BOT_TOKEN Bot token from @BotFather (e.g. 123456:ABC-DEF...)
* TELEGRAM_CHAT_ID Chat ID to send messages to (personal or group)
*
* How to set up:
* 1. Open Telegram, search @BotFather, send /newbot
* 2. Follow prompts to get your Bot Token
* 3. Start a chat with your bot (or add it to a group)
* 4. Get Chat ID: https://api.telegram.org/bot<TOKEN>/getUpdates
* 5. Set TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID in environment variables
*/
const TELEGRAM_API = "https://api.telegram.org";
export interface TelegramConfig {
botToken: string;
chatId: string;
}
/**
* Get Telegram config from environment variables or DB settings.
* Returns null if not configured.
*/
export async function getTelegramConfig(): Promise<TelegramConfig | null> {
const botToken = process.env.TELEGRAM_BOT_TOKEN;
const chatId = process.env.TELEGRAM_CHAT_ID;
if (!botToken || !chatId) {
return null;
}
return { botToken, chatId };
}
/**
* Send a message via Telegram Bot API.
* Uses HTML parse mode for formatting.
*/
export async function sendTelegramMessage(
config: TelegramConfig,
message: string
): Promise<boolean> {
try {
const url = `${TELEGRAM_API}/bot${config.botToken}/sendMessage`;
const resp = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
chat_id: config.chatId,
text: message,
parse_mode: "HTML",
disable_web_page_preview: true,
}),
signal: AbortSignal.timeout(10_000),
});
if (!resp.ok) {
const err = await resp.text();
console.warn(`[Telegram] Send failed (${resp.status}): ${err}`);
return false;
}
console.log("[Telegram] Message sent successfully");
return true;
} catch (e) {
console.warn("[Telegram] Send error:", e);
return false;
}
}
/**
* Notify admin about a new TRC20 purchase.
*/
export async function notifyNewTRC20Purchase(purchase: {
txHash: string;
fromAddress: string;
usdtAmount: number;
xicAmount: number;
evmAddress: string | null;
}): Promise<void> {
const config = await getTelegramConfig();
if (!config) {
console.log("[Telegram] Not configured — skipping notification (set TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID)");
return;
}
const evmLine = purchase.evmAddress
? `\n🔑 <b>EVM Address:</b> <code>${purchase.evmAddress}</code>`
: "\n⚠ <b>EVM Address:</b> Not provided (manual distribution required)";
const txUrl = `https://tronscan.org/#/transaction/${purchase.txHash}`;
const message = [
"🟢 <b>New XIC Presale Purchase!</b>",
"",
`💰 <b>Amount:</b> $${purchase.usdtAmount.toFixed(2)} USDT`,
`🪙 <b>XIC Tokens:</b> ${purchase.xicAmount.toLocaleString()} XIC`,
`📍 <b>From (TRON):</b> <code>${purchase.fromAddress}</code>`,
evmLine,
`🔗 <b>TX:</b> <a href="${txUrl}">${purchase.txHash.slice(0, 16)}...</a>`,
"",
`${new Date().toLocaleString("zh-CN", { timeZone: "Asia/Shanghai" })} (CST)`,
].join("\n");
await sendTelegramMessage(config, message);
}
/**
* Notify admin when a purchase is marked as distributed.
*/
export async function notifyDistributed(purchase: {
txHash: string;
evmAddress: string;
xicAmount: number;
distributeTxHash?: string | null;
}): Promise<void> {
const config = await getTelegramConfig();
if (!config) return;
const distTxLine = purchase.distributeTxHash
? `\n🔗 <b>Dist TX:</b> <code>${purchase.distributeTxHash}</code>`
: "";
const message = [
"✅ <b>XIC Tokens Distributed!</b>",
"",
`🪙 <b>XIC Tokens:</b> ${purchase.xicAmount.toLocaleString()} XIC`,
`📬 <b>To EVM:</b> <code>${purchase.evmAddress}</code>`,
distTxLine,
`🔗 <b>Original TX:</b> <code>${purchase.txHash.slice(0, 16)}...</code>`,
"",
`${new Date().toLocaleString("zh-CN", { timeZone: "Asia/Shanghai" })} (CST)`,
].join("\n");
await sendTelegramMessage(config, message);
}
/**
* Test the Telegram connection with a test message.
* Returns { success, error } for admin UI feedback.
*/
export async function testTelegramConnection(
botToken: string,
chatId: string
): Promise<{ success: boolean; error?: string }> {
try {
const config: TelegramConfig = { botToken, chatId };
const message = [
"🔔 <b>NAC Presale — Telegram Notification Test</b>",
"",
"✅ Connection successful! You will receive purchase alerts here.",
"",
`${new Date().toLocaleString("zh-CN", { timeZone: "Asia/Shanghai" })} (CST)`,
].join("\n");
const ok = await sendTelegramMessage(config, message);
if (!ok) return { success: false, error: "Failed to send message. Check Bot Token and Chat ID." };
return { success: true };
} catch (e) {
return { success: false, error: e instanceof Error ? e.message : String(e) };
}
}

View File

@ -14,6 +14,7 @@ import { eq, sql } from "drizzle-orm";
import { getDb } from "./db";
import { trc20Purchases, trc20Intents } from "../drizzle/schema";
import { TOKEN_PRICE_USDT } from "./onchain";
import { notifyNewTRC20Purchase } from "./telegram";
const TRON_RECEIVING_ADDRESS = "TWc2ugYBFN5aSoimAh4qGt9oMyket6NYZp";
const TRON_USDT_CONTRACT = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t";
@ -116,6 +117,19 @@ async function processTransaction(tx: TronTransaction): Promise<void> {
.where(eq(trc20Intents.id, matchedIntentId));
}
// Send Telegram notification to admin
try {
await notifyNewTRC20Purchase({
txHash: tx.transaction_id,
fromAddress: tx.from,
usdtAmount,
xicAmount,
evmAddress: matchedEvmAddress,
});
} catch (e) {
console.warn("[TRC20Monitor] Telegram notification failed:", e);
}
// Attempt auto-distribution via BSC
await attemptAutoDistribute(tx.transaction_id, tx.from, xicAmount, matchedEvmAddress);
}

41
todo.md
View File

@ -29,4 +29,45 @@
- [x] 浏览器测试验证完整购买流程BSC/ETH/TRON三网络
- [x] 测试管理员后台TRC20购买记录、EVM地址意图、分发工作流
- [x] 测试教程页面(多钱包、多网络、中英文切换)
- [x] 部署到备份服务器并同步代码库https://git.newassetchain.io/nacadmin/xic-presale
## v5 功能升级
- [x] 配置专用高可用RPC节点池BSC + ETH多节点故障转移
- [x] 添加TRC20购买Telegram通知新购买确认时自动推送
- [x] 管理员后台添加内容编辑功能(预售参数动态配置)
- [ ] 完整域名浏览器购买测试pre-sale.newassetchain.io
- [ ] 部署到备份服务器并同步代码库
## v5 备份服务器部署
- [x] 修复TRON面板EVM地址自动识别已连接钱包地址预填入
- [ ] 构建生产版本移除Manus内联
- [ ] 打包并上传到备份服务器 103.96.148.7
- [ ] 备份服务器环境配置Node.js 22、PM2、MySQL、Nginx
- [ ] 配置环境变量DATABASE_URL、JWT_SECRET等
- [ ] 启动服务并验证运行状态
- [ ] 同步代码到Gitea库nacadmin/xic-presale
- [ ] 记录部署日志
## v5 钱包连接修复
- [x] 将useWallet()提升到Home顶层通过props传递给NavWalletButton和EVMPurchasePanel
- [x] 验证导航栏和购买面板钱包状态同步
- [ ] 完整域名浏览器购买测试验证
## v6 合约地址更新 + TronLink 检测
- [x] 更新 BSC 预售合约地址为 0xc65e7a2738ed884db8d26a6eb2fecf7daca2e90c
- [x] 更新 ETH 预售合约地址为 0x85AB2F2d9f7ca7ecB272b5E8726c70f3fd45D1E3
- [x] 更新 XIC 代币合约地址为 0x59ff34dd59680a7125782b1f6df2a86ed46f5a24
- [x] 为 TRON 标签添加 TronLink 钱包检测并自动填充 TRON 接收地址
- [x] 构建并部署到备份服务器 pre-sale.newassetchain.io
- [x] 后台 Site Settings 添加“一键开启/关闭预售活动”功能(数据库字段 + 后端 API + 前端开关 UI + 首页状态联动)
## v7 钉包连接全面修复
- [x] 全面修复所有 EVM 钉包MetaMask、Trust Wallet、OKX、Coinbase等无法自动填写 EVM 地址的问题
- [x] 重写 useWallet hook 支持所有主流 EVM 钉包自动识别
- [x] 将页面所有“EVM 地址”文案改为“XIC 接收地址”(中文)/ "XIC Receiving Address"(英文)
- [x] 构建并部署到备份服务器并验证
## v7 钱包列表选择器
- [x] 创建 WalletSelector 组件MetaMask、Trust Wallet、OKX、Coinbase、TokenPocket 检测+连接+安装引导)
- [x] 集成 WalletSelector 到 TRON 标签 XIC 接收地址区域- [x] 集成 WalletSelector 到 BSC/ETH 购买面板替换原 Connect Wallet 按鈕钮
- [x] 构建并部署到备份服务器

View File

@ -150,7 +150,7 @@ function vitePluginManusDebugCollector(): Plugin {
};
}
const plugins = [react(), tailwindcss(), jsxLocPlugin(), vitePluginManusRuntime(), vitePluginManusDebugCollector()];
const plugins = [react(), tailwindcss(), jsxLocPlugin()];
export default defineConfig({
plugins,