Files
Arkie-Library-Frontend/src/wallet/useWalletConnectLogin.ts

330 lines
11 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from "react";
import { useAccount, useConnect, useDisconnect } from "wagmi";
import { bsc } from "wagmi/chains";
import { hasWalletConnectProjectId } from "./RainbowWalletProvider";
import {
getInjectedWallet,
signInWithInjectedWallet,
type WalletKind,
} from "./injected";
import { localWalletToken, useWallet } from "./WalletProvider";
export type WalletConnectLoginState = "idle" | "connecting" | "signing";
export type WalletConnectLoginMode = "deeplink" | "qr";
function isMobileDevice(): boolean {
if (typeof navigator === "undefined") return false;
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile/i.test(
navigator.userAgent || "",
);
}
function currentUrl(): string {
if (typeof window === "undefined") return "https://ark-library.com";
return window.location.href;
}
function isWalletConnectUri(uri: string): boolean {
return uri.startsWith("wc:");
}
function metaMaskWalletConnectLink(uri: string): string {
if (!isWalletConnectUri(uri)) return uri;
return `https://metamask.app.link/wc?uri=${encodeURIComponent(uri)}`;
}
function walletConnectDeeplink(
kind: WalletKind | undefined,
uri: string,
): string | null {
if (kind === "tokenPocket") {
return isWalletConnectUri(uri)
? `tpoutside://wc?uri=${encodeURIComponent(uri)}`
: uri;
}
if (kind === "metaMask") {
return metaMaskWalletConnectLink(uri);
}
if (kind === "imToken") {
return isWalletConnectUri(uri)
? `imtokenv2://wc?uri=${encodeURIComponent(uri)}`
: uri;
}
return null;
}
function inAppBrowserFallback(kind: WalletKind | undefined): string | null {
if (kind === "imToken") {
return `imtokenv2://navigate/DappView?url=${encodeURIComponent(currentUrl())}`;
}
return null;
}
function openWalletDeeplink(
kind: WalletKind | undefined,
deeplink: string,
): void {
window.location.href = deeplink;
const fallback = inAppBrowserFallback(kind);
if (!fallback) return;
window.setTimeout(() => {
if (document.visibilityState === "visible") {
window.location.href = fallback;
}
}, 1500);
}
function connectorMatchesWallet(
connector: { id: string; name?: string; type?: string },
kind: WalletKind | undefined,
): boolean {
if (!kind) return false;
const id = connector.id.toLowerCase();
const name = connector.name?.toLowerCase() ?? "";
const type = connector.type?.toLowerCase() ?? "";
if (kind === "metaMask") {
return id === "metamask" || type === "metamask" || name === "metamask";
}
if (kind === "tokenPocket") {
return id === "tokenpocket" || name === "tokenpocket";
}
if (kind === "imToken") {
return id === "imtoken" || name === "imtoken";
}
return false;
}
/**
* MetaMask / imToken QR fallback via RainbowKit + WalletConnect.
*
* Flow: connect through RainbowKit/Wagmi on BNB Chain -> once an account is
* connected, complete a local frontend wallet session. WalletConnect fallback
* does not require message signature, backend nonce, or verify call.
*
* 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
* disable the entry instead of triggering a connect with a fake project id.
*/
export function useWalletConnectLogin() {
const available = hasWalletConnectProjectId();
const { address: localAddress, completeLogin } = useWallet();
const { address: wagmiAddress, isConnected: wagmiConnected } = useAccount();
const { connectAsync, connectors } = useConnect();
const { disconnectAsync } = useDisconnect();
const [state, setState] = useState<WalletConnectLoginState>("idle");
const [error, setError] = useState("");
const [qrUri, setQrUri] = useState("");
const [connectedAddress, setConnectedAddress] = useState("");
const pendingRef = useRef(false);
const completedAddressRef = useRef<string | null>(null);
const cleanupMessageRef = useRef<(() => void) | null>(null);
const cleanupPollingRef = useRef<(() => void) | null>(null);
const reset = useCallback(() => {
pendingRef.current = false;
completedAddressRef.current = null;
cleanupMessageRef.current?.();
cleanupMessageRef.current = null;
cleanupPollingRef.current?.();
cleanupPollingRef.current = null;
setState("idle");
setError("");
setQrUri("");
setConnectedAddress("");
}, []);
useEffect(() => {
if (!wagmiConnected || !wagmiAddress) return;
const alreadyCompleted =
completedAddressRef.current?.toLowerCase() === wagmiAddress.toLowerCase();
if (alreadyCompleted) return;
completedAddressRef.current = wagmiAddress;
pendingRef.current = false;
setConnectedAddress(wagmiAddress);
setQrUri("");
setState("idle");
if (localAddress?.toLowerCase() !== wagmiAddress.toLowerCase()) {
console.info("[wallet-login] wagmi account connected", {
address: wagmiAddress,
chain: "BNB Chain",
chainId: bsc.id,
});
completeLogin(localWalletToken(wagmiAddress), wagmiAddress);
console.info("[wallet-login] local wallet session completed", {
address: wagmiAddress,
});
}
}, [completeLogin, localAddress, wagmiAddress, wagmiConnected]);
const start = useCallback(
async (
preferredWallet?: WalletKind,
mode: WalletConnectLoginMode = "qr",
) => {
if (!available) return;
setError("");
setQrUri("");
setConnectedAddress("");
completedAddressRef.current = null;
pendingRef.current = true;
setState("connecting");
if (
mode === "deeplink" &&
preferredWallet &&
getInjectedWallet(preferredWallet)
) {
try {
setState("signing");
const result = await signInWithInjectedWallet(preferredWallet);
console.info("[wallet-login] injected verified", {
preferredWallet,
address: result.wallet,
chain: "BNB Chain",
chainId: bsc.id,
});
completeLogin(result.token, result.wallet);
setState("idle");
return;
} catch (err) {
pendingRef.current = false;
setState("idle");
setError(err instanceof Error ? err.message : "Wallet login failed");
console.info("[wallet-login] injected verification failed", {
preferredWallet,
message: err instanceof Error ? err.message : String(err),
});
return;
}
}
// QR mode must always use a WalletConnect-compatible connector so the
// desktop page can render a scannable `wc:` URI instead of opening a
// local browser extension. Deeplink mode can prefer wallet-specific
// connectors (notably MetaMask SDK on mobile).
const walletConnectConnector =
connectors.find((item) => item.type === "walletConnect") ??
connectors.find((item) => item.id === "walletConnect");
const walletSpecificConnector = connectors.find((item) =>
connectorMatchesWallet(item, preferredWallet),
);
const connector =
mode === "qr"
? walletConnectConnector
: (walletSpecificConnector ?? walletConnectConnector);
if (!connector) {
pendingRef.current = false;
setQrUri("");
setState("idle");
setError("WalletConnect is not available");
return;
}
console.info("[wallet-login] walletconnect connector", {
preferredWallet,
connectorId: connector.id,
connectorName: connector.name,
connectorType: connector.type,
});
const onMessage = (message: { type: string; data?: unknown }) => {
if (
message.type !== "display_uri" ||
typeof message.data !== "string"
) {
return;
}
console.info("[wallet-login] walletconnect display_uri", {
preferredWallet,
connectorId: connector.id,
});
if (mode === "qr") {
setQrUri(message.data);
}
const deeplink = walletConnectDeeplink(preferredWallet, message.data);
if (mode === "deeplink" && deeplink && isMobileDevice()) {
openWalletDeeplink(preferredWallet, deeplink);
}
};
cleanupMessageRef.current?.();
connector.emitter.on("message", onMessage);
cleanupMessageRef.current = () =>
connector.emitter.off("message", onMessage);
cleanupPollingRef.current?.();
const finishFromAddress = (address: string, source: string) => {
const alreadyCompleted =
completedAddressRef.current?.toLowerCase() === address.toLowerCase();
if (alreadyCompleted) return;
pendingRef.current = false;
completedAddressRef.current = address;
setConnectedAddress(address);
setQrUri("");
setState("idle");
cleanupMessageRef.current?.();
cleanupMessageRef.current = null;
cleanupPollingRef.current?.();
cleanupPollingRef.current = null;
console.info("[wallet-login] wallet account connected", {
source,
preferredWallet,
address,
chain: "BNB Chain",
chainId: bsc.id,
});
completeLogin(localWalletToken(address), address);
console.info("[wallet-login] local wallet session completed", {
address,
});
};
const pollId = window.setInterval(() => {
void connector
.getAccounts()
.then((accounts) => {
const account = accounts[0];
if (account) finishFromAddress(account, "connector-poll");
})
.catch(() => undefined);
}, 1000);
cleanupPollingRef.current = () => window.clearInterval(pollId);
try {
await disconnectAsync().catch(() => undefined);
await connector.disconnect().catch(() => undefined);
const result = await connectAsync({ chainId: bsc.id, connector });
const connectedAddress = result.accounts[0];
if (!connectedAddress)
throw new Error("Wallet connected without an account");
finishFromAddress(connectedAddress, "connectAsync");
} catch (err) {
if (completedAddressRef.current) return;
pendingRef.current = false;
setState("idle");
setError(
err instanceof Error ? err.message : "WalletConnect login failed",
);
cleanupMessageRef.current?.();
cleanupMessageRef.current = null;
cleanupPollingRef.current?.();
cleanupPollingRef.current = null;
}
},
[available, completeLogin, connectAsync, connectors, disconnectAsync],
);
return {
available,
state,
error,
qrUri,
address: connectedAddress,
isConnected: Boolean(connectedAddress),
start,
reset,
};
}