Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
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
1 change: 1 addition & 0 deletions apps/cowswap-frontend/src/entities/bridgeProvider/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { useBridgeSupportedNetworks, useBridgeSupportedNetwork } from './useBridgeSupportedNetworks'
export { useBridgeSupportedTokens } from './useBridgeSupportedTokens'
export { useRoutesAvailability } from './useRoutesAvailability'
export { BridgeProvidersUpdater } from './BridgeProvidersUpdater'
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { useMemo } from 'react'

import { SWR_NO_REFRESH_OPTIONS } from '@cowprotocol/common-const'
import { useIsBridgingEnabled } from '@cowprotocol/common-hooks'
import { SupportedChainId } from '@cowprotocol/cow-sdk'

import useSWR from 'swr'
import { bridgingSdk } from 'tradingSdk/bridgingSdk'

import { useBridgeProvidersIds } from './useBridgeProvidersIds'

export interface RoutesAvailabilityResult {
unavailableChainIds: Set<number>
loadingChainIds: Set<number>
isLoading: boolean
}

const EMPTY_RESULT: RoutesAvailabilityResult = {
unavailableChainIds: new Set(),
loadingChainIds: new Set(),
isLoading: false,
}

interface RouteCheckResult {
chainId: number
isAvailable: boolean
}

/**
* Pre-checks route availability for multiple destination chains from a source chain.
* Returns which chains have unavailable routes and which are still loading.
*
* Note: Fires parallel requests for all destination chains without throttling.
* This is acceptable because:
* - SWR caches results, so repeated opens don't re-fetch
* - Chain count is limited (~10-15 max)
* - Requests are lightweight (token existence checks)
* If this becomes a bottleneck, consider batching or sequential fetching.
*/
export function useRoutesAvailability(
sourceChainId: SupportedChainId | undefined,
destinationChainIds: number[],
): RoutesAvailabilityResult {
const isBridgingEnabled = useIsBridgingEnabled()
const providerIds = useBridgeProvidersIds()
const providersKey = providerIds.join('|')

// Filter out the source chain (same-chain swaps are always available)
const chainsToCheck = useMemo(
() => destinationChainIds.filter((id) => id !== sourceChainId),
[destinationChainIds, sourceChainId],
)

// Create a stable key for the SWR request
const swrKey = useMemo(() => {
if (!isBridgingEnabled || !sourceChainId || chainsToCheck.length === 0) {
return null
}
return [sourceChainId, chainsToCheck.sort().join(','), providersKey, 'useRoutesAvailability']
}, [isBridgingEnabled, sourceChainId, chainsToCheck, providersKey])

const { data, isLoading } = useSWR<RouteCheckResult[]>(
swrKey,
async (key) => {
const [sellChainId, chainIdsString] = key as [SupportedChainId, string, string, string]
const chainIds = chainIdsString.split(',').map(Number)

// Check routes in parallel for all destination chains
const results = await Promise.all(
chainIds.map(async (buyChainId: number): Promise<RouteCheckResult> => {
try {
const result = await bridgingSdk.getBuyTokens({ sellChainId, buyChainId })
const isAvailable = result.tokens.length > 0 && result.isRouteAvailable
return { chainId: buyChainId, isAvailable }
} catch (error) {
console.warn(`[useRoutesAvailability] Failed to check route ${sellChainId} -> ${buyChainId}`, error)
// Treat errors as unavailable routes
return { chainId: buyChainId, isAvailable: false }
}
}),
)

return results
},
SWR_NO_REFRESH_OPTIONS,
)

return useMemo(() => {
if (!swrKey) {
return EMPTY_RESULT
}

if (isLoading || !data) {
// While loading, mark all chains being checked as loading
return {
unavailableChainIds: new Set(),
loadingChainIds: new Set(chainsToCheck),
isLoading: true,
}
}

const unavailableChainIds = new Set<number>(
data.filter((result) => !result.isAvailable).map((result) => result.chainId),
)

return {
unavailableChainIds,
loadingChainIds: new Set(),
isLoading: false,
}
}, [swrKey, isLoading, data, chainsToCheck])
}
4 changes: 4 additions & 0 deletions apps/cowswap-frontend/src/locales/es-ES.po
Original file line number Diff line number Diff line change
Expand Up @@ -6388,3 +6388,7 @@ msgstr "Ver todas las {totalChains} redes"
#: apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/MobileChainSelector.tsx
msgid "Selected network {activeChainLabel}"
msgstr "Red seleccionada {activeChainLabel}"

#: apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx
msgid "This destination is not supported for this source chain"
msgstr "Este destino no es compatible con esta red de origen"
4 changes: 4 additions & 0 deletions apps/cowswap-frontend/src/locales/ru-RU.po
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"Сеть назначения не доступна для выбранной исходной сети"

Original file line number Diff line number Diff line change
Expand Up @@ -6388,3 +6388,7 @@ msgstr "Показать все сети ({totalChains})"
#: apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/MobileChainSelector.tsx
msgid "Selected network {activeChainLabel}"
msgstr "Выбранная сеть {activeChainLabel}"

#: apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx
msgid "This destination is not supported for this source chain"
msgstr "Сеть назначения не доступна для выбранной исходной сети"
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ import { useSelectTokenWidgetState } from './useSelectTokenWidgetState'
import { DEFAULT_SELECT_TOKEN_WIDGET_STATE } from '../state/selectTokenWidgetAtom'
import { createChainInfoForTests } from '../test-utils/createChainInfoForTests'

// Default routes availability for tests (no unavailable routes, not loading)
const DEFAULT_ROUTES_AVAILABILITY = {
unavailableChainIds: new Set<number>(),
loadingChainIds: new Set<number>(),
isLoading: false,
}

jest.mock('@cowprotocol/wallet', () => ({
...jest.requireActual('@cowprotocol/wallet'),
useWalletInfo: jest.fn(),
Expand All @@ -28,6 +35,7 @@ jest.mock('@cowprotocol/common-hooks', () => ({
jest.mock('entities/bridgeProvider', () => ({
...jest.requireActual('entities/bridgeProvider'),
useBridgeSupportedNetworks: jest.fn(),
useRoutesAvailability: jest.fn(),
}))

jest.mock('./useSelectTokenWidgetState', () => ({
Expand All @@ -43,10 +51,11 @@ const mockUseIsBridgingEnabled = useIsBridgingEnabled as jest.MockedFunction<typ
const mockUseAvailableChains = useAvailableChains as jest.MockedFunction<typeof useAvailableChains>
const mockUseFeatureFlags = useFeatureFlags as jest.MockedFunction<typeof useFeatureFlags>

const { useBridgeSupportedNetworks } = require('entities/bridgeProvider')
const { useBridgeSupportedNetworks, useRoutesAvailability } = require('entities/bridgeProvider')
const mockUseBridgeSupportedNetworks = useBridgeSupportedNetworks as jest.MockedFunction<
typeof useBridgeSupportedNetworks
>
const mockUseRoutesAvailability = useRoutesAvailability as jest.MockedFunction<typeof useRoutesAvailability>

type WidgetState = ReturnType<typeof useSelectTokenWidgetState>
const createWidgetState = (override: Partial<typeof DEFAULT_SELECT_TOKEN_WIDGET_STATE>): WidgetState => {
Expand All @@ -73,23 +82,29 @@ describe('useChainsToSelect state builders', () => {
])
})

it('sorts bridge destination chains to match the canonical order', () => {
const bridgeChains = [
it('sorts BUY chains using the canonical order and returns all supportedChains', () => {
const supportedChains = [
createChainInfoForTests(SupportedChainId.AVALANCHE),
createChainInfoForTests(SupportedChainId.BASE),
createChainInfoForTests(SupportedChainId.ARBITRUM_ONE),
createChainInfoForTests(SupportedChainId.MAINNET),
]
const bridgeChains = [
createChainInfoForTests(SupportedChainId.MAINNET),
createChainInfoForTests(SupportedChainId.BASE),
]

const state = createOutputChainsState({
selectedTargetChainId: SupportedChainId.POLYGON,
selectedTargetChainId: SupportedChainId.BASE,
chainId: SupportedChainId.MAINNET,
currentChainInfo: createChainInfoForTests(SupportedChainId.MAINNET),
bridgeSupportedNetworks: bridgeChains,
areUnsupportedChainsEnabled: true,
supportedChains,
isLoading: false,
routesAvailability: DEFAULT_ROUTES_AVAILABILITY,
})

// Should return all supportedChains, sorted by canonical order
expect((state.chains ?? []).map((chain) => chain.id)).toEqual([
SupportedChainId.MAINNET,
SupportedChainId.BASE,
Expand All @@ -98,21 +113,172 @@ describe('useChainsToSelect state builders', () => {
])
})

it('falls back to wallet chain when bridge does not support the source chain', () => {
it('disables chains not in bridge destinations when source is bridge-supported', () => {
const supportedChains = [
createChainInfoForTests(SupportedChainId.MAINNET),
createChainInfoForTests(SupportedChainId.BASE),
createChainInfoForTests(SupportedChainId.ARBITRUM_ONE),
createChainInfoForTests(SupportedChainId.AVALANCHE),
]
const bridgeChains = [
createChainInfoForTests(SupportedChainId.MAINNET),
createChainInfoForTests(SupportedChainId.BASE),
]

const state = createOutputChainsState({
selectedTargetChainId: SupportedChainId.BASE,
chainId: SupportedChainId.SEPOLIA,
chainId: SupportedChainId.MAINNET,
currentChainInfo: createChainInfoForTests(SupportedChainId.MAINNET),
bridgeSupportedNetworks: bridgeChains,
supportedChains,
isLoading: false,
routesAvailability: DEFAULT_ROUTES_AVAILABILITY,
})

// Source (Mainnet) and Base are bridge-supported, others should be disabled
expect(state.disabledChainIds?.has(SupportedChainId.MAINNET)).toBeFalsy()
expect(state.disabledChainIds?.has(SupportedChainId.BASE)).toBeFalsy()
expect(state.disabledChainIds?.has(SupportedChainId.ARBITRUM_ONE)).toBe(true)
expect(state.disabledChainIds?.has(SupportedChainId.AVALANCHE)).toBe(true)
})

it('disables all chains except source when source is not bridge-supported', () => {
const supportedChains = [
createChainInfoForTests(SupportedChainId.MAINNET),
createChainInfoForTests(SupportedChainId.ARBITRUM_ONE),
createChainInfoForTests(SupportedChainId.SEPOLIA),
]
const bridgeChains = [
createChainInfoForTests(SupportedChainId.MAINNET),
createChainInfoForTests(SupportedChainId.ARBITRUM_ONE),
]

const state = createOutputChainsState({
selectedTargetChainId: SupportedChainId.BASE,
chainId: SupportedChainId.SEPOLIA, // Sepolia not in bridge destinations
currentChainInfo: createChainInfoForTests(SupportedChainId.SEPOLIA),
bridgeSupportedNetworks: [
createChainInfoForTests(SupportedChainId.MAINNET),
createChainInfoForTests(SupportedChainId.ARBITRUM_ONE),
],
areUnsupportedChainsEnabled: true,
bridgeSupportedNetworks: bridgeChains,
supportedChains,
isLoading: false,
routesAvailability: DEFAULT_ROUTES_AVAILABILITY,
})

// Default to source chain when the selected target isn't available
expect(state.defaultChainId).toBe(SupportedChainId.SEPOLIA)
expect(state.chains?.map((chain) => chain.id)).toEqual([SupportedChainId.SEPOLIA])
// Should return all supportedChains
expect(state.chains?.map((chain) => chain.id)).toEqual([
SupportedChainId.MAINNET,
SupportedChainId.ARBITRUM_ONE,
SupportedChainId.SEPOLIA,
])
// All chains except source should be disabled
expect(state.disabledChainIds?.has(SupportedChainId.MAINNET)).toBe(true)
expect(state.disabledChainIds?.has(SupportedChainId.ARBITRUM_ONE)).toBe(true)
expect(state.disabledChainIds?.has(SupportedChainId.SEPOLIA)).toBeFalsy()
})

it('falls back to source when selected target is disabled', () => {
const supportedChains = [
createChainInfoForTests(SupportedChainId.MAINNET),
createChainInfoForTests(SupportedChainId.BASE),
createChainInfoForTests(SupportedChainId.AVALANCHE),
]
const bridgeChains = [
createChainInfoForTests(SupportedChainId.MAINNET),
createChainInfoForTests(SupportedChainId.BASE),
]

const state = createOutputChainsState({
selectedTargetChainId: SupportedChainId.AVALANCHE, // Not in bridge destinations
chainId: SupportedChainId.MAINNET,
currentChainInfo: createChainInfoForTests(SupportedChainId.MAINNET),
bridgeSupportedNetworks: bridgeChains,
supportedChains,
isLoading: false,
routesAvailability: DEFAULT_ROUTES_AVAILABILITY,
})

// Avalanche is disabled, so should fallback to source (Mainnet)
expect(state.defaultChainId).toBe(SupportedChainId.MAINNET)
})

it('does not apply disabled state while loading bridge data', () => {
const supportedChains = [
createChainInfoForTests(SupportedChainId.MAINNET),
createChainInfoForTests(SupportedChainId.BASE),
createChainInfoForTests(SupportedChainId.GNOSIS_CHAIN),
]

const state = createOutputChainsState({
selectedTargetChainId: SupportedChainId.BASE,
chainId: SupportedChainId.MAINNET,
currentChainInfo: createChainInfoForTests(SupportedChainId.MAINNET),
bridgeSupportedNetworks: undefined,
supportedChains,
isLoading: true, // Still loading
routesAvailability: DEFAULT_ROUTES_AVAILABILITY,
})

// Should render all supportedChains
expect(state.chains?.length).toBe(3)
// No chains should be disabled while loading
expect(state.disabledChainIds).toBeUndefined()
// Selected target should be valid since nothing is disabled
expect(state.defaultChainId).toBe(SupportedChainId.BASE)
})

it('disables all except source when bridge data fails to load', () => {
const supportedChains = [
createChainInfoForTests(SupportedChainId.MAINNET),
createChainInfoForTests(SupportedChainId.BASE),
createChainInfoForTests(SupportedChainId.GNOSIS_CHAIN),
]

const state = createOutputChainsState({
selectedTargetChainId: SupportedChainId.BASE,
chainId: SupportedChainId.MAINNET,
currentChainInfo: createChainInfoForTests(SupportedChainId.MAINNET),
bridgeSupportedNetworks: undefined,
supportedChains,
isLoading: false, // Finished loading, but no data
routesAvailability: DEFAULT_ROUTES_AVAILABILITY,
})

// Should render all supportedChains
expect(state.chains?.length).toBe(3)
// All chains except source should be disabled when bridge data failed
expect(state.disabledChainIds?.has(SupportedChainId.MAINNET)).toBeFalsy()
expect(state.disabledChainIds?.has(SupportedChainId.BASE)).toBe(true)
expect(state.disabledChainIds?.has(SupportedChainId.GNOSIS_CHAIN)).toBe(true)
// Default should fallback to source since selected target is disabled
expect(state.defaultChainId).toBe(SupportedChainId.MAINNET)
})

it('injects current chain when not in supportedChains (feature-flagged chain)', () => {
// Simulate a scenario where wallet is on a feature-flagged chain not in supportedChains
const supportedChains = [
createChainInfoForTests(SupportedChainId.MAINNET),
createChainInfoForTests(SupportedChainId.BASE),
]
const bridgeChains = [
createChainInfoForTests(SupportedChainId.MAINNET),
createChainInfoForTests(SupportedChainId.BASE),
]

const state = createOutputChainsState({
selectedTargetChainId: SupportedChainId.BASE,
chainId: SupportedChainId.GNOSIS_CHAIN, // Not in supportedChains
currentChainInfo: createChainInfoForTests(SupportedChainId.GNOSIS_CHAIN),
bridgeSupportedNetworks: bridgeChains,
supportedChains,
isLoading: false,
routesAvailability: DEFAULT_ROUTES_AVAILABILITY,
})

// Current chain should be injected into the list
expect(state.chains?.some((c) => c.id === SupportedChainId.GNOSIS_CHAIN)).toBe(true)
// Should have 3 chains: Mainnet, Base, and injected Gnosis
expect(state.chains?.length).toBe(3)
})
})

Expand All @@ -127,6 +293,7 @@ describe('useChainsToSelect hook', () => {
data: [createChainInfoForTests(SupportedChainId.GNOSIS_CHAIN)],
isLoading: false,
})
mockUseRoutesAvailability.mockReturnValue(DEFAULT_ROUTES_AVAILABILITY)
})

it('returns undefined for LIMIT_ORDER + OUTPUT (buy token)', () => {
Expand Down
Loading