From a8804d3adce832e5518e75bcfbbe0bb63c4bdbb2 Mon Sep 17 00:00:00 2001 From: Micaela Estabillo <100321200+micaelae@users.noreply.github.com> Date: Mon, 24 Feb 2025 08:35:07 -0800 Subject: [PATCH] refactor: bridge support for CAIP chainIds and non-hex addresses (#30305) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** **Problem**: Multichain controllers identify chains and addresses in the CAIP format, but Bridge components assume that everything is either `Hex` and case-insensitive. This will cause unexpected behavior when non-evm data is integrated into the experience, such as missing data due to bad lookups, invalid bridge-api requests, page crashes due to hex to decimal conversions **Solution**: Decouple the quote parameters used within components from the QuoteRequest object sent to the bridge-api. This enables transforming inputs into any format required by the backend while allowing any data to be passed around in the frontend **Changes** - chainIds and addresses are used as-is and not formatted until they are needed for metrics or bridge-api requests - bridge feature flags for chain config are identified by CAIP chain addresses - `GenericQuoteRequest` type covers all possible parameter types. this is converted to `QuoteRequest` in fetchBridgeQuotes - caip-formatters.ts formats ids and addresses - chainId in metrics will be in caip format instead of hex [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/30305?quickstart=1) ## **Related issues** Fixes: MMS-1867 ## **Manual testing steps** This should not affect the EVM bridging flow. Testing in Flask will be possible when the asset-picker is updated to include non-evm tokens: https://github.com/MetaMask/metamask-extension/pull/30313 ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [X ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-extension/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- .../bridge/bridge-controller.test.ts | 36 ++--- .../controllers/bridge/bridge-controller.ts | 49 ++++--- app/scripts/controllers/bridge/constants.ts | 2 +- app/scripts/lib/bridge-status/metrics.ts | 3 +- shared/constants/swaps.ts | 17 ++- shared/lib/bridge-status/metrics.ts | 6 +- shared/modules/bridge-utils/balance.ts | 2 +- .../modules/bridge-utils/bridge.util.test.ts | 22 +++- shared/modules/bridge-utils/bridge.util.ts | 45 ++++--- .../modules/bridge-utils/caip-formatters.ts | 101 ++++++++++++++ shared/modules/bridge-utils/quote.ts | 19 ++- shared/types/bridge.ts | 46 +++++-- test/e2e/default-fixture.js | 6 +- ...rs-after-init-opt-in-background-state.json | 6 +- .../errors-after-init-opt-in-ui-state.json | 6 +- ...s-before-init-opt-in-background-state.json | 6 +- .../errors-before-init-opt-in-ui-state.json | 6 +- test/jest/mock-store.js | 18 ++- ui/ducks/bridge/actions.ts | 6 +- ui/ducks/bridge/bridge.test.ts | 26 +++- ui/ducks/bridge/bridge.ts | 50 +++++-- ui/ducks/bridge/selectors.test.ts | 97 ++++++++------ ui/ducks/bridge/selectors.ts | 86 +++++++----- ui/ducks/bridge/utils.ts | 35 +++-- ui/hooks/bridge/events/types.ts | 7 +- .../bridge/events/useRequestProperties.ts | 22 ++-- ui/hooks/bridge/useBridgeExchangeRates.ts | 19 ++- ui/hooks/bridge/useBridging.ts | 11 +- ui/hooks/bridge/useLatestBalance.ts | 20 +-- ui/hooks/bridge/useTokensWithFiltering.ts | 16 +-- .../bridge/prepare/bridge-cta-button.test.tsx | 11 +- .../bridge/prepare/bridge-input-group.tsx | 36 +++-- .../prepare/prepare-bridge-page.test.tsx | 5 +- .../bridge/prepare/prepare-bridge-page.tsx | 123 ++++++++++++------ .../bridge-quote-card.test.tsx.snap | 8 +- .../bridge/quotes/bridge-quote-card.test.tsx | 22 +++- ui/pages/bridge/quotes/bridge-quote-card.tsx | 43 +++--- ui/pages/bridge/utils/quote.ts | 5 +- 38 files changed, 692 insertions(+), 352 deletions(-) create mode 100644 shared/modules/bridge-utils/caip-formatters.ts diff --git a/app/scripts/controllers/bridge/bridge-controller.test.ts b/app/scripts/controllers/bridge/bridge-controller.test.ts index a8a61117b389..91c2788feadf 100644 --- a/app/scripts/controllers/bridge/bridge-controller.test.ts +++ b/app/scripts/controllers/bridge/bridge-controller.test.ts @@ -2,7 +2,6 @@ import nock from 'nock'; import { BigNumber } from 'bignumber.js'; import { add0x } from '@metamask/utils'; import { BRIDGE_API_BASE_URL } from '../../../../shared/constants/bridge'; -import { CHAIN_IDS } from '../../../../shared/constants/network'; import { SWAPS_API_V2_BASE_URL } from '../../../../shared/constants/swaps'; import { flushPromises } from '../../../../test/lib/timer-helpers'; import * as bridgeUtil from '../../../../shared/modules/bridge-utils/bridge.util'; @@ -12,6 +11,7 @@ import mockBridgeQuotesNativeErc20 from '../../../../test/data/bridge/mock-quote import mockBridgeQuotesNativeErc20Eth from '../../../../test/data/bridge/mock-quotes-native-erc20-eth.json'; import { type QuoteResponse } from '../../../../shared/types/bridge'; import { decimalToHex } from '../../../../shared/modules/conversion.utils'; +import { MultichainNetworks } from '../../../../shared/constants/multichain/networks'; import BridgeController from './bridge-controller'; import { BridgeControllerMessenger } from './types'; import { DEFAULT_BRIDGE_STATE } from './constants'; @@ -132,10 +132,10 @@ describe('BridgeController', function () { refreshRate: 3, support: true, chains: { - [CHAIN_IDS.OPTIMISM]: { isActiveSrc: true, isActiveDest: false }, - [CHAIN_IDS.SCROLL]: { isActiveSrc: true, isActiveDest: false }, - [CHAIN_IDS.POLYGON]: { isActiveSrc: false, isActiveDest: true }, - [CHAIN_IDS.ARBITRUM]: { isActiveSrc: false, isActiveDest: true }, + 'eip155:10': { isActiveSrc: true, isActiveDest: false }, + 'eip155:534352': { isActiveSrc: true, isActiveDest: false }, + 'eip155:137': { isActiveSrc: false, isActiveDest: true }, + 'eip155:42161': { isActiveSrc: false, isActiveDest: true }, }, }, }; @@ -272,22 +272,22 @@ describe('BridgeController', function () { }); const quoteParams = { - srcChainId: 1, - destChainId: 10, + srcChainId: '0x1', + destChainId: MultichainNetworks.SOLANA, srcTokenAddress: '0x0000000000000000000000000000000000000000', destTokenAddress: '0x123', srcTokenAmount: '1000000000000000000', + walletAddress: '0x123', }; const quoteRequest = { ...quoteParams, slippage: 0.5, - walletAddress: '0x123', }; await bridgeController.updateBridgeQuoteRequestParams(quoteParams); expect(stopAllPollingSpy).toHaveBeenCalledTimes(1); - expect(startPollingSpy).toHaveBeenCalledTimes(1); expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(1); + expect(startPollingSpy).toHaveBeenCalledTimes(1); expect(startPollingSpy).toHaveBeenCalledWith({ networkClientId: expect.anything(), updatedQuoteRequest: { @@ -298,7 +298,7 @@ describe('BridgeController', function () { expect(bridgeController.state.bridgeState).toStrictEqual( expect.objectContaining({ - quoteRequest: { ...quoteRequest, walletAddress: undefined }, + quoteRequest, quotes: DEFAULT_BRIDGE_STATE.quotes, quotesLastFetched: DEFAULT_BRIDGE_STATE.quotesLastFetched, quotesLoadingStatus: DEFAULT_BRIDGE_STATE.quotesLoadingStatus, @@ -421,16 +421,16 @@ describe('BridgeController', function () { }); const quoteParams = { - srcChainId: 1, - destChainId: 10, + srcChainId: '0x1', + destChainId: '0x10', srcTokenAddress: '0x0000000000000000000000000000000000000000', destTokenAddress: '0x123', srcTokenAmount: '1000000000000000000', + walletAddress: '0x123', }; const quoteRequest = { ...quoteParams, slippage: 0.5, - walletAddress: '0x123', }; await bridgeController.updateBridgeQuoteRequestParams(quoteParams); @@ -447,7 +447,7 @@ describe('BridgeController', function () { expect(bridgeController.state.bridgeState).toStrictEqual( expect.objectContaining({ - quoteRequest: { ...quoteRequest, walletAddress: undefined }, + quoteRequest, quotes: DEFAULT_BRIDGE_STATE.quotes, quotesLastFetched: DEFAULT_BRIDGE_STATE.quotesLastFetched, quotesInitialLoadTime: undefined, @@ -614,16 +614,16 @@ describe('BridgeController', function () { }); const quoteParams = { - srcChainId: 10, - destChainId: 1, + srcChainId: '0x10', + destChainId: '0x1', srcTokenAddress: '0x4200000000000000000000000000000000000006', destTokenAddress: '0x0000000000000000000000000000000000000000', srcTokenAmount: '991250000000000000', + walletAddress: 'eip:id/id:id/0x123', }; const quoteRequest = { ...quoteParams, slippage: 0.5, - walletAddress: '0x123', }; await bridgeController.updateBridgeQuoteRequestParams(quoteParams); @@ -640,7 +640,7 @@ describe('BridgeController', function () { expect(bridgeController.state.bridgeState).toStrictEqual( expect.objectContaining({ - quoteRequest: { ...quoteRequest, walletAddress: undefined }, + quoteRequest, quotes: DEFAULT_BRIDGE_STATE.quotes, quotesLastFetched: DEFAULT_BRIDGE_STATE.quotesLastFetched, quotesLoadingStatus: DEFAULT_BRIDGE_STATE.quotesLoadingStatus, diff --git a/app/scripts/controllers/bridge/bridge-controller.ts b/app/scripts/controllers/bridge/bridge-controller.ts index 27d7f230805d..0fa4c9477ee8 100644 --- a/app/scripts/controllers/bridge/bridge-controller.ts +++ b/app/scripts/controllers/bridge/bridge-controller.ts @@ -1,4 +1,4 @@ -import { add0x, Hex } from '@metamask/utils'; +import { add0x, type Hex } from '@metamask/utils'; import { StaticIntervalPollingController } from '@metamask/polling-controller'; import { NetworkClientId } from '@metamask/network-controller'; import { StateMetadata } from '@metamask/base-controller'; @@ -18,17 +18,22 @@ import { } from '../../../../shared/modules/conversion.utils'; import { type L1GasFees, - type QuoteRequest, type QuoteResponse, type TxData, type BridgeControllerState, BridgeFeatureFlagsKey, RequestStatus, + type GenericQuoteRequest, } from '../../../../shared/types/bridge'; import { isValidQuoteRequest } from '../../../../shared/modules/bridge-utils/quote'; import { hasSufficientBalance } from '../../../../shared/modules/bridge-utils/balance'; import { CHAIN_IDS } from '../../../../shared/constants/network'; import { REFRESH_INTERVAL_MS } from '../../../../shared/constants/bridge'; +import { + formatAddressToString, + formatChainIdToHex, +} from '../../../../shared/modules/bridge-utils/caip-formatters'; +import { MultichainNetworks } from '../../../../shared/constants/multichain/networks'; import { BRIDGE_CONTROLLER_NAME, DEFAULT_BRIDGE_STATE, @@ -48,7 +53,7 @@ const RESET_STATE_ABORT_MESSAGE = 'Reset controller state'; /** The input to start polling for the {@link BridgeController} */ type BridgePollingInput = { networkClientId: NetworkClientId; - updatedQuoteRequest: QuoteRequest; + updatedQuoteRequest: GenericQuoteRequest; }; export default class BridgeController extends StaticIntervalPollingController()< @@ -113,7 +118,7 @@ export default class BridgeController extends StaticIntervalPollingController
, + paramsToUpdate: Partial, ) => { this.stopAllPolling(); this.#abortController?.abort('Quote request updated'); @@ -139,38 +144,45 @@ export default class BridgeController extends StaticIntervalPollingController
{ + #hasSufficientBalance = async (quoteRequest: GenericQuoteRequest) => { const walletAddress = this.#getSelectedAccount().address; - const srcChainIdInHex = add0x(decimalToHex(quoteRequest.srcChainId)); + const srcChainIdInHex = formatChainIdToHex(quoteRequest.srcChainId); const provider = this.#getSelectedNetworkClient()?.provider; + const srcTokenAddressWithoutPrefix = formatAddressToString( + quoteRequest.srcTokenAddress, + ); return ( provider && + srcTokenAddressWithoutPrefix && + quoteRequest.srcTokenAmount && + srcChainIdInHex && (await hasSufficientBalance( provider, walletAddress, - quoteRequest.srcTokenAddress, + srcTokenAddressWithoutPrefix, quoteRequest.srcTokenAmount, srcChainIdInHex, )) @@ -207,7 +219,6 @@ export default class BridgeController extends StaticIntervalPollingController
{ this.#abortController?.abort('New quote request'); this.#abortController = new AbortController(); - const { bridgeState } = this.state; this.update((_state) => { _state.bridgeState = { diff --git a/app/scripts/controllers/bridge/constants.ts b/app/scripts/controllers/bridge/constants.ts index 57cce13713b2..8a6fc844cab5 100644 --- a/app/scripts/controllers/bridge/constants.ts +++ b/app/scripts/controllers/bridge/constants.ts @@ -22,7 +22,7 @@ export const DEFAULT_BRIDGE_STATE: BridgeState = { }, quoteRequest: { walletAddress: undefined, - srcTokenAddress: zeroAddress(), + srcTokenAddress: zeroAddress() as `0x${string}`, slippage: BRIDGE_DEFAULT_SLIPPAGE, }, quotesInitialLoadTime: undefined, diff --git a/app/scripts/lib/bridge-status/metrics.ts b/app/scripts/lib/bridge-status/metrics.ts index 535bd71c9ead..d94af0d2d7ab 100644 --- a/app/scripts/lib/bridge-status/metrics.ts +++ b/app/scripts/lib/bridge-status/metrics.ts @@ -24,6 +24,7 @@ import { } from '../../../../shared/types/bridge-status'; import { isEthUsdt } from '../../../../shared/modules/bridge-utils/bridge.util'; import { getCommonProperties } from '../../../../shared/lib/bridge-status/metrics'; +import { formatChainIdToHex } from '../../../../shared/modules/bridge-utils/caip-formatters'; import { getTokenUsdValue } from './metrics-utils'; type TrackEvent = ( @@ -62,7 +63,7 @@ export const handleBridgeTransactionComplete = async ( ).toNumber(); const destTokenUsdValue = (await getTokenUsdValue({ - chainId: chain_id_destination, + chainId: formatChainIdToHex(chain_id_destination), tokenAmount: destTokenAmount, tokenAddress: quote.destAsset.address, state, diff --git a/shared/constants/swaps.ts b/shared/constants/swaps.ts index 14a32da18ba6..655d7d342e71 100644 --- a/shared/constants/swaps.ts +++ b/shared/constants/swaps.ts @@ -1,6 +1,8 @@ -///: BEGIN:ONLY_INCLUDE_IF(solana-swaps) -import { MultichainNetworks } from './multichain/networks'; -///: END:ONLY_INCLUDE_IF +import { MULTICHAIN_NATIVE_CURRENCY_TO_CAIP19 } from './multichain/assets'; +import { + MULTICHAIN_TOKEN_IMAGE_MAP, + MultichainNetworks, +} from './multichain/networks'; import { ETH_TOKEN_IMAGE_URL, TEST_ETH_TOKEN_IMAGE_URL, @@ -129,6 +131,14 @@ export const BASE_SWAPS_TOKEN_OBJECT: SwapsTokenObject = { ...ETH_SWAPS_TOKEN_OBJECT, } as const; +const SOLANA_SWAPS_TOKEN_OBJECT: SwapsTokenObject = { + symbol: 'SOL', + name: 'Solana', + address: MULTICHAIN_NATIVE_CURRENCY_TO_CAIP19.SOL, + decimals: 9, + iconUrl: MULTICHAIN_TOKEN_IMAGE_MAP[MultichainNetworks.SOLANA], +}; + // A gas value for ERC20 approve calls that should be sufficient for all ERC20 approve implementations export const DEFAULT_ERC20_APPROVE_GAS = '0x1d4c0'; @@ -288,6 +298,7 @@ export const SWAPS_CHAINID_DEFAULT_TOKEN_MAP = { [CHAIN_IDS.ZKSYNC_ERA]: ZKSYNC_ERA_SWAPS_TOKEN_OBJECT, [CHAIN_IDS.LINEA_MAINNET]: LINEA_SWAPS_TOKEN_OBJECT, [CHAIN_IDS.BASE]: BASE_SWAPS_TOKEN_OBJECT, + [MultichainNetworks.SOLANA]: SOLANA_SWAPS_TOKEN_OBJECT, } as const; export const ETHEREUM = 'ethereum'; diff --git a/shared/lib/bridge-status/metrics.ts b/shared/lib/bridge-status/metrics.ts index 0b03089ba357..fd23b91a09f0 100644 --- a/shared/lib/bridge-status/metrics.ts +++ b/shared/lib/bridge-status/metrics.ts @@ -9,8 +9,8 @@ import { ActionType } from '../../../ui/hooks/bridge/events/types'; import { formatProviderLabel } from '../../../ui/pages/bridge/utils/quote'; import { getCurrentKeyring } from '../../../ui/selectors'; import { BRIDGE_DEFAULT_SLIPPAGE } from '../../constants/bridge'; -import { decimalToPrefixedHex } from '../../modules/conversion.utils'; import { getIsSmartTransaction } from '../../modules/selectors'; +import { formatChainIdToCaip } from '../../modules/bridge-utils/caip-formatters'; export const getCommonProperties = ( bridgeHistoryItem: BridgeHistoryItem, @@ -20,10 +20,10 @@ export const getCommonProperties = ( // @ts-expect-error keyring type is possibly wrong const is_hardware_wallet = isHardwareKeyring(keyring.type) ?? false; - const chain_id_source = decimalToPrefixedHex( + const chain_id_source = formatChainIdToCaip( bridgeHistoryItem.quote.srcChainId, ); - const chain_id_destination = decimalToPrefixedHex( + const chain_id_destination = formatChainIdToCaip( bridgeHistoryItem.quote.destChainId, ); diff --git a/shared/modules/bridge-utils/balance.ts b/shared/modules/bridge-utils/balance.ts index 3900ac9400bd..b768f0ff2b84 100644 --- a/shared/modules/bridge-utils/balance.ts +++ b/shared/modules/bridge-utils/balance.ts @@ -1,6 +1,6 @@ import { Web3Provider } from '@ethersproject/providers'; import type { Provider } from '@metamask/network-controller'; -import { Hex } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; import { zeroAddress } from 'ethereumjs-util'; import { getAddress } from 'ethers/lib/utils'; import { fetchTokenBalance } from '../../lib/token-util'; diff --git a/shared/modules/bridge-utils/bridge.util.test.ts b/shared/modules/bridge-utils/bridge.util.test.ts index b9fea3db1ea8..f06bbc751aac 100644 --- a/shared/modules/bridge-utils/bridge.util.test.ts +++ b/shared/modules/bridge-utils/bridge.util.test.ts @@ -1,8 +1,8 @@ import { zeroAddress } from 'ethereumjs-util'; import fetchWithCache from '../../lib/fetch-with-cache'; -import { CHAIN_IDS } from '../../constants/network'; import mockBridgeQuotesErc20Erc20 from '../../../test/data/bridge/mock-quotes-erc20-erc20.json'; import mockBridgeQuotesNativeErc20 from '../../../test/data/bridge/mock-quotes-native-erc20.json'; +import { ChainId } from '../../types/bridge'; import { fetchBridgeFeatureFlags, fetchBridgeQuotes, @@ -48,6 +48,10 @@ describe('Bridge utils', () => { isActiveSrc: false, isActiveDest: true, }, + [ChainId.SOLANA]: { + isActiveSrc: false, + isActiveDest: true, + }, }, }, }; @@ -72,27 +76,31 @@ describe('Bridge utils', () => { refreshRate: 3, support: true, chains: { - [CHAIN_IDS.MAINNET]: { + 'eip155:1': { isActiveSrc: true, isActiveDest: true, }, - [CHAIN_IDS.OPTIMISM]: { + 'eip155:10': { isActiveSrc: true, isActiveDest: false, }, - [CHAIN_IDS.LINEA_MAINNET]: { + 'eip155:59144': { isActiveSrc: true, isActiveDest: true, }, - '0x78': { + 'eip155:120': { isActiveSrc: true, isActiveDest: false, }, - [CHAIN_IDS.POLYGON]: { + 'eip155:11111': { + isActiveSrc: false, + isActiveDest: true, + }, + 'eip155:137': { isActiveSrc: false, isActiveDest: true, }, - '0x2b67': { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp': { isActiveSrc: false, isActiveDest: true, }, diff --git a/shared/modules/bridge-utils/bridge.util.ts b/shared/modules/bridge-utils/bridge.util.ts index b7da42ba9cc6..1871a624185a 100644 --- a/shared/modules/bridge-utils/bridge.util.ts +++ b/shared/modules/bridge-utils/bridge.util.ts @@ -1,5 +1,5 @@ import { Contract } from '@ethersproject/contracts'; -import { Hex, add0x } from '@metamask/utils'; +import { type Hex } from '@metamask/utils'; import { abiERC20 } from '@metamask/metamask-eth-abis'; import { BRIDGE_API_BASE_URL, @@ -10,7 +10,7 @@ import { } from '../../constants/bridge'; import { MINUTE } from '../../constants/time'; import fetchWithCache from '../../lib/fetch-with-cache'; -import { decimalToHex, hexToDecimal } from '../conversion.utils'; +import { hexToDecimal } from '../conversion.utils'; import { SWAPS_CHAINID_DEFAULT_TOKEN_MAP, SwapsTokenObject, @@ -21,18 +21,23 @@ import { } from '../swaps.utils'; import { CHAIN_IDS } from '../../constants/network'; import { - BridgeAsset, + type BridgeAsset, BridgeFlag, - FeatureFlagResponse, - FeeData, + type FeatureFlagResponse, + type FeeData, FeeType, - Quote, - QuoteRequest, - QuoteResponse, - TxData, + type Quote, + type QuoteResponse, + type TxData, BridgeFeatureFlagsKey, - BridgeFeatureFlags, + type BridgeFeatureFlags, + type GenericQuoteRequest, } from '../../types/bridge'; +import { + formatAddressToString, + formatChainIdToDec, + formatChainIdToCaip, +} from './caip-formatters'; import { FEATURE_FLAG_VALIDATORS, QUOTE_VALIDATORS, @@ -70,7 +75,7 @@ export async function fetchBridgeFeatureFlags(): Promise { ).reduce( (acc, [chainId, value]) => ({ ...acc, - [add0x(decimalToHex(chainId))]: value, + [formatChainIdToCaip(chainId)]: value, }), {}, ), @@ -128,21 +133,23 @@ export async function fetchBridgeTokens( } // Returns a list of bridge tx quotes +// Converts the quote request to the format the bridge-api expects prior to fetching quotes export async function fetchBridgeQuotes( - request: QuoteRequest, + request: GenericQuoteRequest, signal: AbortSignal, ): Promise { - const queryParams = new URLSearchParams({ - walletAddress: request.walletAddress, - srcChainId: request.srcChainId.toString(), - destChainId: request.destChainId.toString(), - srcTokenAddress: request.srcTokenAddress, - destTokenAddress: request.destTokenAddress, + const normalizedRequest = { + walletAddress: formatAddressToString(request.walletAddress), + srcChainId: formatChainIdToDec(request.srcChainId).toString(), + destChainId: formatChainIdToDec(request.destChainId).toString(), + srcTokenAddress: formatAddressToString(request.srcTokenAddress), + destTokenAddress: formatAddressToString(request.destTokenAddress), srcTokenAmount: request.srcTokenAmount, slippage: request.slippage.toString(), insufficientBal: request.insufficientBal ? 'true' : 'false', resetApproval: request.resetApproval ? 'true' : 'false', - }); + }; + const queryParams = new URLSearchParams(normalizedRequest); const url = `${BRIDGE_API_BASE_URL}/getQuote?${queryParams}`; const quotes = await fetchWithCache({ url, diff --git a/shared/modules/bridge-utils/caip-formatters.ts b/shared/modules/bridge-utils/caip-formatters.ts new file mode 100644 index 000000000000..89ef8ae62d16 --- /dev/null +++ b/shared/modules/bridge-utils/caip-formatters.ts @@ -0,0 +1,101 @@ +import { + type Hex, + type CaipChainId, + isCaipChainId, + isStrictHexString, + parseCaipChainId, + isCaipReference, +} from '@metamask/utils'; +import { zeroAddress, toChecksumAddress } from 'ethereumjs-util'; +import { toEvmCaipChainId } from '@metamask/multichain-network-controller'; +import { MultichainNetworks } from '../../constants/multichain/networks'; +import { ChainId } from '../../types/bridge'; +import { decimalToPrefixedHex, hexToDecimal } from '../conversion.utils'; +import { MULTICHAIN_NATIVE_CURRENCY_TO_CAIP19 } from '../../constants/multichain/assets'; +import { isValidNumber } from './validators'; + +// Returns true if the address looka like a native asset +export const isNativeAddress = (address?: string | null) => + address === zeroAddress() || + address === '' || + !address || + Object.values(MULTICHAIN_NATIVE_CURRENCY_TO_CAIP19).some((assetId) => + assetId.includes(address), + ); + +// Converts a chainId to a CaipChainId +export const formatChainIdToCaip = ( + chainId: Hex | number | CaipChainId | string, +): CaipChainId => { + if (isCaipChainId(chainId)) { + return chainId; + } else if (isStrictHexString(chainId)) { + return toEvmCaipChainId(chainId); + } + const chainIdString = chainId.toString(); + if (chainIdString === '1151111081099710') { + return MultichainNetworks.SOLANA; + } + return toEvmCaipChainId(decimalToPrefixedHex(chainIdString)); +}; + +// Converts a chainId to a decimal number that can be used for bridge-api requests +export const formatChainIdToDec = ( + chainId: number | Hex | CaipChainId | string, +) => { + if (isStrictHexString(chainId)) { + return Number(hexToDecimal(chainId)); + } + if (chainId === MultichainNetworks.SOLANA) { + return ChainId.SOLANA; + } + if (isCaipChainId(chainId)) { + return Number(chainId.split(':').at(-1)); + } + if (typeof chainId === 'string') { + return parseInt(chainId, 10); + } + return chainId; +}; + +// Converts a chainId to a hex string used to read controller data within the app +// Hex chainIds are also used for fetching exchange rates +export const formatChainIdToHex = ( + chainId: Hex | CaipChainId | string | number, +) => { + if (isStrictHexString(chainId)) { + return chainId; + } + if (typeof chainId === 'number' || parseInt(chainId, 10)) { + return decimalToPrefixedHex(chainId.toString()); + } + if (isCaipChainId(chainId)) { + const { reference } = parseCaipChainId(chainId); + if (isCaipReference(reference) && isValidNumber(reference)) { + return decimalToPrefixedHex(reference); + } + } + // TODO handle non-evm chainIds + // Throw an error if a non-evm chainId is passed to this function + // This should never happen, but it's a sanity check + throw new Error('Invalid cross-chain swaps chainId'); +}; + +// Converts an asset or account address to a string that can be used for bridge-api requests +export const formatAddressToString = (address: string) => { + if (isStrictHexString(address)) { + return toChecksumAddress(address); + } + // If the address looks like a native token, return the zero address because it's + // what bridge-api uses to represent a native asset + if (isNativeAddress(address)) { + return zeroAddress(); + } + const addressWithoutPrefix = address.split(':').at(-1); + // If the address is not a valid hex string or CAIP address, throw an error + // This should never happen, but it's a sanity check + if (!addressWithoutPrefix) { + throw new Error('Invalid address'); + } + return addressWithoutPrefix; +}; diff --git a/shared/modules/bridge-utils/quote.ts b/shared/modules/bridge-utils/quote.ts index 3fe4406fa333..5c67b2e2c6e8 100644 --- a/shared/modules/bridge-utils/quote.ts +++ b/shared/modules/bridge-utils/quote.ts @@ -1,14 +1,23 @@ -import type { QuoteRequest } from '../../types/bridge'; +import type { + BridgeControllerState, + GenericQuoteRequest, +} from '../../types/bridge'; export const isValidQuoteRequest = ( - partialRequest: Partial, + partialRequest: Partial, requireAmount = true, -): partialRequest is QuoteRequest => { - const STRING_FIELDS = ['srcTokenAddress', 'destTokenAddress']; +): partialRequest is GenericQuoteRequest => { + const STRING_FIELDS = [ + 'srcTokenAddress', + 'destTokenAddress', + 'srcChainId', + 'destChainId', + 'walletAddress', + ]; if (requireAmount) { STRING_FIELDS.push('srcTokenAmount'); } - const NUMBER_FIELDS = ['srcChainId', 'destChainId', 'slippage']; + const NUMBER_FIELDS = ['slippage']; return ( STRING_FIELDS.every( diff --git a/shared/types/bridge.ts b/shared/types/bridge.ts index 4be73c5cccdc..3206f911ba20 100644 --- a/shared/types/bridge.ts +++ b/shared/types/bridge.ts @@ -1,6 +1,10 @@ -import type { Hex } from '@metamask/utils'; +import type { + CaipAccountId, + CaipAssetId, + CaipChainId, + Hex, +} from '@metamask/utils'; import type { BigNumber } from 'bignumber.js'; -import type { AssetType } from '../constants/transaction'; export type ChainConfiguration = { isActiveSrc: boolean; @@ -35,12 +39,11 @@ export enum SortOrder { } export type BridgeToken = { - type: AssetType.native | AssetType.token; address: string; symbol: string; image: string; decimals: number; - chainId: Hex; + chainId: CaipChainId; balance: string; // raw balance string: string | undefined; // normalized balance as a stringified number tokenFiatAmount?: number | null; @@ -71,13 +74,19 @@ export type BridgeAsset = { icon?: string; }; -export type QuoteRequest = { - walletAddress: string; - destWalletAddress?: string; - srcChainId: ChainId; - destChainId: ChainId; - srcTokenAddress: string; - destTokenAddress: string; +// Generic types for the quote request +// Only the controller and reducer should be overriding these types to prepare the fetch request +export type QuoteRequest< + ChainIdType = ChainId | number, + TokenAddressType = string, + WalletAddressType = string, +> = { + walletAddress: WalletAddressType; + destWalletAddress?: WalletAddressType; + srcChainId: ChainIdType; + destChainId: ChainIdType; + srcTokenAddress: TokenAddressType; + destTokenAddress: TokenAddressType; srcTokenAmount: string; // This is the amount sent slippage: number; aggIds?: string[]; @@ -86,6 +95,7 @@ export type QuoteRequest = { resetApproval?: boolean; refuel?: boolean; }; + type Protocol = { name: string; displayName?: string; @@ -142,6 +152,7 @@ export enum ChainId { ARBITRUM = 42161, AVALANCHE = 43114, LINEA = 59144, + SOLANA = 1151111081099710, } export enum FeeType { @@ -169,7 +180,7 @@ export type BridgeFeatureFlags = { refreshRate: number; maxRefreshCount: number; support: boolean; - chains: Record; + chains: Record; }; }; export enum RequestStatus { @@ -186,9 +197,18 @@ export enum BridgeBackgroundAction { RESET_STATE = 'resetState', GET_BRIDGE_ERC20_ALLOWANCE = 'getBridgeERC20Allowance', } + +// These are types that components pass in. Since data is a mix of types when coming from the redux store, we need to use a generic type that can cover all the types. +// This is formatted by fetchBridgeQuotes right before fetching quotes to whatever type the bridge-api is expecting. +export type GenericQuoteRequest = QuoteRequest< + Hex | CaipChainId | string | number, // chainIds + Hex | CaipAssetId | string, // assetIds/addresses + Hex | CaipAccountId | string // accountIds/addresses +>; + export type BridgeState = { bridgeFeatureFlags: BridgeFeatureFlags; - quoteRequest: Partial; + quoteRequest: Partial; quotes: (QuoteResponse & L1GasFees)[]; quotesInitialLoadTime?: number; quotesLastFetched?: number; diff --git a/test/e2e/default-fixture.js b/test/e2e/default-fixture.js index 84959cd86eda..b6a54bff061d 100644 --- a/test/e2e/default-fixture.js +++ b/test/e2e/default-fixture.js @@ -124,15 +124,15 @@ function defaultFixture(inputChainId = CHAIN_IDS.LOCALHOST) { extensionConfig: { support: false, chains: { - '0x1': { + 'eip155:1': { isActiveSrc: true, isActiveDest: true, }, - '0xa': { + 'eip155:10': { isActiveSrc: true, isActiveDest: true, }, - '0xe708': { + 'eip155:59144': { isActiveSrc: true, isActiveDest: true, }, diff --git a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json index 14c0bde14a1a..5251abea0c5e 100644 --- a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json +++ b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json @@ -68,7 +68,11 @@ "refreshRate": "number", "maxRefreshCount": "number", "support": "boolean", - "chains": { "0x1": "object", "0xa4b1": "object", "0xe708": "object" } + "chains": { + "eip155:1": "object", + "eip155:42161": "object", + "eip155:59144": "object" + } } }, "quoteRequest": { diff --git a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json index 75ee3aebc3ec..a45d4a59d67e 100644 --- a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json +++ b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json @@ -336,7 +336,11 @@ "refreshRate": "number", "maxRefreshCount": "number", "support": "boolean", - "chains": { "0x1": "object", "0xa4b1": "object", "0xe708": "object" } + "chains": { + "eip155:1": "object", + "eip155:42161": "object", + "eip155:59144": "object" + } } }, "quoteRequest": { diff --git a/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-background-state.json b/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-background-state.json index 8a42603df017..2b2e1f633d09 100644 --- a/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-background-state.json +++ b/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-background-state.json @@ -144,9 +144,9 @@ "extensionConfig": { "support": "boolean", "chains": { - "0x1": "object", - "0xa": "object", - "0xe708": "object" + "eip155:1": "object", + "eip155:10": "object", + "eip155:59144": "object" } } } diff --git a/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-ui-state.json b/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-ui-state.json index 5a2c537a68c7..6d5f977f2734 100644 --- a/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-ui-state.json +++ b/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-ui-state.json @@ -153,9 +153,9 @@ "extensionConfig": { "support": "boolean", "chains": { - "0x1": "object", - "0xa": "object", - "0xe708": "object" + "eip155:1": "object", + "eip155:10": "object", + "eip155:59144": "object" } } } diff --git a/test/jest/mock-store.js b/test/jest/mock-store.js index aa01c33ffbdf..f7fdb3a0fb9e 100644 --- a/test/jest/mock-store.js +++ b/test/jest/mock-store.js @@ -7,6 +7,7 @@ import { DEFAULT_BRIDGE_STATE } from '../../app/scripts/controllers/bridge/const import { DEFAULT_BRIDGE_STATUS_STATE } from '../../app/scripts/controllers/bridge-status/constants'; import { BRIDGE_PREFERRED_GAS_ESTIMATE } from '../../shared/constants/bridge'; import { mockTokenData } from '../data/bridge/mock-token-data'; +import { formatChainIdToCaip } from '../../shared/modules/bridge-utils/caip-formatters'; export const createGetSmartTransactionFeesApiResponse = () => { return { @@ -780,8 +781,21 @@ export const createBridgeMockStore = ( ...featureFlagOverrides, extensionConfig: { support: false, - chains: {}, - ...featureFlagOverrides.extensionConfig, + ...featureFlagOverrides?.extensionConfig, + chains: { + [formatChainIdToCaip('0x1')]: { + isActiveSrc: true, + isActiveDest: false, + }, + ...Object.fromEntries( + Object.entries( + featureFlagOverrides?.extensionConfig?.chains ?? {}, + ).map(([chainId, config]) => [ + formatChainIdToCaip(chainId), + config, + ]), + ), + }, }, }, ...bridgeStateOverrides, diff --git a/ui/ducks/bridge/actions.ts b/ui/ducks/bridge/actions.ts index e611e256f985..ee3cc1c6b7f6 100644 --- a/ui/ducks/bridge/actions.ts +++ b/ui/ducks/bridge/actions.ts @@ -2,7 +2,7 @@ import type { Hex } from '@metamask/utils'; import { BridgeBackgroundAction, BridgeUserAction, - QuoteRequest, + type GenericQuoteRequest, } from '../../../shared/types/bridge'; import { forceUpdateMetamaskState } from '../../store/actions'; import { submitRequestToBackground } from '../../store/background-connection'; @@ -68,7 +68,9 @@ export const resetBridgeState = () => { }; // User actions -export const updateQuoteRequestParams = (params: Partial) => { +export const updateQuoteRequestParams = ( + params: Partial, +) => { return async (dispatch: MetaMaskReduxDispatch) => { await dispatch( callBridgeControllerMethod(BridgeUserAction.UPDATE_QUOTE_PARAMS, params), diff --git a/ui/ducks/bridge/bridge.test.ts b/ui/ducks/bridge/bridge.test.ts index 3fbd3b7487b5..5dc1a60f2710 100644 --- a/ui/ducks/bridge/bridge.test.ts +++ b/ui/ducks/bridge/bridge.test.ts @@ -10,6 +10,8 @@ import { } from '../../../shared/types/bridge'; import * as util from '../../helpers/utils/util'; import { BRIDGE_DEFAULT_SLIPPAGE } from '../../../shared/constants/bridge'; +import { formatChainIdToCaip } from '../../../shared/modules/bridge-utils/caip-formatters'; +import { MultichainNetworks } from '../../../shared/constants/multichain/networks'; import bridgeReducer from './bridge'; import { setBridgeFeatureFlags, @@ -61,32 +63,46 @@ describe('Ducks - Bridge', () => { const actions = store.getActions(); expect(actions[0].type).toStrictEqual('bridge/setToChainId'); const newState = bridgeReducer(state, actions[0]); - expect(newState.toChainId).toStrictEqual(actionPayload); + expect(newState.toChainId).toStrictEqual( + formatChainIdToCaip(actionPayload), + ); }); }); describe('setFromToken', () => { it('calls the "bridge/setFromToken" action', () => { const state = store.getState().bridge; - const actionPayload = { symbol: 'SYMBOL', address: '0x13341432' }; + const actionPayload = { + symbol: 'SYMBOL', + address: '0x13341432', + chainId: MultichainNetworks.SOLANA, + }; store.dispatch(setFromToken(actionPayload as never) as never); const actions = store.getActions(); expect(actions[0].type).toStrictEqual('bridge/setFromToken'); const newState = bridgeReducer(state, actions[0]); - expect(newState.fromToken).toStrictEqual(actionPayload); + expect(newState.fromToken).toStrictEqual( + expect.objectContaining(actionPayload), + ); }); }); describe('setToToken', () => { it('calls the "bridge/setToToken" action', () => { const state = store.getState().bridge; - const actionPayload = { symbol: 'SYMBOL', address: '0x13341431' }; + const actionPayload = { + symbol: 'SYMBOL', + address: '0x13341431', + chainId: '0xa', + }; store.dispatch(setToToken(actionPayload as never) as never); const actions = store.getActions(); expect(actions[0].type).toStrictEqual('bridge/setToToken'); const newState = bridgeReducer(state, actions[0]); - expect(newState.toToken).toStrictEqual(actionPayload); + expect(newState.toToken).toStrictEqual( + expect.objectContaining({ ...actionPayload, chainId: 'eip155:10' }), + ); }); }); diff --git a/ui/ducks/bridge/bridge.ts b/ui/ducks/bridge/bridge.ts index 14786b51d4dd..8d210f179e97 100644 --- a/ui/ducks/bridge/bridge.ts +++ b/ui/ducks/bridge/bridge.ts @@ -1,16 +1,18 @@ import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; -import { Hex } from '@metamask/utils'; +import { type Hex, type CaipChainId } from '@metamask/utils'; import { type BridgeToken, + ChainId, type QuoteMetadata, type QuoteResponse, SortOrder, } from '../../../shared/types/bridge'; import { BRIDGE_DEFAULT_SLIPPAGE } from '../../../shared/constants/bridge'; +import { formatChainIdToCaip } from '../../../shared/modules/bridge-utils/caip-formatters'; import { getTokenExchangeRate } from './utils'; export type BridgeState = { - toChainId: Hex | null; + toChainId: CaipChainId | null; fromToken: BridgeToken; toToken: BridgeToken; fromTokenInputValue: string | null; @@ -23,6 +25,20 @@ export type BridgeState = { slippage: number; }; +type ChainIdPayload = { payload: number | Hex | CaipChainId | null }; +type TokenPayload = { + payload: { + address: string; + symbol: string; + image: string; + decimals: number; + chainId: number | Hex | ChainId | CaipChainId; + balance?: string; + string?: string | undefined; + tokenFiatAmount?: number | null; + } | null; +}; + const initialState: BridgeState = { toChainId: null, fromToken: null, @@ -56,14 +72,32 @@ const bridgeSlice = createSlice({ name: 'bridge', initialState: { ...initialState }, reducers: { - setToChainId: (state, action) => { - state.toChainId = action.payload; + setToChainId: (state, { payload }: ChainIdPayload) => { + state.toChainId = payload ? formatChainIdToCaip(payload) : null; }, - setFromToken: (state, action) => { - state.fromToken = action.payload; + setFromToken: (state, { payload }: TokenPayload) => { + if (payload) { + state.fromToken = { + ...payload, + balance: payload.balance ?? '0', + string: payload.string ?? '0', + chainId: formatChainIdToCaip(payload.chainId), + }; + } else { + state.fromToken = payload; + } }, - setToToken: (state, action) => { - state.toToken = action.payload; + setToToken: (state, { payload }: TokenPayload) => { + if (payload) { + state.toToken = { + ...payload, + balance: payload.balance ?? '0', + string: payload.string ?? '0', + chainId: formatChainIdToCaip(payload.chainId), + }; + } else { + state.toToken = payload; + } }, setFromTokenInputValue: (state, action) => { state.fromTokenInputValue = action.payload; diff --git a/ui/ducks/bridge/selectors.test.ts b/ui/ducks/bridge/selectors.test.ts index 48ffaa58bc00..acb83330e6c5 100644 --- a/ui/ducks/bridge/selectors.test.ts +++ b/ui/ducks/bridge/selectors.test.ts @@ -1,11 +1,7 @@ import { BigNumber } from 'bignumber.js'; import { zeroAddress } from 'ethereumjs-util'; import { createBridgeMockStore } from '../../../test/jest/mock-store'; -import { - BUILT_IN_NETWORKS, - CHAIN_IDS, - FEATURED_RPCS, -} from '../../../shared/constants/network'; +import { CHAIN_IDS, FEATURED_RPCS } from '../../../shared/constants/network'; import { ALLOWED_BRIDGE_CHAIN_IDS } from '../../../shared/constants/bridge'; import { mockNetworkState } from '../../../test/stub/networks'; import mockErc20Erc20Quotes from '../../../test/data/bridge/mock-quotes-erc20-erc20.json'; @@ -15,6 +11,7 @@ import { type QuoteResponse, SortOrder, } from '../../../shared/types/bridge'; +import { formatChainIdToCaip } from '../../../shared/modules/bridge-utils/caip-formatters'; import { getAllBridgeableNetworks, getBridgeQuotes, @@ -40,7 +37,7 @@ describe('Bridge selectors', () => { }, }, }, - bridgeSliceOverrides: { toChainId: '0xe708' }, + bridgeSliceOverrides: { toChainId: formatChainIdToCaip('0xe708') }, metamaskStateOverrides: { ...mockNetworkState(FEATURED_RPCS[1]), }, @@ -75,7 +72,7 @@ describe('Bridge selectors', () => { }, }, }, - bridgeSliceOverrides: { toChainId: '0xe708' }, + bridgeSliceOverrides: { toChainId: formatChainIdToCaip('0xe708') }, }); const result = getToChain(state as never); @@ -107,7 +104,7 @@ describe('Bridge selectors', () => { }); const result = getAllBridgeableNetworks(state as never); - expect(result).toHaveLength(8); + expect(result).toHaveLength(9); expect(result[0]).toStrictEqual( expect.objectContaining({ chainId: FEATURED_RPCS[0].chainId }), ); @@ -150,7 +147,7 @@ describe('Bridge selectors', () => { }; const result = getAllBridgeableNetworks(state as never); - expect(result).toHaveLength(2); + expect(result).toHaveLength(3); expect(result[0]).toStrictEqual( expect.objectContaining({ chainId: CHAIN_IDS.MAINNET }), ); @@ -179,7 +176,9 @@ describe('Bridge selectors', () => { }, }, }, - bridgeSliceOverrides: { toChainId: CHAIN_IDS.LINEA_MAINNET }, + bridgeSliceOverrides: { + toChainId: formatChainIdToCaip(CHAIN_IDS.LINEA_MAINNET), + }, }); const result = getFromChains(state as never); @@ -193,7 +192,15 @@ describe('Bridge selectors', () => { }); it('returns empty list when bridgeFeatureFlags are not set', () => { - const state = createBridgeMockStore(); + const state = createBridgeMockStore({ + featureFlagOverrides: { + extensionConfig: { + chains: { + [CHAIN_IDS.MAINNET]: { isActiveSrc: false, isActiveDest: true }, + }, + }, + }, + }); const result = getFromChains(state as never); expect(result).toHaveLength(0); @@ -261,7 +268,7 @@ describe('Bridge selectors', () => { }, }, }, - bridgeSliceOverrides: { toChainId: '0x38' }, + bridgeSliceOverrides: { toChainId: formatChainIdToCaip('0x38') }, metamaskStateOverrides: { ...mockNetworkState({ chainId: '0x1' }), }, @@ -303,7 +310,7 @@ describe('Bridge selectors', () => { }, }, }, - bridgeSliceOverrides: { toChainId: '0x1' }, + bridgeSliceOverrides: { toChainId: formatChainIdToCaip('0x1') }, metamaskStateOverrides: { ...mockNetworkState({ chainId: '0x1' }), }, @@ -325,7 +332,7 @@ describe('Bridge selectors', () => { }, }, }, - bridgeSliceOverrides: { toChainId: '0x38' }, + bridgeSliceOverrides: { toChainId: formatChainIdToCaip('0x38') }, metamaskStateOverrides: { ...mockNetworkState({ chainId: '0x1' }), }, @@ -343,14 +350,19 @@ describe('Bridge selectors', () => { support: true, chains: { '0x1': { isActiveSrc: true, isActiveDest: false }, - '0x38': { isActiveSrc: false, isActiveDest: true }, + [CHAIN_IDS.LINEA_MAINNET]: { + isActiveSrc: false, + isActiveDest: true, + }, }, }, }, - bridgeSliceOverrides: { toChainId: '0x38' }, + bridgeSliceOverrides: { + toChainId: formatChainIdToCaip(CHAIN_IDS.LINEA_MAINNET), + }, metamaskStateOverrides: { ...mockNetworkState( - ...Object.values(BUILT_IN_NETWORKS), + { chainId: CHAIN_IDS.MAINNET }, ...FEATURED_RPCS.filter( (network) => network.chainId !== CHAIN_IDS.LINEA_MAINNET, // Linea mainnet is both a built in network, as well as featured RPC ), @@ -387,15 +399,14 @@ describe('Bridge selectors', () => { expect(result).toStrictEqual({ address: '0x0000000000000000000000000000000000000000', - chainId: '0x1', + chainId: 'eip155:1', decimals: 18, iconUrl: './images/eth_logo.svg', image: './images/eth_logo.svg', name: 'Ether', symbol: 'ETH', - type: 'NATIVE', - balance: '0', string: '0', + balance: '0', }); }); @@ -407,13 +418,12 @@ describe('Bridge selectors', () => { expect(result).toStrictEqual({ address: '0x0000000000000000000000000000000000000000', - chainId: '0x1', + chainId: 'eip155:1', decimals: 18, iconUrl: './images/eth_logo.svg', image: './images/eth_logo.svg', name: 'Ether', symbol: 'ETH', - type: 'NATIVE', balance: '0', string: '0', }); @@ -475,7 +485,7 @@ describe('Bridge selectors', () => { }, }, bridgeSliceOverrides: { - toChainId: '0x89', + toChainId: formatChainIdToCaip('0x89'), fromTokenExchangeRate: 1, fromToken: { address: zeroAddress(), symbol: 'TEST' }, toToken: { address: zeroAddress(), symbol: 'TEST' }, @@ -586,7 +596,7 @@ describe('Bridge selectors', () => { }, }, bridgeSliceOverrides: { - toChainId: '0x89', + toChainId: formatChainIdToCaip('0x89'), fromToken: { address: zeroAddress(), symbol: 'ETH' }, toToken: { address: zeroAddress(), symbol: 'TEST' }, fromTokenExchangeRate: 1, @@ -710,7 +720,7 @@ describe('Bridge selectors', () => { }, }, bridgeSliceOverrides: { - toChainId: '0x89', + toChainId: formatChainIdToCaip('0x89'), fromToken: { address: zeroAddress(), symbol: 'ETH' }, toToken: { address: zeroAddress(), symbol: 'TEST' }, fromTokenExchangeRate: 1, @@ -923,7 +933,7 @@ describe('Bridge selectors', () => { describe('getValidationErrors', () => { it('should return isNoQuotesAvailable=true', () => { const state = createBridgeMockStore({ - bridgeSliceOverrides: { toChainId: '0x1' }, + bridgeSliceOverrides: { toChainId: formatChainIdToCaip('0x1') }, bridgeStateOverrides: { srcTokens: { '0x00': { address: '0x00', symbol: 'TEST' } }, srcTopAssets: [{ address: '0x00', symbol: 'TEST' }], @@ -938,7 +948,7 @@ describe('Bridge selectors', () => { it('should return isNoQuotesAvailable=false on initial load', () => { const state = createBridgeMockStore({ - bridgeSliceOverrides: { toChainId: '0x1' }, + bridgeSliceOverrides: { toChainId: formatChainIdToCaip('0x1') }, bridgeStateOverrides: { srcTokens: { '0x00': { address: '0x00', symbol: 'TEST' } }, srcTopAssets: [{ address: '0x00', symbol: 'TEST' }], @@ -953,7 +963,7 @@ describe('Bridge selectors', () => { it('should return isInsufficientBalance=true', () => { const state = createBridgeMockStore({ bridgeSliceOverrides: { - toChainId: '0x1', + toChainId: formatChainIdToCaip('0x1'), fromToken: { decimals: 6, address: zeroAddress() }, fromChain: { chainId: CHAIN_IDS.MAINNET }, }, @@ -973,7 +983,7 @@ describe('Bridge selectors', () => { it('should return isInsufficientBalance=false when there is no input amount', () => { const state = createBridgeMockStore({ - bridgeSliceOverrides: { toChainId: '0x1' }, + bridgeSliceOverrides: { toChainId: formatChainIdToCaip('0x1') }, bridgeStateOverrides: { srcTokens: { '0x00': { address: '0x00', symbol: 'TEST' } }, srcTopAssets: [{ address: '0x00', symbol: 'TEST' }], @@ -989,7 +999,7 @@ describe('Bridge selectors', () => { it('should return isInsufficientBalance=false when there is no balance', () => { const state = createBridgeMockStore({ - bridgeSliceOverrides: { toChainId: '0x1' }, + bridgeSliceOverrides: { toChainId: formatChainIdToCaip('0x1') }, bridgeStateOverrides: { srcTokens: { '0x00': { address: '0x00', symbol: 'TEST' } }, srcTopAssets: [{ address: '0x00', symbol: 'TEST' }], @@ -1004,7 +1014,7 @@ describe('Bridge selectors', () => { it('should return isInsufficientBalance=false when balance is 0', () => { const state = createBridgeMockStore({ bridgeSliceOverrides: { - toChainId: '0x1', + toChainId: formatChainIdToCaip('0x1'), fromToken: { decimals: 6, address: zeroAddress() }, fromChain: { chainId: CHAIN_IDS.MAINNET }, }, @@ -1025,7 +1035,7 @@ describe('Bridge selectors', () => { it('should return isInsufficientGasBalance=true when balance is equal to srcAmount and fromToken is native', () => { const state = createBridgeMockStore({ bridgeSliceOverrides: { - toChainId: '0x1', + toChainId: formatChainIdToCaip('0x1'), fromTokenInputValue: '0.001', fromToken: { address: zeroAddress(), decimals: 18 }, }, @@ -1047,7 +1057,7 @@ describe('Bridge selectors', () => { const state = createBridgeMockStore({ featureFlagOverrides: { destNetworkAllowlist: ['0x89'] }, bridgeSliceOverrides: { - toChainId: '0x89', + toChainId: formatChainIdToCaip('0x89'), toToken: { address: '0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', symbol: 'TEST', @@ -1089,7 +1099,7 @@ describe('Bridge selectors', () => { it('should return isInsufficientGasBalance=false if there is no fromAmount', () => { const state = createBridgeMockStore({ bridgeSliceOverrides: { - toChainId: '0x1', + toChainId: formatChainIdToCaip('0x1'), fromTokenInputValue: '0.001', }, bridgeStateOverrides: { @@ -1109,7 +1119,7 @@ describe('Bridge selectors', () => { it('should return isInsufficientGasBalance=false when quotes have been loaded', () => { const state = createBridgeMockStore({ bridgeSliceOverrides: { - toChainId: '0x1', + toChainId: formatChainIdToCaip('0x1'), fromTokenInputValue: '0.001', }, bridgeStateOverrides: { @@ -1128,8 +1138,15 @@ describe('Bridge selectors', () => { it('should return isInsufficientGasForQuote=true when balance is less than required network fees in quote', () => { const state = createBridgeMockStore({ + featureFlagOverrides: { + extensionConfig: { + chains: { + '0x1': { isActiveSrc: true, isActiveDest: false }, + }, + }, + }, bridgeSliceOverrides: { - toChainId: '0x1', + toChainId: formatChainIdToCaip('0x1'), fromTokenInputValue: '0.001', fromToken: { address: zeroAddress(), decimals: 18 }, }, @@ -1156,7 +1173,7 @@ describe('Bridge selectors', () => { it('should return isInsufficientGasForQuote=false when balance is greater than max network fees in quote', () => { const state = createBridgeMockStore({ bridgeSliceOverrides: { - toChainId: '0x1', + toChainId: formatChainIdToCaip('0x1'), fromTokenInputValue: '0.001', fromToken: { address: zeroAddress(), decimals: 18 }, }, @@ -1188,13 +1205,14 @@ describe('Bridge selectors', () => { featureFlagOverrides: { extensionConfig: { chains: { + '0x1': { isActiveSrc: true, isActiveDest: false }, '0xa': { isActiveSrc: true, isActiveDest: false }, '0x89': { isActiveSrc: false, isActiveDest: true }, }, }, }, bridgeSliceOverrides: { - toChainId: '0x89', + toChainId: formatChainIdToCaip('0x89'), fromToken: { address: zeroAddress(), symbol: 'ETH' }, toToken: { address: zeroAddress(), symbol: 'TEST' }, fromTokenInputValue: '1', @@ -1244,13 +1262,14 @@ describe('Bridge selectors', () => { featureFlagOverrides: { extensionConfig: { chains: { + '0x1': { isActiveSrc: true, isActiveDest: false }, '0xa': { isActiveSrc: true, isActiveDest: false }, '0x89': { isActiveSrc: false, isActiveDest: true }, }, }, }, bridgeSliceOverrides: { - toChainId: '0x89', + toChainId: formatChainIdToCaip('0x89'), fromToken: { address: zeroAddress(), symbol: 'ETH' }, toToken: { address: zeroAddress(), symbol: 'TEST' }, fromTokenExchangeRate: 2524.25, diff --git a/ui/ducks/bridge/selectors.ts b/ui/ducks/bridge/selectors.ts index 1178e39ccddf..02ea85e32c93 100644 --- a/ui/ducks/bridge/selectors.ts +++ b/ui/ducks/bridge/selectors.ts @@ -1,6 +1,7 @@ import type { - AddNetworkFields, + ///: BEGIN:ONLY_INCLUDE_IF(solana-swaps) NetworkConfiguration, + ///: END:ONLY_INCLUDE_IF NetworkState, } from '@metamask/network-controller'; import { orderBy, uniqBy } from 'lodash'; @@ -8,6 +9,12 @@ import { createSelector } from 'reselect'; import type { GasFeeEstimates } from '@metamask/gas-fee-controller'; import { BigNumber } from 'bignumber.js'; import { calcTokenAmount } from '@metamask/notification-services-controller/push-services'; +///: BEGIN:ONLY_INCLUDE_IF(solana-swaps) +import { + MultichainNetworks, + MULTICHAIN_PROVIDER_CONFIGS, +} from '../../../shared/constants/multichain/networks'; +///: END:ONLY_INCLUDE_IF import { getIsBridgeEnabled, getMarketData, @@ -23,10 +30,7 @@ import { import type { BridgeControllerState } from '../../../shared/types/bridge'; import { createDeepEqualSelector } from '../../../shared/modules/selectors/util'; import { SWAPS_CHAINID_DEFAULT_TOKEN_MAP } from '../../../shared/constants/swaps'; -import { - getProviderConfig, - getNetworkConfigurationsByChainId, -} from '../../../shared/modules/selectors/networks'; +import { getNetworkConfigurationsByChainId } from '../../../shared/modules/selectors/networks'; import { getConversionRate, getGasFeeEstimates } from '../metamask/metamask'; import { type L1GasFees, @@ -45,14 +49,20 @@ import { calcSwapRate, calcToAmount, calcEstimatedAndMaxTotalGasFee, - isNativeAddress, } from '../../pages/bridge/utils/quote'; -import { AssetType } from '../../../shared/constants/transaction'; +import { + isNativeAddress, + formatChainIdToCaip, +} from '../../../shared/modules/bridge-utils/caip-formatters'; import { decGWEIToHexWEI } from '../../../shared/modules/conversion.utils'; import { CHAIN_ID_TOKEN_IMAGE_MAP, FEATURED_RPCS, } from '../../../shared/constants/network'; +import { + getMultichainProviderConfig, + getImageForChainId, +} from '../../selectors/multichain'; import { exchangeRatesFromNativeAndCurrencyRates, exchangeRateFromMarketData, @@ -79,7 +89,21 @@ export const getAllBridgeableNetworks = createDeepEqualSelector( getNetworkConfigurationsByChainId, (networkConfigurationsByChainId) => { return uniqBy( - Object.values(networkConfigurationsByChainId), + [ + ...Object.values(networkConfigurationsByChainId), + ///: BEGIN:ONLY_INCLUDE_IF(solana-swaps) + // TODO: get this from network controller, use placeholder values for now + { + ...MULTICHAIN_PROVIDER_CONFIGS[MultichainNetworks.SOLANA], + blockExplorerUrls: [], + name: '', + nativeCurrency: '', + rpcEndpoints: [{ url: '', type: '', networkClientId: '' }], + defaultRpcEndpointIndex: 0, + chainId: MultichainNetworks.SOLANA, + } as unknown as NetworkConfiguration, + ///: END:ONLY_INCLUDE_IF + ], 'chainId', ).filter(({ chainId }) => ALLOWED_BRIDGE_CHAIN_IDS.includes( @@ -92,47 +116,46 @@ export const getAllBridgeableNetworks = createDeepEqualSelector( export const getFromChains = createDeepEqualSelector( getAllBridgeableNetworks, (state: BridgeAppState) => state.metamask.bridgeState?.bridgeFeatureFlags, - (allBridgeableNetworks, bridgeFeatureFlags) => - allBridgeableNetworks.filter( + (allBridgeableNetworks, bridgeFeatureFlags) => { + return allBridgeableNetworks.filter( ({ chainId }) => bridgeFeatureFlags[BridgeFeatureFlagsKey.EXTENSION_CONFIG].chains[ - chainId + formatChainIdToCaip(chainId) ]?.isActiveSrc, - ), + ); + }, ); export const getFromChain = createDeepEqualSelector( - getNetworkConfigurationsByChainId, - getProviderConfig, - ( - networkConfigurationsByChainId, - providerConfig, - ): NetworkConfiguration | undefined => - providerConfig?.chainId - ? networkConfigurationsByChainId[providerConfig.chainId] - : undefined, + getMultichainProviderConfig, + getFromChains, + (providerConfig, fromChains) => { + return providerConfig?.chainId + ? fromChains.find(({ chainId }) => chainId === providerConfig.chainId) + : undefined; + }, ); export const getToChains = createDeepEqualSelector( getAllBridgeableNetworks, (state: BridgeAppState) => state.metamask.bridgeState?.bridgeFeatureFlags, - ( - allBridgeableNetworks, - bridgeFeatureFlags, - ): (AddNetworkFields | NetworkConfiguration)[] => + (allBridgeableNetworks, bridgeFeatureFlags) => uniqBy([...allBridgeableNetworks, ...FEATURED_RPCS], 'chainId').filter( ({ chainId }) => bridgeFeatureFlags[BridgeFeatureFlagsKey.EXTENSION_CONFIG].chains[ - chainId + formatChainIdToCaip(chainId) ]?.isActiveDest, ), ); -export const getToChain = createDeepEqualSelector( +export const getToChain = createSelector( getToChains, (state: BridgeAppState) => state.bridge.toChainId, - (toChains, toChainId): NetworkConfiguration | AddNetworkFields | undefined => - toChains.find(({ chainId }) => chainId === toChainId), + (toChains, toChainId) => + toChains.find( + ({ chainId }) => + chainId === toChainId || formatChainIdToCaip(chainId) === toChainId, + ), ); export const getFromToken = createSelector( @@ -149,14 +172,13 @@ export const getFromToken = createSelector( ...SWAPS_CHAINID_DEFAULT_TOKEN_MAP[ fromChain.chainId as keyof typeof SWAPS_CHAINID_DEFAULT_TOKEN_MAP ], - chainId: fromChain.chainId, + chainId: formatChainIdToCaip(fromChain.chainId), image: CHAIN_ID_TOKEN_IMAGE_MAP[ fromChain.chainId as keyof typeof CHAIN_ID_TOKEN_IMAGE_MAP - ], + ] ?? getImageForChainId(fromChain.chainId), balance: '0', string: '0', - type: AssetType.native, }; }, ); diff --git a/ui/ducks/bridge/utils.ts b/ui/ducks/bridge/utils.ts index bcfdb73b7f43..c7b598ded493 100644 --- a/ui/ducks/bridge/utils.ts +++ b/ui/ducks/bridge/utils.ts @@ -1,16 +1,18 @@ -import type { Hex } from '@metamask/utils'; +import { type CaipChainId, isStrictHexString, type Hex } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; -import { getAddress } from 'ethers/lib/utils'; import type { ContractMarketData } from '@metamask/assets-controllers'; import { AddNetworkFields, NetworkConfiguration, } from '@metamask/network-controller'; +import { toChecksumAddress } from 'ethereumjs-util'; import { decGWEIToHexWEI } from '../../../shared/modules/conversion.utils'; import { Numeric } from '../../../shared/modules/Numeric'; -import type { TxData } from '../../../shared/types/bridge'; +import { ChainId, type TxData } from '../../../shared/types/bridge'; import { getTransaction1559GasFeeEstimates } from '../../pages/swaps/swaps.util'; import { fetchTokenExchangeRates as fetchTokenExchangeRatesUtil } from '../../helpers/utils/util'; +import { formatChainIdToHex } from '../../../shared/modules/bridge-utils/caip-formatters'; +import { MultichainNetworks } from '../../../shared/constants/multichain/networks'; type GasFeeEstimate = { suggestedMaxPriorityFeePerGas: string; @@ -73,18 +75,23 @@ export const getTxGasEstimates = async ({ }; const fetchTokenExchangeRates = async ( - chainId: string, + chainId: Hex | CaipChainId | ChainId, currency: string, ...tokenAddresses: string[] ) => { + // TODO fetch exchange rates for solana + if (chainId === MultichainNetworks.SOLANA) { + return {}; + } + const exchangeRates = await fetchTokenExchangeRatesUtil( currency, tokenAddresses, - chainId, + formatChainIdToHex(chainId), ); return Object.keys(exchangeRates).reduce( (acc: Record, address) => { - acc[address.toLowerCase()] = exchangeRates[address]; + acc[address] = exchangeRates[address]; return acc; }, {}, @@ -95,7 +102,7 @@ const fetchTokenExchangeRates = async ( // rate is not available in the TokenRatesController, which happens when the selected token has not been // imported into the wallet export const getTokenExchangeRate = async (request: { - chainId: Hex; + chainId: Hex | CaipChainId | ChainId; tokenAddress: string; currency: string; }) => { @@ -105,23 +112,23 @@ export const getTokenExchangeRate = async (request: { currency, tokenAddress, ); + // The exchange rate can be checksummed or not, so we need to check both const exchangeRate = - exchangeRates?.[tokenAddress.toLowerCase()] ?? - exchangeRates?.[getAddress(tokenAddress)]; + exchangeRates?.[toChecksumAddress(tokenAddress)] ?? + exchangeRates?.[tokenAddress.toLowerCase()]; return exchangeRate; }; // This extracts a token's exchange rate from the marketData state object // These exchange rates are against the native asset of the chain export const exchangeRateFromMarketData = ( - chainId: string, + chainId: Hex | ChainId, tokenAddress: string, marketData?: Record, ) => - ( - marketData?.[chainId]?.[tokenAddress.toLowerCase() as Hex] ?? - marketData?.[chainId]?.[getAddress(tokenAddress) as Hex] - )?.price; + isStrictHexString(tokenAddress) && isStrictHexString(chainId) + ? marketData?.[chainId]?.[tokenAddress]?.price + : undefined; export const tokenAmountToCurrency = ( amount: string | BigNumber, diff --git a/ui/hooks/bridge/events/types.ts b/ui/hooks/bridge/events/types.ts index dc7a877e981e..05d371f57655 100644 --- a/ui/hooks/bridge/events/types.ts +++ b/ui/hooks/bridge/events/types.ts @@ -1,4 +1,5 @@ -import { StatusTypes } from '../../../../shared/types/bridge-status'; +import type { CaipChainId } from '@metamask/utils'; +import type { StatusTypes } from '../../../../shared/types/bridge-status'; export enum ActionType { CROSSCHAIN_V1 = 'crosschain-v1', @@ -6,8 +7,8 @@ export enum ActionType { } export type RequestParams = { - chain_id_source: string; - chain_id_destination?: string; + chain_id_source: CaipChainId; + chain_id_destination?: CaipChainId; token_symbol_source: string; token_symbol_destination?: string; token_address_source: string; diff --git a/ui/hooks/bridge/events/useRequestProperties.ts b/ui/hooks/bridge/events/useRequestProperties.ts index 5477436f46a5..a5edff55f019 100644 --- a/ui/hooks/bridge/events/useRequestProperties.ts +++ b/ui/hooks/bridge/events/useRequestProperties.ts @@ -5,7 +5,7 @@ import { getFromToken, getToToken, } from '../../../ducks/bridge/selectors'; -import { Numeric } from '../../../../shared/modules/Numeric'; +import { formatChainIdToCaip } from '../../../../shared/modules/bridge-utils/caip-formatters'; export const useRequestProperties = () => { const { srcChainId, destChainId, srcTokenAddress, destTokenAddress } = @@ -13,18 +13,14 @@ export const useRequestProperties = () => { const fromToken = useSelector(getFromToken); const toToken = useSelector(getToToken); - const chain_id_source = - srcChainId && new Numeric(srcChainId, 10).toPrefixedHexString(); - const chain_id_destination = - destChainId && new Numeric(destChainId, 10).toPrefixedHexString(); const token_symbol_source = fromToken?.symbol; const token_symbol_destination = toToken?.symbol; - const token_address_source = srcTokenAddress?.toLowerCase(); - const token_address_destination = destTokenAddress?.toLowerCase(); + const token_address_source = srcTokenAddress; + const token_address_destination = destTokenAddress; if ( - chain_id_source && - chain_id_destination && + srcChainId && + destChainId && token_address_source && token_address_destination && token_symbol_source && @@ -32,16 +28,16 @@ export const useRequestProperties = () => { ) { return { quoteRequestProperties: { - chain_id_source, - chain_id_destination, + chain_id_source: formatChainIdToCaip(srcChainId), + chain_id_destination: formatChainIdToCaip(destChainId), token_symbol_source, token_symbol_destination, token_address_source, token_address_destination, }, flippedRequestProperties: { - chain_id_source: chain_id_destination, - chain_id_destination: chain_id_source, + chain_id_source: formatChainIdToCaip(destChainId), + chain_id_destination: formatChainIdToCaip(srcChainId), token_symbol_source: token_symbol_destination, token_symbol_destination: token_symbol_source, token_address_source: token_address_destination, diff --git a/ui/hooks/bridge/useBridgeExchangeRates.ts b/ui/hooks/bridge/useBridgeExchangeRates.ts index c2755ffda9ee..4f6261cb60c2 100644 --- a/ui/hooks/bridge/useBridgeExchangeRates.ts +++ b/ui/hooks/bridge/useBridgeExchangeRates.ts @@ -7,7 +7,6 @@ import { } from '../../ducks/bridge/selectors'; import { getMarketData, getParticipateInMetaMetrics } from '../../selectors'; import { getCurrentCurrency } from '../../ducks/metamask/metamask'; -import { decimalToPrefixedHex } from '../../../shared/modules/conversion.utils'; import { getCurrentChainId } from '../../../shared/modules/selectors/networks'; import { setDestTokenExchangeRates, @@ -28,17 +27,15 @@ export const useBridgeExchangeRates = () => { const currency = useSelector(getCurrentCurrency); // Use values from activeQuote if available, otherwise use validated input field values - const fromTokenAddress = ( - activeQuote ? activeQuote.quote.srcAsset.address : srcTokenAddress - )?.toLowerCase(); - const toTokenAddress = ( - activeQuote ? activeQuote.quote.destAsset.address : destTokenAddress - )?.toLowerCase(); - const fromChainId = activeQuote - ? decimalToPrefixedHex(activeQuote.quote.srcChainId) - : chainId; + const fromTokenAddress = activeQuote + ? activeQuote.quote.srcAsset.address + : srcTokenAddress; + const toTokenAddress = activeQuote + ? activeQuote.quote.destAsset.address + : destTokenAddress; + const fromChainId = activeQuote ? activeQuote.quote.srcChainId : chainId; const toChainId = activeQuote - ? decimalToPrefixedHex(activeQuote.quote.destChainId) + ? activeQuote.quote.destChainId : toChain?.chainId; const marketData = useSelector(getMarketData); diff --git a/ui/hooks/bridge/useBridging.ts b/ui/hooks/bridge/useBridging.ts index bcb4a68bc59a..770a2bf1c0b7 100644 --- a/ui/hooks/bridge/useBridging.ts +++ b/ui/hooks/bridge/useBridging.ts @@ -1,6 +1,8 @@ import { useCallback, useContext, useEffect } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useHistory } from 'react-router-dom'; +import { toChecksumAddress } from 'ethereumjs-util'; +import { isStrictHexString } from '@metamask/utils'; import { setBridgeFeatureFlags } from '../../ducks/bridge/actions'; import { ///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask) @@ -31,6 +33,7 @@ import { getPortfolioUrl } from '../../helpers/utils/portfolio'; import { SwapsTokenObject } from '../../../shared/constants/swaps'; import { getProviderConfig } from '../../../shared/modules/selectors/networks'; // eslint-disable-next-line import/no-restricted-paths +import { formatChainIdToCaip } from '../../../shared/modules/bridge-utils/caip-formatters'; import { useCrossChainSwapsEventTracker } from './useCrossChainSwapsEventTracker'; ///: END:ONLY_INCLUDE_IF @@ -75,7 +78,7 @@ const useBridging = () => { location === 'Home' ? MetaMetricsSwapsEventSource.MainView : MetaMetricsSwapsEventSource.TokenView, - chain_id_source: providerConfig.chainId, + chain_id_source: formatChainIdToCaip(providerConfig.chainId), token_symbol_source: token.symbol, token_address_source: token.address, }, @@ -91,7 +94,11 @@ const useBridging = () => { }, }); let url = `${CROSS_CHAIN_SWAP_ROUTE}${PREPARE_SWAP_ROUTE}`; - url += `?token=${token.address?.toLowerCase()}`; + url += `?token=${ + isStrictHexString(token.address) + ? toChecksumAddress(token.address) + : token.address + }`; if (isSwap) { url += '&swaps=true'; } diff --git a/ui/hooks/bridge/useLatestBalance.ts b/ui/hooks/bridge/useLatestBalance.ts index 98ee8dfd4f4c..3d60e106832e 100644 --- a/ui/hooks/bridge/useLatestBalance.ts +++ b/ui/hooks/bridge/useLatestBalance.ts @@ -1,5 +1,5 @@ import { useSelector } from 'react-redux'; -import { Hex } from '@metamask/utils'; +import { type Hex, type CaipChainId, isCaipChainId } from '@metamask/utils'; import { Numeric } from '../../../shared/modules/Numeric'; import { getCurrentChainId } from '../../../shared/modules/selectors/networks'; import { getSelectedInternalAccount } from '../../selectors'; @@ -20,7 +20,7 @@ const useLatestBalance = ( decimals: number; symbol: string; } | null, - chainId?: Hex, + chainId?: Hex | CaipChainId, ) => { const { address: selectedAddress } = useSelector(getSelectedInternalAccount); const currentChainId = useSelector(getCurrentChainId); @@ -28,7 +28,13 @@ const useLatestBalance = ( const { value: latestBalance } = useAsyncResult< Numeric | undefined >(async () => { - if (token?.address && chainId && currentChainId === chainId) { + if ( + token?.address && + // TODO check whether chainId is EVM when MultichainNetworkController is integrated + !isCaipChainId(chainId) && + chainId && + currentChainId === chainId + ) { return await calcLatestSrcBalance( global.ethereumProvider, selectedAddress, @@ -37,13 +43,7 @@ const useLatestBalance = ( ); } return undefined; - }, [ - chainId, - currentChainId, - token, - selectedAddress, - global.ethereumProvider, - ]); + }, [currentChainId, token?.address, selectedAddress]); if (token && !token.decimals) { throw new Error( diff --git a/ui/hooks/bridge/useTokensWithFiltering.ts b/ui/hooks/bridge/useTokensWithFiltering.ts index eaa07ded3674..54ee6227bacd 100644 --- a/ui/hooks/bridge/useTokensWithFiltering.ts +++ b/ui/hooks/bridge/useTokensWithFiltering.ts @@ -1,7 +1,7 @@ import { useCallback } from 'react'; import { useSelector } from 'react-redux'; import { ChainId } from '@metamask/controller-utils'; -import { Hex } from '@metamask/utils'; +import { isStrictHexString, type CaipChainId, type Hex } from '@metamask/utils'; import { zeroAddress } from 'ethereumjs-util'; import { getAllDetectedTokensForSelectedAddress, @@ -17,7 +17,7 @@ import { NativeAsset, } from '../../components/multichain/asset-picker-amount/asset-picker-modal/types'; import { AssetType } from '../../../shared/constants/transaction'; -import { isNativeAddress } from '../../pages/bridge/utils/quote'; +import { isNativeAddress } from '../../../shared/modules/bridge-utils/caip-formatters'; import { CHAIN_ID_TOKEN_IMAGE_MAP } from '../../../shared/constants/network'; import { Token } from '../../components/app/assets/types'; import { useMultichainBalances } from '../useMultichainBalances'; @@ -42,7 +42,9 @@ type FilterPredicate = ( * * @param chainId - the selected src/dest chainId */ -export const useTokensWithFiltering = (chainId?: ChainId | Hex) => { +export const useTokensWithFiltering = ( + chainId?: ChainId | Hex | CaipChainId, +) => { const allDetectedTokens: Record = useSelector( getAllDetectedTokensForSelectedAddress, ); @@ -55,7 +57,7 @@ export const useTokensWithFiltering = (chainId?: ChainId | Hex) => { const { value: tokenList, pending: isTokenListLoading } = useAsyncResult< Record >(async () => { - if (chainId) { + if (chainId && isStrictHexString(chainId)) { const timestamp = cachedTokens[chainId]?.timestamp; // Use cached token data if updated in the last 10 minutes if (timestamp && Date.now() - timestamp <= 10 * MINUTE) { @@ -80,7 +82,7 @@ export const useTokensWithFiltering = (chainId?: ChainId | Hex) => { const buildTokenData = ( token?: SwapsTokenObject, ): AssetWithDisplayData | undefined => { - if (!chainId || !token) { + if (!chainId || !token || !isStrictHexString(chainId)) { return undefined; } // Only tokens on the active chain are processed here here @@ -182,9 +184,7 @@ export const useTokensWithFiltering = (chainId?: ChainId | Hex) => { // Yield topTokens from selected chain for (const token_ of topTokens) { - const matchedToken = - tokenList?.[token_.address] ?? - tokenList?.[token_.address.toLowerCase()]; + const matchedToken = tokenList?.[token_.address]; if ( matchedToken && shouldAddToken( diff --git a/ui/pages/bridge/prepare/bridge-cta-button.test.tsx b/ui/pages/bridge/prepare/bridge-cta-button.test.tsx index 92d6591a2808..3d148b852ddf 100644 --- a/ui/pages/bridge/prepare/bridge-cta-button.test.tsx +++ b/ui/pages/bridge/prepare/bridge-cta-button.test.tsx @@ -5,6 +5,7 @@ import { createBridgeMockStore } from '../../../../test/jest/mock-store'; import { CHAIN_IDS } from '../../../../shared/constants/network'; import mockBridgeQuotesNativeErc20 from '../../../../test/data/bridge/mock-quotes-native-erc20.json'; import { RequestStatus } from '../../../../shared/types/bridge'; +import { formatChainIdToCaip } from '../../../../shared/modules/bridge-utils/caip-formatters'; import { BridgeCTAButton } from './bridge-cta-button'; describe('BridgeCTAButton', () => { @@ -48,7 +49,7 @@ describe('BridgeCTAButton', () => { fromTokenInputValue: null, fromToken: 'ETH', toToken: 'ETH', - toChainId: CHAIN_IDS.LINEA_MAINNET, + toChainId: formatChainIdToCaip(CHAIN_IDS.LINEA_MAINNET), }, }); const { getByText } = renderWithProvider( @@ -77,7 +78,7 @@ describe('BridgeCTAButton', () => { fromTokenInputValue: null, fromToken: 'ETH', toToken: null, - toChainId: CHAIN_IDS.LINEA_MAINNET, + toChainId: formatChainIdToCaip(CHAIN_IDS.LINEA_MAINNET), }, }); const { getByText, container } = renderWithProvider( @@ -107,7 +108,7 @@ describe('BridgeCTAButton', () => { fromTokenInputValue: 1, fromToken: 'ETH', toToken: 'ETH', - toChainId: CHAIN_IDS.LINEA_MAINNET, + toChainId: formatChainIdToCaip(CHAIN_IDS.LINEA_MAINNET), }, bridgeStateOverrides: { quotes: mockBridgeQuotesNativeErc20, @@ -148,7 +149,7 @@ describe('BridgeCTAButton', () => { fromTokenInputValue: 1, fromToken: 'ETH', toToken: 'ETH', - toChainId: CHAIN_IDS.LINEA_MAINNET, + toChainId: formatChainIdToCaip(CHAIN_IDS.LINEA_MAINNET), }, bridgeStateOverrides: { quotes: [], @@ -188,7 +189,7 @@ describe('BridgeCTAButton', () => { fromTokenInputValue: 1, fromToken: 'ETH', toToken: 'ETH', - toChainId: CHAIN_IDS.LINEA_MAINNET, + toChainId: formatChainIdToCaip(CHAIN_IDS.LINEA_MAINNET), }, bridgeStateOverrides: { quotes: mockBridgeQuotesNativeErc20, diff --git a/ui/pages/bridge/prepare/bridge-input-group.tsx b/ui/pages/bridge/prepare/bridge-input-group.tsx index d0e9ec89774f..3fc34b0872ef 100644 --- a/ui/pages/bridge/prepare/bridge-input-group.tsx +++ b/ui/pages/bridge/prepare/bridge-input-group.tsx @@ -1,7 +1,6 @@ import React, { useEffect, useRef } from 'react'; import { useSelector } from 'react-redux'; import { BigNumber } from 'bignumber.js'; -import { getAddress } from 'ethers/lib/utils'; import { Text, TextField, @@ -14,11 +13,8 @@ import { AssetPicker } from '../../../components/multichain/asset-picker-amount/ import { TabName } from '../../../components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal-tabs'; import { useI18nContext } from '../../../hooks/useI18nContext'; import { getCurrentCurrency } from '../../../ducks/metamask/metamask'; -import { - formatCurrencyAmount, - formatTokenAmount, - isNativeAddress, -} from '../utils/quote'; +import { formatCurrencyAmount, formatTokenAmount } from '../utils/quote'; +import { isNativeAddress } from '../../../../shared/modules/bridge-utils/caip-formatters'; import { Column, Row } from '../layout'; import { Display, @@ -28,7 +24,6 @@ import { TextVariant, TextColor, } from '../../../helpers/constants/design-system'; -import { AssetType } from '../../../../shared/constants/transaction'; import useLatestBalance from '../../../hooks/bridge/useLatestBalance'; import { getBridgeQuotes, @@ -40,6 +35,8 @@ import { useCopyToClipboard } from '../../../hooks/useCopyToClipboard'; import { MINUTE } from '../../../../shared/constants/time'; import { getIntlLocale } from '../../../ducks/locale/locale'; import { useIsMultichainSwap } from '../hooks/useIsMultichainSwap'; +import { useMultichainSelector } from '../../../hooks/useMultichainSelector'; +import { getMultichainCurrentChainId } from '../../../selectors/multichain'; import { BridgeAssetPickerButton } from './components/bridge-asset-picker-button'; const sanitizeAmountInput = (textToSanitize: string) => { @@ -91,7 +88,8 @@ export const BridgeInputGroup = ({ const currency = useSelector(getCurrentCurrency); const locale = useSelector(getIntlLocale); - const selectedChainId = networkProps?.network?.chainId; + const currentChainId = useMultichainSelector(getMultichainCurrentChainId); + const selectedChainId = networkProps?.network?.chainId ?? currentChainId; const { balanceAmount } = useLatestBalance(token, selectedChainId); const [, handleCopy] = useCopyToClipboard(MINUTE) as [ @@ -240,22 +238,22 @@ export const BridgeInputGroup = ({ } onClick={() => { if (isAmountReadOnly && token && selectedChainId) { - handleCopy(getAddress(token.address)); + handleCopy(token.address); } }} as={isAmountReadOnly ? 'a' : 'p'} > {isAmountReadOnly && - token && - selectedChainId && - token.type === AssetType.token - ? shortenString(token.address, { - truncatedCharLimit: 11, - truncatedStartChars: 4, - truncatedEndChars: 4, - skipCharacterInEnd: false, - }) - : undefined} + token && + selectedChainId && + (isNativeAddress(token.address) + ? undefined + : shortenString(token.address, { + truncatedCharLimit: 11, + truncatedStartChars: 4, + truncatedEndChars: 4, + skipCharacterInEnd: false, + }))} {!isAmountReadOnly && balanceAmount ? formatTokenAmount(locale, balanceAmount, token?.symbol) : undefined} diff --git a/ui/pages/bridge/prepare/prepare-bridge-page.test.tsx b/ui/pages/bridge/prepare/prepare-bridge-page.test.tsx index 732806a1b071..ababc2e0de0a 100644 --- a/ui/pages/bridge/prepare/prepare-bridge-page.test.tsx +++ b/ui/pages/bridge/prepare/prepare-bridge-page.test.tsx @@ -8,6 +8,7 @@ import configureStore from '../../../store/store'; import { createBridgeMockStore } from '../../../../test/jest/mock-store'; import { CHAIN_IDS } from '../../../../shared/constants/network'; import { createTestProviderTools } from '../../../../test/stub/provider'; +import { formatChainIdToCaip } from '../../../../shared/modules/bridge-utils/caip-formatters'; import PrepareBridgePage from './prepare-bridge-page'; describe('PrepareBridgePage', () => { @@ -134,7 +135,7 @@ describe('PrepareBridgePage', () => { address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', decimals: 6, }, - toChainId: CHAIN_IDS.LINEA_MAINNET, + toChainId: formatChainIdToCaip(CHAIN_IDS.LINEA_MAINNET), }, bridgeStateOverrides: { quoteRequest: { @@ -201,7 +202,7 @@ describe('PrepareBridgePage', () => { address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', decimals: 6, }, - toChainId: CHAIN_IDS.LINEA_MAINNET, + toChainId: formatChainIdToCaip(CHAIN_IDS.LINEA_MAINNET), }, }); diff --git a/ui/pages/bridge/prepare/prepare-bridge-page.tsx b/ui/pages/bridge/prepare/prepare-bridge-page.tsx index afc6ad6886c1..60c355717e71 100644 --- a/ui/pages/bridge/prepare/prepare-bridge-page.tsx +++ b/ui/pages/bridge/prepare/prepare-bridge-page.tsx @@ -11,6 +11,7 @@ import { debounce } from 'lodash'; import { useHistory, useLocation } from 'react-router-dom'; import { BigNumber } from 'bignumber.js'; import { type TokenListMap } from '@metamask/assets-controllers'; +import { toChecksumAddress, zeroAddress } from 'ethereumjs-util'; import { setFromToken, setFromTokenInputValue, @@ -60,11 +61,7 @@ import { useI18nContext } from '../../../hooks/useI18nContext'; import { SWAPS_CHAINID_DEFAULT_TOKEN_MAP } from '../../../../shared/constants/swaps'; import { useTokensWithFiltering } from '../../../hooks/bridge/useTokensWithFiltering'; import { setActiveNetwork } from '../../../store/actions'; -import { - hexToDecimal, - decimalToPrefixedHex, -} from '../../../../shared/modules/conversion.utils'; -import type { QuoteRequest } from '../../../../shared/types/bridge'; +import type { GenericQuoteRequest } from '../../../../shared/types/bridge'; import { calcTokenValue } from '../../../../shared/lib/swaps-utils'; import { BridgeQuoteCard } from '../quotes/bridge-quote-card'; import { @@ -87,12 +84,21 @@ import useRamps from '../../../hooks/ramps/useRamps/useRamps'; import { getNativeCurrency } from '../../../ducks/metamask/metamask'; import useLatestBalance from '../../../hooks/bridge/useLatestBalance'; import { useCountdownTimer } from '../../../hooks/bridge/useCountdownTimer'; -import { getCurrentKeyring, getTokenList } from '../../../selectors'; +import { + getCurrentKeyring, + getSelectedEvmInternalAccount, + getSelectedInternalAccount, + getTokenList, +} from '../../../selectors'; import { isHardwareKeyring } from '../../../helpers/utils/hardware'; import { SECOND } from '../../../../shared/constants/time'; import { BRIDGE_QUOTE_MAX_RETURN_DIFFERENCE_PERCENTAGE } from '../../../../shared/constants/bridge'; import { getIntlLocale } from '../../../ducks/locale/locale'; import { useIsMultichainSwap } from '../hooks/useIsMultichainSwap'; +import { useMultichainSelector } from '../../../hooks/useMultichainSelector'; +import { getMultichainIsEvm } from '../../../selectors/multichain'; +import { MultichainNetworks } from '../../../../shared/constants/multichain/networks'; +import { formatChainIdToCaip } from '../../../../shared/modules/bridge-utils/caip-formatters'; import { BridgeInputGroup } from './bridge-input-group'; import { BridgeCTAButton } from './bridge-cta-button'; @@ -101,6 +107,8 @@ const PrepareBridgePage = () => { const t = useI18nContext(); + const isSwap = useIsMultichainSwap(); + const fromToken = useSelector(getFromToken); const fromTokens = useSelector(getTokenList) as TokenListMap; const isFromTokensLoading = useMemo( @@ -141,6 +149,15 @@ const PrepareBridgePage = () => { const activeQuote = isQuoteExpired && !quoteRequest.insufficientBal ? undefined : activeQuote_; + const isEvm = useMultichainSelector(getMultichainIsEvm); + const selectedEvmAccount = useSelector(getSelectedEvmInternalAccount); + const selectedMultichainAccount = useMultichainSelector( + getSelectedInternalAccount, + ); + const selectedAccount = isEvm + ? selectedEvmAccount + : selectedMultichainAccount; + const keyring = useSelector(getCurrentKeyring); // @ts-expect-error keyring type is wrong maybe? const isUsingHardwareWallet = isHardwareKeyring(keyring.type); @@ -178,8 +195,6 @@ const PrepareBridgePage = () => { const millisecondsUntilNextRefresh = useCountdownTimer(); - const isSwap = useIsMultichainSwap(); - const [rotateSwitchTokens, setRotateSwitchTokens] = useState(false); // Resets the banner visibility when the estimated return is low @@ -208,26 +223,24 @@ const PrepareBridgePage = () => { // Get input data from active quote const { srcAsset, destAsset, destChainId, srcChainId } = activeQuote.quote; - const quoteDestChainId = decimalToPrefixedHex(destChainId); - const quoteSrcChainId = decimalToPrefixedHex(srcChainId); - if (srcAsset && destAsset && quoteDestChainId) { + if (srcAsset && destAsset && destChainId) { // Set inputs to values from active quote - dispatch(setToChainId(quoteDestChainId)); + dispatch(setToChainId(destChainId)); dispatch( setToToken({ ...destAsset, - chainId: quoteDestChainId, - image: destAsset.icon, - address: destAsset.address.toLowerCase(), + chainId: destChainId, + image: destAsset.icon ?? '', + address: destAsset.address, }), ); dispatch( setFromToken({ ...srcAsset, - chainId: quoteSrcChainId, - image: srcAsset.icon, - address: srcAsset.address.toLowerCase(), + chainId: srcChainId, + image: srcAsset.icon ?? '', + address: srcAsset.address, }), ); } @@ -262,7 +275,7 @@ const PrepareBridgePage = () => { const quoteParams = useMemo( () => ({ srcTokenAddress: fromToken?.address, - destTokenAddress: toToken?.address || undefined, + destTokenAddress: toToken?.address, srcTokenAmount: fromAmount && fromToken?.decimals ? calcTokenValue( @@ -274,33 +287,40 @@ const PrepareBridgePage = () => { // Length of decimal part cannot exceed token.decimals .split('.')[0] : undefined, - srcChainId: fromChain?.chainId - ? Number(hexToDecimal(fromChain.chainId)) - : undefined, - destChainId: - isSwap && fromChain?.chainId - ? Number(hexToDecimal(fromChain.chainId)) - : toChain?.chainId && Number(hexToDecimal(toChain.chainId)), + srcChainId: fromChain?.chainId, + destChainId: toChain?.chainId, // This override allows quotes to be returned when the rpcUrl is a tenderly fork // Otherwise quotes get filtered out by the bridge-api when the wallet's real // balance is less than the tenderly balance insufficientBal: Boolean(providerConfig?.rpcUrl?.includes('tenderly')), slippage, + walletAddress: selectedAccount?.address ?? '', + // TODO override with account selector's value + destWalletAddress: + (toChain?.chainId && + formatChainIdToCaip(toChain.chainId) === MultichainNetworks.SOLANA) || + isSwap + ? selectedMultichainAccount?.address + : selectedEvmAccount?.address, }), [ - isSwap, - fromToken, - toToken, + fromToken?.address, + fromToken?.decimals, + toToken?.address, + fromAmount, fromChain?.chainId, toChain?.chainId, - fromAmount, - providerConfig, + providerConfig?.rpcUrl, slippage, + selectedAccount?.address, + isSwap, + selectedMultichainAccount?.address, + selectedEvmAccount?.address, ], ); const debouncedUpdateQuoteRequestInController = useCallback( - debounce((p: Partial) => { + debounce((p: Partial) => { dispatch(updateQuoteRequestParams(p)); dispatch(setSelectedQuote(null)); }, 300), @@ -345,14 +365,19 @@ const PrepareBridgePage = () => { }); }; + // fromTokens is for EVM chains so it's ok to lowercase the token address + const matchedToken = fromTokens[tokenAddressFromUrl.toLowerCase()]; + switch (tokenAddressFromUrl) { - case fromToken?.address?.toLowerCase(): + case fromToken?.address: // If the token is already set, remove the query param removeTokenFromUrl(); break; - case fromTokens[tokenAddressFromUrl]?.address?.toLowerCase(): { + case matchedToken?.address: + case matchedToken?.address + ? toChecksumAddress(matchedToken.address) + : undefined: { // If there is a match, set it as the fromToken - const matchedToken = fromTokens[tokenAddressFromUrl]; dispatch( setFromToken({ ...matchedToken, @@ -379,12 +404,16 @@ const PrepareBridgePage = () => { dispatch(setFromTokenInputValue(e)); }} onAssetChange={(token) => { - dispatch(setFromToken(token)); + const bridgeToken = { + ...token, + address: token.address ?? zeroAddress(), + }; + dispatch(setFromToken(bridgeToken)); dispatch(setFromTokenInputValue(null)); - token?.address && + bridgeToken.address && trackInputEvent({ input: 'token_source', - value: token.address, + value: bridgeToken.address, }); }} networkProps={ @@ -394,12 +423,16 @@ const PrepareBridgePage = () => { network: fromChain, networks: fromChains, onNetworkChange: (networkConfig) => { - networkConfig.chainId !== fromChain?.chainId && + networkConfig?.chainId && + networkConfig.chainId !== fromChain?.chainId && trackInputEvent({ input: 'chain_source', value: networkConfig.chainId, }); - if (networkConfig.chainId === toChain?.chainId) { + if ( + networkConfig?.chainId && + networkConfig.chainId === toChain?.chainId + ) { dispatch(setToChainId(null)); dispatch(setToToken(null)); } @@ -508,12 +541,16 @@ const PrepareBridgePage = () => { header={t('swapSelectToken')} token={toToken} onAssetChange={(token) => { - token?.address && + const bridgeToken = { + ...token, + address: token.address ?? zeroAddress(), + }; + bridgeToken.address && trackInputEvent({ input: 'token_destination', - value: token.address, + value: bridgeToken.address, }); - dispatch(setToToken(token)); + dispatch(setToToken(bridgeToken)); }} networkProps={ isSwap diff --git a/ui/pages/bridge/quotes/__snapshots__/bridge-quote-card.test.tsx.snap b/ui/pages/bridge/quotes/__snapshots__/bridge-quote-card.test.tsx.snap index df14ed02d4d7..e751246a98bc 100644 --- a/ui/pages/bridge/quotes/__snapshots__/bridge-quote-card.test.tsx.snap +++ b/ui/pages/bridge/quotes/__snapshots__/bridge-quote-card.test.tsx.snap @@ -62,7 +62,7 @@ exports[`BridgeQuoteCard should render the recommended quote 1`] = ` class="mm-box mm-text mm-avatar-base mm-avatar-base--size-xs mm-avatar-network mm-text--body-xs mm-text--text-transform-uppercase mm-box--display-flex mm-box--justify-content-center mm-box--align-items-center mm-box--color-text-default mm-box--background-color-transparent mm-box--rounded-md mm-box--border-color-background-default mm-box--border-width-1 box--border-style-solid" > Ethereum Mainnet logo @@ -81,7 +81,7 @@ exports[`BridgeQuoteCard should render the recommended quote 1`] = ` class="mm-box mm-text mm-avatar-base mm-avatar-base--size-xs mm-avatar-network mm-text--body-xs mm-text--text-transform-uppercase mm-box--display-flex mm-box--justify-content-center mm-box--align-items-center mm-box--color-text-default mm-box--background-color-transparent mm-box--rounded-md mm-box--border-color-background-default mm-box--border-width-1 box--border-style-solid" > network logo @@ -226,7 +226,7 @@ exports[`BridgeQuoteCard should render the recommended quote while loading new q class="mm-box mm-text mm-avatar-base mm-avatar-base--size-xs mm-avatar-network mm-text--body-xs mm-text--text-transform-uppercase mm-box--display-flex mm-box--justify-content-center mm-box--align-items-center mm-box--color-text-default mm-box--background-color-transparent mm-box--rounded-md mm-box--border-color-background-default mm-box--border-width-1 box--border-style-solid" > Ethereum Mainnet logo @@ -245,7 +245,7 @@ exports[`BridgeQuoteCard should render the recommended quote while loading new q class="mm-box mm-text mm-avatar-base mm-avatar-base--size-xs mm-avatar-network mm-text--body-xs mm-text--text-transform-uppercase mm-box--display-flex mm-box--justify-content-center mm-box--align-items-center mm-box--color-text-default mm-box--background-color-transparent mm-box--rounded-md mm-box--border-color-background-default mm-box--border-width-1 box--border-style-solid" > network logo diff --git a/ui/pages/bridge/quotes/bridge-quote-card.test.tsx b/ui/pages/bridge/quotes/bridge-quote-card.test.tsx index 25d095705324..af3bfaf3e12a 100644 --- a/ui/pages/bridge/quotes/bridge-quote-card.test.tsx +++ b/ui/pages/bridge/quotes/bridge-quote-card.test.tsx @@ -6,6 +6,8 @@ import { CHAIN_IDS } from '../../../../shared/constants/network'; import mockBridgeQuotesErc20Erc20 from '../../../../test/data/bridge/mock-quotes-erc20-erc20.json'; import mockBridgeQuotesNativeErc20 from '../../../../test/data/bridge/mock-quotes-native-erc20.json'; import { RequestStatus } from '../../../../shared/types/bridge'; +import { mockNetworkState } from '../../../../test/stub/networks'; +import { formatChainIdToCaip } from '../../../../shared/modules/bridge-utils/caip-formatters'; import { BridgeQuoteCard } from './bridge-quote-card'; describe('BridgeQuoteCard', () => { @@ -22,10 +24,14 @@ describe('BridgeQuoteCard', () => { chains: { [CHAIN_IDS.MAINNET]: { isActiveSrc: true, isActiveDest: false }, [CHAIN_IDS.OPTIMISM]: { isActiveSrc: true, isActiveDest: true }, + [CHAIN_IDS.POLYGON]: { isActiveSrc: true, isActiveDest: true }, }, }, }, - bridgeSliceOverrides: { fromTokenInputValue: 1 }, + bridgeSliceOverrides: { + fromTokenInputValue: 1, + toChainId: formatChainIdToCaip(CHAIN_IDS.POLYGON), + }, bridgeStateOverrides: { quoteRequest: { insufficientBal: false }, quotesRefreshCount: 1, @@ -33,6 +39,9 @@ describe('BridgeQuoteCard', () => { getQuotesLastFetched: Date.now(), quotesLoadingStatus: RequestStatus.FETCHED, }, + metamaskStateOverrides: { + ...mockNetworkState({ chainId: CHAIN_IDS.OPTIMISM }), + }, }); const { container } = renderWithProvider( , @@ -47,17 +56,24 @@ describe('BridgeQuoteCard', () => { featureFlagOverrides: { extensionConfig: { chains: { - [CHAIN_IDS.MAINNET]: { isActiveSrc: true, isActiveDest: false }, + [CHAIN_IDS.MAINNET]: { isActiveSrc: false, isActiveDest: false }, [CHAIN_IDS.OPTIMISM]: { isActiveSrc: true, isActiveDest: true }, + [CHAIN_IDS.POLYGON]: { isActiveSrc: true, isActiveDest: true }, }, }, }, - bridgeSliceOverrides: { fromTokenInputValue: 1 }, + bridgeSliceOverrides: { + fromTokenInputValue: 1, + toChainId: formatChainIdToCaip(CHAIN_IDS.POLYGON), + }, bridgeStateOverrides: { quotes: mockBridgeQuotesNativeErc20, getQuotesLastFetched: Date.now() - 5000, quotesLoadingStatus: RequestStatus.LOADING, }, + metamaskStateOverrides: { + ...mockNetworkState({ chainId: CHAIN_IDS.OPTIMISM }), + }, }); const { container, queryByText } = renderWithProvider( , diff --git a/ui/pages/bridge/quotes/bridge-quote-card.tsx b/ui/pages/bridge/quotes/bridge-quote-card.tsx index 303e158ea91b..18989768bc4b 100644 --- a/ui/pages/bridge/quotes/bridge-quote-card.tsx +++ b/ui/pages/bridge/quotes/bridge-quote-card.tsx @@ -45,7 +45,6 @@ import { BRIDGE_MM_FEE_RATE, NETWORK_TO_SHORT_NETWORK_NAME_MAP, } from '../../../../shared/constants/bridge'; -import { decimalToPrefixedHex } from '../../../../shared/modules/conversion.utils'; import { TERMS_OF_USE_LINK } from '../../../../shared/constants/terms'; import { getIntlLocale } from '../../../ducks/locale/locale'; import { getImageForChainId } from '../../../selectors/multichain'; @@ -97,7 +96,7 @@ export const BridgeQuoteCard = () => { { @@ -130,38 +129,38 @@ export const BridgeQuoteCard = () => { - { - NETWORK_TO_SHORT_NETWORK_NAME_MAP[ - decimalToPrefixedHex( - activeQuote.quote.srcChainId, - ) as keyof typeof NETWORK_TO_SHORT_NETWORK_NAME_MAP - ] - } + {fromChain?.chainId + ? NETWORK_TO_SHORT_NETWORK_NAME_MAP[ + fromChain.chainId as keyof typeof NETWORK_TO_SHORT_NETWORK_NAME_MAP + ] + : fromChain?.name} - { - NETWORK_TO_SHORT_NETWORK_NAME_MAP[ - decimalToPrefixedHex( - activeQuote.quote.destChainId, - ) as keyof typeof NETWORK_TO_SHORT_NETWORK_NAME_MAP - ] - } + {toChain?.chainId + ? NETWORK_TO_SHORT_NETWORK_NAME_MAP[ + toChain.chainId as keyof typeof NETWORK_TO_SHORT_NETWORK_NAME_MAP + ] + : toChain?.name} diff --git a/ui/pages/bridge/utils/quote.ts b/ui/pages/bridge/utils/quote.ts index ec532622e632..d0e6f20d96e2 100644 --- a/ui/pages/bridge/utils/quote.ts +++ b/ui/pages/bridge/utils/quote.ts @@ -1,4 +1,3 @@ -import { zeroAddress } from 'ethereumjs-util'; import { BigNumber } from 'bignumber.js'; import { calcTokenAmount } from '../../../../shared/lib/transactions-controller-utils'; import { @@ -16,6 +15,7 @@ import { Numeric } from '../../../../shared/modules/Numeric'; import { EtherDenomination } from '../../../../shared/constants/common'; import { DEFAULT_PRECISION } from '../../../hooks/useCurrencyDisplay'; import { formatAmount } from '../../confirmations/components/simulation-details/formatAmount'; +import { isNativeAddress } from '../../../../shared/modules/bridge-utils/caip-formatters'; export const isQuoteExpired = ( isQuoteGoingToRefresh: boolean, @@ -28,9 +28,6 @@ export const isQuoteExpired = ( Date.now() - quotesLastFetchedMs > refreshRate, ); -export const isNativeAddress = (address?: string | null) => - address === zeroAddress() || address === '' || !address; - export const calcToAmount = ( { destTokenAmount, destAsset }: Quote, exchangeRate: number | null,