nac-presale/client/src/hooks/useWallet.ts.bak.20260309_2...

361 lines
12 KiB
Plaintext

// NAC XIC Presale — Wallet Connection Hook
// Supports MetaMask, Trust Wallet, OKX Wallet, Coinbase Wallet, and all EVM-compatible wallets
// v4: added forceConnect(address) for WalletSelector callback sync
import { useState, useEffect, useCallback, useRef } from "react";
import { BrowserProvider, JsonRpcSigner, Eip1193Provider } from "ethers";
import { shortenAddress, switchToNetwork } from "@/lib/contracts";
export type NetworkType = "BSC" | "ETH" | "TRON";
export interface WalletState {
address: string | null;
shortAddress: string;
isConnected: boolean;
chainId: number | null;
provider: BrowserProvider | null;
signer: JsonRpcSigner | null;
isConnecting: boolean;
error: string | null;
}
const INITIAL_STATE: WalletState = {
address: null,
shortAddress: "",
isConnected: false,
chainId: null,
provider: null,
signer: null,
isConnecting: false,
error: null,
};
// Detect the best available EVM provider across all major wallets
export function detectProvider(): Eip1193Provider | 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" };
}
// Any other error — treat as not ready
return { ready: false, reason: error?.message || "unknown" };
}
}
// 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 {
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 {
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 (): Promise<{ success: boolean; error?: string }> => {
const rawProvider = detectProvider();
if (!rawProvider) {
const msg = "未检测到钱包插件。请安装 MetaMask 或其他 EVM 兼容钱包后刷新页面。";
if (mountedRef.current) setState(s => ({ ...s, error: msg }));
return { success: false, error: msg };
}
setState(s => ({ ...s, isConnecting: true, error: null }));
try {
// 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");
}
const partial = await buildWalletState(rawProvider, accounts[0]);
if (mountedRef.current) setState({ ...INITIAL_STATE, ...partial });
return { success: true };
} catch (err: unknown) {
const error = err as { code?: number; message?: string };
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 初始化设置(创建或导入钱包),然后刷新页面重试。";
} 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 || "未知错误"}。请刷新页面重试。`;
}
if (mountedRef.current) setState(s => ({ ...s, isConnecting: false, error: msg }));
return { success: false, error: msg };
}
}, []);
// ── Force connect with known address (from WalletSelector callback) ─────────
// Use this when WalletSelector has already called eth_requestAccounts and got the address.
// Directly builds wallet state without triggering another popup.
const forceConnect = useCallback(async (address: string): Promise<void> => {
if (!address) return;
const rawProvider = detectProvider();
if (!rawProvider) {
// No provider available — set minimal connected state with just the address
if (mountedRef.current) {
setState({
...INITIAL_STATE,
address,
shortAddress: shortenAddress(address),
isConnected: true,
});
}
return;
}
try {
const partial = await buildWalletState(rawProvider, address);
if (mountedRef.current) setState({ ...INITIAL_STATE, ...partial });
} catch {
// Fallback: set minimal state
if (mountedRef.current) {
setState({
...INITIAL_STATE,
address,
shortAddress: shortenAddress(address),
isConnected: true,
});
}
}
}, []);
// ── Disconnect ──────────────────────────────────────────────────────────────
const disconnect = useCallback(() => {
setState(INITIAL_STATE);
}, []);
// ── Switch Network ──────────────────────────────────────────────────────────
const switchNetwork = useCallback(async (chainId: number) => {
try {
await switchToNetwork(chainId);
const rawProvider = detectProvider();
if (rawProvider) {
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,
error: null,
}));
}
}
} catch (err: unknown) {
if (mountedRef.current) setState(s => ({ ...s, error: (err as Error).message }));
}
}, []);
// ── Auto-detect on page load (silent, no popup) ─────────────────────────────
useEffect(() => {
let cancelled = false;
const tryAutoDetect = async (attempt: number) => {
if (cancelled) return;
const rawProvider = detectProvider();
if (!rawProvider) {
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) {
retryRef.current = setTimeout(() => tryAutoDetect(attempt + 1), 1000 * attempt);
}
} catch {
// Silently ignore — user hasn't connected yet
}
};
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 (!mountedRef.current) return;
if (!accs || accs.length === 0) {
setState(INITIAL_STATE);
} else {
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 = async () => {
if (!mountedRef.current) return;
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 {
window.location.reload();
}
};
eth.on("accountsChanged", handleAccountsChanged);
eth.on("chainChanged", handleChainChanged);
return () => {
if (eth.removeListener) {
eth.removeListener("accountsChanged", handleAccountsChanged);
eth.removeListener("chainChanged", handleChainChanged);
}
};
}, []);
return { ...state, connect, forceConnect, disconnect, switchNetwork };
}