f8133d6003
删除的文件: - ContinentGroupHeader, AiPinnedForecastView, DataFreshnessBar, MarketDecisionLine - opportunity-ai-meta/evidence-summary/v4-decision/v4-risk - HeaderBar, IntradaySignalScene, MapCanvas, PolyWeatherDashboard - ScanFilterPanel, WeatherAuraLayer 修改: - decisionClass 死函数移除 - KoyfinWeatherTerminal → PolyWeatherTerminal - Koyfin-style grid → Multi-panel grid - 欢迎覆盖层 CSS 清理
387 lines
16 KiB
TypeScript
387 lines
16 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect } from "react";
|
|
import type {
|
|
BoundWallet,
|
|
ConnectBindOptions,
|
|
Eip6963ProviderDetail,
|
|
EvmProvider,
|
|
InjectedProviderOption,
|
|
ProviderMode,
|
|
ProviderSelection,
|
|
} from "./types";
|
|
import {
|
|
WALLETCONNECT_POLYGON_RPC_URL,
|
|
WALLET_TRANSACTION_REQUEST_TIMEOUT_MS,
|
|
} from "./constants";
|
|
import { shortAddress } from "./formatters";
|
|
import { normalizePaymentError, requestWalletWithTimeout } from "./payment-utils";
|
|
import {
|
|
eip6963Providers,
|
|
getEvmProvider,
|
|
getEvmWalletLabel,
|
|
getWalletConnectProvider,
|
|
isWalletConnectResetError,
|
|
listInjectedProviders,
|
|
resetWalletConnectProvider,
|
|
} from "./wallet";
|
|
|
|
// ============================================================
|
|
export interface UseWalletBindParams {
|
|
isEn: boolean;
|
|
walletConnectEnabled: boolean;
|
|
copy: Record<string, string>;
|
|
chainId: number;
|
|
|
|
// Shared wallet state + setters
|
|
boundWallets: BoundWallet[];
|
|
setBoundWallets: React.Dispatch<React.SetStateAction<BoundWallet[]>>;
|
|
walletAddress: string;
|
|
setWalletAddress: React.Dispatch<React.SetStateAction<string>>;
|
|
selectedWallet: string;
|
|
setSelectedWallet: React.Dispatch<React.SetStateAction<string>>;
|
|
providerMode: ProviderMode;
|
|
setProviderMode: React.Dispatch<React.SetStateAction<ProviderMode>>;
|
|
injectedProviderOptions: InjectedProviderOption[];
|
|
setInjectedProviderOptions: React.Dispatch<React.SetStateAction<InjectedProviderOption[]>>;
|
|
selectedInjectedProviderKey: string;
|
|
setSelectedInjectedProviderKey: React.Dispatch<React.SetStateAction<string>>;
|
|
|
|
// Payment UI state setters
|
|
setPaymentBusy: (v: boolean) => void;
|
|
setPaymentInfo: (v: string) => void;
|
|
setPaymentError: (v: string) => void;
|
|
setShowOverlay: (v: boolean) => void;
|
|
clearPaymentMessages: () => void;
|
|
|
|
// Derived values from master
|
|
authIsAuthenticated: boolean;
|
|
|
|
// Callbacks from master (must be stable via useCallback)
|
|
getValidAccessToken: () => Promise<string>;
|
|
buildAuthedHeaders: (withJson?: boolean, requireAuth?: boolean) => Promise<Record<string, string>>;
|
|
loadSnapshot: () => Promise<void>;
|
|
loadPaymentSnapshot: () => Promise<void>;
|
|
}
|
|
|
|
// ============================================================
|
|
export function useWalletBind(params: UseWalletBindParams) {
|
|
const {
|
|
isEn,
|
|
walletConnectEnabled,
|
|
copy,
|
|
chainId,
|
|
boundWallets,
|
|
setBoundWallets,
|
|
walletAddress,
|
|
setWalletAddress,
|
|
selectedWallet,
|
|
setSelectedWallet,
|
|
providerMode,
|
|
setProviderMode,
|
|
injectedProviderOptions,
|
|
setInjectedProviderOptions,
|
|
selectedInjectedProviderKey,
|
|
setSelectedInjectedProviderKey,
|
|
setPaymentBusy,
|
|
setPaymentInfo,
|
|
setPaymentError,
|
|
setShowOverlay,
|
|
clearPaymentMessages,
|
|
authIsAuthenticated,
|
|
getValidAccessToken,
|
|
buildAuthedHeaders,
|
|
loadSnapshot,
|
|
loadPaymentSnapshot,
|
|
} = params;
|
|
|
|
// ── EIP-6963 Provider sync ──────────────────────────────
|
|
useEffect(() => {
|
|
const syncProviders = () => {
|
|
const nextOptions = listInjectedProviders();
|
|
setInjectedProviderOptions(nextOptions);
|
|
setSelectedInjectedProviderKey((current) => {
|
|
if (current && nextOptions.some((row) => row.key === current)) {
|
|
return current;
|
|
}
|
|
return nextOptions[0]?.key || "";
|
|
});
|
|
};
|
|
|
|
const handleAnnounce = (event: Event) => {
|
|
const customEvent = event as CustomEvent<Eip6963ProviderDetail>;
|
|
const detail = customEvent.detail;
|
|
if (!detail?.provider || typeof detail.provider.request !== "function") return;
|
|
const uuid = String(detail.info?.uuid || "").trim();
|
|
const fallbackKey = `${String(detail.info?.rdns || "wallet").toLowerCase()}:${String(detail.info?.name || "wallet").toLowerCase()}`;
|
|
eip6963Providers.set(uuid || fallbackKey, detail);
|
|
syncProviders();
|
|
};
|
|
|
|
syncProviders();
|
|
if (typeof window === "undefined") return;
|
|
window.addEventListener("eip6963:announceProvider", handleAnnounce as EventListener);
|
|
window.dispatchEvent(new Event("eip6963:requestProvider"));
|
|
window.addEventListener("ethereum#initialized", syncProviders as EventListener, { once: false });
|
|
return () => {
|
|
window.removeEventListener("eip6963:announceProvider", handleAnnounce as EventListener);
|
|
window.removeEventListener("ethereum#initialized", syncProviders as EventListener);
|
|
};
|
|
}, []);
|
|
|
|
// ── resolvePaymentProvider ──────────────────────────────
|
|
const resolvePaymentProvider = useCallback(
|
|
async (mode: ProviderMode = "auto", preferredInjectedKey = ""): Promise<ProviderSelection> => {
|
|
const targetChainId = Number(chainId || 137);
|
|
if (mode !== "walletconnect") {
|
|
const injectedOptions = listInjectedProviders();
|
|
const injected = injectedOptions.find((row) => row.key === preferredInjectedKey)?.provider || getEvmProvider();
|
|
const label = injectedOptions.find((row) => row.key === preferredInjectedKey)?.label || getEvmWalletLabel(injected);
|
|
if (injected) return { provider: injected, label, mode: "auto" };
|
|
}
|
|
if (!walletConnectEnabled) {
|
|
throw new Error("未检测到浏览器扩展钱包,且 WalletConnect 未启用。请配置 NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID 或安装 EVM 钱包扩展。");
|
|
}
|
|
const wcProvider = await getWalletConnectProvider(targetChainId, WALLETCONNECT_POLYGON_RPC_URL);
|
|
const existingAccounts = (await wcProvider.request({ method: "eth_accounts" }).catch(() => [])) as string[];
|
|
if (!Array.isArray(existingAccounts) || existingAccounts.length === 0) {
|
|
if (typeof wcProvider.connect === "function") {
|
|
try { await wcProvider.connect({ chains: [targetChainId] }); }
|
|
catch (err) {
|
|
if (!isWalletConnectResetError(err)) throw err;
|
|
await resetWalletConnectProvider();
|
|
const freshProvider = await getWalletConnectProvider(targetChainId, WALLETCONNECT_POLYGON_RPC_URL);
|
|
if (typeof freshProvider.connect === "function") await freshProvider.connect({ chains: [targetChainId] });
|
|
return { provider: freshProvider, label: "WalletConnect", mode: "walletconnect" };
|
|
}
|
|
}
|
|
}
|
|
return { provider: wcProvider, label: "WalletConnect", mode: "walletconnect" };
|
|
},
|
|
[chainId, walletConnectEnabled],
|
|
);
|
|
|
|
// ── Low-level helpers ──────────────────────────────────
|
|
const waitForReceipt = async (txHash: string, provider?: EvmProvider, timeoutMs = 120000, pollMs = 3000) => {
|
|
const eth = provider || getEvmProvider();
|
|
if (!eth) throw new Error(copy.noWalletProvider);
|
|
const started = Date.now();
|
|
while (Date.now() - started < timeoutMs) {
|
|
const receipt = await requestWalletWithTimeout<{ status?: string } | null>(
|
|
eth,
|
|
{ method: "eth_getTransactionReceipt", params: [txHash] },
|
|
"查询授权交易确认",
|
|
15_000,
|
|
);
|
|
if (receipt && receipt.status) {
|
|
if (receipt.status === "0x1") return receipt;
|
|
throw new Error(copy.txReverted.replace("{txHash}", txHash));
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
}
|
|
throw new Error(copy.txConfirmTimeout.replace("{txHash}", txHash));
|
|
};
|
|
|
|
const signBindMessage = async (eth: EvmProvider, address: string, message: string): Promise<string> => {
|
|
try {
|
|
return (await eth.request({ method: "personal_sign", params: [message, address] })) as string;
|
|
} catch {
|
|
return (await eth.request({ method: "personal_sign", params: [address, message] })) as string;
|
|
}
|
|
};
|
|
|
|
const ensureTargetChain = async (eth: EvmProvider, targetChainId: number): Promise<void> => {
|
|
const currentChainIdHex = String(
|
|
(await requestWalletWithTimeout<string>(eth, { method: "eth_chainId" }, copy.chainReadError)) || "",
|
|
);
|
|
const targetChainHex = `0x${targetChainId.toString(16)}`;
|
|
if (currentChainIdHex.toLowerCase() === targetChainHex.toLowerCase()) return;
|
|
try {
|
|
await requestWalletWithTimeout(
|
|
eth,
|
|
{ method: "wallet_switchEthereumChain", params: [{ chainId: targetChainHex }] },
|
|
copy.chainSwitchError,
|
|
);
|
|
} catch (err: any) {
|
|
const code = Number(err?.code);
|
|
if (code === 4902 || targetChainId === 137) {
|
|
try {
|
|
await requestWalletWithTimeout(
|
|
eth,
|
|
{
|
|
method: "wallet_addEthereumChain",
|
|
params: [{
|
|
chainId: "0x89",
|
|
chainName: "Polygon Mainnet",
|
|
nativeCurrency: { name: "POL", symbol: "POL", decimals: 18 },
|
|
rpcUrls: ["https://polygon-rpc.com"],
|
|
blockExplorerUrls: ["https://polygonscan.com"],
|
|
}],
|
|
},
|
|
copy.chainAddPolygon,
|
|
);
|
|
return;
|
|
} catch (addErr: any) { err = addErr; }
|
|
}
|
|
throw new Error(`${copy.chainSwitchPrompt} (${err?.message || (isEn ? "Network switch failed" : "网络切换失败")})`);
|
|
}
|
|
};
|
|
|
|
// ── connectAndBindWallet ────────────────────────────────
|
|
const connectAndBindWallet = async (mode: ProviderMode = "auto", options: ConnectBindOptions = {}): Promise<boolean> => {
|
|
clearPaymentMessages();
|
|
if (!authIsAuthenticated) {
|
|
setPaymentError(copy.loginBeforeBind);
|
|
return false;
|
|
}
|
|
|
|
setPaymentBusy(true);
|
|
try {
|
|
const providerSelection = await resolvePaymentProvider(mode, selectedInjectedProviderKey);
|
|
const eth = providerSelection.provider;
|
|
const walletLabel = providerSelection.label;
|
|
const binanceBindHint = walletLabel.toLowerCase().includes("binance") ? copy.binanceBindHint : "";
|
|
|
|
let accessToken: string;
|
|
try { accessToken = await getValidAccessToken(); }
|
|
catch (tokenErr) {
|
|
setPaymentError(normalizePaymentError(tokenErr).message);
|
|
setPaymentBusy(false);
|
|
return false;
|
|
}
|
|
const authHeaders: Record<string, string> = {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${accessToken}`,
|
|
};
|
|
|
|
const accounts = await requestWalletWithTimeout<string[]>(eth, { method: "eth_requestAccounts" }, "连接绑定钱包");
|
|
const address = String(accounts?.[0] || "").toLowerCase();
|
|
if (!address) throw new Error(isEn ? "Wallet account is empty." : "钱包账户为空");
|
|
|
|
const existingWallet = boundWallets.find((w) => String(w.address || "").toLowerCase() === address);
|
|
if (existingWallet) {
|
|
setWalletAddress(address);
|
|
setSelectedWallet(address);
|
|
setPaymentInfo(`${walletLabel} 已绑定: ${shortAddress(address)}。${binanceBindHint || "现在可点击“立即订阅并激活服务”。"}`);
|
|
await Promise.all([loadSnapshot(), loadPaymentSnapshot()]);
|
|
if (options.openOverlayAfterBind) setShowOverlay(true);
|
|
setPaymentBusy(false);
|
|
return true;
|
|
}
|
|
|
|
setWalletAddress(address);
|
|
const challengeRes = await fetch("/api/payments/wallets/challenge", {
|
|
method: "POST", headers: authHeaders, body: JSON.stringify({ address }),
|
|
});
|
|
if (!challengeRes.ok) {
|
|
const raw = (await challengeRes.text()).slice(0, 300);
|
|
throw new Error(copy.challengeFailed.replace("{raw}", raw));
|
|
}
|
|
|
|
const challengeJson = (await challengeRes.json()) as { nonce?: string; message?: string };
|
|
const message = String(challengeJson.message || "");
|
|
const nonce = String(challengeJson.nonce || "");
|
|
if (!message || !nonce) throw new Error(copy.challengeInvalid);
|
|
|
|
const signature = await signBindMessage(eth, address, message);
|
|
const verifyRes = await fetch("/api/payments/wallets/verify", {
|
|
method: "POST", headers: authHeaders, body: JSON.stringify({ address, nonce, signature }),
|
|
});
|
|
if (!verifyRes.ok) {
|
|
const raw = (await verifyRes.text()).slice(0, 300);
|
|
throw new Error(copy.verifyFailedRaw.replace("{raw}", raw));
|
|
}
|
|
|
|
setPaymentInfo(`${walletLabel} 绑定成功: ${shortAddress(address)}。${binanceBindHint || "现在可点击“立即订阅并激活服务”。"}`);
|
|
setProviderMode(providerSelection.mode);
|
|
if (options.openOverlayAfterBind) setShowOverlay(true);
|
|
await Promise.all([loadSnapshot(), loadPaymentSnapshot()]);
|
|
return true;
|
|
} catch (error) {
|
|
setPaymentInfo("");
|
|
setPaymentError(normalizePaymentError(error).message);
|
|
return false;
|
|
} finally {
|
|
setPaymentBusy(false);
|
|
}
|
|
};
|
|
|
|
// ── handleUnbindWallet ──────────────────────────────────
|
|
const handleUnbindWallet = async (address: string) => {
|
|
const target = String(address || "").toLowerCase();
|
|
if (!target) return;
|
|
if (!authIsAuthenticated) {
|
|
setPaymentError(copy.loginBeforeBind);
|
|
return;
|
|
}
|
|
const confirmed = window.confirm(copy.unbindConfirm.replace("{address}", shortAddress(target)));
|
|
if (!confirmed) return;
|
|
|
|
setPaymentBusy(true);
|
|
clearPaymentMessages();
|
|
try {
|
|
const headers = await buildAuthedHeaders(true, false);
|
|
const res = await fetch("/api/payments/wallets", {
|
|
method: "DELETE", headers, body: JSON.stringify({ address: target }),
|
|
});
|
|
const raw = await res.text();
|
|
if (!res.ok) {
|
|
let detail = raw;
|
|
try {
|
|
const parsed = JSON.parse(raw);
|
|
detail = String(parsed?.detail || parsed?.error || raw);
|
|
if (detail.trim().startsWith("{")) {
|
|
try {
|
|
const nested = JSON.parse(detail);
|
|
detail = String(nested?.detail || nested?.error || detail);
|
|
} catch { /* ignore nested parse failure */ }
|
|
}
|
|
} catch { /* ignore */ }
|
|
throw new Error(detail || copy.httpError.replace("{status}", String(res.status)).replace("{raw}", ""));
|
|
}
|
|
|
|
let data: Record<string, unknown> = {};
|
|
try { data = raw ? (JSON.parse(raw) as Record<string, unknown>) : {}; }
|
|
catch { data = {}; }
|
|
|
|
const newPrimary = String(data?.new_primary || "").toLowerCase();
|
|
const selectedWalletNorm = String(selectedWallet || "").toLowerCase();
|
|
const walletAddressNorm = String(walletAddress || "").toLowerCase();
|
|
if (selectedWalletNorm === target) setSelectedWallet(newPrimary || "");
|
|
if (walletAddressNorm === target) setWalletAddress(newPrimary || "");
|
|
setBoundWallets((prev) => prev.filter((row) => String(row.address || "").toLowerCase() !== String(target)));
|
|
await loadPaymentSnapshot();
|
|
setPaymentInfo(newPrimary ? copy.unbindDonePrimary.replace("{address}", shortAddress(newPrimary)) : copy.unbindDone);
|
|
} catch (error) {
|
|
const message = normalizePaymentError(error).message;
|
|
const lower = String(message || "").toLowerCase();
|
|
if (lower.includes("unauthorized") || lower.includes("session required") || lower.includes("401")) {
|
|
setPaymentError(`${copy.unbindFailed}: ${copy.authExpired}`);
|
|
return;
|
|
}
|
|
setPaymentError(`${copy.unbindFailed}: ${message}`);
|
|
} finally {
|
|
setPaymentBusy(false);
|
|
}
|
|
};
|
|
|
|
// ── Derived values ──────────────────────────────────────
|
|
const paymentWalletLabel = String(
|
|
selectedWallet || walletAddress || boundWallets.find((row) => row.is_primary)?.address || boundWallets[0]?.address || "",
|
|
).toLowerCase();
|
|
|
|
const hasPayingWallet = Boolean(String(selectedWallet || walletAddress || boundWallets[0]?.address || "").trim());
|
|
|
|
// ==========================================================
|
|
return {
|
|
resolvePaymentProvider,
|
|
connectAndBindWallet,
|
|
handleUnbindWallet,
|
|
waitForReceipt,
|
|
signBindMessage,
|
|
ensureTargetChain,
|
|
paymentWalletLabel,
|
|
hasPayingWallet,
|
|
};
|
|
}
|