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