From e363f9a560313324f637e27142b199d5e4f336a7 Mon Sep 17 00:00:00 2001 From: Alexandr Kazachenko Date: Wed, 10 Jun 2026 18:48:18 +0500 Subject: [PATCH 01/19] feat(wallet): use reown for wallets management --- apps/cowswap-frontend/package.json | 1 + .../application/containers/App/Updaters.tsx | 8 +- .../src/modules/injectedWidget/index.ts | 1 - .../updaters/WidgetStandaloneMode.updater.tsx | 38 -- .../hooks/useTradeFormValidationContext.ts | 11 +- .../pure/TradeFormButtons/tradeButtonsMap.tsx | 3 + .../services/validateTradeForm.ts | 5 +- .../src/modules/tradeFormValidation/types.ts | 2 + apps/cowswap-frontend/vite.config.mts | 20 + libs/wallet/package.json | 4 +- .../api/container/WalletProvider/index.tsx | 2 +- libs/wallet/src/constants.ts | 3 - libs/wallet/src/index.ts | 5 + libs/wallet/src/reown/consts.ts | 1 + libs/wallet/src/reown/init.ts | 6 - .../WidgetStandaloneMode.updater.test.tsx | 236 ++++++++++++ .../updaters/WidgetStandaloneMode.updater.tsx | 103 +++++ libs/wallet/src/utils/connectWalletById.ts | 10 + libs/wallet/src/utils/getIsSafeAppIframe.ts | 7 + .../src/wagmi/SafeConnectionHandler.tsx | 148 -------- libs/wallet/src/wagmi/Web3Provider.tsx | 172 +-------- libs/wallet/src/wagmi/config.ts | 359 ++++-------------- libs/wallet/src/wagmi/getConnectors.ts | 33 ++ .../src/wagmi/hooks/useDisconnectWallet.ts | 15 +- .../wagmi/hooks/useIsRestoringConnection.ts | 25 +- .../src/wagmi/initialReconnectLifecycle.ts | 23 -- .../src/wagmi/providerIsolation.test.ts | 99 ----- libs/wallet/src/wagmi/providerIsolation.ts | 215 ----------- libs/wallet/src/wagmiStorage.ts | 23 ++ pnpm-lock.yaml | 9 + 30 files changed, 566 insertions(+), 1021 deletions(-) delete mode 100644 apps/cowswap-frontend/src/modules/injectedWidget/updaters/WidgetStandaloneMode.updater.tsx delete mode 100644 libs/wallet/src/reown/init.ts create mode 100644 libs/wallet/src/updaters/WidgetStandaloneMode.updater.test.tsx create mode 100644 libs/wallet/src/updaters/WidgetStandaloneMode.updater.tsx create mode 100644 libs/wallet/src/utils/connectWalletById.ts create mode 100644 libs/wallet/src/utils/getIsSafeAppIframe.ts delete mode 100644 libs/wallet/src/wagmi/SafeConnectionHandler.tsx create mode 100644 libs/wallet/src/wagmi/getConnectors.ts delete mode 100644 libs/wallet/src/wagmi/initialReconnectLifecycle.ts delete mode 100644 libs/wallet/src/wagmi/providerIsolation.test.ts delete mode 100644 libs/wallet/src/wagmi/providerIsolation.ts create mode 100644 libs/wallet/src/wagmiStorage.ts 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/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/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/libs/wallet/package.json b/libs/wallet/package.json index 6163605efe0..4b94a9b5d5a 100644 --- a/libs/wallet/package.json +++ b/libs/wallet/package.json @@ -27,6 +27,7 @@ "@cowprotocol/cow-sdk": "9.1.2", "@reown/appkit": "1.8.19", "@reown/appkit-adapter-wagmi": "1.8.19", + "@reown/appkit-controllers": "1.8.19", "@cowprotocol/assets": "workspace:*", "@cowprotocol/common-const": "workspace:*", "@cowprotocol/common-utils": "workspace:*", @@ -62,6 +63,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/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/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/updaters/WidgetStandaloneMode.updater.test.tsx b/libs/wallet/src/updaters/WidgetStandaloneMode.updater.test.tsx new file mode 100644 index 00000000000..93e1ff2bad1 --- /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) + }) + + 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..3334c8f50e8 --- /dev/null +++ b/libs/wallet/src/updaters/WidgetStandaloneMode.updater.tsx @@ -0,0 +1,103 @@ +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 { 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 { 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) + + /** + * Once in Dapp mode, disconnect any current wallet and connect to the widget connector + */ + useEffect(() => { + if (isDappMode) { + ;(async function () { + await reownAppKit.disconnect() + connectWalletById(COW_WIDGET_CONNECTOR_ID) + })() + } + }, [isDappMode]) + + /** + * Once in standalone mode, disconnect widget configurator + */ + useEffect(() => { + if (isStandaloneMode) { + 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 + }, [isStandaloneMode]) + + /** + * In standalone mode we only allow to be connected to the widget connector + * I dapp 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 + + if ((isDappMode && !isWidgetConnector) || (isStandaloneMode && isWidgetConnector)) { + 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..f9059204ddc --- /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): 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..7c9f6c29f8e --- /dev/null +++ b/libs/wallet/src/utils/getIsSafeAppIframe.ts @@ -0,0 +1,7 @@ +import { getParentOrigin } from '@cowprotocol/iframe-transport' + +import { SAFE_APP_ORIGIN } from '../constants' + +export function getIsSafeAppIframe(): boolean { + return getParentOrigin() === SAFE_APP_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..95c10799794 100644 --- a/libs/wallet/src/wagmi/Web3Provider.tsx +++ b/libs/wallet/src/wagmi/Web3Provider.tsx @@ -1,181 +1,39 @@ 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' 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) - } -} - -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 -} - -function OpenWalletModalOnCustomEvent(): null { - useEffect(() => { - if (!reownAppKit) return - const appKit = reownAppKit - const handler = (): void => { - 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} - + {children} ) } + +function OpenWalletModalOnCustomEvent(): null { + useEffect(() => { + const handler = (): void => { + reownAppKit?.open() + } + document.addEventListener(OPEN_WALLET_MODAL_EVENT, handler) + return () => document.removeEventListener(OPEN_WALLET_MODAL_EVENT, handler) + }, []) + return null +} diff --git a/libs/wallet/src/wagmi/config.ts b/libs/wallet/src/wagmi/config.ts index d5fbae45a69..a959bc28f02 100644 --- a/libs/wallet/src/wagmi/config.ts +++ b/libs/wallet/src/wagmi/config.ts @@ -1,113 +1,18 @@ import { RPC_URLS, VIEM_CHAINS } from '@cowprotocol/common-const' -import { getCurrentChainIdFromUrl, isImTokenBrowser, isInjectedWidget } from '@cowprotocol/common-utils' +import { getCurrentChainIdFromUrl, isImTokenBrowser } 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' - -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 })] -} +import { SAFE_CONNECTOR_ID, SUPPORTED_REOWN_NETWORKS } from '../reown/consts' +import { connectWalletById } from '../utils/connectWalletById' +import { getIsSafeAppIframe } from '../utils/getIsSafeAppIframe' +import { wagmiStorage } from '../wagmiStorage' const wagmiTransports = SUPPORTED_REOWN_NETWORKS.reduce( (acc, chain) => { @@ -132,48 +37,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 +45,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 +54,64 @@ 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 - - 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', - }) -} - -// 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 + 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], + // 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: true, + enableWalletGuide: false, + featuredWalletIds: [ + // Coinbase Wallet + 'fd20dc426fb37566d803205b19bbc1d4096b248ac04548e3cfb6b3a38bd033aa', + // imToken — shown prominently so users inside imToken's browser can find the WalletConnect path + 'ef333840daf915aafdc4a004525502d6d49d77bd9c65e0642dbaefb3c2893bef', + ], + features: { + 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', +}) - activeProviderRef.current = provider - }, - { emitImmediately: true }, - ) +/** + * Instantly connect to Safe if in Safe + */ +if (getIsSafeAppIframe()) { + connectWalletById(SAFE_CONNECTOR_ID) } -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..d92733e5281 100644 --- a/libs/wallet/src/wagmi/hooks/useIsRestoringConnection.ts +++ b/libs/wallet/src/wagmi/hooks/useIsRestoringConnection.ts @@ -1,31 +1,16 @@ -import { useSyncExternalStore } from 'react' - +import { useAppKitState } from '@reown/appkit/react' import { useConnection } from 'wagmi' import { useWalletInfo } from '../../api/hooks' -import { getInitialReconnectLifecycle, subscribeInitialReconnect } from '../initialReconnectLifecycle' - -function getServerSnapshot(): 'pending' | 'settled' { - return 'settled' -} export function useIsRestoringConnection(): boolean { const { status } = useConnection() const { account } = useWalletInfo() + const state = useAppKitState() + const { loading, initialized } = state - // 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 (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/wagmi/providerIsolation.test.ts b/libs/wallet/src/wagmi/providerIsolation.test.ts deleted file mode 100644 index 5830b3160e0..00000000000 --- a/libs/wallet/src/wagmi/providerIsolation.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import type { EIP1193Provider } from 'viem' - -type ProviderIsolationModule = typeof import('./providerIsolation') - -type IsolationTestWindow = Window & - typeof globalThis & { - __cowEip6963InterceptRegistered?: boolean - __cowEip6963ReDispatched?: WeakSet - __cowEip6963DeferredBraveWallet?: unknown[] - __cowEip6963AnnounceProviderListener?: EventListener - } - -const provider: EIP1193Provider = { - request: jest.fn(), - on: jest.fn(), - removeListener: jest.fn(), -} - -function resetWindowState(): void { - const win = window as IsolationTestWindow - if (win.__cowEip6963AnnounceProviderListener) { - window.removeEventListener('eip6963:announceProvider', win.__cowEip6963AnnounceProviderListener, { - capture: true, - }) - } - delete win.__cowEip6963InterceptRegistered - delete win.__cowEip6963ReDispatched - delete win.__cowEip6963DeferredBraveWallet - delete win.__cowEip6963AnnounceProviderListener -} - -async function loadProviderIsolation(): Promise { - jest.resetModules() - return import('./providerIsolation') -} - -describe('interceptEIP6963Providers', () => { - beforeEach(() => { - resetWindowState() - }) - - it('does not read Brave Wallet provider until deferred announcements are flushed', async () => { - const { flushDeferredProviders, interceptEIP6963Providers } = await loadProviderIsolation() - const downstreamListener = jest.fn() - let providerReadCount = 0 - const detail: { info: { name: string; rdns: string }; provider: EIP1193Provider } = { - info: { name: 'Brave Wallet', rdns: 'com.brave.wallet' }, - get provider() { - providerReadCount += 1 - return provider - }, - } - - interceptEIP6963Providers() - window.addEventListener('eip6963:announceProvider', downstreamListener, { capture: true }) - - window.dispatchEvent( - new CustomEvent('eip6963:announceProvider', { - bubbles: true, - detail, - }), - ) - - expect(providerReadCount).toBe(0) - expect(downstreamListener).not.toHaveBeenCalled() - - flushDeferredProviders() - - expect(providerReadCount).toBe(1) - expect(downstreamListener).toHaveBeenCalledTimes(1) - }) - - it('continues to wrap non-Brave providers immediately', async () => { - const { interceptEIP6963Providers } = await loadProviderIsolation() - const downstreamListener = jest.fn() - let providerReadCount = 0 - const detail: { info: { name: string; rdns: string }; provider: EIP1193Provider } = { - info: { name: 'MetaMask', rdns: 'io.metamask' }, - get provider() { - providerReadCount += 1 - return provider - }, - } - - interceptEIP6963Providers() - window.addEventListener('eip6963:announceProvider', downstreamListener, { capture: true }) - - window.dispatchEvent( - new CustomEvent('eip6963:announceProvider', { - bubbles: true, - detail, - }), - ) - - expect(providerReadCount).toBe(1) - expect(downstreamListener).toHaveBeenCalledTimes(1) - expect((downstreamListener.mock.calls[0]?.[0] as CustomEvent).detail.provider).not.toBe(provider) - }) -}) diff --git a/libs/wallet/src/wagmi/providerIsolation.ts b/libs/wallet/src/wagmi/providerIsolation.ts deleted file mode 100644 index cd61f9312cb..00000000000 --- a/libs/wallet/src/wagmi/providerIsolation.ts +++ /dev/null @@ -1,215 +0,0 @@ -import type { EIP1193EventMap, EIP1193Provider } from 'viem' - -/** - * Sentinel value meaning "the user has explicitly disconnected (or connected then disconnected)". - * In this state, all accountsChanged events are blocked to prevent wallets from - * auto-reconnecting disconnected tabs when the user switches accounts in the extension. - * - * Distinguished from `null` which means "no connector established yet" (initial page load), - * where events must pass through for reconnection to work. - */ -export const PROVIDER_DISCONNECTED: unique symbol = Symbol('PROVIDER_DISCONNECTED') - -/** - * Tracks which isolated provider is currently active in this tab (in-memory, per-tab). - * Updated by config.ts whenever config.state.current changes. - * Used by createIsolatedProvider to filter accountsChanged events. - * - * - `null`: initial state, no connector established yet — events pass through - * - `PROVIDER_DISCONNECTED`: user disconnected — events are blocked - * - `EIP1193Provider`: active provider — only events from this provider pass through - */ -export const activeProviderRef: { current: EIP1193Provider | typeof PROVIDER_DISCONNECTED | null } = { current: null } - -type Eip6963ProviderInfo = { name?: string; rdns?: string } -type Eip6963ProviderDetail = { - info: Eip6963ProviderInfo - provider: EIP1193Provider -} -type DeferredBraveWalletAnnouncement = { - info: Eip6963ProviderInfo - event: CustomEvent -} - -// Cache isolated providers by their original so identity is stable across calls. -const cache = new WeakMap() - -/** - * Wraps an EIP-1193 provider to enforce tab-level wallet isolation: - * - * 1. Blocks `wallet_revokePermissions` — wagmi calls this on disconnect, but it revokes - * permissions for the *entire origin* (all tabs), not just the current tab. - * shimDisconnect is sufficient to prevent reconnect on next page load. - * - * 2. Filters `accountsChanged` events — only forwards them when this provider is the - * active one in this tab, preventing a wallet switch in Tab A from affecting Tab B. - */ -export function createIsolatedProvider(original: EIP1193Provider): EIP1193Provider { - const cached = cache.get(original as object) - if (cached) return cached - - // Maps original listener → wrapped listener so removeListener works correctly. - type AccountsChangedListener = EIP1193EventMap['accountsChanged'] - const listenerMap = new Map() - - const proxy: EIP1193Provider = { - request: (async (args) => { - const method = (args as { method: string }).method - if (method === 'wallet_revokePermissions') { - console.log('[providerIsolation] blocked wallet_revokePermissions') - return null - } - return original.request(args as unknown as Parameters[0]) - }) as EIP1193Provider['request'], - - on: (event: event, listener: EIP1193EventMap[event]): void => { - if (event === 'accountsChanged') { - const wrapped: AccountsChangedListener = (accounts) => { - const active = activeProviderRef.current - - // PROVIDER_DISCONNECTED: user explicitly disconnected — block all events to - // prevent wallets from auto-reconnecting when accounts change in the extension. - if (active === PROVIDER_DISCONNECTED) return - - // null: initial page load, no connector established yet — let events through - // so wagmi's reconnection can receive account updates. - // EIP1193Provider: only forward events for the active provider to enforce - // tab-level isolation (wallet switch in Tab A shouldn't affect Tab B). - if (active !== null && active !== proxy) return - ;(listener as unknown as AccountsChangedListener)(accounts) - } - listenerMap.set(listener as unknown as AccountsChangedListener, wrapped) - original.on('accountsChanged', wrapped) - } else { - original.on(event, listener as unknown as EIP1193EventMap[event]) - } - }, - - removeListener: (event: event, listener: EIP1193EventMap[event]): void => { - if (event === 'accountsChanged') { - const wrapped = listenerMap.get(listener as unknown as AccountsChangedListener) - if (wrapped) { - original.removeListener('accountsChanged', wrapped) - listenerMap.delete(listener as unknown as AccountsChangedListener) - } else { - original.removeListener('accountsChanged', listener as unknown as AccountsChangedListener) - } - } else { - original.removeListener(event, listener as unknown as EIP1193EventMap[event]) - } - }, - } - - cache.set(original as object, proxy) - return proxy -} - -// Guards stored on `window` so they survive HMR — module-local variables are -// reset on hot reload, but the capture listener stays attached to `window`. -// Without this, each HMR reload would add another listener and the two instances -// could re-dispatch events back and forth. -type IsolationWindow = Window & { - __cowEip6963InterceptRegistered?: boolean - __cowEip6963ReDispatched?: WeakSet - __cowEip6963DeferredBraveWallet?: DeferredBraveWalletAnnouncement[] - __cowEip6963AnnounceProviderListener?: EventListener -} - -function getReDispatched(): WeakSet { - const win = window as IsolationWindow - if (!win.__cowEip6963ReDispatched) { - win.__cowEip6963ReDispatched = new WeakSet() - } - return win.__cowEip6963ReDispatched -} - -function getDeferredBraveWalletAnnouncements(): DeferredBraveWalletAnnouncement[] { - const win = window as IsolationWindow - if (!win.__cowEip6963DeferredBraveWallet) { - win.__cowEip6963DeferredBraveWallet = [] - } - return win.__cowEip6963DeferredBraveWallet -} - -function getProviderIdentifier(info: Eip6963ProviderInfo): string { - return info.rdns ?? info.name ?? 'unknown' -} - -function isBraveWalletInfo(info: Eip6963ProviderInfo): boolean { - return info.rdns === 'com.brave.wallet' || info.name === 'Brave Wallet' -} - -function createIsolatedProviderAnnouncement(detail: Eip6963ProviderDetail): CustomEvent { - const newEvent = new CustomEvent('eip6963:announceProvider', { - detail: { info: detail.info, provider: createIsolatedProvider(detail.provider) }, - }) - getReDispatched().add(newEvent) - - return newEvent -} - -function deferBraveWalletAnnouncement(info: Eip6963ProviderInfo, event: CustomEvent): void { - const deferred = getDeferredBraveWalletAnnouncements() - const identifier = getProviderIdentifier(info) - const replacementIndex = deferred.findIndex((announcement) => getProviderIdentifier(announcement.info) === identifier) - const announcement = { info, event } - - if (replacementIndex >= 0) { - deferred[replacementIndex] = announcement - } else { - deferred.push(announcement) - } -} - -/** - * Dispatches Brave Wallet EIP-6963 announcements that were hidden during page load. - * Call this only from an explicit wallet-selection path; materializing the Brave - * provider at startup can crash Brave's renderer process. - */ -export function flushDeferredProviders(): void { - if (typeof window === 'undefined') return - - const deferred = getDeferredBraveWalletAnnouncements() - if (deferred.length === 0) return - - for (const announcement of deferred.splice(0)) { - const event = createIsolatedProviderAnnouncement({ - info: announcement.info, - provider: announcement.event.detail.provider, - }) - window.dispatchEvent(event) - } -} - -/** - * Registers a capture-phase listener for EIP-6963 provider announcements so that - * every wallet provider is wrapped with createIsolatedProvider *before* wagmi/AppKit - * processes it and creates connectors. - * - * Must be called before WagmiAdapter / createConfig is instantiated. - * Safe to call multiple times — only registers the listener once (survives HMR). - */ -export function interceptEIP6963Providers(): void { - if (typeof window === 'undefined') return - const win = window as IsolationWindow - if (win.__cowEip6963InterceptRegistered) return - win.__cowEip6963InterceptRegistered = true - const announceProviderListener = ((event: Event): void => { - const reDispatched = getReDispatched() - if (reDispatched.has(event)) return - event.stopImmediatePropagation() - - const customEvent = event as CustomEvent - const detail = customEvent.detail - - if (isBraveWalletInfo(detail.info)) { - deferBraveWalletAnnouncement(detail.info, customEvent) - return - } - - const newEvent = createIsolatedProviderAnnouncement(detail) - window.dispatchEvent(newEvent) - }) satisfies EventListener - win.__cowEip6963AnnounceProviderListener = announceProviderListener - window.addEventListener('eip6963:announceProvider', announceProviderListener, { capture: true }) -} 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/pnpm-lock.yaml b/pnpm-lock.yaml index 33d8d744e9e..07d67074a22 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -655,6 +655,9 @@ importers: '@reown/appkit-adapter-wagmi': specifier: 1.8.19 version: 1.8.19(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) @@ -2075,6 +2078,9 @@ importers: '@reown/appkit-adapter-wagmi': specifier: 1.8.19 version: 1.8.19(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) @@ -2124,6 +2130,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 From 15d746fec4bd2299a64ea08c0230dc222f896b2f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 10 Jun 2026 13:50:38 +0000 Subject: [PATCH 02/19] chore(i18n): extract i18n strings [automatic] --- apps/cowswap-frontend/src/locales/en-US.po | 4 ++++ 1 file changed, 4 insertions(+) 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" From 34bf385f8283d473ca6f238cce3852a29075a78a Mon Sep 17 00:00:00 2001 From: Alexandr Kazachenko Date: Wed, 10 Jun 2026 19:32:27 +0500 Subject: [PATCH 03/19] chore: fix comment --- libs/wallet/src/updaters/WidgetStandaloneMode.updater.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/wallet/src/updaters/WidgetStandaloneMode.updater.tsx b/libs/wallet/src/updaters/WidgetStandaloneMode.updater.tsx index 3334c8f50e8..658c507f779 100644 --- a/libs/wallet/src/updaters/WidgetStandaloneMode.updater.tsx +++ b/libs/wallet/src/updaters/WidgetStandaloneMode.updater.tsx @@ -80,8 +80,8 @@ export function WidgetStandaloneModeUpdater({ standaloneMode }: WidgetStandalone }, [isStandaloneMode]) /** - * In standalone mode we only allow to be connected to the widget connector - * I dapp mode never connect to widget connector + * 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 From bbf1865b6036dac80e8d60f3daf8985d27b142a8 Mon Sep 17 00:00:00 2001 From: Alexandr Kazachenko Date: Thu, 11 Jun 2026 13:01:44 +0500 Subject: [PATCH 04/19] fix: add connection type --- libs/wallet/src/updaters/WidgetStandaloneMode.updater.tsx | 2 +- libs/wallet/src/utils/connectWalletById.ts | 4 ++-- libs/wallet/src/wagmi/config.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/libs/wallet/src/updaters/WidgetStandaloneMode.updater.tsx b/libs/wallet/src/updaters/WidgetStandaloneMode.updater.tsx index 658c507f779..105b90bda9a 100644 --- a/libs/wallet/src/updaters/WidgetStandaloneMode.updater.tsx +++ b/libs/wallet/src/updaters/WidgetStandaloneMode.updater.tsx @@ -52,7 +52,7 @@ export function WidgetStandaloneModeUpdater({ standaloneMode }: WidgetStandalone if (isDappMode) { ;(async function () { await reownAppKit.disconnect() - connectWalletById(COW_WIDGET_CONNECTOR_ID) + connectWalletById(COW_WIDGET_CONNECTOR_ID, 'injected') })() } }, [isDappMode]) diff --git a/libs/wallet/src/utils/connectWalletById.ts b/libs/wallet/src/utils/connectWalletById.ts index f9059204ddc..2e186fb542a 100644 --- a/libs/wallet/src/utils/connectWalletById.ts +++ b/libs/wallet/src/utils/connectWalletById.ts @@ -4,7 +4,7 @@ import { wagmiAdapter } from '../wagmi/config' import type { AdapterBlueprint } from '@reown/appkit-controllers' -export function connectWalletById(id: string): Promise { +export function connectWalletById(id: string, type: string): Promise { StorageUtil.removeDisconnectedConnectorId(id, 'eip155') - return wagmiAdapter.connect({ id, type: '' }) + return wagmiAdapter.connect({ id, type }) } diff --git a/libs/wallet/src/wagmi/config.ts b/libs/wallet/src/wagmi/config.ts index a959bc28f02..559eb910321 100644 --- a/libs/wallet/src/wagmi/config.ts +++ b/libs/wallet/src/wagmi/config.ts @@ -111,7 +111,7 @@ const reownAppKit = createAppKit({ * Instantly connect to Safe if in Safe */ if (getIsSafeAppIframe()) { - connectWalletById(SAFE_CONNECTOR_ID) + connectWalletById(SAFE_CONNECTOR_ID, 'safe') } export { wagmiAdapter, reownAppKit, wagmiStorage } From d1282d5c2504bae9de4deaffe438425926cc86cd Mon Sep 17 00:00:00 2001 From: Alexandr Kazachenko Date: Thu, 11 Jun 2026 13:35:06 +0500 Subject: [PATCH 05/19] fix: fix trade reset on token selection --- .../modules/trade/hooks/setupTradeState/useSetupTradeState.ts | 3 +++ 1 file changed, 3 insertions(+) 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 From ac31ae6498ace934d932ac03d5e706b76cefa081 Mon Sep 17 00:00:00 2001 From: Alexandr Kazachenko Date: Thu, 11 Jun 2026 14:26:29 +0500 Subject: [PATCH 06/19] fix(widget): fix reconnecting state in dapp mode --- libs/wallet/src/state/appWalletContext.atom.ts | 7 +++++++ .../wallet/src/updaters/WidgetStandaloneMode.updater.tsx | 7 +++++++ libs/wallet/src/wagmi/hooks/useIsRestoringConnection.ts | 9 +++++++++ 3 files changed, 23 insertions(+) create mode 100644 libs/wallet/src/state/appWalletContext.atom.ts 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.tsx b/libs/wallet/src/updaters/WidgetStandaloneMode.updater.tsx index 105b90bda9a..aae4441027c 100644 --- a/libs/wallet/src/updaters/WidgetStandaloneMode.updater.tsx +++ b/libs/wallet/src/updaters/WidgetStandaloneMode.updater.tsx @@ -1,3 +1,4 @@ +import { useSetAtom } from 'jotai' import { useEffect, useRef } from 'react' import { isInjectedWidget } from '@cowprotocol/common-utils' @@ -6,6 +7,7 @@ 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' @@ -35,6 +37,7 @@ interface WidgetStandaloneModeUpdaterProps { * Renders nothing. */ export function WidgetStandaloneModeUpdater({ standaloneMode }: WidgetStandaloneModeUpdaterProps): null { + const setAppWalletContext = useSetAtom(appWalletContextAtom) const { connector } = useConnection() const disconnect = useDisconnectWallet() @@ -45,6 +48,10 @@ export function WidgetStandaloneModeUpdater({ standaloneMode }: WidgetStandalone 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 */ diff --git a/libs/wallet/src/wagmi/hooks/useIsRestoringConnection.ts b/libs/wallet/src/wagmi/hooks/useIsRestoringConnection.ts index d92733e5281..49fa7abaa2e 100644 --- a/libs/wallet/src/wagmi/hooks/useIsRestoringConnection.ts +++ b/libs/wallet/src/wagmi/hooks/useIsRestoringConnection.ts @@ -1,14 +1,23 @@ +import { useAtomValue } from 'jotai' + import { useAppKitState } from '@reown/appkit/react' import { useConnection } from 'wagmi' import { useWalletInfo } from '../../api/hooks' +import { appWalletContextAtom } from '../../state/appWalletContext.atom' export function useIsRestoringConnection(): boolean { + const appWalletContext = useAtomValue(appWalletContextAtom) const { status } = useConnection() const { account } = useWalletInfo() const state = useAppKitState() const { loading, initialized } = state + const isWidgetDappMode = appWalletContext?.standaloneMode === false + + // 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 From 56e485c695bdf718c70386607d95a303fdcf8cc7 Mon Sep 17 00:00:00 2001 From: Alexandr Kazachenko Date: Fri, 12 Jun 2026 17:01:34 +0500 Subject: [PATCH 07/19] chore: fix test --- libs/wallet/src/updaters/WidgetStandaloneMode.updater.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/wallet/src/updaters/WidgetStandaloneMode.updater.test.tsx b/libs/wallet/src/updaters/WidgetStandaloneMode.updater.test.tsx index 93e1ff2bad1..8c38df228fc 100644 --- a/libs/wallet/src/updaters/WidgetStandaloneMode.updater.test.tsx +++ b/libs/wallet/src/updaters/WidgetStandaloneMode.updater.test.tsx @@ -91,7 +91,7 @@ describe('WidgetStandaloneModeUpdater', () => { renderUpdater(DAPP_MODE) await waitFor(() => { - expect(connectWalletByIdMock).toHaveBeenCalledWith(COW_WIDGET_CONNECTOR_ID) + expect(connectWalletByIdMock).toHaveBeenCalledWith(COW_WIDGET_CONNECTOR_ID, 'injected') }) expect(reownAppKitDisconnectMock).toHaveBeenCalledTimes(1) From ce6efdb955f37c5e18c677e7a546c00916496eb9 Mon Sep 17 00:00:00 2001 From: Alexandr Kazachenko Date: Mon, 15 Jun 2026 14:27:11 +0500 Subject: [PATCH 08/19] fix: add timeout to restoring wallet --- .../wagmi/hooks/useIsRestoringConnection.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/libs/wallet/src/wagmi/hooks/useIsRestoringConnection.ts b/libs/wallet/src/wagmi/hooks/useIsRestoringConnection.ts index 49fa7abaa2e..5c040cbdde9 100644 --- a/libs/wallet/src/wagmi/hooks/useIsRestoringConnection.ts +++ b/libs/wallet/src/wagmi/hooks/useIsRestoringConnection.ts @@ -1,12 +1,38 @@ 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 { appWalletContextAtom } from '../../state/appWalletContext.atom' +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() From 1c6f760f55c6aa5512d79711e3a29b1f44e08b24 Mon Sep 17 00:00:00 2001 From: Alexandr Kazachenko Date: Mon, 15 Jun 2026 16:33:23 +0500 Subject: [PATCH 09/19] chore: extend getIsSafeAppIframe --- libs/wallet/src/utils/getIsSafeAppIframe.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/libs/wallet/src/utils/getIsSafeAppIframe.ts b/libs/wallet/src/utils/getIsSafeAppIframe.ts index 7c9f6c29f8e..cbddfacdc6a 100644 --- a/libs/wallet/src/utils/getIsSafeAppIframe.ts +++ b/libs/wallet/src/utils/getIsSafeAppIframe.ts @@ -2,6 +2,13 @@ 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 { - return getParentOrigin() === SAFE_APP_ORIGIN + const origin = getParentOrigin() + + if (!origin) return false + return SAFE_SUPPORTED_ORIGINS.includes(origin) } From c4b2208e3e3a2eb7aae57adf1ba3e4bbae73e978 Mon Sep 17 00:00:00 2001 From: Alexandr Kazachenko Date: Mon, 15 Jun 2026 17:36:52 +0500 Subject: [PATCH 10/19] chore: fix WidgetStandaloneModeUpdater --- .../updaters/WidgetStandaloneMode.updater.tsx | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/libs/wallet/src/updaters/WidgetStandaloneMode.updater.tsx b/libs/wallet/src/updaters/WidgetStandaloneMode.updater.tsx index aae4441027c..f23a742538c 100644 --- a/libs/wallet/src/updaters/WidgetStandaloneMode.updater.tsx +++ b/libs/wallet/src/updaters/WidgetStandaloneMode.updater.tsx @@ -56,19 +56,27 @@ export function WidgetStandaloneModeUpdater({ standaloneMode }: WidgetStandalone * 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]) + }, [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 @@ -84,7 +92,7 @@ export function WidgetStandaloneModeUpdater({ standaloneMode }: WidgetStandalone } return undefined - }, [isStandaloneMode]) + }, [isSafeApp, isStandaloneMode]) /** * In dapp mode we only allow to be connected to the widget connector @@ -96,8 +104,12 @@ export function WidgetStandaloneModeUpdater({ standaloneMode }: WidgetStandalone // 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 }) - if ((isDappMode && !isWidgetConnector) || (isStandaloneMode && isWidgetConnector)) { isDisconnectInProgress.current = true disconnect().finally(() => { From 786eb527643548a564f0c0dc74722ed5c6df03bd Mon Sep 17 00:00:00 2001 From: Alexandr Kazachenko Date: Mon, 15 Jun 2026 21:14:28 +0500 Subject: [PATCH 11/19] chore: mock --- .../src/app/configurator/hooks/useWidgetParamsAndSettings.ts | 3 +++ 1 file changed, 3 insertions(+) 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}` } From f8b0fee466d5b38dbe6f198fc8629677e0bac7c5 Mon Sep 17 00:00:00 2001 From: Alexandr Kazachenko Date: Mon, 15 Jun 2026 21:22:49 +0500 Subject: [PATCH 12/19] fix: isolate reown localStorage --- libs/wallet/package.json | 1 + .../wallet/src/reown/patchSafeLocalStorage.ts | 33 +++++++++++++++++++ libs/wallet/src/wagmi/config.ts | 3 ++ pnpm-lock.yaml | 3 ++ 4 files changed, 40 insertions(+) create mode 100644 libs/wallet/src/reown/patchSafeLocalStorage.ts diff --git a/libs/wallet/package.json b/libs/wallet/package.json index 4b94a9b5d5a..d542673edf9 100644 --- a/libs/wallet/package.json +++ b/libs/wallet/package.json @@ -28,6 +28,7 @@ "@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:*", diff --git a/libs/wallet/src/reown/patchSafeLocalStorage.ts b/libs/wallet/src/reown/patchSafeLocalStorage.ts new file mode 100644 index 00000000000..33113d78917 --- /dev/null +++ b/libs/wallet/src/reown/patchSafeLocalStorage.ts @@ -0,0 +1,33 @@ +import { isSafe, SafeLocalStorage, SafeLocalStorageKey } from '@reown/appkit-common' + +import { WAGMI_STORAGE_KEY } from '../wagmiStorage' + +/** + * Isolates localStorage of Reown depending on environment + */ +export function patchSafeLocalStorage(): void { + Object.assign(SafeLocalStorage, { + setItem(key: SafeLocalStorageKey, value?: string): void { + if (isSafe() && value !== undefined) { + localStorage.setItem(WAGMI_STORAGE_KEY + key, value) + } + }, + getItem(key: SafeLocalStorageKey): string | undefined { + if (isSafe()) { + return localStorage.getItem(WAGMI_STORAGE_KEY + key) || undefined + } + + return undefined + }, + removeItem(key: SafeLocalStorageKey): void { + if (isSafe()) { + localStorage.removeItem(WAGMI_STORAGE_KEY + key) + } + }, + clear(): void { + if (isSafe()) { + localStorage.clear() + } + }, + }) +} diff --git a/libs/wallet/src/wagmi/config.ts b/libs/wallet/src/wagmi/config.ts index 559eb910321..456e2794c5b 100644 --- a/libs/wallet/src/wagmi/config.ts +++ b/libs/wallet/src/wagmi/config.ts @@ -10,10 +10,13 @@ import { type Transport } from 'wagmi' import { getConnectors } from './getConnectors' import { SAFE_CONNECTOR_ID, SUPPORTED_REOWN_NETWORKS } from '../reown/consts' +import { patchSafeLocalStorage } from '../reown/patchSafeLocalStorage' import { connectWalletById } from '../utils/connectWalletById' import { getIsSafeAppIframe } from '../utils/getIsSafeAppIframe' import { wagmiStorage } from '../wagmiStorage' +patchSafeLocalStorage() + const wagmiTransports = SUPPORTED_REOWN_NETWORKS.reduce( (acc, chain) => { const chainId = chain.id as EvmChains diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 07d67074a22..07c2e84ab90 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2078,6 +2078,9 @@ importers: '@reown/appkit-adapter-wagmi': specifier: 1.8.19 version: 1.8.19(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) From ca4a932c19fe51f3f98464e1b34feeefb19e27f2 Mon Sep 17 00:00:00 2001 From: Alexandr Kazachenko Date: Mon, 15 Jun 2026 21:47:16 +0500 Subject: [PATCH 13/19] chore: fix reown features --- .../wallet/src/reown/patchSafeLocalStorage.ts | 33 ------------------- libs/wallet/src/wagmi/config.ts | 7 ++-- 2 files changed, 4 insertions(+), 36 deletions(-) delete mode 100644 libs/wallet/src/reown/patchSafeLocalStorage.ts diff --git a/libs/wallet/src/reown/patchSafeLocalStorage.ts b/libs/wallet/src/reown/patchSafeLocalStorage.ts deleted file mode 100644 index 33113d78917..00000000000 --- a/libs/wallet/src/reown/patchSafeLocalStorage.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { isSafe, SafeLocalStorage, SafeLocalStorageKey } from '@reown/appkit-common' - -import { WAGMI_STORAGE_KEY } from '../wagmiStorage' - -/** - * Isolates localStorage of Reown depending on environment - */ -export function patchSafeLocalStorage(): void { - Object.assign(SafeLocalStorage, { - setItem(key: SafeLocalStorageKey, value?: string): void { - if (isSafe() && value !== undefined) { - localStorage.setItem(WAGMI_STORAGE_KEY + key, value) - } - }, - getItem(key: SafeLocalStorageKey): string | undefined { - if (isSafe()) { - return localStorage.getItem(WAGMI_STORAGE_KEY + key) || undefined - } - - return undefined - }, - removeItem(key: SafeLocalStorageKey): void { - if (isSafe()) { - localStorage.removeItem(WAGMI_STORAGE_KEY + key) - } - }, - clear(): void { - if (isSafe()) { - localStorage.clear() - } - }, - }) -} diff --git a/libs/wallet/src/wagmi/config.ts b/libs/wallet/src/wagmi/config.ts index 456e2794c5b..15abf577a5e 100644 --- a/libs/wallet/src/wagmi/config.ts +++ b/libs/wallet/src/wagmi/config.ts @@ -10,13 +10,10 @@ import { type Transport } from 'wagmi' import { getConnectors } from './getConnectors' import { SAFE_CONNECTOR_ID, SUPPORTED_REOWN_NETWORKS } from '../reown/consts' -import { patchSafeLocalStorage } from '../reown/patchSafeLocalStorage' import { connectWalletById } from '../utils/connectWalletById' import { getIsSafeAppIframe } from '../utils/getIsSafeAppIframe' import { wagmiStorage } from '../wagmiStorage' -patchSafeLocalStorage() - const wagmiTransports = SUPPORTED_REOWN_NETWORKS.reduce( (acc, chain) => { const chainId = chain.id as EvmChains @@ -98,6 +95,10 @@ const reownAppKit = createAppKit({ 'ef333840daf915aafdc4a004525502d6d49d77bd9c65e0642dbaefb3c2893bef', ], features: { + swaps: false, + onramp: false, + receive: false, + send: false, analytics: false, email: false, socials: false, From d46928b33c8db62cb8d7f7a0917190e6441845ce Mon Sep 17 00:00:00 2001 From: Alexandr Kazachenko Date: Mon, 15 Jun 2026 21:50:35 +0500 Subject: [PATCH 14/19] chore: fix open view --- libs/wallet/src/wagmi/Web3Provider.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/wallet/src/wagmi/Web3Provider.tsx b/libs/wallet/src/wagmi/Web3Provider.tsx index 95c10799794..729e220c719 100644 --- a/libs/wallet/src/wagmi/Web3Provider.tsx +++ b/libs/wallet/src/wagmi/Web3Provider.tsx @@ -30,7 +30,7 @@ export function Web3Provider({ children }: Web3ProviderProps): ReactNode { function OpenWalletModalOnCustomEvent(): null { useEffect(() => { const handler = (): void => { - reownAppKit?.open() + reownAppKit?.open({ view: 'Connect' }) } document.addEventListener(OPEN_WALLET_MODAL_EVENT, handler) return () => document.removeEventListener(OPEN_WALLET_MODAL_EVENT, handler) From 6f544f393165f52238b04681743847bdeace56e6 Mon Sep 17 00:00:00 2001 From: Alexandr Kazachenko Date: Mon, 15 Jun 2026 23:45:12 +0500 Subject: [PATCH 15/19] chore: fix imtoken wallet --- libs/wallet/src/wagmi/config.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/libs/wallet/src/wagmi/config.ts b/libs/wallet/src/wagmi/config.ts index 15abf577a5e..0e9ef479e74 100644 --- a/libs/wallet/src/wagmi/config.ts +++ b/libs/wallet/src/wagmi/config.ts @@ -1,5 +1,5 @@ import { RPC_URLS, VIEM_CHAINS } from '@cowprotocol/common-const' -import { getCurrentChainIdFromUrl, isImTokenBrowser } from '@cowprotocol/common-utils' +import { getCurrentChainIdFromUrl } from '@cowprotocol/common-utils' import { EvmChains, isEvmChain } from '@cowprotocol/cow-sdk' import { createAppKit } from '@reown/appkit/react' @@ -81,11 +81,7 @@ const reownAppKit = createAppKit({ 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, + enableEIP6963: true, enableReconnect: true, enableWalletGuide: false, featuredWalletIds: [ From ee8f3b77ed1e25ccb274646717c8a6b29f314aa2 Mon Sep 17 00:00:00 2001 From: Alexandr Kazachenko Date: Tue, 16 Jun 2026 00:01:25 +0500 Subject: [PATCH 16/19] fix: do not override current connector --- .../@reown+appkit-adapter-wagmi+1.8.19.patch | 20 --------- package.json | 3 ++ .../@reown__appkit-adapter-wagmi@1.8.19.patch | 41 +++++++++++++++++++ pnpm-lock.yaml | 13 ++++-- 4 files changed, 53 insertions(+), 24 deletions(-) delete mode 100644 apps/cowswap-frontend/patches/@reown+appkit-adapter-wagmi+1.8.19.patch create mode 100644 patches/@reown__appkit-adapter-wagmi@1.8.19.patch diff --git a/apps/cowswap-frontend/patches/@reown+appkit-adapter-wagmi+1.8.19.patch b/apps/cowswap-frontend/patches/@reown+appkit-adapter-wagmi+1.8.19.patch deleted file mode 100644 index 29a67ab8526..00000000000 --- a/apps/cowswap-frontend/patches/@reown+appkit-adapter-wagmi+1.8.19.patch +++ /dev/null @@ -1,20 +0,0 @@ -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 @@ - chainId, - token: params.tokens?.[caipNetwork.caipNetworkId]?.address - }); -+ const formatted = balance.formatted ?? formatUnits(balance.value, balance.decimals); - StorageUtil.updateNativeBalanceCache({ - caipAddress, -- balance: balance.formatted, -+ balance: formatted, - symbol: balance.symbol, - timestamp: Date.now() - }); -- resolve({ balance: balance.formatted, symbol: balance.symbol }); -+ resolve({ balance: formatted, symbol: balance.symbol }); - } - catch (error) { - console.warn('Appkit:WagmiAdapter:getBalance - Error getting balance', error); 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/patches/@reown__appkit-adapter-wagmi@1.8.19.patch b/patches/@reown__appkit-adapter-wagmi@1.8.19.patch new file mode 100644 index 00000000000..cfd2ab9a090 --- /dev/null +++ b/patches/@reown__appkit-adapter-wagmi@1.8.19.patch @@ -0,0 +1,41 @@ +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 +@@ -142,6 +142,11 @@ export class WagmiAdapter extends AdapterBlueprint { + }); + watchAccount(this.wagmiConfig, { + onChange: (accountData, prevAccountData) => { ++ const hasConnectorChanged = !!prevAccountData.connector && accountData.connector.id !== prevAccountData.connector.id; ++ ++ // Patch: ignore changes from not current connector ++ if (hasConnectorChanged) return ++ + if (accountData.status === 'disconnected' && prevAccountData.address) { + this.emit('disconnect'); + } +@@ -152,7 +157,6 @@ export class WagmiAdapter extends AdapterBlueprint { + } + if (accountData.status === 'connected') { + const hasAccountChanged = accountData.address !== prevAccountData?.address; +- const hasConnectorChanged = accountData.connector.id !== prevAccountData.connector?.id; + const hasConnectionStatusChanged = prevAccountData.status !== 'connected'; + if (hasAccountChanged || hasConnectorChanged || hasConnectionStatusChanged) { + this.setupWatchPendingTransactions(); +@@ -520,13 +524,14 @@ export class WagmiAdapter extends AdapterBlueprint { + chainId, + token: params.tokens?.[caipNetwork.caipNetworkId]?.address + }); ++ const formatted = balance.formatted ?? formatUnits(balance.value, balance.decimals); + StorageUtil.updateNativeBalanceCache({ + caipAddress, +- balance: balance.formatted, ++ balance: formatted, + symbol: balance.symbol, + timestamp: Date.now() + }); +- resolve({ balance: balance.formatted, symbol: balance.symbol }); ++ resolve({ balance: formatted, symbol: balance.symbol }); + } + catch (error) { + console.warn('Appkit:WagmiAdapter:getBalance - Error getting balance', error); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 07c2e84ab90..08c7d709e55 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: f78611af719579832876ca1539b26f27c6159db92511d8c5ca19a9c98e63fc7c + path: patches/@reown__appkit-adapter-wagmi@1.8.19.patch + importers: .: @@ -654,7 +659,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=f78611af719579832876ca1539b26f27c6159db92511d8c5ca19a9c98e63fc7c)(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) @@ -1261,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=f78611af719579832876ca1539b26f27c6159db92511d8c5ca19a9c98e63fc7c)(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) @@ -2077,7 +2082,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=f78611af719579832876ca1539b26f27c6159db92511d8c5ca19a9c98e63fc7c)(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) @@ -21736,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=f78611af719579832876ca1539b26f27c6159db92511d8c5ca19a9c98e63fc7c)(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) From 045da000803115dc8974cba093d9f9cc6b7a25f6 Mon Sep 17 00:00:00 2001 From: Alexandr Kazachenko Date: Tue, 16 Jun 2026 00:03:46 +0500 Subject: [PATCH 17/19] chore: update deps --- pnpm-lock.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 08c7d709e55..f0178240284 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,7 +21,7 @@ packageExtensionsChecksum: sha256-pRepWyCfyr/CPwq7w85jzIOlvbhWnZfcj1y272Xarmg= patchedDependencies: '@reown/appkit-adapter-wagmi@1.8.19': - hash: f78611af719579832876ca1539b26f27c6159db92511d8c5ca19a9c98e63fc7c + hash: 4e156cd264d801aa863bcdd15c26b42c60b79a7e951b87ab987cb974bdabad53 path: patches/@reown__appkit-adapter-wagmi@1.8.19.patch importers: @@ -659,7 +659,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(patch_hash=f78611af719579832876ca1539b26f27c6159db92511d8c5ca19a9c98e63fc7c)(49a2bfb92b0b8729060e38e5adbad5e5) + version: 1.8.19(patch_hash=4e156cd264d801aa863bcdd15c26b42c60b79a7e951b87ab987cb974bdabad53)(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) @@ -1266,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(patch_hash=f78611af719579832876ca1539b26f27c6159db92511d8c5ca19a9c98e63fc7c)(49a2bfb92b0b8729060e38e5adbad5e5) + version: 1.8.19(patch_hash=4e156cd264d801aa863bcdd15c26b42c60b79a7e951b87ab987cb974bdabad53)(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) @@ -2082,7 +2082,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(patch_hash=f78611af719579832876ca1539b26f27c6159db92511d8c5ca19a9c98e63fc7c)(49a2bfb92b0b8729060e38e5adbad5e5) + version: 1.8.19(patch_hash=4e156cd264d801aa863bcdd15c26b42c60b79a7e951b87ab987cb974bdabad53)(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) @@ -21741,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(patch_hash=f78611af719579832876ca1539b26f27c6159db92511d8c5ca19a9c98e63fc7c)(49a2bfb92b0b8729060e38e5adbad5e5)': + '@reown/appkit-adapter-wagmi@1.8.19(patch_hash=4e156cd264d801aa863bcdd15c26b42c60b79a7e951b87ab987cb974bdabad53)(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) From 9e8698485359acca083439c1c3627770e2b83f81 Mon Sep 17 00:00:00 2001 From: Alexandr Kazachenko Date: Tue, 16 Jun 2026 00:41:03 +0500 Subject: [PATCH 18/19] chore: remove patch --- .../@reown__appkit-adapter-wagmi@1.8.19.patch | 20 ------------------- pnpm-lock.yaml | 10 +++++----- 2 files changed, 5 insertions(+), 25 deletions(-) diff --git a/patches/@reown__appkit-adapter-wagmi@1.8.19.patch b/patches/@reown__appkit-adapter-wagmi@1.8.19.patch index cfd2ab9a090..09e044de55a 100644 --- a/patches/@reown__appkit-adapter-wagmi@1.8.19.patch +++ b/patches/@reown__appkit-adapter-wagmi@1.8.19.patch @@ -2,26 +2,6 @@ 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 -@@ -142,6 +142,11 @@ export class WagmiAdapter extends AdapterBlueprint { - }); - watchAccount(this.wagmiConfig, { - onChange: (accountData, prevAccountData) => { -+ const hasConnectorChanged = !!prevAccountData.connector && accountData.connector.id !== prevAccountData.connector.id; -+ -+ // Patch: ignore changes from not current connector -+ if (hasConnectorChanged) return -+ - if (accountData.status === 'disconnected' && prevAccountData.address) { - this.emit('disconnect'); - } -@@ -152,7 +157,6 @@ export class WagmiAdapter extends AdapterBlueprint { - } - if (accountData.status === 'connected') { - const hasAccountChanged = accountData.address !== prevAccountData?.address; -- const hasConnectorChanged = accountData.connector.id !== prevAccountData.connector?.id; - const hasConnectionStatusChanged = prevAccountData.status !== 'connected'; - if (hasAccountChanged || hasConnectorChanged || hasConnectionStatusChanged) { - this.setupWatchPendingTransactions(); @@ -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 f0178240284..b7c74a37923 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,7 +21,7 @@ packageExtensionsChecksum: sha256-pRepWyCfyr/CPwq7w85jzIOlvbhWnZfcj1y272Xarmg= patchedDependencies: '@reown/appkit-adapter-wagmi@1.8.19': - hash: 4e156cd264d801aa863bcdd15c26b42c60b79a7e951b87ab987cb974bdabad53 + hash: b762004636088a2b38d255e990181fd088b8ae189b70b9c34e67e69f4a5e00c6 path: patches/@reown__appkit-adapter-wagmi@1.8.19.patch importers: @@ -659,7 +659,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(patch_hash=4e156cd264d801aa863bcdd15c26b42c60b79a7e951b87ab987cb974bdabad53)(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) @@ -1266,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(patch_hash=4e156cd264d801aa863bcdd15c26b42c60b79a7e951b87ab987cb974bdabad53)(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) @@ -2082,7 +2082,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(patch_hash=4e156cd264d801aa863bcdd15c26b42c60b79a7e951b87ab987cb974bdabad53)(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) @@ -21741,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(patch_hash=4e156cd264d801aa863bcdd15c26b42c60b79a7e951b87ab987cb974bdabad53)(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) From 5ee6763542a4be0fbf9989d6b7f4f98cb01c65d1 Mon Sep 17 00:00:00 2001 From: Alexandr Kazachenko Date: Tue, 16 Jun 2026 01:00:05 +0500 Subject: [PATCH 19/19] chore: revert providerIsolation deletion --- libs/wallet/src/bindActiveProvider.ts | 40 ++++ libs/wallet/src/providerIsolation.test.ts | 99 ++++++++++ libs/wallet/src/providerIsolation.ts | 215 ++++++++++++++++++++++ libs/wallet/src/wagmi/Web3Provider.tsx | 2 + libs/wallet/src/wagmi/config.ts | 6 + 5 files changed, 362 insertions(+) create mode 100644 libs/wallet/src/bindActiveProvider.ts create mode 100644 libs/wallet/src/providerIsolation.test.ts create mode 100644 libs/wallet/src/providerIsolation.ts 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/providerIsolation.test.ts b/libs/wallet/src/providerIsolation.test.ts new file mode 100644 index 00000000000..5830b3160e0 --- /dev/null +++ b/libs/wallet/src/providerIsolation.test.ts @@ -0,0 +1,99 @@ +import type { EIP1193Provider } from 'viem' + +type ProviderIsolationModule = typeof import('./providerIsolation') + +type IsolationTestWindow = Window & + typeof globalThis & { + __cowEip6963InterceptRegistered?: boolean + __cowEip6963ReDispatched?: WeakSet + __cowEip6963DeferredBraveWallet?: unknown[] + __cowEip6963AnnounceProviderListener?: EventListener + } + +const provider: EIP1193Provider = { + request: jest.fn(), + on: jest.fn(), + removeListener: jest.fn(), +} + +function resetWindowState(): void { + const win = window as IsolationTestWindow + if (win.__cowEip6963AnnounceProviderListener) { + window.removeEventListener('eip6963:announceProvider', win.__cowEip6963AnnounceProviderListener, { + capture: true, + }) + } + delete win.__cowEip6963InterceptRegistered + delete win.__cowEip6963ReDispatched + delete win.__cowEip6963DeferredBraveWallet + delete win.__cowEip6963AnnounceProviderListener +} + +async function loadProviderIsolation(): Promise { + jest.resetModules() + return import('./providerIsolation') +} + +describe('interceptEIP6963Providers', () => { + beforeEach(() => { + resetWindowState() + }) + + it('does not read Brave Wallet provider until deferred announcements are flushed', async () => { + const { flushDeferredProviders, interceptEIP6963Providers } = await loadProviderIsolation() + const downstreamListener = jest.fn() + let providerReadCount = 0 + const detail: { info: { name: string; rdns: string }; provider: EIP1193Provider } = { + info: { name: 'Brave Wallet', rdns: 'com.brave.wallet' }, + get provider() { + providerReadCount += 1 + return provider + }, + } + + interceptEIP6963Providers() + window.addEventListener('eip6963:announceProvider', downstreamListener, { capture: true }) + + window.dispatchEvent( + new CustomEvent('eip6963:announceProvider', { + bubbles: true, + detail, + }), + ) + + expect(providerReadCount).toBe(0) + expect(downstreamListener).not.toHaveBeenCalled() + + flushDeferredProviders() + + expect(providerReadCount).toBe(1) + expect(downstreamListener).toHaveBeenCalledTimes(1) + }) + + it('continues to wrap non-Brave providers immediately', async () => { + const { interceptEIP6963Providers } = await loadProviderIsolation() + const downstreamListener = jest.fn() + let providerReadCount = 0 + const detail: { info: { name: string; rdns: string }; provider: EIP1193Provider } = { + info: { name: 'MetaMask', rdns: 'io.metamask' }, + get provider() { + providerReadCount += 1 + return provider + }, + } + + interceptEIP6963Providers() + window.addEventListener('eip6963:announceProvider', downstreamListener, { capture: true }) + + window.dispatchEvent( + new CustomEvent('eip6963:announceProvider', { + bubbles: true, + detail, + }), + ) + + expect(providerReadCount).toBe(1) + expect(downstreamListener).toHaveBeenCalledTimes(1) + expect((downstreamListener.mock.calls[0]?.[0] as CustomEvent).detail.provider).not.toBe(provider) + }) +}) diff --git a/libs/wallet/src/providerIsolation.ts b/libs/wallet/src/providerIsolation.ts new file mode 100644 index 00000000000..cd61f9312cb --- /dev/null +++ b/libs/wallet/src/providerIsolation.ts @@ -0,0 +1,215 @@ +import type { EIP1193EventMap, EIP1193Provider } from 'viem' + +/** + * Sentinel value meaning "the user has explicitly disconnected (or connected then disconnected)". + * In this state, all accountsChanged events are blocked to prevent wallets from + * auto-reconnecting disconnected tabs when the user switches accounts in the extension. + * + * Distinguished from `null` which means "no connector established yet" (initial page load), + * where events must pass through for reconnection to work. + */ +export const PROVIDER_DISCONNECTED: unique symbol = Symbol('PROVIDER_DISCONNECTED') + +/** + * Tracks which isolated provider is currently active in this tab (in-memory, per-tab). + * Updated by config.ts whenever config.state.current changes. + * Used by createIsolatedProvider to filter accountsChanged events. + * + * - `null`: initial state, no connector established yet — events pass through + * - `PROVIDER_DISCONNECTED`: user disconnected — events are blocked + * - `EIP1193Provider`: active provider — only events from this provider pass through + */ +export const activeProviderRef: { current: EIP1193Provider | typeof PROVIDER_DISCONNECTED | null } = { current: null } + +type Eip6963ProviderInfo = { name?: string; rdns?: string } +type Eip6963ProviderDetail = { + info: Eip6963ProviderInfo + provider: EIP1193Provider +} +type DeferredBraveWalletAnnouncement = { + info: Eip6963ProviderInfo + event: CustomEvent +} + +// Cache isolated providers by their original so identity is stable across calls. +const cache = new WeakMap() + +/** + * Wraps an EIP-1193 provider to enforce tab-level wallet isolation: + * + * 1. Blocks `wallet_revokePermissions` — wagmi calls this on disconnect, but it revokes + * permissions for the *entire origin* (all tabs), not just the current tab. + * shimDisconnect is sufficient to prevent reconnect on next page load. + * + * 2. Filters `accountsChanged` events — only forwards them when this provider is the + * active one in this tab, preventing a wallet switch in Tab A from affecting Tab B. + */ +export function createIsolatedProvider(original: EIP1193Provider): EIP1193Provider { + const cached = cache.get(original as object) + if (cached) return cached + + // Maps original listener → wrapped listener so removeListener works correctly. + type AccountsChangedListener = EIP1193EventMap['accountsChanged'] + const listenerMap = new Map() + + const proxy: EIP1193Provider = { + request: (async (args) => { + const method = (args as { method: string }).method + if (method === 'wallet_revokePermissions') { + console.log('[providerIsolation] blocked wallet_revokePermissions') + return null + } + return original.request(args as unknown as Parameters[0]) + }) as EIP1193Provider['request'], + + on: (event: event, listener: EIP1193EventMap[event]): void => { + if (event === 'accountsChanged') { + const wrapped: AccountsChangedListener = (accounts) => { + const active = activeProviderRef.current + + // PROVIDER_DISCONNECTED: user explicitly disconnected — block all events to + // prevent wallets from auto-reconnecting when accounts change in the extension. + if (active === PROVIDER_DISCONNECTED) return + + // null: initial page load, no connector established yet — let events through + // so wagmi's reconnection can receive account updates. + // EIP1193Provider: only forward events for the active provider to enforce + // tab-level isolation (wallet switch in Tab A shouldn't affect Tab B). + if (active !== null && active !== proxy) return + ;(listener as unknown as AccountsChangedListener)(accounts) + } + listenerMap.set(listener as unknown as AccountsChangedListener, wrapped) + original.on('accountsChanged', wrapped) + } else { + original.on(event, listener as unknown as EIP1193EventMap[event]) + } + }, + + removeListener: (event: event, listener: EIP1193EventMap[event]): void => { + if (event === 'accountsChanged') { + const wrapped = listenerMap.get(listener as unknown as AccountsChangedListener) + if (wrapped) { + original.removeListener('accountsChanged', wrapped) + listenerMap.delete(listener as unknown as AccountsChangedListener) + } else { + original.removeListener('accountsChanged', listener as unknown as AccountsChangedListener) + } + } else { + original.removeListener(event, listener as unknown as EIP1193EventMap[event]) + } + }, + } + + cache.set(original as object, proxy) + return proxy +} + +// Guards stored on `window` so they survive HMR — module-local variables are +// reset on hot reload, but the capture listener stays attached to `window`. +// Without this, each HMR reload would add another listener and the two instances +// could re-dispatch events back and forth. +type IsolationWindow = Window & { + __cowEip6963InterceptRegistered?: boolean + __cowEip6963ReDispatched?: WeakSet + __cowEip6963DeferredBraveWallet?: DeferredBraveWalletAnnouncement[] + __cowEip6963AnnounceProviderListener?: EventListener +} + +function getReDispatched(): WeakSet { + const win = window as IsolationWindow + if (!win.__cowEip6963ReDispatched) { + win.__cowEip6963ReDispatched = new WeakSet() + } + return win.__cowEip6963ReDispatched +} + +function getDeferredBraveWalletAnnouncements(): DeferredBraveWalletAnnouncement[] { + const win = window as IsolationWindow + if (!win.__cowEip6963DeferredBraveWallet) { + win.__cowEip6963DeferredBraveWallet = [] + } + return win.__cowEip6963DeferredBraveWallet +} + +function getProviderIdentifier(info: Eip6963ProviderInfo): string { + return info.rdns ?? info.name ?? 'unknown' +} + +function isBraveWalletInfo(info: Eip6963ProviderInfo): boolean { + return info.rdns === 'com.brave.wallet' || info.name === 'Brave Wallet' +} + +function createIsolatedProviderAnnouncement(detail: Eip6963ProviderDetail): CustomEvent { + const newEvent = new CustomEvent('eip6963:announceProvider', { + detail: { info: detail.info, provider: createIsolatedProvider(detail.provider) }, + }) + getReDispatched().add(newEvent) + + return newEvent +} + +function deferBraveWalletAnnouncement(info: Eip6963ProviderInfo, event: CustomEvent): void { + const deferred = getDeferredBraveWalletAnnouncements() + const identifier = getProviderIdentifier(info) + const replacementIndex = deferred.findIndex((announcement) => getProviderIdentifier(announcement.info) === identifier) + const announcement = { info, event } + + if (replacementIndex >= 0) { + deferred[replacementIndex] = announcement + } else { + deferred.push(announcement) + } +} + +/** + * Dispatches Brave Wallet EIP-6963 announcements that were hidden during page load. + * Call this only from an explicit wallet-selection path; materializing the Brave + * provider at startup can crash Brave's renderer process. + */ +export function flushDeferredProviders(): void { + if (typeof window === 'undefined') return + + const deferred = getDeferredBraveWalletAnnouncements() + if (deferred.length === 0) return + + for (const announcement of deferred.splice(0)) { + const event = createIsolatedProviderAnnouncement({ + info: announcement.info, + provider: announcement.event.detail.provider, + }) + window.dispatchEvent(event) + } +} + +/** + * Registers a capture-phase listener for EIP-6963 provider announcements so that + * every wallet provider is wrapped with createIsolatedProvider *before* wagmi/AppKit + * processes it and creates connectors. + * + * Must be called before WagmiAdapter / createConfig is instantiated. + * Safe to call multiple times — only registers the listener once (survives HMR). + */ +export function interceptEIP6963Providers(): void { + if (typeof window === 'undefined') return + const win = window as IsolationWindow + if (win.__cowEip6963InterceptRegistered) return + win.__cowEip6963InterceptRegistered = true + const announceProviderListener = ((event: Event): void => { + const reDispatched = getReDispatched() + if (reDispatched.has(event)) return + event.stopImmediatePropagation() + + const customEvent = event as CustomEvent + const detail = customEvent.detail + + if (isBraveWalletInfo(detail.info)) { + deferBraveWalletAnnouncement(detail.info, customEvent) + return + } + + const newEvent = createIsolatedProviderAnnouncement(detail) + window.dispatchEvent(newEvent) + }) satisfies EventListener + win.__cowEip6963AnnounceProviderListener = announceProviderListener + window.addEventListener('eip6963:announceProvider', announceProviderListener, { capture: true }) +} diff --git a/libs/wallet/src/wagmi/Web3Provider.tsx b/libs/wallet/src/wagmi/Web3Provider.tsx index 729e220c719..d5010477e5d 100644 --- a/libs/wallet/src/wagmi/Web3Provider.tsx +++ b/libs/wallet/src/wagmi/Web3Provider.tsx @@ -8,6 +8,7 @@ import { WagmiProvider } from 'wagmi' import { reownAppKit, wagmiAdapter } from './config' import { OPEN_WALLET_MODAL_EVENT } from '../constants' +import { flushDeferredProviders } from '../providerIsolation' const queryClient = new QueryClient() @@ -31,6 +32,7 @@ function OpenWalletModalOnCustomEvent(): null { useEffect(() => { const handler = (): void => { reownAppKit?.open({ view: 'Connect' }) + flushDeferredProviders() } document.addEventListener(OPEN_WALLET_MODAL_EVENT, handler) return () => document.removeEventListener(OPEN_WALLET_MODAL_EVENT, handler) diff --git a/libs/wallet/src/wagmi/config.ts b/libs/wallet/src/wagmi/config.ts index 0e9ef479e74..d48bbb61f1b 100644 --- a/libs/wallet/src/wagmi/config.ts +++ b/libs/wallet/src/wagmi/config.ts @@ -9,11 +9,15 @@ import { type Transport } from 'wagmi' import { getConnectors } from './getConnectors' +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' +interceptEIP6963Providers() + const wagmiTransports = SUPPORTED_REOWN_NETWORKS.reduce( (acc, chain) => { const chainId = chain.id as EvmChains @@ -114,4 +118,6 @@ if (getIsSafeAppIframe()) { connectWalletById(SAFE_CONNECTOR_ID, 'safe') } +bindActiveProvider(wagmiAdapter) + export { wagmiAdapter, reownAppKit, wagmiStorage }