From e8651108f8fbc237c8b5c1bfa50e60763f2a1264 Mon Sep 17 00:00:00 2001 From: ehsan shariati Date: Fri, 4 Sep 2026 17:51:16 -0400 Subject: [PATCH] fix(wallet): stop believing a socket that says OPEN after a trip to the wallet Coming back from MetaMask, the page sat on "Connecting Wallet..." for several seconds after the user had already approved. Two causes, both ours. 1. The wake trusted `relayer.connected`, which is nothing but the WebSocket's own `readyState === 1`. Android suspends the TCP connection underneath a socket without telling it, so it keeps reporting OPEN while the wallet's approval sits on the relay waiting for a client that thinks it needs nothing. Nothing in @walletconnect/core catches this from a browser: the ping-based liveness check (`startPingTimeout`) is gated on `isNode()`, and the heartbeat's own reconnect only fires on `!connected`. And our `wakeRelay` returned early on exactly the same flag. So on return, everyone looked at a dead socket that said OPEN and did nothing until Chrome's TCP stack gave up. Now, when the tab has been hidden for at least BACKGROUND_STINT_MS (1s -- a trip to a wallet is never shorter, a flick between desktop tabs usually is), `readyState` is not consulted and the transport is restarted outright. `restartTransport()` tears the socket down, dials again, re-subscribes every topic, and its subscriber then calls `batchFetchMessages` -- which is the fetch of whatever the wallet published while we were dead. A socket that was in fact healthy pays one reconnect, well under a second; one that was not pays nothing it did not already owe. 2. The wake never ran for the connect round-trip at all. `wallet.provider` comes from AppKit's `ProviderController.setProvider`, which runs on connection -- so during the connect itself, the first trip every user makes, it was undefined and `useRelayWake` had nothing to act on. The socket the approval arrives over lives on AppKit's UniversalProvider, which exists from the moment the chooser opens; WalletSigner now asks AppKit for it and wakes that when there is no session provider yet. Also: the stuck-wallet hint names the likely cause of MetaMask wedging on its splash screen EVERY time -- Android restricting the wallet in the background, so it is suspended or killed on every switch and cannot handle the request it is resumed for. The fix is the user's to make (Settings > Apps > MetaMask > Battery > Unrestricted, and the same for the browser); it is the setting the mobile app always needed for the same prompts, and a web page cannot set it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013BpqXrkEPA9odTyRdK5Mnx --- .../src/components/setup/WalletSigner.tsx | 20 +++++- .../fxblox-web/src/i18n/locales/en/setup.json | 2 +- .../fxblox-web/src/i18n/locales/zh/setup.json | 2 +- .../__tests__/LinkPassword.test.tsx | 2 + .../src/wallet/__tests__/relayWake.test.ts | 72 ++++++++++++++++++- apps/fxblox-web/src/wallet/relayWake.ts | 67 +++++++++++++++-- 6 files changed, 155 insertions(+), 10 deletions(-) diff --git a/apps/fxblox-web/src/components/setup/WalletSigner.tsx b/apps/fxblox-web/src/components/setup/WalletSigner.tsx index 9e5e101..b3a0001 100644 --- a/apps/fxblox-web/src/components/setup/WalletSigner.tsx +++ b/apps/fxblox-web/src/components/setup/WalletSigner.tsx @@ -43,7 +43,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { FxBox, FxButton, FxSpinner, FxText } from '@functionland/fx-ui'; import { useColorMode } from '@/stores/useSettingsStore'; -import { setAppKitTheme } from '@/wallet/appkit'; +import { getAppKit, setAppKitTheme } from '@/wallet/appkit'; import { signChainCode } from '@/wallet/signChainCode'; import { connectedWalletLink } from '@/wallet/walletLink'; import { @@ -145,7 +145,23 @@ export default function WalletSigner({ // Coming back from the wallet lands on a socket Android killed while we were backgrounded. Reconnect it now // rather than waiting out the library's backoff, which is the several seconds of "connecting" a user sees // after they have already approved. - useRelayWake(wallet.provider); + // + // `wallet.provider` is only set once a session EXISTS (`ProviderController.setProvider` runs on connect), so + // for the connect round-trip itself — the first, and the one every user makes — it is undefined and the wake + // had nothing to act on. The socket that the approval has to arrive over lives on AppKit's UniversalProvider, + // which exists from the moment the chooser opens; ask AppKit for it directly. + const [universalProvider, setUniversalProvider] = useState(undefined); + useEffect(() => { + let alive = true; + void getAppKit() + ?.getUniversalProvider() + .then((p) => alive && setUniversalProvider(p)) + .catch(() => undefined); + return () => { + alive = false; + }; + }, []); + useRelayWake(wallet.provider ?? universalProvider); // The parent swaps the password field for a spinner while we are busy. `readyToSign` is NOT busy — the user // has to see the button to press it — so it deliberately does not count. diff --git a/apps/fxblox-web/src/i18n/locales/en/setup.json b/apps/fxblox-web/src/i18n/locales/en/setup.json index 49fa9a3..95e1eb7 100644 --- a/apps/fxblox-web/src/i18n/locales/en/setup.json +++ b/apps/fxblox-web/src/i18n/locales/en/setup.json @@ -58,7 +58,7 @@ "signaturePortalHint": "Sign at fxblox.fx.land, then paste the signature below together with the wallet address you signed with.", "walletConnectedTapSign": "Wallet connected. Tap below to approve the signature — this opens your wallet again.", "approveInWallet": "Approve the request in your wallet…", - "walletStuckHint": "Wallet opened but stuck on its splash screen? Close it completely from your recent apps, then tap Open wallet to approve again. That is the only thing that clears it — tapping again without closing the wallet first will not. Your request stays valid, so nothing is lost.", + "walletStuckHint": "Wallet opened but stuck on its splash screen? Close it completely from your recent apps, then tap Open wallet to approve again — tapping without closing it first will not help. Your request stays valid. If this happens every time, Android is restricting the wallet in the background: set MetaMask and your browser to Unrestricted under Settings → Apps → (app) → Battery, and it stops.", "openWalletToApprove": "Open wallet to approve" }, "connectToBlox": { diff --git a/apps/fxblox-web/src/i18n/locales/zh/setup.json b/apps/fxblox-web/src/i18n/locales/zh/setup.json index 087efd0..8cc7368 100644 --- a/apps/fxblox-web/src/i18n/locales/zh/setup.json +++ b/apps/fxblox-web/src/i18n/locales/zh/setup.json @@ -58,7 +58,7 @@ "signaturePortalHint": "请在 fxblox.fx.land 完成签名,然后将签名与用于签名的钱包地址一起粘贴到下方。", "walletConnectedTapSign": "钱包已连接。点击下方按钮进行签名 — 这会再次打开您的钱包。", "approveInWallet": "请在您的钱包中确认此请求…", - "walletStuckHint": "钱包已打开但停在启动画面?请从最近任务中彻底关闭钱包,然后再次点击“打开钱包进行签名”。只有这样才能解决 — 不先关闭钱包而直接再次点击是没有用的。您的签名请求仍然有效,不会丢失。", + "walletStuckHint": "钱包已打开但停在启动画面?请从最近任务中彻底关闭钱包,然后再次点击“打开钱包进行签名” — 不先关闭钱包而直接再次点击是没有用的。您的签名请求仍然有效。如果每次都这样,说明 Android 正在后台限制钱包:请在 设置 → 应用 → (应用)→ 电池 中,将 MetaMask 和您的浏览器设为“不受限制”,问题即可消除。", "openWalletToApprove": "打开钱包进行签名" }, "connectToBlox": { diff --git a/apps/fxblox-web/src/screens/InitialSetup/__tests__/LinkPassword.test.tsx b/apps/fxblox-web/src/screens/InitialSetup/__tests__/LinkPassword.test.tsx index a0bfaab..019386f 100644 --- a/apps/fxblox-web/src/screens/InitialSetup/__tests__/LinkPassword.test.tsx +++ b/apps/fxblox-web/src/screens/InitialSetup/__tests__/LinkPassword.test.tsx @@ -20,6 +20,8 @@ const wallet = vi.hoisted(() => ({ initAppKit: vi.fn(), setAppKitTheme: vi.fn(), disconnectWallet: vi.fn(async () => undefined), + // No AppKit instance in these tests: the relay wake falls back to `wallet.provider`, as it did before. + getAppKit: vi.fn(() => null), }, })); diff --git a/apps/fxblox-web/src/wallet/__tests__/relayWake.test.ts b/apps/fxblox-web/src/wallet/__tests__/relayWake.test.ts index 5f01147..531194a 100644 --- a/apps/fxblox-web/src/wallet/__tests__/relayWake.test.ts +++ b/apps/fxblox-web/src/wallet/__tests__/relayWake.test.ts @@ -1,6 +1,6 @@ import { renderHook } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { useRelayWake, wakeRelay, WAKE_TIMEOUT_MS } from '../relayWake'; +import { BACKGROUND_STINT_MS, useRelayWake, wakeRelay, WAKE_TIMEOUT_MS } from '../relayWake'; interface RelayerState { connected?: boolean; @@ -54,9 +54,40 @@ describe('wakeRelay', () => { }); it('leaves a live socket alone', async () => { - const { provider, transportOpen } = providerWithRelayer({ connected: true }); + const { provider, transportOpen, restartTransport } = providerWithRelayer({ connected: true }); await wakeRelay(provider); expect(transportOpen).not.toHaveBeenCalled(); + expect(restartTransport).not.toHaveBeenCalled(); + }); + + it('does not believe "connected" after a background stint, and restarts the socket outright', async () => { + // `relayer.connected` is `socket.readyState === OPEN`, and Android suspends the TCP connection underneath + // a socket without telling it. Nothing in the library probes a browser socket, so a tab that comes back + // and trusts OPEN sits there while the wallet's approval waits on the relay — the "Connecting Wallet…" + // the user watches after they already approved. Restart, and let the resubscribe fetch what was queued. + const { provider, transportOpen, restartTransport } = providerWithRelayer({ connected: true }); + await wakeRelay(provider, { afterBackground: true }); + expect(restartTransport).toHaveBeenCalledTimes(1); + expect(transportOpen).not.toHaveBeenCalled(); + }); + + it('after a background stint, a socket that is honestly down takes the normal path', async () => { + const { provider, transportOpen, restartTransport } = providerWithRelayer({ opensSuccessfully: true }); + await wakeRelay(provider, { afterBackground: true }); + expect(transportOpen).toHaveBeenCalledTimes(1); + expect(restartTransport).not.toHaveBeenCalled(); + }); + + it('after a background stint, a relayer without restartTransport is left alone rather than broken', async () => { + const { provider, transportOpen } = providerWithRelayer({ connected: true, withRestart: false }); + await expect(wakeRelay(provider, { afterBackground: true })).resolves.toBeUndefined(); + expect(transportOpen).not.toHaveBeenCalled(); + }); + + it('a restart that fails after a background stint is swallowed too', async () => { + const { provider, restartTransport } = providerWithRelayer({ connected: true }); + restartTransport.mockRejectedValueOnce(new Error('relay unreachable')); + await expect(wakeRelay(provider, { afterBackground: true })).resolves.toBeUndefined(); }); it('still wakes when the relayer claims to be connecting', async () => { @@ -124,6 +155,43 @@ describe('useRelayWake', () => { expect(transportOpen).toHaveBeenCalledTimes(1); }); + it('restarts a socket that claims to be open when the tab was away long enough to have been in a wallet', () => { + vi.useFakeTimers(); + const { provider, restartTransport, transportOpen } = providerWithRelayer({ connected: true }); + renderHook(() => useRelayWake(provider)); + + setVisibility('hidden'); + document.dispatchEvent(new Event('visibilitychange')); + vi.advanceTimersByTime(BACKGROUND_STINT_MS + 500); + setVisibility('visible'); + document.dispatchEvent(new Event('visibilitychange')); + + expect(restartTransport).toHaveBeenCalledTimes(1); + expect(transportOpen).not.toHaveBeenCalled(); + }); + + it('leaves an open socket alone after a mere flick between tabs', () => { + // A desktop socket was never suspended; restarting it on every tab switch would be a reconnect for nothing. + vi.useFakeTimers(); + const { provider, restartTransport } = providerWithRelayer({ connected: true }); + renderHook(() => useRelayWake(provider)); + + setVisibility('hidden'); + document.dispatchEvent(new Event('visibilitychange')); + vi.advanceTimersByTime(BACKGROUND_STINT_MS - 200); + setVisibility('visible'); + document.dispatchEvent(new Event('visibilitychange')); + + expect(restartTransport).not.toHaveBeenCalled(); + }); + + it('a visible event with no preceding hidden is not treated as a return from the background', () => { + const { provider, restartTransport } = providerWithRelayer({ connected: true }); + renderHook(() => useRelayWake(provider)); + document.dispatchEvent(new Event('visibilitychange')); + expect(restartTransport).not.toHaveBeenCalled(); + }); + it('uses the current provider, not the one it mounted with', () => { // The provider arrives after the wallet connects, which is later than this hook mounts. const first = providerWithRelayer(); diff --git a/apps/fxblox-web/src/wallet/relayWake.ts b/apps/fxblox-web/src/wallet/relayWake.ts index 49887d2..077a32c 100644 --- a/apps/fxblox-web/src/wallet/relayWake.ts +++ b/apps/fxblox-web/src/wallet/relayWake.ts @@ -44,6 +44,27 @@ * session that will never reconnect on its own. `restartTransport()` already sequences the same teardown * correctly (`confirmOnlineStateOrThrow` → `resetTransport` → `transportOpen`), and `connect()` clears the * flag on the way in. Use the library's version. + * + * ## Why "connected" is not believed after a background stint + * + * All of the above only ever ran when `relayer.connected` was false. It usually is not. From the same bundle: + * + * get connected(){ return this.provider?.connection?.socket?.readyState === 1 || false } + * + * That is the WebSocket's own `readyState`, and Android suspends the TCP connection UNDERNEATH a socket + * without telling it. The socket keeps reporting OPEN — while the wallet's approval sits on the relay, waiting + * for a client that believes it needs nothing. Nothing in the library catches this from a browser: the + * ping-based liveness check (`startPingTimeout`) is gated on `isNode()`, because browser WebSockets expose no + * ping frames, and the heartbeat's own reconnect fires only on `!this.connected`. So the first thing to notice + * is Chrome's TCP stack, eventually, which is the several seconds of "Connecting Wallet…" a user watches + * after they have already approved. + * + * So when the tab comes back from a real stint in the background, `readyState` is not asked. The transport + * is restarted outright. `restartTransport()` tears the socket down, dials again, re-subscribes every topic, + * and its subscriber then calls `batchFetchMessages` — which is precisely the fetch of whatever the wallet + * published while we were dead. A socket that was in fact healthy pays one reconnect, well under a second on + * a working network; a socket that was not pays nothing more than it already owed. A tab hidden for less than + * `BACKGROUND_STINT_MS` is a flick between tabs, not a trip to a wallet, and is left alone. */ import { useEffect, useRef } from 'react'; @@ -56,6 +77,22 @@ import { useEffect, useRef } from 'react'; */ export const WAKE_TIMEOUT_MS = 2500; +/** + * How long the tab has to have been hidden before its socket is presumed dead on return. + * + * A trip to a wallet app and back is never shorter than this. A tab switch on a desktop routinely is, and a + * desktop socket was never suspended, so restarting it there would be a reconnect for nothing. + */ +export const BACKGROUND_STINT_MS = 1000; + +export interface WakeRelayOptions { + /** + * The tab is back from a real stint in the background. `relayer.connected` is then not trusted — see the + * file header — and a socket that claims to be open is restarted anyway. + */ + afterBackground?: boolean; +} + interface RelayerLike { /** True only when the underlying socket's readyState is OPEN. */ connected: boolean; @@ -96,22 +133,44 @@ const settleAfter = (ms: number): Promise => * has no relay). Never rejects: a relay that cannot be reached is not something the caller can act on, and the * next real request reports it properly, with the context of what the user was trying to do. */ -export async function wakeRelay(provider: unknown): Promise { +export async function wakeRelay(provider: unknown, opts: WakeRelayOptions = {}): Promise { const relayer = relayerFrom(provider); - if (!relayer || relayer.connected) return; + if (!relayer) return; + if (relayer.connected) { + // `readyState === OPEN` is the socket's opinion, and after a background stint it is not worth having — + // Android suspends the connection underneath it without a word. Restart rather than wait for Chrome's + // TCP stack to find out (file header). Off the background path a live socket is left alone as before. + if (!opts.afterBackground || typeof relayer.restartTransport !== 'function') return; + console.log('[relay] back from the background — restarting a socket that claims to be open'); + await relayer.restartTransport().catch(() => undefined); + return; + } // Bounded, because this call awaits any in-flight attempt — including one Android froze (see header). await Promise.race([relayer.transportOpen().catch(() => undefined), settleAfter(WAKE_TIMEOUT_MS)]); if (relayer.connected || typeof relayer.restartTransport !== 'function') return; await relayer.restartTransport().catch(() => undefined); } -/** Wake the socket every time this tab becomes visible again, for as long as the component is mounted. */ +/** + * Wake the socket every time this tab becomes visible again, for as long as the component is mounted. + * + * Remembers when the tab went hidden, so the return can tell a trip to the wallet (socket presumed dead, + * restart it) from a flick between tabs (leave a working socket alone). + */ export function useRelayWake(provider: unknown): void { const latest = useRef(provider); latest.current = provider; useEffect(() => { + let hiddenAt: number | null = null; const onVisibilityChange = () => { - if (document.visibilityState === 'visible') void wakeRelay(latest.current); + if (document.visibilityState === 'hidden') { + hiddenAt = Date.now(); + return; + } + if (document.visibilityState !== 'visible') return; + const afterBackground = hiddenAt !== null && Date.now() - hiddenAt >= BACKGROUND_STINT_MS; + hiddenAt = null; + void wakeRelay(latest.current, { afterBackground }); }; document.addEventListener('visibilitychange', onVisibilityChange); return () => document.removeEventListener('visibilitychange', onVisibilityChange);