fix: simplify wallet choices and use bnb chain

This commit is contained in:
TerryM
2026-06-02 02:58:01 +08:00
parent b9fe7ff168
commit 0edcc80513
7 changed files with 197 additions and 248 deletions

View File

@@ -171,9 +171,26 @@ export const enDict: Dict = {
walletDisconnect: "Disconnect", walletDisconnect: "Disconnect",
walletLoginTitle: "Connect wallet", walletLoginTitle: "Connect wallet",
walletLoginDesc: walletLoginDesc:
"Sign a message to verify your wallet address. No transaction or gas fee.", "Sign a message to verify your BNB Chain wallet address. No transaction or gas fee.",
walletInjected: "Browser wallet / DApp browser", walletInjected: "Use browser wallet",
walletInjectedDesc: "Use the wallet already available in this browser.", 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", walletTokenPocketQr: "TokenPocket QR login",
walletTokenPocketQrDesc: walletTokenPocketQrDesc:
"Recommended for China users. Scan with TokenPocket and sign to return login to this browser.", "Recommended for China users. Scan with TokenPocket and sign to return login to this browser.",

View File

@@ -164,9 +164,23 @@ export const zhDict: Dict = {
walletConnectedAs: "已连接钱包", walletConnectedAs: "已连接钱包",
walletDisconnect: "断开连接", walletDisconnect: "断开连接",
walletLoginTitle: "连接钱包", walletLoginTitle: "连接钱包",
walletLoginDesc: "签名验证钱包地址,不会发起交易,也不需要 Gas。", walletLoginDesc: "签名验证 BNB Chain 钱包地址,不会发起交易,也不需要 Gas。",
walletInjected: "浏览器钱包 / 钱包内置浏览器", walletInjected: "使用浏览器钱包登录",
walletInjectedDesc: "使用当前浏览器里已经注入的钱包。", 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 扫码登录", walletTokenPocketQr: "TokenPocket 扫码登录",
walletTokenPocketQrDesc: walletTokenPocketQrDesc:
"推荐中国用户使用。用 TokenPocket 扫码签名后,会回到当前浏览器完成登录。", "推荐中国用户使用。用 TokenPocket 扫码签名后,会回到当前浏览器完成登录。",

View File

@@ -13,7 +13,7 @@ import {
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState, type ReactNode } from "react"; import { useState, type ReactNode } from "react";
import { http, createConfig, WagmiProvider } from "wagmi"; import { http, createConfig, WagmiProvider } from "wagmi";
import { mainnet } from "wagmi/chains"; import { bsc } from "wagmi/chains";
const projectId = const projectId =
import.meta.env.VITE_WALLETCONNECT_PROJECT_ID || "ark-library-dev-only"; import.meta.env.VITE_WALLETCONNECT_PROJECT_ID || "ark-library-dev-only";
@@ -32,11 +32,11 @@ const connectors = connectorsForWallets(
); );
export const wagmiConfig = createConfig({ export const wagmiConfig = createConfig({
chains: [mainnet], chains: [bsc],
connectors, connectors,
ssr: false, ssr: false,
transports: { transports: {
[mainnet.id]: http(), [bsc.id]: http(),
}, },
}); });

View File

@@ -1,159 +1,84 @@
import { useConnectModal } from "@rainbow-me/rainbowkit"; import { useEffect, useState } from "react";
import { QRCodeSVG } from "qrcode.react";
import { useEffect, useRef, useState } from "react";
import { useAccount, useSignMessage } from "wagmi";
import { useToast } from "../components/Toast";
import { useI18n } from "../i18n"; import { useI18n } from "../i18n";
import {
createTokenPocketLoginRequest,
fetchTokenPocketLoginResult,
requestWalletNonce,
verifyWalletSignature,
type TokenPocketLoginRequest,
} from "./api";
import { openWalletDeepLink } from "./deepLinks"; import { openWalletDeepLink } from "./deepLinks";
import { getInjectedWallet, type WalletKind } from "./injected";
import { useWallet } from "./WalletProvider"; 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() { export function WalletLoginModal() {
const { t } = useI18n(); const { t } = useI18n();
const { showToast } = useToast(); const { closeLoginModal, loginModalOpen, signInInjected } = useWallet();
const { closeLoginModal, completeLogin, loginModalOpen, signInInjected } =
useWallet();
const { openConnectModal } = useConnectModal();
const { address, isConnected } = useAccount();
const { signMessageAsync } = useSignMessage();
const [state, setState] = useState<ModalState>("idle"); const [state, setState] = useState<ModalState>("idle");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [tpRequest, setTpRequest] = useState<TokenPocketLoginRequest | null>( const [mobileDevice, setMobileDevice] = useState(false);
null, const [selectedWallet, setSelectedWallet] = useState<WalletKind | null>(null);
);
const [rainbowPending, setRainbowPending] = useState(false); useEffect(() => {
const rainbowSigningRef = useRef(false); if (!loginModalOpen) return;
setMobileDevice(isMobileDevice());
setSelectedWallet(null);
setError("");
}, [loginModalOpen]);
const close = () => { const close = () => {
if (state === "tpLoading" || state === "rainbowSigning") return; if (state === "signing") return;
closeLoginModal(); closeLoginModal();
setError(""); 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; if (!loginModalOpen) return null;
const startInjected = async () => { const walletName = (kind: WalletKind) => {
setError(""); if (kind === "tokenPocket") return t("walletTokenPocket");
setState("idle"); if (kind === "metaMask") return t("walletMetaMask");
await signInInjected().catch(() => undefined); return t("walletImToken");
}; };
const startTokenPocketQr = async () => { const chooseWallet = async (kind: WalletKind) => {
setError(""); setError("");
setState("tpLoading"); setSelectedWallet(kind);
try {
const req = await createTokenPocketLoginRequest(); const injectedWallet = getInjectedWallet(kind);
setTpRequest(req); if (!injectedWallet) {
setState("tpPolling"); if (mobileDevice) {
} catch { openWalletDeepLink(kind);
setState("idle"); return;
setError(t("walletTpQrFailed")); }
setError(
t("walletInstallSelected").replace("{wallet}", walletName(kind)),
);
return;
} }
};
const startRainbowFallback = () => { setState("signing");
setError(""); await signInInjected(kind)
setRainbowPending(true); .catch((err) => {
openConnectModal?.(); setError(err instanceof Error ? err.message : t("walletLoginFailed"));
if (!openConnectModal) setError(t("walletRainbowUnavailable")); })
.finally(() => setState("idle"));
}; };
return ( return (
@@ -186,97 +111,38 @@ export function WalletLoginModal() {
</div> </div>
<div className="mt-5 grid gap-3"> <div className="mt-5 grid gap-3">
<button <p className="text-sm font-medium text-neutral-300">
type="button" {mobileDevice ? t("walletChooseMobile") : t("walletChooseDesktop")}
onClick={() => void startInjected()} </p>
className="rounded-2xl border border-ark-gold/40 bg-ark-gold/10 p-4 text-left transition hover:bg-ark-gold/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80" <div className="grid gap-2">
> {walletOptions.map((option) => {
<span className="block font-semibold text-ark-gold2"> const signingThis =
{t("walletInjected")} state === "signing" && selectedWallet === option.kind;
</span> return (
<span className="mt-1 block text-sm text-neutral-400"> <button
{t("walletInjectedDesc")} key={option.kind}
</span> type="button"
</button> onClick={() => void chooseWallet(option.kind)}
disabled={state === "signing"}
<div className="rounded-2xl border border-white/10 bg-white/[0.03] p-4"> className="flex items-center justify-between rounded-2xl border border-white/10 bg-[#20202a] px-4 py-4 text-left text-base font-semibold text-neutral-100 transition hover:border-ark-gold/50 hover:bg-ark-gold/10 disabled:cursor-wait disabled:opacity-70"
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between"> >
<div> <span>{t(option.labelKey)}</span>
<h3 className="font-semibold text-white"> <span className="text-sm text-ark-gold">
{t("walletTokenPocketQr")} {signingThis
</h3> ? t("walletSigning")
<p className="mt-1 text-sm leading-6 text-neutral-400"> : mobileDevice
{t("walletTokenPocketQrDesc")} ? t("walletOpen")
</p> : t("walletConnect")}
</div> </span>
<button </button>
type="button" );
onClick={() => void startTokenPocketQr()} })}
disabled={state === "tpLoading"}
className="shrink-0 rounded-full bg-ark-gold px-4 py-2 text-sm font-semibold text-black transition hover:bg-ark-gold2 disabled:cursor-wait disabled:opacity-70"
>
{state === "tpLoading" ? t("loading") : t("walletGenerateQr")}
</button>
</div>
{tpRequest ? (
<div className="mt-4 grid place-items-center gap-3 rounded-2xl bg-white p-4 text-center">
<QRCodeSVG value={tpRequest.qrUrl} size={196} level="M" />
<p className="text-xs font-medium text-neutral-700">
{t("walletQrUseAnotherDevice")}
</p>
</div>
) : null}
</div> </div>
{!mobileDevice ? (
<div className="grid gap-2 sm:grid-cols-3"> <p className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-xs leading-5 text-neutral-400">
<button {t("walletDesktopHint")}
type="button"
onClick={() => openWalletDeepLink("tokenPocket")}
className="rounded-2xl border border-white/10 bg-[#20202a] px-4 py-3 text-sm font-semibold text-neutral-100 transition hover:border-ark-gold/40"
>
{t("walletOpenTokenPocket")}
</button>
<button
type="button"
onClick={() => openWalletDeepLink("metaMask")}
className="rounded-2xl border border-white/10 bg-[#20202a] px-4 py-3 text-sm font-semibold text-neutral-100 transition hover:border-ark-gold/40"
>
{t("walletOpenMetaMask")}
</button>
<button
type="button"
onClick={() => openWalletDeepLink("imToken")}
className="rounded-2xl border border-white/10 bg-[#20202a] px-4 py-3 text-sm font-semibold text-neutral-100 transition hover:border-ark-gold/40"
>
{t("walletOpenImToken")}
</button>
</div>
<div className="rounded-2xl border border-dashed border-white/15 p-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<h3 className="font-semibold text-white">
{t("walletRainbowFallback")}
</h3>
<p className="mt-1 text-sm leading-6 text-neutral-400">
{t("walletRainbowFallbackDesc")}
</p>
</div>
<button
type="button"
onClick={startRainbowFallback}
disabled={state === "rainbowSigning"}
className="shrink-0 rounded-full border border-ark-gold/50 px-4 py-2 text-sm font-semibold text-ark-gold transition hover:bg-ark-gold/10 disabled:cursor-wait disabled:opacity-70"
>
{state === "rainbowSigning"
? t("walletSigning")
: t("walletOpenRainbow")}
</button>
</div>
<p className="mt-3 text-xs leading-5 text-yellow-200/80">
{t("walletNetworkWarning")}
</p> </p>
</div> ) : null}
</div> </div>
{error ? ( {error ? (

View File

@@ -10,7 +10,7 @@ import {
import { useToast } from "../components/Toast"; import { useToast } from "../components/Toast";
import { useI18n } from "../i18n"; import { useI18n } from "../i18n";
import { fetchWalletMe } from "./api"; import { fetchWalletMe } from "./api";
import { signInWithInjectedWallet } from "./injected"; import { signInWithInjectedWallet, type WalletKind } from "./injected";
import { clearWalletToken, readWalletToken, writeWalletToken } from "./token"; import { clearWalletToken, readWalletToken, writeWalletToken } from "./token";
type WalletStatus = "loading" | "loggedOut" | "loggedIn"; type WalletStatus = "loading" | "loggedOut" | "loggedIn";
@@ -22,7 +22,7 @@ type WalletContextValue = {
loginModalOpen: boolean; loginModalOpen: boolean;
openLoginModal: () => void; openLoginModal: () => void;
closeLoginModal: () => void; closeLoginModal: () => void;
signInInjected: () => Promise<void>; signInInjected: (kind?: WalletKind) => Promise<void>;
completeLogin: (token: string, wallet: string) => void; completeLogin: (token: string, wallet: string) => void;
logout: () => void; logout: () => void;
}; };
@@ -80,18 +80,21 @@ export function WalletProvider({ children }: { children: ReactNode }) {
setLoginModalOpen(false); setLoginModalOpen(false);
}, []); }, []);
const signInInjected = useCallback(async () => { const signInInjected = useCallback(
try { async (kind?: WalletKind) => {
const res = await signInWithInjectedWallet(); try {
completeLogin(res.token, res.wallet); const res = await signInWithInjectedWallet(kind);
showToast(t("walletLoginSuccess")); completeLogin(res.token, res.wallet);
} catch (error) { showToast(t("walletLoginSuccess"));
const message = } catch (error) {
error instanceof Error ? error.message : t("walletLoginFailed"); const message =
showToast(message || t("walletLoginFailed"), "error"); error instanceof Error ? error.message : t("walletLoginFailed");
throw error; showToast(message || t("walletLoginFailed"), "error");
} throw error;
}, [completeLogin, showToast, t]); }
},
[completeLogin, showToast, t],
);
const logout = useCallback(() => { const logout = useCallback(() => {
clearWalletToken(); clearWalletToken();

View File

@@ -12,7 +12,7 @@ export function walletDeepLink(
switch (kind) { switch (kind) {
case "tokenPocket": case "tokenPocket":
return `tpdapp://open?params=${encodeURIComponent( return `tpdapp://open?params=${encodeURIComponent(
JSON.stringify({ url: dappUrl, chain: "ETH" }), JSON.stringify({ url: dappUrl, chain: "BSC" }),
)}`; )}`;
case "metaMask": case "metaMask":
return `https://metamask.app.link/dapp/${encodeURIComponent( return `https://metamask.app.link/dapp/${encodeURIComponent(

View File

@@ -1,6 +1,14 @@
import { requestWalletNonce, verifyWalletSignature } from "./api"; import { requestWalletNonce, verifyWalletSignature } from "./api";
export type WalletKind = "tokenPocket" | "metaMask" | "imToken";
const bnbChainIdHex = "0x38";
export type EthereumProvider = { export type EthereumProvider = {
isMetaMask?: boolean;
isTokenPocket?: boolean;
isImToken?: boolean;
providers?: EthereumProvider[];
request: <T = unknown>(args: { request: <T = unknown>(args: {
method: string; method: string;
params?: unknown[]; params?: unknown[];
@@ -13,13 +21,54 @@ export function getInjectedEthereum(): EthereumProvider | null {
return maybeWindow.ethereum ?? 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<void> {
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; token: string;
wallet: string; wallet: string;
}> { }> {
const ethereum = getInjectedEthereum(); const ethereum = getInjectedWallet(kind);
if (!ethereum) throw new Error("No injected wallet found"); if (!ethereum) throw new Error("No injected wallet found");
await ensureBnbChain(ethereum);
const accounts = await ethereum.request<string[]>({ const accounts = await ethereum.request<string[]>({
method: "eth_requestAccounts", method: "eth_requestAccounts",
}); });