diff --git a/.unipi/docs/debug/2026-06-02-metamask-wallet-login-debug.md b/.unipi/docs/debug/2026-06-02-metamask-wallet-login-debug.md new file mode 100644 index 0000000..a9ef5cd --- /dev/null +++ b/.unipi/docs/debug/2026-06-02-metamask-wallet-login-debug.md @@ -0,0 +1,103 @@ +--- +title: "MetaMask Wallet Login Does Not Surface Address — Debug Report" +type: debug +date: 2026-06-02 +severity: high +status: root-caused +--- + +# MetaMask Wallet Login Does Not Surface Address — Debug Report + +## Summary +MetaMask QR login and mobile deeplink login can be approved in MetaMask, but the ARK frontend does not write the approved wallet address into the local wallet session; the mobile QR waiting text is also incorrectly TokenPocket-specific for all wallets. + +## Expected Behavior +- Selecting MetaMask and approving the connection should result in the page showing the connected wallet address. +- Mobile MetaMask deeplink login should return/reconnect to the page and complete local no-signature login with `local-wallet:
`. +- QR login copy should be generic or absent; it should not say “waiting in TokenPocket” when the selected wallet is MetaMask or imToken. + +## Actual Behavior +- Terry can approve MetaMask QR/deeplink login, but the web page does not show the authorized address. +- The QR panel uses TokenPocket-specific copy for any wallet on mobile, e.g. imToken QR login still shows a TokenPocket waiting message. + +## Reproduction Steps +1. Open the public frontend and open the wallet login modal. +2. Select MetaMask. +3. Use either: + - QR login: scan the QR using MetaMask and approve, or + - Mobile app login: tap “Open wallet app”, approve in MetaMask, then return to the web page. +4. Observe that the page does not show the wallet address. +5. Select imToken QR login on mobile and observe that the QR panel displays TokenPocket-specific waiting text. + +## Environment +- Project: Arkie Library Frontend (`ark-database-web`) +- Branch context: `terry-wallet-login` +- Stack: React 18, Vite, TypeScript, RainbowKit, Wagmi, WalletConnect/Reown +- Wallets involved: MetaMask Mobile, TokenPocket, imToken +- Backend auth endpoints intentionally not required for this flow; login is local no-signature wallet session. + +## Root Cause Analysis + +### Failure Chain +1. `WalletLoginModal` calls `wc.start(kind, mode)` for all wallet app/QR flows. +2. `useWalletConnectLogin.start()` currently chooses the first generic WalletConnect connector for non-injected flows: + - `connectors.find((item) => item.type === "walletConnect") ?? connectors.find((item) => item.id === "walletConnect")` +3. On MetaMask mobile, RainbowKit’s own MetaMask wallet definition intentionally uses Wagmi’s `metaMask()` / MetaMask SDK connector, not the generic WalletConnect connector. +4. The custom hook bypasses that MetaMask-specific connector on mobile, so MetaMask SDK deeplink/reconnect handling is not used. +5. The hook only calls `completeLogin(localWalletToken(address), address)` inside the awaited `connectAsync(...)` result path. +6. If MetaMask approval completes while the browser is backgrounded, after a page reload, or through a restored Wagmi connection, there is no `useAccount`/reconnect bridge that converts the Wagmi connected address into the app’s local wallet session. +7. `RainbowWalletProvider` calls `useReconnect()`, but this only restores Wagmi connection state; it does not update `WalletProvider` unless `useWalletConnectLogin` observes the restored account and calls `completeLogin`. +8. Therefore MetaMask may be approved/connected at the wallet/Wagmi layer but the ARK UI still has no `local-wallet:
` token and shows logged-out/no address. + +### Root Cause +The MetaMask flow is treated as a generic WalletConnect flow, but RainbowKit/Wagmi have MetaMask-specific mobile behavior. Additionally, local ARK wallet login is tied only to the synchronous `connectAsync` return path instead of being derived from Wagmi account state/reconnect events. This misses MetaMask connections that resolve via mobile app backgrounding, deep link return, QR approval, or page reload. + +### Evidence +- File: `src/wallet/useWalletConnectLogin.ts` — connector selection prefers `item.type === "walletConnect"` for all wallets, so mobile MetaMask does not use the RainbowKit/Wagmi MetaMask connector. +- File: `src/wallet/useWalletConnectLogin.ts` — `completeLogin(localWalletToken(...), ...)` only runs after `await connectAsync(...)`; there is no `useAccount` effect to complete local login when Wagmi is already/reconnected. +- File: `src/wallet/RainbowWalletProvider.tsx` — `WalletReconnectOnMount` calls `useReconnect()`, but no downstream code maps the restored Wagmi account into `WalletProvider`. +- File: `node_modules/@rainbow-me/rainbowkit/dist/wallets/walletConnectors/chunk-BQHQU37S.js` — RainbowKit MetaMask wallet uses `metaMask()` connector on mobile and comments that “MetaMask mobile deep linking [is] handled by wagmi”. The custom hook bypasses this by selecting a generic WalletConnect connector. +- File: `src/wallet/WalletLoginModal.tsx` — QR text uses `mobileDevice ? t("walletTpWaiting") : t("walletQrUseAnotherDevice")` for every selected wallet, causing TokenPocket-specific copy for MetaMask/imToken. +- File: `src/locales/en.ts` and `src/locales/zh-CN.ts` — `walletQrUseAnotherDevice` also explicitly mentions TokenPocket, so even desktop/generic QR copy is wallet-specific. + +## Affected Files +- `src/wallet/useWalletConnectLogin.ts` — connector choice, deeplink generation, QR URI generation, local-login completion. +- `src/wallet/WalletLoginModal.tsx` — misleading QR panel copy. +- `src/wallet/RainbowWalletProvider.tsx` — currently reconnects Wagmi but does not by itself complete local login. +- `src/locales/*.ts` — QR copy currently contains TokenPocket-specific text. + +## Suggested Fix +Use the wallet-specific connector when a wallet is selected, especially MetaMask, and add a Wagmi account/reconnect bridge that completes the local wallet session whenever Wagmi has an address. Remove or generalize TokenPocket-specific QR waiting copy. + +### Fix Strategy +1. In `useWalletConnectLogin.ts`, prefer a connector matching `preferredWallet` before falling back to generic WalletConnect: + - MetaMask: `id === "metaMask"` or `type === "metaMask"` + - imToken/TokenPocket: matching wallet id when present, otherwise generic WalletConnect +2. Add `useAccount()` inside `useWalletConnectLogin` and a `useEffect` that calls `completeLogin(localWalletToken(address), address)` when Wagmi reports `isConnected && address`. +3. For MetaMask QR, transform the displayed QR value with the same wallet-specific URI RainbowKit uses: `https://metamask.app.link/wc?uri=` instead of always rendering raw `wc:`. +4. Remove the QR panel paragraph entirely, or replace it with generic copy such as “Please approve the connection in your wallet app.” +5. Re-test MetaMask separately for desktop QR scan, mobile Chrome deeplink return, and MetaMask in-app browser injected login. + +### Risk Assessment +- Risk: Selecting MetaMask’s SDK connector may behave differently from WalletConnect for QR mode. Mitigate by falling back to WalletConnect if the MetaMask connector does not emit a `display_uri` in QR mode. +- Risk: Auto-completing local login from any Wagmi connected account may log in a stale account after reconnect. Mitigate by only completing when modal/pending/login-in-progress context exists or by clearing stale flags. +- Risk: Changing QR value for MetaMask may affect wallets that scan raw WalletConnect URIs. Mitigate by only applying MetaMask app-link QR transformation for MetaMask. + +## Verification Plan +1. Run `npx tsc --noEmit`. +2. Run `npm run format:check`. +3. Run `npm test`. +4. Desktop Chrome + MetaMask Mobile QR: Select MetaMask → QR login → scan/approve → confirm address and `local-wallet:
`. +5. Mobile Chrome + MetaMask app login: Select MetaMask → Open wallet app → approve → return/refresh browser → confirm address appears. +6. Regression test TokenPocket and imToken app login. +7. Confirm imToken/MetaMask QR login no longer displays TokenPocket-specific text. + +## Related Issues +- TokenPocket was previously fixed by handling mobile return/reload behavior. +- imToken was previously fixed by adding in-app browser fallback and injected no-signature login. +- Existing local-memory context indicates MetaMask QR approval has been observed to not update frontend state. + +## Notes +- This report is diagnosis only; no source fix was applied during `/unipi:debug`. +- The local no-signature session model means the frontend does not need backend wallet nonce/verify endpoints for this fix. +- The current debug UI showing `Wallet debug` may be useful during verification but should be removed or hidden before final production cleanup if no longer needed. diff --git a/src/wallet/WalletLoginModal.tsx b/src/wallet/WalletLoginModal.tsx index ce17c75..34d7645 100644 --- a/src/wallet/WalletLoginModal.tsx +++ b/src/wallet/WalletLoginModal.tsx @@ -165,13 +165,8 @@ export function WalletLoginModal() { ) : null} {selected && wc.qrUri ? ( -
+
-

- {mobileDevice - ? t("walletTpWaiting") - : t("walletQrUseAnotherDevice")} -

) : null} diff --git a/src/wallet/useWalletConnectLogin.ts b/src/wallet/useWalletConnectLogin.ts index dddbc91..c74c609 100644 --- a/src/wallet/useWalletConnectLogin.ts +++ b/src/wallet/useWalletConnectLogin.ts @@ -1,5 +1,5 @@ -import { useCallback, useRef, useState } from "react"; -import { useConnect, useDisconnect } from "wagmi"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { useAccount, useConnect, useDisconnect } from "wagmi"; import { bsc } from "wagmi/chains"; import { hasWalletConnectProjectId } from "./RainbowWalletProvider"; import { @@ -24,18 +24,39 @@ function currentUrl(): string { 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 walletConnectQrValue( + kind: WalletKind | undefined, + uri: string, +): string { + if (kind === "metaMask") return metaMaskWalletConnectLink(uri); + return uri; +} + function walletConnectDeeplink( kind: WalletKind | undefined, uri: string, ): string | null { if (kind === "tokenPocket") { - return `tpoutside://wc?uri=${encodeURIComponent(uri)}`; + return isWalletConnectUri(uri) + ? `tpoutside://wc?uri=${encodeURIComponent(uri)}` + : uri; } if (kind === "metaMask") { - return `https://metamask.app.link/wc?uri=${encodeURIComponent(uri)}`; + return metaMaskWalletConnectLink(uri); } if (kind === "imToken") { - return `imtokenv2://wc?uri=${encodeURIComponent(uri)}`; + return isWalletConnectUri(uri) + ? `imtokenv2://wc?uri=${encodeURIComponent(uri)}` + : uri; } return null; } @@ -61,6 +82,26 @@ function openWalletDeeplink( }, 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. * @@ -74,7 +115,8 @@ function openWalletDeeplink( */ export function useWalletConnectLogin() { const available = hasWalletConnectProjectId(); - const { completeLogin } = useWallet(); + const { address: localAddress, completeLogin } = useWallet(); + const { address: wagmiAddress, isConnected: wagmiConnected } = useAccount(); const { connectAsync, connectors } = useConnect(); const { disconnect, disconnectAsync } = useDisconnect(); const [state, setState] = useState("idle"); @@ -82,10 +124,12 @@ export function useWalletConnectLogin() { const [qrUri, setQrUri] = useState(""); const [connectedAddress, setConnectedAddress] = useState(""); const pendingRef = useRef(false); + const completedAddressRef = useRef(null); const cleanupMessageRef = useRef<(() => void) | null>(null); const reset = useCallback(() => { pendingRef.current = false; + completedAddressRef.current = null; cleanupMessageRef.current?.(); cleanupMessageRef.current = null; setState("idle"); @@ -94,6 +138,33 @@ export function useWalletConnectLogin() { 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, + }); + } + + void disconnect(); + }, [completeLogin, disconnect, localAddress, wagmiAddress, wagmiConnected]); + const start = useCallback( async ( preferredWallet?: WalletKind, @@ -103,6 +174,7 @@ export function useWalletConnectLogin() { setError(""); setQrUri(""); setConnectedAddress(""); + completedAddressRef.current = null; pendingRef.current = true; setState("connecting"); @@ -126,9 +198,13 @@ export function useWalletConnectLogin() { } } - // This modal is QR/WalletConnect-only. RainbowKit also exposes wallet- - // specific injected connectors (for example `tokenPocket`) when an + // Prefer the connector RainbowKit created for the selected wallet. This + // is especially important for MetaMask Mobile: RainbowKit/Wagmi use the + // MetaMask SDK connector there instead of the generic WalletConnect one. const connector = + connectors.find((item) => + connectorMatchesWallet(item, preferredWallet), + ) ?? connectors.find((item) => item.type === "walletConnect") ?? connectors.find((item) => item.id === "walletConnect"); @@ -158,7 +234,9 @@ export function useWalletConnectLogin() { preferredWallet, connectorId: connector.id, }); - if (mode === "qr") setQrUri(message.data); + if (mode === "qr") { + setQrUri(walletConnectQrValue(preferredWallet, message.data)); + } const deeplink = walletConnectDeeplink(preferredWallet, message.data); if (mode === "deeplink" && deeplink && isMobileDevice()) { openWalletDeeplink(preferredWallet, deeplink); @@ -178,6 +256,7 @@ export function useWalletConnectLogin() { if (!connectedAddress) throw new Error("Wallet connected without an account"); pendingRef.current = false; + completedAddressRef.current = connectedAddress; setConnectedAddress(connectedAddress); console.info("[wallet-login] walletconnect connected", { address: connectedAddress,