Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 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
e35c0b0
docs: add Solana limit orders prototype design spec
shoom3301 Jul 16, 2026
2562044
docs: add Solana limit orders implementation plan
shoom3301 Jul 16, 2026
6bcb813
chore(solana): add web3.js, spl-token and noble/hashes to cowswap-fro…
shoom3301 Jul 16, 2026
85606d2
feat(solana): order intent encoding, UID and PDA derivation
shoom3301 Jul 16, 2026
35e1cc2
feat(solana): create-order instruction building
shoom3301 Jul 16, 2026
0e3d602
feat(solana): solanaOrderFlow service sending the create-order tx
shoom3301 Jul 16, 2026
96c2f77
feat(solana): limit orders Solana flow context hook
shoom3301 Jul 16, 2026
87c29bc
feat(solana): add Solana trade buttons for limit orders
shoom3301 Jul 16, 2026
c39f536
feat(solana): skip quote polling for solana sell tokens
shoom3301 Jul 16, 2026
c2d37c0
fix(solana): tolerate block-height-exceeded on order confirmation
shoom3301 Jul 16, 2026
94dd26d
fix(solana): confirm order by polling signature status
shoom3301 Jul 16, 2026
3705588
chore(i18n): extract i18n strings [automatic]
cowswap-release-sync[bot] Jul 16, 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
4 changes: 4 additions & 0 deletions apps/cowswap-frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,12 @@
"@lingui/macro": "5.5.1",
"@lingui/react": "5.5.1",
"@marsidev/react-turnstile": "1.4.2",
"@noble/hashes": "1.4.0",
"@reach/dialog": "0.18.0",
"@reach/menu-button": "0.18.0",
"@react-spring/web": "9.7.3",
"@reduxjs/toolkit": "1.9.5",
"@reown/appkit-adapter-solana": "1.8.19",
"@reown/appkit-adapter-wagmi": "1.8.19",
"@reown/appkit-controllers": "1.8.19",
"@reown/appkit": "1.8.19",
Expand All @@ -79,6 +81,8 @@
"@sentry/browser": "7.80.0",
"@sentry/react": "7.80.0",
"@sentry/types": "7.80.0",
"@solana/spl-token": "0.4.14",
"@solana/web3.js": "1.98.4",
"@tanstack/query-core": "5.90.20",
"@tanstack/react-query": "5.90.20",
"@tanstack/react-virtual": "3.13.12",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { ConfirmationModal as Pure, ConfirmationModalProps } from 'common/pure/C
// TODO: Add proper return type annotation
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export function ConfirmationModal({ isOpen, onDismiss }: Pick<ConfirmationModalProps, 'isOpen' | 'onDismiss'>) {
const { title, callToAction, description, onEnable, warning, confirmWord, action, skipInput } =
const { title, callToAction, description, onEnable, warning, confirmWord, action, skipInput, bottomContent } =
useAtomValue(confirmationModalContextAtom)

return (
Expand All @@ -23,6 +23,7 @@ export function ConfirmationModal({ isOpen, onDismiss }: Pick<ConfirmationModalP
confirmWord={confirmWord === DEFAULT_CONFIRMATION_MODAL_CONTEXT.confirmWord ? t`confirm` : confirmWord}
action={action}
skipInput={skipInput}
bottomContent={bottomContent}
/>
)
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { atom, useSetAtom } from 'jotai'
import { atomWithReset, useResetAtom } from 'jotai/utils'
import { useCallback } from 'react'
import { ReactNode, useCallback } from 'react'

import { Command } from '@cowprotocol/types'

Expand All @@ -14,12 +14,13 @@ interface ConfirmationModalContext {
activePromise?: Promise<boolean>
title: string
callToAction: string
description?: string
description?: ReactNode
warning?: string
confirmWord: string
action: string
onEnable: Command
skipInput?: boolean
bottomContent?: ReactNode
triggerConfirmation: ({
title,
description,
Expand All @@ -30,7 +31,7 @@ interface ConfirmationModalContext {
}
type TriggerConfirmationParams = Pick<
ConfirmationModalProps,
'title' | 'description' | 'callToAction' | 'warning' | 'confirmWord' | 'action' | 'skipInput'
'title' | 'description' | 'callToAction' | 'warning' | 'confirmWord' | 'action' | 'skipInput' | 'bottomContent'
>

export const DEFAULT_CONFIRMATION_MODAL_CONTEXT: ConfirmationModalContext = {
Expand Down
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 { CrossChainFamilySwitchState, 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 NOT_CROSSING_CHAIN for a same-family change without prompting', async () => {
setWallet(SupportedChainId.MAINNET, '0xConnected')
const { result } = renderHook(() => useCrossChainFamilySwitch())

let handled: CrossChainFamilySwitchState | undefined
await act(async () => {
handled = await result.current(SupportedChainId.ARBITRUM_ONE)
})

expect(handled).toBe(CrossChainFamilySwitchState.NOT_CROSSING_CHAIN)
expect(triggerConfirmation).not.toHaveBeenCalled()
expect(disconnectWallet).not.toHaveBeenCalled()
})

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

let handled: CrossChainFamilySwitchState | undefined
await act(async () => {
handled = await result.current(SupportedChainId.SOLANA)
})

expect(handled).toBe(CrossChainFamilySwitchState.WALLET_NOT_CONNECTED)
expect(triggerConfirmation).not.toHaveBeenCalled()
expect(disconnectWallet).not.toHaveBeenCalled()
})

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

let handled: CrossChainFamilySwitchState | undefined
await act(async () => {
handled = await result.current(SupportedChainId.SOLANA)
})

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

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

let handled: CrossChainFamilySwitchState | undefined
await act(async () => {
handled = await result.current(SupportedChainId.SOLANA)
})

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

it('returns DISCONNECT_FAILED and 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: CrossChainFamilySwitchState | undefined
await act(async () => {
handled = await result.current(SupportedChainId.SOLANA)
})

expect(handled).toBe(CrossChainFamilySwitchState.DISCONNECT_FAILED)
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()
})
})
117 changes: 117 additions & 0 deletions apps/cowswap-frontend/src/common/hooks/useCrossChainFamilySwitch.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { useCallback } from 'react'

import { CHAIN_INFO } from '@cowprotocol/common-const'
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 { Trans } from '@lingui/react/macro'

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

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

export enum CrossChainFamilySwitchState {
WALLET_NOT_CONNECTED = 'WALLET_NOT_CONNECTED',
NOT_CROSSING_CHAIN = 'NOT_CROSSING_CHAIN',
NOT_CONFIRMED = 'NOT_CONFIRMED',
DISCONNECT_FAILED = 'DISCONNECT_FAILED',
FINISHED = 'FINISHED',
}

/**
* 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<CrossChainFamilySwitchState> {
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 (targetChainId: SupportedChainId, skipClose?: boolean) => {
const isWalletConnected = !!account
const crossingChainFamily = !isSameChainFamily(currentChainId, targetChainId)

if (!crossingChainFamily) {
return CrossChainFamilySwitchState.NOT_CROSSING_CHAIN
}

if (!isWalletConnected) {
return CrossChainFamilySwitchState.WALLET_NOT_CONNECTED
}

const sourceChainLabel = CHAIN_INFO[currentChainId].label
const targetChainLabel = CHAIN_INFO[targetChainId].label

const confirmed = await triggerConfirmation({
confirmWord: t`confirm`,
title: t`Switching network type`,
description: (
<span>
<Trans>
You're switching from {sourceChainLabel} to {targetChainLabel}.
</Trans>
<br />
<Trans>This requires connecting a different wallet.</Trans>
<br />
<Trans>Your current wallet will be disconnected.</Trans>
</span>
),
action: t`switch network type`,
callToAction: t`Connect wallet`,
skipInput: true,
bottomContent: null,
})

if (!confirmed) {
return CrossChainFamilySwitchState.NOT_CONFIRMED
}

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 CrossChainFamilySwitchState.DISCONNECT_FAILED
}

setChainIdToUrl(targetChainId)
openWalletConnectionModal()

if (!skipClose) {
closeModal()
}

return CrossChainFamilySwitchState.FINISHED
},
[
account,
currentChainId,
triggerConfirmation,
disconnectWallet,
openWalletConnectionModal,
setChainIdToUrl,
closeModal,
],
)
}
Loading
Loading