Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ export function SelectTokenWidget({ displayLpTokenLists, standalone }: SelectTok

const onDismiss = useCallback(() => {
setIsManageWidgetOpen(false)
closeTokenSelectWidget()
closeTokenSelectWidget({ overrideForceLock: true })
}, [closeTokenSelectWidget])

const importTokenAndClose = (tokens: TokenWithLogo[]): void => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { SupportedChainId } from '@cowprotocol/cow-sdk'

import { createInputChainsState, createOutputChainsState } from './useChainsToSelect'

import { createChainInfoForTests } from '../test-utils/createChainInfoForTests'

describe('useChainsToSelect state builders', () => {
it('sorts sell-side chains using the canonical order', () => {
const supportedChains = [
createChainInfoForTests(SupportedChainId.AVALANCHE),
createChainInfoForTests(SupportedChainId.BASE),
createChainInfoForTests(SupportedChainId.MAINNET),
]

const state = createInputChainsState(SupportedChainId.BASE, supportedChains)

expect((state.chains ?? []).map((chain) => chain.id)).toEqual([
SupportedChainId.MAINNET,
SupportedChainId.BASE,
SupportedChainId.AVALANCHE,
])
})

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

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

expect((state.chains ?? []).map((chain) => chain.id)).toEqual([
SupportedChainId.MAINNET,
SupportedChainId.BASE,
SupportedChainId.ARBITRUM_ONE,
SupportedChainId.AVALANCHE,
])
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,13 @@ import { useBridgeSupportedNetworks } from 'entities/bridgeProvider'

import { Field } from 'legacy/state/types'

import { TradeType } from 'modules/trade/types'

import { useSelectTokenWidgetState } from './useSelectTokenWidgetState'

import { ChainsToSelectState } from '../types'
import { mapChainInfo } from '../utils/mapChainInfo'
import { sortChainsByDisplayOrder } from '../utils/sortChainsByDisplayOrder'

/**
* Returns an array of chains to select in the token selector widget.
Expand All @@ -22,11 +25,12 @@ import { mapChainInfo } from '../utils/mapChainInfo'
*/
export function useChainsToSelect(): ChainsToSelectState | undefined {
const { chainId } = useWalletInfo()
const { field, selectedTargetChainId = chainId } = useSelectTokenWidgetState()
const { field, selectedTargetChainId = chainId, tradeType } = useSelectTokenWidgetState()
const { data: bridgeSupportedNetworks, isLoading } = useBridgeSupportedNetworks()
const { areUnsupportedChainsEnabled } = useFeatureFlags()
const isBridgingEnabled = useIsBridgingEnabled()
const availableChains = useAvailableChains()
const isAdvancedTradeType = tradeType === TradeType.LIMIT_ORDER || tradeType === TradeType.ADVANCED_ORDERS

const supportedChains = useMemo(() => {
return availableChains.reduce((acc, id) => {
Expand All @@ -41,42 +45,33 @@ export function useChainsToSelect(): ChainsToSelectState | undefined {
}, [availableChains])

return useMemo(() => {
if (!field || !isBridgingEnabled) return undefined
if (!field || !chainId) return undefined

const currentChainInfo = mapChainInfo(chainId, CHAIN_INFO[chainId])
const isSourceChainSupportedByBridge = Boolean(
bridgeSupportedNetworks?.find((bridgeChain) => bridgeChain.id === chainId),
)
const chainInfo = CHAIN_INFO[chainId]
if (!chainInfo) return undefined

// For the sell token selector we only display supported chains
if (field === Field.INPUT) {
return {
defaultChainId: selectedTargetChainId,
chains: supportedChains,
isLoading: false,
}
}
const currentChainInfo = mapChainInfo(chainId, chainInfo)
// Limit/TWAP buys must stay on the wallet chain, so skip bridge wiring entirely.
const shouldForceSingleChain = isAdvancedTradeType && field === Field.OUTPUT

/**
* When the source chain is not supported by bridge provider
* We act as non-bridge mode
*/
if (!isSourceChainSupportedByBridge) {
return {
defaultChainId: selectedTargetChainId,
chains: [],
isLoading: false,
}
if (!isBridgingEnabled && !shouldForceSingleChain) return undefined

if (shouldForceSingleChain) {
return createSingleChainState(chainId, currentChainInfo)
}

const destinationChains = filterDestinationChains(bridgeSupportedNetworks, areUnsupportedChainsEnabled)
if (field === Field.INPUT) {
return createInputChainsState(selectedTargetChainId, supportedChains)
}

return {
defaultChainId: selectedTargetChainId,
// Add the source network to the list if it's not supported by bridge provider
chains: [...(isSourceChainSupportedByBridge ? [] : [currentChainInfo]), ...(destinationChains || [])],
return createOutputChainsState({
selectedTargetChainId,
chainId,
currentChainInfo,
bridgeSupportedNetworks,
areUnsupportedChainsEnabled,
isLoading,
}
})
}, [
field,
selectedTargetChainId,
Expand All @@ -86,6 +81,7 @@ export function useChainsToSelect(): ChainsToSelectState | undefined {
isBridgingEnabled,
areUnsupportedChainsEnabled,
supportedChains,
isAdvancedTradeType,
])
}

Expand All @@ -101,3 +97,64 @@ function filterDestinationChains(
return bridgeSupportedNetworks?.filter((chain) => chain.id in SupportedChainId)
}
}

// Represents the “non-bridge” UX (bridging disabled or advanced-trade guardrail) where only the current chain is available.
function createSingleChainState(
defaultChainId: SupportedChainId | number,
chain: ChainInfo,
isLoading = false,
): ChainsToSelectState {
return {
defaultChainId,
chains: [chain],
isLoading,
}
}

// Sell-side selector intentionally limits chains to the wallet-supported list; bridge destinations never appear here.
export function createInputChainsState(
selectedTargetChainId: SupportedChainId | number,
supportedChains: ChainInfo[],
): ChainsToSelectState {
return {
defaultChainId: selectedTargetChainId,
chains: sortChainsByDisplayOrder(supportedChains),
isLoading: false,
}
}

interface CreateOutputChainsOptions {
selectedTargetChainId: SupportedChainId | number
chainId: SupportedChainId
currentChainInfo: ChainInfo
bridgeSupportedNetworks: ChainInfo[] | undefined
areUnsupportedChainsEnabled: boolean | undefined
isLoading: boolean
}

export function createOutputChainsState({
selectedTargetChainId,
chainId,
currentChainInfo,
bridgeSupportedNetworks,
areUnsupportedChainsEnabled,
isLoading,
}: CreateOutputChainsOptions): ChainsToSelectState {
const destinationChains = filterDestinationChains(bridgeSupportedNetworks, areUnsupportedChainsEnabled) ?? []
const orderedDestinationChains = sortChainsByDisplayOrder(destinationChains)
const isSourceChainSupportedByBridge = Boolean(
bridgeSupportedNetworks?.some((bridgeChain) => bridgeChain.id === chainId),
)

if (!isSourceChainSupportedByBridge) {
// Source chain is unsupported by the bridge provider; fall back to non-bridge behavior.
return createSingleChainState(selectedTargetChainId, currentChainInfo)
}

return {
defaultChainId: selectedTargetChainId,
// Bridge supports this chain, so expose the provider-supplied destinations.
chains: orderedDestinationChains,
isLoading,
}
}
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
import { useCallback } from 'react'

import { useSelectTokenWidgetState } from './useSelectTokenWidgetState'
import { useUpdateSelectTokenWidgetState } from './useUpdateSelectTokenWidgetState'

import { DEFAULT_SELECT_TOKEN_WIDGET_STATE } from '../state/selectTokenWidgetAtom'

// TODO: Add proper return type annotation
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export function useCloseTokenSelectWidget() {
type CloseTokenSelectWidget = (options?: { overrideForceLock?: boolean }) => void

export function useCloseTokenSelectWidget(): CloseTokenSelectWidget {
const updateSelectTokenWidget = useUpdateSelectTokenWidgetState()
const widgetState = useSelectTokenWidgetState()

return useCallback(
(options?: { overrideForceLock?: boolean }) => {
if (widgetState.forceOpen && !options?.overrideForceLock) return

return useCallback(() => {
updateSelectTokenWidget(DEFAULT_SELECT_TOKEN_WIDGET_STATE)
}, [updateSelectTokenWidget])
updateSelectTokenWidget(DEFAULT_SELECT_TOKEN_WIDGET_STATE)
},
[updateSelectTokenWidget, widgetState.forceOpen],
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,31 @@ import { useCallback } from 'react'

import { ChainInfo } from '@cowprotocol/cow-sdk'

import { Field } from 'legacy/state/types'

import { TradeType } from 'modules/trade/types'

import { useSelectTokenWidgetState } from './useSelectTokenWidgetState'
import { useUpdateSelectTokenWidgetState } from './useUpdateSelectTokenWidgetState'

// TODO: Add proper return type annotation
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export function useOnSelectChain() {
type OnSelectChainHandler = (chain: ChainInfo) => void

export function useOnSelectChain(): OnSelectChainHandler {
const updateSelectTokenWidget = useUpdateSelectTokenWidgetState()
const widgetState = useSelectTokenWidgetState()
const shouldForceOpen =
widgetState.field === Field.INPUT &&
(widgetState.tradeType === TradeType.LIMIT_ORDER || widgetState.tradeType === TradeType.ADVANCED_ORDERS)
// Limit/TWAP sells keep the widget pinned while the user flips chains; forceOpen keeps that behavior intact.

return useCallback(
(chain: ChainInfo) => {
updateSelectTokenWidget({ selectedTargetChainId: chain.id })
updateSelectTokenWidget({
selectedTargetChainId: chain.id,
open: true,
forceOpen: shouldForceOpen,
})
},
[updateSelectTokenWidget],
[updateSelectTokenWidget, shouldForceOpen],
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import { Nullish } from 'types'

import { Field } from 'legacy/state/types'

import { useTradeTypeInfo } from 'modules/trade/hooks/useTradeTypeInfo'
import { useTradeTypeInfoFromUrl } from 'modules/trade/hooks/useTradeTypeInfoFromUrl'
import { TradeType } from 'modules/trade/types'

import { useCloseTokenSelectWidget } from './useCloseTokenSelectWidget'
import { useUpdateSelectTokenWidgetState } from './useUpdateSelectTokenWidgetState'

Expand All @@ -20,28 +24,42 @@ export function useOpenTokenSelectWidget(): (
const updateSelectTokenWidget = useUpdateSelectTokenWidgetState()
const closeTokenSelectWidget = useCloseTokenSelectWidget()
const isBridgingEnabled = useIsBridgingEnabled()
const tradeTypeInfoFromState = useTradeTypeInfo()
const tradeTypeInfoFromUrl = useTradeTypeInfoFromUrl()
const tradeTypeInfo = tradeTypeInfoFromState ?? tradeTypeInfoFromUrl
const tradeType = tradeTypeInfo?.tradeType
// Advanced trades lock the target chain so price guarantees stay valid while the widget is open.
const shouldLockTargetChain = tradeType === TradeType.LIMIT_ORDER || tradeType === TradeType.ADVANCED_ORDERS

return useCallback(
(selectedToken, field, oppositeToken, onSelectToken) => {
const isOutputField = field === Field.OUTPUT
const selectedTargetChainId =
isOutputField && selectedToken && isBridgingEnabled ? selectedToken.chainId : undefined
const nextSelectedTargetChainId =
isOutputField && selectedToken && isBridgingEnabled && !shouldLockTargetChain
? selectedToken.chainId
: undefined

updateSelectTokenWidget({
selectedToken,
field,
oppositeToken,
open: true,
selectedTargetChainId,
forceOpen: false,
selectedTargetChainId: nextSelectedTargetChainId,
tradeType,
onSelectToken: (currency) => {
// Close the token selector regardless of network switching.
// UX: When a user picks a token (even from another network),
// the selector should close as per issue #6251 expected behavior.
closeTokenSelectWidget()
// Keep selector UX consistent with #6251: always close after a selection, even if a chain switch follows.
closeTokenSelectWidget({ overrideForceLock: true })
onSelectToken(currency)
},
})
},
[closeTokenSelectWidget, updateSelectTokenWidget, isBridgingEnabled],
[
closeTokenSelectWidget,
updateSelectTokenWidget,
isBridgingEnabled,
shouldLockTargetChain,
tradeType,
],
)
}
1 change: 1 addition & 0 deletions apps/cowswap-frontend/src/modules/tokensList/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ export { useUpdateSelectTokenWidgetState } from './hooks/useUpdateSelectTokenWid
export { useOnTokenListAddingError } from './hooks/useOnTokenListAddingError'
export { useTokenListAddingError } from './hooks/useTokenListAddingError'
export { useSourceChainId } from './hooks/useSourceChainId'
export { useChainsToSelect } from './hooks/useChainsToSelect'
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export function AddIntermediateTokenModal({ onDismiss, onBack, onImport }: AddIn
importTokenCallback([tokenToImport])
onImport(tokenToImport)
// when we import the token from here, we don't need to import it again in the SelectTokenWidget
closeTokenSelectWidget()
closeTokenSelectWidget({ overrideForceLock: true })
}
}, [onImport, importTokenCallback, closeTokenSelectWidget, tokenToImport])

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { Nullish } from 'types'

import { Field } from 'legacy/state/types'

import { TradeType } from 'modules/trade/types'

interface SelectTokenWidgetState {
open: boolean
field?: Field
Expand All @@ -21,6 +23,8 @@ interface SelectTokenWidgetState {
onSelectToken?: (currency: Currency) => void
onInputPressEnter?: Command
selectedTargetChainId?: number
tradeType?: TradeType
forceOpen?: boolean
}

export const DEFAULT_SELECT_TOKEN_WIDGET_STATE: SelectTokenWidgetState = {
Expand All @@ -32,6 +36,8 @@ export const DEFAULT_SELECT_TOKEN_WIDGET_STATE: SelectTokenWidgetState = {
listToImport: undefined,
selectedPoolAddress: undefined,
selectedTargetChainId: undefined,
tradeType: undefined,
forceOpen: false,
}

export const { atom: selectTokenWidgetAtom, updateAtom: updateSelectTokenWidgetAtom } = atomWithPartialUpdate(
Expand Down
Loading