diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index 4bc99b92e73b..32bacc2f5b58 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -48,6 +48,7 @@ import { BRIDGE_CONTROLLER_NAME, BridgeUserAction, BridgeBackgroundAction, + calcLatestSrcBalance, } from '@metamask/bridge-controller'; import { @@ -2615,6 +2616,19 @@ export default class MetamaskController extends EventEmitter { this.networkController.getNetworkConfigurationByNetworkClientId.bind( this.networkController, ), + getBalanceAmount: async (selectedAddress, tokenAddress, chainId) => { + const networkClientId = + await this.networkController.findNetworkClientIdByChainId(chainId); + const networkClient = + await this.networkController.getNetworkClientById(networkClientId); + const balance = await calcLatestSrcBalance( + networkClient.provider, + selectedAddress, + tokenAddress, + chainId, + ); + return balance?.toString(); + }, // PreferencesController setSelectedAddress: (address) => { const account = this.accountsController.getAccountByAddress(address); @@ -3174,6 +3188,19 @@ export default class MetamaskController extends EventEmitter { this.controllerMessenger, `${BRIDGE_CONTROLLER_NAME}:${BridgeBackgroundAction.FETCH_QUOTES}`, ), + getBalanceAmount: async (selectedAddress, tokenAddress, chainId) => { + const networkClientId = + await this.networkController.findNetworkClientIdByChainId(chainId); + const networkClient = + await this.networkController.getNetworkClientById(networkClientId); + const balance = await calcLatestSrcBalance( + networkClient.provider, + selectedAddress, + tokenAddress, + chainId, + ); + return balance?.toString(); + }, // Bridge Tx submission [BridgeStatusAction.SUBMIT_TX]: this.controllerMessenger.call.bind( diff --git a/shared/constants/bridge.ts b/shared/constants/bridge.ts index f3dc6b0f832d..affeeea86d7d 100644 --- a/shared/constants/bridge.ts +++ b/shared/constants/bridge.ts @@ -3,9 +3,14 @@ import { BRIDGE_DEV_API_BASE_URL, BRIDGE_PROD_API_BASE_URL, ChainId, + formatChainIdToCaip, } from '@metamask/bridge-controller'; import { MultichainNetworks } from './multichain/networks'; -import { CHAIN_IDS, NETWORK_TO_NAME_MAP } from './network'; +import { + CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP, + CHAIN_IDS, + NETWORK_TO_NAME_MAP, +} from './network'; const ALLOWED_MULTICHAIN_BRIDGE_CHAIN_IDS = [ MultichainNetworks.SOLANA, @@ -56,6 +61,18 @@ export const BRIDGE_API_BASE_URL = process.env.BRIDGE_USE_DEV_APIS ? BRIDGE_DEV_API_BASE_URL : BRIDGE_PROD_API_BASE_URL; +export const BRIDGE_CHAIN_ID_TO_NETWORK_IMAGE_MAP: Record< + (typeof ALLOWED_BRIDGE_CHAIN_IDS_IN_CAIP)[number], + string +> = ALLOWED_BRIDGE_CHAIN_IDS.reduce( + (acc, chainId) => { + acc[formatChainIdToCaip(chainId)] = + CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP[chainId]; + return acc; + }, + {} as Record<(typeof ALLOWED_BRIDGE_CHAIN_IDS_IN_CAIP)[number], string>, +); + export const ETH_USDT_ADDRESS = '0xdac17f958d2ee523a2206206994597c13d831ec7'; export const NETWORK_TO_SHORT_NETWORK_NAME_MAP: Record< AllowedBridgeChainIds, @@ -95,6 +112,18 @@ export const NETWORK_TO_SHORT_NETWORK_NAME_MAP: Record< ///: END:ONLY_INCLUDE_IF }; +export const BRIDGE_CHAIN_ID_TO_NETWORK_IMAGE_MAP: Record< + (typeof ALLOWED_BRIDGE_CHAIN_IDS_IN_CAIP)[number], + string +> = ALLOWED_BRIDGE_CHAIN_IDS.reduce( + (acc, chainId) => { + acc[formatChainIdToCaip(chainId)] = + CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP[chainId]; + return acc; + }, + {} as Record<(typeof ALLOWED_BRIDGE_CHAIN_IDS_IN_CAIP)[number], string>, +); + export const STATIC_METAMASK_BASE_URL = 'https://static.cx.metamask.io'; export const BRIDGE_CHAINID_COMMON_TOKEN_PAIR: Partial< diff --git a/shared/constants/common.ts b/shared/constants/common.ts index b3afcdc6dc63..82703c1e4343 100644 --- a/shared/constants/common.ts +++ b/shared/constants/common.ts @@ -1,4 +1,6 @@ +import { toEvmCaipChainId } from '@metamask/multichain-network-controller'; import { CHAIN_IDS } from './network'; +import { CaipChainId, isStrictHexString } from '@metamask/utils'; export enum EtherDenomination { ETH = 'ETH', @@ -50,6 +52,19 @@ export const CHAINID_DEFAULT_BLOCK_EXPLORER_URL_MAP: BlockExplorerUrlMap = { [CHAIN_IDS.MONAD]: MONAD_DEFAULT_BLOCK_EXPLORER_URL, } as const; +export const CAIP_CHAINID_DEFAULT_BLOCK_EXPLORER_URL_MAP = Object.entries( + CHAINID_DEFAULT_BLOCK_EXPLORER_URL_MAP, +).reduce( + (acc, [chainId, url]) => { + if (isStrictHexString(chainId)) { + const caipChainId = toEvmCaipChainId(chainId); + acc[caipChainId] = url; + } + return acc; + }, + {} as Record, +); + export const CHAINID_DEFAULT_BLOCK_EXPLORER_HUMAN_READABLE_URL_MAP: BlockExplorerUrlMap = { [CHAIN_IDS.BSC]: BSC_DEFAULT_BLOCK_EXPLORER_HUMAN_READABLE_URL, diff --git a/shared/lib/asset-utils.ts b/shared/lib/asset-utils.ts index bc2743ec6a1e..77db65bc4f8a 100644 --- a/shared/lib/asset-utils.ts +++ b/shared/lib/asset-utils.ts @@ -24,15 +24,23 @@ import { TEN_SECONDS_IN_MILLISECONDS } from './transactions-controller-utils'; const TOKEN_API_V3_BASE_URL = 'https://tokens.api.cx.metamask.io/v3'; const STATIC_METAMASK_BASE_URL = 'https://static.cx.metamask.io'; +/** + * Converts an address and chainId to a CAIP asset type + * + * @param address - The address of the asset + * @param chainId - The chainId of the asset + * @returns The CAIP asset type + * @throws An error if the chainId is not supported by the Swap and Bridge APIs + */ export const toAssetId = ( address: Hex | CaipAssetType | string, chainId: CaipChainId, -): CaipAssetType | undefined => { +): CaipAssetType => { if (isCaipAssetType(address)) { return address; } if (isNativeAddress(address)) { - return getNativeAssetForChainId(chainId)?.assetId; + return getNativeAssetForChainId(chainId).assetId; } if (chainId === MultichainNetwork.Solana) { return CaipAssetTypeStruct.create(`${chainId}/token:${address}`); @@ -43,7 +51,7 @@ export const toAssetId = ( `${chainId}/erc20:${address.toLowerCase()}`, ); } - return undefined; + throw new Error(`Invalid address or chainId: ${address} ${chainId}`); }; /** diff --git a/ui/components/multichain/asset-picker-amount/asset-picker-modal/hooks/useAssetMetadata.ts b/ui/components/multichain/asset-picker-amount/asset-picker-modal/hooks/useAssetMetadata.ts index 3a435ad67779..a1a43f346178 100644 --- a/ui/components/multichain/asset-picker-amount/asset-picker-modal/hooks/useAssetMetadata.ts +++ b/ui/components/multichain/asset-picker-amount/asset-picker-modal/hooks/useAssetMetadata.ts @@ -21,7 +21,7 @@ export const useAssetMetadata = ( searchQuery: string, shouldFetchMetadata: boolean, abortControllerRef: React.MutableRefObject, - chainId?: Hex | CaipChainId, + chainId: CaipChainId, ) => { const allowExternalServices = useSelector(getUseExternalServices); @@ -31,7 +31,7 @@ export const useAssetMetadata = ( symbol: string; decimals: number; image: string; - chainId: Hex | CaipChainId; + chainId: CaipChainId; isNative: boolean; type: AssetType.token; balance: string; @@ -39,7 +39,7 @@ export const useAssetMetadata = ( } | undefined >(async () => { - if (!chainId || !searchQuery) { + if (!searchQuery) { return undefined; } diff --git a/ui/components/multichain/network-list-item/network-list-item.tsx b/ui/components/multichain/network-list-item/network-list-item.tsx index 9e0a6aaf3b5d..5725a7d87f45 100644 --- a/ui/components/multichain/network-list-item/network-list-item.tsx +++ b/ui/components/multichain/network-list-item/network-list-item.tsx @@ -62,6 +62,7 @@ export const NetworkListItem = ({ disabled = false, variant, notSelectable = false, + avatarNetworkProps = {}, }: { name: string; iconSrc?: string; @@ -81,6 +82,7 @@ export const NetworkListItem = ({ disabled?: boolean; variant?: TextVariant; notSelectable?: boolean; + avatarNetworkProps?: Partial>; }) => { const t = useI18nContext(); const networkRef = useRef(null); @@ -175,6 +177,7 @@ export const NetworkListItem = ({ name={name} src={iconSrc} size={iconSize as AvatarNetworkSize} + {...avatarNetworkProps} /> )} { return async (dispatch: MetaMaskReduxDispatch) => { - await submitRequestToBackground(bridgeAction, args); + const result = await submitRequestToBackground(bridgeAction, args); await forceUpdateMetamaskState(dispatch); + return result; }; }; @@ -119,96 +117,76 @@ export const updateQuoteRequestParams = ( }; }; -export const setEVMSrcTokenBalance = ( - token: TokenPayload['payload'], - selectedAddress?: string, +const getEVMBalance = async ( + accountAddress: string, + { chainId: srcChainId, address }: BridgeToken, ) => { - return async (dispatch: MetaMaskReduxDispatch) => { - if (token) { - trace({ - name: TraceName.BridgeBalancesUpdated, - data: { - srcChainId: formatChainIdToCaip(token.chainId), - isNative: isNativeAddress(token.address), - }, - startTime: Date.now(), - }); - await dispatch( - setEVMSrcTokenBalance_({ - selectedAddress, - tokenAddress: token.address, - chainId: token.chainId, - }), - ); - } - }; + return async (dispatch: MetaMaskReduxDispatch) => + ((await dispatch( + await callBridgeControllerMethod( + 'getBalanceAmount', + accountAddress, + address || zeroAddress(), + formatChainIdToHex(srcChainId), + ), + )) as string) || null; }; -export const setFromChain = ({ - networkConfig, - selectedAccount, - token = null, -}: { - networkConfig?: - | NetworkConfiguration - | AddNetworkFields - | (Omit & { chainId: CaipChainId }); - selectedAccount: InternalAccount | null; - token?: TokenPayload['payload']; -}) => { - return async (dispatch: MetaMaskReduxDispatch) => { - if (!networkConfig) { - return; - } - - // Check for ALL non-EVM chains - const isNonEvm = isNonEvmChain(networkConfig.chainId); +/** + * This action reads the latest on chain balance for the selected token and its chain's native token + * It also traces the balance update. + * + * @param token - The token to fetch the balance for + */ +export const setLatestEVMBalances = (token: BridgeToken) => { + return async ( + dispatch: MetaMaskReduxDispatch, + getState: () => MetaMaskReduxState, + ) => { + const { chainId, assetId, address } = token; - // Set the src network - if (isNonEvm) { - dispatch(setActiveNetworkWithError(networkConfig.chainId)); - } else { - const networkId = isNetworkAdded(networkConfig) - ? networkConfig.rpcEndpoints?.[networkConfig.defaultRpcEndpointIndex] - ?.networkClientId - : null; - if (networkId) { - dispatch(setActiveNetworkWithError(networkId)); - } + if (isNonEvmChain(chainId)) { + return null; } - - // Set the src token - if no token provided, set native token for non-EVM chains - if (token) { - dispatch(setFromToken(token)); - } else if (isNonEvm) { - // Auto-select native token for non-EVM chains when switching - const nativeAsset = getNativeAssetForChainId(networkConfig.chainId); - if (nativeAsset) { - dispatch( - setFromToken({ - ...nativeAsset, - chainId: networkConfig.chainId, - }), - ); - } + const hexChainId = formatChainIdToHex(chainId); + const caipChainId = formatChainIdToCaip(hexChainId); + const account = getInternalAccountBySelectedAccountGroupAndCaip( + getState(), + caipChainId, + ); + if (!account?.address) { + return null; } - // Fetch the native balance (EVM only) - if (selectedAccount && !isNonEvm) { - trace({ + return await trace( + { name: TraceName.BridgeBalancesUpdated, data: { - srcChainId: formatChainIdToCaip(networkConfig.chainId), - isNative: true, + chainId, + isNative: isNativeAddress(address), }, startTime: Date.now(), - }); - await dispatch( - setEVMSrcNativeBalance({ - selectedAddress: selectedAccount.address, - chainId: networkConfig.chainId, - }), - ); - } + }, + async () => { + dispatch( + setEVMSrcTokenBalance({ + balance: await dispatch( + await getEVMBalance(account.address, token), + ), + assetId, + }), + ); + + const nativeToken = toBridgeToken(getNativeAssetForChainId(chainId)); + dispatch( + setEVMSrcNativeBalance({ + balance: await dispatch( + await getEVMBalance(account.address, nativeToken), + ), + chainId, + }), + ); + }, + ); }; }; diff --git a/ui/ducks/bridge/bridge.ts b/ui/ducks/bridge/bridge.ts index 7d1d004856f8..af2164345bcb 100644 --- a/ui/ducks/bridge/bridge.ts +++ b/ui/ducks/bridge/bridge.ts @@ -1,25 +1,16 @@ import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; import { SortOrder, - formatChainIdToCaip, getNativeAssetForChainId, - calcLatestSrcBalance, - isNonEvmChainId, - isCrossChain, - formatChainIdToHex, - type GenericQuoteRequest, type QuoteResponse, - isBitcoinChainId, } from '@metamask/bridge-controller'; -import { zeroAddress } from 'ethereumjs-util'; +import type { CaipChainId, type CaipAssetType } from '@metamask/utils'; import { fetchTxAlerts } from '../../../shared/modules/bridge-utils/security-alerts-api.util'; -import { endTrace, TraceName } from '../../../shared/lib/trace'; import { SlippageValue } from '../../pages/bridge/utils/slippage-service'; import { getTokenExchangeRate, toBridgeToken } from './utils'; -import type { BridgeState, ChainIdPayload, TokenPayload } from './types'; +import type { BridgeState, BridgeToken, TokenPayload } from './types'; const initialState: BridgeState = { - toChainId: null, fromToken: null, toToken: null, fromTokenInputValue: null, @@ -55,80 +46,38 @@ export const setTxAlerts = createAsyncThunk( fetchTxAlerts, ); -const getBalanceAmount = async ({ - selectedAddress, - tokenAddress, - chainId, -}: { - selectedAddress?: string; - tokenAddress: string; - chainId: GenericQuoteRequest['srcChainId']; -}) => { - if (isNonEvmChainId(chainId) || !selectedAddress) { - return null; - } - return ( - await calcLatestSrcBalance( - global.ethereumProvider, - selectedAddress, - tokenAddress, - formatChainIdToHex(chainId), - ) - )?.toString(); -}; - -export const setEVMSrcNativeBalance = createAsyncThunk( - 'bridge/setEVMSrcNativeBalance', - async ({ - selectedAddress, - chainId, - }: Omit[0], 'tokenAddress'>) => - await getBalanceAmount({ - selectedAddress, - tokenAddress: zeroAddress(), - chainId, - }), -); - -export const setEVMSrcTokenBalance = createAsyncThunk( - 'bridge/setEVMSrcTokenBalance', - getBalanceAmount, -); - const bridgeSlice = createSlice({ name: 'bridge', initialState: { ...initialState }, reducers: { - setToChainId: (state, { payload }: ChainIdPayload) => { - state.toChainId = payload ? formatChainIdToCaip(payload) : null; - state.toToken = null; + switchTokens: ( + state, + { + payload: { fromToken, toToken }, + }: { payload: { fromToken: BridgeToken; toToken: BridgeToken } }, + ) => { + state.fromToken = toToken; + state.toToken = fromToken; + state.fromNativeBalance = null; + state.fromTokenBalance = null; }, setFromToken: (state, { payload }: TokenPayload) => { - state.fromToken = toBridgeToken(payload); + state.fromToken = payload ? toBridgeToken(payload) : null; + state.fromTokenBalance = null; + state.fromNativeBalance = null; state.fromTokenBalance = null; // Unset toToken if it's the same as the fromToken if ( state.fromToken?.assetId && state.toToken?.assetId && - // TODO: determine if this is necessary. state.fromToken.assetId?.toLowerCase() === state.toToken.assetId?.toLowerCase() ) { state.toToken = null; } - // if new fromToken is BTC, and toToken is BTC, unset toChain and toToken - if ( - state.fromToken?.chainId && - isBitcoinChainId(state.fromToken.chainId) && - state.toChainId && - isBitcoinChainId(state.toChainId) - ) { - state.toChainId = null; - state.toToken = null; - } }, setToToken: (state, { payload }: TokenPayload) => { - const toToken = toBridgeToken(payload); + const toToken = payload ? toBridgeToken(payload) : null; state.toToken = toToken ? { ...toToken, @@ -137,16 +86,6 @@ const bridgeSlice = createSlice({ getNativeAssetForChainId(toToken.chainId)?.address, } : toToken; - // Update toChainId if it's different from the toToken chainId - if ( - toToken?.chainId && - (state.toChainId - ? formatChainIdToCaip(toToken.chainId) !== - formatChainIdToCaip(state.toChainId) - : true) - ) { - state.toChainId = formatChainIdToCaip(toToken.chainId); - } }, setFromTokenInputValue: ( state, @@ -163,7 +102,6 @@ const bridgeSlice = createSlice({ ) => { state.fromToken = toBridgeToken(quote.srcAsset); state.toToken = toBridgeToken(quote.destAsset); - state.toChainId = formatChainIdToCaip(quote.destChainId); }, setSortOrder: (state, action) => { state.sortOrder = action.payload; @@ -177,6 +115,34 @@ const bridgeSlice = createSlice({ setSlippage: (state, action) => { state.slippage = action.payload; }, + setEVMSrcTokenBalance: ( + state, + action: { + payload: { + assetId: CaipAssetType; + balance: BridgeState['fromTokenBalance']; + }; + }, + ) => { + const { assetId, balance } = action.payload; + if (!state.fromToken || assetId === state.fromToken.assetId) { + state.fromTokenBalance = balance; + } + }, + setEVMSrcNativeBalance: ( + state, + action: { + payload: { + chainId: CaipChainId; + balance: BridgeState['fromNativeBalance']; + }; + }, + ) => { + const { chainId, balance } = action.payload; + if (!state.fromToken || chainId === state.fromToken.chainId) { + state.fromNativeBalance = balance; + } + }, }, extraReducers: (builder) => { builder.addCase(setDestTokenExchangeRates.pending, (state) => { @@ -206,40 +172,6 @@ const bridgeSlice = createSlice({ builder.addCase(setTxAlerts.rejected, (state) => { state.txAlert = null; }); - builder.addCase(setEVMSrcTokenBalance.fulfilled, (state, action) => { - const isTokenInChain = !isCrossChain( - action.meta.arg.chainId, - state.fromToken?.chainId, - ); - if ( - isTokenInChain && state.fromToken?.address - ? action.meta.arg.tokenAddress === state.fromToken.address - : true - ) { - state.fromTokenBalance = action.payload?.toString() ?? null; - } - endTrace({ - name: TraceName.BridgeBalancesUpdated, - }); - }); - builder.addCase(setEVMSrcTokenBalance.rejected, (state) => { - state.fromTokenBalance = null; - endTrace({ - name: TraceName.BridgeBalancesUpdated, - }); - }); - builder.addCase(setEVMSrcNativeBalance.fulfilled, (state, action) => { - state.fromNativeBalance = action.payload?.toString() ?? null; - endTrace({ - name: TraceName.BridgeBalancesUpdated, - }); - }); - builder.addCase(setEVMSrcNativeBalance.rejected, (state) => { - state.fromNativeBalance = null; - endTrace({ - name: TraceName.BridgeBalancesUpdated, - }); - }); }, }); diff --git a/ui/ducks/bridge/selectors.ts b/ui/ducks/bridge/selectors.ts index b093db6641cd..a00596175871 100644 --- a/ui/ducks/bridge/selectors.ts +++ b/ui/ducks/bridge/selectors.ts @@ -1,4 +1,5 @@ import type { + AddNetworkFields, NetworkConfiguration, NetworkState, } from '@metamask/network-controller'; @@ -17,6 +18,7 @@ import { selectMinimumBalanceForRentExemptionInSOL, isValidQuoteRequest, isCrossChain, + ChainId, } from '@metamask/bridge-controller'; import type { RemoteFeatureFlagControllerState } from '@metamask/remote-feature-flag-controller'; import { SolAccountType, BtcAccountType } from '@metamask/keyring-api'; @@ -43,28 +45,27 @@ import type { TokenRatesControllerState, } from '@metamask/assets-controllers'; import type { MultichainTransactionsControllerState } from '@metamask/multichain-transactions-controller'; -import type { MultichainNetworkControllerState } from '@metamask/multichain-network-controller'; +import { type NetworkEnablementControllerState } from '@metamask/network-enablement-controller'; +import type { + MultichainNetworkConfiguration, + MultichainNetworkControllerState, +} from '@metamask/multichain-network-controller'; import { type AccountGroupObject, type AccountTreeControllerState, } from '@metamask/account-tree-controller'; -import { - MultichainNetworks, - MULTICHAIN_PROVIDER_CONFIGS, -} from '../../../shared/constants/multichain/networks'; +import { MultichainNetworks } from '../../../shared/constants/multichain/networks'; import { getHardwareWalletType, getUSDConversionRateByChainId, selectConversionRateByChainId, } from '../../selectors/selectors'; -import { ALLOWED_BRIDGE_CHAIN_IDS } from '../../../shared/constants/bridge'; +import { ALL_ALLOWED_BRIDGE_CHAIN_IDS } from '../../../shared/constants/bridge'; import { createDeepEqualSelector } from '../../../shared/modules/selectors/util'; -import { getNetworkConfigurationsByChainId } from '../../../shared/modules/selectors/networks'; import { FEATURED_RPCS } from '../../../shared/constants/network'; import { getMultichainBalances, getMultichainCoinRates, - getMultichainProviderConfig, } from '../../selectors/multichain'; import { getAssetsRates } from '../../selectors/assets'; import { @@ -72,13 +73,9 @@ import { HardwareKeyringType, } from '../../../shared/constants/hardware-wallets'; import { toAssetId } from '../../../shared/lib/asset-utils'; -import { MULTICHAIN_NATIVE_CURRENCY_TO_CAIP19 } from '../../../shared/constants/multichain/assets'; import { Numeric } from '../../../shared/modules/Numeric'; import { getIsSmartTransaction } from '../../../shared/modules/selectors'; -import { - getInternalAccountsByScope, - getSelectedInternalAccount, -} from '../../selectors/accounts'; +import { getInternalAccountsByScope } from '../../selectors/accounts'; import { getRemoteFeatureFlags } from '../../selectors/remote-feature-flags'; import { getAllAccountGroups, @@ -95,32 +92,7 @@ import { toBridgeToken, isNonEvmChain, } from './utils'; -import type { BridgeState } from './types'; - -/** - * Helper function to determine the CAIP asset type for non-EVM native assets - * - * @param chainId - The chain ID - * @param address - The asset address - * @param assetId - The asset ID - * @returns The appropriate CAIP asset type string - */ -const getNonEvmNativeAssetType = ( - chainId: Hex | string | number | CaipChainId, - address: string, - assetId?: string, -): CaipAssetType | string => { - if (isSolanaChainId(chainId)) { - return isNativeAddress(address) - ? MULTICHAIN_NATIVE_CURRENCY_TO_CAIP19.SOL - : (assetId ?? address); - } - if (isBitcoinChainId(chainId)) { - // Bitcoin bridge only supports mainnet - return MULTICHAIN_NATIVE_CURRENCY_TO_CAIP19.BTC; - } - return assetId ?? address; -}; +import type { BridgeState, BridgeToken } from './types'; export type BridgeAppState = { metamask: BridgeAppStateFromController & @@ -137,6 +109,7 @@ export type BridgeAppState = { MultichainNetworkControllerState & TokenListState & RemoteFeatureFlagControllerState & + NetworkEnablementControllerState & CurrencyRateState & { useExternalServices: boolean; }; @@ -167,44 +140,45 @@ const hasBitcoinAccounts = (state: BridgeAppState) => { }); }; -// only includes networks user has added -export const getAllBridgeableNetworks = createDeepEqualSelector( - getNetworkConfigurationsByChainId, - (networkConfigurationsByChainId) => { - return uniqBy( - [ - ...Object.values(networkConfigurationsByChainId), - // TODO: get this from network controller, use placeholder values for now - { - ...MULTICHAIN_PROVIDER_CONFIGS[MultichainNetworks.SOLANA], - blockExplorerUrls: [], - name: MULTICHAIN_PROVIDER_CONFIGS[MultichainNetworks.SOLANA].nickname, - nativeCurrency: - MULTICHAIN_PROVIDER_CONFIGS[MultichainNetworks.SOLANA].ticker, - rpcEndpoints: [{ url: '', type: '', networkClientId: '' }], - defaultRpcEndpointIndex: 0, - chainId: MultichainNetworks.SOLANA, - } as unknown as NetworkConfiguration, - ///: BEGIN:ONLY_INCLUDE_IF(bitcoin-swaps) - // TODO: get this from network controller, use placeholder values for now - { - ...MULTICHAIN_PROVIDER_CONFIGS[MultichainNetworks.BITCOIN], - blockExplorerUrls: [], - name: MULTICHAIN_PROVIDER_CONFIGS[MultichainNetworks.BITCOIN] - .nickname, - nativeCurrency: - MULTICHAIN_PROVIDER_CONFIGS[MultichainNetworks.BITCOIN].ticker, - rpcEndpoints: [{ url: '', type: '', networkClientId: '' }], - defaultRpcEndpointIndex: 0, - chainId: MultichainNetworks.BITCOIN, - } as unknown as NetworkConfiguration, - ///: END:ONLY_INCLUDE_IF +const getAllBridgeableNetworks = createDeepEqualSelector( + [ + (state: BridgeAppState) => + state.metamask.multichainNetworkConfigurationsByChainId[ + MultichainNetworks.SOLANA ], - 'chainId', - ).filter(({ chainId }) => - ALLOWED_BRIDGE_CHAIN_IDS.includes( - chainId as (typeof ALLOWED_BRIDGE_CHAIN_IDS)[number], - ), + (state: BridgeAppState) => + state.metamask.multichainNetworkConfigurationsByChainId[ + MultichainNetworks.BITCOIN + ], + (state: BridgeAppState) => + Object.values(state.metamask.networkConfigurationsByChainId), + ], + ( + solanaMultichainNetworkConfiguration, + bitcoinMultichainNetworkConfiguration, + networkConfigurations, + ): Record => { + return Object.fromEntries( + uniqBy( + [ + ...networkConfigurations.map((network) => ({ + ...network, + isEvm: true as const, + defaultBlockExplorerUrlIndex: + network.defaultBlockExplorerUrlIndex ?? 0, + })), + solanaMultichainNetworkConfiguration, + ///: BEGIN:ONLY_INCLUDE_IF(bitcoin-swaps) + bitcoinMultichainNetworkConfiguration, + ///: END:ONLY_INCLUDE_IF + ], + 'chainId', + ) + .filter(({ chainId }) => ALL_ALLOWED_BRIDGE_CHAIN_IDS.includes(chainId)) + .map((network) => [ + formatChainIdToCaip(network.chainId), + { ...network, chainId: formatChainIdToCaip(network.chainId) }, + ]), ); }, ); @@ -225,74 +199,106 @@ export const getPriceImpactThresholds = createDeepEqualSelector( ); export const getFromChains = createDeepEqualSelector( - getAllBridgeableNetworks, - getBridgeFeatureFlags, - (state: BridgeAppState) => hasSolanaAccounts(state), - (state: BridgeAppState) => hasBitcoinAccounts(state), + [ + getAllBridgeableNetworks, + // @ts-expect-error - chainRanking is not typed yet + (state: BridgeAppState) => getBridgeFeatureFlags(state).chainRanking, + (state: BridgeAppState) => hasSolanaAccounts(state), + (state: BridgeAppState) => hasBitcoinAccounts(state), + ], ( - allBridgeableNetworks, - bridgeFeatureFlags, + allBridgeableNetworksByChainId, + chainRanking, hasSolanaAccount, hasBitcoinAccount, - ) => { - // First filter out Solana from source chains if no Solana account exists - let filteredNetworks = hasSolanaAccount - ? allBridgeableNetworks - : allBridgeableNetworks.filter( - ({ chainId }) => !isSolanaChainId(chainId), - ); - - // Then filter out Bitcoin from source chains if no Bitcoin account exists - filteredNetworks = hasBitcoinAccount - ? filteredNetworks - : filteredNetworks.filter(({ chainId }) => !isBitcoinChainId(chainId)); - - // Then apply the standard filter for active source chains - return filteredNetworks.filter( - ({ chainId }) => - bridgeFeatureFlags.chains[formatChainIdToCaip(chainId)]?.isActiveSrc, - ); + ): MultichainNetworkConfiguration[] => { + return chainRanking + .map( + ({ chainId }: { chainId: CaipChainId }) => + allBridgeableNetworksByChainId[chainId], + ) + .filter((network?: MultichainNetworkConfiguration) => { + if (!network) { + return false; + } + // if no solana account, filter out solana + if (!hasSolanaAccount && isSolanaChainId(network.chainId)) { + return false; + } + // if no bitcoin account, filter out bitcoin + if (!hasBitcoinAccount && isBitcoinChainId(network.chainId)) { + return false; + } + return true; + }); }, ); -/** - * This matches the network filter in the activity and asset lists - */ -export const getLastSelectedChainId = createSelector( - [getAllEnabledNetworksForAllNamespaces], - (allEnabledNetworksForAllNamespaces) => { - return allEnabledNetworksForAllNamespaces[0]; +// Returns the latest enabled network that matches a supported bridge network +// Matches the network filter in the activity and asset lists +const getLastEnabledChain = createSelector( + [getAllEnabledNetworksForAllNamespaces, getFromChains], + (chainIdRanking, fromChains) => { + const lastEnabledChainId = chainIdRanking.find((chainId) => + fromChains.some( + ({ chainId: fromChainId }: NetworkConfiguration) => + fromChainId === chainId, + ), + ); + return ( + fromChains.find( + ({ chainId: fromChainId }: NetworkConfiguration) => + fromChainId === lastEnabledChainId, + ) ?? fromChains[0] + ); }, ); +// This matches the network filter in the activity and asset lists +const getLastSelectedChainId = (state: BridgeAppState) => + getAllEnabledNetworksForAllNamespaces(state)[0]; -// This returns undefined if the selected chain is not supported by swap/bridge (i.e, testnets) -export const getFromChain = createDeepEqualSelector( - [getFromChains, getMultichainProviderConfig], - (fromChains, providerConfig) => { - // When the page loads the global network always matches the network filter - // Because useBridging checks whether the lastSelectedNetwork matches the provider config - // Then useBridgeQueryParams sets the global network to lastSelectedNetwork as needed - // TODO remove providerConfig references and just use getLastSelectedChainId - return fromChains.find( - ({ chainId }) => chainId === providerConfig?.chainId, - ); - }, +// If this is null, show all networks +const getSelectedFromChainId = (state: BridgeAppState) => + state.bridge.fromToken?.chainId; + +export const getFromToken = createSelector( + [ + (state) => getLastEnabledChain(state).chainId, + (state: BridgeAppState) => state.bridge.fromToken, + ], + (lastEnabledChainId, fromToken) => + fromToken ?? toBridgeToken(getNativeAssetForChainId(lastEnabledChainId)), ); +const getFromChainId = (state: BridgeAppState) => getFromToken(state).chainId; +// For compatibility with old code +export const getFromChain = (state: BridgeAppState) => ({ + chainId: getFromChainId(state), +}); + export const getToChains = createDeepEqualSelector( - getAllBridgeableNetworks, - getBridgeFeatureFlags, - (allBridgeableNetworks, bridgeFeatureFlags) => { - const availableChains = uniqBy( - [...allBridgeableNetworks, ...FEATURED_RPCS], - 'chainId', - ).filter( - ({ chainId }) => - bridgeFeatureFlags?.chains?.[formatChainIdToCaip(chainId)] - ?.isActiveDest, + [ + getAllBridgeableNetworks, + // @ts-expect-error - chainRanking is not typed yet + (state: BridgeAppState) => getBridgeFeatureFlags(state).chainRanking, + ], + (allBridgeableNetworks, chainRanking): MultichainNetworkConfiguration[] => { + const featuredNetworksByChainId = Object.fromEntries( + FEATURED_RPCS.map((rpc: AddNetworkFields) => [ + formatChainIdToCaip(rpc.chainId), + { + ...rpc, + isEvm: true as const, + chainId: formatChainIdToCaip(rpc.chainId), + }, + ]), ); - - return availableChains; + return chainRanking + .map( + ({ chainId }: { chainId: CaipChainId }) => + allBridgeableNetworks[chainId] ?? featuredNetworksByChainId[chainId], + ) + .filter(Boolean); }, ); @@ -309,14 +315,12 @@ export const getTopAssetsFromFeatureFlags = ( const getDefaultTokenPair = createDeepEqualSelector( [ - (state) => getFromChain(state)?.chainId, + (state) => state.bridge.fromChainId, (state) => getBridgeFeatureFlags(state).bip44DefaultPairs, ], - (fromChainId, bip44DefaultPairs): null | [CaipAssetType, CaipAssetType] => { - if (!fromChainId) { - return null; - } - const { namespace } = parseCaipChainId(formatChainIdToCaip(fromChainId)); + (fromChainId, bip44DefaultPairs): [CaipAssetType, CaipAssetType] | null => { + const chainIdToUse = fromChainId ?? 'eip155:1'; + const { namespace } = parseCaipChainId(formatChainIdToCaip(chainIdToUse)); const defaultTokenPair = bip44DefaultPairs?.[namespace]?.standard; if (defaultTokenPair) { return Object.entries(defaultTokenPair).flat() as [ @@ -338,79 +342,112 @@ const getBIP44DefaultToChainId = createSelector( }, ); -// If the user has selected a toChainId, return it as the destination chain -// Otherwise, use the source chain as the destination chain (default to swap params) -export const getToChain = createSelector( +// TODO should always be defined +export const getFromToken = createSelector( [ - getFromChain, - getToChains, - (state: BridgeAppState) => state.bridge?.toChainId, - getBIP44DefaultToChainId, + (state: BridgeAppState) => state.bridge.fromToken, + // (state) => state.bridge.fromChainId, + // getDefaultTokenPair, ], - (fromChain, toChains, toChainId, defaultToChainId) => { - // If user has explicitly selected a destination, use it - if (toChainId) { - return toChains.find( - ({ chainId }) => - chainId === toChainId || formatChainIdToCaip(chainId) === toChainId, - ); - } - - // Bitcoin can only bridge to EVM chains, not to Bitcoin - // So if source is Bitcoin, default to BIP44 default chain - if (fromChain && isBitcoinChainId(fromChain.chainId)) { - return toChains.find(({ chainId }) => { - return formatChainIdToCaip(chainId) === defaultToChainId; - }); + (fromToken) => { + if (fromToken) { + return fromToken; } - - // For all other chains, default to same chain (swap mode) - return fromChain; + // TODO return default bip44 + return toBridgeToken(getNativeAssetForChainId(ChainId.ETH)); + // const chainIdToUse = fromChainId; + // const { iconUrl, ...nativeAsset } = getNativeAssetForChainId(chainIdToUse); + // return toBridgeToken(nativeAsset); }, ); -export const getFromToken = createSelector( - [ - (state: BridgeAppState) => state.bridge.fromToken, - (state) => getFromChain(state)?.chainId, - ], - (fromToken, fromChainId) => { +// TODO just read getFromToken and return its chainId +// This returns undefined if the selected chain is not supported by swap/bridge (i.e, testnets) +// The network picker will show "All networks" in this case +export const getFromChain = createSelector( + [getFromChains, getLastSelectedChainId, getSelectedFromChainId], + ( + fromChains, + lastSelectedChainId, + fromChainId, + ): MultichainNetworkConfiguration | null => { + // When the page loads the global network always matches the network filter + // Because useBridging checks whether the lastSelectedNetwork matches the provider config + // Then useBridgeQueryParams sets the global network to lastSelectedNetwork as neede + const lastSelectedSupportedChain = ALL_ALLOWED_BRIDGE_CHAIN_IDS.includes( + lastSelectedChainId, + ) + ? fromChains.find( + ({ chainId }) => chainId === formatChainIdToCaip(lastSelectedChainId), + ) + : null; + if (!fromChainId) { - return null; - } - if (fromToken?.address) { - return fromToken; + return lastSelectedSupportedChain ?? null; } - const { iconUrl, ...nativeAsset } = getNativeAssetForChainId(fromChainId); - const newToToken = toBridgeToken(nativeAsset); - return newToToken - ? { - ...newToToken, - chainId: formatChainIdToCaip(fromChainId), - } - : newToToken; + return ( + fromChains.find(({ chainId }) => fromChainId === chainId) ?? fromChains[0] + ); }, ); export const getToToken = createSelector( - [getFromToken, getToChain, (state) => state.bridge.toToken], - (fromToken, toChain, toToken) => { - if (!toChain || !fromToken) { - return null; + [getFromToken, (state) => state.bridge.toToken], + (fromToken, toToken): BridgeToken => { + // Bitcoin can only bridge to EVM chains, not to Bitcoin + // So if source is Bitcoin, default to BIP44 default chain + if (fromToken?.chainId && isBitcoinChainId(fromToken.chainId)) { + // TODO return bip44 + return toBridgeToken(getNativeAssetForChainId(1)); } + // If the user has selected a token, return it if (toToken) { return toToken; } + // Bitcoin only has 1 asset, so we can use the default asset from LD + if (isBitcoinChainId(fromToken.chainId) && defaultToChainId) { + return toBridgeToken(getNativeAssetForChainId(defaultToChainId)); + } // Otherwise, determine the default token to use based on fromToken and toChain - const defaultToken = getDefaultToToken( - formatChainIdToCaip(toChain.chainId), - fromToken, + const defaultToken = fromToken + ? getDefaultToToken(fromToken.chainId, fromToken) + : null; + return toBridgeToken( + defaultToken ?? getNativeAssetForChainId(fromToken?.chainId), ); - return defaultToken ? toBridgeToken(defaultToken) : null; }, ); +// If the user has selected a toToken, return its chainId as the destination chain +// Otherwise, use the source chain as the destination chain (default to swap params) +export const getToChain = createDeepEqualSelector( + [ + (state) => getToToken(state).chainId, + getToChains, + (state: BridgeAppState) => state.bridge?.toToken?.chainId, + ], + (defaultToChainId, toChains, toChainId) => { + // If user has explicitly selected a destination, use it + if (toChainId) { + return ( + toChains.find( + ({ chainId }) => + chainId === toChainId || formatChainIdToCaip(chainId) === toChainId, + ) ?? null + ); + } + + // For all other chains, default to same chain (swap mode) + return toChains.find(({ chainId }) => chainId === defaultToChainId) ?? null; + }, +); + +const getToChainId = (state: BridgeAppState) => getToToken(state).chainId; +export const getToChain = (state: BridgeAppState) => ({ + chainId: getToChainId(state), +}); + export const getFromAmount = (state: BridgeAppState): string | null => state.bridge.fromTokenInputValue; @@ -428,18 +465,12 @@ export const getAccountGroupNameByInternalAccount = createSelector( ); export const getFromAccount = createSelector( - [ - (state) => getFromChain(state)?.chainId, - (state) => state, - getSelectedInternalAccount, - ], - (fromChainId, state, selectedInternalAccount) => { + [(state) => getFromToken(state)?.chainId, (state) => state], + (fromChainId, state) => { if (fromChainId) { - return ( - getInternalAccountBySelectedAccountGroupAndCaip( - state, - formatChainIdToCaip(fromChainId), - ) ?? selectedInternalAccount + return getInternalAccountBySelectedAccountGroupAndCaip( + state, + formatChainIdToCaip(fromChainId), ); } return null; @@ -447,14 +478,14 @@ export const getFromAccount = createSelector( ); export const getToAccounts = createSelector( - [getToChain, getWalletsWithAccounts, (state) => state], - (toChain, accountsByWallet, state) => { - if (!toChain) { + [getToChainId, getWalletsWithAccounts, (state) => state], + (toChainId, accountsByWallet, state) => { + if (!toChainId) { return []; } const internalAccounts = getInternalAccountsByScope( state, - formatChainIdToCaip(toChain.chainId), + formatChainIdToCaip(toChainId), ); return internalAccounts.map((account) => ({ @@ -476,23 +507,21 @@ export const getToAccounts = createSelector( const _getFromNativeBalance = createSelector( [ - getFromChain, + getFromChainId, (state: BridgeAppState) => state.bridge.fromNativeBalance, getMultichainBalances, (state) => getFromAccount(state)?.id, ], - (fromChain, fromNativeBalance, nonEvmBalancesByAccountId, id) => { - if (!fromChain || !id) { + (fromChainId, fromNativeBalance, nonEvmBalancesByAccountId, id) => { + if (!id) { return null; } - const { chainId } = fromChain; - const { decimals, address, assetId } = getNativeAssetForChainId(chainId); + const { decimals, assetId } = getNativeAssetForChainId(fromChainId); // Use the balance provided by the multichain balances controller for non-EVM chains - if (isNonEvmChain(chainId)) { - const caipAssetType = getNonEvmNativeAssetType(chainId, address, assetId); - return nonEvmBalancesByAccountId?.[id]?.[caipAssetType]?.amount ?? null; + if (isNonEvmChain(fromChainId)) { + return nonEvmBalancesByAccountId?.[id]?.[assetId]?.amount ?? null; } return fromNativeBalance @@ -502,30 +531,24 @@ const _getFromNativeBalance = createSelector( ); export const getFromTokenBalance = createSelector( - getFromToken, - getFromChain, - (state: BridgeAppState) => state.bridge.fromTokenBalance, - getMultichainBalances, - getFromAccount, - ( - fromToken, - fromChain, - fromTokenBalance, - nonEvmBalancesByAccountId, - fromAccount, - ) => { - if (!fromToken || !fromChain || !fromAccount) { + [ + getFromToken, + (state: BridgeAppState) => state.bridge.fromTokenBalance, + getMultichainBalances, + getFromAccount, + ], + (fromToken, fromTokenBalance, nonEvmBalancesByAccountId, fromAccount) => { + if (!fromAccount) { return null; } const { id } = fromAccount; - const { chainId, decimals, address, assetId } = fromToken; + const { chainId, decimals, assetId } = fromToken; // Use the balance provided by the multichain balances controller for non-EVM chains if (isNonEvmChain(chainId)) { - const caipAssetType = getNonEvmNativeAssetType(chainId, address, assetId); return ( - nonEvmBalancesByAccountId?.[id]?.[caipAssetType]?.amount ?? - fromToken.string ?? + nonEvmBalancesByAccountId?.[id]?.[assetId]?.amount ?? + fromToken.balance ?? null ); } @@ -544,12 +567,9 @@ export const getQuoteRequest = (state: BridgeAppState) => { }; export const getQuoteRefreshRate = createSelector( - getBridgeFeatureFlags, - getFromChain, - (extensionConfig, fromChain) => - (fromChain && - extensionConfig.chains[formatChainIdToCaip(fromChain.chainId)] - ?.refreshRate) ?? + [getBridgeFeatureFlags, getFromChainId], + (extensionConfig, fromChainId) => + extensionConfig.chains[formatChainIdToCaip(fromChainId)]?.refreshRate ?? extensionConfig.refreshRate, ); export const getBridgeSortOrder = (state: BridgeAppState) => @@ -557,7 +577,6 @@ export const getBridgeSortOrder = (state: BridgeAppState) => export const getFromTokenConversionRate = createSelector( [ - getFromChain, (state: BridgeAppState) => state.metamask.marketData, // rates for non-native evm tokens getAssetsRates, // non-evm conversion rates multichain equivalent of getMarketData getFromToken, @@ -566,7 +585,6 @@ export const getFromTokenConversionRate = createSelector( (state: BridgeAppState) => state.bridge.fromTokenExchangeRate, ], ( - fromChain, marketData, conversionRates, fromToken, @@ -574,96 +592,84 @@ export const getFromTokenConversionRate = createSelector( currencyRates, fromTokenExchangeRate, ) => { - if (fromChain?.chainId && fromToken) { - const nativeAssetId = getNativeAssetForChainId( - fromChain.chainId, - )?.assetId; - const tokenAssetId = toAssetId( - fromToken.address, - formatChainIdToCaip(fromChain.chainId), - ); - const nativeToCurrencyRate = isNonEvmChain(fromChain.chainId) - ? Number( - rates?.[fromChain.nativeCurrency?.toLowerCase()]?.conversionRate ?? - conversionRates?.[nativeAssetId as CaipAssetType]?.rate ?? - null, - ) - : (currencyRates[fromChain.nativeCurrency]?.conversionRate ?? null); - const nativeToUsdRate = isNonEvmChain(fromChain.chainId) - ? Number( - rates?.[fromChain.nativeCurrency?.toLowerCase()] - ?.usdConversionRate ?? - conversionRates?.[nativeAssetId as CaipAssetType]?.rate ?? - null, - ) - : (currencyRates[fromChain.nativeCurrency]?.usdConversionRate ?? null); - - if (isNativeAddress(fromToken.address)) { - return { - valueInCurrency: nativeToCurrencyRate, - usd: nativeToUsdRate, - }; - } - if (isSolanaChainId(fromChain.chainId) && nativeAssetId && tokenAssetId) { - // For SOLANA tokens, we use the conversion rates provided by the multichain rates controller - const tokenToNativeAssetRate = tokenPriceInNativeAsset( - Number( - conversionRates?.[tokenAssetId]?.rate ?? - fromTokenExchangeRate ?? - null, - ), - Number( + const fromChainId = fromToken.chainId; + const nativeAssetId = getNativeAssetForChainId(fromChainId)?.assetId; + const tokenAssetId = toAssetId( + fromToken.address, + formatChainIdToCaip(fromChainId), + ); + const nativeAssetSymbol = getNativeAssetForChainId(fromChainId)?.symbol; + const nativeToCurrencyRate = isNonEvmChain(fromChainId) + ? Number( + rates?.[nativeAssetSymbol.toLowerCase()]?.conversionRate ?? conversionRates?.[nativeAssetId as CaipAssetType]?.rate ?? - rates?.sol?.conversionRate ?? - null, - ), - ); - return exchangeRatesFromNativeAndCurrencyRates( - tokenToNativeAssetRate, - Number(nativeToCurrencyRate), - Number(nativeToUsdRate), - ); - } + null, + ) + : (currencyRates[nativeAssetSymbol]?.conversionRate ?? null); + const nativeToUsdRate = isNonEvmChain(fromChainId) + ? Number( + rates?.[nativeAssetSymbol?.toLowerCase()]?.usdConversionRate ?? + conversionRates?.[nativeAssetId as CaipAssetType]?.rate ?? + null, + ) + : (currencyRates[nativeAssetSymbol]?.usdConversionRate ?? null); - if ( - isBitcoinChainId(fromChain.chainId) && - nativeAssetId && - tokenAssetId - ) { - // For Bitcoin tokens, we use the conversion rates provided by the multichain rates controller - const nativeAssetRate = Number( - conversionRates?.[nativeAssetId as CaipAssetType]?.rate ?? null, - ); - const tokenToNativeAssetRate = tokenPriceInNativeAsset( - Number( - conversionRates?.[tokenAssetId]?.rate ?? - fromTokenExchangeRate ?? - null, - ), - nativeAssetRate, - ); - return exchangeRatesFromNativeAndCurrencyRates( - tokenToNativeAssetRate, - Number(nativeToCurrencyRate), - Number(nativeToUsdRate), - ); - } - // For EVM tokens, we use the market data to get the exchange rate - const tokenToNativeAssetRate = - exchangeRateFromMarketData( - fromChain.chainId, - fromToken.address, - marketData, - ) ?? - tokenPriceInNativeAsset(fromTokenExchangeRate, nativeToCurrencyRate); + if (isNativeAddress(fromToken.address)) { + return { + valueInCurrency: nativeToCurrencyRate, + usd: nativeToUsdRate, + }; + } + if (isSolanaChainId(fromChainId) && nativeAssetId && tokenAssetId) { + // For SOLANA tokens, we use the conversion rates provided by the multichain rates controller + const tokenToNativeAssetRate = tokenPriceInNativeAsset( + Number( + conversionRates?.[tokenAssetId]?.rate ?? + fromTokenExchangeRate ?? + null, + ), + Number( + conversionRates?.[nativeAssetId as CaipAssetType]?.rate ?? + rates?.sol?.conversionRate ?? + null, + ), + ); + return exchangeRatesFromNativeAndCurrencyRates( + tokenToNativeAssetRate, + Number(nativeToCurrencyRate), + Number(nativeToUsdRate), + ); + } + if (isBitcoinChainId(fromChainId) && nativeAssetId && tokenAssetId) { + // For Bitcoin tokens, we use the conversion rates provided by the multichain rates controller + const nativeAssetRate = Number( + conversionRates?.[nativeAssetId as CaipAssetType]?.rate ?? null, + ); + const tokenToNativeAssetRate = tokenPriceInNativeAsset( + Number( + conversionRates?.[tokenAssetId]?.rate ?? + fromTokenExchangeRate ?? + null, + ), + nativeAssetRate, + ); return exchangeRatesFromNativeAndCurrencyRates( tokenToNativeAssetRate, - nativeToCurrencyRate, - nativeToUsdRate, + Number(nativeToCurrencyRate), + Number(nativeToUsdRate), ); } - return exchangeRatesFromNativeAndCurrencyRates(); + // For EVM tokens, we use the market data to get the exchange rate + const tokenToNativeAssetRate = + exchangeRateFromMarketData(fromChainId, fromToken.address, marketData) ?? + tokenPriceInNativeAsset(fromTokenExchangeRate, nativeToCurrencyRate); + + return exchangeRatesFromNativeAndCurrencyRates( + tokenToNativeAssetRate, + nativeToCurrencyRate, + nativeToUsdRate, + ); }, ); @@ -671,11 +677,10 @@ export const getFromTokenConversionRate = createSelector( // The cached exchange rate won't be available so the rate from the bridge state is used export const getToTokenConversionRate = createDeepEqualSelector( [ - getToChain, (state: BridgeAppState) => state.metamask.marketData, // rates for non-native evm tokens getAssetsRates, // non-evm conversion rates, multichain equivalent of getMarketData getToToken, - getNetworkConfigurationsByChainId, + getFromChains, (state) => ({ state, toTokenExchangeRate: state.bridge.toTokenExchangeRate, @@ -684,93 +689,80 @@ export const getToTokenConversionRate = createDeepEqualSelector( getMultichainCoinRates, // multichain native rates ], ( - toChain, marketData, conversionRates, toToken, - allNetworksByChainId, + fromChains, { state, toTokenExchangeRate, toTokenUsdExchangeRate }, rates, ) => { + const { chainId } = toToken; + const isToChainEnabled = fromChains.some( + ({ chainId: fromChainId }) => fromChainId === chainId, + ); // When the toChain is not imported, the exchange rate to native asset is not available // The rate in the bridge state is used instead - if ( - toChain?.chainId && - !allNetworksByChainId[toChain.chainId] && - toTokenExchangeRate - ) { + if (!isToChainEnabled && toTokenExchangeRate) { return { valueInCurrency: toTokenExchangeRate, usd: toTokenUsdExchangeRate, }; } - if (toChain?.chainId && toToken) { - const nativeAssetId = getNativeAssetForChainId(toChain.chainId)?.assetId; - const tokenAssetId = toAssetId( - toToken.address, - formatChainIdToCaip(toChain.chainId), - ); - - if (isSolanaChainId(toChain.chainId) && nativeAssetId && tokenAssetId) { - // For SOLANA tokens, we use the conversion rates provided by the multichain rates controller - const tokenToNativeAssetRate = tokenPriceInNativeAsset( - Number(conversionRates?.[tokenAssetId]?.rate ?? null), - Number( - conversionRates?.[nativeAssetId as CaipAssetType]?.rate ?? null, - ), - ); - return exchangeRatesFromNativeAndCurrencyRates( - tokenToNativeAssetRate, - rates?.[toChain.nativeCurrency?.toLowerCase()]?.conversionRate ?? - null, - rates?.[toChain.nativeCurrency?.toLowerCase()]?.usdConversionRate ?? - null, - ); - } - - if (isBitcoinChainId(toChain.chainId) && nativeAssetId && tokenAssetId) { - // For Bitcoin tokens, we use the conversion rates provided by the multichain rates controller - const nativeAssetRate = Number( - conversionRates?.[nativeAssetId as CaipAssetType]?.rate ?? null, - ); - const tokenToNativeAssetRate = tokenPriceInNativeAsset( - Number(conversionRates?.[tokenAssetId]?.rate ?? null), - nativeAssetRate, - ); - return exchangeRatesFromNativeAndCurrencyRates( - tokenToNativeAssetRate, - rates?.[toChain.nativeCurrency?.toLowerCase()]?.conversionRate ?? - null, - rates?.[toChain.nativeCurrency?.toLowerCase()]?.usdConversionRate ?? - null, - ); - } - - const { chainId } = toChain; + const { assetId: nativeAssetId, symbol } = + getNativeAssetForChainId(chainId); + const tokenAssetId = toAssetId( + toToken.address, + formatChainIdToCaip(chainId), + ); + const nativeSymbol = symbol?.toLowerCase(); - const nativeToCurrencyRate = selectConversionRateByChainId( - state, - chainId, + if (isSolanaChainId(chainId) && nativeAssetId && tokenAssetId) { + // For SOLANA tokens, we use the conversion rates provided by the multichain rates controller + const tokenToNativeAssetRate = tokenPriceInNativeAsset( + Number(conversionRates?.[tokenAssetId]?.rate ?? null), + Number(conversionRates?.[nativeAssetId as CaipAssetType]?.rate ?? null), ); - const nativeToUsdRate = getUSDConversionRateByChainId(chainId)(state); - - if (isNativeAddress(toToken.address)) { - return { - valueInCurrency: nativeToCurrencyRate, - usd: nativeToUsdRate, - }; - } + return exchangeRatesFromNativeAndCurrencyRates( + tokenToNativeAssetRate, + rates?.[nativeSymbol]?.conversionRate ?? null, + rates?.[nativeSymbol]?.usdConversionRate ?? null, + ); + } - const tokenToNativeAssetRate = - exchangeRateFromMarketData(chainId, toToken.address, marketData) ?? - tokenPriceInNativeAsset(toTokenExchangeRate, nativeToCurrencyRate); + if (isBitcoinChainId(chainId) && nativeAssetId && tokenAssetId) { + // For Bitcoin tokens, we use the conversion rates provided by the multichain rates controller + const nativeAssetRate = Number( + conversionRates?.[nativeAssetId as CaipAssetType]?.rate ?? null, + ); + const tokenToNativeAssetRate = tokenPriceInNativeAsset( + Number(conversionRates?.[tokenAssetId]?.rate ?? null), + nativeAssetRate, + ); return exchangeRatesFromNativeAndCurrencyRates( tokenToNativeAssetRate, - nativeToCurrencyRate, - nativeToUsdRate, + rates?.[nativeSymbol]?.conversionRate ?? null, + rates?.[nativeSymbol]?.usdConversionRate ?? null, ); } - return exchangeRatesFromNativeAndCurrencyRates(); + + const nativeToCurrencyRate = selectConversionRateByChainId(state, chainId); + const nativeToUsdRate = getUSDConversionRateByChainId(chainId)(state); + + if (isNativeAddress(toToken.address)) { + return { + valueInCurrency: nativeToCurrencyRate, + usd: nativeToUsdRate, + }; + } + + const tokenToNativeAssetRate = + exchangeRateFromMarketData(chainId, toToken.address, marketData) ?? + tokenPriceInNativeAsset(toTokenExchangeRate, nativeToCurrencyRate); + return exchangeRatesFromNativeAndCurrencyRates( + tokenToNativeAssetRate, + nativeToCurrencyRate, + nativeToUsdRate, + ); }, ); @@ -792,17 +784,8 @@ export const getBridgeQuotes = createSelector( }), ); -export const getIsBridgeTx = createDeepEqualSelector( - getFromChain, - getToChain, - (fromChain, toChain) => - toChain && fromChain?.chainId - ? fromChain.chainId !== toChain.chainId - : false, -); - export const getIsSwap = createDeepEqualSelector( - getQuoteRequest, + [getQuoteRequest], ({ srcChainId, destChainId }) => Boolean( srcChainId && @@ -812,29 +795,28 @@ export const getIsSwap = createDeepEqualSelector( ); const _getValidatedSrcAmount = createSelector( - getFromToken, - (state: BridgeAppState) => state.metamask.quoteRequest.srcTokenAmount, - (fromToken, srcTokenAmount) => - srcTokenAmount && fromToken?.decimals - ? calcTokenAmount(srcTokenAmount, Number(fromToken.decimals)).toString() + [ + (state) => getFromToken(state).decimals, + (state: BridgeAppState) => state.metamask.quoteRequest.srcTokenAmount, + ], + (decimals, srcTokenAmount) => + srcTokenAmount && decimals + ? calcTokenAmount(srcTokenAmount, Number(decimals)).toString() : null, ); export const getFromAmountInCurrency = createSelector( - getFromToken, - getFromChain, - _getValidatedSrcAmount, - getFromTokenConversionRate, + [getFromToken, _getValidatedSrcAmount, getFromTokenConversionRate], ( fromToken, - fromChain, validatedSrcAmount, { valueInCurrency: fromTokenToCurrencyExchangeRate, usd: fromTokenToUsdExchangeRate, }, ) => { - if (fromToken?.symbol && fromChain?.chainId && validatedSrcAmount) { + const fromChainId = fromToken.chainId; + if (fromToken.symbol && fromChainId && validatedSrcAmount) { if (fromTokenToCurrencyExchangeRate) { return { valueInCurrency: new BigNumber(validatedSrcAmount).mul( @@ -982,16 +964,15 @@ export const needsBitcoinAccountForDestination = createDeepEqualSelector( ); export const getIsToOrFromNonEvm = createSelector( - getFromChain, - getToChain, - (fromChain, toChain) => { - if (!fromChain?.chainId || !toChain?.chainId) { + [getFromChainId, getToChainId], + (fromChainId, toChainId) => { + if (!fromChainId || !toChainId) { return false; } // Parse the CAIP chain IDs to get their namespaces - const fromCaipChainId = formatChainIdToCaip(fromChain.chainId); - const toCaipChainId = formatChainIdToCaip(toChain.chainId); + const fromCaipChainId = formatChainIdToCaip(fromChainId); + const toCaipChainId = formatChainIdToCaip(toChainId); const { namespace: fromNamespace } = parseCaipChainId(fromCaipChainId); const { namespace: toNamespace } = parseCaipChainId(toCaipChainId); @@ -1003,15 +984,14 @@ export const getIsToOrFromNonEvm = createSelector( ); export const getIsSolanaSwap = createSelector( - getFromChain, - getToChain, - (fromChain, toChain) => { - if (!fromChain?.chainId || !toChain?.chainId) { + [getFromChainId, getToChainId], + (fromChainId, toChainId) => { + if (!fromChainId || !toChainId) { return false; } - const fromChainIsSolana = isSolanaChainId(fromChain.chainId); - const toChainIsSolana = isSolanaChainId(toChain.chainId); + const fromChainIsSolana = isSolanaChainId(fromChainId); + const toChainIsSolana = isSolanaChainId(toChainId); // Return true if BOTH chains are Solana (Solana-to-Solana swap) return fromChainIsSolana && toChainIsSolana; diff --git a/ui/ducks/bridge/types.ts b/ui/ducks/bridge/types.ts index cec78727bf4c..91a15bf9a72b 100644 --- a/ui/ducks/bridge/types.ts +++ b/ui/ducks/bridge/types.ts @@ -11,14 +11,13 @@ import { type TxAlert } from '../../../shared/types/security-alerts-api'; export type BridgeToken = { address: string; - assetId?: CaipAssetType; + assetId: CaipAssetType; symbol: string; + name?: string; image: string; decimals: number; - chainId: number | Hex | ChainId | CaipChainId; - balance: string; // raw balance - // TODO deprecate this field and use balance instead - string: string | undefined; // normalized balance as a stringified number + chainId: CaipChainId; + balance: string; // normalized balance as a stringified number tokenFiatAmount?: number | null; occurrences?: number; aggregators?: string[]; @@ -26,12 +25,6 @@ export type BridgeToken = { }; export type BridgeState = { - /* - * This stores the user's selected destination chain, and will be null if the user has not selected a destination chain - * This should not be accessed directly in components/hooks, use the getToChain selector instead - * The getToChain selector uses the source chain as the destination chain by default if toChainId is null - */ - toChainId: CaipChainId | null; fromToken: BridgeToken | null; toToken: BridgeToken | null; fromTokenInputValue: string | null; @@ -47,13 +40,12 @@ export type BridgeState = { txAlert: TxAlert | null; }; -export type ChainIdPayload = { payload: ChainId | Hex | CaipChainId | null }; export type TokenPayload = { payload: { address: GenericQuoteRequest['srcTokenAddress']; symbol: string; decimals: number; - chainId: Exclude; + chainId: ChainId | Hex | CaipChainId; balance?: string; string?: string; image?: string; diff --git a/ui/ducks/bridge/utils.ts b/ui/ducks/bridge/utils.ts index 534e83702ab4..5f44820d4a8f 100644 --- a/ui/ducks/bridge/utils.ts +++ b/ui/ducks/bridge/utils.ts @@ -6,10 +6,6 @@ import { } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; import type { ContractMarketData } from '@metamask/assets-controllers'; -import { - AddNetworkFields, - NetworkConfiguration, -} from '@metamask/network-controller'; import { ChainId, type TxData, @@ -235,16 +231,6 @@ export const exchangeRatesFromNativeAndCurrencyRates = ( }; }; -export const isNetworkAdded = ( - v: - | NetworkConfiguration - | AddNetworkFields - | (Omit & { chainId: CaipChainId }) - | undefined, -): v is NetworkConfiguration => - v !== undefined && - 'networkClientId' in v.rpcEndpoints[v.defaultRpcEndpointIndex]; - const getTokenImage = (payload: TokenPayload['payload']) => { if (!payload) { return ''; @@ -273,21 +259,18 @@ const getTokenImage = (payload: TokenPayload['payload']) => { }; export const toBridgeToken = ( - payload: TokenPayload['payload'], -): BridgeToken | null => { - if (!payload) { - return null; - } + payload: NonNullable, +): BridgeToken => { const caipChainId = formatChainIdToCaip(payload.chainId); return { ...payload, balance: payload.balance ?? '0', - string: payload.string ?? '0', - chainId: payload.chainId, + chainId: formatChainIdToCaip(payload.chainId), image: getTokenImage(payload), assetId: payload.assetId ?? toAssetId(payload.address, caipChainId), }; }; + const createBridgeTokenPayload = ( tokenData: { address: string; @@ -297,7 +280,7 @@ const createBridgeTokenPayload = ( assetId?: string; }, chainId: ChainId | Hex | CaipChainId, -): TokenPayload['payload'] | null => { +) => { const { assetId, ...rest } = tokenData; return toBridgeToken({ ...rest, @@ -306,9 +289,9 @@ const createBridgeTokenPayload = ( }; export const getDefaultToToken = ( - targetChainId: CaipChainId, fromToken: Pick, 'address' | 'chainId'>, ) => { + const targetChainId = formatChainIdToCaip(fromToken.chainId); const commonPair = BRIDGE_CHAINID_COMMON_TOKEN_PAIR[targetChainId]; if (commonPair) { @@ -344,5 +327,5 @@ export const getDefaultToToken = ( return createBridgeTokenPayload(nativeAsset, targetChainId); } - return null; + throw new Error(`No default token found for chainId: ${targetChainId}`); }; diff --git a/ui/hooks/bridge/useBridgeExchangeRates.ts b/ui/hooks/bridge/useBridgeExchangeRates.ts index 95ff724d7541..df910a6c6c9f 100644 --- a/ui/hooks/bridge/useBridgeExchangeRates.ts +++ b/ui/hooks/bridge/useBridgeExchangeRates.ts @@ -4,7 +4,6 @@ import { getBridgeQuotes, getFromToken, getQuoteRequest, - getToChain, getToToken, } from '../../ducks/bridge/selectors'; import { getMarketData, getParticipateInMetaMetrics } from '../../selectors'; @@ -15,15 +14,12 @@ import { setSrcTokenExchangeRates, } from '../../ducks/bridge/bridge'; import { exchangeRateFromMarketData } from '../../ducks/bridge/utils'; -import { useMultichainSelector } from '../useMultichainSelector'; -import { getMultichainCurrentChainId } from '../../selectors/multichain'; export const useBridgeExchangeRates = () => { const { srcTokenAddress, destTokenAddress } = useSelector(getQuoteRequest); const { activeQuote } = useSelector(getBridgeQuotes); - const fromChainId = useMultichainSelector(getMultichainCurrentChainId); - const toChain = useSelector(getToChain); - const toChainId = toChain?.chainId; + const fromChainId = useSelector(getFromToken).chainId; + const toChainId = useSelector(getToToken).chainId; const isMetaMetricsEnabled = useSelector(getParticipateInMetaMetrics); diff --git a/ui/hooks/bridge/useBridgeQueryParams.ts b/ui/hooks/bridge/useBridgeQueryParams.ts index dde7bda93b01..8dc04d994fe6 100644 --- a/ui/hooks/bridge/useBridgeQueryParams.ts +++ b/ui/hooks/bridge/useBridgeQueryParams.ts @@ -2,18 +2,14 @@ import { useEffect, useMemo, useCallback, useState, useRef } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useNavigate, useLocation } from 'react-router-dom-v5-compat'; import { - CaipAssetType, + type CaipAssetType, CaipAssetTypeStruct, parseCaipAssetType, } from '@metamask/utils'; import { - formatChainIdToCaip, getNativeAssetForChainId, - isCrossChain, isNativeAddress, } from '@metamask/bridge-controller'; -import { type InternalAccount } from '@metamask/keyring-internal-api'; -import { type NetworkConfiguration } from '@metamask/network-controller'; import { type AssetMetadata, fetchAssetMetadataForAssetIds, @@ -21,19 +17,12 @@ import { import { BridgeQueryParams } from '../../../shared/lib/deep-links/routes/swap'; import { calcTokenAmount } from '../../../shared/lib/transactions-controller-utils'; import { - setEVMSrcTokenBalance, - setEVMSrcNativeBalance, - setFromChain, setFromToken, setFromTokenInputValue, + setLatestEVMBalances, setToToken, } from '../../ducks/bridge/actions'; -import { - getFromAccount, - getFromChain, - getFromChains, - getFromToken, -} from '../../ducks/bridge/selectors'; +import { getFromChains, getFromToken } from '../../ducks/bridge/selectors'; const parseAsset = (assetId: string | null) => { if (!assetId) { @@ -81,9 +70,7 @@ const fetchAssetMetadata = async ( export const useBridgeQueryParams = () => { const dispatch = useDispatch(); const fromChains = useSelector(getFromChains); - const fromChain = useSelector(getFromChain); const fromToken = useSelector(getFromToken); - const selectedAccount = useSelector(getFromAccount); const abortController = useRef(new AbortController()); @@ -168,53 +155,22 @@ export const useBridgeQueryParams = () => { }, [searchParams]); // Set fromChain and fromToken - const setFromChainAndToken = useCallback( - ( - fromTokenMetadata, - fromAsset, - networks: NetworkConfiguration[], - account: InternalAccount | null, - network?: NetworkConfiguration, - ) => { - const { chainId: assetChainId } = fromAsset; - - if (fromTokenMetadata) { - const { chainId, assetReference } = parseCaipAssetType( - fromTokenMetadata.assetId, - ); - const nativeAsset = getNativeAssetForChainId(chainId); - // TODO remove this after v36.0.0 bridge-controller bump - const isNativeReference = nativeAsset?.assetId.includes(assetReference); - const token = { - ...fromTokenMetadata, - chainId, - address: - isNativeReference || isNativeAddress(assetReference) - ? (nativeAsset?.address ?? '') - : assetReference, - }; - // If asset's chain is the same as fromChain, only set the fromToken - if (network && assetChainId === formatChainIdToCaip(network.chainId)) { - dispatch(setFromToken(token)); - } else { - // Find the chain matching the srcAsset's chainId - const targetChain = networks.find( - (chain) => formatChainIdToCaip(chain.chainId) === assetChainId, - ); - if (targetChain) { - dispatch( - setFromChain({ - networkConfig: targetChain, - selectedAccount: account, - token, - }), - ); - } - } - } - }, - [], - ); + const setFromTokenWithMetadata = useCallback((fromTokenMetadata) => { + if (fromTokenMetadata) { + const { chainId, assetReference } = parseCaipAssetType( + fromTokenMetadata.assetId, + ); + const nativeAsset = getNativeAssetForChainId(chainId); + const token = { + ...fromTokenMetadata, + chainId, + address: isNativeAddress(assetReference) + ? (nativeAsset?.address ?? '') + : assetReference, + }; + dispatch(setFromToken(token)); + } + }, []); const setToChainAndToken = useCallback( async (toTokenMetadata: AssetMetadata) => { @@ -247,20 +203,8 @@ export const useBridgeQueryParams = () => { ]; // Process from chain/token first - setFromChainAndToken( - fromTokenMetadata, - parsedFromAssetId, - fromChains, - selectedAccount, - fromChain, - ); - }, [ - assetMetadataByAssetId, - parsedFromAssetId, - fromChains, - fromChain, - selectedAccount, - ]); + setFromTokenWithMetadata(fromTokenMetadata); + }, [assetMetadataByAssetId, parsedFromAssetId]); // Set toChainId and toToken useEffect(() => { @@ -307,18 +251,9 @@ export const useBridgeQueryParams = () => { // Wait for url params to be applied !parsedFromAssetId && !searchParams.get(BridgeQueryParams.FROM) && - fromToken && - // Wait for network to be changed if needed - !isCrossChain(fromToken.chainId, fromChain?.chainId) && - selectedAccount + fromToken ) { - dispatch(setEVMSrcTokenBalance(fromToken, selectedAccount.address)); - dispatch( - setEVMSrcNativeBalance({ - selectedAddress: selectedAccount.address, - chainId: fromToken.chainId, - }), - ); + dispatch(setLatestEVMBalances(fromToken)); } - }, [parsedFromAssetId, selectedAccount, fromToken, fromChain, searchParams]); + }, [parsedFromAssetId, fromToken, searchParams]); }; diff --git a/ui/hooks/bridge/useBridging.ts b/ui/hooks/bridge/useBridging.ts index 5f2ad074bb67..5379670c0ba3 100644 --- a/ui/hooks/bridge/useBridging.ts +++ b/ui/hooks/bridge/useBridging.ts @@ -32,12 +32,8 @@ import { BridgeQueryParams } from '../../../shared/lib/deep-links/routes/swap'; import { trace, TraceName } from '../../../shared/lib/trace'; import { toAssetId } from '../../../shared/lib/asset-utils'; import { ALL_ALLOWED_BRIDGE_CHAIN_IDS } from '../../../shared/constants/bridge'; -import { - getFromChains, - getLastSelectedChainId, -} from '../../ducks/bridge/selectors'; -import { getMultichainProviderConfig } from '../../selectors/multichain'; -import { CHAIN_IDS } from '../../../shared/constants/network'; +import { getFromChain } from '../../ducks/bridge/selectors'; +import { getFromChains } from '../../ducks/bridge/selectors'; const useBridging = () => { const navigate = useNavigate(); @@ -48,8 +44,7 @@ const useBridging = () => { const isMetaMetricsEnabled = useSelector(getParticipateInMetaMetrics); const isMarketingEnabled = useSelector(getDataCollectionForMarketing); - const lastSelectedChainId = useSelector(getLastSelectedChainId); - const providerConfig = useSelector(getMultichainProviderConfig); + const fromChain = useSelector(getFromChain); const fromChains = useSelector(getFromChains); const isChainIdEnabledForBridging = useCallback( @@ -83,11 +78,8 @@ const useBridging = () => { * * default fromChain: srctoken.chainId > lastSelectedId > MAINNET */ - const targetChainId = isChainIdEnabledForBridging(lastSelectedChainId) - ? lastSelectedChainId - : CHAIN_IDS.MAINNET; - if (!srcAssetIdToUse && targetChainId !== providerConfig?.chainId) { - srcAssetIdToUse = getNativeAssetForChainId(targetChainId)?.assetId; + if (!srcAssetIdToUse) { + srcAssetIdToUse = getNativeAssetForChainId(fromChain.chainId)?.assetId; } trace({ @@ -105,7 +97,7 @@ const useBridging = () => { text: 'Swap', // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 // eslint-disable-next-line @typescript-eslint/naming-convention - chain_id: srcToken?.chainId ?? lastSelectedChainId, + chain_id: srcToken?.chainId ?? fromChain?.chainId, }, }); dispatch( @@ -139,8 +131,7 @@ const useBridging = () => { trackEvent, isMetaMetricsEnabled, isMarketingEnabled, - lastSelectedChainId, - providerConfig?.chainId, + fromChain?.chainId, isChainIdEnabledForBridging, ], ); diff --git a/ui/hooks/bridge/useIsTxSubmittable.ts b/ui/hooks/bridge/useIsTxSubmittable.ts index 35986dfce132..dd10e018cd24 100644 --- a/ui/hooks/bridge/useIsTxSubmittable.ts +++ b/ui/hooks/bridge/useIsTxSubmittable.ts @@ -6,13 +6,10 @@ import { getValidationErrors, getToToken, } from '../../ducks/bridge/selectors'; -import { getMultichainCurrentChainId } from '../../selectors/multichain'; -import { useMultichainSelector } from '../useMultichainSelector'; export const useIsTxSubmittable = () => { const fromToken = useSelector(getFromToken); const toToken = useSelector(getToToken); - const fromChainId = useMultichainSelector(getMultichainCurrentChainId); const fromAmount = useSelector(getFromAmount); const { activeQuote } = useSelector(getBridgeQuotes); @@ -26,7 +23,6 @@ export const useIsTxSubmittable = () => { return Boolean( fromToken && toToken && - fromChainId && fromAmount && activeQuote && !isInsufficientBalance && diff --git a/ui/hooks/bridge/useSmartSlippage.ts b/ui/hooks/bridge/useSmartSlippage.ts index 96c84834f8c9..56487c104954 100644 --- a/ui/hooks/bridge/useSmartSlippage.ts +++ b/ui/hooks/bridge/useSmartSlippage.ts @@ -1,20 +1,12 @@ import { useCallback, useEffect } from 'react'; -import { useDispatch } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { calculateSlippage, getSlippageReason, type SlippageContext, } from '../../pages/bridge/utils/slippage-service'; import { setSlippage } from '../../ducks/bridge/actions'; -import type { BridgeToken } from '../../ducks/bridge/types'; - -type UseSmartSlippageParams = { - fromChain: { chainId: string } | null | undefined; - toChain: { chainId: string } | null | undefined; - fromToken: BridgeToken | null; - toToken: BridgeToken | null; - isSwap: boolean; -}; +import { getFromToken, getToToken } from '../../ducks/bridge/selectors'; // This hook doesn't return anything as it only dispatches slippage updates // The slippage value can be accessed via getSlippage selector @@ -28,55 +20,44 @@ type UseSmartSlippageParams = { * - Supports Solana AUTO mode (undefined) * * @param options0 - * @param options0.fromChain - * @param options0.toChain * @param options0.fromToken * @param options0.toToken - * @param options0.isSwap */ -export function useSmartSlippage({ - fromChain, - toChain, - fromToken, - toToken, - isSwap, -}: UseSmartSlippageParams): void { +export function useSmartSlippage(): void { const dispatch = useDispatch(); - // Calculate the appropriate slippage for current context - const calculateCurrentSlippage = useCallback(() => { - const context: SlippageContext = { - fromChain, - toChain, - fromToken, - toToken, - isSwap, - }; + const fromToken = useSelector(getFromToken); + const toToken = useSelector(getToToken); + // Calculate the appropriate slippage for current context + const calculateCurrentSlippage = useCallback((context: SlippageContext) => { const slippage = calculateSlippage(context); // Log the reason in development if (process.env.NODE_ENV === 'development') { const reason = getSlippageReason(context); - console.log( - `[useSmartSlippage] Slippage calculated: ${slippage ?? 'AUTO'}% - ${reason}`, - ); + // console.log( + // `[useSmartSlippage] Slippage calculated: ${slippage ?? 'AUTO'}% - ${reason}`, + // ); } return slippage; - }, [fromChain, toChain, fromToken, toToken, isSwap]); + }, []); // Update slippage when context changes useEffect(() => { - const newSlippage = calculateCurrentSlippage(); + console.log('===useSmartSlippage', { + fromToken, + toToken, + }); + const newSlippage = calculateCurrentSlippage({ + fromToken, + toToken, + }); dispatch(setSlippage(newSlippage)); }, [ - fromChain, - toChain, fromToken, + // TODO trigger on toToken, but this causes an infinite loop toToken, - isSwap, - calculateCurrentSlippage, - dispatch, ]); } diff --git a/ui/hooks/bridge/useTokenAlerts.ts b/ui/hooks/bridge/useTokenAlerts.ts index 703972ab1ce2..4a420bbabc0a 100644 --- a/ui/hooks/bridge/useTokenAlerts.ts +++ b/ui/hooks/bridge/useTokenAlerts.ts @@ -3,12 +3,7 @@ import { formatAddressToCaipReference, isNativeAddress, } from '@metamask/bridge-controller'; -import { - getFromToken, - getFromChain, - getToToken, - getToChain, -} from '../../ducks/bridge/selectors'; +import { getToToken } from '../../ducks/bridge/selectors'; import { convertChainIdToBlockAidChainName, fetchTokenAlert, @@ -18,22 +13,13 @@ import { AllowedBridgeChainIds } from '../../../shared/constants/bridge'; import { useAsyncResult } from '../useAsync'; export const useTokenAlerts = () => { - const fromToken = useSelector(getFromToken); - const fromChain = useSelector(getFromChain); const toToken = useSelector(getToToken); - const toChain = useSelector(getToChain); const { value: tokenAlert } = useAsyncResult(async () => { - if ( - fromToken && - fromChain && - toToken && - toChain && - !isNativeAddress(toToken.address) - ) { + if (!isNativeAddress(toToken.address)) { const chainName = convertChainIdToBlockAidChainName( - toChain?.chainId as AllowedBridgeChainIds, + toToken.chainId as AllowedBridgeChainIds, ); if (chainName) { return await fetchTokenAlert( @@ -43,7 +29,7 @@ export const useTokenAlerts = () => { } } return null; - }, [toToken?.address, toChain?.chainId]); + }, [toToken.address, toToken.chainId]); return { tokenAlert }; }; diff --git a/ui/hooks/bridge/useTokensWithFiltering.ts b/ui/hooks/bridge/useTokensWithFiltering.ts index bebf7c3d2cc9..3f2b9bb64a2a 100644 --- a/ui/hooks/bridge/useTokensWithFiltering.ts +++ b/ui/hooks/bridge/useTokensWithFiltering.ts @@ -112,6 +112,7 @@ type FilterPredicate = ( * - popularity * - all other tokens * + * @deprecated use useMultichainBalances instead * @param chainId - the selected src/dest chainId * @param tokenToExclude - a token to exclude from the token list, usually the token being swapped from * @param tokenToExclude.symbol @@ -259,7 +260,7 @@ export const useTokensWithFiltering = ( // Yield multichain tokens with balances and are not blocked for (const token of multichainTokensWithBalance) { if (shouldAddToken(token.symbol, token.address, token.chainId)) { - if (isNativeAddress(token.address) || token.isNative) { + if (isNativeAddress(token.address)) { yield { symbol: token.symbol, chainId: token.chainId, diff --git a/ui/hooks/useMultichainBalances.ts b/ui/hooks/useMultichainBalances.ts index 3e4ed02155c6..b8a744be0293 100644 --- a/ui/hooks/useMultichainBalances.ts +++ b/ui/hooks/useMultichainBalances.ts @@ -1,6 +1,7 @@ import { useMemo } from 'react'; import { useSelector } from 'react-redux'; import { + isCaipChainId, parseCaipAssetType, type CaipChainId, type Hex, @@ -8,8 +9,8 @@ import { import { SolScope, BtcScope } from '@metamask/keyring-api'; import { type InternalAccount } from '@metamask/keyring-internal-api'; import { BigNumber } from 'bignumber.js'; +import { toEvmCaipChainId } from '@metamask/multichain-network-controller'; import { AssetType } from '../../shared/constants/transaction'; -import type { TokenWithBalance } from '../components/app/assets/types'; import { getAccountAssets, getAssetsMetadata, @@ -23,22 +24,13 @@ import { getSelectedAccountGroup, } from '../selectors/multichain-accounts/account-tree'; import { type MultichainAccountsState } from '../selectors/multichain-accounts/account-tree.types'; +import { toAssetId } from '../../shared/lib/asset-utils'; import { useMultichainSelector } from './useMultichainSelector'; const useNonEvmAssetsWithBalances = ( accountId?: string, accountType?: InternalAccount['type'], -): (Omit & { - chainId: `${string}:${string}`; - decimals: number; - address: string; - assetId: `${string}:${string}`; - string: string; - balance: string; - tokenFiatAmount: number; - symbol: string; - accountType?: InternalAccount['type']; -})[] => { +) => { // non-evm tokens owned by non-evm account, includes native and non-native assets const assetsByAccountId = useSelector(getAccountAssets); const assetMetadataById = useSelector(getAssetsMetadata); @@ -149,13 +141,21 @@ export const useMultichainBalances = ( // return TokenWithFiat sorted by fiat balance amount const assetsWithBalance = useMemo(() => { return [ - ...evmBalancesWithFiatByChainId, + ...evmBalancesWithFiatByChainId.map((t) => ({ + ...t, + chainId: isCaipChainId(t.chainId) + ? t.chainId + : toEvmCaipChainId(t.chainId), + assetId: toAssetId( + t.address, + isCaipChainId(t.chainId) ? t.chainId : toEvmCaipChainId(t.chainId), + ), + })), ...solanaBalancesWithFiat, ...bitcoinBalancesWithFiat, ] .map((token) => ({ ...token, - type: token.isNative ? AssetType.native : AssetType.token, })) .sort((a, b) => (b.tokenFiatAmount ?? 0) - (a.tokenFiatAmount ?? 0)); }, [ diff --git a/ui/pages/bridge/hooks/useGasIncluded7702.ts b/ui/pages/bridge/hooks/useGasIncluded7702.ts index b45b52dffccd..ff39ae95ea0b 100644 --- a/ui/pages/bridge/hooks/useGasIncluded7702.ts +++ b/ui/pages/bridge/hooks/useGasIncluded7702.ts @@ -9,10 +9,7 @@ import { getIsSmartTransaction } from '../../../../shared/modules/selectors'; import { isRelaySupported } from '../../../store/actions'; import { isAtomicBatchSupported } from '../../../store/controller-actions/transaction-controller'; import { getUseSmartAccount } from '../../confirmations/selectors/preferences'; - -type Chain = { - chainId: string; -}; +import type { BridgeToken } from '../../../ducks/bridge/types'; type Account = { address: string; @@ -21,7 +18,7 @@ type Account = { type UseGasIncluded7702Params = { isSwap: boolean; selectedAccount: Account | null | undefined; - fromChain: Chain | null | undefined; + fromChainId: BridgeToken['chainId']; isSendBundleSupportedForChain: boolean; }; @@ -31,20 +28,20 @@ type UseGasIncluded7702Params = { * @param params - Configuration object * @param params.isSwap - Whether this is a swap transaction * @param params.selectedAccount - The selected account - * @param params.fromChain - The source chain + * @param params.fromChainId - The source chain id * @param params.isSendBundleSupportedForChain - Whether send bundle is supported for the chain * @returns Whether gasless 7702 is supported */ export function useGasIncluded7702({ isSwap, selectedAccount, - fromChain, + fromChainId, isSendBundleSupportedForChain, }: UseGasIncluded7702Params): boolean { const [isGasIncluded7702Supported, setIsGasIncluded7702Supported] = useState(false); const isSmartTransaction = useSelector((state) => - getIsSmartTransaction(state as never, fromChain?.chainId), + getIsSmartTransaction(state as never, fromChainId), ); const smartAccountOptedIn = useSelector(getUseSmartAccount); @@ -58,7 +55,7 @@ export function useGasIncluded7702({ !smartAccountOptedIn || !isSwap || !selectedAccount?.address || - !fromChain?.chainId + !fromChainId ) { if (!isCancelled) { setIsGasIncluded7702Supported(false); @@ -66,13 +63,13 @@ export function useGasIncluded7702({ return; } - if (isNonEvmChainId(fromChain.chainId)) { + if (isNonEvmChainId(fromChainId)) { setIsGasIncluded7702Supported(false); return; } try { - const chainIdInHex = formatChainIdToHex(fromChain.chainId); + const chainIdInHex = formatChainIdToHex(fromChainId); const atomicBatchResult = await isAtomicBatchSupported({ address: selectedAccount.address as Hex, chainIds: [chainIdInHex], @@ -84,7 +81,7 @@ export function useGasIncluded7702({ const atomicBatchChainSupport = atomicBatchResult?.find( (result) => - result.chainId.toLowerCase() === fromChain.chainId.toLowerCase(), + result.chainId.toLowerCase() === fromChainId.toLowerCase(), ); const relaySupportsChain = await isRelaySupported(chainIdInHex); @@ -111,7 +108,7 @@ export function useGasIncluded7702({ }; }, [ smartAccountOptedIn, - fromChain?.chainId, + fromChainId, isSendBundleSupportedForChain, isSmartTransaction, isSwap, diff --git a/ui/pages/bridge/hooks/useIsSendBundleSupported.ts b/ui/pages/bridge/hooks/useIsSendBundleSupported.ts index 5618be91bbea..7e68ee3a1753 100644 --- a/ui/pages/bridge/hooks/useIsSendBundleSupported.ts +++ b/ui/pages/bridge/hooks/useIsSendBundleSupported.ts @@ -1,20 +1,19 @@ import { useEffect, useState } from 'react'; -import { formatChainIdToHex } from '@metamask/bridge-controller'; +import { + formatChainIdToHex, + isNonEvmChainId, +} from '@metamask/bridge-controller'; import { isSendBundleSupported } from '../../../store/actions'; -import { isNonEvmChain } from '../../../ducks/bridge/utils'; - -type Chain = { - chainId: string; -}; +import type { BridgeToken } from '../../../ducks/bridge/types'; /** * Custom hook to check if send bundle is supported for a chain * - * @param fromChain - The source chain to check support for + * @param fromChainId - The source chain id to check support for * @returns Whether send bundle is supported for the chain */ export function useIsSendBundleSupported( - fromChain: Chain | null | undefined, + fromChainId: BridgeToken['chainId'], ): boolean { const [isSendBundleSupportedForChain, setIsSendBundleSupportedForChain] = useState(false); @@ -23,21 +22,21 @@ export function useIsSendBundleSupported( let isCancelled = false; const checkSendBundleSupport = async () => { - if (!fromChain?.chainId) { + if (!fromChainId) { if (!isCancelled) { setIsSendBundleSupportedForChain(false); } return; } - if (isNonEvmChain(fromChain.chainId)) { + if (isNonEvmChainId(fromChainId)) { setIsSendBundleSupportedForChain(false); return; } try { const isSupported = await isSendBundleSupported( - formatChainIdToHex(fromChain.chainId), + formatChainIdToHex(fromChainId), ); if (!isCancelled) { @@ -56,7 +55,7 @@ export function useIsSendBundleSupported( return () => { isCancelled = true; }; - }, [fromChain?.chainId]); + }, [fromChainId]); return isSendBundleSupportedForChain; } diff --git a/ui/pages/bridge/hooks/useTokenList.ts b/ui/pages/bridge/hooks/useTokenList.ts new file mode 100644 index 000000000000..62614b8c8afa --- /dev/null +++ b/ui/pages/bridge/hooks/useTokenList.ts @@ -0,0 +1,3 @@ +export const useTokens = () => { + return []; +}; diff --git a/ui/pages/bridge/index.tsx b/ui/pages/bridge/index.tsx index b7544a44328c..6fc69d936704 100644 --- a/ui/pages/bridge/index.tsx +++ b/ui/pages/bridge/index.tsx @@ -1,10 +1,11 @@ import React, { useContext, useEffect, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { Route, Routes } from 'react-router-dom-v5-compat'; +import { type Hex } from '@metamask/utils'; import { UnifiedSwapBridgeEventName, - // TODO: update this with all non-EVM chains when bitcoin added. - isSolanaChainId, + formatChainIdToHex, + isNonEvmChainId, } from '@metamask/bridge-controller'; import { I18nContext } from '../../contexts/i18n'; import { clearSwapsState } from '../../ducks/swaps/swaps'; @@ -21,7 +22,6 @@ import { ButtonIconSize, IconName, } from '../../components/component-library'; -import { getSelectedNetworkClientId } from '../../../shared/modules/selectors/networks'; import useBridging from '../../hooks/bridge/useBridging'; import { Content, @@ -42,6 +42,7 @@ import { TextVariant } from '../../helpers/constants/design-system'; import { useTxAlerts } from '../../hooks/bridge/useTxAlerts'; import { getFromChain, getBridgeQuotes } from '../../ducks/bridge/selectors'; import { useSafeNavigation } from '../../hooks/useSafeNavigation'; +import { getNetworkConfigurationIdByChainId } from '../../selectors'; import PrepareBridgePage from './prepare/prepare-bridge-page'; import AwaitingSignaturesCancelButton from './awaiting-signatures/awaiting-signatures-cancel-button'; import AwaitingSignatures from './awaiting-signatures/awaiting-signatures'; @@ -69,8 +70,6 @@ const CrossChainSwap = ({ location }: CrossChainSwapProps) => { 'isFromTransactionShield', ); - const selectedNetworkClientId = useSelector(getSelectedNetworkClientId); - const resetControllerAndInputStates = async () => { await dispatch(resetBridgeState()); }; @@ -79,10 +78,13 @@ const CrossChainSwap = ({ location }: CrossChainSwapProps) => { // Get chain information to determine if we need gas estimates const fromChain = useSelector(getFromChain); - // Only fetch gas estimates if the source chain is EVM (not Solana) - const shouldFetchGasEstimates = - // TODO: update this with all non-EVM chains when bitcoin added. - fromChain?.chainId && !isSolanaChainId(fromChain.chainId); + const isEvmChain = !isNonEvmChainId(fromChain.chainId); + const networkClientIdsByHexChainId: Record = useSelector( + getNetworkConfigurationIdByChainId, + ); + const selectedNetworkClientId = isEvmChain + ? networkClientIdsByHexChainId[formatChainIdToHex(fromChain.chainId)] + : undefined; useEffect(() => { dispatch( @@ -106,7 +108,7 @@ const CrossChainSwap = ({ location }: CrossChainSwapProps) => { }, []); // Needed for refreshing gas estimates (only for EVM chains) - useGasFeeEstimates(selectedNetworkClientId, shouldFetchGasEstimates); + useGasFeeEstimates(selectedNetworkClientId, isEvmChain); // Needed for fetching exchange rates for tokens that have not been imported useBridgeExchangeRates(); // Emits events related to quote-fetching diff --git a/ui/pages/bridge/layout/row.tsx b/ui/pages/bridge/layout/row.tsx index eeb94a7e06f7..dd3f4b1a8e66 100644 --- a/ui/pages/bridge/layout/row.tsx +++ b/ui/pages/bridge/layout/row.tsx @@ -11,7 +11,7 @@ import { JustifyContent, } from '../../../helpers/constants/design-system'; -const Row = (props: ContainerProps<'div'>) => { +const Row = (props: ContainerProps<'div' | 'button'>) => { return ( { // Remove characters that are not numbers or decimal points if rendering a controlled or pasted value @@ -61,21 +59,17 @@ export const BridgeInputGroup = ({ token, onAssetChange, onAmountChange, - networkProps, - isTokenListLoading, - customTokenListGenerator, + networks, amountFieldProps, amountInFiat, onMaxButtonClick, - isMultiselectEnabled, onBlockExplorerClick, buttonProps, containerProps = {}, - isDestinationToken = false, }: { amountInFiat?: string; onAmountChange?: (value: string) => void; - token: BridgeToken | null; + token: BridgeToken; buttonProps: { testId: string }; amountFieldProps: Pick< React.ComponentProps, @@ -84,16 +78,14 @@ export const BridgeInputGroup = ({ onMaxButtonClick?: (value: string) => void; onBlockExplorerClick?: (token: BridgeToken) => void; containerProps?: React.ComponentProps; - isDestinationToken?: boolean; } & Pick< - React.ComponentProps, - | 'networkProps' - | 'header' - | 'customTokenListGenerator' - | 'onAssetChange' - | 'isTokenListLoading' - | 'isMultiselectEnabled' ->) => { + React.ComponentProps, + 'header' | 'onAssetChange' +> & + Pick< + React.ComponentProps, + 'networks' + >) => { const t = useI18nContext(); const { isLoading } = useSelector(getBridgeQuotes); @@ -102,9 +94,7 @@ export const BridgeInputGroup = ({ const currency = useSelector(getCurrentCurrency); const locale = useSelector(getIntlLocale); - const currentChainId = useSelector(getMultichainCurrentChainId); - const selectedChainId = networkProps?.network?.chainId ?? currentChainId; - + const selectedChainId = token?.chainId; const [, handleCopy] = useCopyToClipboard(MINUTE); const inputRef = useRef(null); @@ -132,10 +122,9 @@ export const BridgeInputGroup = ({ const handleAddressClick = () => { if (token && selectedChainId) { const caipChainId = formatChainIdToCaip(selectedChainId); - const isSolana = caipChainId === MultichainNetworks.SOLANA; let blockExplorerUrl = ''; - if (isSolana) { + if (isNonEvmChainId(selectedChainId)) { const blockExplorerUrls = MULTICHAIN_NETWORK_BLOCK_EXPLORER_FORMAT_URLS_MAP[caipChainId]; if (blockExplorerUrls) { @@ -146,13 +135,13 @@ export const BridgeInputGroup = ({ } } else { const explorerUrl = - networkProps?.network?.blockExplorerUrls?.[ - networkProps?.network?.defaultBlockExplorerUrlIndex ?? 0 + CAIP_CHAINID_DEFAULT_BLOCK_EXPLORER_URL_MAP[ + formatChainIdToCaip(token.chainId) ]; if (explorerUrl) { blockExplorerUrl = getAccountLink( token.address, - selectedChainId, + formatChainIdToHex(selectedChainId), { blockExplorerUrl: explorerUrl, }, @@ -168,6 +157,8 @@ export const BridgeInputGroup = ({ } }; + const [isAssetPickerOpen, setIsAssetPickerOpen] = useState(false); + return ( @@ -231,41 +222,22 @@ export const BridgeInputGroup = ({ }} {...amountFieldProps} /> - - {(onClickHandler, networkImageSrc) => - isAmountReadOnly && !token ? ( - - ) : ( - - ) - } - + isOpen={isAssetPickerOpen} + onClose={() => setIsAssetPickerOpen(false)} + onAssetChange={(asset) => { + setIsAssetPickerOpen(false); + onAssetChange?.(asset); + }} + networks={networks} + /> + setIsAssetPickerOpen(true)} + asset={token} + data-testid={buttonProps.testId} + /> diff --git a/ui/pages/bridge/prepare/components/asset-list.tsx b/ui/pages/bridge/prepare/components/asset-list.tsx new file mode 100644 index 000000000000..9fa13cdb7619 --- /dev/null +++ b/ui/pages/bridge/prepare/components/asset-list.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { BridgeToken } from '../../../../ducks/bridge/types'; +import { useMultichainBalances } from '../../../../hooks/useMultichainBalances'; +import { Column } from '../../layout'; +import { BridgeAsset } from './asset'; +import { BridgeAssetPickerNetworkPopover } from './asset-picker-network-popover'; + +export const AssetPickerTokenList = ({ + networks, + selectedChainId, + onAssetChange, + asset, + searchQuery, +}: { + onAssetChange: (asset: BridgeToken) => void; + asset: BridgeToken | null; + searchQuery: string; +} & Pick< + React.ComponentProps, + 'networks' | 'selectedChainId' +>) => { + const { assetsWithBalance } = useMultichainBalances(); + // TODO isTokenListLoading use Skeleton + // TODO show selected asset first + // TODO apply search query + // TODO exclude src asset (pass list of skipped assetIds) + return ( + + {assetsWithBalance + .filter((token) => { + return selectedChainId + ? token.chainId === selectedChainId + : networks.some( + (networkOption) => networkOption.chainId === token.chainId, + ); + }) + .map((token) => ( + { + // if (token.chainId !== network?.chainId) { + // onNetworkChange(token.chainId); + // } + onAssetChange(token); + }} + selected={asset?.assetId === token.assetId} + /> + ))} + + ); +}; diff --git a/ui/pages/bridge/prepare/components/asset-picker-button.tsx b/ui/pages/bridge/prepare/components/asset-picker-button.tsx new file mode 100644 index 000000000000..fbbefaaaf330 --- /dev/null +++ b/ui/pages/bridge/prepare/components/asset-picker-button.tsx @@ -0,0 +1,79 @@ +import React from 'react'; +import { + AvatarNetwork, + AvatarNetworkSize, + AvatarToken, + BadgeWrapper, +} from '@metamask/design-system-react'; +import { + SelectButtonProps, + SelectButtonSize, +} from '../../../../components/component-library/select-button/select-button.types'; +import { + AlignItems, + BackgroundColor, + BorderColor, + BorderRadius, + Display, + OverflowWrap, +} from '../../../../helpers/constants/design-system'; +import { BridgeToken } from '../../../../ducks/bridge/types'; +import { + IconName, + SelectButton, +} from '../../../../components/component-library'; +import { + BRIDGE_CHAIN_ID_TO_NETWORK_IMAGE_MAP, + NETWORK_TO_SHORT_NETWORK_NAME_MAP, +} from '../../../../../shared/constants/bridge'; + +export const BridgeAssetPickerButton = ({ + asset, + ...props +}: { + asset: BridgeToken | null; +} & SelectButtonProps<'div'>) => { + return ( + + } + > + + + ) : undefined + } + {...props} + /> + ); +}; diff --git a/ui/pages/bridge/prepare/components/asset-picker-network-popover.tsx b/ui/pages/bridge/prepare/components/asset-picker-network-popover.tsx new file mode 100644 index 000000000000..b73f3c62ff6d --- /dev/null +++ b/ui/pages/bridge/prepare/components/asset-picker-network-popover.tsx @@ -0,0 +1,100 @@ +import React from 'react'; +import { formatChainIdToHex } from '@metamask/bridge-controller'; +import { + AvatarIcon, + AvatarIconSize, + IconColor, + IconName, +} from '@metamask/design-system-react'; +import { MultichainNetworkConfiguration } from '@metamask/multichain-network-controller'; +import { CaipChainId } from '@metamask/utils'; +import { Popover } from '../../../../components/component-library'; +import { NetworkListItem } from '../../../../components/multichain'; +import { getImageForChainId } from '../../../../selectors/multichain'; +import { useI18nContext } from '../../../../hooks/useI18nContext'; +import { + BackgroundColor, + BorderRadius, + Display, +} from '../../../../helpers/constants/design-system'; +import { BridgeToken } from '../../../../ducks/bridge/types'; + +export const BridgeAssetPickerNetworkPopover = ({ + networks, + selectedChainId, + onNetworkChange, + referenceElement, + isOpen, + onClose, +}: { + networks: MultichainNetworkConfiguration[]; + selectedChainId: CaipChainId | null; + onNetworkChange: (chainId: CaipChainId | null, token?: BridgeToken) => void; + referenceElement: HTMLElement | null; + isOpen: boolean; + onClose: () => void; +}) => { + const t = useI18nContext(); + + return ( + <> + { + onClose(); + }} + preventOverflow={true} + offset={[0, 12]} + style={{ + zIndex: 100, + padding: 0, + overflow: 'scroll', + minWidth: '100%', + maxHeight: '90%', + }} + borderRadius={BorderRadius.XL} + backgroundColor={BackgroundColor.backgroundSubsection} + className="bridge-network-list-popover" + > + { + onNetworkChange(null); + }} + startAccessory={ + + } + avatarNetworkProps={{ + display: Display.None, + }} + /> + {networks.map((networkOption) => ( + { + onNetworkChange(networkOption.chainId); + }} + /> + ))} + + + ); +}; diff --git a/ui/pages/bridge/prepare/components/asset-picker.tsx b/ui/pages/bridge/prepare/components/asset-picker.tsx new file mode 100644 index 000000000000..38bad0a99f0f --- /dev/null +++ b/ui/pages/bridge/prepare/components/asset-picker.tsx @@ -0,0 +1,183 @@ +import React, { useRef, useState } from 'react'; +import { + ButtonIconSize, + Icon, + IconColor, + IconName, + IconSize, +} from '@metamask/design-system-react'; +import { type CaipChainId } from '@metamask/utils'; +import { + Modal, + ModalOverlay, + ModalContent, + ModalBody, + ModalHeader, + PickerNetwork, + TextField, + ModalContentSize, +} from '../../../../components/component-library'; +import { useI18nContext } from '../../../../hooks/useI18nContext'; +import { + BackgroundColor, + BlockSize, + BorderColor, + BorderRadius, + Display, + FlexDirection, + TextVariant, +} from '../../../../helpers/constants/design-system'; +import { BRIDGE_CHAIN_ID_TO_NETWORK_IMAGE_MAP } from '../../../../../shared/constants/bridge'; +import { BridgeAssetPickerNetworkPopover } from './asset-picker-network-popover'; +import { AssetPickerTokenList } from './asset-list'; + +export const BridgeAssetPicker = ({ + networks, + isOpen, + onClose, + onAssetChange, + header, + ...assetListProps +}: { + isOpen: boolean; + onClose: () => void; + header: string; +} & Pick< + React.ComponentProps, + 'networks' +> & + Pick< + React.ComponentProps, + 'asset' | 'onAssetChange' + >) => { + const t = useI18nContext(); + const [isNetworkPickerOpen, setIsNetworkPickerOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const referenceElementRef = useRef(null); + const [selectedChainId, setSelectedChainId] = useState( + null, + ); + const selectedNetwork = networks.find( + (network) => network.chainId === selectedChainId, + ); + + return ( + <> + { + setIsNetworkPickerOpen(false); + }} + > + + + + {header} + + + + isNetworkPickerOpen + ? setIsNetworkPickerOpen(false) + : setIsNetworkPickerOpen(true) + } + data-testid="asset-picker-network-popover__button" + marginInline={4} + paddingLeft={4} + paddingRight={4} + backgroundColor={BackgroundColor.backgroundMuted} + borderRadius={BorderRadius.XL} + width={BlockSize.Max} + style={{ minHeight: '32px' }} + /> + { + setSelectedChainId(chainId); + setSearchQuery(''); + setIsNetworkPickerOpen(false); + }} + onClose={() => setIsNetworkPickerOpen(false)} + /> + {!isNetworkPickerOpen && ( + setSearchQuery(e.target.value)} + borderRadius={BorderRadius.XL} + borderWidth={1} + borderColor={BorderColor.borderMuted} + inputProps={{ + disableStateStyles: true, + textVariant: TextVariant.bodyMd, + paddingRight: 2, + }} + style={{ + minHeight: 48, + paddingRight: '8px', + outline: 'none', + }} + marginInline={4} + startAccessory={ + + } + /> + )} + {!isNetworkPickerOpen && ( + { + setSelectedChainId(asset.chainId); + onAssetChange(asset); + }} + {...assetListProps} + /> + )} + + + + + ); +}; diff --git a/ui/pages/bridge/prepare/components/asset.tsx b/ui/pages/bridge/prepare/components/asset.tsx new file mode 100644 index 000000000000..df8e85e096cd --- /dev/null +++ b/ui/pages/bridge/prepare/components/asset.tsx @@ -0,0 +1,126 @@ +import React from 'react'; +import { useSelector } from 'react-redux'; +import { + AvatarNetwork, + AvatarNetworkSize, + AvatarToken, + AvatarTokenSize, + BadgeWrapper, + Box, + BoxBackgroundColor, + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react'; +import { ContainerProps } from '../../../../components/component-library'; +import { + AlignItems, + BackgroundColor, + BlockSize, + BorderRadius, +} from '../../../../helpers/constants/design-system'; +import { Column, Row } from '../../layout'; +import { + BRIDGE_CHAIN_ID_TO_NETWORK_IMAGE_MAP, + NETWORK_TO_SHORT_NETWORK_NAME_MAP, +} from '../../../../../shared/constants/bridge'; +import { formatCurrencyAmount, formatTokenAmount } from '../../utils/quote'; +import { getCurrentCurrency } from '../../../../ducks/metamask/metamask'; +import { getIntlLocale } from '../../../../ducks/locale/locale'; +import { type BridgeToken } from '../../../../ducks/bridge/types'; + +export const BridgeAsset = ({ + asset, + selected, + ...buttonProps +}: { + asset: BridgeToken; + selected: boolean; +} & ContainerProps<'button'>) => { + // TODO onClick: onAssetChange, onNetworkChange + const currency = useSelector(getCurrentCurrency); + const locale = useSelector(getIntlLocale); + + return ( + + {selected && ( + + )} + + } + > + + + + + {asset.symbol} + + {asset.name ?? asset.symbol} + + + + + + {asset.tokenFiatAmount + ? formatCurrencyAmount( + asset.tokenFiatAmount.toFixed(4), + currency, + 2, + ) + : ''} + + + {asset.balance && asset.balance !== '0' + ? formatTokenAmount(locale, asset.balance, asset.symbol) + : ''} + + + + ); +}; diff --git a/ui/pages/bridge/prepare/components/bridge-asset-picker-button.tsx b/ui/pages/bridge/prepare/components/bridge-asset-picker-button.tsx deleted file mode 100644 index 587b7fd149cb..000000000000 --- a/ui/pages/bridge/prepare/components/bridge-asset-picker-button.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import React from 'react'; -import { - SelectButtonProps, - SelectButtonSize, -} from '../../../../components/component-library/select-button/select-button.types'; -import { - AvatarNetwork, - AvatarNetworkSize, - AvatarToken, - BadgeWrapper, - IconName, - SelectButton, - Text, -} from '../../../../components/component-library'; -import { - AlignItems, - BackgroundColor, - BorderColor, - BorderRadius, - Display, - OverflowWrap, - TextVariant, -} from '../../../../helpers/constants/design-system'; -import { useI18nContext } from '../../../../hooks/useI18nContext'; -import { AssetPicker } from '../../../../components/multichain/asset-picker-amount/asset-picker'; -import { getNftImage } from '../../../../helpers/utils/nfts'; - -export const BridgeAssetPickerButton = ({ - asset, - networkProps, - networkImageSrc, - ...props -}: { - networkImageSrc?: string; -} & SelectButtonProps<'div'> & - Pick, 'asset' | 'networkProps'>) => { - const t = useI18nContext(); - - return ( - - {asset?.symbol ?? t('bridgeTo')} - - } - startAccessory={ - asset ? ( - - ) : undefined - } - > - {asset ? ( - - ) : undefined} - - ) : undefined - } - {...props} - /> - ); -}; diff --git a/ui/pages/bridge/prepare/components/destination-account-list-item.tsx b/ui/pages/bridge/prepare/components/destination-account-list-item.tsx index 01bbb0197d88..04330b42c20a 100644 --- a/ui/pages/bridge/prepare/components/destination-account-list-item.tsx +++ b/ui/pages/bridge/prepare/components/destination-account-list-item.tsx @@ -35,6 +35,7 @@ import { } from '../../../../selectors'; // eslint-disable-next-line import/no-restricted-paths import { normalizeSafeAddress } from '../../../../../app/scripts/lib/multichain/address'; +import { NETWORK_TO_SHORT_NETWORK_NAME_MAP } from '../../../../../shared/constants/bridge'; import { useGetFormattedTokensPerChain } from '../../../../hooks/useGetFormattedTokensPerChain'; import { useAccountTotalCrossChainFiatBalance } from '../../../../hooks/useAccountTotalCrossChainFiatBalance'; import UserPreferencedCurrencyDisplay from '../../../../components/app/user-preferenced-currency-display/user-preferenced-currency-display.component'; @@ -184,7 +185,11 @@ const DestinationAccountListItem: React.FC = ({ : (toChain?.chainId ?? '') ] } - name={toChain?.name ?? ''} + name={ + NETWORK_TO_SHORT_NETWORK_NAME_MAP[ + toChain.chainId as keyof typeof NETWORK_TO_SHORT_NETWORK_NAME_MAP + ] + } size={AvatarNetworkSize.Xs} /> diff --git a/ui/pages/bridge/prepare/components/index.scss b/ui/pages/bridge/prepare/components/index.scss index 5b9bd53048b7..8e411bd5fa9d 100644 --- a/ui/pages/bridge/prepare/components/index.scss +++ b/ui/pages/bridge/prepare/components/index.scss @@ -7,3 +7,16 @@ .dark { --shadow-bridge-picker: 0 0 2px 0 var(--color-border-muted), 0 0 16px 0 rgba(226, 228, 233, 0.16); } + + +.bridge-asset{ + &:hover:not(&--selected) { + background: var(--color-background-default-hover); + } +} + +.bridge-network-list-popover { + .multichain-network-list-item { + gap: 10px; + } +} diff --git a/ui/pages/bridge/prepare/prepare-bridge-page.tsx b/ui/pages/bridge/prepare/prepare-bridge-page.tsx index eaafc9afe075..39852324ea3a 100644 --- a/ui/pages/bridge/prepare/prepare-bridge-page.tsx +++ b/ui/pages/bridge/prepare/prepare-bridge-page.tsx @@ -8,12 +8,9 @@ import React, { import { useSelector, useDispatch } from 'react-redux'; import classnames from 'classnames'; import { debounce } from 'lodash'; -import { type TokenListMap } from '@metamask/assets-controllers'; import { zeroAddress } from 'ethereumjs-util'; import { formatChainIdToCaip, - isSolanaChainId, - isBitcoinChainId, isValidQuoteRequest, BRIDGE_QUOTE_MAX_RETURN_DIFFERENCE_PERCENTAGE, getNativeAssetForChainId, @@ -21,18 +18,19 @@ import { UnifiedSwapBridgeEventName, type BridgeController, isCrossChain, + isNonEvmChainId, + formatChainIdToHex, } from '@metamask/bridge-controller'; -import { Hex, parseCaipChainId } from '@metamask/utils'; +import { type CaipChainId, Hex, parseCaipChainId } from '@metamask/utils'; import { setFromToken, setFromTokenInputValue, setSelectedQuote, - setToChainId, setToToken, updateQuoteRequestParams, resetBridgeState, trackUnifiedSwapBridgeEvent, - setFromChain, + switchTokens, } from '../../../ducks/bridge/actions'; import { getBridgeQuotes, @@ -79,29 +77,21 @@ import { TextVariant, } from '../../../helpers/constants/design-system'; import { useI18nContext } from '../../../hooks/useI18nContext'; -import { useTokensWithFiltering } from '../../../hooks/bridge/useTokensWithFiltering'; import { calcTokenValue } from '../../../../shared/lib/swaps-utils'; import { formatTokenAmount, isQuoteExpiredOrInvalid as isQuoteExpiredOrInvalidUtil, } from '../utils/quote'; -import { isNetworkAdded } from '../../../ducks/bridge/utils'; import MascotBackgroundAnimation from '../../swaps/mascot-background-animation/mascot-background-animation'; import { Column } from '../layout'; import useRamps from '../../../hooks/ramps/useRamps/useRamps'; import { getCurrentKeyring, getEnabledNetworksByNamespace, - getTokenList, } from '../../../selectors'; import { isHardwareKeyring } from '../../../helpers/utils/hardware'; import { SECOND } from '../../../../shared/constants/time'; import { getIntlLocale } from '../../../ducks/locale/locale'; -import { useMultichainSelector } from '../../../hooks/useMultichainSelector'; -import { - getMultichainNativeCurrency, - getMultichainProviderConfig, -} from '../../../selectors/multichain'; import { setEnabledAllPopularNetworks } from '../../../store/actions'; import { MultichainBridgeQuoteCard } from '../quotes/multichain-bridge-quote-card'; import { TokenFeatureType } from '../../../../shared/types/security-alerts-api'; @@ -136,17 +126,21 @@ export const useEnableMissingNetwork = () => { const dispatch = useDispatch(); const enableMissingNetwork = useCallback( - (chainId: Hex) => { + (chainId: CaipChainId) => { + if (isNonEvmChainId(chainId)) { + return; + } const enabledNetworkKeys = Object.keys(enabledNetworksByNamespace ?? {}); - const caipChainId = formatChainIdToCaip(chainId); - const { namespace } = parseCaipChainId(caipChainId); + const { namespace } = parseCaipChainId(chainId); + const chainIdInHex = formatChainIdToHex(chainId); if (namespace) { - const isPopularNetwork = FEATURED_NETWORK_CHAIN_IDS.includes(chainId); + const isPopularNetwork = + FEATURED_NETWORK_CHAIN_IDS.includes(chainIdInHex); if (isPopularNetwork) { - const isNetworkEnabled = enabledNetworkKeys.includes(chainId); + const isNetworkEnabled = enabledNetworkKeys.includes(chainIdInHex); if (!isNetworkEnabled) { // Bridging between popular networks indicates we want the 'select all' enabled // This way users can see their full bridging tx activity @@ -175,38 +169,25 @@ const PrepareBridgePage = ({ const isSwap = useSelector(getIsSwap); - const isSendBundleSupportedForChain = useIsSendBundleSupported(fromChain); + const isSendBundleSupportedForChain = useIsSendBundleSupported( + fromChain.chainId, + ); const gasIncluded = useSelector((state) => getIsGasIncluded(state, isSendBundleSupportedForChain), ); const fromToken = useSelector(getFromToken); - const fromTokens = useSelector(getTokenList) as TokenListMap; - const toToken = useSelector(getToToken); const fromChains = useSelector(getFromChains); const toChains = useSelector(getToChains); const toChain = useSelector(getToChain); - const isFromTokensLoading = useMemo(() => { - // Non-EVM chains (Solana, Bitcoin) don't use the EVM token list - if ( - fromChain && - (isSolanaChainId(fromChain.chainId) || - isBitcoinChainId(fromChain.chainId)) - ) { - return false; - } - return Object.keys(fromTokens).length === 0; - }, [fromTokens, fromChain]); - const fromAmount = useSelector(getFromAmount); const fromAmountInCurrency = useSelector(getFromAmountInCurrency); const smartTransactionsEnabled = useSelector(getIsStxEnabled); - const providerConfig = useMultichainSelector(getMultichainProviderConfig); const slippage = useSelector(getSlippage); const quoteRequest = useSelector(getQuoteRequest); @@ -228,8 +209,8 @@ const PrepareBridgePage = ({ const isQuoteExpiredOrInvalid = isQuoteExpiredOrInvalidUtil({ activeQuote: unvalidatedQuote, toToken, - toChain, - fromChain, + toChainId: toChain?.chainId, + fromChainId: fromChain?.chainId, isQuoteExpired, insufficientBal: quoteRequest.insufficientBal, }); @@ -242,7 +223,7 @@ const PrepareBridgePage = ({ isSwap, isSendBundleSupportedForChain, selectedAccount, - fromChain, + fromChainId: fromChain.chainId, }); const shouldShowMaxButton = @@ -256,7 +237,9 @@ const PrepareBridgePage = ({ const isTxSubmittable = useIsTxSubmittable(); const locale = useSelector(getIntlLocale); - const ticker = useMultichainSelector(getMultichainNativeCurrency); + const ticker = fromChain + ? getNativeAssetForChainId(fromChain.chainId)?.symbol + : undefined; const { isEstimatedReturnLow, isNoQuotesAvailable, @@ -274,38 +257,6 @@ const PrepareBridgePage = ({ setIsDestinationAccountPickerOpen, } = useDestinationAccount(); - const { - filteredTokenListGenerator: toTokenListGenerator, - isLoading: isToTokensLoading, - } = useTokensWithFiltering( - toChain?.chainId ?? fromChain?.chainId, - fromChain?.chainId === toChain?.chainId && fromToken && fromChain - ? (() => { - // Determine the address format based on chain type - // We need to make evm tokens lowercase for comparison as sometimes they are checksummed - let address = ''; - if (isNativeAddress(fromToken.address)) { - address = ''; - } else if ( - isSolanaChainId(fromChain.chainId) || - isBitcoinChainId(fromChain.chainId) - ) { - address = fromToken.address || ''; - } else { - address = fromToken.address?.toLowerCase() || ''; - } - - return { - ...fromToken, - address, - // Ensure chainId is in CAIP format for proper comparison - chainId: formatChainIdToCaip(fromChain.chainId), - }; - })() - : null, - selectedDestinationAccount?.address, - ); - const [rotateSwitchTokens, setRotateSwitchTokens] = useState(false); // Resets the banner visibility when the estimated return is low @@ -375,10 +326,10 @@ const PrepareBridgePage = ({ () => selectedAccount?.address ? { - srcTokenAddress: fromToken?.address, - destTokenAddress: toToken?.address, + srcTokenAddress: fromToken.address, + destTokenAddress: toToken.address, srcTokenAmount: - fromAmount && fromToken?.decimals + fromAmount && fromToken.decimals ? calcTokenValue( // Treat empty or incomplete amount as 0 to reject NaN ['', '.'].includes(fromAmount) ? '0' : fromAmount, @@ -388,14 +339,8 @@ const PrepareBridgePage = ({ // Length of decimal part cannot exceed token.decimals .split('.')[0] : undefined, - srcChainId: fromChain?.chainId, - destChainId: toChain?.chainId, - // This override allows quotes to be returned when the rpcUrl is a forked network - // Otherwise quotes get filtered out by the bridge-api when the wallet's real - // balance is less than the tenderly balance - insufficientBal: providerConfig?.rpcUrl?.includes('localhost') - ? true - : undefined, + srcChainId: fromToken?.chainId, + destChainId: toToken?.chainId, slippage, walletAddress: selectedAccount.address, destWalletAddress: selectedDestinationAccount?.address, @@ -405,15 +350,14 @@ const PrepareBridgePage = ({ : undefined, [ fromToken?.address, + fromToken?.chainId, fromToken?.decimals, toToken?.address, + toToken?.chainId, fromAmount, - fromChain?.chainId, - toChain?.chainId, slippage, selectedAccount?.address, selectedDestinationAccount?.address, - providerConfig?.rpcUrl, gasIncluded, gasIncluded7702, ], @@ -458,13 +402,7 @@ const PrepareBridgePage = ({ }, [quoteParams]); // Use smart slippage defaults - useSmartSlippage({ - fromChain, - toChain, - fromToken, - toToken, - isSwap, - }); + useSmartSlippage(); // Trace swap/bridge view loaded useEffect(() => { @@ -531,29 +469,11 @@ const PrepareBridgePage = ({ ...token, address: token.address ?? zeroAddress(), }; + dispatch(setFromToken(bridgeToken)); dispatch(setFromTokenInputValue(null)); - if (token.address === toToken?.address) { - dispatch(setToToken(null)); - } - }} - networkProps={{ - network: fromChain, - networks: fromChains, - onNetworkChange: (networkConfig) => { - if (isNetworkAdded(networkConfig)) { - enableMissingNetwork(networkConfig.chainId); - } - dispatch( - setFromChain({ - networkConfig, - selectedAccount, - }), - ); - }, - header: t('yourNetworks'), }} - isMultiselectEnabled={true} + networks={fromChains} onMaxButtonClick={ shouldShowMaxButton ? (value: string) => { @@ -577,7 +497,6 @@ const PrepareBridgePage = ({ containerProps={{ paddingInline: 4, }} - isTokenListLoading={isFromTokensLoading} buttonProps={{ testId: 'bridge-source-button' }} onBlockExplorerClick={(token) => { setBlockExplorerToken(token); @@ -627,11 +546,14 @@ const PrepareBridgePage = ({ ariaLabel="switch-tokens" iconName={IconName.SwapVertical} color={IconColor.iconAlternative} - disabled={ + disabled={Boolean( isSwitchingTemporarilyDisabled || - !isValidQuoteRequest(quoteRequest, false) || - (toChain && !isNetworkAdded(toChain)) - } + !isValidQuoteRequest(quoteRequest, false) || + (toChain && + !fromChains.find( + (chain) => chain.chainId === toChain.chainId, + )), + )} onClick={() => { dispatch(setSelectedQuote(null)); // Track the flip event @@ -677,22 +599,8 @@ const PrepareBridgePage = ({ }, ), ); - setRotateSwitchTokens(!rotateSwitchTokens); - - if (isSwap) { - dispatch(setFromToken(toToken)); - } else { - // Handle account switching for Solana - dispatch( - setFromChain({ - networkConfig: toChain, - token: toToken, - selectedAccount, - }), - ); - } - dispatch(setToToken(fromToken)); + dispatch(switchTokens({ fromToken, toToken })); }} /> @@ -714,22 +622,9 @@ const PrepareBridgePage = ({ address: token.address ?? zeroAddress(), }; dispatch(setToToken(bridgeToken)); + enableMissingNetwork(token.chainId); }} - networkProps={{ - network: toChain, - networks: toChains, - onNetworkChange: (networkConfig) => { - if (isNetworkAdded(networkConfig)) { - enableMissingNetwork(networkConfig.chainId); - } - dispatch(setToChainId(networkConfig.chainId)); - }, - header: t('yourNetworks'), - shouldDisableNetwork: ({ chainId }) => - isBitcoinChainId(chainId) && - !isCrossChain(chainId, fromChain?.chainId), - }} - customTokenListGenerator={toTokenListGenerator} + networks={toChains} amountInFiat={ // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31880 // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing @@ -747,14 +642,12 @@ const PrepareBridgePage = ({ ? 'amount-input defined' : 'amount-input', }} - isTokenListLoading={isToTokensLoading} buttonProps={{ testId: 'bridge-destination-button' }} onBlockExplorerClick={(token) => { setBlockExplorerToken(token); setShowBlockExplorerToast(true); setToastTriggerCounter((prev) => prev + 1); }} - isDestinationToken /> `${args?.bridgeId}_${args?.bridges[0]}`; +// TODO if user requests quote before setting src chain, this might fail export const isQuoteExpiredOrInvalid = ({ activeQuote, toToken, - toChain, - fromChain, + toChainId, + fromChainId, isQuoteExpired, insufficientBal, }: { activeQuote: QuoteResponse | null; toToken: BridgeToken | null; - toChain?: NetworkConfiguration | AddNetworkFields; - fromChain?: NetworkConfiguration; + toChainId: BridgeToken['chainId']; + fromChainId: BridgeToken['chainId']; isQuoteExpired: boolean; insufficientBal?: boolean; }): boolean => { @@ -126,7 +124,7 @@ export const isQuoteExpiredOrInvalid = ({ isQuoteExpired && (!insufficientBal || // `insufficientBal` is always true for non-EVM chains (Solana, Bitcoin) - (fromChain && isNonEvmChainId(fromChain.chainId))) + isNonEvmChainId(fromChainId)) ) { return true; } @@ -140,8 +138,8 @@ export const isQuoteExpiredOrInvalid = ({ const quoteDestChainIdCaip = activeQuote.quote?.destChainId ? formatChainIdToCaip(activeQuote.quote.destChainId) : ''; - const selectedDestChainIdCaip = toChain?.chainId - ? formatChainIdToCaip(toChain.chainId) + const selectedDestChainIdCaip = toChainId + ? formatChainIdToCaip(toChainId) : ''; return !( diff --git a/ui/pages/bridge/utils/slippage-service.ts b/ui/pages/bridge/utils/slippage-service.ts index 220282672a85..5fe256fba8ef 100644 --- a/ui/pages/bridge/utils/slippage-service.ts +++ b/ui/pages/bridge/utils/slippage-service.ts @@ -1,4 +1,4 @@ -import { isSolanaChainId } from '@metamask/bridge-controller'; +import { isCrossChain, isSolanaChainId } from '@metamask/bridge-controller'; import type { BridgeToken } from '../../../ducks/bridge/types'; import { STABLECOINS_BY_CHAIN_ID } from './stablecoins'; @@ -15,11 +15,8 @@ export enum SlippageValue { * Context for calculating slippage */ export type SlippageContext = { - fromChain: { chainId: string } | null | undefined; - toChain: { chainId: string } | null | undefined; - fromToken: BridgeToken | null; - toToken: BridgeToken | null; - isSwap: boolean; + fromToken: BridgeToken; + toToken: BridgeToken; }; /** @@ -80,25 +77,25 @@ function isStablecoinPair( export function calculateSlippage( context: SlippageContext, ): number | undefined { - const { fromChain, toChain, fromToken, toToken, isSwap } = context; + const { fromToken, toToken } = context; // If no source chain, we can't determine the type - if (!fromChain?.chainId || !toChain?.chainId) { + if (!fromToken.chainId || !toToken.chainId) { return SlippageValue.BridgeDefault; } // 1. Cross-chain (bridge) → 2% - if (!isSwap || fromChain.chainId !== toChain.chainId) { + if (fromToken.chainId !== toToken.chainId) { return SlippageValue.BridgeDefault; } // 2. Solana swap → undefined (AUTO mode) - if (isSolanaChainId(fromChain.chainId)) { + if (isSolanaChainId(fromToken.chainId)) { return undefined; } // 3. EVM swap → check for stablecoin pair - if (isStablecoinPair(fromChain.chainId, fromToken, toToken)) { + if (isStablecoinPair(fromToken.chainId, fromToken, toToken)) { return SlippageValue.EvmStablecoin; // 0.5% } @@ -113,21 +110,21 @@ export function calculateSlippage( * @param context */ export function getSlippageReason(context: SlippageContext): string { - const { fromChain, toChain, fromToken, toToken, isSwap } = context; + const { fromToken, toToken } = context; - if (!fromChain?.chainId || !toChain?.chainId) { + if (!fromToken?.chainId || !toToken?.chainId) { return 'Incomplete chain setup - using bridge default'; } - if (!isSwap || fromChain.chainId !== toChain.chainId) { + if (fromToken.chainId !== toToken.chainId) { return 'Cross-chain transaction'; } - if (isSolanaChainId(fromChain.chainId)) { + if (isSolanaChainId(fromToken.chainId)) { return 'Solana swap (AUTO mode)'; } - if (isStablecoinPair(fromChain.chainId, fromToken, toToken)) { + if (isStablecoinPair(fromToken.chainId, fromToken, toToken)) { return 'EVM stablecoin pair'; }