From 071ced1a4194fe552fb702cec749c3df9521ed78 Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Fri, 14 Nov 2025 10:09:01 +0000 Subject: [PATCH] feat(tokenselector): add chain selection logic and enhance token widget interactions --- .../containers/SelectTokenWidget/index.tsx | 2 +- .../hooks/useChainsToSelect.test.ts | 48 +++++++ .../tokensList/hooks/useChainsToSelect.ts | 117 +++++++++++++----- .../hooks/useCloseTokenSelectWidget.ts | 19 ++- .../tokensList/hooks/useOnSelectChain.ts | 24 +++- .../hooks/useOpenTokenSelectWidget.ts | 34 +++-- .../src/modules/tokensList/index.ts | 1 + .../pure/AddIntermediateTokenModal/index.tsx | 2 +- .../tokensList/state/selectTokenWidgetAtom.ts | 6 + .../test-utils/createChainInfoForTests.ts | 103 +++++++++++++++ .../utils/sortChainsByDisplayOrder.ts | 52 ++++++++ 11 files changed, 357 insertions(+), 51 deletions(-) create mode 100644 apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.test.ts create mode 100644 apps/cowswap-frontend/src/modules/tokensList/test-utils/createChainInfoForTests.ts create mode 100644 apps/cowswap-frontend/src/modules/tokensList/utils/sortChainsByDisplayOrder.ts diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx index fa8966f62f..21cc6df5be 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx @@ -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 => { diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.test.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.test.ts new file mode 100644 index 0000000000..5bf6a56e45 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.test.ts @@ -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, + ]) + }) +}) diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.ts index b781b7c112..c42664014c 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.ts @@ -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. @@ -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) => { @@ -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, @@ -86,6 +81,7 @@ export function useChainsToSelect(): ChainsToSelectState | undefined { isBridgingEnabled, areUnsupportedChainsEnabled, supportedChains, + isAdvancedTradeType, ]) } @@ -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, + } +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useCloseTokenSelectWidget.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useCloseTokenSelectWidget.ts index 6434545dfa..9f52134ab7 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useCloseTokenSelectWidget.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useCloseTokenSelectWidget.ts @@ -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], + ) } diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useOnSelectChain.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useOnSelectChain.ts index eaf2b8997f..f29896d146 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useOnSelectChain.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useOnSelectChain.ts @@ -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], ) } diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useOpenTokenSelectWidget.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useOpenTokenSelectWidget.ts index 2f1a24c8ab..1b7de71863 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useOpenTokenSelectWidget.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useOpenTokenSelectWidget.ts @@ -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' @@ -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, + ], ) } diff --git a/apps/cowswap-frontend/src/modules/tokensList/index.ts b/apps/cowswap-frontend/src/modules/tokensList/index.ts index c38c9b46b9..648d15da92 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/index.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/index.ts @@ -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' diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/AddIntermediateTokenModal/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/AddIntermediateTokenModal/index.tsx index dea219daae..afaf243779 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/AddIntermediateTokenModal/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/AddIntermediateTokenModal/index.tsx @@ -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]) diff --git a/apps/cowswap-frontend/src/modules/tokensList/state/selectTokenWidgetAtom.ts b/apps/cowswap-frontend/src/modules/tokensList/state/selectTokenWidgetAtom.ts index d8cb8eadc3..fda562f5a3 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/state/selectTokenWidgetAtom.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/state/selectTokenWidgetAtom.ts @@ -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 @@ -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 = { @@ -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( diff --git a/apps/cowswap-frontend/src/modules/tokensList/test-utils/createChainInfoForTests.ts b/apps/cowswap-frontend/src/modules/tokensList/test-utils/createChainInfoForTests.ts new file mode 100644 index 0000000000..05c88af196 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/test-utils/createChainInfoForTests.ts @@ -0,0 +1,103 @@ +import { ALL_SUPPORTED_CHAINS_MAP, ChainInfo, SupportedChainId } from '@cowprotocol/cow-sdk' + +export function createChainInfoForTests(baseChainId: SupportedChainId, overrides?: Partial): ChainInfo { + const base = ALL_SUPPORTED_CHAINS_MAP[baseChainId] + + if (!base) { + throw new Error(`Missing base chain definition for ${baseChainId}`) + } + + return buildChainInfo(base, overrides) +} + +function buildChainInfo(base: ChainInfo, overrides: Partial | undefined): ChainInfo { + const chainId = resolveChainId(base, overrides) + + return { + ...base, + ...overrides, + id: chainId, + contracts: resolveContracts(base, overrides), + bridges: resolveBridges(base, overrides), + rpcUrls: resolveRpcUrls(base, overrides), + logo: resolveLogo(base, overrides), + docs: resolveDocs(base, overrides), + website: resolveWebsite(base, overrides), + blockExplorer: resolveBlockExplorer(base, overrides), + nativeCurrency: resolveNativeCurrency(base, overrides, chainId), + } +} + +function resolveChainId(base: ChainInfo, overrides: Partial | undefined): ChainInfo['id'] { + return overrides?.id ?? base.id +} + +function resolveContracts(base: ChainInfo, overrides: Partial | undefined): ChainInfo['contracts'] { + const merged = overrides?.contracts + + return merged ? { ...base.contracts, ...merged } : { ...base.contracts } +} + +function resolveBridges(base: ChainInfo, overrides: Partial | undefined): ChainInfo['bridges'] { + const bridges = overrides?.bridges ?? base.bridges + + return bridges?.map(cloneBridge) +} + +function resolveRpcUrls(base: ChainInfo, overrides: Partial | undefined): ChainInfo['rpcUrls'] { + return cloneRpcUrls(overrides?.rpcUrls ?? base.rpcUrls) +} + +function resolveLogo(base: ChainInfo, overrides: Partial | undefined): ChainInfo['logo'] { + return cloneThemedImage(overrides?.logo ?? base.logo) +} + +function resolveDocs(base: ChainInfo, overrides: Partial | undefined): ChainInfo['docs'] { + return cloneWebUrl(overrides?.docs ?? base.docs) +} + +function resolveWebsite(base: ChainInfo, overrides: Partial | undefined): ChainInfo['website'] { + return cloneWebUrl(overrides?.website ?? base.website) +} + +function resolveBlockExplorer(base: ChainInfo, overrides: Partial | undefined): ChainInfo['blockExplorer'] { + return cloneWebUrl(overrides?.blockExplorer ?? base.blockExplorer) +} + +function resolveNativeCurrency( + base: ChainInfo, + overrides: Partial | undefined, + chainId: ChainInfo['id'], +): ChainInfo['nativeCurrency'] { + return { + ...base.nativeCurrency, + ...(overrides?.nativeCurrency ?? {}), + chainId, + } +} + +function cloneBridge(bridge: NonNullable[number]): NonNullable[number] { + return { ...bridge } +} + +function cloneRpcUrls(rpcUrls: ChainInfo['rpcUrls']): ChainInfo['rpcUrls'] { + return Object.entries(rpcUrls).reduce( + (acc, [key, value]) => { + acc[key] = { + http: [...value.http], + ...(value.webSocket ? { webSocket: [...value.webSocket] } : {}), + } + + return acc + }, + {} as ChainInfo['rpcUrls'], + ) +} + +function cloneThemedImage(image: ChainInfo['logo']): ChainInfo['logo'] { + return { ...image } +} + +function cloneWebUrl(webUrl: T): T { + return { ...webUrl } +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/utils/sortChainsByDisplayOrder.ts b/apps/cowswap-frontend/src/modules/tokensList/utils/sortChainsByDisplayOrder.ts new file mode 100644 index 0000000000..59d30cd945 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/utils/sortChainsByDisplayOrder.ts @@ -0,0 +1,52 @@ +import { SORTED_CHAIN_IDS } from '@cowprotocol/common-const' +import { ChainInfo, SupportedChainId } from '@cowprotocol/cow-sdk' + +const CHAIN_ORDER = SORTED_CHAIN_IDS.reduce>((acc, chainId, index) => { + acc[chainId] = index + return acc +}, {} as Record) + +interface SortOptions { + pinChainId?: ChainInfo['id'] +} + +/** + * Sorts a list of chains so it matches the canonical network selector order. + * Optionally promotes the provided `pinChainId` to the first position. + */ +export function sortChainsByDisplayOrder(chains: ChainInfo[], options?: SortOptions): ChainInfo[] { + if (chains.length <= 1) { + return chains.slice() + } + + const weightedChains = chains.map((chain, index) => ({ + chain, + weight: CHAIN_ORDER[chain.id as SupportedChainId] ?? Number.MAX_SAFE_INTEGER, + index, + })) + + weightedChains.sort((a, b) => { + if (a.weight === b.weight) { + return a.index - b.index + } + + return a.weight - b.weight + }) + + const orderedChains = weightedChains.map((entry) => entry.chain) + + if (!options?.pinChainId) { + return orderedChains + } + + const pinIndex = orderedChains.findIndex((chain) => chain.id === options.pinChainId) + + if (pinIndex <= 0) { + return orderedChains + } + + const [pinnedChain] = orderedChains.splice(pinIndex, 1) + orderedChains.unshift(pinnedChain) + + return orderedChains +}