From 3791f7a01069c02b9079ab17e6c5ab33a3ae6db6 Mon Sep 17 00:00:00 2001 From: Daniel Constantin Date: Fri, 8 May 2026 10:50:06 +0300 Subject: [PATCH 1/3] feat(wallet): add ConnectWalletButton and isConnectionRestoring state --- apps/cowswap-frontend/src/locales/en-US.po | 4 ++++ .../containers/AffiliatePartnerOnboard.tsx | 2 +- .../containers/AffiliateTraderOnboard.tsx | 6 ++--- .../wallet/containers/Web3Status/index.tsx | 4 +++- .../wallet/pure/Web3StatusInner/index.tsx | 23 +++++++++++++++++-- .../src/pages/Account/AffiliatePartner.tsx | 12 ++++++++-- .../src/pages/Account/AffiliateTrader.tsx | 5 +++- libs/ui/src/pure/Button/index.tsx | 10 ++++++++ libs/wallet/src/api/types.ts | 1 + libs/wallet/src/index.ts | 1 + .../wagmi/hooks/useIsRestoringConnection.ts | 12 ++++++++++ libs/wallet/src/wagmi/updater.ts | 15 ++++++++++-- 12 files changed, 83 insertions(+), 12 deletions(-) create mode 100644 libs/wallet/src/wagmi/hooks/useIsRestoringConnection.ts diff --git a/apps/cowswap-frontend/src/locales/en-US.po b/apps/cowswap-frontend/src/locales/en-US.po index 1445b1efdfb..8524200255f 100644 --- a/apps/cowswap-frontend/src/locales/en-US.po +++ b/apps/cowswap-frontend/src/locales/en-US.po @@ -7027,6 +7027,10 @@ msgstr "Default approve" msgid "Order cannot be filled due to insufficient balance on the current account." msgstr "Order cannot be filled due to insufficient balance on the current account." +#: apps/cowswap-frontend/src/modules/wallet/pure/Web3StatusInner/index.tsx +msgid "Restoring wallet..." +msgstr "Restoring wallet..." + #: apps/cowswap-frontend/src/modules/account/containers/Transaction/ActivityDetails.tsx #: apps/cowswap-frontend/src/modules/account/containers/Transaction/ActivityDetails.tsx msgid "required" diff --git a/apps/cowswap-frontend/src/modules/affiliate/containers/AffiliatePartnerOnboard.tsx b/apps/cowswap-frontend/src/modules/affiliate/containers/AffiliatePartnerOnboard.tsx index 4ddc2730e43..5eb70fb2e39 100644 --- a/apps/cowswap-frontend/src/modules/affiliate/containers/AffiliatePartnerOnboard.tsx +++ b/apps/cowswap-frontend/src/modules/affiliate/containers/AffiliatePartnerOnboard.tsx @@ -67,7 +67,7 @@ export function AffiliatePartnerOnboard(): ReactNode { {!account && ( - + Connect wallet )} diff --git a/apps/cowswap-frontend/src/modules/affiliate/containers/AffiliateTraderOnboard.tsx b/apps/cowswap-frontend/src/modules/affiliate/containers/AffiliateTraderOnboard.tsx index ef34e10a9b8..f30862eb077 100644 --- a/apps/cowswap-frontend/src/modules/affiliate/containers/AffiliateTraderOnboard.tsx +++ b/apps/cowswap-frontend/src/modules/affiliate/containers/AffiliateTraderOnboard.tsx @@ -2,7 +2,7 @@ import { useSetAtom } from 'jotai' import { ReactNode } from 'react' import EARN_AS_TRADER_ILLUSTRATION from '@cowprotocol/assets/images/earn-as-trader.svg' -import { ButtonPrimary } from '@cowprotocol/ui' +import { ButtonPrimary, ButtonSize } from '@cowprotocol/ui' import { useWalletInfo } from '@cowprotocol/wallet' import { Trans } from '@lingui/react/macro' @@ -21,8 +21,8 @@ import { toggleTraderModalAtom } from '../state/affiliateTraderModalAtom' export function AffiliateTraderOnboard(): ReactNode { const { account } = useWalletInfo() - const toggleWalletModal = useToggleWalletModal() const toggleAffiliateModal = useSetAtom(toggleTraderModalAtom) + const toggleWalletModal = useToggleWalletModal() const traderRewardAmount = formatUsdcCompact(getDefaultTraderRewardAmount()) const triggerVolumeLabel = formatUsdCompact(getDefaultTriggerVolume()) const affiliateTimeCapDays = PROGRAM_DEFAULTS.AFFILIATE_TIME_CAP_DAYS @@ -49,7 +49,7 @@ export function AffiliateTraderOnboard(): ReactNode { Add code ) : ( - + Connect wallet )} diff --git a/apps/cowswap-frontend/src/modules/wallet/containers/Web3Status/index.tsx b/apps/cowswap-frontend/src/modules/wallet/containers/Web3Status/index.tsx index 9eed6cf215a..acf3deac205 100644 --- a/apps/cowswap-frontend/src/modules/wallet/containers/Web3Status/index.tsx +++ b/apps/cowswap-frontend/src/modules/wallet/containers/Web3Status/index.tsx @@ -1,6 +1,6 @@ import { ReactNode } from 'react' -import { useConnectionType, useWalletDetails, useWalletInfo } from '@cowprotocol/wallet' +import { useConnectionType, useIsRestoringConnection, useWalletDetails, useWalletInfo } from '@cowprotocol/wallet' import { useToggleWalletModal } from 'legacy/state/application/hooks' @@ -21,6 +21,7 @@ export interface Web3StatusProps { export function Web3Status({ className, onClick, joinedLeft = false }: Web3StatusProps): ReactNode { const connectionType = useConnectionType() const { account } = useWalletInfo() + const isConnectionRestoring = useIsRestoringConnection() const { ensName } = useWalletDetails() const toggleWalletModal = useToggleWalletModal() @@ -37,6 +38,7 @@ export function Web3Status({ className, onClick, joinedLeft = false }: Web3Statu ensName={ensName} connectWallet={toggleWalletModal} connectionType={connectionType} + isConnectionRestoring={isConnectionRestoring} /> ) diff --git a/apps/cowswap-frontend/src/modules/wallet/pure/Web3StatusInner/index.tsx b/apps/cowswap-frontend/src/modules/wallet/pure/Web3StatusInner/index.tsx index 093105e50ba..2e34094e7a0 100644 --- a/apps/cowswap-frontend/src/modules/wallet/pure/Web3StatusInner/index.tsx +++ b/apps/cowswap-frontend/src/modules/wallet/pure/Web3StatusInner/index.tsx @@ -4,7 +4,7 @@ import { useMediaQuery } from '@cowprotocol/common-hooks' import { shortenAddress } from '@cowprotocol/common-utils' import { Command } from '@cowprotocol/types' import { Loader, RowBetween, Media } from '@cowprotocol/ui' -import { ConnectionType } from '@cowprotocol/wallet' +import { type ConnectionType } from '@cowprotocol/wallet' import { t } from '@lingui/core/macro' import { Trans } from '@lingui/react/macro' @@ -20,6 +20,7 @@ import { StatusIcon } from '../StatusIcon' export interface Web3StatusInnerProps { account?: string + isConnectionRestoring?: boolean pendingCount: number connectWallet: Command connectionType: ConnectionType @@ -28,7 +29,15 @@ export interface Web3StatusInnerProps { } export function Web3StatusInner(props: Web3StatusInnerProps): ReactNode { - const { account, pendingCount, ensName, connectionType, connectWallet, showUnfillableOrdersAlert } = props + const { + account, + isConnectionRestoring, + pendingCount, + ensName, + connectionType, + connectWallet, + showUnfillableOrdersAlert, + } = props const hasPendingTransactions = !!pendingCount const isUpToExtraSmall = useMediaQuery(Media.upToExtraSmall(false)) @@ -68,6 +77,16 @@ export function Web3StatusInner(props: Web3StatusInnerProps): ReactNode { ) } + if (isConnectionRestoring) { + return ( + + + Restoring wallet... + + + ) + } + return ( - {!account || (!isSupportedPayoutNetwork && !partnerInfo && !infoLoading) || !isSupportedTradingNetworkValue ? ( + {showLoadingSkeleton ? ( + + ) : !account || (!isSupportedPayoutNetwork && !partnerInfo) || !isSupportedTradingNetworkValue ? ( ) : ( <> diff --git a/apps/cowswap-frontend/src/pages/Account/AffiliateTrader.tsx b/apps/cowswap-frontend/src/pages/Account/AffiliateTrader.tsx index 9cee96af09a..accc7488ca4 100644 --- a/apps/cowswap-frontend/src/pages/Account/AffiliateTrader.tsx +++ b/apps/cowswap-frontend/src/pages/Account/AffiliateTrader.tsx @@ -2,6 +2,7 @@ import { useAtomValue } from 'jotai' import { ReactNode } from 'react' import { PAGE_TITLES } from '@cowprotocol/common-const' +import { useIsRestoringConnection } from '@cowprotocol/wallet' import { useLingui } from '@lingui/react/macro' @@ -35,6 +36,8 @@ export default function AffiliateTrader(): ReactNode { const hasSavedCode = !!savedCode const pageState = getAffiliateTraderPageState(walletStatus, hasSavedCode) const isProviderNetworkUnsupported = useIsProviderNetworkUnsupported() + const isConnectionRestoring = useIsRestoringConnection() + const showLoadingSkeleton = isConnectionRestoring || walletStatus === TraderWalletStatus.PENDING useAffiliateStateViewAnalytics({ action: 'affiliate_trader_page_state_viewed', @@ -61,7 +64,7 @@ export default function AffiliateTrader(): ReactNode { ) : walletStatus === TraderWalletStatus.UNSUPPORTED ? ( - ) : walletStatus === TraderWalletStatus.PENDING ? ( + ) : showLoadingSkeleton ? ( ) : !savedCode || walletStatus === TraderWalletStatus.DISCONNECTED ? ( diff --git a/libs/ui/src/pure/Button/index.tsx b/libs/ui/src/pure/Button/index.tsx index 0ee884d9556..74b7dbdb189 100644 --- a/libs/ui/src/pure/Button/index.tsx +++ b/libs/ui/src/pure/Button/index.tsx @@ -23,6 +23,16 @@ type ButtonSecondaryStyleProps = { $fontSize?: string $minHeight?: string } +export type ButtonPrimaryProps = HTMLAttributes & + ButtonProps & { + altDisabledStyle?: boolean + buttonSize?: ButtonSize + padding?: string + status?: StatusColorVariant + width?: string + $borderRadius?: string + $gap?: string + } function getButtonStatusStyles(status?: StatusColorVariant): ReturnType | undefined { if (!status || status === StatusColorVariant.Default) { diff --git a/libs/wallet/src/api/types.ts b/libs/wallet/src/api/types.ts index 5791bdc71b1..5fe908cc6fc 100644 --- a/libs/wallet/src/api/types.ts +++ b/libs/wallet/src/api/types.ts @@ -16,6 +16,7 @@ export interface WalletInfo { chainId: SupportedChainId account?: Address active?: boolean + isConnectionRestoring?: boolean } export interface WalletDetails { diff --git a/libs/wallet/src/index.ts b/libs/wallet/src/index.ts index 44a6e31b5d4..c5dd1415b42 100644 --- a/libs/wallet/src/index.ts +++ b/libs/wallet/src/index.ts @@ -19,6 +19,7 @@ export * from './wagmi/hooks/useIsSmartContractWallet' export * from './wagmi/hooks/useDisconnectWallet' export * from './wagmi/hooks/useSwitchNetwork' export * from './wagmi/hooks/useConnectionType' +export * from './wagmi/hooks/useIsRestoringConnection' // Updater export { WalletUpdater } from './wagmi/updater' diff --git a/libs/wallet/src/wagmi/hooks/useIsRestoringConnection.ts b/libs/wallet/src/wagmi/hooks/useIsRestoringConnection.ts new file mode 100644 index 00000000000..8d78a0e981a --- /dev/null +++ b/libs/wallet/src/wagmi/hooks/useIsRestoringConnection.ts @@ -0,0 +1,12 @@ +import { useConnection } from 'wagmi' + +import { useWalletInfo } from '../../api/hooks' + +export function useIsRestoringConnection(): boolean { + const { status } = useConnection() + const { account } = useWalletInfo() + + // WalletInfo is backed by an atom updated from wagmi, so account can lag behind + // the connected status for one render. Keep showing reconnecting during that gap. + return status === 'reconnecting' || (status === 'connected' && !account) +} diff --git a/libs/wallet/src/wagmi/updater.ts b/libs/wallet/src/wagmi/updater.ts index 153bedf572d..1ec2010f9e0 100644 --- a/libs/wallet/src/wagmi/updater.ts +++ b/libs/wallet/src/wagmi/updater.ts @@ -41,7 +41,8 @@ function useBrowserUrlKey(): string { function useWalletInfo(): WalletInfo { const urlKey = useBrowserUrlKey() - const { address, chainId, isConnected } = useConnection() + const { address, chainId, isConnected, status } = useConnection() + const isConnectionRestoring = status === 'reconnecting' const isChainIdUnsupported = !!chainId && !(chainId in SupportedChainId) const [lastStableChainId, setLastStableChainId] = useState(undefined) const [lastResolvedChainId, setLastResolvedChainId] = useState(() => getCurrentChainIdFromUrl()) @@ -76,8 +77,18 @@ function useWalletInfo(): WalletInfo { chainId: resolvedChainId, active: isConnected, account: address, + isConnectionRestoring, } - }, [address, chainId, isConnected, isChainIdUnsupported, lastStableChainId, lastResolvedChainId, urlKey]) + }, [ + address, + chainId, + isConnected, + isChainIdUnsupported, + isConnectionRestoring, + lastStableChainId, + lastResolvedChainId, + urlKey, + ]) useEffect(() => { setLastResolvedChainId(walletInfo.chainId) From 72065f08a819acc31af70f401e1056e9046935e7 Mon Sep 17 00:00:00 2001 From: brunota20 Date: Mon, 18 May 2026 08:52:10 -0300 Subject: [PATCH 2/3] fix(wallet): detect restoring state when reconnectOnMount is false --- .../wagmi/hooks/useIsRestoringConnection.ts | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/libs/wallet/src/wagmi/hooks/useIsRestoringConnection.ts b/libs/wallet/src/wagmi/hooks/useIsRestoringConnection.ts index 8d78a0e981a..c10bd40768e 100644 --- a/libs/wallet/src/wagmi/hooks/useIsRestoringConnection.ts +++ b/libs/wallet/src/wagmi/hooks/useIsRestoringConnection.ts @@ -1,12 +1,27 @@ +import { useSyncExternalStore } from 'react' + import { useConnection } from 'wagmi' import { useWalletInfo } from '../../api/hooks' +import { config } from '../config' + +function getCurrentConnectorUid(): string | null { + return config.state.current ?? null +} + +function subscribeToCurrentConnectorUid(callback: () => void): () => void { + return config.subscribe((state) => state.current, callback) +} export function useIsRestoringConnection(): boolean { const { status } = useConnection() const { account } = useWalletInfo() - // WalletInfo is backed by an atom updated from wagmi, so account can lag behind - // the connected status for one render. Keep showing reconnecting during that gap. - return status === 'reconnecting' || (status === 'connected' && !account) + const currentConnectorUid = useSyncExternalStore(subscribeToCurrentConnectorUid, getCurrentConnectorUid, () => null) + + if (status === 'reconnecting') return true + if (status === 'connected' && !account) return true + if (!!currentConnectorUid && !account) return true + + return false } From 7a73c920494dfb17db856f85d032adb2c4d98b35 Mon Sep 17 00:00:00 2001 From: brunota20 Date: Mon, 18 May 2026 10:12:18 -0300 Subject: [PATCH 3/3] fix(wallet): track explicit lifecycle for initial reconnect --- libs/wallet/src/wagmi/Web3Provider.tsx | 23 ++++++++++++++---- libs/wallet/src/wagmi/config.ts | 22 +++++++++++++++++ .../wagmi/hooks/useIsRestoringConnection.ts | 24 +++++++++++-------- .../src/wagmi/initialReconnectLifecycle.ts | 23 ++++++++++++++++++ 4 files changed, 78 insertions(+), 14 deletions(-) create mode 100644 libs/wallet/src/wagmi/initialReconnectLifecycle.ts diff --git a/libs/wallet/src/wagmi/Web3Provider.tsx b/libs/wallet/src/wagmi/Web3Provider.tsx index 96b120a4486..2aba2848bdc 100644 --- a/libs/wallet/src/wagmi/Web3Provider.tsx +++ b/libs/wallet/src/wagmi/Web3Provider.tsx @@ -8,6 +8,7 @@ import { reconnect } from '@wagmi/core' import { WagmiProvider } from 'wagmi' import { config, reownAppKit } from './config' +import { markInitialReconnectSettled } from './initialReconnectLifecycle' import { SafeConnectionHandler } from './SafeConnectionHandler' import { getIsInjectedMobileBrowser } from '../api/utils/connection' @@ -39,9 +40,13 @@ function reconnectWidgetConnector(): (() => void) | undefined { // Clear the shimDisconnect flag so reconnect() passes isAuthorized() even if the // connector was previously "disconnected" (which can happen on widget recreations). void config.storage?.removeItem(`${COW_WIDGET_CONNECTOR_ID}.disconnected`) - reconnect(config, { connectors: [widgetConnector] }).catch((error) => { - console.debug('[ReconnectOnMount] widget connector reconnect failed', error) - }) + reconnect(config, { connectors: [widgetConnector] }) + .catch((error) => { + console.debug('[ReconnectOnMount] widget connector reconnect failed', error) + }) + .finally(() => { + markInitialReconnectSettled() + }) } doReconnect() @@ -79,7 +84,12 @@ function ReconnectOnMount(): null { useEffect((): (() => void) | void => { // When running as a pure Safe App (not a widget), skip reconnect and let SafeConnectionHandler // handle the wallet — reconnecting a previously saved non-Safe connector first causes a race condition. - if (isEmbeddedInIframe() && !isInjectedWidget()) return + if (isEmbeddedInIframe() && !isInjectedWidget()) { + // SafeConnectionHandler drives the connection here; we won't observe a wagmi reconnect lifecycle, + // so settle immediately to avoid the restoring spinner getting stuck in this context. + markInitialReconnectSettled() + return + } if (isInjectedWidget()) { // In widget context, use reconnect() (not connect()) to avoid triggering wallet popups. @@ -114,6 +124,8 @@ function ReconnectOnMount(): null { console.debug('[ReconnectOnMount] mobile reconnect result', res) } catch (error) { console.debug('[ReconnectOnMount] mobile reconnect failed', error) + } finally { + markInitialReconnectSettled() } })() return @@ -127,6 +139,9 @@ function ReconnectOnMount(): null { .catch((error: unknown) => { console.error('[ReconnectOnMount] error', error) }) + .finally(() => { + markInitialReconnectSettled() + }) }, []) return null } diff --git a/libs/wallet/src/wagmi/config.ts b/libs/wallet/src/wagmi/config.ts index d07252907a9..4eacedfed91 100644 --- a/libs/wallet/src/wagmi/config.ts +++ b/libs/wallet/src/wagmi/config.ts @@ -139,6 +139,28 @@ const WAGMI_STORAGE_KEY = isInjectedWidget() ? 'cowswap-wallet-safe' : 'cowswap-wallet' +function persistedStateHasSession(state: unknown): boolean { + if (!state || typeof state !== 'object') return false + const s = state as { current?: unknown; connections?: { __type?: string; value?: unknown } } + if (typeof s.current === 'string' && s.current) return true + const c = s.connections + return c?.__type === 'Map' && Array.isArray(c.value) && c.value.length > 0 +} + +// Sniff persisted wagmi state synchronously at module init, BEFORE WagmiProvider's +// `` mounts and runs `setState({ connections: new Map() })` (which the guard +// below may rewrite to `current: null`, erasing the signal at runtime). Used by the +// initialReconnectLifecycle module to know whether a wallet session needs restoring. +export const HAS_PERSISTED_WAGMI_SESSION = ((): boolean => { + if (typeof window === 'undefined') return false + try { + const raw = window.sessionStorage.getItem(`${WAGMI_STORAGE_KEY}.store`) + return raw ? persistedStateHasSession(JSON.parse(raw)?.state) : false + } catch { + return false + } +})() + const storage = typeof window === 'undefined' ? createStorage({ diff --git a/libs/wallet/src/wagmi/hooks/useIsRestoringConnection.ts b/libs/wallet/src/wagmi/hooks/useIsRestoringConnection.ts index c10bd40768e..e4da345c6d6 100644 --- a/libs/wallet/src/wagmi/hooks/useIsRestoringConnection.ts +++ b/libs/wallet/src/wagmi/hooks/useIsRestoringConnection.ts @@ -3,25 +3,29 @@ import { useSyncExternalStore } from 'react' import { useConnection } from 'wagmi' import { useWalletInfo } from '../../api/hooks' -import { config } from '../config' +import { getInitialReconnectLifecycle, subscribeInitialReconnect } from '../initialReconnectLifecycle' -function getCurrentConnectorUid(): string | null { - return config.state.current ?? null -} - -function subscribeToCurrentConnectorUid(callback: () => void): () => void { - return config.subscribe((state) => state.current, callback) +function getServerSnapshot(): 'pending' | 'settled' { + return 'settled' } export function useIsRestoringConnection(): boolean { const { status } = useConnection() const { account } = useWalletInfo() - const currentConnectorUid = useSyncExternalStore(subscribeToCurrentConnectorUid, getCurrentConnectorUid, () => null) - + // Web3Provider mounts WagmiProvider with reconnectOnMount={false} and triggers + // reconnect() from a useEffect, so wagmi's own `status` only flips to 'reconnecting' + // one render after mount. The `config.setState` guard in config.ts then erases + // `current` on any subsequent Hydrate.onMount, so we can't rely on the live wagmi + // state for the initial-load window either. + // + // Track the lifecycle explicitly: 'pending' from module init (if sessionStorage held a + // session) until ReconnectOnMount calls markInitialReconnectSettled() in every code path. + const lifecycle = useSyncExternalStore(subscribeInitialReconnect, getInitialReconnectLifecycle, getServerSnapshot) + + if (lifecycle === 'pending' && !account) return true if (status === 'reconnecting') return true if (status === 'connected' && !account) return true - if (!!currentConnectorUid && !account) return true return false } diff --git a/libs/wallet/src/wagmi/initialReconnectLifecycle.ts b/libs/wallet/src/wagmi/initialReconnectLifecycle.ts new file mode 100644 index 00000000000..6904be1bf8c --- /dev/null +++ b/libs/wallet/src/wagmi/initialReconnectLifecycle.ts @@ -0,0 +1,23 @@ +import { HAS_PERSISTED_WAGMI_SESSION } from './config' + +type Lifecycle = 'pending' | 'settled' + +let lifecycle: Lifecycle = HAS_PERSISTED_WAGMI_SESSION ? 'pending' : 'settled' +const listeners = new Set<() => void>() + +export function markInitialReconnectSettled(): void { + if (lifecycle === 'settled') return + lifecycle = 'settled' + for (const listener of listeners) listener() +} + +export function subscribeInitialReconnect(callback: () => void): () => void { + listeners.add(callback) + return () => { + listeners.delete(callback) + } +} + +export function getInitialReconnectLifecycle(): Lifecycle { + return lifecycle +}