diff --git a/apps/cowswap-frontend/package.json b/apps/cowswap-frontend/package.json
index 4a6974f08e1..0a3274e67aa 100644
--- a/apps/cowswap-frontend/package.json
+++ b/apps/cowswap-frontend/package.json
@@ -72,6 +72,7 @@
"@react-spring/web": "9.7.3",
"@reown/appkit": "1.8.19",
"@reown/appkit-adapter-wagmi": "1.8.19",
+ "@reown/appkit-controllers": "1.8.19",
"@reduxjs/toolkit": "1.9.5",
"@safe-global/api-kit": "4.0.1",
"@safe-global/types-kit": "3.0.0",
diff --git a/apps/cowswap-frontend/src/locales/en-US.po b/apps/cowswap-frontend/src/locales/en-US.po
index a000bf81c70..f52af3c780c 100644
--- a/apps/cowswap-frontend/src/locales/en-US.po
+++ b/apps/cowswap-frontend/src/locales/en-US.po
@@ -1588,6 +1588,10 @@ msgstr "Cookie Policy"
msgid "Partner fee can not be more than {PARTNER_FEE_MAX_BPS} BPS!"
msgstr "Partner fee can not be more than {PARTNER_FEE_MAX_BPS} BPS!"
+#: apps/cowswap-frontend/src/modules/tradeFormValidation/pure/TradeFormButtons/tradeButtonsMap.tsx
+msgid "Restoring wallet"
+msgstr "Restoring wallet"
+
#: apps/cowswap-frontend/src/modules/tokensList/pure/TokensContent/index.tsx
#~ msgid "Manage Token Lists"
#~ msgstr "Manage Token Lists"
diff --git a/apps/cowswap-frontend/src/modules/application/containers/App/Updaters.tsx b/apps/cowswap-frontend/src/modules/application/containers/App/Updaters.tsx
index e9c399d181a..578e47474be 100644
--- a/apps/cowswap-frontend/src/modules/application/containers/App/Updaters.tsx
+++ b/apps/cowswap-frontend/src/modules/application/containers/App/Updaters.tsx
@@ -8,13 +8,14 @@ import {
TokensListsUpdater,
UnsupportedTokensUpdater,
} from '@cowprotocol/tokens'
-import { useWalletInfo, WalletUpdater } from '@cowprotocol/wallet'
+import { useWalletInfo, WalletUpdater, WidgetStandaloneModeUpdater } from '@cowprotocol/wallet'
import { CowSdkUpdater } from 'cowSdk'
import { useBalancesContext } from 'entities/balancesContext/useBalancesContext'
import { BridgeOrdersCleanUpdater } from 'entities/bridgeOrders'
import { BridgeProvidersUpdater, useBridgeSupportedNetworks } from 'entities/bridgeProvider'
import { CorrelatedTokensUpdater } from 'entities/correlatedTokens'
+import { useInjectedWidgetParams } from 'entities/injectedWidget'
import { ThemeConfigUpdater } from 'theme/ThemeConfigUpdater'
import { TradingSdkUpdater } from 'tradingSdk/TradingSdkUpdater'
@@ -22,7 +23,7 @@ import { BalancesDevtools, CommonPriorityBalancesAndAllowancesUpdater } from 'mo
import { PendingBridgeOrdersUpdater, BridgingEnabledUpdater } from 'modules/bridge'
import { BalancesCombinedUpdater } from 'modules/combinedBalances'
import { InFlightOrderFinalizeUpdater } from 'modules/ethFlow'
-import { CowEventsUpdater, InjectedWidgetUpdater, WidgetStandaloneModeUpdater } from 'modules/injectedWidget'
+import { CowEventsUpdater, InjectedWidgetUpdater } from 'modules/injectedWidget'
import { FinalizeTxUpdater } from 'modules/onchainTransactions'
import {
OrderProgressEventsUpdater,
@@ -77,6 +78,7 @@ export function Updaters(): ReactNode {
const { chainId: sourceChainId } = useSourceChainId()
const bridgeNetworkInfo = useBridgeSupportedNetworks()
const balancesContext = useBalancesContext()
+ const { standaloneMode } = useInjectedWidgetParams()
const balancesAccount = balancesContext.account || account
return (
@@ -102,7 +104,7 @@ export function Updaters(): ReactNode {
-
+
diff --git a/apps/cowswap-frontend/src/modules/injectedWidget/index.ts b/apps/cowswap-frontend/src/modules/injectedWidget/index.ts
index 6527624fad9..3838fb7c76b 100644
--- a/apps/cowswap-frontend/src/modules/injectedWidget/index.ts
+++ b/apps/cowswap-frontend/src/modules/injectedWidget/index.ts
@@ -1,6 +1,5 @@
export { InjectedWidgetUpdater } from './updaters/InjectedWidgetUpdater'
export { CowEventsUpdater } from './updaters/CowEventsUpdater'
-export { WidgetStandaloneModeUpdater } from './updaters/WidgetStandaloneMode.updater'
export { useIsInfiniteApproveDisabledInWidget } from './hooks/useIsInfiniteApproveDisabledInWidget'
export { useInjectedWidgetDeadline } from './hooks/useInjectedWidgetDeadline'
export { useInjectedWidgetMetaData } from './hooks/useInjectedWidgetMetaData'
diff --git a/apps/cowswap-frontend/src/modules/injectedWidget/updaters/WidgetStandaloneMode.updater.tsx b/apps/cowswap-frontend/src/modules/injectedWidget/updaters/WidgetStandaloneMode.updater.tsx
deleted file mode 100644
index c98c120b277..00000000000
--- a/apps/cowswap-frontend/src/modules/injectedWidget/updaters/WidgetStandaloneMode.updater.tsx
+++ /dev/null
@@ -1,38 +0,0 @@
-import { type ReactNode, useEffect } from 'react'
-
-import { isInjectedWidget } from '@cowprotocol/common-utils'
-import { COW_WIDGET_CONNECTOR_ID, useDisconnectWallet } from '@cowprotocol/wallet'
-
-import { useInjectedWidgetParams } from 'entities/injectedWidget'
-import { useConnection } from 'wagmi'
-
-/**
- * When the widget switches from standalone mode to dapp mode (without iframe recreation),
- * the EIP-6963 wallet connection established in standalone mode stays active in wagmi.
- * This causes the widget to appear connected even though the dapp hasn't provided a provider.
- *
- * This updater watches for standaloneMode→false transitions and disconnects any active
- * non-widget connections so dapp mode starts clean.
- */
-export function WidgetStandaloneModeUpdater(): ReactNode {
- const { standaloneMode } = useInjectedWidgetParams()
- const { connector } = useConnection()
- const disconnect = useDisconnectWallet()
-
- const isWidgetConnector = connector?.id === COW_WIDGET_CONNECTOR_ID
- const isDappMode = standaloneMode === false
-
- /**
- * In standalone mode we only allow to be connected to the widget connector
- */
- useEffect(() => {
- if (!isInjectedWidget()) return
- if (!connector) return
-
- if (isDappMode && !isWidgetConnector) {
- void disconnect()
- }
- }, [isWidgetConnector, isDappMode, disconnect, connector])
-
- return null
-}
diff --git a/apps/cowswap-frontend/src/modules/trade/hooks/setupTradeState/useSetupTradeState.ts b/apps/cowswap-frontend/src/modules/trade/hooks/setupTradeState/useSetupTradeState.ts
index d846b181acd..7468a6f058f 100644
--- a/apps/cowswap-frontend/src/modules/trade/hooks/setupTradeState/useSetupTradeState.ts
+++ b/apps/cowswap-frontend/src/modules/trade/hooks/setupTradeState/useSetupTradeState.ts
@@ -252,6 +252,9 @@ export function useSetupTradeState(): void {
* 4. Otherwise, navigate to the new chainId with default tokens
*/
useEffect(() => {
+ // Take urlChainId directly from window.location to avoid race conditions
+ const urlChainId = getRawCurrentChainIdFromUrl()
+
// When we came back to the tab and there is a new chainId in provider
const providerChangedNetworkWhenWindowInactive =
isWindowVisible && prevIsWindowVisible !== isWindowVisible && providerChainId !== urlChainId
diff --git a/apps/cowswap-frontend/src/modules/tradeFormValidation/hooks/useTradeFormValidationContext.ts b/apps/cowswap-frontend/src/modules/tradeFormValidation/hooks/useTradeFormValidationContext.ts
index b160680cc06..d1b6fa67da4 100644
--- a/apps/cowswap-frontend/src/modules/tradeFormValidation/hooks/useTradeFormValidationContext.ts
+++ b/apps/cowswap-frontend/src/modules/tradeFormValidation/hooks/useTradeFormValidationContext.ts
@@ -6,7 +6,13 @@ import { Nullish } from '@cowprotocol/cow-sdk'
import { Currency, Token } from '@cowprotocol/currency'
import { useENSAddress } from '@cowprotocol/ens'
import { useIsTradeUnsupported, useIsXstockToken, useTryFindToken } from '@cowprotocol/tokens'
-import { useGnosisSafeInfo, useIsTxBundlingSupported, useWalletDetails, useWalletInfo } from '@cowprotocol/wallet'
+import {
+ useGnosisSafeInfo,
+ useIsRestoringConnection,
+ useIsTxBundlingSupported,
+ useWalletDetails,
+ useWalletInfo,
+} from '@cowprotocol/wallet'
import { useHasHookBridgeProvidersEnabled } from 'entities/bridgeProvider'
import { useInjectedWidgetParams } from 'entities/injectedWidget'
@@ -44,6 +50,7 @@ export function useTradeFormValidationContext(): TradeFormValidationCommonContex
const isProviderNetworkDeprecated = useIsProviderNetworkDeprecated()
const isOnline = useIsOnline()
const { isLoading: isBalancesLoading, hasFirstLoad, error: balancesError } = useTokensBalancesCombined()
+ const isRestoringConnection = useIsRestoringConnection()
const { inputCurrency, outputCurrency, recipient, tradeType } = derivedTradeState || {}
const customTokenError = useTokenCustomTradeError(inputCurrency, outputCurrency, tradeQuote.error)
@@ -115,6 +122,7 @@ export function useTradeFormValidationContext(): TradeFormValidationCommonContex
isInputCurrencyXstock,
isOutputCurrencyXstock,
isNonEvmReceiverConfirmed,
+ isRestoringConnection,
}
}, [
hasFirstLoad,
@@ -146,6 +154,7 @@ export function useTradeFormValidationContext(): TradeFormValidationCommonContex
injectedWidgetParams,
tradePriceImpact,
isNonEvmReceiverConfirmed,
+ isRestoringConnection,
])
}
diff --git a/apps/cowswap-frontend/src/modules/tradeFormValidation/pure/TradeFormButtons/tradeButtonsMap.tsx b/apps/cowswap-frontend/src/modules/tradeFormValidation/pure/TradeFormButtons/tradeButtonsMap.tsx
index 93ae0c0b313..a622474139e 100644
--- a/apps/cowswap-frontend/src/modules/tradeFormValidation/pure/TradeFormButtons/tradeButtonsMap.tsx
+++ b/apps/cowswap-frontend/src/modules/tradeFormValidation/pure/TradeFormButtons/tradeButtonsMap.tsx
@@ -416,4 +416,7 @@ export const tradeButtonsMap: RecordThe token pair is constrained,
},
+ [TradeFormValidation.RestoringWallet]: {
+ text: Restoring wallet,
+ },
}
diff --git a/apps/cowswap-frontend/src/modules/tradeFormValidation/services/validateTradeForm.ts b/apps/cowswap-frontend/src/modules/tradeFormValidation/services/validateTradeForm.ts
index f9c848a20ac..bf43e1f5001 100644
--- a/apps/cowswap-frontend/src/modules/tradeFormValidation/services/validateTradeForm.ts
+++ b/apps/cowswap-frontend/src/modules/tradeFormValidation/services/validateTradeForm.ts
@@ -37,6 +37,7 @@ export function validateTradeForm(context: TradeFormValidationContext): TradeFor
injectedWidgetParams,
tradePriceImpact,
isNonEvmReceiverConfirmed,
+ isRestoringConnection,
} = context
const {
@@ -117,7 +118,9 @@ export function validateTradeForm(context: TradeFormValidationContext): TradeFor
}
if (!isSwapUnsupported && !account) {
- validations.push(TradeFormValidation.WalletNotConnected)
+ validations.push(
+ isRestoringConnection ? TradeFormValidation.RestoringWallet : TradeFormValidation.WalletNotConnected,
+ )
}
if (!isSupportedWallet) {
diff --git a/apps/cowswap-frontend/src/modules/tradeFormValidation/types.ts b/apps/cowswap-frontend/src/modules/tradeFormValidation/types.ts
index a8623ed820f..ee97b3d0213 100644
--- a/apps/cowswap-frontend/src/modules/tradeFormValidation/types.ts
+++ b/apps/cowswap-frontend/src/modules/tradeFormValidation/types.ts
@@ -58,6 +58,7 @@ export interface TradeFormValidationCommonContext {
injectedWidgetParams: Partial
tradePriceImpact: PriceImpact
isNonEvmReceiverConfirmed: boolean
+ isRestoringConnection: boolean
}
export interface TradeFormValidationContext extends TradeFormValidationCommonContext {}
@@ -75,6 +76,7 @@ export enum TradeFormValidation {
WalletNotSupported,
SafeReadonlyUser,
WalletCapabilitiesLoading,
+ RestoringWallet,
// Quote request params
CurrencyNotSet,
diff --git a/apps/cowswap-frontend/vite.config.mts b/apps/cowswap-frontend/vite.config.mts
index 833ac079812..c7544fa545f 100644
--- a/apps/cowswap-frontend/vite.config.mts
+++ b/apps/cowswap-frontend/vite.config.mts
@@ -13,6 +13,7 @@ import svgr from 'vite-plugin-svgr'
import viteTsConfigPaths from 'vite-tsconfig-paths'
import { execSync } from 'child_process'
+import { readFile } from 'node:fs/promises'
import * as path from 'path'
import pkg from './package.json'
@@ -195,6 +196,25 @@ export default defineConfig(({ mode, isPreview }) => {
})
},
},
+ {
+ // @reown/appkit ships .js.map files whose `sources` point at the original
+ // TypeScript (exports/react.ts, src/**/*.ts) without inlining `sourcesContent`,
+ // and the published tarball doesn't include those .ts files. esbuild follows the
+ // sourceMappingURL pragma during prebundling and propagates the null sources into
+ // the optimized dep map, so devtools 404s on every reown source ("DevTools failed
+ // to load source map"). Drop the pragma for @reown files so esbuild treats the
+ // shipped compiled .js as the source and embeds real `sourcesContent` instead.
+ name: 'cow-reown-strip-sourcemap',
+ setup(build) {
+ build.onLoad({ filter: /[\\/]@reown[\\/].*\.js$/ }, async (args) => {
+ const contents = await readFile(args.path, 'utf8')
+ return {
+ contents: contents.replace(/\n?\/\/# sourceMappingURL=.*$/gm, ''),
+ loader: 'js',
+ }
+ })
+ },
+ },
],
},
// Only include packages that are direct or resolvable from the app; transitive
diff --git a/apps/widget-configurator/src/app/configurator/hooks/useWidgetParamsAndSettings.ts b/apps/widget-configurator/src/app/configurator/hooks/useWidgetParamsAndSettings.ts
index 78113e2f889..1756be23f61 100644
--- a/apps/widget-configurator/src/app/configurator/hooks/useWidgetParamsAndSettings.ts
+++ b/apps/widget-configurator/src/app/configurator/hooks/useWidgetParamsAndSettings.ts
@@ -17,6 +17,9 @@ const getBaseUrl = (): string => {
if (isVercel) {
const prKey = window.location.hostname.replace('widget-configurator-git-', '').replace(vercelSuffix, '')
+ // TODO: remove after tests
+ if (2 > 1) return 'https://fix-wallets.swap-dev-5u6.pages.dev'
+
return `https://swap-dev-git-${prKey}${vercelSuffix}`
}
diff --git a/libs/wallet/package.json b/libs/wallet/package.json
index 6163605efe0..d542673edf9 100644
--- a/libs/wallet/package.json
+++ b/libs/wallet/package.json
@@ -27,6 +27,8 @@
"@cowprotocol/cow-sdk": "9.1.2",
"@reown/appkit": "1.8.19",
"@reown/appkit-adapter-wagmi": "1.8.19",
+ "@reown/appkit-controllers": "1.8.19",
+ "@reown/appkit-common": "1.8.19",
"@cowprotocol/assets": "workspace:*",
"@cowprotocol/common-const": "workspace:*",
"@cowprotocol/common-utils": "workspace:*",
@@ -62,6 +64,7 @@
"devDependencies": {
"@types/ms.macro": "2.0.0",
"@types/react": "19.1.3",
- "@types/styled-components": "5.1.34"
+ "@types/styled-components": "5.1.34",
+ "@testing-library/react": "16.3.0"
}
}
diff --git a/libs/wallet/src/api/container/WalletProvider/index.tsx b/libs/wallet/src/api/container/WalletProvider/index.tsx
index 831f180c6b9..9ed5d81b67a 100644
--- a/libs/wallet/src/api/container/WalletProvider/index.tsx
+++ b/libs/wallet/src/api/container/WalletProvider/index.tsx
@@ -2,7 +2,7 @@ import { ReactNode, useEffect } from 'react'
import { useTheme } from '@cowprotocol/common-hooks'
-import { reownAppKit } from '../../../reown/init'
+import { reownAppKit } from '../../../wagmi/config'
interface WalletProviderProps {
children: ReactNode
diff --git a/libs/wallet/src/bindActiveProvider.ts b/libs/wallet/src/bindActiveProvider.ts
new file mode 100644
index 00000000000..98c383b2de2
--- /dev/null
+++ b/libs/wallet/src/bindActiveProvider.ts
@@ -0,0 +1,40 @@
+import { WagmiAdapter } from '@reown/appkit-adapter-wagmi'
+import { EIP1193Provider } from 'viem'
+
+import { activeProviderRef, PROVIDER_DISCONNECTED } from './providerIsolation'
+
+export function bindActiveProvider(adapter: WagmiAdapter): void {
+ // Keep activeProviderRef in sync with the active connector so the per-tab
+ // accountsChanged filter in providerIsolation.ts knows which provider is current.
+ if (typeof window !== 'undefined') {
+ let hasEverConnected = false
+ let syncVersion = 0
+
+ adapter.wagmiConfig.subscribe(
+ (state) => state.current,
+ async (current) => {
+ const version = ++syncVersion
+
+ if (!current) {
+ // Distinguish "never connected yet" (null, let events through for reconnection)
+ // from "was connected, now disconnected" (PROVIDER_DISCONNECTED, block events).
+ activeProviderRef.current = hasEverConnected ? PROVIDER_DISCONNECTED : null
+ return
+ }
+ hasEverConnected = true
+ const connector = adapter.wagmiConfig.connectors.find((c) => c.uid === current)
+ if (!connector) {
+ activeProviderRef.current = PROVIDER_DISCONNECTED
+ return
+ }
+ const provider = (await connector.getProvider().catch(() => null)) as EIP1193Provider | null
+
+ // Ignore stale resolution — a newer subscribe call may have fired while we awaited.
+ if (version !== syncVersion) return
+
+ activeProviderRef.current = provider
+ },
+ { emitImmediately: true },
+ )
+ }
+}
diff --git a/libs/wallet/src/constants.ts b/libs/wallet/src/constants.ts
index ac88e4c6320..c5066402802 100644
--- a/libs/wallet/src/constants.ts
+++ b/libs/wallet/src/constants.ts
@@ -5,9 +5,6 @@ import type { CaipNetworkId } from '@reown/appkit'
/** Custom event name for "open wallet modal". Dispatched by the app; handled in Web3Provider to open Reown (AppKit). */
export const OPEN_WALLET_MODAL_EVENT = 'cowswap-open-wallet-modal'
-/** SessionStorage key: set on user disconnect so InjectedBrowserAutoConnect does not reopen the wallet (e.g. Rabby). */
-export const USER_DISCONNECTED_SESSION_KEY = 'cowswap:userDisconnected:v0'
-
export const WC_DISABLED_TEXT =
'Wallet-connect based wallet is already in use. Please disconnect it to connect to this wallet.'
diff --git a/libs/wallet/src/index.ts b/libs/wallet/src/index.ts
index c5dd1415b42..a53f7fa0fab 100644
--- a/libs/wallet/src/index.ts
+++ b/libs/wallet/src/index.ts
@@ -20,6 +20,8 @@ export * from './wagmi/hooks/useDisconnectWallet'
export * from './wagmi/hooks/useSwitchNetwork'
export * from './wagmi/hooks/useConnectionType'
export * from './wagmi/hooks/useIsRestoringConnection'
+export { WidgetStandaloneModeUpdater } from './updaters/WidgetStandaloneMode.updater'
+export { reownAppKit, wagmiAdapter, wagmiStorage } from './wagmi/config'
// Updater
export { WalletUpdater } from './wagmi/updater'
@@ -40,3 +42,6 @@ export { Web3Provider } from './wagmi/Web3Provider'
// TODO: this export is discussable, however it's already used outside
export * from './api/state'
export * from './api/state'
+
+export * from './utils/getIsSafeAppIframe'
+export * from './utils/connectWalletById'
diff --git a/libs/wallet/src/wagmi/providerIsolation.test.ts b/libs/wallet/src/providerIsolation.test.ts
similarity index 100%
rename from libs/wallet/src/wagmi/providerIsolation.test.ts
rename to libs/wallet/src/providerIsolation.test.ts
diff --git a/libs/wallet/src/wagmi/providerIsolation.ts b/libs/wallet/src/providerIsolation.ts
similarity index 100%
rename from libs/wallet/src/wagmi/providerIsolation.ts
rename to libs/wallet/src/providerIsolation.ts
diff --git a/libs/wallet/src/reown/consts.ts b/libs/wallet/src/reown/consts.ts
index 88365891be0..c4e0f9fb953 100644
--- a/libs/wallet/src/reown/consts.ts
+++ b/libs/wallet/src/reown/consts.ts
@@ -14,3 +14,4 @@ export const SUPPORTED_REOWN_NETWORKS = ALL_SUPPORTED_CHAIN_IDS.flatMap((chainId
) as [Chain, ...Chain[]]
export const COW_WIDGET_CONNECTOR_ID = 'cow-widget'
+export const SAFE_CONNECTOR_ID = 'safe'
diff --git a/libs/wallet/src/reown/init.ts b/libs/wallet/src/reown/init.ts
deleted file mode 100644
index daa196739a6..00000000000
--- a/libs/wallet/src/reown/init.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-// Re-export from wagmi config so config and WagmiProvider share the same types (avoids Config version mismatch).
-export { reownAppKit, wagmiAdapter } from '../wagmi/config'
-import { config, wagmiAdapter } from '../wagmi/config'
-
-// wagmiAdapter is null in Safe App iframes (no AppKit). Fall back to the shared wagmi config.
-export const reownWagmiConfig = wagmiAdapter?.wagmiConfig ?? config
diff --git a/libs/wallet/src/state/appWalletContext.atom.ts b/libs/wallet/src/state/appWalletContext.atom.ts
new file mode 100644
index 00000000000..e7557058dd9
--- /dev/null
+++ b/libs/wallet/src/state/appWalletContext.atom.ts
@@ -0,0 +1,7 @@
+import { atom } from 'jotai'
+
+export interface AppWalletContext {
+ standaloneMode: boolean | undefined
+}
+
+export const appWalletContextAtom = atom(null)
diff --git a/libs/wallet/src/updaters/WidgetStandaloneMode.updater.test.tsx b/libs/wallet/src/updaters/WidgetStandaloneMode.updater.test.tsx
new file mode 100644
index 00000000000..8c38df228fc
--- /dev/null
+++ b/libs/wallet/src/updaters/WidgetStandaloneMode.updater.test.tsx
@@ -0,0 +1,236 @@
+import { isInjectedWidget } from '@cowprotocol/common-utils'
+
+import { ConnectorController } from '@reown/appkit-controllers'
+import { render, RenderResult, waitFor } from '@testing-library/react'
+import { useConnection } from 'wagmi'
+
+import { WidgetStandaloneModeUpdater } from './WidgetStandaloneMode.updater'
+
+import { COW_WIDGET_CONNECTOR_ID, SAFE_CONNECTOR_ID } from '../reown/consts'
+import { connectWalletById } from '../utils/connectWalletById'
+import { getIsSafeAppIframe } from '../utils/getIsSafeAppIframe'
+import { reownAppKit, wagmiAdapter } from '../wagmi/config'
+import { useDisconnectWallet } from '../wagmi/hooks/useDisconnectWallet'
+
+jest.mock('@cowprotocol/common-utils', () => ({
+ isInjectedWidget: jest.fn(),
+}))
+
+jest.mock('wagmi', () => ({
+ useConnection: jest.fn(),
+}))
+
+jest.mock('../utils/connectWalletById', () => ({
+ connectWalletById: jest.fn(),
+}))
+
+jest.mock('../utils/getIsSafeAppIframe', () => ({
+ getIsSafeAppIframe: jest.fn(),
+}))
+
+jest.mock('../wagmi/config', () => ({
+ reownAppKit: { disconnect: jest.fn() },
+ wagmiAdapter: { disconnect: jest.fn(), syncConnections: jest.fn() },
+}))
+
+jest.mock('../wagmi/hooks/useDisconnectWallet', () => ({
+ useDisconnectWallet: jest.fn(),
+}))
+
+jest.mock('@reown/appkit-controllers', () => ({
+ ConnectorController: {
+ subscribe: jest.fn(),
+ state: { connectors: [], allConnectors: [] },
+ },
+}))
+
+const isInjectedWidgetMock = isInjectedWidget as jest.Mock
+const useConnectionMock = useConnection as jest.Mock
+const connectWalletByIdMock = connectWalletById as jest.Mock
+const getIsSafeAppIframeMock = getIsSafeAppIframe as jest.Mock
+const useDisconnectWalletMock = useDisconnectWallet as jest.Mock
+const reownAppKitDisconnectMock = reownAppKit.disconnect as jest.Mock
+const wagmiAdapterDisconnectMock = wagmiAdapter.disconnect as jest.Mock
+const wagmiAdapterSyncConnectionsMock = wagmiAdapter.syncConnections as jest.Mock
+const connectorControllerSubscribeMock = ConnectorController.subscribe as jest.Mock
+
+const disconnectMock = jest.fn()
+
+const OTHER_CONNECTOR_ID = 'metamask'
+
+const DAPP_MODE = false
+const STANDALONE_MODE = true
+
+function setConnector(id: string | undefined): void {
+ useConnectionMock.mockReturnValue({ connector: id ? { id } : undefined })
+}
+
+function renderUpdater(standaloneMode: boolean | undefined): RenderResult {
+ return render()
+}
+
+beforeEach(() => {
+ jest.clearAllMocks()
+
+ isInjectedWidgetMock.mockReturnValue(true)
+ getIsSafeAppIframeMock.mockReturnValue(false)
+ reownAppKitDisconnectMock.mockResolvedValue(undefined)
+ wagmiAdapterDisconnectMock.mockResolvedValue(undefined)
+ disconnectMock.mockResolvedValue(undefined)
+ useDisconnectWalletMock.mockReturnValue(disconnectMock)
+ ConnectorController.state.connectors = []
+ ConnectorController.state.allConnectors = []
+ setConnector(undefined)
+})
+
+describe('WidgetStandaloneModeUpdater', () => {
+ describe('dapp mode: connecting to the widget connector', () => {
+ it('disconnects the current wallet and connects the widget connector', async () => {
+ setConnector(COW_WIDGET_CONNECTOR_ID)
+
+ renderUpdater(DAPP_MODE)
+
+ await waitFor(() => {
+ expect(connectWalletByIdMock).toHaveBeenCalledWith(COW_WIDGET_CONNECTOR_ID, 'injected')
+ })
+
+ expect(reownAppKitDisconnectMock).toHaveBeenCalledTimes(1)
+ })
+
+ it('disconnects before connecting the widget connector', async () => {
+ const callOrder: string[] = []
+ reownAppKitDisconnectMock.mockImplementation(async () => {
+ callOrder.push('disconnect')
+ })
+ connectWalletByIdMock.mockImplementation(() => {
+ callOrder.push('connect')
+ })
+
+ renderUpdater(DAPP_MODE)
+
+ await waitFor(() => {
+ expect(callOrder).toEqual(['disconnect', 'connect'])
+ })
+ })
+
+ it('does not connect the widget connector in standalone mode', () => {
+ renderUpdater(STANDALONE_MODE)
+
+ expect(reownAppKitDisconnectMock).not.toHaveBeenCalled()
+ expect(connectWalletByIdMock).not.toHaveBeenCalled()
+ })
+
+ it('does not connect the widget connector when the mode is undefined', () => {
+ renderUpdater(undefined)
+
+ expect(reownAppKitDisconnectMock).not.toHaveBeenCalled()
+ expect(connectWalletByIdMock).not.toHaveBeenCalled()
+ })
+
+ it('only attempts to connect the widget connector once', async () => {
+ const { rerender } = renderUpdater(DAPP_MODE)
+
+ await waitFor(() => {
+ expect(connectWalletByIdMock).toHaveBeenCalledTimes(1)
+ })
+
+ // Toggle out of and back into dapp mode - the ref guard must prevent a second attempt
+ rerender()
+ rerender()
+
+ expect(connectWalletByIdMock).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ describe('standalone mode: disconnecting the widget configurator', () => {
+ it('disconnects the widget connector and subscribes to the connector controller', () => {
+ renderUpdater(STANDALONE_MODE)
+
+ expect(wagmiAdapterDisconnectMock).toHaveBeenCalledWith({ id: COW_WIDGET_CONNECTOR_ID })
+ expect(connectorControllerSubscribeMock).toHaveBeenCalledTimes(1)
+ })
+
+ it('does not disconnect the widget connector in dapp mode', () => {
+ renderUpdater(DAPP_MODE)
+
+ expect(wagmiAdapterDisconnectMock).not.toHaveBeenCalled()
+ expect(connectorControllerSubscribeMock).not.toHaveBeenCalled()
+ })
+
+ it('removes the widget connector from the reown connection modal', () => {
+ renderUpdater(STANDALONE_MODE)
+
+ const onState = connectorControllerSubscribeMock.mock.calls[0][0]
+ const remainingConnector = { id: OTHER_CONNECTOR_ID }
+
+ onState({ connectors: [{ id: COW_WIDGET_CONNECTOR_ID }, remainingConnector] })
+
+ expect(ConnectorController.state.connectors).toEqual([remainingConnector])
+ expect(ConnectorController.state.allConnectors).toEqual([remainingConnector])
+ expect(wagmiAdapterSyncConnectionsMock).toHaveBeenCalledTimes(1)
+ })
+
+ it('does nothing when the widget connector is not in the list', () => {
+ renderUpdater(STANDALONE_MODE)
+
+ const onState = connectorControllerSubscribeMock.mock.calls[0][0]
+ const connectors = [{ id: OTHER_CONNECTOR_ID }]
+
+ onState({ connectors })
+
+ expect(wagmiAdapterSyncConnectionsMock).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('enforcing the allowed connector', () => {
+ it('disconnects a non-widget connector in dapp mode', () => {
+ setConnector(OTHER_CONNECTOR_ID)
+
+ renderUpdater(DAPP_MODE)
+
+ expect(disconnectMock).toHaveBeenCalledTimes(1)
+ })
+
+ it('keeps the widget connector in dapp mode', () => {
+ setConnector(COW_WIDGET_CONNECTOR_ID)
+
+ renderUpdater(DAPP_MODE)
+
+ expect(disconnectMock).not.toHaveBeenCalled()
+ })
+
+ it('disconnects the widget connector in standalone mode', () => {
+ setConnector(COW_WIDGET_CONNECTOR_ID)
+
+ renderUpdater(STANDALONE_MODE)
+
+ expect(disconnectMock).toHaveBeenCalledTimes(1)
+ })
+
+ it('keeps a non-widget connector in standalone mode', () => {
+ setConnector(OTHER_CONNECTOR_ID)
+
+ renderUpdater(STANDALONE_MODE)
+
+ expect(disconnectMock).not.toHaveBeenCalled()
+ })
+
+ it('does not disconnect the Safe connector inside the Safe App iframe', () => {
+ getIsSafeAppIframeMock.mockReturnValue(true)
+ setConnector(SAFE_CONNECTOR_ID)
+
+ renderUpdater(DAPP_MODE)
+
+ expect(disconnectMock).not.toHaveBeenCalled()
+ })
+
+ it('disconnects the Safe connector when not inside the Safe App iframe', () => {
+ getIsSafeAppIframeMock.mockReturnValue(false)
+ setConnector(SAFE_CONNECTOR_ID)
+
+ renderUpdater(DAPP_MODE)
+
+ expect(disconnectMock).toHaveBeenCalledTimes(1)
+ })
+ })
+})
diff --git a/libs/wallet/src/updaters/WidgetStandaloneMode.updater.tsx b/libs/wallet/src/updaters/WidgetStandaloneMode.updater.tsx
new file mode 100644
index 00000000000..f23a742538c
--- /dev/null
+++ b/libs/wallet/src/updaters/WidgetStandaloneMode.updater.tsx
@@ -0,0 +1,122 @@
+import { useSetAtom } from 'jotai'
+import { useEffect, useRef } from 'react'
+
+import { isInjectedWidget } from '@cowprotocol/common-utils'
+
+import { ConnectorController } from '@reown/appkit-controllers'
+import { useConnection } from 'wagmi'
+
+import { COW_WIDGET_CONNECTOR_ID } from '../reown/consts'
+import { appWalletContextAtom } from '../state/appWalletContext.atom'
+import { connectWalletById } from '../utils/connectWalletById'
+import { getIsSafeAppIframe } from '../utils/getIsSafeAppIframe'
+import { reownAppKit, wagmiAdapter } from '../wagmi/config'
+import { useDisconnectWallet } from '../wagmi/hooks/useDisconnectWallet'
+
+interface WidgetStandaloneModeUpdaterProps {
+ standaloneMode: boolean | undefined
+}
+
+/**
+ * Keeps the wallet connection in sync with the widget's `standaloneMode` setting.
+ *
+ * The CoW widget can run in two modes:
+ * - Dapp mode (`standaloneMode === false`): the embedding dapp owns the wallet connection,
+ * so the widget connects to the special "cow-widget" connector and must never use its own wallet.
+ * - Standalone mode (`standaloneMode === true`): the widget owns the wallet connection,
+ * so the "cow-widget" connector must never be used and is hidden from the Reown connection modal.
+ *
+ * To enforce that, this updater runs three effects:
+ * 1. On entering dapp mode: disconnect the current wallet and connect the "cow-widget" connector.
+ * 2. On entering standalone mode: disconnect the "cow-widget" connector and remove it from the
+ * Reown wallet connection modal so users can't pick it.
+ * 3. Continuously (for injected widgets): disconnect any connector that is not allowed for the current mode -
+ * a non-widget connector in dapp mode, or the widget connector in standalone mode. The Safe App
+ * connection is left untouched.
+ *
+ * Renders nothing.
+ */
+export function WidgetStandaloneModeUpdater({ standaloneMode }: WidgetStandaloneModeUpdaterProps): null {
+ const setAppWalletContext = useSetAtom(appWalletContextAtom)
+ const { connector } = useConnection()
+ const disconnect = useDisconnectWallet()
+
+ const isSafeApp = getIsSafeAppIframe()
+ const isSafeConnector = connector?.id === 'safe'
+ const isWidgetConnector = connector?.id === COW_WIDGET_CONNECTOR_ID
+ const isDappMode = standaloneMode === false
+ const isStandaloneMode = standaloneMode === true
+ const isDisconnectInProgress = useRef(false)
+
+ useEffect(() => {
+ setAppWalletContext((state) => ({ ...state, standaloneMode }))
+ }, [setAppWalletContext, standaloneMode])
+
+ /**
+ * Once in Dapp mode, disconnect any current wallet and connect to the widget connector
+ */
+ useEffect(() => {
+ if (isSafeApp) return
+
+ if (isDappMode) {
+ ;(async function () {
+ console.debug('[WidgetStandaloneModeUpdater] connect widget connector')
+
+ await reownAppKit.disconnect()
+ connectWalletById(COW_WIDGET_CONNECTOR_ID, 'injected')
+ })()
+ }
+ }, [isDappMode, isSafeApp])
+
+ /**
+ * Once in standalone mode, disconnect widget configurator
+ */
+ useEffect(() => {
+ if (isSafeApp) return
+
+ if (isStandaloneMode) {
+ console.debug('[WidgetStandaloneModeUpdater] disconnect widget connector')
+
+ wagmiAdapter.disconnect({ id: COW_WIDGET_CONNECTOR_ID })
+
+ // Remove widget connector from the list in Reown wallet connection modal
+ return ConnectorController.subscribe((state) => {
+ const newConnectors = state.connectors.filter((c) => c.id !== COW_WIDGET_CONNECTOR_ID)
+
+ if (newConnectors.length === state.connectors.length) return
+
+ ConnectorController.state.connectors = newConnectors
+ ConnectorController.state.allConnectors = newConnectors
+ wagmiAdapter.syncConnections()
+ })
+ }
+
+ return undefined
+ }, [isSafeApp, isStandaloneMode])
+
+ /**
+ * In dapp mode we only allow to be connected to the widget connector
+ * In standalone mode never connect to widget connector
+ */
+ useEffect(() => {
+ if (!isInjectedWidget()) return
+ if (!connector) return
+ // Do not disconnect Safe App
+ if (isSafeApp && isSafeConnector) return
+ if (isDisconnectInProgress.current) return
+ const inDappMode = isDappMode && !isWidgetConnector
+ const inStandaloneMode = isStandaloneMode && isWidgetConnector
+
+ if (inDappMode || inStandaloneMode) {
+ console.debug('[WidgetStandaloneModeUpdater] disconnect connector', { inDappMode, inStandaloneMode })
+
+ isDisconnectInProgress.current = true
+
+ disconnect().finally(() => {
+ isDisconnectInProgress.current = false
+ })
+ }
+ }, [isWidgetConnector, isDappMode, isStandaloneMode, disconnect, connector, isSafeApp, isSafeConnector])
+
+ return null
+}
diff --git a/libs/wallet/src/utils/connectWalletById.ts b/libs/wallet/src/utils/connectWalletById.ts
new file mode 100644
index 00000000000..2e186fb542a
--- /dev/null
+++ b/libs/wallet/src/utils/connectWalletById.ts
@@ -0,0 +1,10 @@
+import { StorageUtil } from '@reown/appkit-controllers'
+
+import { wagmiAdapter } from '../wagmi/config'
+
+import type { AdapterBlueprint } from '@reown/appkit-controllers'
+
+export function connectWalletById(id: string, type: string): Promise {
+ StorageUtil.removeDisconnectedConnectorId(id, 'eip155')
+ return wagmiAdapter.connect({ id, type })
+}
diff --git a/libs/wallet/src/utils/getIsSafeAppIframe.ts b/libs/wallet/src/utils/getIsSafeAppIframe.ts
new file mode 100644
index 00000000000..cbddfacdc6a
--- /dev/null
+++ b/libs/wallet/src/utils/getIsSafeAppIframe.ts
@@ -0,0 +1,14 @@
+import { getParentOrigin } from '@cowprotocol/iframe-transport'
+
+import { SAFE_APP_ORIGIN } from '../constants'
+
+const SAFE_APP_PREVIEW_URL = 'https://safe-wallet-monorepo-cowswap-web.vercel.app'
+const SAFE_APP_LOCAL_URL = 'http://localhost:4003'
+const SAFE_SUPPORTED_ORIGINS = [SAFE_APP_ORIGIN, SAFE_APP_PREVIEW_URL, SAFE_APP_LOCAL_URL]
+
+export function getIsSafeAppIframe(): boolean {
+ const origin = getParentOrigin()
+
+ if (!origin) return false
+ return SAFE_SUPPORTED_ORIGINS.includes(origin)
+}
diff --git a/libs/wallet/src/wagmi/SafeConnectionHandler.tsx b/libs/wallet/src/wagmi/SafeConnectionHandler.tsx
deleted file mode 100644
index 1fd3da54661..00000000000
--- a/libs/wallet/src/wagmi/SafeConnectionHandler.tsx
+++ /dev/null
@@ -1,148 +0,0 @@
-import { useEffect, useRef, type ReactNode } from 'react'
-
-import { getCurrentChainIdFromUrl, isInjectedWidget } from '@cowprotocol/common-utils'
-import { SupportedChainId } from '@cowprotocol/cow-sdk'
-import { useSafeAppsSDK } from '@safe-global/safe-apps-react-sdk'
-
-import { connect, getConnection, reconnect } from '@wagmi/core'
-import { type Connector, useConnection, useConnectors } from 'wagmi'
-
-import { config, IS_CROSS_ORIGIN_IFRAME } from './config'
-
-import { ConnectionType } from '../api/types'
-import { COW_WIDGET_CONNECTOR_ID } from '../reown/consts'
-
-interface SafeConnectionHandlerProps {
- children: ReactNode
-}
-
-function isEmbeddedApp(): boolean {
- return IS_CROSS_ORIGIN_IFRAME
-}
-
-function isSupportedChainId(chainId: number): chainId is SupportedChainId {
- return Object.values(SupportedChainId).includes(chainId as SupportedChainId)
-}
-
-function isSafeConnector(c: Connector | undefined): boolean {
- return c?.id === 'safe' || c?.type === ConnectionType.GNOSIS_SAFE
-}
-
-function findSafeConnector(connectors: readonly Connector[]): Connector | undefined {
- const byStandard = connectors.find((c) => c.id === 'safe' || c.type === ConnectionType.GNOSIS_SAFE)
- if (byStandard) return byStandard
- return connectors.find((c) => {
- const id = typeof c.id === 'string' ? c.id.toLowerCase() : ''
- return id.includes('safe') || c.name?.toLowerCase().includes('safe')
- })
-}
-
-function getRegisteredSafeConnector(fallbackFromHook: readonly Connector[]): Connector | undefined {
- const fromConfig = config.connectors as readonly Connector[] | undefined
- const list = Array.isArray(fromConfig) && fromConfig.length > 0 ? fromConfig : fallbackFromHook
- if (!Array.isArray(list) || list.length === 0) return undefined
- return findSafeConnector(list)
-}
-
-function resolveEmbeddedChainId(safe: { chainId?: number } | undefined, sdkConnected: boolean): number {
- const fromUrl = getCurrentChainIdFromUrl()
- const sdkChain = safe?.chainId
- if (sdkConnected && typeof sdkChain === 'number' && isSupportedChainId(sdkChain)) {
- return sdkChain
- }
- return fromUrl
-}
-
-async function connectSafeInIframe(
- safe: { chainId?: number } | undefined,
- sdkConnected: boolean,
- connectorsFallback: readonly Connector[],
-): Promise {
- const safeConnector = getRegisteredSafeConnector(connectorsFallback)
- if (!safeConnector) return
-
- const chainId = resolveEmbeddedChainId(safe, sdkConnected)
-
- try {
- await reconnect(config, { connectors: [safeConnector] })
- } catch {
- // No persisted session — fall through to connect()
- }
-
- const connection = getConnection(config)
- if (connection.status === 'connected' && isSafeConnector(connection.connector)) return
-
- await connect(config, { chainId, connector: safeConnector })
-}
-
-export function SafeConnectionHandler({ children }: SafeConnectionHandlerProps): ReactNode {
- const { connector: currentConnector, isConnected } = useConnection()
- const connectors = useConnectors()
- const { connected: isConnectedThroughSafeApp, safe } = useSafeAppsSDK()
- const isInSafeSdkContext = isConnectedThroughSafeApp && !!safe?.safeAddress
- const isConnectingToSafe = useRef(false)
-
- const safeRef = useRef(safe)
- const sdkConnectedRef = useRef(isConnectedThroughSafeApp)
- const connectorsRef = useRef(connectors)
- const isConnectedRef = useRef(isConnected)
- const currentConnectorRef = useRef(currentConnector)
- const isInSafeSdkContextRef = useRef(isInSafeSdkContext)
-
- useEffect(() => {
- safeRef.current = safe
- sdkConnectedRef.current = isConnectedThroughSafeApp
- connectorsRef.current = connectors
- isConnectedRef.current = isConnected
- currentConnectorRef.current = currentConnector
- isInSafeSdkContextRef.current = isInSafeSdkContext
- }, [safe, isConnectedThroughSafeApp, connectors, isConnected, currentConnector, isInSafeSdkContext])
-
- useEffect(() => {
- const isConnectedToWidget = currentConnectorRef.current?.id === COW_WIDGET_CONNECTOR_ID
-
- if (!isEmbeddedApp()) return
- if (isConnected && isSafeConnector(currentConnector)) return
- // In widget context without Safe SDK: wallet is provided by the parent dapp via WidgetEthereumProvider.
- // Skip Safe auto-connect entirely to avoid competing with the COW_WIDGET_CONNECTOR_ID connection.
- if (isConnectedToWidget) return
- if (isConnectingToSafe.current) return
-
- isConnectingToSafe.current = true
- void connectSafeInIframe(safe, isConnectedThroughSafeApp, connectors)
- .catch(() => {})
- .finally(() => {
- isConnectingToSafe.current = false
- })
- }, [currentConnector, isConnected, isConnectedThroughSafeApp, connectors, safe, isInSafeSdkContext])
-
- useEffect(() => {
- if (!isEmbeddedApp()) return
-
- const reconnectSafeIfNeeded = (): void => {
- const isConnectedToWidget = currentConnectorRef.current?.id === COW_WIDGET_CONNECTOR_ID
-
- if (isConnectedToWidget) return
- if (document.visibilityState === 'hidden') return
- if (isConnectedRef.current && isSafeConnector(currentConnectorRef.current)) return
- if (isInjectedWidget() && !isInSafeSdkContextRef.current) return
- if (isConnectingToSafe.current) return
-
- isConnectingToSafe.current = true
- void connectSafeInIframe(safeRef.current, sdkConnectedRef.current, connectorsRef.current)
- .catch(() => {})
- .finally(() => {
- isConnectingToSafe.current = false
- })
- }
-
- window.addEventListener('focus', reconnectSafeIfNeeded)
- document.addEventListener('visibilitychange', reconnectSafeIfNeeded)
- return () => {
- window.removeEventListener('focus', reconnectSafeIfNeeded)
- document.removeEventListener('visibilitychange', reconnectSafeIfNeeded)
- }
- }, [])
-
- return children
-}
diff --git a/libs/wallet/src/wagmi/Web3Provider.tsx b/libs/wallet/src/wagmi/Web3Provider.tsx
index 0109b7f3ee0..d5010477e5d 100644
--- a/libs/wallet/src/wagmi/Web3Provider.tsx
+++ b/libs/wallet/src/wagmi/Web3Provider.tsx
@@ -1,181 +1,41 @@
import { useEffect, type ReactNode } from 'react'
-import { isImTokenBrowser, isInjectedWidget } from '@cowprotocol/common-utils'
-import { getParentOrigin } from '@cowprotocol/iframe-transport'
import { SafeProvider } from '@safe-global/safe-apps-react-sdk'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
-import { reconnect } from '@wagmi/core'
import { WagmiProvider } from 'wagmi'
-import { config, reownAppKit } from './config'
-import { markInitialReconnectSettled } from './initialReconnectLifecycle'
-import { flushDeferredProviders } from './providerIsolation'
-import { SafeConnectionHandler } from './SafeConnectionHandler'
+import { reownAppKit, wagmiAdapter } from './config'
-import { getIsInjectedMobileBrowser } from '../api/utils/connection'
-import { OPEN_WALLET_MODAL_EVENT, SAFE_APP_ORIGIN } from '../constants'
-import { COW_WIDGET_CONNECTOR_ID } from '../reown/consts'
+import { OPEN_WALLET_MODAL_EVENT } from '../constants'
+import { flushDeferredProviders } from '../providerIsolation'
const queryClient = new QueryClient()
-function reconnectWidgetConnector(): (() => void) | undefined {
- const widgetConnector = config.connectors.find((c) => c.id === COW_WIDGET_CONNECTOR_ID)
- if (!widgetConnector) return undefined
-
- // Clear stale connections from previous sessions (e.g., EIP-6963 connections from
- // standalone mode) to prevent them from interfering with the widget connector.
- // Without this, switching standalone→dapp leaves the old MetaMask EIP-6963 connection
- // as "current" in wagmi's persisted state, blocking the widget connector.
- config.setState((state) => ({
- ...state,
- connections: new Map(),
- current: null,
- status: 'disconnected',
- }))
-
- const doReconnect = (): void => {
- // 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)
- })
- .finally(() => {
- markInitialReconnectSettled()
- })
- }
-
- doReconnect()
-
- // AppKit with enableReconnect=false calls unSyncExistingConnection() during init,
- // which asynchronously disconnects ALL wagmi connections — including the one we just
- // established above. Subscribe to state changes and re-reconnect once if that happens.
- // We track whether we've ever been connected to avoid reacting to the initial clear above.
- let wasConnected = false
- let retried = false
- const unsubscribe = config.subscribe(
- (state) => state.status,
- (status) => {
- if (status === 'connected') {
- wasConnected = true
- }
- if (status === 'disconnected' && wasConnected && !retried) {
- retried = true
- unsubscribe()
- console.debug('[ReconnectOnMount] detected disconnect (likely AppKit unSync), re-reconnecting widget connector')
- doReconnect()
- }
- },
- )
-
- const timeoutId = setTimeout(() => unsubscribe(), 5000)
-
- return () => {
- unsubscribe()
- clearTimeout(timeoutId)
- }
+interface Web3ProviderProps {
+ children: ReactNode
+ standaloneMode?: boolean
}
-function ReconnectOnMount({ isOpenInSafeApp }: { isOpenInSafeApp: boolean }): 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 (isOpenInSafeApp && !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.
- // connect() with shimDisconnect=true calls wallet_requestPermissions which shows a MetaMask
- // account selector. reconnect() uses eth_accounts (silent) via isReconnecting=true path.
- // IframeRpcProviderBridge forwards eth_accounts to the parent wallet's provider.
- return reconnectWidgetConnector()
- }
-
- if (getIsInjectedMobileBrowser()) {
- const injectedConnector = config.connectors.find((c) => c.id === 'injected')
-
- if (injectedConnector) {
- void (async () => {
- try {
- const provider = await injectedConnector.getProvider()
- if (provider && typeof (provider as { request?: unknown }).request === 'function') {
- const eth = provider as { request: (args: { method: string }) => Promise }
-
- if (isImTokenBrowser) {
- // imToken's eth_requestAccounts hangs when called programmatically.
- // Connection is handled via WalletConnect instead — skip this path.
- return
- }
-
- // MetaMask iOS: auto-approves eth_requestAccounts inside its own browser.
- // Calling it first seeds eth_accounts so the subsequent reconnect() succeeds
- // without triggering an AppKit state-sync disconnect (which connect() would cause).
- await eth.request({ method: 'eth_requestAccounts' })
- }
- const res = await reconnect(config, { connectors: [injectedConnector] })
- console.debug('[ReconnectOnMount] mobile reconnect result', res)
- } catch (error) {
- console.debug('[ReconnectOnMount] mobile reconnect failed', error)
- } finally {
- markInitialReconnectSettled()
- }
- })()
- return
- }
- }
-
- void reconnect(config)
- .then((res: unknown) => {
- console.debug('[ReconnectOnMount] result', res)
- })
- .catch((error: unknown) => {
- console.error('[ReconnectOnMount] error', error)
- })
- .finally(() => {
- markInitialReconnectSettled()
- })
- }, [isOpenInSafeApp])
-
- return null
+export function Web3Provider({ children }: Web3ProviderProps): ReactNode {
+ return (
+
+
+
+ {children}
+
+
+ )
}
function OpenWalletModalOnCustomEvent(): null {
useEffect(() => {
- if (!reownAppKit) return
- const appKit = reownAppKit
const handler = (): void => {
+ reownAppKit?.open({ view: 'Connect' })
flushDeferredProviders()
- void appKit.open()
}
document.addEventListener(OPEN_WALLET_MODAL_EVENT, handler)
return () => document.removeEventListener(OPEN_WALLET_MODAL_EVENT, handler)
}, [])
return null
}
-
-interface Web3ProviderProps {
- children: ReactNode
- standaloneMode?: boolean
-}
-
-export function Web3Provider({ children }: Web3ProviderProps): ReactNode {
- const isOpenInSafeApp = getParentOrigin() === SAFE_APP_ORIGIN
-
- return (
-
-
-
-
-
- {isOpenInSafeApp ? {children} : children}
-
-
-
- )
-}
diff --git a/libs/wallet/src/wagmi/config.ts b/libs/wallet/src/wagmi/config.ts
index d5fbae45a69..d48bbb61f1b 100644
--- a/libs/wallet/src/wagmi/config.ts
+++ b/libs/wallet/src/wagmi/config.ts
@@ -1,114 +1,23 @@
import { RPC_URLS, VIEM_CHAINS } from '@cowprotocol/common-const'
-import { getCurrentChainIdFromUrl, isImTokenBrowser, isInjectedWidget } from '@cowprotocol/common-utils'
+import { getCurrentChainIdFromUrl } from '@cowprotocol/common-utils'
import { EvmChains, isEvmChain } from '@cowprotocol/cow-sdk'
-import { WidgetEthereumProvider } from '@cowprotocol/iframe-transport'
import { createAppKit } from '@reown/appkit/react'
import { WagmiAdapter } from '@reown/appkit-adapter-wagmi'
-import { injected, safe } from '@wagmi/connectors'
-import { EIP1193Provider, http } from 'viem'
-import { createConfig, createStorage, type Config, type Transport } from 'wagmi'
+import { http } from 'viem'
+import { type Transport } from 'wagmi'
-import { activeProviderRef, interceptEIP6963Providers, PROVIDER_DISCONNECTED } from './providerIsolation'
+import { getConnectors } from './getConnectors'
-import { COW_WIDGET_CONNECTOR_ID, SUPPORTED_REOWN_NETWORKS } from '../reown/consts'
+import { bindActiveProvider } from '../bindActiveProvider'
+import { interceptEIP6963Providers } from '../providerIsolation'
+import { SAFE_CONNECTOR_ID, SUPPORTED_REOWN_NETWORKS } from '../reown/consts'
+import { connectWalletById } from '../utils/connectWalletById'
+import { getIsSafeAppIframe } from '../utils/getIsSafeAppIframe'
+import { wagmiStorage } from '../wagmiStorage'
-type ConnectorInstance = ReturnType | ReturnType
-
-/**
- * True when the app is running inside a cross-origin iframe (e.g. Safe App).
- * Accessing window.parent.location.href throws a SecurityError for cross-origin frames.
- * Same-origin iframes (e.g. local dev) do not throw.
- */
-export const IS_CROSS_ORIGIN_IFRAME = (() => {
- if (typeof window === 'undefined' || window.self === window.top) return false
- try {
- void window.parent.location.href
- return false
- } catch {
- return true
- }
-})()
-
-// Safe App iframe: skip AppKit — it interferes with Safe's postMessage flow.
-// The widget needs AppKit for the standalone-mode wallet modal, so it keeps AppKit
-// but with localStorage isolation (see below) to avoid cross-context state leaks.
-const isSafeIframe = IS_CROSS_ORIGIN_IFRAME && !isInjectedWidget()
-
-// Redirect AppKit's @appkit/* localStorage keys to sessionStorage on ALL pages.
-// sessionStorage is per-tab but survives page refreshes, giving us:
-// - Tab isolation: connecting MetaMask in Tab B won't overwrite Rabby in Tab A
-// - Refresh persistence: reloading a tab keeps the wallet connected
-// In cross-origin iframes this also prevents the regular app's storage events from
-// leaking into the iframe. We patch Storage.prototype (not the localStorage instance)
-// because browsers may ignore own-property overrides on native host objects.
-if (typeof window !== 'undefined' && !(Storage.prototype as unknown as { __isCowPatched?: boolean }).__isCowPatched) {
- ;(Storage.prototype as unknown as { __isCowPatched: boolean }).__isCowPatched = true
-
- const origSetItem = Storage.prototype.setItem
- const origGetItem = Storage.prototype.getItem
- const origRemoveItem = Storage.prototype.removeItem
-
- Storage.prototype.setItem = function (key: string, value: string) {
- if (this === localStorage && key.startsWith('@appkit/')) {
- origSetItem.call(sessionStorage, key, value)
- } else {
- origSetItem.call(this, key, value)
- }
- }
- Storage.prototype.getItem = function (key: string): string | null {
- if (this === localStorage && key.startsWith('@appkit/')) {
- return origGetItem.call(sessionStorage, key)
- }
- return origGetItem.call(this, key)
- }
- Storage.prototype.removeItem = function (key: string) {
- if (this === localStorage && key.startsWith('@appkit/')) {
- origRemoveItem.call(sessionStorage, key)
- } else {
- origRemoveItem.call(this, key)
- }
- }
-}
-
-// Intercept EIP-6963 provider announcements before wagmi/AppKit processes them so
-// every wallet provider gets wrapped with tab-isolation logic (no wallet_revokePermissions,
-// filtered accountsChanged). Must run before WagmiAdapter / createConfig is instantiated.
interceptEIP6963Providers()
-function getConnectors(): ConnectorInstance[] {
- if (IS_CROSS_ORIGIN_IFRAME) {
- if (isInjectedWidget()) {
- // No plain `injected` connector here — MetaMask is a per-origin singleton, so registering
- // it would leak wallet state between the widget and the main app (connecting in one
- // auto-connects the other). The widget connects via WidgetEthereumProvider (dapp mode)
- // or WalletConnect (standalone mode) instead.
- return [
- injected({
- shimDisconnect: true,
- target: {
- name: 'CoW Widget',
- id: COW_WIDGET_CONNECTOR_ID,
- provider: new WidgetEthereumProvider() as EIP1193Provider,
- },
- }),
- // Include Safe connector so the widget can auto-connect when hosted inside a Safe app
- // (e.g. widget-configurator loaded as a Safe App). IframeSafeSdkBridge in widget-lib
- // already forwards the Safe SDK postMessages through the configurator to app.safe.global.
- safe({ shimDisconnect: true }),
- ]
- }
-
- return [safe({ shimDisconnect: true }), injected({ shimDisconnect: true })]
- }
-
- // EIP-6963 wallets (Rabby, MetaMask, etc.) are already wrapped with tab-isolation
- // by interceptEIP6963Providers(). The plain injected connector is only a fallback for
- // legacy wallets that don't support EIP-6963 — keep it simple so wagmi's built-in
- // provider discovery and reconnection work correctly.
- return [injected({ shimDisconnect: true })]
-}
-
const wagmiTransports = SUPPORTED_REOWN_NETWORKS.reduce(
(acc, chain) => {
const chainId = chain.id as EvmChains
@@ -132,48 +41,6 @@ for (const chain of SUPPORTED_REOWN_NETWORKS) {
const projectId = 'ac287751638b5d374a03c39e37f70376'
-// Use a distinct storage key per context to avoid cross-context session pollution.
-const WAGMI_STORAGE_KEY = isInjectedWidget()
- ? 'cowswap-wallet-' + COW_WIDGET_CONNECTOR_ID
- : IS_CROSS_ORIGIN_IFRAME
- ? '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({
- storage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
- })
- : createStorage({
- // sessionStorage is per-tab but survives refreshes — each tab keeps its own
- // wallet connection without cross-tab interference (e.g. Tab A stays on Rabby
- // even if Tab B switches to MetaMask).
- storage: window.sessionStorage,
- key: WAGMI_STORAGE_KEY,
- })
-
const metadata = {
name: 'CoW Swap | The smartest way to trade cryptocurrencies',
description:
@@ -182,19 +49,7 @@ const metadata = {
icons: ['https://swap.cow.fi/apple-touch-icon.png'],
}
-const connectors = getConnectors()
-
-let wagmiAdapter: WagmiAdapter | null = null
-let reownAppKit: ReturnType | null = null
-let config: Config
-
-// `batch.multicall` collapses concurrent single `useReadContract` calls into one
-// multicall3 aggregate3 — the dominant savings for our `eth_call` budget (otherwise
-// each singular contract read is its own RPC call).
-// `pollingInterval` overrides viem's 4s default so block-driven hooks (BlockNumberProvider,
-// useReadContracts refetches) poll once per ~mainnet block time. Cowswap's UX tolerates
-// the L2 staleness this introduces because trades settle on the protocol's batch cadence.
-const VIEM_CLIENT_TUNING = {
+const wagmiAdapter = new WagmiAdapter({
batch: {
multicall: {
wait: 130, // coalescing window in ms
@@ -203,140 +58,66 @@ const VIEM_CLIENT_TUNING = {
},
// Frequency (in ms) for polling enabled actions & events.
pollingInterval: 12_000,
-} as const
-
-if (isSafeIframe) {
- // Safe App iframe: no AppKit — use a plain wagmi config with only the Safe connector.
- config = createConfig({
- ...VIEM_CLIENT_TUNING,
- connectors,
- chains: SUPPORTED_REOWN_NETWORKS,
- storage,
- transports: wagmiTransports,
- })
-} else {
- wagmiAdapter = new WagmiAdapter({
- ...VIEM_CLIENT_TUNING,
- connectors: connectors as ConstructorParameters[0]['connectors'],
- customRpcUrls,
- networks: SUPPORTED_REOWN_NETWORKS,
- projectId,
- storage,
- transports: wagmiTransports,
- })
-
- config = wagmiAdapter.wagmiConfig
-
- const RECENT_CONNECTOR_KEY = 'recentConnectorId'
- if (isInjectedWidget()) {
- // Recent connector takes priority, and we have to override it in the widget
- storage.setItem(RECENT_CONNECTOR_KEY, COW_WIDGET_CONNECTOR_ID)
-
- // Prevent the CoW Widget connector from appearing in the wallet modal.
- // It must remain registered with wagmi (for reconnect/connect to work) but should not be
- // shown as an option — users connect via the parent dapp's wallet, not by picking a wallet manually.
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const _addWagmiConnector = (wagmiAdapter as any).addWagmiConnector.bind(wagmiAdapter)
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- ;(wagmiAdapter as any).addWagmiConnector = async (connector: { id: string }) => {
- if (connector.id === COW_WIDGET_CONNECTOR_ID) return
- return _addWagmiConnector(connector)
- }
- }
-
- const urlChainId = getCurrentChainIdFromUrl()
- const defaultEvmChainId: EvmChains = isEvmChain(urlChainId) ? urlChainId : EvmChains.MAINNET
+ connectors: getConnectors(),
+ customRpcUrls,
+ networks: SUPPORTED_REOWN_NETWORKS,
+ projectId,
+ storage: wagmiStorage,
+ transports: wagmiTransports,
+})
+
+// Prevent the CoW Widget connector from appearing in the wallet modal.
+// It must remain registered with wagmi (for reconnect/connect to work) but should not be
+// shown as an option — users connect via the parent dapp's wallet, not by picking a wallet manually.
+
+// const _addWagmiConnector = (wagmiAdapter as any).addWagmiConnector.bind(wagmiAdapter)
+// // eslint-disable-next-line @typescript-eslint/no-explicit-any
+// ;(wagmiAdapter as any).addWagmiConnector = async (connector: { id: string }) => {
+// if (connector.id === COW_WIDGET_CONNECTOR_ID) return
+// return _addWagmiConnector(connector)
+// }
+
+const urlChainId = getCurrentChainIdFromUrl()
+const defaultEvmChainId: EvmChains = isEvmChain(urlChainId) ? urlChainId : EvmChains.MAINNET
+
+const reownAppKit = createAppKit({
+ adapters: [wagmiAdapter],
+ allowUnsupportedChain: true,
+ customRpcUrls,
+ defaultNetwork: VIEM_CHAINS[defaultEvmChainId],
+ enableEIP6963: true,
+ enableReconnect: true,
+ enableWalletGuide: false,
+ featuredWalletIds: [
+ // Coinbase Wallet
+ 'fd20dc426fb37566d803205b19bbc1d4096b248ac04548e3cfb6b3a38bd033aa',
+ // imToken — shown prominently so users inside imToken's browser can find the WalletConnect path
+ 'ef333840daf915aafdc4a004525502d6d49d77bd9c65e0642dbaefb3c2893bef',
+ ],
+ features: {
+ swaps: false,
+ onramp: false,
+ receive: false,
+ send: false,
+ analytics: false,
+ email: false,
+ socials: false,
+ connectorTypeOrder: ['recent', 'injected', 'walletConnect'],
+ },
+ metadata,
+ networks: SUPPORTED_REOWN_NETWORKS,
+ projectId,
+ termsConditionsUrl:
+ 'https://cow.fi/legal/cowswap-terms?utm_source=swap.cow.fi&utm_medium=web&utm_content=wallet-modal-terms-link',
+})
- reownAppKit = createAppKit({
- adapters: [wagmiAdapter],
- allowUnsupportedChain: true,
- customRpcUrls,
- defaultNetwork: VIEM_CHAINS[defaultEvmChainId],
- // Disable EIP-6963 inside imToken's browser: AppKit's EIP-6963 path calls eth_requestAccounts
- // through too many async layers, losing the iOS WebKit gesture context — the call hangs forever.
- // imToken is instead featured as a WalletConnect option (featuredWalletIds) so it appears on
- // the first modal screen, and the WalletConnect path works correctly inside imToken's browser.
- enableEIP6963: !isImTokenBrowser,
- enableReconnect: !isInjectedWidget(),
- enableWalletGuide: false,
- featuredWalletIds: [
- 'fd20dc426fb37566d803205b19bbc1d4096b248ac04548e3cfb6b3a38bd033aa',
- // imToken — shown prominently so users inside imToken's browser can find the WalletConnect path
- 'ef333840daf915aafdc4a004525502d6d49d77bd9c65e0642dbaefb3c2893bef',
- ],
- features: {
- analytics: false,
- email: false,
- socials: false,
- connectorTypeOrder: ['injected', 'recent', 'walletConnect'],
- },
- metadata,
- networks: SUPPORTED_REOWN_NETWORKS,
- projectId,
- termsConditionsUrl:
- 'https://cow.fi/legal/cowswap-terms?utm_source=swap.cow.fi&utm_medium=web&utm_content=wallet-modal-terms-link',
- })
+/**
+ * Instantly connect to Safe if in Safe
+ */
+if (getIsSafeAppIframe()) {
+ connectWalletById(SAFE_CONNECTOR_ID, 'safe')
}
-// Wagmi's `` (wrapped by WagmiProvider) calls `hydrate.onMount` synchronously
-// on every render. With `reconnectOnMount={false}` (used so SafeConnectionHandler can
-// take over without racing against an auto-reconnect), onMount runs:
-// config.setState((x) => ({ ...x, connections: new Map() }))
-// which clears `connections` but leaves `status` and `current` alone. Once our manual
-// reconnect has set `status: 'connected'`, the next re-render of WagmiProvider wipes
-// the connections map and the store ends up with status='connected' but empty
-// connections — `getConnection()` then returns `{ status: 'connected', connector: undefined }`,
-// and @reown/appkit-adapter-wagmi's watchAccount crashes reading `accountData.connector.id`.
-//
-// Enforce the invariant ourselves by wrapping `config.setState`: if the next state has
-// empty connections, force status='disconnected' and current=null in the same write so
-// the inconsistent state is never observed by any subscriber.
-const _wagmiSetState = config.setState.bind(config)
-config.setState = ((value: Parameters[0]) => {
- return _wagmiSetState((current) => {
- const next = typeof value === 'function' ? value(current) : value
- if (next && typeof next === 'object' && 'connections' in next) {
- const { connections, status } = next as { connections?: Map; status?: string }
- if (connections && connections.size === 0 && status === 'connected') {
- return { ...next, status: 'disconnected', current: null }
- }
- }
- return next
- })
-}) as typeof config.setState
-
-// Keep activeProviderRef in sync with the active connector so the per-tab
-// accountsChanged filter in providerIsolation.ts knows which provider is current.
-if (typeof window !== 'undefined') {
- let hasEverConnected = false
- let syncVersion = 0
-
- config.subscribe(
- (state) => state.current,
- async (current) => {
- const version = ++syncVersion
-
- if (!current) {
- // Distinguish "never connected yet" (null, let events through for reconnection)
- // from "was connected, now disconnected" (PROVIDER_DISCONNECTED, block events).
- activeProviderRef.current = hasEverConnected ? PROVIDER_DISCONNECTED : null
- return
- }
- hasEverConnected = true
- const connector = config.connectors.find((c) => c.uid === current)
- if (!connector) {
- activeProviderRef.current = PROVIDER_DISCONNECTED
- return
- }
- const provider = (await connector.getProvider().catch(() => null)) as EIP1193Provider | null
-
- // Ignore stale resolution — a newer subscribe call may have fired while we awaited.
- if (version !== syncVersion) return
-
- activeProviderRef.current = provider
- },
- { emitImmediately: true },
- )
-}
+bindActiveProvider(wagmiAdapter)
-export { wagmiAdapter, reownAppKit, config }
+export { wagmiAdapter, reownAppKit, wagmiStorage }
diff --git a/libs/wallet/src/wagmi/getConnectors.ts b/libs/wallet/src/wagmi/getConnectors.ts
new file mode 100644
index 00000000000..ccbbb423a99
--- /dev/null
+++ b/libs/wallet/src/wagmi/getConnectors.ts
@@ -0,0 +1,33 @@
+import { isInjectedWidget } from '@cowprotocol/common-utils'
+import { WidgetEthereumProvider } from '@cowprotocol/iframe-transport'
+
+import { EIP1193Provider } from 'viem'
+import { injected, safe } from 'wagmi/connectors'
+
+import { COW_WIDGET_CONNECTOR_ID } from '../reown/consts'
+import { getIsSafeAppIframe } from '../utils/getIsSafeAppIframe'
+
+import type { CreateConnectorFn } from 'wagmi'
+
+export function getConnectors(): CreateConnectorFn[] | undefined {
+ const connectors: CreateConnectorFn[] = []
+
+ if (getIsSafeAppIframe()) {
+ connectors.push(safe({ unstable_getInfoTimeout: 1000 }))
+ }
+
+ if (isInjectedWidget()) {
+ connectors.push(
+ injected({
+ target: {
+ name: 'CoW Widget',
+ id: COW_WIDGET_CONNECTOR_ID,
+ provider: new WidgetEthereumProvider() as EIP1193Provider,
+ },
+ shimDisconnect: false,
+ }),
+ )
+ }
+
+ return connectors.length === 0 ? undefined : connectors
+}
diff --git a/libs/wallet/src/wagmi/hooks/useDisconnectWallet.ts b/libs/wallet/src/wagmi/hooks/useDisconnectWallet.ts
index 7b38defb757..d9512b7b549 100644
--- a/libs/wallet/src/wagmi/hooks/useDisconnectWallet.ts
+++ b/libs/wallet/src/wagmi/hooks/useDisconnectWallet.ts
@@ -2,21 +2,12 @@ import { useCallback } from 'react'
import { Command } from '@cowprotocol/types'
-import { useDisconnect } from 'wagmi'
-
-import { USER_DISCONNECTED_SESSION_KEY } from '../../constants'
+import { reownAppKit } from '../config'
export function useDisconnectWallet(onDisconnect?: Command): () => Promise {
- const { mutateAsync: wagmiDisconnect } = useDisconnect()
-
return useCallback(async () => {
- await wagmiDisconnect()
-
- // Prevent InjectedBrowserAutoConnect from reopening the wallet (e.g. Rabby) right after disconnect
- if (typeof sessionStorage !== 'undefined') {
- sessionStorage.setItem(USER_DISCONNECTED_SESSION_KEY, '1')
- }
+ await reownAppKit.disconnect()
onDisconnect?.()
- }, [wagmiDisconnect, onDisconnect])
+ }, [onDisconnect])
}
diff --git a/libs/wallet/src/wagmi/hooks/useIsRestoringConnection.ts b/libs/wallet/src/wagmi/hooks/useIsRestoringConnection.ts
index e4da345c6d6..5c040cbdde9 100644
--- a/libs/wallet/src/wagmi/hooks/useIsRestoringConnection.ts
+++ b/libs/wallet/src/wagmi/hooks/useIsRestoringConnection.ts
@@ -1,31 +1,51 @@
-import { useSyncExternalStore } from 'react'
+import { useAtomValue } from 'jotai'
+import { useEffect, useState } from 'react'
+import { useAppKitState } from '@reown/appkit/react'
+import ms from 'ms.macro'
import { useConnection } from 'wagmi'
import { useWalletInfo } from '../../api/hooks'
-import { getInitialReconnectLifecycle, subscribeInitialReconnect } from '../initialReconnectLifecycle'
+import { appWalletContextAtom } from '../../state/appWalletContext.atom'
-function getServerSnapshot(): 'pending' | 'settled' {
- return 'settled'
-}
+const RESTORING_CONNECTION_TIMEOUT = ms`1s`
export function useIsRestoringConnection(): boolean {
+ const isRestoring = useIsRestoringConnectionRaw()
+
+ // Don't allow the restoring state to last longer than the timeout,
+ // because wallets can not answer on connection request
+ // and the UI can can get stuck waiting for a connection that never resolves.
+ const [timedOut, setTimedOut] = useState(false)
+
+ useEffect(() => {
+ if (!isRestoring) {
+ setTimedOut(false)
+ return
+ }
+
+ const timer = setTimeout(() => setTimedOut(true), RESTORING_CONNECTION_TIMEOUT)
+
+ return () => clearTimeout(timer)
+ }, [isRestoring])
+
+ return isRestoring && !timedOut
+}
+
+function useIsRestoringConnectionRaw(): boolean {
+ const appWalletContext = useAtomValue(appWalletContextAtom)
const { status } = useConnection()
const { account } = useWalletInfo()
+ const state = useAppKitState()
+ const { loading, initialized } = state
+
+ const isWidgetDappMode = appWalletContext?.standaloneMode === false
- // 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
+ // In widget with Dapp Mode we use the widget connected which doesn't answer on accounts request
+ // So we don't consider it's as reconnecting at all, because otherwise it will be stuck in that state
+ if (isWidgetDappMode) return false
+ if (loading || !initialized) return true
if (status === 'reconnecting') return true
- if (status === 'connected' && !account) return true
- return false
+ return status === 'connected' && !account
}
diff --git a/libs/wallet/src/wagmi/initialReconnectLifecycle.ts b/libs/wallet/src/wagmi/initialReconnectLifecycle.ts
deleted file mode 100644
index 6904be1bf8c..00000000000
--- a/libs/wallet/src/wagmi/initialReconnectLifecycle.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-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
-}
diff --git a/libs/wallet/src/wagmiStorage.ts b/libs/wallet/src/wagmiStorage.ts
new file mode 100644
index 00000000000..c592558e252
--- /dev/null
+++ b/libs/wallet/src/wagmiStorage.ts
@@ -0,0 +1,23 @@
+import { isInjectedWidget } from '@cowprotocol/common-utils'
+
+import { createStorage } from 'wagmi'
+
+import { COW_WIDGET_CONNECTOR_ID } from './reown/consts'
+import { getIsSafeAppIframe } from './utils/getIsSafeAppIframe'
+
+const safeSuffix = getIsSafeAppIframe() ? '_safe-app' : ''
+/**
+ * Variants:
+ * - swap.cow.fi, not iframe: cowswap-wallet
+ * - swap.cow.fi, widget, not safe: cowswap-wallet-cow-widget
+ * - swap.cow.fi, widget, safe: cowswap-wallet-cow-widget_safe-app
+ * - swap.cow.fi, safe: cowswap-wallet_safe-app
+ */
+export const WAGMI_STORAGE_KEY = isInjectedWidget()
+ ? 'cowswap-wallet-' + COW_WIDGET_CONNECTOR_ID + safeSuffix
+ : 'cowswap-wallet' + safeSuffix
+
+export const wagmiStorage = createStorage({
+ key: WAGMI_STORAGE_KEY,
+ storage: localStorage,
+})
diff --git a/package.json b/package.json
index 677f501ec2a..03a4b0814d9 100644
--- a/package.json
+++ b/package.json
@@ -123,6 +123,9 @@
"lit-html": "2.8.0"
}
}
+ },
+ "patchedDependencies": {
+ "@reown/appkit-adapter-wagmi@1.8.19": "patches/@reown__appkit-adapter-wagmi@1.8.19.patch"
}
},
"dependencies": {
diff --git a/apps/cowswap-frontend/patches/@reown+appkit-adapter-wagmi+1.8.19.patch b/patches/@reown__appkit-adapter-wagmi@1.8.19.patch
similarity index 74%
rename from apps/cowswap-frontend/patches/@reown+appkit-adapter-wagmi+1.8.19.patch
rename to patches/@reown__appkit-adapter-wagmi@1.8.19.patch
index 29a67ab8526..09e044de55a 100644
--- a/apps/cowswap-frontend/patches/@reown+appkit-adapter-wagmi+1.8.19.patch
+++ b/patches/@reown__appkit-adapter-wagmi@1.8.19.patch
@@ -1,7 +1,8 @@
-diff --git a/node_modules/@reown/appkit-adapter-wagmi/dist/esm/src/client.js b/node_modules/@reown/appkit-adapter-wagmi/dist/esm/src/client.js
---- a/node_modules/@reown/appkit-adapter-wagmi/dist/esm/src/client.js
-+++ b/node_modules/@reown/appkit-adapter-wagmi/dist/esm/src/client.js
-@@ -520,13 +520,14 @@
+diff --git a/dist/esm/src/client.js b/dist/esm/src/client.js
+index 79697e342068039131b11f6f47d97702e28c9ff2..0fe9085611f5d0d446fe7803462226f6a13c64fe 100644
+--- a/dist/esm/src/client.js
++++ b/dist/esm/src/client.js
+@@ -520,13 +524,14 @@ export class WagmiAdapter extends AdapterBlueprint {
chainId,
token: params.tokens?.[caipNetwork.caipNetworkId]?.address
});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 33d8d744e9e..b7c74a37923 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -19,6 +19,11 @@ overrides:
packageExtensionsChecksum: sha256-pRepWyCfyr/CPwq7w85jzIOlvbhWnZfcj1y272Xarmg=
+patchedDependencies:
+ '@reown/appkit-adapter-wagmi@1.8.19':
+ hash: b762004636088a2b38d255e990181fd088b8ae189b70b9c34e67e69f4a5e00c6
+ path: patches/@reown__appkit-adapter-wagmi@1.8.19.patch
+
importers:
.:
@@ -654,7 +659,10 @@ importers:
version: 1.8.19(@types/react@19.1.3)(bufferutil@4.0.8)(encoding@0.1.13)(immer@10.0.2)(react@19.1.2)(typescript@5.9.3)(use-sync-external-store@1.5.0(react@19.1.2))(utf-8-validate@5.0.10)(zod@3.25.76)
'@reown/appkit-adapter-wagmi':
specifier: 1.8.19
- version: 1.8.19(49a2bfb92b0b8729060e38e5adbad5e5)
+ version: 1.8.19(patch_hash=b762004636088a2b38d255e990181fd088b8ae189b70b9c34e67e69f4a5e00c6)(49a2bfb92b0b8729060e38e5adbad5e5)
+ '@reown/appkit-controllers':
+ specifier: 1.8.19
+ version: 1.8.19(@types/react@19.1.3)(bufferutil@4.0.8)(encoding@0.1.13)(react@19.1.2)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)
'@safe-global/api-kit':
specifier: 4.0.1
version: 4.0.1(bufferutil@4.0.8)(encoding@0.1.13)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)
@@ -1258,7 +1266,7 @@ importers:
version: 1.8.19(@types/react@19.1.3)(bufferutil@4.0.8)(encoding@0.1.13)(immer@10.0.2)(react@19.1.2)(typescript@5.9.3)(use-sync-external-store@1.5.0(react@19.1.2))(utf-8-validate@5.0.10)(zod@3.25.76)
'@reown/appkit-adapter-wagmi':
specifier: 1.8.19
- version: 1.8.19(49a2bfb92b0b8729060e38e5adbad5e5)
+ version: 1.8.19(patch_hash=b762004636088a2b38d255e990181fd088b8ae189b70b9c34e67e69f4a5e00c6)(49a2bfb92b0b8729060e38e5adbad5e5)
'@reown/appkit-controllers':
specifier: 1.8.19
version: 1.8.19(@types/react@19.1.3)(bufferutil@4.0.8)(encoding@0.1.13)(react@19.1.2)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)
@@ -2074,7 +2082,13 @@ importers:
version: 1.8.19(@types/react@19.1.3)(bufferutil@4.0.8)(encoding@0.1.13)(immer@10.0.2)(react@19.1.2)(typescript@5.9.3)(use-sync-external-store@1.5.0(react@19.1.2))(utf-8-validate@5.0.10)(zod@3.25.76)
'@reown/appkit-adapter-wagmi':
specifier: 1.8.19
- version: 1.8.19(49a2bfb92b0b8729060e38e5adbad5e5)
+ version: 1.8.19(patch_hash=b762004636088a2b38d255e990181fd088b8ae189b70b9c34e67e69f4a5e00c6)(49a2bfb92b0b8729060e38e5adbad5e5)
+ '@reown/appkit-common':
+ specifier: 1.8.19
+ version: 1.8.19(bufferutil@4.0.8)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)
+ '@reown/appkit-controllers':
+ specifier: 1.8.19
+ version: 1.8.19(@types/react@19.1.3)(bufferutil@4.0.8)(encoding@0.1.13)(react@19.1.2)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)
'@safe-global/api-kit':
specifier: 4.0.1
version: 4.0.1(bufferutil@4.0.8)(encoding@0.1.13)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)
@@ -2124,6 +2138,9 @@ importers:
specifier: 3.6.9
version: 3.6.9(@coinbase/wallet-sdk@4.3.7(bufferutil@4.0.8)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76))(@safe-global/safe-apps-provider@0.18.6(bufferutil@4.0.8)(encoding@0.1.13)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76))(@safe-global/safe-apps-sdk@9.1.0(bufferutil@4.0.8)(encoding@0.1.13)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76))(@tanstack/query-core@5.90.20)(@tanstack/react-query@5.90.20(react@19.1.2))(@types/react@19.1.3)(@walletconnect/ethereum-provider@2.18.0(@types/react@19.1.3)(bufferutil@4.0.8)(encoding@0.1.13)(react@19.1.2)(utf-8-validate@5.0.10))(immer@10.0.2)(react@19.1.2)(typescript@5.9.3)(viem@2.48.8(bufferutil@4.0.8)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76))
devDependencies:
+ '@testing-library/react':
+ specifier: 16.3.0
+ version: 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.1.3(@types/react@19.1.3))(@types/react@19.1.3)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)
'@types/ms.macro':
specifier: 2.0.0
version: 2.0.0
@@ -21724,7 +21741,7 @@ snapshots:
react: 19.1.2
react-redux: 8.1.2(@types/react-dom@19.1.3(@types/react@19.1.3))(@types/react@19.1.3)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)(redux@4.2.1)
- '@reown/appkit-adapter-wagmi@1.8.19(49a2bfb92b0b8729060e38e5adbad5e5)':
+ '@reown/appkit-adapter-wagmi@1.8.19(patch_hash=b762004636088a2b38d255e990181fd088b8ae189b70b9c34e67e69f4a5e00c6)(49a2bfb92b0b8729060e38e5adbad5e5)':
dependencies:
'@reown/appkit': 1.8.19(@types/react@19.1.3)(bufferutil@4.0.8)(encoding@0.1.13)(immer@10.0.2)(react@19.1.2)(typescript@5.9.3)(use-sync-external-store@1.5.0(react@19.1.2))(utf-8-validate@5.0.10)(zod@3.25.76)
'@reown/appkit-common': 1.8.19(bufferutil@4.0.8)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)