diff --git a/src/locales/en.ts b/src/locales/en.ts index 31662f6..6ab44db 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -171,9 +171,26 @@ export const enDict: Dict = { walletDisconnect: "Disconnect", walletLoginTitle: "Connect wallet", walletLoginDesc: - "Sign a message to verify your wallet address. No transaction or gas fee.", - walletInjected: "Browser wallet / DApp browser", - walletInjectedDesc: "Use the wallet already available in this browser.", + "Sign a message to verify your BNB Chain wallet address. No transaction or gas fee.", + walletInjected: "Use browser wallet", + walletInjectedDesc: "Sign with the wallet available in this browser.", + walletNoBrowserWallet: "No browser wallet detected", + walletNoBrowserWalletDesc: + "Install or enable a browser wallet extension, such as MetaMask.", + walletOpenWalletApp: "Open wallet app", + walletOpenWalletAppDesc: + "Open this site in your wallet app, then sign to log in.", + walletTokenPocket: "TokenPocket", + walletMetaMask: "MetaMask", + walletImToken: "imToken", + walletChooseDesktop: + "Choose the wallet you want to use. On desktop, install the matching browser extension and switch to BNB Chain.", + walletChooseMobile: "Choose a wallet app to open this site.", + walletDesktopHint: + "If no wallet opens after clicking, make sure the matching browser extension is installed and enabled.", + walletInstallSelected: + "No {wallet} browser extension detected. Install or enable it, then try again.", + walletOpen: "Open", walletTokenPocketQr: "TokenPocket QR login", walletTokenPocketQrDesc: "Recommended for China users. Scan with TokenPocket and sign to return login to this browser.", diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts index abe2d98..74735fb 100644 --- a/src/locales/zh-CN.ts +++ b/src/locales/zh-CN.ts @@ -164,9 +164,23 @@ export const zhDict: Dict = { walletConnectedAs: "已连接钱包", walletDisconnect: "断开连接", walletLoginTitle: "连接钱包", - walletLoginDesc: "签名验证钱包地址,不会发起交易,也不需要 Gas。", - walletInjected: "浏览器钱包 / 钱包内置浏览器", - walletInjectedDesc: "使用当前浏览器里已经注入的钱包。", + walletLoginDesc: "签名验证 BNB Chain 钱包地址,不会发起交易,也不需要 Gas。", + walletInjected: "使用浏览器钱包登录", + walletInjectedDesc: "签名验证当前浏览器里的钱包。", + walletNoBrowserWallet: "未检测到浏览器钱包", + walletNoBrowserWalletDesc: "请安装或启用浏览器钱包插件,例如 MetaMask。", + walletOpenWalletApp: "打开钱包 App", + walletOpenWalletAppDesc: "请在钱包 App 中打开本站后签名登录。", + walletTokenPocket: "TokenPocket", + walletMetaMask: "MetaMask", + walletImToken: "imToken", + walletChooseDesktop: + "选择你要使用的钱包。电脑端需要先安装对应浏览器插件,并切换到 BNB Chain。", + walletChooseMobile: "选择钱包 App 打开本站。", + walletDesktopHint: + "如果点击后没有弹出钱包,请确认已安装并启用对应的钱包浏览器插件。", + walletInstallSelected: "未检测到 {wallet} 浏览器插件,请先安装或启用后再试。", + walletOpen: "打开", walletTokenPocketQr: "TokenPocket 扫码登录", walletTokenPocketQrDesc: "推荐中国用户使用。用 TokenPocket 扫码签名后,会回到当前浏览器完成登录。", diff --git a/src/wallet/RainbowWalletProvider.tsx b/src/wallet/RainbowWalletProvider.tsx index 7a596a9..83678ed 100644 --- a/src/wallet/RainbowWalletProvider.tsx +++ b/src/wallet/RainbowWalletProvider.tsx @@ -13,7 +13,7 @@ import { import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { useState, type ReactNode } from "react"; import { http, createConfig, WagmiProvider } from "wagmi"; -import { mainnet } from "wagmi/chains"; +import { bsc } from "wagmi/chains"; const projectId = import.meta.env.VITE_WALLETCONNECT_PROJECT_ID || "ark-library-dev-only"; @@ -32,11 +32,11 @@ const connectors = connectorsForWallets( ); export const wagmiConfig = createConfig({ - chains: [mainnet], + chains: [bsc], connectors, ssr: false, transports: { - [mainnet.id]: http(), + [bsc.id]: http(), }, }); diff --git a/src/wallet/WalletLoginModal.tsx b/src/wallet/WalletLoginModal.tsx index d7c8f6f..e90c9da 100644 --- a/src/wallet/WalletLoginModal.tsx +++ b/src/wallet/WalletLoginModal.tsx @@ -1,159 +1,84 @@ -import { useConnectModal } from "@rainbow-me/rainbowkit"; -import { QRCodeSVG } from "qrcode.react"; -import { useEffect, useRef, useState } from "react"; -import { useAccount, useSignMessage } from "wagmi"; -import { useToast } from "../components/Toast"; +import { useEffect, useState } from "react"; import { useI18n } from "../i18n"; -import { - createTokenPocketLoginRequest, - fetchTokenPocketLoginResult, - requestWalletNonce, - verifyWalletSignature, - type TokenPocketLoginRequest, -} from "./api"; import { openWalletDeepLink } from "./deepLinks"; +import { getInjectedWallet, type WalletKind } from "./injected"; import { useWallet } from "./WalletProvider"; -const pollIntervalMs = 1800; +type ModalState = "idle" | "signing"; -type ModalState = "idle" | "tpLoading" | "tpPolling" | "rainbowSigning"; +type WalletOption = { + kind: WalletKind; + labelKey: string; +}; + +const walletOptions: WalletOption[] = [ + { kind: "tokenPocket", labelKey: "walletTokenPocket" }, + { kind: "metaMask", labelKey: "walletMetaMask" }, + { kind: "imToken", labelKey: "walletImToken" }, +]; + +function isMobileDevice(): boolean { + if (typeof navigator === "undefined") return false; + const ua = navigator.userAgent || ""; + return ( + /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile/i.test( + ua, + ) || + (/Macintosh/i.test(ua) && navigator.maxTouchPoints > 1) + ); +} export function WalletLoginModal() { const { t } = useI18n(); - const { showToast } = useToast(); - const { closeLoginModal, completeLogin, loginModalOpen, signInInjected } = - useWallet(); - const { openConnectModal } = useConnectModal(); - const { address, isConnected } = useAccount(); - const { signMessageAsync } = useSignMessage(); + const { closeLoginModal, loginModalOpen, signInInjected } = useWallet(); const [state, setState] = useState("idle"); const [error, setError] = useState(""); - const [tpRequest, setTpRequest] = useState( - null, - ); - const [rainbowPending, setRainbowPending] = useState(false); - const rainbowSigningRef = useRef(false); + const [mobileDevice, setMobileDevice] = useState(false); + const [selectedWallet, setSelectedWallet] = useState(null); + + useEffect(() => { + if (!loginModalOpen) return; + setMobileDevice(isMobileDevice()); + setSelectedWallet(null); + setError(""); + }, [loginModalOpen]); const close = () => { - if (state === "tpLoading" || state === "rainbowSigning") return; + if (state === "signing") return; closeLoginModal(); setError(""); }; - useEffect(() => { - if (!loginModalOpen || !tpRequest) return; - if (state !== "tpPolling") return; - - let cancelled = false; - const abortController = new AbortController(); - const poll = async () => { - try { - const result = await fetchTokenPocketLoginResult( - tpRequest.actionId, - abortController.signal, - ); - if (cancelled) return; - if (result.status === "completed") { - const verified = await verifyWalletSignature({ - address: result.address, - message: result.message, - signature: result.signature, - }); - if (cancelled) return; - completeLogin(verified.token, verified.wallet); - showToast(t("walletLoginSuccess")); - setState("idle"); - setTpRequest(null); - return; - } - if (result.status === "expired" || result.status === "failed") { - setState("idle"); - setError(result.error || t("walletTpExpired")); - } - } catch (err) { - if ( - !cancelled && - !(err instanceof DOMException && err.name === "AbortError") - ) { - setError(t("walletLoginFailed")); - } - } - }; - - void poll(); - const timer = window.setInterval(() => void poll(), pollIntervalMs); - return () => { - cancelled = true; - abortController.abort(); - window.clearInterval(timer); - }; - }, [completeLogin, loginModalOpen, state, tpRequest, t, showToast]); - - useEffect(() => { - if (!rainbowPending || !isConnected || !address) return; - if (rainbowSigningRef.current) return; - rainbowSigningRef.current = true; - setState("rainbowSigning"); - setError(""); - - void (async () => { - try { - const nonce = await requestWalletNonce(address); - const signature = await signMessageAsync({ message: nonce.message }); - const verified = await verifyWalletSignature({ - address, - message: nonce.message, - signature, - }); - completeLogin(verified.token, verified.wallet); - showToast(t("walletLoginSuccess")); - } catch (err) { - const message = - err instanceof Error ? err.message : t("walletLoginFailed"); - setError(message || t("walletLoginFailed")); - showToast(t("walletLoginFailed"), "error"); - } finally { - setState("idle"); - setRainbowPending(false); - rainbowSigningRef.current = false; - } - })(); - }, [ - address, - isConnected, - rainbowPending, - showToast, - signMessageAsync, - t, - completeLogin, - ]); - if (!loginModalOpen) return null; - const startInjected = async () => { - setError(""); - setState("idle"); - await signInInjected().catch(() => undefined); + const walletName = (kind: WalletKind) => { + if (kind === "tokenPocket") return t("walletTokenPocket"); + if (kind === "metaMask") return t("walletMetaMask"); + return t("walletImToken"); }; - const startTokenPocketQr = async () => { + const chooseWallet = async (kind: WalletKind) => { setError(""); - setState("tpLoading"); - try { - const req = await createTokenPocketLoginRequest(); - setTpRequest(req); - setState("tpPolling"); - } catch { - setState("idle"); - setError(t("walletTpQrFailed")); + setSelectedWallet(kind); + + const injectedWallet = getInjectedWallet(kind); + if (!injectedWallet) { + if (mobileDevice) { + openWalletDeepLink(kind); + return; + } + setError( + t("walletInstallSelected").replace("{wallet}", walletName(kind)), + ); + return; } - }; - const startRainbowFallback = () => { - setError(""); - setRainbowPending(true); - openConnectModal?.(); - if (!openConnectModal) setError(t("walletRainbowUnavailable")); + setState("signing"); + await signInInjected(kind) + .catch((err) => { + setError(err instanceof Error ? err.message : t("walletLoginFailed")); + }) + .finally(() => setState("idle")); }; return ( @@ -186,97 +111,38 @@ export function WalletLoginModal() {
- - -
-
-
-

- {t("walletTokenPocketQr")} -

-

- {t("walletTokenPocketQrDesc")} -

-
- -
- {tpRequest ? ( -
- -

- {t("walletQrUseAnotherDevice")} -

-
- ) : null} +

+ {mobileDevice ? t("walletChooseMobile") : t("walletChooseDesktop")} +

+
+ {walletOptions.map((option) => { + const signingThis = + state === "signing" && selectedWallet === option.kind; + return ( + + ); + })}
- -
- - - -
- -
-
-
-

- {t("walletRainbowFallback")} -

-

- {t("walletRainbowFallbackDesc")} -

-
- -
-

- {t("walletNetworkWarning")} + {!mobileDevice ? ( +

+ {t("walletDesktopHint")}

-
+ ) : null}
{error ? ( diff --git a/src/wallet/WalletProvider.tsx b/src/wallet/WalletProvider.tsx index 39f9a49..af43668 100644 --- a/src/wallet/WalletProvider.tsx +++ b/src/wallet/WalletProvider.tsx @@ -10,7 +10,7 @@ import { import { useToast } from "../components/Toast"; import { useI18n } from "../i18n"; import { fetchWalletMe } from "./api"; -import { signInWithInjectedWallet } from "./injected"; +import { signInWithInjectedWallet, type WalletKind } from "./injected"; import { clearWalletToken, readWalletToken, writeWalletToken } from "./token"; type WalletStatus = "loading" | "loggedOut" | "loggedIn"; @@ -22,7 +22,7 @@ type WalletContextValue = { loginModalOpen: boolean; openLoginModal: () => void; closeLoginModal: () => void; - signInInjected: () => Promise; + signInInjected: (kind?: WalletKind) => Promise; completeLogin: (token: string, wallet: string) => void; logout: () => void; }; @@ -80,18 +80,21 @@ export function WalletProvider({ children }: { children: ReactNode }) { setLoginModalOpen(false); }, []); - const signInInjected = useCallback(async () => { - try { - const res = await signInWithInjectedWallet(); - completeLogin(res.token, res.wallet); - showToast(t("walletLoginSuccess")); - } catch (error) { - const message = - error instanceof Error ? error.message : t("walletLoginFailed"); - showToast(message || t("walletLoginFailed"), "error"); - throw error; - } - }, [completeLogin, showToast, t]); + const signInInjected = useCallback( + async (kind?: WalletKind) => { + try { + const res = await signInWithInjectedWallet(kind); + completeLogin(res.token, res.wallet); + showToast(t("walletLoginSuccess")); + } catch (error) { + const message = + error instanceof Error ? error.message : t("walletLoginFailed"); + showToast(message || t("walletLoginFailed"), "error"); + throw error; + } + }, + [completeLogin, showToast, t], + ); const logout = useCallback(() => { clearWalletToken(); diff --git a/src/wallet/deepLinks.ts b/src/wallet/deepLinks.ts index 69ec5f6..33da445 100644 --- a/src/wallet/deepLinks.ts +++ b/src/wallet/deepLinks.ts @@ -12,7 +12,7 @@ export function walletDeepLink( switch (kind) { case "tokenPocket": return `tpdapp://open?params=${encodeURIComponent( - JSON.stringify({ url: dappUrl, chain: "ETH" }), + JSON.stringify({ url: dappUrl, chain: "BSC" }), )}`; case "metaMask": return `https://metamask.app.link/dapp/${encodeURIComponent( diff --git a/src/wallet/injected.ts b/src/wallet/injected.ts index cc16f66..16fab04 100644 --- a/src/wallet/injected.ts +++ b/src/wallet/injected.ts @@ -1,6 +1,14 @@ import { requestWalletNonce, verifyWalletSignature } from "./api"; +export type WalletKind = "tokenPocket" | "metaMask" | "imToken"; + +const bnbChainIdHex = "0x38"; + export type EthereumProvider = { + isMetaMask?: boolean; + isTokenPocket?: boolean; + isImToken?: boolean; + providers?: EthereumProvider[]; request: (args: { method: string; params?: unknown[]; @@ -13,13 +21,54 @@ export function getInjectedEthereum(): EthereumProvider | null { return maybeWindow.ethereum ?? null; } -export async function signInWithInjectedWallet(): Promise<{ +export function getInjectedWallet(kind?: WalletKind): EthereumProvider | null { + const ethereum = getInjectedEthereum(); + if (!ethereum || !kind) return ethereum; + const providers = ethereum.providers?.length + ? ethereum.providers + : [ethereum]; + const match = providers.find((provider) => { + if (kind === "metaMask") return provider.isMetaMask; + if (kind === "tokenPocket") return provider.isTokenPocket; + if (kind === "imToken") return provider.isImToken; + return false; + }); + return match ?? null; +} + +async function ensureBnbChain(ethereum: EthereumProvider): Promise { + try { + await ethereum.request({ + method: "wallet_switchEthereumChain", + params: [{ chainId: bnbChainIdHex }], + }); + } catch (error) { + const code = (error as { code?: number | string }).code; + if (code !== 4902 && code !== "4902") throw error; + await ethereum.request({ + method: "wallet_addEthereumChain", + params: [ + { + blockExplorerUrls: ["https://bscscan.com"], + chainId: bnbChainIdHex, + chainName: "BNB Smart Chain", + nativeCurrency: { decimals: 18, name: "BNB", symbol: "BNB" }, + rpcUrls: ["https://bsc-dataseed.binance.org"], + }, + ], + }); + } +} + +export async function signInWithInjectedWallet(kind?: WalletKind): Promise<{ token: string; wallet: string; }> { - const ethereum = getInjectedEthereum(); + const ethereum = getInjectedWallet(kind); if (!ethereum) throw new Error("No injected wallet found"); + await ensureBnbChain(ethereum); + const accounts = await ethereum.request({ method: "eth_requestAccounts", });