terry-wallet-login #15

Merged
terry merged 95 commits from terry-wallet-login into terry-staging 2026-06-05 16:32:43 +00:00
3 changed files with 119 additions and 72 deletions
Showing only changes of commit 4d38c4513d - Show all commits

View File

@@ -51,9 +51,14 @@ export function WalletLoginModal() {
wc.reset(); wc.reset();
}; };
const startWalletLogin = (kind: WalletKind) => { const selectWallet = (kind: WalletKind) => {
setSelected(kind); setSelected(kind);
void wc.start(kind); wc.reset();
};
const startWalletLogin = (kind: WalletKind, mode: "deeplink" | "qr") => {
setSelected(kind);
void wc.start(kind, mode);
}; };
return ( return (
@@ -92,16 +97,23 @@ export function WalletLoginModal() {
const connecting = active && wc.state === "connecting"; const connecting = active && wc.state === "connecting";
const signing = active && wc.state === "signing"; const signing = active && wc.state === "signing";
return ( return (
<button <div
key={kind} key={kind}
type="button" className={`rounded-2xl border p-3 transition ${
onClick={() => startWalletLogin(kind)}
disabled={!wc.available || busy}
className={`flex items-center gap-3 rounded-2xl border px-4 py-4 text-left transition ${
active active
? "border-ark-gold/60 bg-ark-gold/10" ? "border-ark-gold/60 bg-ark-gold/10"
: "border-white/10 bg-[#20202a] hover:border-ark-gold/50 hover:bg-ark-gold/10" : "border-white/10 bg-[#20202a]"
} disabled:cursor-wait disabled:opacity-70`} }`}
>
<button
type="button"
onClick={() =>
mobileDevice
? selectWallet(kind)
: startWalletLogin(kind, "qr")
}
disabled={!wc.available || busy}
className="flex w-full items-center gap-3 text-left disabled:cursor-wait disabled:opacity-70"
> >
<WalletBrandIcon kind={kind} size={32} /> <WalletBrandIcon kind={kind} size={32} />
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
@@ -120,6 +132,28 @@ export function WalletLoginModal() {
<LoaderCircle className="h-4 w-4 animate-spin text-ark-gold" /> <LoaderCircle className="h-4 w-4 animate-spin text-ark-gold" />
) : null} ) : null}
</button> </button>
{mobileDevice && active ? (
<div className="mt-3 grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => startWalletLogin(kind, "deeplink")}
disabled={!wc.available || busy}
className="rounded-full bg-ark-gold px-3 py-2 text-sm font-bold text-black transition hover:bg-ark-gold2 disabled:cursor-wait disabled:opacity-70"
>
{t("walletOpenWalletApp")}
</button>
<button
type="button"
onClick={() => startWalletLogin(kind, "qr")}
disabled={!wc.available || busy}
className="rounded-full border border-ark-gold/50 px-3 py-2 text-sm font-semibold text-ark-gold transition hover:bg-ark-gold/10 disabled:cursor-wait disabled:opacity-70"
>
{t("walletQrLogin")}
</button>
</div>
) : null}
</div>
); );
})} })}
</div> </div>

View File

@@ -15,6 +15,18 @@ import { clearWalletToken, readWalletToken, writeWalletToken } from "./token";
type WalletStatus = "loading" | "loggedOut" | "loggedIn"; type WalletStatus = "loading" | "loggedOut" | "loggedIn";
const localWalletTokenPrefix = "local-wallet:";
export function localWalletToken(wallet: string): string {
return `${localWalletTokenPrefix}${wallet}`;
}
function walletFromLocalToken(token: string): string | null {
return token.startsWith(localWalletTokenPrefix)
? token.slice(localWalletTokenPrefix.length)
: null;
}
type WalletContextValue = { type WalletContextValue = {
address: string | null; address: string | null;
token: string | null; token: string | null;
@@ -52,6 +64,13 @@ export function WalletProvider({ children }: { children: ReactNode }) {
return; return;
} }
const localWallet = walletFromLocalToken(token);
if (localWallet) {
setAddress(localWallet);
setStatus("loggedIn");
return;
}
setStatus("loading"); setStatus("loading");
fetchWalletMe(token) fetchWalletMe(token)
.then((me) => { .then((me) => {

View File

@@ -1,12 +1,12 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useAccount, useConnect, useDisconnect, useSignMessage } from "wagmi"; import { useAccount, useConnect, useDisconnect } from "wagmi";
import { bsc } from "wagmi/chains"; import { bsc } from "wagmi/chains";
import { requestWalletNonce, verifyWalletSignature } from "./api";
import { hasWalletConnectProjectId } from "./RainbowWalletProvider"; import { hasWalletConnectProjectId } from "./RainbowWalletProvider";
import type { WalletKind } from "./injected"; import type { WalletKind } from "./injected";
import { useWallet } from "./WalletProvider"; import { localWalletToken, useWallet } from "./WalletProvider";
export type WalletConnectLoginState = "idle" | "connecting" | "signing"; export type WalletConnectLoginState = "idle" | "connecting" | "signing";
export type WalletConnectLoginMode = "deeplink" | "qr";
function isMobileDevice(): boolean { function isMobileDevice(): boolean {
if (typeof navigator === "undefined") return false; if (typeof navigator === "undefined") return false;
@@ -15,13 +15,28 @@ function isMobileDevice(): boolean {
); );
} }
function walletConnectDeeplink(
kind: WalletKind | undefined,
uri: string,
): string | null {
if (kind === "tokenPocket") {
return `tpoutside://wc?uri=${encodeURIComponent(uri)}`;
}
if (kind === "metaMask") {
return `https://metamask.app.link/wc?uri=${encodeURIComponent(uri)}`;
}
if (kind === "imToken") {
return `imtokenv2://wc?uri=${encodeURIComponent(uri)}`;
}
return null;
}
/** /**
* MetaMask / imToken QR fallback via RainbowKit + WalletConnect. * MetaMask / imToken QR fallback via RainbowKit + WalletConnect.
* *
* Flow: open the RainbowKit connect modal (WalletConnect QR) -> once an account * Flow: connect through RainbowKit/Wagmi on BNB Chain -> once an account is
* is connected, request a nonce, sign it with `personal_sign` through wagmi, * connected, complete a local frontend wallet session. No message signature,
* verify against the backend and complete our own JWT login. The wagmi/WC * backend nonce, or verify call is required.
* session is only needed for the signature, so we disconnect right after.
* *
* Entirely gated behind a real `VITE_WALLETCONNECT_PROJECT_ID`: when it is * Entirely gated behind a real `VITE_WALLETCONNECT_PROJECT_ID`: when it is
* missing `available` is false and `start` is a no-op, so callers can hide or * missing `available` is false and `start` is a no-op, so callers can hide or
@@ -31,7 +46,6 @@ export function useWalletConnectLogin() {
const available = hasWalletConnectProjectId(); const available = hasWalletConnectProjectId();
const { completeLogin } = useWallet(); const { completeLogin } = useWallet();
const { address, isConnected } = useAccount(); const { address, isConnected } = useAccount();
const { signMessageAsync } = useSignMessage();
const { connectAsync, connectors } = useConnect(); const { connectAsync, connectors } = useConnect();
const { disconnect } = useDisconnect(); const { disconnect } = useDisconnect();
const [state, setState] = useState<WalletConnectLoginState>("idle"); const [state, setState] = useState<WalletConnectLoginState>("idle");
@@ -50,17 +64,24 @@ export function useWalletConnectLogin() {
}, []); }, []);
const start = useCallback( const start = useCallback(
async (preferredWallet?: WalletKind) => { async (
preferredWallet?: WalletKind,
mode: WalletConnectLoginMode = "qr",
) => {
if (!available) return; if (!available) return;
setError(""); setError("");
setQrUri(""); setQrUri("");
pendingRef.current = true; pendingRef.current = true;
setState("connecting"); setState("connecting");
// This modal is QR/WalletConnect-only. RainbowKit also exposes wallet-
// specific injected connectors (for example `tokenPocket`) when an
// extension is installed; using those here makes the click try the local
// browser extension and can fail with "wallet must has at least one
// account" before a QR is shown.
const connector = const connector =
connectors.find((item) => item.id === preferredWallet) ?? connectors.find((item) => item.type === "walletConnect") ??
connectors.find((item) => item.id === "walletConnect") ?? connectors.find((item) => item.id === "walletConnect");
connectors.find((item) => item.type === "walletConnect");
if (!connector) { if (!connector) {
pendingRef.current = false; pendingRef.current = false;
@@ -88,11 +109,10 @@ export function useWalletConnectLogin() {
preferredWallet, preferredWallet,
connectorId: connector.id, connectorId: connector.id,
}); });
setQrUri(message.data); if (mode === "qr") setQrUri(message.data);
if (preferredWallet === "tokenPocket" && isMobileDevice()) { const deeplink = walletConnectDeeplink(preferredWallet, message.data);
window.location.href = `tpoutside://wc?uri=${encodeURIComponent( if (mode === "deeplink" && deeplink && isMobileDevice()) {
message.data, window.location.href = deeplink;
)}`;
} }
}; };
@@ -120,37 +140,11 @@ export function useWalletConnectLogin() {
useEffect(() => { useEffect(() => {
if (!pendingRef.current || !isConnected || !address) return; if (!pendingRef.current || !isConnected || !address) return;
pendingRef.current = false; pendingRef.current = false;
setState("signing"); completeLogin(localWalletToken(address), address);
let cancelled = false;
void (async () => {
try {
const nonce = await requestWalletNonce(address);
const signature = await signMessageAsync({ message: nonce.message });
const verified = await verifyWalletSignature({
address,
message: nonce.message,
signature,
});
if (cancelled) return;
completeLogin(verified.token, verified.wallet);
setState("idle");
} catch (err) {
if (!cancelled) {
setError(
err instanceof Error ? err.message : "WalletConnect login failed",
);
setQrUri(""); setQrUri("");
setState("idle"); setState("idle");
}
} finally {
// We only needed a one-off signature, not a persistent wagmi session.
disconnect(); disconnect();
} }, [address, completeLogin, disconnect, isConnected]);
})();
return () => {
cancelled = true;
};
}, [address, completeLogin, disconnect, isConnected, signMessageAsync]);
return { available, state, error, qrUri, start, reset }; return { available, state, error, qrUri, start, reset };
} }