diff --git a/apps/cowswap-frontend/src/locales/en-US.po b/apps/cowswap-frontend/src/locales/en-US.po index c740d59cfbf..18a1031a8e2 100644 --- a/apps/cowswap-frontend/src/locales/en-US.po +++ b/apps/cowswap-frontend/src/locales/en-US.po @@ -1510,6 +1510,7 @@ msgstr "Edit code" msgid "Select an {accountProxyLabelString} to check for available refunds {chain}" msgstr "Select an {accountProxyLabelString} to check for available refunds {chain}" +#: apps/cowswap-frontend/src/modules/twap/pure/PrimaryActionButton/index.tsx #: apps/cowswap-frontend/src/modules/twap/pure/PrimaryActionButton/index.tsx msgid "Unsupported wallet" msgstr "Unsupported wallet" diff --git a/apps/cowswap-frontend/src/modules/twap/containers/TwapFormWarnings/index.tsx b/apps/cowswap-frontend/src/modules/twap/containers/TwapFormWarnings/index.tsx index 80a5d411488..2eed4569164 100644 --- a/apps/cowswap-frontend/src/modules/twap/containers/TwapFormWarnings/index.tsx +++ b/apps/cowswap-frontend/src/modules/twap/containers/TwapFormWarnings/index.tsx @@ -72,7 +72,10 @@ export function TwapFormWarnings({ localFormValidation, isConfirmationModal }: T return ( <> {(() => { - if (localFormValidation === TwapFormState.TX_BUNDLING_NOT_SUPPORTED) { + if ( + localFormValidation === TwapFormState.WALLET_NOT_SUPPORTED || + localFormValidation === TwapFormState.TX_BUNDLING_NOT_SUPPORTED + ) { return } diff --git a/apps/cowswap-frontend/src/modules/twap/containers/TwapFormWarnings/warnings/UnsupportedWalletWarning.tsx b/apps/cowswap-frontend/src/modules/twap/containers/TwapFormWarnings/warnings/UnsupportedWalletWarning.tsx index 721afc3d403..457d1a42830 100644 --- a/apps/cowswap-frontend/src/modules/twap/containers/TwapFormWarnings/warnings/UnsupportedWalletWarning.tsx +++ b/apps/cowswap-frontend/src/modules/twap/containers/TwapFormWarnings/warnings/UnsupportedWalletWarning.tsx @@ -1,20 +1,23 @@ +import { ReactNode } from 'react' + import { getSafeAccountUrl } from '@cowprotocol/core' import { SupportedChainId } from '@cowprotocol/cow-sdk' import { ExternalLink, InlineBanner, StatusColorVariant } from '@cowprotocol/ui' import { Trans } from '@lingui/react/macro' -import { UNSUPPORTED_WALLET_LINK } from 'modules/twap/const' +import { UNSUPPORTED_WALLET_LINK } from 'modules/twap' export interface UnsupportedWalletWarningProps { chainId: SupportedChainId account?: string + /** + * If true, we want to suggest the user to try the Safe web app, because their wallet connection did not confirm tx bundling. + */ isSafeViaWc: boolean } -// TODO: Add proper return type annotation -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -export function UnsupportedWalletWarning({ isSafeViaWc, chainId, account }: UnsupportedWalletWarningProps) { +export function UnsupportedWalletWarning({ isSafeViaWc, chainId, account }: UnsupportedWalletWarningProps): ReactNode { if (isSafeViaWc && account) { return ( diff --git a/apps/cowswap-frontend/src/modules/twap/containers/TwapFormWidget/index.tsx b/apps/cowswap-frontend/src/modules/twap/containers/TwapFormWidget/index.tsx index db846a170ad..4c8d8c6df0c 100644 --- a/apps/cowswap-frontend/src/modules/twap/containers/TwapFormWidget/index.tsx +++ b/apps/cowswap-frontend/src/modules/twap/containers/TwapFormWidget/index.tsx @@ -129,7 +129,7 @@ export function TwapFormWidget({ tradeWarnings }: TwapFormWidget): ReactNode { useEffect(() => { if (account && verification) { - if (localFormValidation === TwapFormState.TX_BUNDLING_NOT_SUPPORTED) { + if (localFormValidation === TwapFormState.WALLET_NOT_SUPPORTED) { cowAnalytics.sendEvent({ category: CowSwapAnalyticsCategory.TWAP, action: 'non-compatible', diff --git a/apps/cowswap-frontend/src/modules/twap/hooks/useTwapFormState.ts b/apps/cowswap-frontend/src/modules/twap/hooks/useTwapFormState.ts index b741ae10b9b..2af9f640eca 100644 --- a/apps/cowswap-frontend/src/modules/twap/hooks/useTwapFormState.ts +++ b/apps/cowswap-frontend/src/modules/twap/hooks/useTwapFormState.ts @@ -1,6 +1,6 @@ import { useAtomValue } from 'jotai' -import { useIsTxBundlingSupported, useWalletInfo } from '@cowprotocol/wallet' +import { isSafeAppAtom, isSafeViaWcAtom, useIsTxBundlingSupported, useWalletInfo } from '@cowprotocol/wallet' import { useGetReceiveAmountInfo } from 'modules/trade' import { tradeFormValidationContextAtom } from 'modules/tradeFormValidation' @@ -26,9 +26,13 @@ export function useTwapFormState(): TwapFormState | null { const tradeFormValidationContext = useAtomValue(tradeFormValidationContextAtom) const verification = useFallbackHandlerVerification() + const isSafeApp = useAtomValue(isSafeAppAtom) + const isSafeViaWc = useAtomValue(isSafeViaWcAtom) const isTxBundlingSupported = useIsTxBundlingSupported() + const isWalletSupported = isSafeApp === null || isSafeViaWc === null ? null : isSafeApp || isSafeViaWc return getTwapFormState({ + isWalletSupported, isTxBundlingSupported, verification, twapOrder, diff --git a/apps/cowswap-frontend/src/modules/twap/index.ts b/apps/cowswap-frontend/src/modules/twap/index.ts index 0486398f7bb..09286babe3d 100644 --- a/apps/cowswap-frontend/src/modules/twap/index.ts +++ b/apps/cowswap-frontend/src/modules/twap/index.ts @@ -9,6 +9,7 @@ export * from './hooks/useMapTwapCurrencyInfo' export * from './hooks/useScaledReceiveAmountInfo' export * from './hooks/useTwapFormState' export * from './hooks/useTwapSlippage' +export * from './const' export { useIsFallbackHandlerRequired } from './hooks/useFallbackHandlerVerification' export { SetupFallbackHandlerWarning } from './containers/SetupFallbackHandlerWarning' export * from './updaters/index' diff --git a/apps/cowswap-frontend/src/modules/twap/pure/PrimaryActionButton/getTwapFormState.test.ts b/apps/cowswap-frontend/src/modules/twap/pure/PrimaryActionButton/getTwapFormState.test.ts index 2748d653242..f9058e3ff81 100644 --- a/apps/cowswap-frontend/src/modules/twap/pure/PrimaryActionButton/getTwapFormState.test.ts +++ b/apps/cowswap-frontend/src/modules/twap/pure/PrimaryActionButton/getTwapFormState.test.ts @@ -25,9 +25,42 @@ const twapOrder: TWAPOrder = { } describe('getTwapFormState()', () => { + it('returns WALLET_NOT_SUPPORTED for a non-Safe wallet', () => { + const result = getTwapFormState({ + isWalletSupported: false, + isTxBundlingSupported: true, + verification: ExtensibleFallbackVerification.HAS_NOTHING, + twapOrder, + sellAmountPartFiat: null, + chainId: SupportedChainId.SEPOLIA, + partTime: undefined, + numberOfPartsValue: 1, + tradeFormValidationContext: null, + }) + + expect(result).toEqual(TwapFormState.WALLET_NOT_SUPPORTED) + }) + + it('returns TX_BUNDLING_NOT_SUPPORTED for a Safe without batching support', () => { + const result = getTwapFormState({ + isWalletSupported: true, + isTxBundlingSupported: false, + verification: ExtensibleFallbackVerification.HAS_NOTHING, + twapOrder, + sellAmountPartFiat: null, + chainId: SupportedChainId.SEPOLIA, + partTime: undefined, + numberOfPartsValue: 1, + tradeFormValidationContext: null, + }) + + expect(result).toEqual(TwapFormState.TX_BUNDLING_NOT_SUPPORTED) + }) + describe('When sell fiat amount is under threshold', () => { it('And order has buy amount, then should return SELL_AMOUNT_TOO_SMALL', () => { const result = getTwapFormState({ + isWalletSupported: true, isTxBundlingSupported: true, verification: ExtensibleFallbackVerification.HAS_DOMAIN_VERIFIER, twapOrder: { ...twapOrder }, @@ -43,6 +76,7 @@ describe('getTwapFormState()', () => { it('And order does NOT have buy amount, then should return null', () => { const result = getTwapFormState({ + isWalletSupported: true, isTxBundlingSupported: true, verification: ExtensibleFallbackVerification.HAS_DOMAIN_VERIFIER, twapOrder: { ...twapOrder, buyAmount: CurrencyAmount.fromRawAmount(COW_SEPOLIA, 0) }, diff --git a/apps/cowswap-frontend/src/modules/twap/pure/PrimaryActionButton/getTwapFormState.tsx b/apps/cowswap-frontend/src/modules/twap/pure/PrimaryActionButton/getTwapFormState.tsx index dd87b5265ba..5f8c91e0ce3 100644 --- a/apps/cowswap-frontend/src/modules/twap/pure/PrimaryActionButton/getTwapFormState.tsx +++ b/apps/cowswap-frontend/src/modules/twap/pure/PrimaryActionButton/getTwapFormState.tsx @@ -14,6 +14,7 @@ import { isPartTimeIntervalTooShort } from '../../utils/isPartTimeIntervalTooSho import { isSellAmountTooSmall } from '../../utils/isSellAmountTooSmall' export interface TwapFormStateParams { + isWalletSupported: boolean | null isTxBundlingSupported: boolean | null verification: ExtensibleFallbackVerification | null twapOrder: TWAPOrder | null @@ -26,6 +27,7 @@ export interface TwapFormStateParams { export enum TwapFormState { LOADING_SAFE_INFO = 'LOADING_SAFE_INFO', + WALLET_NOT_SUPPORTED = 'WALLET_NOT_SUPPORTED', TX_BUNDLING_NOT_SUPPORTED = 'TX_BUNDLING_NOT_SUPPORTED', SELL_AMOUNT_TOO_SMALL = 'SELL_AMOUNT_TOO_SMALL', PART_TIME_INTERVAL_TOO_SHORT = 'PART_TIME_INTERVAL_TOO_SHORT', @@ -35,6 +37,7 @@ export enum TwapFormState { export function getTwapFormState(props: TwapFormStateParams): TwapFormState | null { const { + isWalletSupported, twapOrder, isTxBundlingSupported, verification, @@ -45,9 +48,12 @@ export function getTwapFormState(props: TwapFormStateParams): TwapFormState | nu numberOfPartsValue, } = props + if (isWalletSupported === false) return TwapFormState.WALLET_NOT_SUPPORTED if (isTxBundlingSupported === false) return TwapFormState.TX_BUNDLING_NOT_SUPPORTED - if (verification === null || isTxBundlingSupported === null) return TwapFormState.LOADING_SAFE_INFO + if (verification === null || isTxBundlingSupported === null || isWalletSupported === null) { + return TwapFormState.LOADING_SAFE_INFO + } if (!isFractionFalsy(twapOrder?.buyAmount) && isSellAmountTooSmall(sellAmountPartFiat, chainId)) { return TwapFormState.SELL_AMOUNT_TOO_SMALL diff --git a/apps/cowswap-frontend/src/modules/twap/pure/PrimaryActionButton/index.tsx b/apps/cowswap-frontend/src/modules/twap/pure/PrimaryActionButton/index.tsx index e4fd361d09d..27b3bfdab11 100644 --- a/apps/cowswap-frontend/src/modules/twap/pure/PrimaryActionButton/index.tsx +++ b/apps/cowswap-frontend/src/modules/twap/pure/PrimaryActionButton/index.tsx @@ -18,6 +18,11 @@ const buttonsMap: RecordLoading... ), + [TwapFormState.WALLET_NOT_SUPPORTED]: () => ( + + Unsupported wallet + + ), [TwapFormState.TX_BUNDLING_NOT_SUPPORTED]: () => ( Unsupported wallet diff --git a/libs/wallet/package.json b/libs/wallet/package.json index 4d2743f52c0..e9a37fd8814 100644 --- a/libs/wallet/package.json +++ b/libs/wallet/package.json @@ -41,7 +41,6 @@ "@cowprotocol/iframe-transport": "workspace:*", "@cowprotocol/types": "workspace:*", "@cowprotocol/ui": "workspace:*", - "@cowprotocol/wallet-provider": "workspace:*", "@ethereumjs/util": "10.1.0", "@metamask/jazzicon": "2.0.0", "@metamask/sdk": "0.31.4", diff --git a/libs/wallet/src/api/state/walletCapabilitiesAtom.test.ts b/libs/wallet/src/api/state/walletCapabilitiesAtom.test.ts index 7a08371c851..45b3b5bafbf 100644 --- a/libs/wallet/src/api/state/walletCapabilitiesAtom.test.ts +++ b/libs/wallet/src/api/state/walletCapabilitiesAtom.test.ts @@ -1,39 +1,22 @@ import { createStore, type WritableAtom } from 'jotai' -import type { EIP1193Provider, PublicClient } from 'viem' import type { Connector } from 'wagmi' import { SupportedChainId } from '@cowprotocol/cow-sdk' -import { AccountType } from '@cowprotocol/types' import { - getShouldSkipCapabilitiesCheck, isAtomicBatchSupportedAtom, isAtomicBatchSupportedAsyncAtom, isAtomicBatchSupportedLoadableAtom, - resolveCapabilitiesForChain, walletCapabilitiesAtom, } from './walletCapabilitiesAtom' -import { - accountTypeAtom, - isSafeAppAtom, - isSafeViaWcAtom, - isSafeWalletAtom, - isSmartContractWalletAtom, -} from '../../wagmi/state/walletMetadata.atoms' +import { isSafeAppAtom, isSafeViaWcAtom } from '../../wagmi/state/walletMetadata.atoms' import { walletInfoAtom } from '../state' /** Mocked module exports writable primitives; Jest widens atom types, so casts are required for store.set. */ const writableIsSafeAppAtom = isSafeAppAtom as WritableAtom const writableIsSafeViaWcAtom = isSafeViaWcAtom as WritableAtom -const writableAccountTypeAtom = accountTypeAtom as WritableAtom -const writableIsSmartContractWalletAtom = isSmartContractWalletAtom as WritableAtom< - boolean | null, - [boolean | null], - unknown -> -const writableIsSafeWalletAtom = isSafeWalletAtom as WritableAtom import type { WalletCapabilities } from './walletCapabilitiesAtom' import type { WalletInfo } from '../types' @@ -44,9 +27,6 @@ jest.mock('../../wagmi/state/walletMetadata.atoms', () => { return { isSafeAppAtom: jotai.atom(false), isSafeViaWcAtom: jotai.atom(false), - accountTypeAtom: jotai.atom('EOA'), - isSmartContractWalletAtom: jotai.atom(false), - isSafeWalletAtom: jotai.atom(false), } }) @@ -55,150 +35,34 @@ const MOCK_CHAIN_ID = SupportedChainId.MAINNET const MOCK_CONNECTOR = { type: 'injected' } as Connector const mockGetCapabilities = jest.fn() -const mockWagmiConfigGetClient = jest.fn() -const mockGetIsWalletConnect = jest.fn() -const mockIsMobile = { value: false } - -jest.mock('@cowprotocol/common-utils', () => { - const actual = jest.requireActual('@cowprotocol/common-utils') - - return { - ...actual, - getCurrentChainIdFromUrl: () => 1, - get isMobile() { - return mockIsMobile.value - }, - } -}) -jest.mock('../../wagmi/hooks/useIsWalletConnect', () => ({ - getIsWalletConnect: (...args: unknown[]) => mockGetIsWalletConnect(...args), -})) - -jest.mock('viem/actions', () => ({ +jest.mock('wagmi/actions', () => ({ getCapabilities: (...args: unknown[]) => mockGetCapabilities(...args), })) jest.mock('../../wagmi/config', () => ({ - wagmiConfig: { - getClient: (...args: unknown[]) => mockWagmiConfigGetClient(...args), - }, + wagmiConfig: {}, })) -function createMockEip1193Provider(request = jest.fn()): EIP1193Provider { - return { - request, - on: jest.fn(), - removeListener: jest.fn(), - } as unknown as EIP1193Provider -} - -function seedResolvedWalletMetadata( - store: ReturnType, - overrides: Partial<{ - accountType: AccountType | null - isSafeWallet: boolean - isSmartContractWallet: boolean | null - isSafeViaWc: boolean - isSafeApp: boolean - }> = {}, -): void { - store.set(writableAccountTypeAtom, overrides.accountType ?? AccountType.EOA) - store.set(writableIsSafeWalletAtom, overrides.isSafeWallet ?? false) - store.set( - writableIsSmartContractWalletAtom, - 'isSmartContractWallet' in overrides ? (overrides.isSmartContractWallet ?? null) : false, - ) - store.set(writableIsSafeViaWcAtom, overrides.isSafeViaWc ?? false) - store.set(writableIsSafeAppAtom, overrides.isSafeApp ?? false) -} - function setWalletInfo( store: ReturnType, overrides: Partial<{ account: string chainId: SupportedChainId connector: Connector - provider: NonNullable }> = {}, ): void { store.set(walletInfoAtom, { chainId: overrides.chainId ?? MOCK_CHAIN_ID, account: overrides.account ?? MOCK_ACCOUNT, connector: overrides.connector ?? MOCK_CONNECTOR, - provider: overrides.provider ?? createMockEip1193Provider(), }) } -describe('resolveCapabilitiesForChain', () => { - const capabilities: WalletCapabilities = { atomic: { status: 'supported' } } - - it('matches numeric chain id key', () => { - expect(resolveCapabilitiesForChain({ [MOCK_CHAIN_ID]: capabilities }, MOCK_CHAIN_ID)).toEqual(capabilities) - }) - - it('matches hex chain id key', () => { - expect(resolveCapabilitiesForChain({ '0x1': capabilities }, MOCK_CHAIN_ID)).toEqual(capabilities) - }) - - it('falls back to the first entry when no chain key matches', () => { - expect(resolveCapabilitiesForChain({ '0x64': capabilities }, MOCK_CHAIN_ID)).toEqual(capabilities) - }) - - it('returns null for empty capabilities', () => { - expect(resolveCapabilitiesForChain({}, MOCK_CHAIN_ID)).toBeNull() - }) -}) - -describe('getShouldSkipCapabilitiesCheck', () => { - beforeEach(() => { - mockIsMobile.value = false - mockGetIsWalletConnect.mockReturnValue(false) - }) - - it('returns false on desktop', async () => { - expect(await getShouldSkipCapabilitiesCheck(MOCK_CONNECTOR, createMockEip1193Provider())).toBe(false) - }) - - it('returns true on mobile WalletConnect', async () => { - mockIsMobile.value = true - mockGetIsWalletConnect.mockReturnValue(true) - - expect(await getShouldSkipCapabilitiesCheck(MOCK_CONNECTOR, createMockEip1193Provider())).toBe(true) - }) -}) - describe('walletCapabilitiesAtom', () => { beforeEach(() => { jest.clearAllMocks() - mockIsMobile.value = false - mockGetIsWalletConnect.mockReturnValue(false) mockGetCapabilities.mockResolvedValue({}) - mockWagmiConfigGetClient.mockReturnValue({ chainId: MOCK_CHAIN_ID }) - }) - - it('returns null when isSafeViaWc is still loading', async () => { - const store = createStore() - store.set(writableIsSafeViaWcAtom, null) - setWalletInfo(store) - - const result = await store.get(walletCapabilitiesAtom) - - expect(result).toBeNull() - expect(mockGetCapabilities).not.toHaveBeenCalled() - }) - - it('returns null on mobile WalletConnect without fetching capabilities', async () => { - mockIsMobile.value = true - mockGetIsWalletConnect.mockReturnValue(true) - - const store = createStore() - setWalletInfo(store) - - const result = await store.get(walletCapabilitiesAtom) - - expect(result).toBeNull() - expect(mockGetCapabilities).not.toHaveBeenCalled() }) describe('walletInfoAtom state: missing required fields', () => { @@ -207,7 +71,6 @@ describe('walletCapabilitiesAtom', () => { store.set(walletInfoAtom, { chainId: MOCK_CHAIN_ID, connector: MOCK_CONNECTOR, - provider: createMockEip1193Provider(), }) const result = await store.get(walletCapabilitiesAtom) @@ -221,7 +84,6 @@ describe('walletCapabilitiesAtom', () => { store.set(walletInfoAtom, { account: MOCK_ACCOUNT, connector: MOCK_CONNECTOR, - provider: createMockEip1193Provider(), } as WalletInfo) const result = await store.get(walletCapabilitiesAtom) @@ -235,21 +97,6 @@ describe('walletCapabilitiesAtom', () => { store.set(walletInfoAtom, { chainId: MOCK_CHAIN_ID, account: MOCK_ACCOUNT, - provider: createMockEip1193Provider(), - }) - - const result = await store.get(walletCapabilitiesAtom) - - expect(result).toBeNull() - expect(mockGetCapabilities).not.toHaveBeenCalled() - }) - - it('returns null when provider is missing', async () => { - const store = createStore() - store.set(walletInfoAtom, { - chainId: MOCK_CHAIN_ID, - account: MOCK_ACCOUNT, - connector: MOCK_CONNECTOR, }) const result = await store.get(walletCapabilitiesAtom) @@ -259,7 +106,7 @@ describe('walletCapabilitiesAtom', () => { }) }) - describe('getCapabilities (viem)', () => { + describe('getCapabilities (wagmi)', () => { it('returns capabilities when getCapabilities resolves for the current chain', async () => { const capabilities: WalletCapabilities = { atomic: { status: 'supported' } } mockGetCapabilities.mockResolvedValue(capabilities) @@ -270,8 +117,14 @@ describe('walletCapabilitiesAtom', () => { const result = await store.get(walletCapabilitiesAtom) expect(result).toEqual(capabilities) - expect(mockGetCapabilities).toHaveBeenCalled() - expect(mockWagmiConfigGetClient).toHaveBeenCalledWith({ chainId: MOCK_CHAIN_ID }) + expect(mockGetCapabilities).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + account: MOCK_ACCOUNT, + chainId: MOCK_CHAIN_ID, + connector: MOCK_CONNECTOR, + }), + ) }) it('returns empty capabilities object when getCapabilities resolves with no fields', async () => { @@ -285,111 +138,23 @@ describe('walletCapabilitiesAtom', () => { expect(result).toEqual({}) }) - it('returns null when getCapabilities and wallet_getCapabilities both fail', async () => { - mockGetCapabilities.mockRejectedValue(new Error('viem error')) - const mockRequest = jest.fn().mockRejectedValue(new Error('legacy error')) + it('returns null when getCapabilities fails', async () => { + mockGetCapabilities.mockRejectedValue(new Error('wagmi error')) const store = createStore() - setWalletInfo(store, { provider: createMockEip1193Provider(mockRequest) }) - - const result = await store.get(walletCapabilitiesAtom) - - expect(result).toBeNull() - expect(mockRequest).toHaveBeenCalledWith({ - method: 'wallet_getCapabilities', - params: [MOCK_ACCOUNT], - }) - }) - - it('returns null when getCapabilities fails and provider does not support EIP-1193 requests', async () => { - const error = new Error('viem error') - mockGetCapabilities.mockRejectedValue(error) - - const store = createStore() - setWalletInfo(store, { provider: {} as PublicClient }) + setWalletInfo(store) const result = await store.get(walletCapabilitiesAtom) expect(result).toBeNull() }) - it('returns null when getCapabilities times out and provider does not support EIP-1193 requests', async () => { + it('returns null when getCapabilities times out', async () => { jest.useFakeTimers() mockGetCapabilities.mockImplementation(() => new Promise(() => undefined)) const store = createStore() - setWalletInfo(store, { provider: {} as PublicClient }) - - try { - const resultPromise = store.get(walletCapabilitiesAtom) - await jest.advanceTimersByTimeAsync(5_000) - const result = await resultPromise - - expect(result).toBeNull() - } finally { - jest.useRealTimers() - } - }) - }) - - describe('wallet_getCapabilities legacy fallback', () => { - it('returns capabilities resolved from hex chain id key when viem throws', async () => { - const capabilities: WalletCapabilities = { atomic: { status: 'supported' } } - mockGetCapabilities.mockRejectedValue(new Error('viem error')) - const mockRequest = jest.fn().mockResolvedValue({ '0x1': capabilities }) - - const store = createStore() - setWalletInfo(store, { provider: createMockEip1193Provider(mockRequest) }) - - const result = await store.get(walletCapabilitiesAtom) - - expect(result).toEqual(capabilities) - }) - - it('falls back to the first legacy capability entry when chain key is missing', async () => { - const capabilities: WalletCapabilities = { atomic: { status: 'supported' } } - mockGetCapabilities.mockRejectedValue(new Error('viem error')) - const mockRequest = jest.fn().mockResolvedValue({ '0x64': capabilities }) - - const store = createStore() - setWalletInfo(store, { provider: createMockEip1193Provider(mockRequest) }) - - const result = await store.get(walletCapabilitiesAtom) - - expect(result).toEqual(capabilities) - }) - - it('uses legacy fallback when getCapabilities times out', async () => { - jest.useFakeTimers() - const capabilities: WalletCapabilities = { atomic: { status: 'supported' } } - mockGetCapabilities.mockImplementation(() => new Promise(() => undefined)) - const mockRequest = jest.fn().mockResolvedValue({ '0x1': capabilities }) - - const store = createStore() - setWalletInfo(store, { provider: createMockEip1193Provider(mockRequest) }) - - try { - const resultPromise = store.get(walletCapabilitiesAtom) - await jest.advanceTimersByTimeAsync(5_000) - const result = await resultPromise - - expect(result).toEqual(capabilities) - expect(mockRequest).toHaveBeenCalledWith({ - method: 'wallet_getCapabilities', - params: [MOCK_ACCOUNT], - }) - } finally { - jest.useRealTimers() - } - }) - - it('returns null when legacy fallback times out', async () => { - jest.useFakeTimers() - mockGetCapabilities.mockRejectedValue(new Error('viem error')) - const mockRequest = jest.fn().mockImplementation(() => new Promise(() => undefined)) - - const store = createStore() - setWalletInfo(store, { provider: createMockEip1193Provider(mockRequest) }) + setWalletInfo(store) try { const resultPromise = store.get(walletCapabilitiesAtom) @@ -407,15 +172,11 @@ describe('walletCapabilitiesAtom', () => { describe('isAtomicBatchSupportedAsyncAtom', () => { beforeEach(() => { jest.clearAllMocks() - mockIsMobile.value = false - mockGetIsWalletConnect.mockReturnValue(false) mockGetCapabilities.mockResolvedValue({}) - mockWagmiConfigGetClient.mockReturnValue({ chainId: MOCK_CHAIN_ID }) }) it('returns false when walletInfoAtom yields no capabilities (disconnected)', async () => { const store = createStore() - seedResolvedWalletMetadata(store) store.set(walletInfoAtom, { chainId: MOCK_CHAIN_ID } as WalletInfo) const result = await store.get(isAtomicBatchSupportedAsyncAtom) @@ -455,7 +216,7 @@ describe('isAtomicBatchSupportedAsyncAtom', () => { expect(result).toBe(true) }) - it('returns false when isSafeViaWcAtom and capabilities atomic status is ready', async () => { + it('returns true when isSafeViaWcAtom and capabilities atomic status is ready', async () => { const store = createStore() store.set(writableIsSafeViaWcAtom, true) mockGetCapabilities.mockResolvedValue({ atomic: { status: 'ready' } }) @@ -463,7 +224,7 @@ describe('isAtomicBatchSupportedAsyncAtom', () => { const result = await store.get(isAtomicBatchSupportedAsyncAtom) - expect(result).toBe(false) + expect(result).toBe(true) }) it('returns true when atomicBatch.supported is true', async () => { @@ -497,65 +258,12 @@ describe('isAtomicBatchSupportedAsyncAtom', () => { expect(result).toBe(true) }) - - it('does not wait on Safe info for EOA when gnosisSafeInfo is still undefined', async () => { - const store = createStore() - seedResolvedWalletMetadata(store, { accountType: AccountType.EOA, isSafeWallet: false }) - mockGetCapabilities.mockResolvedValue({ atomic: { status: 'supported' } }) - setWalletInfo(store) - - const result = await store.get(isAtomicBatchSupportedAsyncAtom) - - expect(result).toBe(true) - expect(mockGetCapabilities).toHaveBeenCalled() - }) - - it('returns false for smart contract wallet before Safe info confirms a Safe', async () => { - const store = createStore() - seedResolvedWalletMetadata(store, { isSmartContractWallet: true, isSafeWallet: false }) - mockGetCapabilities.mockResolvedValue({ atomic: { status: 'supported' } }) - setWalletInfo(store) - - const result = await store.get(isAtomicBatchSupportedAsyncAtom) - - expect(result).toBe(false) - expect(mockGetCapabilities).not.toHaveBeenCalled() - }) - - it('returns false for non-Safe smart contract wallet before checking capabilities', async () => { - const store = createStore() - store.set(writableIsSmartContractWalletAtom, true) - store.set(writableIsSafeWalletAtom, false) - mockGetCapabilities.mockResolvedValue({ atomic: { status: 'supported' } }) - setWalletInfo(store) - - const result = await store.get(isAtomicBatchSupportedAsyncAtom) - - expect(result).toBe(false) - expect(mockGetCapabilities).not.toHaveBeenCalled() - }) - - it('returns false for EIP-7702 account before checking capabilities', async () => { - const store = createStore() - store.set(writableAccountTypeAtom, AccountType.EIP7702EOA) - store.set(writableIsSafeWalletAtom, false) - mockGetCapabilities.mockResolvedValue({ atomic: { status: 'supported' } }) - setWalletInfo(store) - - const result = await store.get(isAtomicBatchSupportedAsyncAtom) - - expect(result).toBe(false) - expect(mockGetCapabilities).not.toHaveBeenCalled() - }) }) describe('isAtomicBatchSupportedLoadableAtom and isAtomicBatchSupportedAtom', () => { beforeEach(() => { jest.clearAllMocks() - mockIsMobile.value = false - mockGetIsWalletConnect.mockReturnValue(false) mockGetCapabilities.mockResolvedValue({}) - mockWagmiConfigGetClient.mockReturnValue({ chainId: MOCK_CHAIN_ID }) }) it('isAtomicBatchSupportedAtom returns null while loading', async () => { @@ -582,9 +290,7 @@ describe('isAtomicBatchSupportedLoadableAtom and isAtomicBatchSupportedAtom', () store.set(writableIsSafeViaWcAtom, false) store.set(writableIsSafeAppAtom, false) mockGetCapabilities.mockRejectedValue(new Error('network error')) - setWalletInfo(store, { - provider: createMockEip1193Provider(jest.fn().mockRejectedValue(new Error('legacy error'))), - }) + setWalletInfo(store) store.get(isAtomicBatchSupportedLoadableAtom) const asyncResult = await store.get(isAtomicBatchSupportedAsyncAtom) diff --git a/libs/wallet/src/api/state/walletCapabilitiesAtom.ts b/libs/wallet/src/api/state/walletCapabilitiesAtom.ts index 24d34f3e7fe..5cdd08d5772 100644 --- a/libs/wallet/src/api/state/walletCapabilitiesAtom.ts +++ b/libs/wallet/src/api/state/walletCapabilitiesAtom.ts @@ -1,171 +1,38 @@ import { atom } from 'jotai' import { loadable } from 'jotai/utils' -import { EIP1193Provider, numberToHex, PublicClient } from 'viem' -import { Connector } from 'wagmi' +import { getCapabilities, type GetCapabilitiesReturnType } from 'wagmi/actions' -import { isMobile, logWallet, normalizeError, TimeoutError, withTimeout } from '@cowprotocol/common-utils' -import { ProviderMetaInfoPayload, WidgetEthereumProvider } from '@cowprotocol/iframe-transport' -import { AccountType } from '@cowprotocol/types' +import { logWallet, normalizeError, TimeoutError, withTimeout } from '@cowprotocol/common-utils' import ms from 'ms.macro' -import { getCapabilities, type GetCapabilitiesReturnType } from 'viem/actions' import { wagmiConfig } from '../../wagmi/config' -import { getIsWalletConnect } from '../../wagmi/hooks/useIsWalletConnect' -import { - isSafeAppAtom, - isSafeViaWcAtom, - accountTypeAtom, - isSmartContractWalletAtom, - isSafeWalletAtom, -} from '../../wagmi/state/walletMetadata.atoms' -import { isEip1193Provider } from '../../wagmi/utils/isEip1193Provider.utils' +import { isSafeAppAtom, isSafeViaWcAtom } from '../../wagmi/state/walletMetadata.atoms' import { walletInfoAtom } from '../state' export type WalletCapabilities = GetCapabilitiesReturnType[number] const REQUEST_TIMEOUT_MS = ms`5s` -/** - * WalletConnect in mobile browsers initiates a request with confirmation to the wallet - * to get the capabilities. It breaks the flow with perpetual requests. - */ -export async function getShouldSkipCapabilitiesCheck( - connector: Connector, - provider: EIP1193Provider | WidgetEthereumProvider | PublicClient, -): Promise { - if (!isMobile) return false - - const isWalletConnect = getIsWalletConnect(connector) - - if (isWalletConnect) return true - - const widgetProviderMetaInfo = await fetchWidgetProviderMetaInfo(provider) - const isWalletConnectViaWidget = !!widgetProviderMetaInfo?.providerWcMetadata - - return isWalletConnectViaWidget -} - -/** - * Safe WC returns EIP-5792 capabilities keyed by hex chain id (e.g. "0xaa36a7") - * while walletInfoAtom.chainId is numeric (e.g. 11155111). Numeric lookup alone misses them. - */ -export function resolveCapabilitiesForChain( - capabilities: Record, - chainId: number, -): WalletCapabilities | null { - const capabilitiesForChain = capabilities[chainId] ?? capabilities[numberToHex(chainId)] - - if (capabilitiesForChain) return capabilitiesForChain - - // This fallback was initially here for Safe via WC, even thought it should return a value as shown in the example in - // the long TSDoc comment below. `Object.values` should not be needed, as we are already parsing this properly with - // `numberToHex` above: - - return Object.values(capabilities)[0] || null -} - -async function fetchWidgetProviderMetaInfo( - provider: EIP1193Provider | WidgetEthereumProvider | PublicClient, -): Promise { - if (provider instanceof WidgetEthereumProvider) { - const providerMetaInfo = new Promise((resolve) => { - provider.onProviderMetaInfo((data) => { - provider.clearProviderMetaInfoListener() - resolve(data) - }) - }) - - return withTimeout(providerMetaInfo, { - timeout: REQUEST_TIMEOUT_MS, - timeoutMessage: `Widget provider meta info request timed out after ${REQUEST_TIMEOUT_MS}ms`, - }).catch(() => { - provider.clearProviderMetaInfoListener() - return null - }) - } - - return Promise.resolve(null) -} - /** * Async atom that fetches wallet capabilities (EIP-5792) via wagmi/viem. * Returns capabilities for the current account and chain, or null when disconnected or on error. */ -// eslint-disable-next-line complexity export const walletCapabilitiesAtom = atom(async (get): Promise => { - const { account, chainId, provider, connector } = get(walletInfoAtom) - const isSafeViaWc = get(isSafeViaWcAtom) + const { account, chainId, connector } = get(walletInfoAtom) - if (!account || !chainId || !connector || !provider || isSafeViaWc === null) return null - - let capabilities: WalletCapabilities | null = null + if (!account || !chainId || !connector) return null try { - const shouldSkipCapabilitiesCheck = await getShouldSkipCapabilitiesCheck(connector, provider) - - if (shouldSkipCapabilitiesCheck) { - return null - } - - /** - * Viem takes care here of getting the capabilities for the exact chainId, so we don't need to do it manually. - * However, keep in mind this branch MUST run for Safe via WC, but getCapabilities() will throw an error. However, - * using `wallet_getCapabilities` directly does work. You can test this with: - * - * ``` - * const allCapabilities = await getCapabilities(wagmiConfig.getClient({ chainId }), { - * account: account as `0x${string}`, - * }).catch((error) => { - * console.error('Cannot fetchallCapabilities', error) - * return {} as GetCapabilitiesReturnType - * }) - * - * const capabilitiesForChain = await getCapabilities(wagmiConfig.getClient({ chainId }), { - * account: account as `0x${string}`, - * chainId, - * }).catch((error) => { - * console.error('Cannot fetch capabilitiesForChain', error) - * return {} as GetCapabilitiesReturnType - * }) - * - * const legacyCapabilities = isEip1193Provider(provider) - * ? await provider - * .request({ - * method: 'wallet_getCapabilities', - * params: [account], - * }) - * .catch((error) => { - * console.error('Cannot fetch legacyCapabilities', error) - * return {} as GetCapabilitiesReturnType - * }) - * : null - * ``` - * - * The last one should return: - * - * ```json - * { - * "0xaa36a7": { - * "atomicBatch": { - * "supported": true - * }, - * "atomic": { - * "status": "supported" - * } - * } - * } - * ``` - */ - logWallet.debug('Fetching wallet capabilities', { account, chainId }) - capabilities = await withTimeout( - getCapabilities(wagmiConfig.getClient({ chainId }), { + const capabilities = await withTimeout( + getCapabilities(wagmiConfig, { account: account as `0x${string}`, chainId, + connector, }), { timeout: REQUEST_TIMEOUT_MS, @@ -174,81 +41,45 @@ export const walletCapabilitiesAtom = atom(async (get): Promise => { const isSafeApp = get(isSafeAppAtom) - - if (isSafeApp === null) return null - - if (isSafeApp) return true - - const accountType = get(accountTypeAtom) - const isSmartContractWallet = get(isSmartContractWalletAtom) - const isSafeWallet = get(isSafeWalletAtom) const isSafeViaWc = get(isSafeViaWcAtom) - if (accountType === null || isSmartContractWallet === null || isSafeViaWc === null) return null - - // Smart accounts (ERC-4337, Coinbase Smart Wallet, EIP-7702, etc.) that are not a Safe lack the - // fallback handler mechanism TWAP requires, so we treat them as unsupported. - // Note: useIsSmartContractWallet() only detects AccountType.SMART_CONTRACT, not EIP-7702 accounts - // (which keep the same EOA address but have delegation bytecode). We check both explicitly. - if ((isSmartContractWallet || accountType === AccountType.EIP7702EOA) && !isSafeWallet && !isSafeViaWc) return false + /** + * A SafeWallet connected through SafeApp is assumed to have support. + * A SafeWallet connected through WC or any other provider needs to pass the capabilities check. + */ + if (isSafeApp) return true + if (isSafeApp === null || isSafeViaWc === null) return null const walletCapabilities = await get(walletCapabilitiesAtom) - // If `walletCapabilitiesAtom` returns `null` it's because `shouldSkipCapabilitiesCheck === true`, - // or because some kind of API empty response or error. So, if we cannot check, then we must be false, + // If `walletCapabilitiesAtom` returns `null` it's because some kind of API empty response + // or error. So, if we cannot check, then we must be false, // not null (as some components/functions like `validateTradeForm` treat `null` as loading): - if (!walletCapabilities) return false + if (walletCapabilities === null) return false - const status = walletCapabilities.atomic?.status || '' - const supported = walletCapabilities?.atomicBatch?.supported + const status = walletCapabilities.atomic?.status + const isLegacyAtomicBatchSupported = Boolean(walletCapabilities?.atomicBatch?.supported) // See https://www.eip5792.xyz/getting-started: // - supported: The wallet will execute all calls atomically and contiguously // - ready: The wallet is able to upgrade to supported pending user approval (e.g. via EIP-7702) - return status === 'supported' || !!supported - // return status === 'supported' || status === 'ready' + return status === 'supported' || status === 'ready' || isLegacyAtomicBatchSupported }) export const isAtomicBatchSupportedLoadableAtom = loadable(isAtomicBatchSupportedAsyncAtom) diff --git a/libs/wallet/src/api/types.ts b/libs/wallet/src/api/types.ts index c227ae7b967..f450ff65504 100644 --- a/libs/wallet/src/api/types.ts +++ b/libs/wallet/src/api/types.ts @@ -1,9 +1,7 @@ -import { EIP1193Provider, PublicClient } from 'viem' import { Connector as WagmiConnector } from 'wagmi' import { injected, walletConnect, coinbaseWallet, safe } from 'wagmi/connectors' import { SupportedChainId } from '@cowprotocol/cow-sdk' -import { WidgetEthereumProvider } from '@cowprotocol/iframe-transport' import type { SafeInfoResponse } from '@safe-global/api-kit' export const ConnectionType = { @@ -41,8 +39,6 @@ export interface WalletInfo { account?: string active?: boolean connector?: WagmiConnector - provider?: EIP1193Provider | WidgetEthereumProvider | PublicClient - isConnectionRestoring?: boolean } export enum WalletType { diff --git a/libs/wallet/src/wagmi/updater.ts b/libs/wallet/src/wagmi/updater.ts index 8d45df69b77..185274e2456 100644 --- a/libs/wallet/src/wagmi/updater.ts +++ b/libs/wallet/src/wagmi/updater.ts @@ -8,7 +8,6 @@ import { getCurrentChainIdFromUrl, getRawCurrentChainIdFromUrl, logSafeApi } fro import { getSafeInfo, normalizeSafeError, SAFE_RATE_LIMIT_MSG } from '@cowprotocol/core' import { SupportedChainId } from '@cowprotocol/cow-sdk' import { AccountType } from '@cowprotocol/types' -import { useWalletProvider } from '@cowprotocol/wallet-provider' import type { SafeInfoResponse } from '@safe-global/api-kit' import type { SafeInfoExtended } from '@safe-global/safe-apps-sdk' @@ -47,8 +46,7 @@ function useBrowserUrlKey(): string { function useWalletInfo(): WalletInfo { // TODO: Replace urlKey with locationNetworkAtom, which will also trigger the useMemo below less often. const urlKey = useBrowserUrlKey() - const { address, chainId, isConnected, status, connector } = useAccountState() - const isConnectionRestoring = status === 'reconnecting' + const { address, chainId, isConnected, connector } = useAccountState() const isChainIdUnsupported = !!chainId && !(chainId in SupportedChainId) const [lastStableChainId, setLastStableChainId] = useState(undefined) const [lastResolvedChainId, setLastResolvedChainId] = useState(() => getCurrentChainIdFromUrl()) @@ -84,19 +82,8 @@ function useWalletInfo(): WalletInfo { active: isConnected, account: address, connector, - isConnectionRestoring, } - }, [ - address, - chainId, - isConnected, - connector, - isChainIdUnsupported, - isConnectionRestoring, - lastStableChainId, - lastResolvedChainId, - urlKey, - ]) + }, [address, chainId, isConnected, connector, isChainIdUnsupported, lastStableChainId, lastResolvedChainId, urlKey]) useEffect(() => { setLastResolvedChainId(walletInfo.chainId) @@ -141,7 +128,7 @@ let shortSafeInfoInterval: ReturnType | null = null let longSafeInfoInterval: ReturnType | null = null export function WalletUpdater(): null { - const { chainId, active, account, connector, isConnectionRestoring } = useWalletInfo() + const { chainId, active, account, connector } = useWalletInfo() const walletDetails = useWalletDetails(account) const gnosisSafeInfo = useSafeInfo() @@ -150,11 +137,9 @@ export function WalletUpdater(): null { const setWalletDetails = useSetAtom(walletDetailsAtom) const setGnosisSafeInfo = useSetAtom(gnosisSafeInfoAtom) - const provider = useWalletProvider() - useEffect(() => { - setWalletInfo({ chainId, active, account, connector, isConnectionRestoring, provider }) - }, [chainId, active, account, connector, isConnectionRestoring, provider, setWalletInfo]) + setWalletInfo({ chainId, active, account, connector }) + }, [chainId, active, account, connector, setWalletInfo]) useEffect(() => { const walletType = getWalletType({ gnosisSafeInfo, isSmartContractWallet: walletDetails.isSmartContractWallet }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0dec49b5fa4..3eef0a76891 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2106,9 +2106,6 @@ importers: '@cowprotocol/ui': specifier: workspace:* version: link:../ui - '@cowprotocol/wallet-provider': - specifier: workspace:* - version: link:../wallet-provider '@ethereumjs/util': specifier: 10.1.0 version: 10.1.0