Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
ef14f70
fix: switch network through reown
shoom3301 Jul 13, 2026
4789ba4
docs: design for EVM/non-EVM network switching flow
shoom3301 Jul 13, 2026
a9ad910
feat(solana): switch between EVM/non-EVM chains
shoom3301 Jul 13, 2026
fdbc00b
chore(i18n): extract i18n strings [automatic]
cowswap-release-sync[bot] Jul 13, 2026
dde49f1
fix: support non-EVM network switching from URL
shoom3301 Jul 13, 2026
c97af95
fix: handle wallet disconnect error
shoom3301 Jul 13, 2026
11e5ce3
chore: rename test
shoom3301 Jul 14, 2026
2a8d3ee
feat(solana): load token balances
shoom3301 Jul 14, 2026
f249cde
Merge branch 'develop' of https://github.com/cowprotocol/cowswap into…
shoom3301 Jul 14, 2026
e9e29e2
fix: confirm cross-chain switch on currency select
shoom3301 Jul 14, 2026
1db89fb
chore: update text
shoom3301 Jul 14, 2026
755d8d3
Merge branch 'develop' into solana/web-1
fairlighteth Jul 14, 2026
b1d4de1
chore: rename text
shoom3301 Jul 14, 2026
2306273
chore: ff IS_SOLANA_ENABLED in URL
shoom3301 Jul 14, 2026
0a6d43d
chore: ff IS_SOLANA_ENABLED in URL
shoom3301 Jul 14, 2026
6dd1ff6
Merge branch 'develop' of https://github.com/cowprotocol/cowswap into…
shoom3301 Jul 14, 2026
bb85f30
Merge remote-tracking branch 'origin/solana/web-1' into solana/web-1
shoom3301 Jul 14, 2026
855a751
chore(i18n): extract i18n strings [automatic]
cowswap-release-sync[bot] Jul 14, 2026
079e0f0
chore: update confirm text
shoom3301 Jul 15, 2026
0cdca9a
fix: disable bridge from Solana
shoom3301 Jul 15, 2026
e0fd749
fix: update text
shoom3301 Jul 15, 2026
1fce32b
chore(i18n): extract i18n strings [automatic]
cowswap-release-sync[bot] Jul 15, 2026
72254aa
fix: fix network switching
shoom3301 Jul 15, 2026
f1d582d
Merge remote-tracking branch 'origin/solana/web-1' into solana/web-1
shoom3301 Jul 15, 2026
f17495f
fix: fix network switching
shoom3301 Jul 15, 2026
5c916cd
Merge branch 'solana/web-1' of https://github.com/cowprotocol/cowswap…
shoom3301 Jul 15, 2026
a3e8e29
feat(solana): support token2022
shoom3301 Jul 15, 2026
8144ef2
fix: capture network switch error
shoom3301 Jul 15, 2026
e7c453a
fix: fix account.toLowerCase
shoom3301 Jul 15, 2026
a4c85c9
fix: isolates malformed mint
shoom3301 Jul 15, 2026
68c3211
Merge branch 'solana/web-1' of https://github.com/cowprotocol/cowswap…
shoom3301 Jul 15, 2026
e624ae6
chore: add solana default RPC
shoom3301 Jul 15, 2026
8d440d7
fix(solana): fix recent wallet reconnect
shoom3301 Jul 15, 2026
27bff7d
fix: fix solana dedupe
shoom3301 Jul 15, 2026
979400f
fix: fix build
shoom3301 Jul 15, 2026
72dd7d8
chore: remove DISABLED_SOURCE_CHAINS
shoom3301 Jul 16, 2026
cbd9c2e
chore: fix solana bridge
shoom3301 Jul 16, 2026
eaf2e51
Merge branch 'solana/web-1' of https://github.com/cowprotocol/cowswap…
shoom3301 Jul 16, 2026
d0e58b0
Merge remote-tracking branch 'origin/solana/web-2' into solana/web-2
shoom3301 Jul 16, 2026
49150e3
Merge branch 'develop' into solana/web-2
shoom3301 Jul 17, 2026
65b0b61
chore: update comment
shoom3301 Jul 17, 2026
4c9078d
chore: add refetchOnMount
shoom3301 Jul 17, 2026
a228233
chore: add try catch
shoom3301 Jul 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { SupportedChainId } from '@cowprotocol/cow-sdk'
import { useDisconnectWallet, useOpenWalletConnectionModal, useWalletInfo } from '@cowprotocol/wallet'

import { act, renderHook } from '@testing-library/react'

import { useCloseModal } from 'legacy/state/application/hooks'

import { useConfirmationRequest } from './useConfirmationRequest'
import { useCrossChainFamilySwitch } from './useCrossChainFamilySwitch'
import { useLegacySetChainIdToUrl } from './useLegacySetChainIdToUrl'

jest.mock('@cowprotocol/wallet')
jest.mock('./useConfirmationRequest')
jest.mock('./useLegacySetChainIdToUrl')
jest.mock('legacy/state/application/hooks')

const mockedUseWalletInfo = useWalletInfo as jest.MockedFunction<typeof useWalletInfo>
const mockedUseDisconnectWallet = useDisconnectWallet as jest.MockedFunction<typeof useDisconnectWallet>
const mockedUseOpenWalletConnectionModal = useOpenWalletConnectionModal as jest.MockedFunction<
typeof useOpenWalletConnectionModal
>
const mockedUseConfirmationRequest = useConfirmationRequest as jest.MockedFunction<typeof useConfirmationRequest>
const mockedUseLegacySetChainIdToUrl = useLegacySetChainIdToUrl as jest.MockedFunction<typeof useLegacySetChainIdToUrl>
const mockedUseCloseModal = useCloseModal as jest.MockedFunction<typeof useCloseModal>

describe('useCrossChainFamilySwitch', () => {
let disconnectWallet: jest.Mock
let openWalletConnectionModal: jest.Mock
let setChainIdToUrl: jest.Mock
let closeModal: jest.Mock
let triggerConfirmation: jest.Mock

function setWallet(chainId: SupportedChainId, account: string | undefined): void {
mockedUseWalletInfo.mockReturnValue({ chainId, account } as ReturnType<typeof useWalletInfo>)
}

beforeEach(() => {
jest.clearAllMocks()

disconnectWallet = jest.fn().mockResolvedValue(undefined)
openWalletConnectionModal = jest.fn()
setChainIdToUrl = jest.fn()
closeModal = jest.fn()
triggerConfirmation = jest.fn().mockResolvedValue(true)

mockedUseDisconnectWallet.mockReturnValue(disconnectWallet)
mockedUseOpenWalletConnectionModal.mockReturnValue(openWalletConnectionModal)
mockedUseLegacySetChainIdToUrl.mockReturnValue(setChainIdToUrl)
mockedUseConfirmationRequest.mockReturnValue(triggerConfirmation)
mockedUseCloseModal.mockReturnValue(closeModal)

setWallet(SupportedChainId.MAINNET, '0xConnected')
})

it('returns false for a same-family change without prompting', async () => {
setWallet(SupportedChainId.MAINNET, '0xConnected')
const { result } = renderHook(() => useCrossChainFamilySwitch())

let handled = true
await act(async () => {
handled = await result.current(SupportedChainId.ARBITRUM_ONE)
})

expect(handled).toBe(false)
expect(triggerConfirmation).not.toHaveBeenCalled()
expect(disconnectWallet).not.toHaveBeenCalled()
})

it('returns false for a cross-family change when no wallet is connected', async () => {
setWallet(SupportedChainId.MAINNET, undefined)
const { result } = renderHook(() => useCrossChainFamilySwitch())

let handled = true
await act(async () => {
handled = await result.current(SupportedChainId.SOLANA)
})

expect(handled).toBe(false)
expect(triggerConfirmation).not.toHaveBeenCalled()
expect(disconnectWallet).not.toHaveBeenCalled()
})

it('confirms, disconnects, opens the connect modal and returns true for a cross-family change when connected', async () => {
setWallet(SupportedChainId.MAINNET, '0xConnected')
triggerConfirmation.mockResolvedValue(true)
const { result } = renderHook(() => useCrossChainFamilySwitch())

let handled = false
await act(async () => {
handled = await result.current(SupportedChainId.SOLANA)
})

expect(handled).toBe(true)
expect(triggerConfirmation).toHaveBeenCalledWith(expect.objectContaining({ skipInput: true }))
expect(setChainIdToUrl).toHaveBeenCalledWith(SupportedChainId.SOLANA)
expect(disconnectWallet).toHaveBeenCalled()
expect(openWalletConnectionModal).toHaveBeenCalled()
expect(closeModal).toHaveBeenCalled()
})

it('returns true but does nothing else when the user cancels', async () => {
setWallet(SupportedChainId.MAINNET, '0xConnected')
triggerConfirmation.mockResolvedValue(false)
const { result } = renderHook(() => useCrossChainFamilySwitch())

let handled = false
await act(async () => {
handled = await result.current(SupportedChainId.SOLANA)
})

expect(handled).toBe(true)
expect(disconnectWallet).not.toHaveBeenCalled()
expect(openWalletConnectionModal).not.toHaveBeenCalled()
expect(closeModal).not.toHaveBeenCalled()
})

it('leaves the URL unchanged when the disconnect fails', async () => {
setWallet(SupportedChainId.MAINNET, '0xConnected')
triggerConfirmation.mockResolvedValue(true)
disconnectWallet.mockRejectedValue(new Error('disconnect failed'))
const { result } = renderHook(() => useCrossChainFamilySwitch())

let handled = false
await act(async () => {
handled = await result.current(SupportedChainId.SOLANA)
})

expect(handled).toBe(true)
expect(setChainIdToUrl).not.toHaveBeenCalled()
expect(openWalletConnectionModal).not.toHaveBeenCalled()
expect(closeModal).not.toHaveBeenCalled()
})

it('keeps the network selector open when skipClose is passed', async () => {
setWallet(SupportedChainId.MAINNET, '0xConnected')
triggerConfirmation.mockResolvedValue(true)
const { result } = renderHook(() => useCrossChainFamilySwitch())

await act(async () => {
await result.current(SupportedChainId.SOLANA, true)
})

expect(disconnectWallet).toHaveBeenCalled()
expect(closeModal).not.toHaveBeenCalled()
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { useCallback } from 'react'

import { isSameChainFamily } from '@cowprotocol/common-utils'
import { SupportedChainId } from '@cowprotocol/cow-sdk'
import { useDisconnectWallet, useOpenWalletConnectionModal, useWalletInfo } from '@cowprotocol/wallet'

import { t } from '@lingui/core/macro'

import { useCloseModal } from 'legacy/state/application/hooks'
import { ApplicationModal } from 'legacy/state/application/reducer'

import { useConfirmationRequest } from './useConfirmationRequest'
import { useLegacySetChainIdToUrl } from './useLegacySetChainIdToUrl'

/**
* Handles switching to a network that belongs to a different chain family than the
* currently connected wallet (EVM ↔ non-EVM, e.g. Ethereum → Solana).
*
* These families use different wallets, so we can't hot-swap the chain. Instead we
* confirm the user's intent, disconnect the current wallet and open the connection
* modal so they can connect a wallet compatible with the target network.
*
* Returns a function that resolves to `true` when it took over the switch (target
* crosses the family boundary while a wallet is connected — whether the user then
* confirmed or cancelled), and `false` when the caller should perform a regular
* same-family network switch.
*/
export function useCrossChainFamilySwitch(): (chainId: SupportedChainId, skipClose?: boolean) => Promise<boolean> {
const { chainId: currentChainId, account } = useWalletInfo()
const closeModal = useCloseModal(ApplicationModal.NETWORK_SELECTOR)
const setChainIdToUrl = useLegacySetChainIdToUrl()
const disconnectWallet = useDisconnectWallet()
const openWalletConnectionModal = useOpenWalletConnectionModal()
const triggerConfirmation = useConfirmationRequest({})

return useCallback(
async (targetChain: SupportedChainId, skipClose?: boolean) => {
const isWalletConnected = !!account
const crossingChainFamily = !isSameChainFamily(currentChainId, targetChain)

if (!isWalletConnected || !crossingChainFamily) {
return false
}

const confirmed = await triggerConfirmation({
confirmWord: t`confirm`,
title: t`Switching network type`,
description: t`You're switching between EVM and non-EVM networks. This requires connecting a different wallet. Your current wallet will be disconnected. Are you sure?`,
action: t`switch network type`,
callToAction: t`Confirm`,
skipInput: true,
})

if (!confirmed) {
return true
}

try {
// Only change the URL after the wallet is actually disconnected, so URL and wallet
// stay in sync if the disconnect fails.
await disconnectWallet()
} catch (error) {
console.error('Failed to disconnect wallet while switching network type', error)
return true
}

setChainIdToUrl(targetChain)
openWalletConnectionModal()

if (!skipClose) {
closeModal()
}

return true
},
[
account,
currentChainId,
triggerConfirmation,
disconnectWallet,
openWalletConnectionModal,
setChainIdToUrl,
closeModal,
],
)
}
69 changes: 69 additions & 0 deletions apps/cowswap-frontend/src/common/hooks/useOnSelectNetwork.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { SupportedChainId } from '@cowprotocol/cow-sdk'
import { useSwitchNetwork } from '@cowprotocol/wallet'

import { act, renderHook } from '@testing-library/react'

import { useCloseModal } from 'legacy/state/application/hooks'

import { useCrossChainFamilySwitch } from './useCrossChainFamilySwitch'
import { useLegacySetChainIdToUrl } from './useLegacySetChainIdToUrl'
import { useOnSelectNetwork } from './useOnSelectNetwork'

jest.mock('@cowprotocol/wallet')
jest.mock('@cowprotocol/snackbars', () => ({ useAddSnackbar: () => jest.fn() }))
jest.mock('./useCrossChainFamilySwitch')
jest.mock('./useLegacySetChainIdToUrl')
jest.mock('legacy/state/application/hooks')

const mockedUseSwitchNetwork = useSwitchNetwork as jest.MockedFunction<typeof useSwitchNetwork>
const mockedUseCrossChainFamilySwitch = useCrossChainFamilySwitch as jest.MockedFunction<
typeof useCrossChainFamilySwitch
>
const mockedUseLegacySetChainIdToUrl = useLegacySetChainIdToUrl as jest.MockedFunction<typeof useLegacySetChainIdToUrl>
const mockedUseCloseModal = useCloseModal as jest.MockedFunction<typeof useCloseModal>

describe('useOnSelectNetwork', () => {
let switchNetwork: jest.Mock
let setChainIdToUrl: jest.Mock
let handleCrossChainFamilySwitch: jest.Mock

beforeEach(() => {
jest.clearAllMocks()

switchNetwork = jest.fn().mockResolvedValue(undefined)
setChainIdToUrl = jest.fn()
// By default the switch does not cross a chain family, so the regular path runs.
handleCrossChainFamilySwitch = jest.fn().mockResolvedValue(false)

mockedUseSwitchNetwork.mockReturnValue(switchNetwork)
mockedUseCrossChainFamilySwitch.mockReturnValue(handleCrossChainFamilySwitch)
mockedUseLegacySetChainIdToUrl.mockReturnValue(setChainIdToUrl)
mockedUseCloseModal.mockReturnValue(jest.fn())
})

it('performs a regular network switch when the change stays within the same chain family', async () => {
handleCrossChainFamilySwitch.mockResolvedValue(false)
const { result } = renderHook(() => useOnSelectNetwork())

await act(async () => {
await result.current(SupportedChainId.ARBITRUM_ONE)
})

expect(handleCrossChainFamilySwitch).toHaveBeenCalledWith(SupportedChainId.ARBITRUM_ONE, undefined)
expect(switchNetwork).toHaveBeenCalledWith(SupportedChainId.ARBITRUM_ONE)
expect(setChainIdToUrl).toHaveBeenCalledWith(SupportedChainId.ARBITRUM_ONE)
})

it('delegates to the cross-family flow and short-circuits when it handles the switch', async () => {
handleCrossChainFamilySwitch.mockResolvedValue(true)
const { result } = renderHook(() => useOnSelectNetwork())

await act(async () => {
await result.current(SupportedChainId.SOLANA)
})

expect(handleCrossChainFamilySwitch).toHaveBeenCalledWith(SupportedChainId.SOLANA, undefined)
expect(switchNetwork).not.toHaveBeenCalled()
expect(setChainIdToUrl).not.toHaveBeenCalled()
})
})
18 changes: 10 additions & 8 deletions apps/cowswap-frontend/src/common/hooks/useOnSelectNetwork.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/* eslint-disable @typescript-eslint/no-restricted-imports */ // TODO: Don't use 'modules' import
// TODO: Don't use 'modules' import
import { useCallback } from 'react'

import { getChainInfo } from '@cowprotocol/common-const'
Expand All @@ -12,21 +12,25 @@ import { Trans } from '@lingui/react/macro'
import { useCloseModal } from 'legacy/state/application/hooks'
import { ApplicationModal } from 'legacy/state/application/reducer'

import { useSetWalletConnectionError } from 'modules/wallet/hooks/useSetWalletConnectionError'

import { useCrossChainFamilySwitch } from './useCrossChainFamilySwitch'
import { useLegacySetChainIdToUrl } from './useLegacySetChainIdToUrl'

export function useOnSelectNetwork(): (chainId: SupportedChainId, skipClose?: boolean) => Promise<void> {
const addSnackbar = useAddSnackbar()
const closeModal = useCloseModal(ApplicationModal.NETWORK_SELECTOR)
const setChainIdToUrl = useLegacySetChainIdToUrl()
const setWalletConnectionError = useSetWalletConnectionError()
const switchNetwork = useSwitchNetwork()
const handleCrossChainFamilySwitch = useCrossChainFamilySwitch()

return useCallback(
async (targetChain: SupportedChainId, skipClose?: boolean) => {
// Switching between EVM and non-EVM networks requires a different wallet and is handled
// separately (confirm + disconnect + reconnect) instead of a regular network switch.
if (await handleCrossChainFamilySwitch(targetChain, skipClose)) {
return
}

try {
setWalletConnectionError(undefined)
await switchNetwork(targetChain)

setChainIdToUrl(targetChain)
Expand All @@ -52,14 +56,12 @@ export function useOnSelectNetwork(): (chainId: SupportedChainId, skipClose?: bo
</Trans>
),
})

setWalletConnectionError(error.message)
}

if (!skipClose) {
closeModal()
}
},
[switchNetwork, setWalletConnectionError, addSnackbar, closeModal, setChainIdToUrl],
[handleCrossChainFamilySwitch, switchNetwork, addSnackbar, closeModal, setChainIdToUrl],
)
}
Loading
Loading