terry-staging #16
@@ -806,7 +806,7 @@ export function PublicLayout() {
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<nav className="fixed inset-x-0 bottom-0 z-40 select-none bg-[#0C0D0F]/95 pb-[max(env(safe-area-inset-bottom),0px)] backdrop-blur md:hidden">
|
<nav className="fixed inset-x-0 bottom-0 z-40 select-none bg-[#0C0D0F]/95 pb-[max(env(safe-area-inset-bottom),0px)] backdrop-blur md:hidden">
|
||||||
<div className="grid h-[68px] grid-cols-3 gap-3 px-5 py-[10px] text-center text-[11px] leading-[17.6px]">
|
<div className="grid h-[68px] grid-cols-4 gap-2 px-4 py-[10px] text-center text-[11px] leading-[17.6px]">
|
||||||
<BottomNavIcon
|
<BottomNavIcon
|
||||||
to={homePath}
|
to={homePath}
|
||||||
label={t("home")}
|
label={t("home")}
|
||||||
@@ -822,6 +822,12 @@ export function PublicLayout() {
|
|||||||
!new URLSearchParams(search).get("sort")
|
!new URLSearchParams(search).get("sort")
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<BottomNavIcon
|
||||||
|
to={lp("/favorites")}
|
||||||
|
label={t("favorites")}
|
||||||
|
icon="bookmark"
|
||||||
|
active={na("favorites")}
|
||||||
|
/>
|
||||||
<BottomNavIcon
|
<BottomNavIcon
|
||||||
to={popularHref}
|
to={popularHref}
|
||||||
label={t("popular")}
|
label={t("popular")}
|
||||||
|
|||||||
@@ -1,47 +1,26 @@
|
|||||||
import { QRCodeSVG } from "qrcode.react";
|
import { QRCodeSVG } from "qrcode.react";
|
||||||
import { ChevronLeft, ChevronRight, LoaderCircle, X } from "lucide-react";
|
import { LoaderCircle, X } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useI18n } from "../i18n";
|
import { useI18n } from "../i18n";
|
||||||
|
import type { WalletKind } from "./injected";
|
||||||
import { openWalletDeepLink } from "./deepLinks";
|
|
||||||
import { getInjectedWallet, type WalletKind } from "./injected";
|
|
||||||
import { useWallet } from "./WalletProvider";
|
import { useWallet } from "./WalletProvider";
|
||||||
import { useWalletConnectLogin } from "./useWalletConnectLogin";
|
import { useWalletConnectLogin } from "./useWalletConnectLogin";
|
||||||
import { WalletBrandIcon } from "./WalletBrandIcon";
|
import { WalletBrandIcon } from "./WalletBrandIcon";
|
||||||
|
|
||||||
type ModalState = "idle" | "signing";
|
|
||||||
type Step = "wallet" | "method";
|
|
||||||
|
|
||||||
const wallets: WalletKind[] = ["tokenPocket", "metaMask", "imToken"];
|
const wallets: WalletKind[] = ["tokenPocket", "metaMask", "imToken"];
|
||||||
|
|
||||||
function isMobileDevice(): boolean {
|
function isMobileDevice(): boolean {
|
||||||
if (typeof navigator === "undefined") return false;
|
if (typeof navigator === "undefined") return false;
|
||||||
const ua = navigator.userAgent || "";
|
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile/i.test(
|
||||||
if (
|
navigator.userAgent || "",
|
||||||
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile/i.test(
|
);
|
||||||
ua,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
// iPadOS 13+ reports a desktop "Macintosh" UA. A genuine touch-primary iPad
|
|
||||||
// exposes a coarse pointer; a Mac (even with a touch peripheral) keeps a fine
|
|
||||||
// pointer, so it stays on the desktop flow instead of the wallet-app jump.
|
|
||||||
const coarsePointer =
|
|
||||||
typeof window !== "undefined" &&
|
|
||||||
typeof window.matchMedia === "function" &&
|
|
||||||
window.matchMedia("(pointer: coarse)").matches;
|
|
||||||
return /Macintosh/i.test(ua) && navigator.maxTouchPoints > 1 && coarsePointer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WalletLoginModal() {
|
export function WalletLoginModal() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { closeLoginModal, loginModalOpen, signInInjected } = useWallet();
|
const { closeLoginModal, loginModalOpen } = useWallet();
|
||||||
const wc = useWalletConnectLogin();
|
const wc = useWalletConnectLogin();
|
||||||
const [step, setStep] = useState<Step>("wallet");
|
|
||||||
const [selected, setSelected] = useState<WalletKind | null>(null);
|
const [selected, setSelected] = useState<WalletKind | null>(null);
|
||||||
const [state, setState] = useState<ModalState>("idle");
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
const [mobileDevice, setMobileDevice] = useState(false);
|
const [mobileDevice, setMobileDevice] = useState(false);
|
||||||
|
|
||||||
const resetWalletConnect = wc.reset;
|
const resetWalletConnect = wc.reset;
|
||||||
@@ -49,74 +28,34 @@ export function WalletLoginModal() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!loginModalOpen) return;
|
if (!loginModalOpen) return;
|
||||||
setMobileDevice(isMobileDevice());
|
setMobileDevice(isMobileDevice());
|
||||||
setStep("wallet");
|
|
||||||
setSelected(null);
|
setSelected(null);
|
||||||
setState("idle");
|
|
||||||
setError("");
|
|
||||||
resetWalletConnect();
|
resetWalletConnect();
|
||||||
}, [loginModalOpen, resetWalletConnect]);
|
}, [loginModalOpen, resetWalletConnect]);
|
||||||
|
|
||||||
const busy = state === "signing";
|
|
||||||
|
|
||||||
const close = () => {
|
|
||||||
if (busy) return;
|
|
||||||
closeLoginModal();
|
|
||||||
setError("");
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!loginModalOpen) return null;
|
if (!loginModalOpen) return null;
|
||||||
|
|
||||||
const walletName = (kind: WalletKind) => t(walletNameKey(kind));
|
const walletName = (kind: WalletKind) => t(walletNameKey(kind));
|
||||||
// A wallet's browser flow only works if THAT wallet is injected right now
|
const walletHint = (kind: WalletKind) => {
|
||||||
// (its desktop extension, or its in-app browser on mobile).
|
if (kind === "tokenPocket") {
|
||||||
const injectedAvailable = (kind: WalletKind) =>
|
return mobileDevice
|
||||||
Boolean(getInjectedWallet(kind));
|
? t("walletTpMobileDesc")
|
||||||
// All scan/app login paths use RainbowKit/WalletConnect on BNB Chain.
|
: t("walletTokenPocketQrDesc");
|
||||||
const qrAvailable = () => wc.available;
|
}
|
||||||
|
return t("walletRainbowFallbackDesc");
|
||||||
|
};
|
||||||
|
const busy = wc.state !== "idle";
|
||||||
|
|
||||||
const resetFlow = () => {
|
const close = () => {
|
||||||
setError("");
|
closeLoginModal();
|
||||||
setState("idle");
|
setSelected(null);
|
||||||
wc.reset();
|
wc.reset();
|
||||||
};
|
};
|
||||||
|
|
||||||
const pickWallet = (kind: WalletKind) => {
|
const startWalletLogin = (kind: WalletKind) => {
|
||||||
resetFlow();
|
|
||||||
setSelected(kind);
|
setSelected(kind);
|
||||||
setStep("method");
|
|
||||||
};
|
|
||||||
|
|
||||||
const back = () => {
|
|
||||||
if (busy) return;
|
|
||||||
resetFlow();
|
|
||||||
setSelected(null);
|
|
||||||
setStep("wallet");
|
|
||||||
};
|
|
||||||
|
|
||||||
const signInjectedFor = async (kind: WalletKind) => {
|
|
||||||
setError("");
|
|
||||||
if (!getInjectedWallet(kind)) {
|
|
||||||
setError(
|
|
||||||
t("walletInstallSelected").replace("{wallet}", walletName(kind)),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setState("signing");
|
|
||||||
await signInInjected(kind)
|
|
||||||
.catch((err) => {
|
|
||||||
setError(err instanceof Error ? err.message : t("walletLoginFailed"));
|
|
||||||
})
|
|
||||||
.finally(() => setState("idle"));
|
|
||||||
};
|
|
||||||
|
|
||||||
const chooseQr = (kind: WalletKind) => {
|
|
||||||
setError("");
|
|
||||||
void wc.start(kind);
|
void wc.start(kind);
|
||||||
};
|
};
|
||||||
|
|
||||||
const iconButtonClass =
|
|
||||||
"inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full border border-white/10 text-neutral-300 transition hover:border-ark-gold/50 hover:text-ark-gold";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-[120] flex items-end justify-center bg-black/70 px-3 pb-3 pt-10 backdrop-blur-sm md:items-center md:p-6"
|
className="fixed inset-0 z-[120] flex items-end justify-center bg-black/70 px-3 pb-3 pt-10 backdrop-blur-sm md:items-center md:p-6"
|
||||||
@@ -126,17 +65,6 @@ export function WalletLoginModal() {
|
|||||||
>
|
>
|
||||||
<div className="max-h-[92dvh] w-full max-w-[460px] overflow-y-auto rounded-3xl border border-white/10 bg-[#17171d] p-5 shadow-2xl shadow-black/70 md:p-6">
|
<div className="max-h-[92dvh] w-full max-w-[460px] overflow-y-auto rounded-3xl border border-white/10 bg-[#17171d] p-5 shadow-2xl shadow-black/70 md:p-6">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex min-w-0 items-start gap-2">
|
|
||||||
{step === "method" ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={back}
|
|
||||||
aria-label={t("walletBack")}
|
|
||||||
className={`${iconButtonClass} mt-0.5`}
|
|
||||||
>
|
|
||||||
<ChevronLeft size={18} />
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h2
|
<h2
|
||||||
id="wallet-login-title"
|
id="wallet-login-title"
|
||||||
@@ -145,155 +73,65 @@ export function WalletLoginModal() {
|
|||||||
{t("walletLoginTitle")}
|
{t("walletLoginTitle")}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-2 text-sm leading-6 text-neutral-400">
|
<p className="mt-2 text-sm leading-6 text-neutral-400">
|
||||||
{step === "wallet"
|
{t("walletLoginDesc")}
|
||||||
? t("walletLoginDesc")
|
|
||||||
: t("walletChooseMethod")}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={close}
|
onClick={close}
|
||||||
aria-label={t("close")}
|
aria-label={t("close")}
|
||||||
className={iconButtonClass}
|
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full border border-white/10 text-neutral-300 transition hover:border-ark-gold/50 hover:text-ark-gold"
|
||||||
>
|
>
|
||||||
<X size={18} />
|
<X size={18} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Step 1 — choose a wallet. */}
|
|
||||||
{step === "wallet" ? (
|
|
||||||
<div className="mt-5 grid gap-2">
|
<div className="mt-5 grid gap-2">
|
||||||
{wallets.map((kind) => (
|
{wallets.map((kind) => {
|
||||||
|
const active = selected === kind;
|
||||||
|
const connecting = active && wc.state === "connecting";
|
||||||
|
const signing = active && wc.state === "signing";
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
key={kind}
|
key={kind}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => pickWallet(kind)}
|
onClick={() => startWalletLogin(kind)}
|
||||||
className="flex items-center gap-3 rounded-2xl border border-white/10 bg-[#20202a] px-4 py-4 text-left transition hover:border-ark-gold/50 hover:bg-ark-gold/10"
|
disabled={!wc.available || busy}
|
||||||
|
className={`flex items-center gap-3 rounded-2xl border px-4 py-4 text-left transition ${
|
||||||
|
active
|
||||||
|
? "border-ark-gold/60 bg-ark-gold/10"
|
||||||
|
: "border-white/10 bg-[#20202a] hover:border-ark-gold/50 hover:bg-ark-gold/10"
|
||||||
|
} disabled:cursor-wait disabled:opacity-70`}
|
||||||
>
|
>
|
||||||
<WalletBrandIcon kind={kind} size={32} />
|
<WalletBrandIcon kind={kind} size={32} />
|
||||||
<span className="flex-1 text-base font-semibold text-neutral-100">
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block text-base font-semibold text-neutral-100">
|
||||||
{walletName(kind)}
|
{walletName(kind)}
|
||||||
</span>
|
</span>
|
||||||
<ChevronRight size={18} className="text-neutral-500" />
|
<span className="mt-1 block text-xs leading-5 text-neutral-400">
|
||||||
</button>
|
{connecting
|
||||||
))}
|
? t("walletConnecting")
|
||||||
</div>
|
: signing
|
||||||
) : selected ? (
|
? t("walletSigning")
|
||||||
<div className="mt-5 grid gap-3">
|
: walletHint(kind)}
|
||||||
{/* Selected wallet header. */}
|
|
||||||
<div className="flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3">
|
|
||||||
<WalletBrandIcon kind={selected} size={28} />
|
|
||||||
<span className="text-base font-semibold text-white">
|
|
||||||
{walletName(selected)}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
|
||||||
|
|
||||||
{!mobileDevice && !injectedAvailable(selected) ? (
|
|
||||||
<p className="rounded-2xl border border-amber-400/30 bg-amber-400/10 px-4 py-3 text-sm leading-6 text-amber-100">
|
|
||||||
{t("walletInstallSelected").replace(
|
|
||||||
"{wallet}",
|
|
||||||
walletName(selected),
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{mobileDevice ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => openWalletDeepLink(selected)}
|
|
||||||
className="flex flex-col items-start gap-1 rounded-2xl border border-white/10 bg-[#20202a] px-4 py-3 text-left transition hover:border-ark-gold/50 hover:bg-ark-gold/10"
|
|
||||||
>
|
|
||||||
<span className="text-base font-semibold text-neutral-100">
|
|
||||||
{t("walletOpenWalletApp")}
|
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs leading-5 text-neutral-400">
|
{connecting || signing ? (
|
||||||
{t("walletOpenWalletAppDesc")}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* Method: browser wallet (injected). Hidden on normal mobile browsers,
|
|
||||||
because only wallet in-app browsers expose an injected wallet. */}
|
|
||||||
{(() => {
|
|
||||||
const ok = injectedAvailable(selected);
|
|
||||||
if (mobileDevice) return null;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void signInjectedFor(selected)}
|
|
||||||
disabled={!ok || busy}
|
|
||||||
className={`flex flex-col items-start gap-1 rounded-2xl border px-4 py-3 text-left transition ${
|
|
||||||
ok
|
|
||||||
? "border-white/10 bg-[#20202a] hover:border-ark-gold/50 hover:bg-ark-gold/10"
|
|
||||||
: "cursor-not-allowed border-white/5 bg-white/[0.02] opacity-60"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="flex items-center gap-2 text-base font-semibold text-neutral-100">
|
|
||||||
{state === "signing" ? (
|
|
||||||
<LoaderCircle className="h-4 w-4 animate-spin text-ark-gold" />
|
<LoaderCircle className="h-4 w-4 animate-spin text-ark-gold" />
|
||||||
) : null}
|
) : null}
|
||||||
{mobileDevice ? t("walletUseCurrent") : t("walletInjected")}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs leading-5 text-neutral-400">
|
|
||||||
{ok
|
|
||||||
? t("walletInjectedDesc")
|
|
||||||
: mobileDevice
|
|
||||||
? t("walletOpenWalletAppDesc")
|
|
||||||
: t("walletInstallSelected").replace(
|
|
||||||
"{wallet}",
|
|
||||||
walletName(selected),
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})()}
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Method: scan to log in. */}
|
{!wc.available ? (
|
||||||
{(() => {
|
<p className="mt-4 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-neutral-400">
|
||||||
const ok = qrAvailable();
|
{t("walletRainbowUnavailable")}
|
||||||
const isTp = selected === "tokenPocket";
|
|
||||||
const qrBusy = wc.state !== "idle";
|
|
||||||
const qrLabel =
|
|
||||||
wc.state === "connecting"
|
|
||||||
? t("walletConnecting")
|
|
||||||
: wc.state === "signing"
|
|
||||||
? t("walletSigning")
|
|
||||||
: t("walletQrLogin");
|
|
||||||
return (
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => chooseQr(selected)}
|
|
||||||
disabled={!ok || qrBusy || busy}
|
|
||||||
className={`flex flex-col items-start gap-1 rounded-2xl border px-4 py-3 text-left transition ${
|
|
||||||
ok
|
|
||||||
? "border-white/10 bg-[#20202a] hover:border-ark-gold/50 hover:bg-ark-gold/10"
|
|
||||||
: "cursor-not-allowed border-white/5 bg-white/[0.02] opacity-60"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="text-base font-semibold text-neutral-100">
|
|
||||||
{qrLabel}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs leading-5 text-neutral-400">
|
|
||||||
{ok
|
|
||||||
? isTp
|
|
||||||
? mobileDevice
|
|
||||||
? t("walletTpMobileDesc")
|
|
||||||
: t("walletTokenPocketQrDesc")
|
|
||||||
: t("walletRainbowFallbackDesc")
|
|
||||||
: t("walletRainbowUnavailable")}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{ok ? (
|
|
||||||
<p className="text-xs leading-5 text-amber-300/80">
|
|
||||||
{t("walletNetworkWarning")}
|
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{wc.qrUri ? (
|
{selected && wc.qrUri ? (
|
||||||
<div className="grid place-items-center gap-2 rounded-2xl bg-white p-4 text-center">
|
<div className="mt-4 grid place-items-center gap-2 rounded-2xl bg-white p-4 text-center">
|
||||||
<QRCodeSVG value={wc.qrUri} size={180} level="M" />
|
<QRCodeSVG value={wc.qrUri} size={180} level="M" />
|
||||||
<p className="text-xs font-medium text-neutral-700">
|
<p className="text-xs font-medium text-neutral-700">
|
||||||
{mobileDevice
|
{mobileDevice
|
||||||
@@ -302,15 +140,16 @@ export function WalletLoginModal() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
|
||||||
);
|
{wc.error ? (
|
||||||
})()}
|
<p className="mt-4 rounded-2xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-200">
|
||||||
</div>
|
{wc.error}
|
||||||
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{error || wc.error ? (
|
{selected ? (
|
||||||
<p className="mt-4 rounded-2xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-200">
|
<p className="mt-4 text-xs leading-5 text-amber-300/80">
|
||||||
{error || wc.error}
|
{t("walletNetworkWarning")}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,15 @@ import { requestWalletNonce, verifyWalletSignature } from "./api";
|
|||||||
|
|
||||||
export type WalletKind = "tokenPocket" | "metaMask" | "imToken";
|
export type WalletKind = "tokenPocket" | "metaMask" | "imToken";
|
||||||
|
|
||||||
|
const BNB_CHAIN_ID_HEX = "0x38";
|
||||||
|
const BNB_CHAIN_PARAMS = {
|
||||||
|
chainId: BNB_CHAIN_ID_HEX,
|
||||||
|
chainName: "BNB Smart Chain",
|
||||||
|
nativeCurrency: { name: "BNB", symbol: "BNB", decimals: 18 },
|
||||||
|
rpcUrls: ["https://bsc-dataseed.binance.org"],
|
||||||
|
blockExplorerUrls: ["https://bscscan.com"],
|
||||||
|
};
|
||||||
|
|
||||||
export type EthereumProvider = {
|
export type EthereumProvider = {
|
||||||
isMetaMask?: boolean;
|
isMetaMask?: boolean;
|
||||||
isTokenPocket?: boolean;
|
isTokenPocket?: boolean;
|
||||||
@@ -13,6 +22,116 @@ export type EthereumProvider = {
|
|||||||
}) => Promise<T>;
|
}) => Promise<T>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function isAddress(value: unknown): value is string {
|
||||||
|
return typeof value === "string" && /^0x[a-fA-F0-9]{40}$/.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function utf8ToHex(value: string): string {
|
||||||
|
return `0x${Array.from(new TextEncoder().encode(value), (byte) =>
|
||||||
|
byte.toString(16).padStart(2, "0"),
|
||||||
|
).join("")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorText(error: unknown): string {
|
||||||
|
if (!error || typeof error !== "object") return String(error ?? "");
|
||||||
|
const parts: string[] = [];
|
||||||
|
const record = error as Record<string, unknown>;
|
||||||
|
for (const key of ["shortMessage", "message", "details"]) {
|
||||||
|
const value = record[key];
|
||||||
|
if (typeof value === "string") parts.push(value);
|
||||||
|
}
|
||||||
|
if (record.cause) parts.push(errorText(record.cause));
|
||||||
|
return parts.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNoAccountError(error: unknown): boolean {
|
||||||
|
return /wallet must has at least one account|wallet must has one account|must have at least one account|no wallet account returned/i.test(
|
||||||
|
errorText(error),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeWalletError(error: unknown): Error {
|
||||||
|
if (isNoAccountError(error)) return new Error("walletNoAccount");
|
||||||
|
if (error instanceof Error) return error;
|
||||||
|
const message = errorText(error);
|
||||||
|
return new Error(message || "Wallet login failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldRetryPersonalSign(error: unknown): boolean {
|
||||||
|
const text = errorText(error);
|
||||||
|
return /wallet must has at least one account|wallet must has one account|must have at least one account|invalid params|invalid account|account not found/i.test(
|
||||||
|
text,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureBnbChain(ethereum: EthereumProvider): Promise<void> {
|
||||||
|
const chainId = await ethereum
|
||||||
|
.request<string>({ method: "eth_chainId" })
|
||||||
|
.catch(() => "");
|
||||||
|
if (chainId.toLowerCase() === BNB_CHAIN_ID_HEX) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ethereum.request({
|
||||||
|
method: "wallet_switchEthereumChain",
|
||||||
|
params: [{ chainId: BNB_CHAIN_ID_HEX }],
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const code = (error as { code?: number | string } | null)?.code;
|
||||||
|
if (code !== 4902 && code !== "4902") throw error;
|
||||||
|
await ethereum.request({
|
||||||
|
method: "wallet_addEthereumChain",
|
||||||
|
params: [BNB_CHAIN_PARAMS],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestInjectedAddress(
|
||||||
|
ethereum: EthereumProvider,
|
||||||
|
): Promise<string> {
|
||||||
|
const existingAccounts: unknown[] = await ethereum
|
||||||
|
.request<unknown[]>({ method: "eth_accounts" })
|
||||||
|
.catch((): unknown[] => []);
|
||||||
|
const existingAddress = existingAccounts.find(isAddress);
|
||||||
|
if (existingAddress) return existingAddress;
|
||||||
|
|
||||||
|
const requestedAccounts = await ethereum
|
||||||
|
.request<unknown[]>({
|
||||||
|
method: "eth_requestAccounts",
|
||||||
|
})
|
||||||
|
.catch((error: unknown): never => {
|
||||||
|
throw normalizeWalletError(error);
|
||||||
|
});
|
||||||
|
const requestedAddress = requestedAccounts.find(isAddress);
|
||||||
|
if (!requestedAddress) throw new Error("walletNoAccount");
|
||||||
|
return requestedAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function personalSign(params: {
|
||||||
|
ethereum: EthereumProvider;
|
||||||
|
message: string;
|
||||||
|
address: string;
|
||||||
|
}): Promise<string> {
|
||||||
|
const { ethereum, message, address } = params;
|
||||||
|
const hexMessage = utf8ToHex(message);
|
||||||
|
try {
|
||||||
|
return await ethereum.request<string>({
|
||||||
|
method: "personal_sign",
|
||||||
|
params: [hexMessage, address],
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (!shouldRetryPersonalSign(error)) throw error;
|
||||||
|
// Some injected wallets incorrectly expect the legacy param order.
|
||||||
|
return ethereum
|
||||||
|
.request<string>({
|
||||||
|
method: "personal_sign",
|
||||||
|
params: [address, hexMessage],
|
||||||
|
})
|
||||||
|
.catch((retryError: unknown): never => {
|
||||||
|
throw normalizeWalletError(retryError);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function getInjectedEthereum(): EthereumProvider | null {
|
export function getInjectedEthereum(): EthereumProvider | null {
|
||||||
if (typeof window === "undefined") return null;
|
if (typeof window === "undefined") return null;
|
||||||
const maybeWindow = window as typeof window & { ethereum?: EthereumProvider };
|
const maybeWindow = window as typeof window & { ethereum?: EthereumProvider };
|
||||||
@@ -69,23 +188,22 @@ export async function signInWithInjectedWallet(kind?: WalletKind): Promise<{
|
|||||||
throw new Error("No injected wallet found");
|
throw new Error("No injected wallet found");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Login is signature-only (EIP-191 personal_sign). The backend verifies the
|
// BNB Smart Chain is EVM-compatible, so browser wallets still expose the
|
||||||
// recovered address and never inspects chainId, so we deliberately do NOT
|
// standard EIP-1193 method names (`eth_*`) while operating on BNB chain 56.
|
||||||
// switch or add any chain — that only adds a failure-prone wallet popup.
|
console.info("[wallet-login] requesting BNB wallet account…");
|
||||||
console.info("[wallet-login] requesting accounts (eth_requestAccounts)…");
|
const address = await requestInjectedAddress(ethereum);
|
||||||
const accounts = await ethereum.request<string[]>({
|
console.info("[wallet-login] account", address);
|
||||||
method: "eth_requestAccounts",
|
|
||||||
});
|
console.info("[wallet-login] ensuring BNB Chain (0x38)…");
|
||||||
console.info("[wallet-login] accounts", accounts);
|
await ensureBnbChain(ethereum);
|
||||||
const address = accounts[0];
|
|
||||||
if (!address) throw new Error("No wallet account returned");
|
|
||||||
|
|
||||||
console.info("[wallet-login] requesting nonce for", address);
|
console.info("[wallet-login] requesting nonce for", address);
|
||||||
const nonce = await requestWalletNonce(address);
|
const nonce = await requestWalletNonce(address);
|
||||||
console.info("[wallet-login] got nonce, requesting personal_sign…");
|
console.info("[wallet-login] got nonce, requesting personal_sign…");
|
||||||
const signature = await ethereum.request<string>({
|
const signature = await personalSign({
|
||||||
method: "personal_sign",
|
ethereum,
|
||||||
params: [nonce.message, address],
|
message: nonce.message,
|
||||||
|
address,
|
||||||
});
|
});
|
||||||
console.info("[wallet-login] signed, verifying with backend…");
|
console.info("[wallet-login] signed, verifying with backend…");
|
||||||
const result = await verifyWalletSignature({
|
const result = await verifyWalletSignature({
|
||||||
|
|||||||
Reference in New Issue
Block a user