terry-wallet-login #15
103
.unipi/docs/debug/2026-06-02-metamask-wallet-login-debug.md
Normal file
103
.unipi/docs/debug/2026-06-02-metamask-wallet-login-debug.md
Normal file
@@ -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:<address>`.
|
||||||
|
- 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:<address>` 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=<encoded_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:<address>`.
|
||||||
|
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.
|
||||||
@@ -165,13 +165,8 @@ export function WalletLoginModal() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{selected && wc.qrUri ? (
|
{selected && wc.qrUri ? (
|
||||||
<div className="mt-4 grid place-items-center gap-2 rounded-2xl bg-white p-4 text-center">
|
<div className="mt-4 grid place-items-center 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">
|
|
||||||
{mobileDevice
|
|
||||||
? t("walletTpWaiting")
|
|
||||||
: t("walletQrUseAnotherDevice")}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useConnect, useDisconnect } from "wagmi";
|
import { useAccount, useConnect, useDisconnect } from "wagmi";
|
||||||
import { bsc } from "wagmi/chains";
|
import { bsc } from "wagmi/chains";
|
||||||
import { hasWalletConnectProjectId } from "./RainbowWalletProvider";
|
import { hasWalletConnectProjectId } from "./RainbowWalletProvider";
|
||||||
import {
|
import {
|
||||||
@@ -24,18 +24,39 @@ function currentUrl(): string {
|
|||||||
return window.location.href;
|
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(
|
function walletConnectDeeplink(
|
||||||
kind: WalletKind | undefined,
|
kind: WalletKind | undefined,
|
||||||
uri: string,
|
uri: string,
|
||||||
): string | null {
|
): string | null {
|
||||||
if (kind === "tokenPocket") {
|
if (kind === "tokenPocket") {
|
||||||
return `tpoutside://wc?uri=${encodeURIComponent(uri)}`;
|
return isWalletConnectUri(uri)
|
||||||
|
? `tpoutside://wc?uri=${encodeURIComponent(uri)}`
|
||||||
|
: uri;
|
||||||
}
|
}
|
||||||
if (kind === "metaMask") {
|
if (kind === "metaMask") {
|
||||||
return `https://metamask.app.link/wc?uri=${encodeURIComponent(uri)}`;
|
return metaMaskWalletConnectLink(uri);
|
||||||
}
|
}
|
||||||
if (kind === "imToken") {
|
if (kind === "imToken") {
|
||||||
return `imtokenv2://wc?uri=${encodeURIComponent(uri)}`;
|
return isWalletConnectUri(uri)
|
||||||
|
? `imtokenv2://wc?uri=${encodeURIComponent(uri)}`
|
||||||
|
: uri;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -61,6 +82,26 @@ function openWalletDeeplink(
|
|||||||
}, 1500);
|
}, 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.
|
* MetaMask / imToken QR fallback via RainbowKit + WalletConnect.
|
||||||
*
|
*
|
||||||
@@ -74,7 +115,8 @@ function openWalletDeeplink(
|
|||||||
*/
|
*/
|
||||||
export function useWalletConnectLogin() {
|
export function useWalletConnectLogin() {
|
||||||
const available = hasWalletConnectProjectId();
|
const available = hasWalletConnectProjectId();
|
||||||
const { completeLogin } = useWallet();
|
const { address: localAddress, completeLogin } = useWallet();
|
||||||
|
const { address: wagmiAddress, isConnected: wagmiConnected } = useAccount();
|
||||||
const { connectAsync, connectors } = useConnect();
|
const { connectAsync, connectors } = useConnect();
|
||||||
const { disconnect, disconnectAsync } = useDisconnect();
|
const { disconnect, disconnectAsync } = useDisconnect();
|
||||||
const [state, setState] = useState<WalletConnectLoginState>("idle");
|
const [state, setState] = useState<WalletConnectLoginState>("idle");
|
||||||
@@ -82,10 +124,12 @@ export function useWalletConnectLogin() {
|
|||||||
const [qrUri, setQrUri] = useState("");
|
const [qrUri, setQrUri] = useState("");
|
||||||
const [connectedAddress, setConnectedAddress] = useState("");
|
const [connectedAddress, setConnectedAddress] = useState("");
|
||||||
const pendingRef = useRef(false);
|
const pendingRef = useRef(false);
|
||||||
|
const completedAddressRef = useRef<string | null>(null);
|
||||||
const cleanupMessageRef = useRef<(() => void) | null>(null);
|
const cleanupMessageRef = useRef<(() => void) | null>(null);
|
||||||
|
|
||||||
const reset = useCallback(() => {
|
const reset = useCallback(() => {
|
||||||
pendingRef.current = false;
|
pendingRef.current = false;
|
||||||
|
completedAddressRef.current = null;
|
||||||
cleanupMessageRef.current?.();
|
cleanupMessageRef.current?.();
|
||||||
cleanupMessageRef.current = null;
|
cleanupMessageRef.current = null;
|
||||||
setState("idle");
|
setState("idle");
|
||||||
@@ -94,6 +138,33 @@ export function useWalletConnectLogin() {
|
|||||||
setConnectedAddress("");
|
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(
|
const start = useCallback(
|
||||||
async (
|
async (
|
||||||
preferredWallet?: WalletKind,
|
preferredWallet?: WalletKind,
|
||||||
@@ -103,6 +174,7 @@ export function useWalletConnectLogin() {
|
|||||||
setError("");
|
setError("");
|
||||||
setQrUri("");
|
setQrUri("");
|
||||||
setConnectedAddress("");
|
setConnectedAddress("");
|
||||||
|
completedAddressRef.current = null;
|
||||||
pendingRef.current = true;
|
pendingRef.current = true;
|
||||||
setState("connecting");
|
setState("connecting");
|
||||||
|
|
||||||
@@ -126,9 +198,13 @@ export function useWalletConnectLogin() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// This modal is QR/WalletConnect-only. RainbowKit also exposes wallet-
|
// Prefer the connector RainbowKit created for the selected wallet. This
|
||||||
// specific injected connectors (for example `tokenPocket`) when an
|
// is especially important for MetaMask Mobile: RainbowKit/Wagmi use the
|
||||||
|
// MetaMask SDK connector there instead of the generic WalletConnect one.
|
||||||
const connector =
|
const connector =
|
||||||
|
connectors.find((item) =>
|
||||||
|
connectorMatchesWallet(item, preferredWallet),
|
||||||
|
) ??
|
||||||
connectors.find((item) => item.type === "walletConnect") ??
|
connectors.find((item) => item.type === "walletConnect") ??
|
||||||
connectors.find((item) => item.id === "walletConnect");
|
connectors.find((item) => item.id === "walletConnect");
|
||||||
|
|
||||||
@@ -158,7 +234,9 @@ export function useWalletConnectLogin() {
|
|||||||
preferredWallet,
|
preferredWallet,
|
||||||
connectorId: connector.id,
|
connectorId: connector.id,
|
||||||
});
|
});
|
||||||
if (mode === "qr") setQrUri(message.data);
|
if (mode === "qr") {
|
||||||
|
setQrUri(walletConnectQrValue(preferredWallet, message.data));
|
||||||
|
}
|
||||||
const deeplink = walletConnectDeeplink(preferredWallet, message.data);
|
const deeplink = walletConnectDeeplink(preferredWallet, message.data);
|
||||||
if (mode === "deeplink" && deeplink && isMobileDevice()) {
|
if (mode === "deeplink" && deeplink && isMobileDevice()) {
|
||||||
openWalletDeeplink(preferredWallet, deeplink);
|
openWalletDeeplink(preferredWallet, deeplink);
|
||||||
@@ -178,6 +256,7 @@ export function useWalletConnectLogin() {
|
|||||||
if (!connectedAddress)
|
if (!connectedAddress)
|
||||||
throw new Error("Wallet connected without an account");
|
throw new Error("Wallet connected without an account");
|
||||||
pendingRef.current = false;
|
pendingRef.current = false;
|
||||||
|
completedAddressRef.current = connectedAddress;
|
||||||
setConnectedAddress(connectedAddress);
|
setConnectedAddress(connectedAddress);
|
||||||
console.info("[wallet-login] walletconnect connected", {
|
console.info("[wallet-login] walletconnect connected", {
|
||||||
address: connectedAddress,
|
address: connectedAddress,
|
||||||
|
|||||||
Reference in New Issue
Block a user