361 lines
12 KiB
Plaintext
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 };
|
|
}
|