diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json
index 43396b0fe8a0..c6fce85a2190 100644
--- a/app/_locales/en/messages.json
+++ b/app/_locales/en/messages.json
@@ -509,6 +509,9 @@
"allCustodianAccountsConnectedTitle": {
"message": "No accounts available to connect"
},
+ "allNetworks": {
+ "message": "All networks"
+ },
"allOfYour": {
"message": "All of your $1",
"description": "$1 is the symbol or name of the token that the user is approving spending"
@@ -580,6 +583,9 @@
"message": "MetaMask Institutional",
"description": "The name of the application (MMI)"
},
+ "apply": {
+ "message": "Apply"
+ },
"approve": {
"message": "Approve spend limit"
},
@@ -2934,6 +2940,7 @@
"loadingScreenSnapMessage": {
"message": "Please complete the transaction on the Snap."
},
+ "loadingTokenList": { "message": "Loading token list" },
"loadingTokens": {
"message": "Loading tokens..."
},
@@ -4828,6 +4835,9 @@
"searchTokens": {
"message": "Search tokens"
},
+ "searchTokensByNameOrAddress": {
+ "message": "Search tokens by name or address"
+ },
"secretRecoveryPhrase": {
"message": "Secret Recovery Phrase"
},
@@ -5193,6 +5203,9 @@
"simulationsSettingSubHeader": {
"message": "Estimate balance changes"
},
+ "singleNetwork": {
+ "message": "1 network"
+ },
"siweIssued": {
"message": "Issued"
},
@@ -5422,6 +5435,9 @@
"solanaSupportToggleTitle": {
"message": "Enable \"Add a new Solana account (Beta)\""
},
+ "someNetworks": {
+ "message": "$1 networks"
+ },
"somethingDoesntLookRight": {
"message": "Something doesn't look right? $1",
"description": "A false positive message for users to contact support. $1 is a link to the support page."
diff --git a/shared/constants/bridge.ts b/shared/constants/bridge.ts
index eb2ceb065590..06e0d55b8195 100644
--- a/shared/constants/bridge.ts
+++ b/shared/constants/bridge.ts
@@ -1,4 +1,4 @@
-import { CHAIN_IDS } from './network';
+import { CHAIN_IDS, NETWORK_TO_NAME_MAP } from './network';
// TODO read from feature flags
export const ALLOWED_BRIDGE_CHAIN_IDS = [
@@ -31,3 +31,18 @@ export const BRIDGE_QUOTE_MAX_RETURN_DIFFERENCE_PERCENTAGE = 0.8; // if a quote
export const BRIDGE_PREFERRED_GAS_ESTIMATE = 'medium';
export const BRIDGE_DEFAULT_SLIPPAGE = 0.5;
+
+export const NETWORK_TO_SHORT_NETWORK_NAME_MAP: Record<
+ AllowedBridgeChainIds,
+ string
+> = {
+ [CHAIN_IDS.MAINNET]: 'Ethereum',
+ [CHAIN_IDS.LINEA_MAINNET]: 'Linea',
+ [CHAIN_IDS.POLYGON]: NETWORK_TO_NAME_MAP[CHAIN_IDS.POLYGON],
+ [CHAIN_IDS.AVALANCHE]: 'Avalanche',
+ [CHAIN_IDS.BSC]: NETWORK_TO_NAME_MAP[CHAIN_IDS.BSC],
+ [CHAIN_IDS.ARBITRUM]: NETWORK_TO_NAME_MAP[CHAIN_IDS.ARBITRUM],
+ [CHAIN_IDS.OPTIMISM]: NETWORK_TO_NAME_MAP[CHAIN_IDS.OPTIMISM],
+ [CHAIN_IDS.ZKSYNC_ERA]: 'ZkSync Era',
+ [CHAIN_IDS.BASE]: 'Base',
+};
diff --git a/test/data/mock-send-state.json b/test/data/mock-send-state.json
index f9c602808719..0b7cc237547f 100644
--- a/test/data/mock-send-state.json
+++ b/test/data/mock-send-state.json
@@ -64,6 +64,11 @@
},
"metamask": {
"accountsByChainId": {},
+ "tokenBalances": {
+ "0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc": {
+ "0x5": {}
+ }
+ },
"ipfsGateway": "",
"dismissSeedBackUpReminder": false,
"usePhishDetect": true,
diff --git a/test/e2e/page-objects/pages/home/asset-list.ts b/test/e2e/page-objects/pages/home/asset-list.ts
index c80eb9e96eed..abc5870ec04a 100644
--- a/test/e2e/page-objects/pages/home/asset-list.ts
+++ b/test/e2e/page-objects/pages/home/asset-list.ts
@@ -119,7 +119,7 @@ class AssetListPage {
*/
async hideToken(tokenName: string): Promise {
console.log(`Hide token ${tokenName} on homepage`);
- await this.driver.clickElement({ text: tokenName, tag: 'span' });
+ await this.driver.clickElement({ text: tokenName, tag: 'p' });
await this.driver.clickElement(this.assetOptionsButton);
await this.driver.clickElement(this.hideTokenButton);
await this.driver.waitForSelector(this.hideTokenConfirmationModalTitle);
diff --git a/test/e2e/tests/dapp-interactions/block-explorer.spec.js b/test/e2e/tests/dapp-interactions/block-explorer.spec.js
index 0b01aa65aab3..d6d64dcf4322 100644
--- a/test/e2e/tests/dapp-interactions/block-explorer.spec.js
+++ b/test/e2e/tests/dapp-interactions/block-explorer.spec.js
@@ -82,7 +82,7 @@ describe('Block Explorer', function () {
await driver.clickElement({
text: 'TST',
- tag: 'span',
+ tag: 'p',
});
await driver.clickElement('[data-testid="asset-options__button"]');
diff --git a/test/e2e/tests/multichain/asset-picker-send.spec.ts b/test/e2e/tests/multichain/asset-picker-send.spec.ts
index a071bec9426d..a6d8e193d537 100644
--- a/test/e2e/tests/multichain/asset-picker-send.spec.ts
+++ b/test/e2e/tests/multichain/asset-picker-send.spec.ts
@@ -88,7 +88,7 @@ describe('AssetPickerSendFlow @no-mmi', function () {
await searchInputField.sendKeys('CHZ');
// check that CHZ is disabled
- const [, tkn] = await driver.findElements(
+ const [tkn] = await driver.findElements(
'[data-testid="multichain-token-list-button"]',
);
diff --git a/test/e2e/tests/network/custom-rpc-history.spec.js b/test/e2e/tests/network/custom-rpc-history.spec.js
index 6e92f532698f..2d1a52994d10 100644
--- a/test/e2e/tests/network/custom-rpc-history.spec.js
+++ b/test/e2e/tests/network/custom-rpc-history.spec.js
@@ -264,6 +264,7 @@ describe('Custom RPC history', function () {
const customRpcs = await driver.findElements({
text: 'Localhost 8545',
tag: 'p',
+ css: '.multichain-network-list-item__tooltip',
});
// click Mainnet to dismiss network dropdown
diff --git a/test/e2e/tests/network/update-network.spec.ts b/test/e2e/tests/network/update-network.spec.ts
index 3f0b9882688f..f7861f46ab99 100644
--- a/test/e2e/tests/network/update-network.spec.ts
+++ b/test/e2e/tests/network/update-network.spec.ts
@@ -23,7 +23,7 @@ const selectors = {
deleteButton: { text: 'Delete', tag: 'button' },
cancelButton: { text: 'Cancel', tag: 'button' },
saveButton: { text: 'Save', tag: 'button' },
- updatedNetworkDropDown: { tag: 'span', text: 'Update Network' },
+ updatedNetworkDropDown: { tag: 'p', text: 'Update Network' },
errorMessageInvalidUrl: {
text: 'URLs require the appropriate HTTP/HTTPS prefix.',
},
diff --git a/test/e2e/tests/responsive-ui/metamask-responsive-ui.spec.js b/test/e2e/tests/responsive-ui/metamask-responsive-ui.spec.js
index db43124355c2..54236d84f2f5 100644
--- a/test/e2e/tests/responsive-ui/metamask-responsive-ui.spec.js
+++ b/test/e2e/tests/responsive-ui/metamask-responsive-ui.spec.js
@@ -163,7 +163,7 @@ describe('MetaMask Responsive UI', function () {
// Import Secret Recovery Phrase
await driver.waitForSelector({
- tag: 'span',
+ tag: 'p',
text: 'Localhost 8545',
});
await driver.clickElement({
diff --git a/test/e2e/tests/tokens/add-multiple-tokens.spec.js b/test/e2e/tests/tokens/add-multiple-tokens.spec.js
index 8be2a430b622..bc6ea5e6f2c0 100644
--- a/test/e2e/tests/tokens/add-multiple-tokens.spec.js
+++ b/test/e2e/tests/tokens/add-multiple-tokens.spec.js
@@ -104,7 +104,7 @@ describe('Multiple ERC20 Watch Asset', function () {
// Check all three tokens have been added to the token list.
const addedTokens = await driver.findElements({
- tag: 'span',
+ tag: 'p',
text: 'TST',
});
assert.equal(addedTokens.length, 3);
diff --git a/test/e2e/tests/transaction/change-assets.spec.js b/test/e2e/tests/transaction/change-assets.spec.js
index 9764941771b7..33944f7a2160 100644
--- a/test/e2e/tests/transaction/change-assets.spec.js
+++ b/test/e2e/tests/transaction/change-assets.spec.js
@@ -111,7 +111,7 @@ describe('Change assets', function () {
// Click the Send button
await driver.clickElement({
- css: '[data-testid="multichain-token-list-button"] span',
+ css: '[data-testid="multichain-token-list-button"] p',
text: 'TST',
});
@@ -471,7 +471,7 @@ describe('Change assets', function () {
// Click the Send button
await driver.clickElement({
- css: '[data-testid="multichain-token-list-button"] span',
+ css: '[data-testid="multichain-token-list-button"] p',
text: 'TST',
});
diff --git a/test/jest/mock-store.js b/test/jest/mock-store.js
index f937dcac0cf5..84d18e1a3803 100644
--- a/test/jest/mock-store.js
+++ b/test/jest/mock-store.js
@@ -740,6 +740,113 @@ export const createBridgeMockStore = (
},
currencyRates: {
ETH: { conversionRate: 2524.25 },
+ usd: { conversionRate: 1 },
+ },
+ marketData: {
+ '0x1': {
+ '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984': {
+ currency: 'usd',
+ price: 2.3,
+ },
+ },
+ },
+ allTokens: {
+ [CHAIN_IDS.MAINNET]: {
+ '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc': [
+ {
+ address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984',
+ balance: 'a',
+ decimals: 6,
+ },
+ {
+ address: '0x514910771af9ca656af840dff83e8264ecf986ca',
+ balance: 'e',
+ },
+ ],
+ '0xec1adf982415d2ef5ec55899b9bfb8bc0f29251b': [
+ {
+ address: '0x6b3595068778dd592e39a122f4f5a5cf09c90fe2',
+ balance: 'e',
+ },
+ ],
+ },
+ [CHAIN_IDS.LINEA_MAINNET]: {
+ '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc': [
+ {
+ address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984',
+ balance: 'e',
+ },
+ ],
+ '0xec1adf982415d2ef5ec55899b9bfb8bc0f29251b': [
+ {
+ address: '0x6b3595068778dd592e39a122f4f5a5cf09c90fe2',
+ balance: 'e',
+ },
+ ],
+ },
+ },
+ accountsByChainId: {
+ [CHAIN_IDS.MAINNET]: {
+ '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc': {
+ address: '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc',
+ balance: '0xa',
+ },
+ '0xec1adf982415d2ef5ec55899b9bfb8bc0f29251b': {
+ address: '0xec1adf982415d2ef5ec55899b9bfb8bc0f29251b',
+ balance: '0xe',
+ },
+ },
+ [CHAIN_IDS.LINEA_MAINNET]: {
+ '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc': {
+ address: '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc',
+ balance: '0xe',
+ },
+ '0xec1adf982415d2ef5ec55899b9bfb8bc0f29251b': {
+ address: '0xec1adf982415d2ef5ec55899b9bfb8bc0f29251b',
+ balance: '0xe',
+ },
+ },
+ },
+ tokensChainsCache: {
+ [CHAIN_IDS.MAINNET]: {
+ timestamp: 111111,
+ data: [
+ {
+ address: '0x514910771af9ca656af840dff83e8264ecf986ca',
+ symbol: 'LINK',
+ decimals: 18,
+ },
+ {
+ address: '0xc00e94cb662c3520282e6f5717214004a7f26888',
+ symbol: 'COMP',
+ decimals: 18,
+ },
+ ],
+ },
+ [CHAIN_IDS.LINEA_MAINNET]: {
+ timestamp: 111111,
+ data: {
+ '0x514910771af9ca656af840dff83e8264ecf986ca': {
+ address: '0x514910771af9ca656af840dff83e8264ecf986ca',
+ symbol: 'LINK',
+ decimals: 18,
+ },
+ '0xc00e94cb662c3520282e6f5717214004a7f26888': {
+ address: '0xc00e94cb662c3520282e6f5717214004a7f26888',
+ symbol: 'COMP',
+ decimals: 18,
+ },
+ },
+ },
+ },
+ tokenBalances: {
+ '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc': {
+ '0x5': {},
+ '0x1': {
+ '0x514910771af9ca656af840dff83e8264ecf986ca': '0x1',
+ '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984': '0x738',
+ },
+ },
},
...metamaskStateOverrides,
bridgeState: {
diff --git a/ui/components/app/assets/token-cell/__snapshots__/token-cell.test.tsx.snap b/ui/components/app/assets/token-cell/__snapshots__/token-cell.test.tsx.snap
index 9a62f050e644..7fb51f212ebd 100644
--- a/ui/components/app/assets/token-cell/__snapshots__/token-cell.test.tsx.snap
+++ b/ui/components/app/assets/token-cell/__snapshots__/token-cell.test.tsx.snap
@@ -3,13 +3,14 @@
exports[`Token Cell should match snapshot 1`] = `
diff --git a/ui/components/component-library/picker-network/__snapshots__/picker-network.test.tsx.snap b/ui/components/component-library/picker-network/__snapshots__/picker-network.test.tsx.snap
index 3e43996dc512..a451e62576a6 100644
--- a/ui/components/component-library/picker-network/__snapshots__/picker-network.test.tsx.snap
+++ b/ui/components/component-library/picker-network/__snapshots__/picker-network.test.tsx.snap
@@ -1,5 +1,129 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
+exports[`PickerNetwork should render multiple avatars when avatarGroupProps is present 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +2
+
+
+
+
+ test
+
+
+
+
+`;
+
+exports[`PickerNetwork should render multiple avatars with a stacked tag when isTagOverlay is present 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +2
+
+
+
+
+ test
+
+
+
+
+`;
+
exports[`PickerNetwork should render the label inside the PickerNetwork 1`] = `
I
-
Imported
-
+
= (args) => (
>
);
+
+export const AvatarGroupProps: StoryFn = () => (
+ ({
+ avatarValue:
+ CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP[
+ c as keyof typeof CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP
+ ],
+ symbol:
+ NETWORK_TO_SHORT_NETWORK_NAME_MAP[
+ c as keyof typeof NETWORK_TO_SHORT_NETWORK_NAME_MAP
+ ],
+ })),
+ avatarType: AvatarType.NETWORK,
+ }}
+ />
+);
diff --git a/ui/components/component-library/picker-network/picker-network.test.tsx b/ui/components/component-library/picker-network/picker-network.test.tsx
index ac7ee292b55c..d05e4a52caf9 100644
--- a/ui/components/component-library/picker-network/picker-network.test.tsx
+++ b/ui/components/component-library/picker-network/picker-network.test.tsx
@@ -2,6 +2,9 @@ import { render, screen } from '@testing-library/react';
import React from 'react';
import { IconName } from '..';
+import { renderWithProvider } from '../../../../test/lib/render-helpers';
+import configureStore from '../../../store/store';
+import { AvatarType } from '../../multichain/avatar-group/avatar-group.types';
import { PickerNetwork } from './picker-network';
describe('PickerNetwork', () => {
@@ -77,4 +80,46 @@ describe('PickerNetwork', () => {
);
expect(getByTestId('picker-network-label')).toHaveClass('test-class');
});
+ // avatarGroupProps
+ it('should render multiple avatars when avatarGroupProps is present', () => {
+ const { container } = renderWithProvider(
+ ,
+ configureStore({ metamask: { useBlockie: false } }),
+ );
+ expect(container).toMatchSnapshot();
+ });
+ it('should render multiple avatars with a stacked tag when isTagOverlay is present', () => {
+ const { container } = renderWithProvider(
+ ,
+ configureStore({ metamask: { useBlockie: true } }),
+ );
+ expect(container).toMatchSnapshot();
+ });
});
diff --git a/ui/components/component-library/picker-network/picker-network.tsx b/ui/components/component-library/picker-network/picker-network.tsx
index 7363b1e16bc3..f92ad174beed 100644
--- a/ui/components/component-library/picker-network/picker-network.tsx
+++ b/ui/components/component-library/picker-network/picker-network.tsx
@@ -18,6 +18,7 @@ import {
Text,
} from '..';
import { BoxProps, PolymorphicRef } from '../box';
+import { AvatarGroup } from '../../multichain/avatar-group';
import {
PickerNetworkComponent,
PickerNetworkProps,
@@ -27,6 +28,7 @@ export const PickerNetwork: PickerNetworkComponent = React.forwardRef(
(
{
className = '',
+ avatarGroupProps,
avatarNetworkProps,
iconProps,
label,
@@ -50,14 +52,19 @@ export const PickerNetwork: PickerNetworkComponent = React.forwardRef(
display={Display.Flex}
{...(props as BoxProps)}
>
-
-
+ {avatarGroupProps ? (
+
+ ) : (
+
+ )}
+
+
{label}
-
Chain 5
-
+
{
return mockState.getTokenList;
} else if (selector === getIntlLocale) {
return mockState.getIntlLocale;
+ } else if (selector === getNetworkConfigurationIdByChainId) {
+ return {
+ '0x1': { networkName: 'Ethereum', iconUrl: 'network-icon-url' },
+ };
}
return undefined;
});
@@ -69,6 +76,7 @@ describe('Asset', () => {
balance="10000000000000000000"
decimals={18}
tooltipText="tooltip"
+ chainId="0x1"
/>,
);
diff --git a/ui/components/multichain/asset-picker-amount/asset-picker-modal/Asset.tsx b/ui/components/multichain/asset-picker-amount/asset-picker-modal/Asset.tsx
index f6e7e7f3ccc9..5561af182dc0 100644
--- a/ui/components/multichain/asset-picker-amount/asset-picker-modal/Asset.tsx
+++ b/ui/components/multichain/asset-picker-amount/asset-picker-modal/Asset.tsx
@@ -1,17 +1,26 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { BigNumber } from 'bignumber.js';
-import { getCurrentChainId } from '../../../../../shared/modules/selectors/networks';
-import { getTokenList } from '../../../../selectors';
+import {
+ getCurrentCurrency,
+ getNetworkConfigurationIdByChainId,
+ getTokenList,
+} from '../../../../selectors';
import { useTokenFiatAmount } from '../../../../hooks/useTokenFiatAmount';
import { TokenListItem } from '../../token-list-item';
import { isEqualCaseInsensitive } from '../../../../../shared/modules/string-utils';
import { formatAmount } from '../../../../pages/confirmations/components/simulation-details/formatAmount';
import { getIntlLocale } from '../../../../ducks/locale/locale';
-import { AssetWithDisplayData, ERC20Asset } from './types';
+import { CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP } from '../../../../../shared/constants/network';
+import { formatCurrency } from '../../../../helpers/utils/confirm-tx.util';
+import { AssetWithDisplayData, ERC20Asset, NativeAsset } from './types';
-type AssetProps = AssetWithDisplayData & {
+type AssetProps = AssetWithDisplayData & {
tooltipText?: string;
+ assetItemProps?: Pick<
+ React.ComponentProps,
+ 'isTitleNetworkName' | 'isTitleHidden'
+ >;
};
export default function Asset({
@@ -20,11 +29,18 @@ export default function Asset({
symbol,
string: decimalTokenAmount,
tooltipText,
+ tokenFiatAmount,
+ chainId,
+ assetItemProps = {},
}: AssetProps) {
const locale = useSelector(getIntlLocale);
- const chainId = useSelector(getCurrentChainId);
+ const currency = useSelector(getCurrentCurrency);
const tokenList = useSelector(getTokenList);
+ const allNetworks = useSelector(getNetworkConfigurationIdByChainId);
+ const isTokenChainIdInWallet = Boolean(
+ chainId ? allNetworks[chainId as keyof typeof allNetworks] : true,
+ );
const tokenData = address
? Object.values(tokenList).find(
(token) =>
@@ -45,20 +61,30 @@ export default function Asset({
const formattedAmount = decimalTokenAmount
? `${formatAmount(
locale,
- new BigNumber(decimalTokenAmount || '0', 10),
+ new BigNumber(decimalTokenAmount.toString(), 10),
)} ${symbol}`
: undefined;
+ const primaryAmountToUse = tokenFiatAmount
+ ? formatCurrency(tokenFiatAmount.toString(), currency, 2)
+ : formattedFiat;
return (
);
}
diff --git a/ui/components/multichain/asset-picker-amount/asset-picker-modal/AssetList.test.tsx b/ui/components/multichain/asset-picker-amount/asset-picker-modal/AssetList.test.tsx
index c4e4ba0ceb29..d3807b4952d1 100644
--- a/ui/components/multichain/asset-picker-amount/asset-picker-modal/AssetList.test.tsx
+++ b/ui/components/multichain/asset-picker-amount/asset-picker-modal/AssetList.test.tsx
@@ -2,6 +2,7 @@ import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { useSelector } from 'react-redux';
import {
+ getCurrentNetwork,
getPreferences,
getSelectedAccountCachedBalance,
} from '../../../../selectors';
@@ -10,6 +11,7 @@ import { useUserPreferencedCurrency } from '../../../../hooks/useUserPreferenced
import { useCurrencyDisplay } from '../../../../hooks/useCurrencyDisplay';
import { AssetType } from '../../../../../shared/constants/transaction';
import { CHAIN_ID_TOKEN_IMAGE_MAP } from '../../../../../shared/constants/network';
+import { getCurrentChainId } from '../../../../../shared/modules/selectors/networks';
import AssetList from './AssetList';
import { AssetWithDisplayData, ERC20Asset, NativeAsset } from './types';
@@ -55,6 +57,7 @@ describe('AssetList', () => {
string: '10',
decimals: 18,
balance: '0',
+ chainId: '0x1',
},
{
address: '0xToken2',
@@ -64,6 +67,7 @@ describe('AssetList', () => {
string: '20',
decimals: 6,
balance: '10',
+ chainId: '0x1',
},
{
address: null,
@@ -73,6 +77,7 @@ describe('AssetList', () => {
string: '30',
decimals: 18,
balance: '0x121',
+ chainId: '0x1',
},
];
const primaryCurrency = 'USD';
@@ -110,6 +115,15 @@ describe('AssetList', () => {
});
it('should render the token list', () => {
+ (useSelector as jest.Mock).mockImplementation((selector) => {
+ if (selector === getCurrentChainId) {
+ return '0x1';
+ }
+ if (selector === getCurrentNetwork) {
+ return { chainId: '0x1' };
+ }
+ return undefined;
+ });
render(
{
type: AssetType.native,
image: CHAIN_ID_TOKEN_IMAGE_MAP['0x1'],
symbol: 'ETH',
+ chainId: '0x1',
}}
tokenList={tokenList}
/>,
@@ -134,6 +149,7 @@ describe('AssetList', () => {
type: AssetType.native,
image: CHAIN_ID_TOKEN_IMAGE_MAP['0x1'],
symbol: 'ETH',
+ chainId: '0x1',
}}
tokenList={tokenList}
/>,
@@ -166,6 +182,7 @@ describe('AssetList', () => {
type: AssetType.native,
image: CHAIN_ID_TOKEN_IMAGE_MAP['0x1'],
symbol: 'ETH',
+ chainId: '0x1',
}}
tokenList={tokenList}
isTokenDisabled={(token) => token.address === '0xToken1'}
diff --git a/ui/components/multichain/asset-picker-amount/asset-picker-modal/AssetList.tsx b/ui/components/multichain/asset-picker-amount/asset-picker-modal/AssetList.tsx
index 6f867d25ca85..ece3d7046fde 100644
--- a/ui/components/multichain/asset-picker-amount/asset-picker-modal/AssetList.tsx
+++ b/ui/components/multichain/asset-picker-amount/asset-picker-modal/AssetList.tsx
@@ -1,9 +1,14 @@
import React from 'react';
import { useSelector } from 'react-redux';
import classnames from 'classnames';
+import {
+ AddNetworkFields,
+ NetworkConfiguration,
+} from '@metamask/network-controller';
import { getCurrentChainId } from '../../../../../shared/modules/selectors/networks';
import {
getCurrentCurrency,
+ getCurrentNetwork,
getSelectedAccountCachedBalance,
} from '../../../../selectors';
import { getNativeCurrency } from '../../../../ducks/metamask/metamask';
@@ -18,6 +23,9 @@ import {
FlexWrap,
} from '../../../../helpers/constants/design-system';
import { TokenListItem } from '../..';
+import LoadingScreen from '../../../ui/loading-screen';
+import { useI18nContext } from '../../../../hooks/useI18nContext';
+import { CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP } from '../../../../../shared/constants/network';
import AssetComponent from './Asset';
import { AssetWithDisplayData, ERC20Asset, NativeAsset } from './types';
@@ -33,6 +41,12 @@ type AssetListProps = {
isTokenDisabled?: (
token: AssetWithDisplayData | AssetWithDisplayData,
) => boolean;
+ network?: NetworkConfiguration | AddNetworkFields;
+ isTokenListLoading?: boolean;
+ assetItemProps?: Pick<
+ React.ComponentProps,
+ 'isTitleNetworkName' | 'isTitleHidden'
+ >;
};
export default function AssetList({
@@ -40,8 +54,20 @@ export default function AssetList({
asset,
tokenList,
isTokenDisabled,
+ network,
+ isTokenListLoading = false,
+ assetItemProps = {},
}: AssetListProps) {
- const selectedToken = asset?.address;
+ const t = useI18nContext();
+ const selectedTokenAddress = asset?.address;
+
+ const currentNetwork = useSelector(getCurrentNetwork);
+ // If a network is provided, display tokens in that network
+ // Otherwise, assume tokens in the current network are displayed
+ const networkToUse = network ?? currentNetwork;
+ // This indicates whether tokens in the wallet's active network are displayed
+ const isSelectedNetworkActive =
+ networkToUse.chainId === currentNetwork.chainId;
const chainId = useSelector(getCurrentChainId);
const nativeCurrency = useSelector(getNativeCurrency);
@@ -59,9 +85,24 @@ export default function AssetList({
return (
+ {isTokenListLoading && (
+
+ )}
{tokenList.map((token) => {
const tokenAddress = token.address?.toLowerCase();
- const isSelected = tokenAddress === selectedToken?.toLowerCase();
+
+ const isMatchingChainId = token.chainId === networkToUse?.chainId;
+ const isMatchingAddress =
+ // the native asset can have an undefined, null, '', or zero address
+ (token.type === AssetType.native &&
+ !token.address &&
+ !selectedTokenAddress) ||
+ tokenAddress === selectedTokenAddress?.toLowerCase();
+ const isSelected = isMatchingChainId && isMatchingAddress;
+
const isDisabled = isTokenDisabled?.(token) ?? false;
return (
@@ -69,7 +110,7 @@ export default function AssetList({
padding={0}
gap={0}
margin={0}
- key={token.symbol}
+ key={`${token.symbol}-${tokenAddress ?? ''}-${token.chainId}`}
backgroundColor={
isSelected
? BackgroundColor.primaryMuted
@@ -101,24 +142,33 @@ export default function AssetList({
flexWrap={FlexWrap.NoWrap}
alignItems={AlignItems.center}
>
-
- {token.type === AssetType.native ? (
+
+ {token.type === AssetType.native &&
+ token.chainId === chainId &&
+ isSelectedNetworkActive ? (
+ // Only use this component for the native token of the active network
) : (
)}
diff --git a/ui/components/multichain/asset-picker-amount/asset-picker-modal/__snapshots__/asset-picker-modal-network.test.tsx.snap b/ui/components/multichain/asset-picker-amount/asset-picker-modal/__snapshots__/asset-picker-modal-network.test.tsx.snap
index 4e0e3640a75a..f0d99284b7a8 100644
--- a/ui/components/multichain/asset-picker-amount/asset-picker-modal/__snapshots__/asset-picker-modal-network.test.tsx.snap
+++ b/ui/components/multichain/asset-picker-amount/asset-picker-modal/__snapshots__/asset-picker-modal-network.test.tsx.snap
@@ -71,11 +71,10 @@ exports[`AssetPickerModalNetwork renders modal with no network list by default 1
@@ -161,20 +160,19 @@ exports[`AssetPickerModalNetwork should not show selected network when network p
@@ -185,14 +183,25 @@ exports[`AssetPickerModalNetwork should not show selected network when network p
>
@@ -200,10 +209,10 @@ exports[`AssetPickerModalNetwork should not show selected network when network p
class="mm-box multichain-network-list-item mm-box--padding-top-4 mm-box--padding-right-4 mm-box--padding-bottom-4 mm-box--padding-left-4 mm-box--display-flex mm-box--gap-4 mm-box--justify-content-space-between mm-box--align-items-center mm-box--width-full mm-box--background-color-transparent"
>
@@ -214,14 +223,25 @@ exports[`AssetPickerModalNetwork should not show selected network when network p
>
@@ -310,11 +330,10 @@ exports[`AssetPickerModalNetwork should use passed in network as default when ne
@@ -337,14 +356,25 @@ exports[`AssetPickerModalNetwork should use passed in network as default when ne
>
@@ -352,10 +382,10 @@ exports[`AssetPickerModalNetwork should use passed in network as default when ne
class="mm-box multichain-network-list-item mm-box--padding-top-4 mm-box--padding-right-4 mm-box--padding-bottom-4 mm-box--padding-left-4 mm-box--display-flex mm-box--gap-4 mm-box--justify-content-space-between mm-box--align-items-center mm-box--width-full mm-box--background-color-transparent"
>
@@ -366,14 +396,25 @@ exports[`AssetPickerModalNetwork should use passed in network as default when ne
>
diff --git a/ui/components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal-network.test.tsx b/ui/components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal-network.test.tsx
index 3fc1e8cf7952..a9246874943e 100644
--- a/ui/components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal-network.test.tsx
+++ b/ui/components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal-network.test.tsx
@@ -3,8 +3,10 @@ import configureStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { screen, fireEvent } from '@testing-library/react';
import { RpcEndpointType } from '@metamask/network-controller';
+import { CHAIN_IDS } from '@metamask/transaction-controller';
import { renderWithProvider } from '../../../../../test/lib/render-helpers';
import mockState from '../../../../../test/data/mock-send-state.json';
+import { NETWORK_TO_SHORT_NETWORK_NAME_MAP } from '../../../../../shared/constants/bridge';
import { AssetPickerModalNetwork } from './asset-picker-modal-network';
const mockOnClose = jest.fn();
@@ -26,7 +28,7 @@ describe('AssetPickerModalNetwork', () => {
const networkProps = {
network: {
- chainId: '0x1',
+ chainId: CHAIN_IDS.MAINNET,
nativeCurrency: 'ETH',
defaultBlockExplorerUrlIndex: 0,
blockExplorerUrls: ['https://explorerurl'],
@@ -43,7 +45,7 @@ describe('AssetPickerModalNetwork', () => {
} as any,
networks: [
{
- chainId: '0x1',
+ chainId: CHAIN_IDS.MAINNET,
nativeCurrency: 'ETH',
defaultBlockExplorerUrlIndex: 0,
blockExplorerUrls: ['https://explorerurl'],
@@ -58,7 +60,7 @@ describe('AssetPickerModalNetwork', () => {
name: 'Network name 3',
},
{
- chainId: '0xa',
+ chainId: CHAIN_IDS.OPTIMISM,
nativeCurrency: 'ETH',
defaultBlockExplorerUrlIndex: 0,
blockExplorerUrls: ['https://explorerurl'],
@@ -110,7 +112,10 @@ describe('AssetPickerModalNetwork', () => {
});
it('should call onClose and onBack when header buttons are clicked', () => {
- renderWithProvider( , store);
+ renderWithProvider(
+ ,
+ store,
+ );
fireEvent.click(screen.getByLabelText('Close'));
expect(mockOnClose).toHaveBeenCalledTimes(1);
@@ -125,7 +130,9 @@ describe('AssetPickerModalNetwork', () => {
store,
);
- fireEvent.click(screen.getByText('Network name 3'));
+ fireEvent.click(
+ screen.getByText(NETWORK_TO_SHORT_NETWORK_NAME_MAP[CHAIN_IDS.MAINNET]),
+ );
expect(mockOnBack).toHaveBeenCalledTimes(1);
expect(mockOnNetworkChange).toHaveBeenCalledTimes(1);
});
diff --git a/ui/components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal-network.tsx b/ui/components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal-network.tsx
index f64209543557..7d6963240619 100644
--- a/ui/components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal-network.tsx
+++ b/ui/components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal-network.tsx
@@ -1,11 +1,19 @@
-import React from 'react';
+import React, { useEffect, useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
-import { NetworkConfiguration } from '@metamask/network-controller';
+import {
+ AddNetworkFields,
+ NetworkConfiguration,
+} from '@metamask/network-controller';
+import { IconName } from '@metamask/snaps-sdk/jsx';
import {
Display,
FlexDirection,
BlockSize,
+ AlignItems,
+ TextVariant,
+ IconColor,
+ BackgroundColor,
} from '../../../../helpers/constants/design-system';
import {
ModalOverlay,
@@ -13,27 +21,37 @@ import {
ModalHeader,
Modal,
Box,
+ ButtonLink,
+ Checkbox,
+ Text,
+ AvatarNetworkSize,
} from '../../../component-library';
import { CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP } from '../../../../../shared/constants/network';
///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask)
import { useI18nContext } from '../../../../hooks/useI18nContext';
///: END:ONLY_INCLUDE_IF
import { NetworkListItem } from '../../network-list-item';
-import {
- getNetworkConfigurationsByChainId,
- getProviderConfig,
-} from '../../../../../shared/modules/selectors/networks';
+import { getNetworkConfigurationsByChainId } from '../../../../../shared/modules/selectors/networks';
+import { getCurrentCurrency } from '../../../../selectors';
+import { formatCurrency } from '../../../../helpers/utils/confirm-tx.util';
+import { useMultichainBalances } from '../../../../hooks/useMultichainBalances';
+import { NETWORK_TO_SHORT_NETWORK_NAME_MAP } from '../../../../../shared/constants/bridge';
/**
* AssetPickerModalNetwork component displays a modal for selecting a network in the asset picker.
*
* @param props
* @param props.isOpen - Determines whether the modal is open or not.
- * @param props.network - The currently selected network, not necessarily the active wallet network.
+ * @param props.network - The currently selected network, not necessarily the active wallet network, and possibly not imported yet.
* @param props.networks - The list of selectable networks.
* @param props.onNetworkChange - The callback function to handle network change.
* @param props.onClose - The callback function to handle modal close.
* @param props.onBack - The callback function to handle going back in the modal.
+ * @param props.shouldDisableNetwork - The callback function to determine if a network should be disabled.
+ * @param props.header - A custom header for the modal.
+ * @param props.onMultiselectSubmit - The callback function to run when multiple networks are selected.
+ * @param props.selectedChainIds - A list of selected chainIds.
+ * @param props.isMultiselectEnabled - Determines whether selecting multiple networks is enabled.
* @returns A modal with a list of selectable networks.
*/
export const AssetPickerModalNetwork = ({
@@ -43,26 +61,95 @@ export const AssetPickerModalNetwork = ({
network,
networks,
onNetworkChange,
+ shouldDisableNetwork,
+ header,
+ isMultiselectEnabled,
+ onMultiselectSubmit,
+ selectedChainIds,
}: {
isOpen: boolean;
- network?: NetworkConfiguration;
- networks?: NetworkConfiguration[];
- onNetworkChange: (network: NetworkConfiguration) => void;
+ network?: NetworkConfiguration | AddNetworkFields;
+ networks?: (NetworkConfiguration | AddNetworkFields)[];
+ onNetworkChange: (network: NetworkConfiguration | AddNetworkFields) => void;
+ shouldDisableNetwork?: (
+ network: NetworkConfiguration | AddNetworkFields,
+ ) => boolean;
onClose: () => void;
onBack: () => void;
+ header?: JSX.Element | string | null;
+ isMultiselectEnabled?: boolean;
+ selectedChainIds?: string[];
+ onMultiselectSubmit?: (selectedChainIds: string[]) => void;
}) => {
///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask)
const t = useI18nContext();
///: END:ONLY_INCLUDE_IF
- const currentNetwork = useSelector(getProviderConfig);
+ const { balanceByChainId } = useMultichainBalances();
+
const allNetworks = useSelector(getNetworkConfigurationsByChainId);
+ const currency = useSelector(getCurrentCurrency);
+ // Use the networks prop if it is provided, otherwise use all available networks
+ // Sort the networks by balance in descending order
+ const networksList = useMemo(
+ () =>
+ (networks ?? Object.values(allNetworks) ?? []).sort(
+ (a, b) => balanceByChainId[b.chainId] - balanceByChainId[a.chainId],
+ ),
+ [],
+ );
+
+ // Tracks the selection/checked state of each network
+ // Initialized with the selectedChainIds if provided
+ const [checkedChainIds, setCheckedChainIds] = useState<
+ Record
+ >(
+ networksList?.reduce(
+ (acc, { chainId }) => ({
+ ...acc,
+ [chainId]: selectedChainIds
+ ? selectedChainIds.includes(chainId)
+ : false,
+ }),
+ {},
+ ) ?? {},
+ );
- const selectedNetwork =
- network ?? (currentNetwork?.chainId && allNetworks[currentNetwork.chainId]);
+ // Reset checkedChainIds if selectedChainIds change in parent component
+ useEffect(() => {
+ networksList &&
+ setCheckedChainIds(
+ networksList.reduce(
+ (acc, { chainId }) => ({
+ ...acc,
+ [chainId]: selectedChainIds
+ ? selectedChainIds.includes(chainId)
+ : false,
+ }),
+ {},
+ ),
+ );
+ }, [networksList, selectedChainIds]);
- const networksList: NetworkConfiguration[] =
- networks ?? Object.values(allNetworks) ?? [];
+ const handleToggleNetwork = (chainId: string) => {
+ setCheckedChainIds((prev) => ({
+ ...prev,
+ [chainId]: !prev[chainId],
+ }));
+ };
+
+ // Toggles all networks to be checked or unchecked
+ const handleToggleAllNetworks = () => {
+ setCheckedChainIds(
+ Object.keys(checkedChainIds)?.reduce(
+ (agg, chainId) => ({
+ ...agg,
+ [chainId]: !Object.values(checkedChainIds).every((v) => v),
+ }),
+ {},
+ ),
+ );
+ };
return (
-
- {t('bridgeSelectNetwork')}
+ !v)}
+ onClick={() => {
+ onMultiselectSubmit?.(
+ Object.keys(checkedChainIds).filter(
+ (chainId) => checkedChainIds[chainId],
+ ),
+ );
+ onBack();
+ }}
+ >
+ {t('apply')}
+
+ ) : undefined
+ }
+ >
+ {header ?? t('bridgeSelectNetwork')}
-
+ {isMultiselectEnabled && (
+
+ v)}
+ iconProps={{
+ name: Object.values(checkedChainIds).some((v) => !v)
+ ? IconName.MinusBold
+ : IconName.Add,
+ color: IconColor.primaryInverse,
+ backgroundColor: BackgroundColor.primaryDefault,
+ }}
+ isChecked
+ onChange={() => {
+ handleToggleAllNetworks();
+ }}
+ />
+ {
+ handleToggleAllNetworks();
+ }}
+ style={{
+ alignSelf: AlignItems.flexStart,
+ paddingInline: 16,
+ }}
+ >
+ {t('selectAll')}
+
+
+ )}
+
{networksList.map((networkConfig) => {
const { name, chainId } = networkConfig;
return (
{
+ if (isMultiselectEnabled) {
+ handleToggleNetwork(chainId);
+ return;
+ }
onNetworkChange(networkConfig);
onBack();
}}
@@ -101,7 +249,29 @@ export const AssetPickerModalNetwork = ({
chainId as keyof typeof CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP
]
}
+ iconSize={AvatarNetworkSize.Sm}
focus={false}
+ disabled={shouldDisableNetwork?.(networkConfig)}
+ startAccessory={
+ isMultiselectEnabled ? (
+
+ ) : undefined
+ }
+ showEndAccessory={isMultiselectEnabled}
+ variant={TextVariant.bodyMdMedium}
+ endAccessory={
+ isMultiselectEnabled ? (
+
+ {formatCurrency(
+ balanceByChainId[chainId]?.toString(),
+ currency,
+ )}
+
+ ) : undefined
+ }
/>
);
})}
diff --git a/ui/components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal-search.tsx b/ui/components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal-search.tsx
index c7c0d699793e..9299f508e1d8 100644
--- a/ui/components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal-search.tsx
+++ b/ui/components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal-search.tsx
@@ -19,6 +19,8 @@ import { useI18nContext } from '../../../../hooks/useI18nContext';
* @param props.onChange - The function to handle search query changes.
* @param props.isNFTSearch - Indicates if the search is for NFTs.
* @param props.props - Additional props for the containing Box component.
+ * @param props.placeholder - A custom placeholder for the search input.
+ * @param props.autoFocus
* @returns The rendered search component.
*/
export const Search = ({
@@ -26,11 +28,15 @@ export const Search = ({
onChange,
isNFTSearch = false,
props,
+ placeholder,
+ autoFocus = true,
}: {
searchQuery: string;
onChange: (value: string) => void;
isNFTSearch?: boolean;
props?: React.ComponentProps;
+ placeholder?: JSX.Element | string | null;
+ autoFocus?: boolean;
}) => {
const t = useI18nContext();
@@ -38,21 +44,26 @@ export const Search = ({
onChange(e.target.value)}
error={false}
- autoFocus
+ autoFocus={autoFocus}
autoComplete={false}
width={BlockSize.Full}
clearButtonOnClick={() => onChange('')}
clearButtonProps={{
size: ButtonIconSize.Sm,
}}
+ style={{ paddingInline: 8 }}
showClearButton
className="asset-picker-modal__search-list"
inputProps={{
'data-testid': 'asset-picker-modal-search-input',
+ marginRight: 0,
}}
endAccessory={null}
size={TextFieldSearchSize.Lg}
diff --git a/ui/components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal.test.tsx b/ui/components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal.test.tsx
index fc4796073c74..724cba8a1ec6 100644
--- a/ui/components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal.test.tsx
+++ b/ui/components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal.test.tsx
@@ -29,7 +29,10 @@ import { getTopAssets } from '../../../../ducks/swaps/swaps';
import { getRenderableTokenData } from '../../../../hooks/useTokensToSearch';
import * as actions from '../../../../store/actions';
import { getSwapsBlockedTokens } from '../../../../ducks/send';
-import { getCurrentChainId } from '../../../../../shared/modules/selectors/networks';
+import {
+ getCurrentChainId,
+ getNetworkConfigurationsByChainId,
+} from '../../../../../shared/modules/selectors/networks';
import { AssetPickerModal } from './asset-picker-modal';
import AssetList from './AssetList';
import { ERC20Asset } from './types';
@@ -57,6 +60,11 @@ jest.mock('../../../../hooks/useTokensToSearch', () => ({
getRenderableTokenData: jest.fn(),
}));
+const mockUseMultichainBalances = jest.fn();
+jest.mock('../../../../hooks/useMultichainBalances', () => ({
+ useMultichainBalances: () => mockUseMultichainBalances(),
+}));
+
describe('AssetPickerModal', () => {
const useSelectorMock = useSelector as jest.Mock;
const useI18nContextMock = useI18nContext as jest.Mock;
@@ -88,8 +96,11 @@ describe('AssetPickerModal', () => {
beforeEach(() => {
useSelectorMock.mockImplementation((selector) => {
+ if (selector === getNetworkConfigurationsByChainId) {
+ return { '0x1': { chainId: '0x1' } };
+ }
if (selector === getCurrentChainId) {
- return '1';
+ return '0x1';
}
if (selector === getCurrentCurrency) {
return 'USD';
@@ -151,6 +162,7 @@ describe('AssetPickerModal', () => {
tokensWithBalances: [],
});
(getRenderableTokenData as jest.Mock).mockReturnValue({});
+ mockUseMultichainBalances.mockReturnValue({});
});
afterEach(() => {
@@ -162,7 +174,9 @@ describe('AssetPickerModal', () => {
renderWithProvider( , store);
expect(screen.getByTestId('asset-picker-modal')).toBeInTheDocument();
- expect(screen.getByPlaceholderText('searchTokens')).toBeInTheDocument();
+ expect(
+ screen.getByPlaceholderText('searchTokensByNameOrAddress'),
+ ).toBeInTheDocument();
});
it('calls onClose when modal is closed', () => {
@@ -195,17 +209,23 @@ describe('AssetPickerModal', () => {
it('filters tokens based on search query', () => {
renderWithProvider( , store);
- fireEvent.change(screen.getByPlaceholderText('searchTokens'), {
- target: { value: 'TO' },
- });
+ fireEvent.change(
+ screen.getByPlaceholderText('searchTokensByNameOrAddress'),
+ {
+ target: { value: 'TO' },
+ },
+ );
expect(
(AssetList as jest.Mock).mock.calls.slice(-1)[0][0].tokenList.length,
).toBe(2);
- fireEvent.change(screen.getByPlaceholderText('searchTokens'), {
- target: { value: 'UNAVAILABLE TOKEN' },
- });
+ fireEvent.change(
+ screen.getByPlaceholderText('searchTokensByNameOrAddress'),
+ {
+ target: { value: 'UNAVAILABLE TOKEN' },
+ },
+ );
expect((AssetList as jest.Mock).mock.calls[1][0]).not.toEqual(
expect.objectContaining({
@@ -245,7 +265,9 @@ describe('AssetPickerModal', () => {
store,
);
const modalTitle = getByText('sendSelectReceiveAsset');
- const searchPlaceholder = getByPlaceholderText('searchTokens');
+ const searchPlaceholder = getByPlaceholderText(
+ 'searchTokensByNameOrAddress',
+ );
expect(modalTitle).toBeInTheDocument();
expect(searchPlaceholder).toBeInTheDocument();
@@ -260,17 +282,23 @@ describe('AssetPickerModal', () => {
store,
);
- fireEvent.change(screen.getByPlaceholderText('searchTokens'), {
- target: { value: 'TO' },
- });
+ fireEvent.change(
+ screen.getByPlaceholderText('searchTokensByNameOrAddress'),
+ {
+ target: { value: 'TO' },
+ },
+ );
expect(
(AssetList as jest.Mock).mock.calls.slice(-1)[0][0].tokenList.length,
).toBe(2);
- fireEvent.change(screen.getByPlaceholderText('searchTokens'), {
- target: { value: 'TOKEN1' },
- });
+ fireEvent.change(
+ screen.getByPlaceholderText('searchTokensByNameOrAddress'),
+ {
+ target: { value: 'TOKEN1' },
+ },
+ );
expect((AssetList as jest.Mock).mock.calls[1][0]).not.toEqual(
expect.objectContaining({
@@ -322,7 +350,7 @@ describe('AssetPickerModal', () => {
expect(modalTitle).toBeInTheDocument();
expect(getAllByRole('img')).toHaveLength(2);
- const modalContent = getByText('Network name');
+ const modalContent = getByText('Ethereum Mainnet');
expect(modalContent).toBeInTheDocument();
});
diff --git a/ui/components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal.tsx b/ui/components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal.tsx
index 48c56c5106ec..d0b9b32988df 100644
--- a/ui/components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal.tsx
+++ b/ui/components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal.tsx
@@ -8,6 +8,7 @@ import {
TokenListToken,
} from '@metamask/assets-controllers';
import { Hex } from '@metamask/utils';
+import { zeroAddress } from 'ethereumjs-util';
import {
Modal,
ModalContent,
@@ -29,7 +30,10 @@ import {
import { useI18nContext } from '../../../../hooks/useI18nContext';
import { AssetType } from '../../../../../shared/constants/transaction';
-import { getCurrentChainId } from '../../../../../shared/modules/selectors/networks';
+import {
+ getCurrentChainId,
+ getNetworkConfigurationsByChainId,
+} from '../../../../../shared/modules/selectors/networks';
import {
getAllTokens,
getCurrentCurrency,
@@ -49,7 +53,13 @@ import { getTopAssets } from '../../../../ducks/swaps/swaps';
import { getRenderableTokenData } from '../../../../hooks/useTokensToSearch';
import { getSwapsBlockedTokens } from '../../../../ducks/send';
import { isEqualCaseInsensitive } from '../../../../../shared/modules/string-utils';
-import { CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP } from '../../../../../shared/constants/network';
+import {
+ CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP,
+ NETWORK_TO_NAME_MAP,
+} from '../../../../../shared/constants/network';
+import { useMultichainBalances } from '../../../../hooks/useMultichainBalances';
+import { AvatarType } from '../../avatar-group/avatar-group.types';
+import { NETWORK_TO_SHORT_NETWORK_NAME_MAP } from '../../../../../shared/constants/bridge';
import {
ERC20Asset,
NativeAsset,
@@ -68,6 +78,7 @@ type AssetPickerModalProps = {
isOpen: boolean;
onClose: () => void;
action?: 'send' | 'receive';
+ onBack?: () => void;
asset?: ERC20Asset | NativeAsset | Pick;
onAssetChange: (
asset: AssetWithDisplayData | AssetWithDisplayData,
@@ -82,15 +93,23 @@ type AssetPickerModalProps = {
* by a custom order.
*/
customTokenListGenerator?: (
- filterPredicate: (symbol: string, address?: string) => boolean,
+ filterPredicate: (
+ symbol: string,
+ address?: null | string,
+ chainId?: string,
+ ) => boolean,
) => Generator<
AssetWithDisplayData | AssetWithDisplayData
>;
+ isTokenListLoading?: boolean;
} & Pick<
React.ComponentProps,
'visibleTabs' | 'defaultActiveTabKey'
> &
- Pick, 'network'>;
+ Pick<
+ React.ComponentProps,
+ 'network' | 'networks' | 'isMultiselectEnabled' | 'selectedChainIds'
+ >;
const MAX_UNOWNED_TOKENS_RENDERED = 30;
@@ -98,13 +117,18 @@ export function AssetPickerModal({
header,
isOpen,
onClose,
+ onBack,
asset,
onAssetChange,
sendingAsset,
network,
+ networks,
action,
onNetworkPickerClick,
customTokenListGenerator,
+ isTokenListLoading = false,
+ isMultiselectEnabled,
+ selectedChainIds,
...tabProps
}: AssetPickerModalProps) {
const t = useI18nContext();
@@ -118,7 +142,13 @@ export function AssetPickerModal({
const handleAssetChange = useCallback(onAssetChange, [onAssetChange]);
- const chainId = useSelector(getCurrentChainId);
+ const currentChainId = useSelector(getCurrentChainId);
+ const allNetworks = useSelector(getNetworkConfigurationsByChainId);
+ const selectedNetwork =
+ network ?? (currentChainId && allNetworks[currentChainId]);
+ const allNetworksToUse = networks ?? Object.values(allNetworks ?? {});
+ // This indicates whether tokens in the wallet's active network are displayed
+ const isSelectedNetworkActive = selectedNetwork.chainId === currentChainId;
const nativeCurrencyImage = useSelector(getNativeCurrencyImage);
const nativeCurrency = useSelector(getNativeCurrency);
@@ -136,7 +166,7 @@ export function AssetPickerModal({
const detectedTokens: Record> = useSelector(
getAllTokens,
);
- const tokens = detectedTokens?.[chainId]?.[selectedAddress] ?? [];
+ const tokens = detectedTokens?.[currentChainId]?.[selectedAddress] ?? [];
const { tokensWithBalances }: { tokensWithBalances: TokenWithBalance[] } =
useTokenTracker({
@@ -145,6 +175,9 @@ export function AssetPickerModal({
hideZeroBalanceTokens: Boolean(shouldHideZeroBalanceTokens),
});
+ const { assetsWithBalance: multichainTokensWithBalance } =
+ useMultichainBalances();
+
// Swaps token list
const tokenList = useSelector(getTokenList) as TokenListMap;
const topTokens = useSelector(getTopAssets, isEqual);
@@ -176,10 +209,15 @@ export function AssetPickerModal({
const tokenListGenerator = useCallback(
function* (
- shouldAddToken: (symbol: string, address?: null | string) => boolean,
+ shouldAddToken: (
+ symbol: string,
+ address?: null | string,
+ tokenChainId?: string,
+ ) => boolean,
): Generator<
| AssetWithDisplayData
| ((Token | TokenListToken) & {
+ chainId: string;
balance?: string;
string?: string;
})
@@ -191,42 +229,61 @@ export function AssetPickerModal({
image: nativeCurrencyImage,
balance: balanceValue,
string: undefined,
+ chainId: currentChainId,
type: AssetType.native,
};
- if (shouldAddToken(nativeToken.symbol, nativeToken.address)) {
+ if (
+ shouldAddToken(
+ nativeToken.symbol,
+ nativeToken.address,
+ nativeToken.chainId,
+ )
+ ) {
yield nativeToken;
}
const blockedTokens = [];
+ // Yield multichain tokens with balances
+ if (isMultiselectEnabled) {
+ for (const token of multichainTokensWithBalance) {
+ if (shouldAddToken(token.symbol, token.address, token.chainId)) {
+ yield token;
+ }
+ }
+ }
+
for (const token of memoizedUsersTokens) {
- if (shouldAddToken(token.symbol, token.address)) {
- yield token;
+ if (shouldAddToken(token.symbol, token.address, currentChainId)) {
+ yield { ...token, chainId: currentChainId };
}
}
// topTokens should already be sorted by popularity
for (const address of Object.keys(topTokens)) {
const token = tokenList?.[address];
- if (token && shouldAddToken(token.symbol, token.address)) {
+ if (
+ token &&
+ shouldAddToken(token.symbol, token.address, currentChainId)
+ ) {
if (getIsDisabled(token)) {
blockedTokens.push(token);
continue;
} else {
- yield token;
+ yield { ...token, chainId: currentChainId };
}
}
}
for (const token of Object.values(tokenList)) {
- if (shouldAddToken(token.symbol, token.address)) {
- yield token;
+ if (shouldAddToken(token.symbol, token.address, currentChainId)) {
+ yield { ...token, chainId: currentChainId };
}
}
for (const token of blockedTokens) {
- yield token;
+ yield { ...token, chainId: currentChainId };
}
},
[
@@ -237,6 +294,8 @@ export function AssetPickerModal({
topTokens,
tokenList,
getIsDisabled,
+ isMultiselectEnabled,
+ multichainTokensWithBalance,
],
);
@@ -245,16 +304,33 @@ export function AssetPickerModal({
| AssetWithDisplayData
| AssetWithDisplayData
)[] = [];
- // undefined would be the native token address
+ // List of token identifiers formatted like `chainId:address`
const filteredTokensAddresses = new Set();
+ const getTokenKey = (address?: string | null, tokenChainId?: string) =>
+ `${address?.toLowerCase() ?? zeroAddress()}:${
+ tokenChainId ?? currentChainId
+ }`;
// Default filter predicate for whether a token should be included in displayed list
- const shouldAddToken = (symbol: string, address?: string | null) => {
- const trimmedSearchQuery = searchQuery.trim();
- return (
- (!trimmedSearchQuery ||
- symbol?.toLowerCase().includes(trimmedSearchQuery.toLowerCase())) &&
- !filteredTokensAddresses.has(address?.toLowerCase())
+ const shouldAddToken = (
+ symbol: string,
+ address?: string | null,
+ tokenChainId?: string,
+ ) => {
+ const trimmedSearchQuery = searchQuery.trim().toLowerCase();
+ const isMatchedBySearchQuery = Boolean(
+ !trimmedSearchQuery ||
+ symbol?.toLowerCase().includes(trimmedSearchQuery) ||
+ address?.toLowerCase().includes(trimmedSearchQuery),
+ );
+ const isTokenInSelectedChain = isMultiselectEnabled
+ ? tokenChainId && selectedChainIds?.includes(tokenChainId)
+ : selectedNetwork?.chainId === tokenChainId;
+
+ return Boolean(
+ isTokenInSelectedChain &&
+ isMatchedBySearchQuery &&
+ !filteredTokensAddresses.has(getTokenKey(address, tokenChainId)),
);
};
@@ -267,7 +343,7 @@ export function AssetPickerModal({
continue;
}
- filteredTokensAddresses.add(token.address?.toLowerCase());
+ filteredTokensAddresses.add(getTokenKey(token.address, token.chainId));
filteredTokens.push(
customTokenListGenerator
? token
@@ -282,7 +358,7 @@ export function AssetPickerModal({
tokenConversionRates,
conversionRate,
currentCurrency,
- chainId,
+ token.chainId,
tokenList,
),
);
@@ -298,11 +374,43 @@ export function AssetPickerModal({
tokenConversionRates,
conversionRate,
currentCurrency,
- chainId,
+ currentChainId,
tokenListGenerator,
customTokenListGenerator,
+ isMultiselectEnabled,
+ selectedChainIds,
+ isMultiselectEnabled,
+ selectedNetwork,
]);
+ const getNetworkImageUrl = (networkChainId: string) =>
+ CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP[
+ networkChainId as keyof typeof CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP
+ ];
+
+ const getNetworkPickerLabel = () => {
+ if (!isMultiselectEnabled) {
+ return (
+ (selectedNetwork?.chainId &&
+ NETWORK_TO_NAME_MAP[
+ selectedNetwork.chainId as keyof typeof NETWORK_TO_NAME_MAP
+ ]) ??
+ selectedNetwork?.name ??
+ t('bridgeSelectNetwork')
+ );
+ }
+ switch (selectedChainIds?.length) {
+ case allNetworksToUse.length:
+ return t('allNetworks');
+ case 1:
+ return t('singleNetwork');
+ case 0:
+ return t('bridgeSelectNetwork');
+ default:
+ return t('someNetworks', [selectedChainIds?.length]);
+ }
+ };
+
return (
-
+
{header}
@@ -337,12 +445,26 @@ export function AssetPickerModal({
{onNetworkPickerClick && (
({
+ avatarValue: getNetworkImageUrl(c),
+ symbol:
+ NETWORK_TO_SHORT_NETWORK_NAME_MAP[
+ c as keyof typeof NETWORK_TO_SHORT_NETWORK_NAME_MAP
+ ],
+ })),
+ avatarType: AvatarType.NETWORK,
+ }
+ : undefined
}
onClick={onNetworkPickerClick}
data-testid="multichain-asset-picker__network"
@@ -355,12 +477,23 @@ export function AssetPickerModal({
setSearchQuery(value)}
+ autoFocus={!isMultiselectEnabled}
/>
& {
+export type ERC20Asset = {
type: AssetType.token;
image: string;
-};
+} & Pick &
+ Pick;
+
export type NativeAsset = {
type: AssetType.native;
- address?: null;
+ address?: null | string;
image: typeof CHAIN_ID_TOKEN_IMAGE_MAP extends Record
? V
: never; // only allow wallet's hardcoded images
symbol: typeof CHAIN_ID_TO_CURRENCY_SYMBOL_MAP extends Record
? V
: never; // only allow wallet's hardcoded symbols
-};
+} & Pick;
/**
* ERC20Asset or NativeAsset, plus additional fields for display purposes in the Asset component
*/
export type AssetWithDisplayData = T & {
- balance: T['type'] extends AssetType.token ? string : Hex; // raw balance
+ balance: string; // raw balance
string: string | undefined; // normalized balance as a stringified number
-} & Pick;
+} & Pick & {
+ tokenFiatAmount?: TokenWithFiatAmount['tokenFiatAmount'];
+ };
export type Collection = {
collectionName: string;
diff --git a/ui/components/multichain/asset-picker-amount/asset-picker/asset-picker.stories.tsx b/ui/components/multichain/asset-picker-amount/asset-picker/asset-picker.stories.tsx
index 0572e2d237c2..e25780561ccb 100644
--- a/ui/components/multichain/asset-picker-amount/asset-picker/asset-picker.stories.tsx
+++ b/ui/components/multichain/asset-picker-amount/asset-picker/asset-picker.stories.tsx
@@ -25,6 +25,7 @@ const props = {
address: '0xaddress1',
image: CHAIN_ID_TOKEN_IMAGE_MAP['0x1'],
type: AssetType.token,
+ chainId: '0x1',
} as ERC20Asset,
};
export const DefaultStory = () => {
@@ -65,6 +66,7 @@ export const SendDestStory = () => {
symbol: 'ETH',
image: CHAIN_ID_TOKEN_IMAGE_MAP['0x1'],
type: AssetType.native,
+ chainId: '0x1',
}}
sendingAsset={{
image: CHAIN_ID_TOKEN_IMAGE_MAP['0x1'],
@@ -93,8 +95,70 @@ SendDestStory.decorators = [
SendDestStory.storyName = 'With Sending Asset';
-export const NetworksStory = ({ isOpen }: { isOpen: boolean }) => {
- const t = useI18nContext();
+const networkProps = {
+ network: {
+ chainId: '0x1',
+ name: 'Mainnet',
+ blockExplorerUrls: [],
+ defaultRpcEndpointIndex: 0,
+ rpcEndpoints: [
+ {
+ networkClientId: 'test1',
+ url: 'https://mainnet.infura.io/v3/',
+ type: RpcEndpointType.Custom,
+ },
+ ],
+ nativeCurrency: 'ETH',
+ },
+ networks: [
+ {
+ chainId: '0x1',
+ name: 'Mainnet Name That Is Very Long',
+ blockExplorerUrls: [],
+ defaultRpcEndpointIndex: 0,
+ rpcEndpoints: [
+ {
+ networkClientId: 'test1',
+ url: 'https://mainnet.infura.io/v3/',
+ type: RpcEndpointType.Custom,
+ },
+ ],
+ nativeCurrency: 'ETH',
+ },
+ {
+ chainId: '0x10',
+ name: 'Optimism',
+ blockExplorerUrls: [],
+ defaultRpcEndpointIndex: 0,
+ rpcEndpoints: [
+ {
+ networkClientId: 'test2',
+ url: 'https://optimism.infura.io/v3/',
+ type: RpcEndpointType.Custom,
+ },
+ ],
+ nativeCurrency: 'ETH',
+ },
+ {
+ chainId: CHAIN_IDS.LINEA_MAINNET,
+ name: 'Linea Mainnet Test Name',
+ blockExplorerUrls: [],
+ defaultRpcEndpointIndex: 0,
+ rpcEndpoints: [
+ {
+ networkClientId: 'test3',
+ url: 'https://linea.infura.io/v3/',
+ type: RpcEndpointType.Custom,
+ },
+ ],
+ nativeCurrency: 'ETH',
+ },
+ ],
+ shouldDisableNetwork: (networkConfig) =>
+ networkConfig.chainId === CHAIN_IDS.LINEA_MAINNET,
+ onNetworkChange: () => ({}),
+};
+export const NetworksStory = () => {
return (
{
symbol: 'ETH',
image: CHAIN_ID_TOKEN_IMAGE_MAP['0x1'],
type: AssetType.native,
+ chainId: '0x1',
}}
- networkProps={{
- network: {
- chainId: '0x1',
- name: 'Mainnet',
- blockExplorerUrls: [],
- defaultRpcEndpointIndex: 0,
- rpcEndpoints: [
- {
- networkClientId: 'test1',
- url: 'https://mainnet.infura.io/v3/',
- type: RpcEndpointType.Custom,
- },
- ],
- nativeCurrency: 'ETH',
- },
- networks: [
- {
- chainId: '0x1',
- name: 'Mainnet',
- blockExplorerUrls: [],
- defaultRpcEndpointIndex: 0,
- rpcEndpoints: [
- {
- networkClientId: 'test1',
- url: 'https://mainnet.infura.io/v3/',
- type: RpcEndpointType.Custom,
- },
- ],
- nativeCurrency: 'ETH',
- },
- {
- chainId: '0x10',
- name: 'Optimism',
- blockExplorerUrls: [],
- defaultRpcEndpointIndex: 0,
- rpcEndpoints: [
- {
- networkClientId: 'test2',
- url: 'https://optimism.infura.io/v3/',
- type: RpcEndpointType.Custom,
- },
- ],
- nativeCurrency: 'ETH',
- },
- ],
- onNetworkChange: () => ({}),
- }}
+ networkProps={networkProps as never}
visibleTabs={[TabName.TOKENS]}
/>
);
@@ -163,4 +182,29 @@ NetworksStory.decorators = [
NetworksStory.storyName = 'With Network Picker';
+export const MultichainNetworksStory = () => {
+ return (
+ ({})}
+ {...props}
+ asset={{
+ symbol: 'ETH',
+ image: CHAIN_ID_TOKEN_IMAGE_MAP['0x1'],
+ type: AssetType.native,
+ chainId: '0x1',
+ }}
+ isMultiselectEnabled={true}
+ networkProps={networkProps as never}
+ visibleTabs={[TabName.TOKENS]}
+ />
+ );
+};
+
+MultichainNetworksStory.decorators = [
+ (story) => {story()} ,
+];
+
+MultichainNetworksStory.storyName = 'With Multichain Network Picker';
+
export default storybook;
diff --git a/ui/components/multichain/asset-picker-amount/asset-picker/asset-picker.tsx b/ui/components/multichain/asset-picker-amount/asset-picker/asset-picker.tsx
index cf87ee3ba7f1..1388a8bf5b92 100644
--- a/ui/components/multichain/asset-picker-amount/asset-picker/asset-picker.tsx
+++ b/ui/components/multichain/asset-picker-amount/asset-picker/asset-picker.tsx
@@ -1,4 +1,4 @@
-import React, { useState } from 'react';
+import React, { useEffect, useState } from 'react';
import { useSelector } from 'react-redux';
import {
AvatarTokenSize,
@@ -24,8 +24,10 @@ import {
} from '../../../../helpers/constants/design-system';
import { AssetType } from '../../../../../shared/constants/transaction';
import { AssetPickerModal } from '../asset-picker-modal/asset-picker-modal';
-import { getNetworkConfigurationsByChainId } from '../../../../../shared/modules/selectors/networks';
-import { getCurrentNetwork } from '../../../../selectors';
+import {
+ getCurrentChainId,
+ getNetworkConfigurationsByChainId,
+} from '../../../../../shared/modules/selectors/networks';
import Tooltip from '../../../ui/tooltip';
import { LARGE_SYMBOL_LENGTH } from '../constants';
///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask)
@@ -45,10 +47,15 @@ import {
GOERLI_DISPLAY_NAME,
SEPOLIA_DISPLAY_NAME,
} from '../../../../../shared/constants/network';
+import { useMultichainBalances } from '../../../../hooks/useMultichainBalances';
const ELLIPSIFY_LENGTH = 13; // 6 (start) + 4 (end) + 3 (...)
export type AssetPickerProps = {
+ children?: (
+ onClick: () => void,
+ networkImageSrc?: string,
+ ) => React.ReactElement; // Overrides default button
asset?:
| ERC20Asset
| NativeAsset
@@ -65,17 +72,27 @@ export type AssetPickerProps = {
onClick?: () => void;
isDisabled?: boolean;
action?: 'send' | 'receive';
+ isMultiselectEnabled?: boolean;
networkProps?: Pick<
React.ComponentProps,
- 'network' | 'networks' | 'onNetworkChange'
+ | 'network'
+ | 'networks'
+ | 'onNetworkChange'
+ | 'shouldDisableNetwork'
+ | 'header'
>;
} & Pick<
React.ComponentProps,
- 'visibleTabs' | 'header' | 'sendingAsset' | 'customTokenListGenerator'
+ | 'visibleTabs'
+ | 'header'
+ | 'sendingAsset'
+ | 'customTokenListGenerator'
+ | 'isTokenListLoading'
>;
// A component that lets the user pick from a list of assets.
export function AssetPicker({
+ children,
header,
asset,
onAssetChange,
@@ -86,6 +103,8 @@ export function AssetPicker({
isDisabled = false,
visibleTabs,
customTokenListGenerator,
+ isTokenListLoading = false,
+ isMultiselectEnabled = false,
}: AssetPickerProps) {
///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask)
const t = useI18nContext();
@@ -106,12 +125,32 @@ export function AssetPicker({
: symbol;
// Badge details
- const currentNetwork = useSelector(getCurrentNetwork);
+ const currentChainId = useSelector(getCurrentChainId);
const allNetworks = useSelector(getNetworkConfigurationsByChainId);
+ const currentNetwork = allNetworks[currentChainId];
const selectedNetwork =
networkProps?.network ??
(currentNetwork?.chainId && allNetworks[currentNetwork.chainId]);
+ const allNetworksToUse = networkProps?.networks ?? Object.values(allNetworks);
+ const { balanceByChainId } = useMultichainBalances();
+ // This is used to determine which tokens to display when isMultiselectEnabled=true
+ const [selectedChainIds, setSelectedChainIds] = useState(
+ isMultiselectEnabled
+ ? allNetworksToUse
+ ?.map(({ chainId }) => chainId)
+ .sort((a, b) => balanceByChainId[b] - balanceByChainId[a]) ?? []
+ : [],
+ );
+ const [isSelectingNetwork, setIsSelectingNetwork] = useState(false);
+
+ useEffect(() => {
+ const newChainId = networkProps?.network?.chainId;
+ newChainId &&
+ !selectedChainIds.includes(newChainId) &&
+ setSelectedChainIds((c) => [...c, newChainId]);
+ }, [networkProps?.network?.chainId]);
+
const handleAssetPickerTitle = (): string | undefined => {
///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask)
if (isDisabled) {
@@ -122,20 +161,47 @@ export function AssetPicker({
return undefined;
};
- const [isSelectingNetwork, setIsSelectingNetwork] = useState(false);
+ const networkImageSrc =
+ selectedNetwork?.chainId &&
+ CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP[
+ selectedNetwork.chainId as keyof typeof CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP
+ ];
+
+ const handleButtonClick = () => {
+ if (networkProps && !networkProps.network) {
+ setIsSelectingNetwork(true);
+ } else {
+ setShowAssetPickerModal(true);
+ }
+ onClick?.();
+ };
return (
<>
{networkProps && (
{
- setIsSelectingNetwork(false);
- }}
+ onClose={() => setIsSelectingNetwork(false)}
onBack={() => {
setIsSelectingNetwork(false);
setShowAssetPickerModal(true);
}}
+ isMultiselectEnabled={isMultiselectEnabled}
+ onMultiselectSubmit={(chainIds: string[]) => {
+ setSelectedChainIds(chainIds);
+ // If there is only 1 selected network switch to that network to populate tokens
+ if (
+ chainIds.length === 1 &&
+ chainIds[0] !== currentNetwork?.chainId
+ ) {
+ if (networkProps?.onNetworkChange) {
+ networkProps.onNetworkChange(
+ allNetworks[chainIds[0] as keyof typeof allNetworks],
+ );
+ }
+ }
+ }}
+ selectedChainIds={selectedChainIds}
{...networkProps}
/>
)}
@@ -152,11 +218,23 @@ export function AssetPicker({
| AssetWithDisplayData
| AssetWithDisplayData,
) => {
+ // If isMultiselectEnabled=true, update the network when a token is selected
+ if (isMultiselectEnabled && networkProps?.onNetworkChange) {
+ const networkFromToken = token.chainId
+ ? allNetworks[token.chainId as keyof typeof allNetworks]
+ : undefined;
+ if (networkFromToken) {
+ networkProps.onNetworkChange(networkFromToken);
+ }
+ }
onAssetChange(token);
setShowAssetPickerModal(false);
}}
+ isMultiselectEnabled={isMultiselectEnabled}
sendingAsset={sendingAsset}
- network={networkProps?.network ? networkProps.network : undefined}
+ network={networkProps?.network}
+ networks={networkProps?.networks}
+ selectedChainIds={selectedChainIds}
onNetworkPickerClick={
networkProps
? () => {
@@ -169,37 +247,32 @@ export function AssetPicker({
asset?.type === AssetType.NFT ? TabName.NFTS : TabName.TOKENS
}
customTokenListGenerator={customTokenListGenerator}
+ isTokenListLoading={isTokenListLoading}
/>
- {
- if (networkProps && !networkProps.network) {
- setIsSelectingNetwork(true);
- } else {
- setShowAssetPickerModal(true);
- }
- onClick?.();
- }}
- endIconName={IconName.ArrowDown}
- endIconProps={{
- color: IconColor.iconDefault,
- marginInlineStart: 0,
- display: isDisabled ? Display.None : Display.InlineBlock,
- }}
- title={handleAssetPickerTitle()}
- >
- {asset ? (
+ {/** If a child prop is passed in, use it as the trigger button instead of the default */}
+ {children?.(handleButtonClick, networkImageSrc) || (
+
- ) : (
-
- {t('swapSelectToken')}
-
- )}
-
+
+ )}
>
);
}
diff --git a/ui/components/multichain/asset-picker-amount/asset-picker/index.scss b/ui/components/multichain/asset-picker-amount/asset-picker/index.scss
index 6cfaf877efbd..d30ce8c016d7 100644
--- a/ui/components/multichain/asset-picker-amount/asset-picker/index.scss
+++ b/ui/components/multichain/asset-picker-amount/asset-picker/index.scss
@@ -25,9 +25,4 @@
opacity: 1;
cursor: not-allowed;
}
-
- &__fallback {
- text-wrap: nowrap;
- padding-left: 8px;
- }
}
diff --git a/ui/components/multichain/avatar-group/__snapshots__/avatar-group.test.tsx.snap b/ui/components/multichain/avatar-group/__snapshots__/avatar-group.test.tsx.snap
index ff4c6a297c38..4e08ceaf5177 100644
--- a/ui/components/multichain/avatar-group/__snapshots__/avatar-group.test.tsx.snap
+++ b/ui/components/multichain/avatar-group/__snapshots__/avatar-group.test.tsx.snap
@@ -7,7 +7,7 @@ exports[`AvatarGroup should render AvatarGroup component 1`] = `
data-testid="avatar-group"
>
= () => (
+
+);
+
+export const NetworksWithOverlayTag: StoryFn
= () => (
+
+);
diff --git a/ui/components/multichain/avatar-group/avatar-group.tsx b/ui/components/multichain/avatar-group/avatar-group.tsx
index 0de7cf5638eb..39af437980d9 100644
--- a/ui/components/multichain/avatar-group/avatar-group.tsx
+++ b/ui/components/multichain/avatar-group/avatar-group.tsx
@@ -10,9 +10,15 @@ import {
AvatarAccount,
AvatarAccountSize,
AvatarAccountVariant,
+ AvatarNetwork,
+ AvatarNetworkSize,
+ AvatarBase,
+ AvatarBaseSize,
} from '../../component-library';
import {
AlignItems,
+ BackgroundColor,
+ BorderColor,
BorderRadius,
Display,
TextColor,
@@ -27,6 +33,7 @@ export const AvatarGroup: React.FC = ({
size = AvatarTokenSize.Xs,
avatarType = AvatarType.TOKEN,
borderColor,
+ isTagOverlay = false,
}): JSX.Element => {
const membersCount = members.length;
const visibleMembers = members.slice(0, limit).reverse();
@@ -50,7 +57,7 @@ export const AvatarGroup: React.FC = ({
data-testid="avatar-group"
gap={1}
>
-
+
{visibleMembers.map((member, i) => {
return (
= ({
key={i}
style={{ marginLeft: i === 0 ? '0' : marginLeftValue }}
>
- {avatarType === AvatarType.TOKEN ? (
+ {avatarType === AvatarType.TOKEN && (
- ) : (
+ )}
+ {avatarType === AvatarType.ACCOUNT && (
= ({
borderColor={borderColor}
/>
)}
+ {avatarType === AvatarType.NETWORK && (
+
+ )}
);
})}
+ {showTag && isTagOverlay && (
+
+ {tagValue}
+
+ )}
- {showTag ? (
+ {showTag && !isTagOverlay ? (
{tagValue}
diff --git a/ui/components/multichain/avatar-group/avatar-group.types.tsx b/ui/components/multichain/avatar-group/avatar-group.types.tsx
index 6737b64eeea7..b5c52b1ed9f5 100644
--- a/ui/components/multichain/avatar-group/avatar-group.types.tsx
+++ b/ui/components/multichain/avatar-group/avatar-group.types.tsx
@@ -2,9 +2,7 @@ import { BorderColor } from '../../../helpers/constants/design-system';
import { AvatarTokenSize } from '../../component-library';
import type { StyleUtilityProps } from '../../component-library/box';
-// TODO: Convert to a `type` in a future major version.
-// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
-export interface AvatarGroupProps extends StyleUtilityProps {
+export type AvatarGroupProps = StyleUtilityProps & {
/** * Additional class name for the AvatarGroup component */
className?: string;
/** * Limit to show only a certain number of tokens and extras in Text */
@@ -22,9 +20,12 @@ export interface AvatarGroupProps extends StyleUtilityProps {
size?: AvatarTokenSize;
/** * Border Color of Avatar Tokens */
borderColor?: BorderColor;
-}
+ /** * Whether the tag should be displayed as separate text or within an overlay avatar */
+ isTagOverlay?: boolean;
+};
export enum AvatarType {
TOKEN = 'TOKEN',
ACCOUNT = 'ACCOUNT',
+ NETWORK = 'NETWORK',
}
diff --git a/ui/components/multichain/network-list-item/__snapshots__/network-list-item.test.js.snap b/ui/components/multichain/network-list-item/__snapshots__/network-list-item.test.js.snap
index 38f44e767f77..08590b29ee88 100644
--- a/ui/components/multichain/network-list-item/__snapshots__/network-list-item.test.js.snap
+++ b/ui/components/multichain/network-list-item/__snapshots__/network-list-item.test.js.snap
@@ -22,12 +22,23 @@ exports[`NetworkListItem renders properly 1`] = `
class="mm-box mm-box--display-flex mm-box--align-items-center mm-box--width-full"
data-testid="Polygon"
>
-
- Polygon
-
+
+
void;
focus?: boolean;
startAccessory?: ReactNode;
+ endAccessory?: ReactNode;
showEndAccessory?: boolean;
+ disabled?: boolean;
+ variant?: TextVariant;
}) => {
const t = useI18nContext();
const networkRef = useRef(null);
@@ -116,12 +122,13 @@ export const NetworkListItem = ({
}
className={classnames('multichain-network-list-item', {
'multichain-network-list-item--selected': selected,
+ 'multichain-network-list-item--disabled': disabled,
})}
display={Display.Flex}
alignItems={AlignItems.center}
justifyContent={JustifyContent.spaceBetween}
width={BlockSize.Full}
- onClick={onClick}
+ onClick={disabled ? undefined : onClick}
>
{startAccessory ? {startAccessory} : null}
{selected && (
@@ -152,26 +159,24 @@ export const NetworkListItem = ({
alignItems={AlignItems.center}
data-testid={name}
>
-
- {name?.length > MAXIMUM_CHARACTERS_WITHOUT_TOOLTIP ? (
-
- {name}
-
- ) : (
- name
- )}
-
+
+ {name}
+
+
{rpcEndpoint && (
{renderButton()}
- {showEndAccessory ? (
- setNetworkOptionsMenuOpen(false)}
- />
- ) : null}
+ {showEndAccessory
+ ? endAccessory ?? (
+ setNetworkOptionsMenuOpen(false)}
+ />
+ )
+ : null}
);
};
@@ -256,7 +263,11 @@ NetworkListItem.propTypes = {
*/
startAccessory: PropTypes.node,
/**
- * Represents if we need to show menu option
+ * Represents end accessory
+ */
+ endAccessory: PropTypes.node,
+ /**
+ * Represents if we need to show menu option or endAccessory
*/
showEndAccessory: PropTypes.bool,
};
diff --git a/ui/components/multichain/network-list-menu/__snapshots__/network-list-menu.test.js.snap b/ui/components/multichain/network-list-menu/__snapshots__/network-list-menu.test.js.snap
index f2042c2385bd..095c2c16509d 100644
--- a/ui/components/multichain/network-list-menu/__snapshots__/network-list-menu.test.js.snap
+++ b/ui/components/multichain/network-list-menu/__snapshots__/network-list-menu.test.js.snap
@@ -132,12 +132,23 @@ exports[`NetworkListMenu renders properly 1`] = `
class="mm-box mm-box--display-flex mm-box--align-items-center mm-box--width-full"
data-testid="Ethereum Mainnet"
>
-
- Ethereum Mainnet
-
+
+
-
- Linea Mainnet
-
+
+
-
- BNB Chain
-
+
+
-
- Chain 5
-
+
+
+`;
+
+exports[`TokenListItem should display warning scam modal 1`] = `
+
+
+
+
+
+
+
+
+
+ 11.9751 ETH
+
+ SCAM_TOKEN
+
+
+
+
+
+
+`;
+
+exports[`TokenListItem should display warning scam modal fallback when safechains fails to resolve correctly 1`] = `
+
+
+
+
+
+
+
+
+ 11.9751 ETH
+
+ SCAM_TOKEN
+
+
+
+
+
+`;
+
+exports[`TokenListItem should render correctly 1`] = `
+
`;
+
+exports[`TokenListItem should render crypto balance 1`] = `
+
+`;
+
+exports[`TokenListItem should render crypto balance with warning scam 1`] = `
+
+`;
diff --git a/ui/components/multichain/token-list-item/token-list-item.stories.js b/ui/components/multichain/token-list-item/token-list-item.stories.js
index bd5d7849bbec..d78535fd23e2 100644
--- a/ui/components/multichain/token-list-item/token-list-item.stories.js
+++ b/ui/components/multichain/token-list-item/token-list-item.stories.js
@@ -93,3 +93,33 @@ export const NoImagesStory = Template.bind({});
NoImagesStory.args = {
tokenImage: '',
};
+
+export const CrossChainTokenStory = (args) => (
+
+
+
+);
+CrossChainTokenStory.decorators = [
+ (Story) => (
+
+
+
+ ),
+];
+
+CrossChainTokenStory.args = {
+ title: 'USDC',
+ secondary: '$94556756776.80 USD',
+ primary: '34449765768526.00',
+ isTitleNetworkName: true,
+ chainId: CHAIN_IDS.LINEA_SEPOLIA,
+};
diff --git a/ui/components/multichain/token-list-item/token-list-item.test.tsx b/ui/components/multichain/token-list-item/token-list-item.test.tsx
index 6f08f276a302..e32397dfa1a8 100644
--- a/ui/components/multichain/token-list-item/token-list-item.test.tsx
+++ b/ui/components/multichain/token-list-item/token-list-item.test.tsx
@@ -124,11 +124,12 @@ describe('TokenListItem', () => {
title: '',
chainId: '0x1',
};
- const { getByText } = renderWithProvider(
+ const { getByText, container } = renderWithProvider(
,
store,
);
expect(getByText('11.9751 ETH')).toBeInTheDocument();
+ expect(container).toMatchSnapshot();
});
it('should display warning scam modal', () => {
@@ -152,10 +153,11 @@ describe('TokenListItem', () => {
tokenSymbol: 'SCAM_TOKEN',
chainId: '0x1',
};
- const { getByTestId, getByText } = renderWithProvider(
+ const { getByTestId, getByText, container } = renderWithProvider(
,
store,
);
+ expect(container).toMatchSnapshot();
const warningScamModal = getByTestId('scam-warning');
fireEvent.click(warningScamModal);
@@ -180,11 +182,12 @@ describe('TokenListItem', () => {
tokenSymbol: 'SCAM_TOKEN',
chainId: '0x1',
};
- const { getByTestId, getByText } = renderWithProvider(
+ const { getByTestId, getByText, container } = renderWithProvider(
,
store,
);
+ expect(container).toMatchSnapshot();
const warningScamModal = getByTestId('scam-warning');
fireEvent.click(warningScamModal);
@@ -209,11 +212,12 @@ describe('TokenListItem', () => {
chainId: '0x1',
};
- const { getByText } = renderWithProvider(
+ const { getByText, container } = renderWithProvider(
,
store,
);
expect(getByText('11.9751 ETH')).toBeInTheDocument();
+ expect(container).toMatchSnapshot();
});
it('handles click action and fires onClick', () => {
@@ -232,7 +236,7 @@ describe('TokenListItem', () => {
it('handles clicking staking opens tab', async () => {
const store = configureMockStore()(state);
- const { queryByTestId } = renderWithProvider(
+ const { queryByTestId, container } = renderWithProvider(
,
store,
);
@@ -243,6 +247,7 @@ describe('TokenListItem', () => {
expect(stakeButton).toBeInTheDocument();
expect(stakeButton).not.toBeDisabled();
+ expect(container).toMatchSnapshot();
stakeButton && fireEvent.click(stakeButton);
expect(openTabSpy).toHaveBeenCalledTimes(1);
diff --git a/ui/components/multichain/token-list-item/token-list-item.tsx b/ui/components/multichain/token-list-item/token-list-item.tsx
index 5ee4c19c8c52..76152770dbc9 100644
--- a/ui/components/multichain/token-list-item/token-list-item.tsx
+++ b/ui/components/multichain/token-list-item/token-list-item.tsx
@@ -5,7 +5,6 @@ import classnames from 'classnames';
import { getNativeTokenAddress } from '@metamask/assets-controllers';
import { Hex } from '@metamask/utils';
import {
- AlignItems,
BackgroundColor,
BlockSize,
Display,
@@ -69,6 +68,7 @@ import {
SafeChain,
useSafeChains,
} from '../../../pages/settings/networks-tab/networks-form/use-safe-chains';
+import { NETWORK_TO_SHORT_NETWORK_NAME_MAP } from '../../../../shared/constants/bridge';
import { PercentageChange } from './price/percentage-change/percentage-change';
type TokenListItemProps = {
@@ -82,6 +82,8 @@ type TokenListItemProps = {
tooltipText?: string;
isNativeCurrency?: boolean;
isStakeable?: boolean;
+ isTitleNetworkName?: boolean;
+ isTitleHidden?: boolean;
tokenChainImage?: string;
chainId: string;
address?: string | null;
@@ -104,6 +106,8 @@ export const TokenListItem = ({
isPrimaryTokenSymbolHidden = false,
isNativeCurrency = false,
isStakeable = false,
+ isTitleNetworkName = false,
+ isTitleHidden = false,
address = null,
showPercentage = false,
privacyMode = false,
@@ -141,6 +145,14 @@ export const TokenListItem = ({
const history = useHistory();
const getTokenTitle = () => {
+ if (isTitleNetworkName) {
+ return NETWORK_TO_SHORT_NETWORK_NAME_MAP[
+ chainId as keyof typeof NETWORK_TO_SHORT_NETWORK_NAME_MAP
+ ];
+ }
+ if (isTitleHidden) {
+ return undefined;
+ }
switch (title) {
case CURRENCY_SYMBOLS.ETH:
return t('networkNameEthereum');
@@ -160,9 +172,8 @@ export const TokenListItem = ({
: null;
const tokenTitle = getTokenTitle();
- const tokenMainTitleToDisplay = shouldShowPercentage
- ? tokenTitle
- : tokenSymbol;
+ const tokenMainTitleToDisplay =
+ shouldShowPercentage && !isTitleNetworkName ? tokenTitle : tokenSymbol;
const stakeableTitle = (
-
- {title?.length > 12 ? (
-
-
- {isStakeable ? (
- <>
- {tokenMainTitleToDisplay} {stakeableTitle}
- >
- ) : (
- tokenMainTitleToDisplay
- )}
-
-
- ) : (
+ {title?.length > 12 ? (
+
- {isStakeable ? (
-
- {tokenMainTitleToDisplay}
- {stakeableTitle}
-
- ) : (
- tokenMainTitleToDisplay
- )}
+ {tokenMainTitleToDisplay}
+ {isStakeable && stakeableTitle}
- )}
-
- {shouldShowPercentage ? (
-
- ) : (
-
- {tokenTitle}
-
- )}
-
-
- {showScamWarning ? (
-
+ ) : (
+
- ,
- ) => {
- e.preventDefault();
- e.stopPropagation();
- setShowScamWarningModal(true);
- }}
- color={IconColor.errorDefault}
- size={ButtonIconSize.Md}
- backgroundColor={BackgroundColor.transparent}
- data-testid="scam-warning"
- ariaLabel={''}
- />
+ {tokenMainTitleToDisplay}
+ {isStakeable && stakeableTitle}
+
+ )}
-
- {primary} {isPrimaryTokenSymbolHidden ? '' : tokenSymbol}
-
-
+ {showScamWarning ? (
+ ,
+ ) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setShowScamWarningModal(true);
+ }}
+ color={IconColor.errorDefault}
+ size={ButtonIconSize.Md}
+ backgroundColor={BackgroundColor.transparent}
+ data-testid="scam-warning"
+ ariaLabel={''}
+ />
) : (
-
-
- {secondary}
-
-
- {primary} {isPrimaryTokenSymbolHidden ? '' : tokenSymbol}
-
-
+ {secondary}
+
)}
+
+ >
+ {shouldShowPercentage ? (
+
+ ) : (
+
+ {tokenTitle}
+
+ )}
+
+ {showScamWarning ? (
+
+ {primary} {isPrimaryTokenSymbolHidden ? '' : tokenSymbol}
+
+ ) : (
+
+ {primary} {isPrimaryTokenSymbolHidden ? '' : tokenSymbol}
+
+ )}
+
{isEvm && showScamWarningModal ? (
diff --git a/ui/ducks/bridge/utils.ts b/ui/ducks/bridge/utils.ts
index 1ddd7871d4df..638ba92e9ae8 100644
--- a/ui/ducks/bridge/utils.ts
+++ b/ui/ducks/bridge/utils.ts
@@ -2,6 +2,10 @@ import { Hex } from '@metamask/utils';
import { BigNumber } from 'bignumber.js';
import { getAddress } from 'ethers/lib/utils';
import { ContractMarketData } from '@metamask/assets-controllers';
+import {
+ AddNetworkFields,
+ NetworkConfiguration,
+} from '@metamask/network-controller';
import { decGWEIToHexWEI } from '../../../shared/modules/conversion.utils';
import { Numeric } from '../../../shared/modules/Numeric';
import { TxData } from '../../pages/bridge/types';
@@ -133,3 +137,8 @@ export const exchangeRatesFromNativeAndCurrencyRates = (
: null,
};
};
+
+export const isNetworkAdded = (
+ v: NetworkConfiguration | AddNetworkFields | undefined,
+): v is NetworkConfiguration =>
+ !v || 'networkClientId' in v.rpcEndpoints[v.defaultRpcEndpointIndex];
diff --git a/ui/hooks/useMultichainBalances.test.ts b/ui/hooks/useMultichainBalances.test.ts
new file mode 100644
index 000000000000..7bcdc74e8418
--- /dev/null
+++ b/ui/hooks/useMultichainBalances.test.ts
@@ -0,0 +1,121 @@
+import { createBridgeMockStore } from '../../test/jest/mock-store';
+import { renderHookWithProvider } from '../../test/lib/render-helpers';
+import { useMultichainBalances } from './useMultichainBalances';
+
+describe('useMultichainBalances', () => {
+ it('should return the native token of each imported network when no token balances are cached', () => {
+ const mockStore = createBridgeMockStore({
+ metamaskStateOverrides: {
+ allTokens: {},
+ },
+ });
+ const { result } = renderHookWithProvider(
+ () => useMultichainBalances(),
+ mockStore,
+ );
+
+ expect(result.current.assetsWithBalance).toHaveLength(2);
+ expect(result.current.assetsWithBalance).toStrictEqual(
+ expect.objectContaining([
+ {
+ balance: '0.000000000000000014',
+ chainId: '0xe708',
+ decimals: 18,
+ image: './images/eth_logo.svg',
+ string: '0.000000000000000014',
+ symbol: 'ETH',
+ tokenFiatAmount: 3.53395e-14,
+ type: 'NATIVE',
+ },
+ {
+ balance: '0.00000000000000001',
+ chainId: '0x1',
+ decimals: 18,
+ image: './images/eth_logo.svg',
+ string: '0.00000000000000001',
+ symbol: 'ETH',
+ tokenFiatAmount: 2.5242500000000003e-14,
+ type: 'NATIVE',
+ },
+ ]),
+ );
+ });
+
+ it('should return a list of assets with balances', () => {
+ const mockStore = createBridgeMockStore();
+ const { result } = renderHookWithProvider(
+ () => useMultichainBalances(),
+ mockStore,
+ );
+
+ expect(result.current.assetsWithBalance).toHaveLength(5);
+ expect(result.current.assetsWithBalance).toStrictEqual(
+ expect.objectContaining([
+ {
+ address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984',
+ balance: '0.00184',
+ chainId: '0x1',
+ decimals: 6,
+ image: undefined,
+ isNative: false,
+ string: '0.00184',
+ tokenFiatAmount: 0.004232,
+ type: 'TOKEN',
+ },
+ {
+ balance: '0.000000000000000014',
+ chainId: '0xe708',
+ decimals: 18,
+ image: './images/eth_logo.svg',
+ string: '0.000000000000000014',
+ symbol: 'ETH',
+ tokenFiatAmount: 3.53395e-14,
+ type: 'NATIVE',
+ },
+ {
+ balance: '0.00000000000000001',
+ chainId: '0x1',
+ decimals: 18,
+ image: './images/eth_logo.svg',
+ string: '0.00000000000000001',
+ symbol: 'ETH',
+ tokenFiatAmount: 2.5242500000000003e-14,
+ type: 'NATIVE',
+ },
+ {
+ address: '0x514910771af9ca656af840dff83e8264ecf986ca',
+ balance: '1',
+ chainId: '0x1',
+ image: undefined,
+ isNative: false,
+ string: '1',
+ tokenFiatAmount: null,
+ type: 'TOKEN',
+ },
+ {
+ address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984',
+ balance: '0',
+ chainId: '0xe708',
+ image: undefined,
+ isNative: false,
+ string: '0',
+ tokenFiatAmount: null,
+ type: 'TOKEN',
+ },
+ ]),
+ );
+ });
+
+ it('should return a mapping of chainId to balance', () => {
+ const mockStore = createBridgeMockStore();
+ const { result } = renderHookWithProvider(
+ () => useMultichainBalances(),
+ mockStore,
+ );
+
+ expect(result.current.balanceByChainId).toStrictEqual({
+ '0x1': 0.0042320000000252425,
+ '0xe708': 3.53395e-14,
+ });
+ });
+});
diff --git a/ui/hooks/useMultichainBalances.ts b/ui/hooks/useMultichainBalances.ts
new file mode 100644
index 000000000000..b953043f7851
--- /dev/null
+++ b/ui/hooks/useMultichainBalances.ts
@@ -0,0 +1,154 @@
+import { useMemo } from 'react';
+import { useSelector } from 'react-redux';
+import { Hex } from '@metamask/utils';
+import {
+ getCurrencyRates,
+ getMarketData,
+ getSelectedAccount,
+ getSelectedAccountNativeTokenCachedBalanceByChainId,
+ getSelectedAccountTokensAcrossChains,
+ selectERC20TokensByChain,
+} from '../selectors';
+import {
+ ChainAddressMarketData,
+ Token,
+} from '../components/app/assets/token-list/token-list';
+import { calculateTokenFiatAmount } from '../components/app/assets/util/calculateTokenFiatAmount';
+import { calculateTokenBalance } from '../components/app/assets/util/calculateTokenBalance';
+import {
+ CHAIN_ID_TO_CURRENCY_SYMBOL_MAP,
+ CHAIN_ID_TOKEN_IMAGE_MAP,
+ TEST_CHAINS,
+} from '../../shared/constants/network';
+import {
+ AssetWithDisplayData,
+ ERC20Asset,
+ NativeAsset,
+} from '../components/multichain/asset-picker-amount/asset-picker-modal/types';
+import { AssetType } from '../../shared/constants/transaction';
+import { useTokenBalances } from './useTokenBalances';
+
+const useFilteredAccountTokens = () => {
+ const selectedAccountTokensChains: Record
= useSelector(
+ getSelectedAccountTokensAcrossChains,
+ ) as Record;
+
+ const filteredAccountTokensChains = useMemo(() => {
+ return Object.fromEntries(
+ Object.entries(selectedAccountTokensChains).filter(
+ ([chainId]) => !(TEST_CHAINS as string[]).includes(chainId),
+ ),
+ );
+ }, [selectedAccountTokensChains, TEST_CHAINS]);
+
+ return filteredAccountTokensChains;
+};
+
+// This hook is used to get the balances of all tokens across all chains
+// native balances are included, with fields isNative=true and address=''
+export const useMultichainBalances = () => {
+ const selectedAccountTokensChains = useFilteredAccountTokens();
+ const selectedAccount = useSelector(getSelectedAccount);
+
+ const { tokenBalances } = useTokenBalances();
+ const selectedAccountTokenBalancesAcrossChains =
+ tokenBalances[selectedAccount.address];
+
+ const marketData: ChainAddressMarketData = useSelector(
+ getMarketData,
+ ) as ChainAddressMarketData;
+
+ const currencyRates = useSelector(getCurrencyRates);
+ const nativeBalances: Record = useSelector(
+ getSelectedAccountNativeTokenCachedBalanceByChainId,
+ ) as Record;
+
+ const erc20TokensByChain = useSelector(selectERC20TokensByChain);
+
+ const assetsWithBalance = useMemo(() => {
+ const tokensWithBalance: AssetWithDisplayData[] =
+ [];
+
+ Object.entries(selectedAccountTokensChains).forEach(
+ ([stringChainKey, tokens]) => {
+ const chainId = stringChainKey as Hex;
+ tokens.forEach((token: Token) => {
+ const { isNative, address, decimals } = token;
+ const balance =
+ calculateTokenBalance({
+ isNative,
+ chainId,
+ address,
+ decimals,
+ nativeBalances,
+ selectedAccountTokenBalancesAcrossChains,
+ }) || '';
+
+ const tokenFiatAmount = calculateTokenFiatAmount({
+ token,
+ chainId,
+ balance,
+ marketData,
+ currencyRates,
+ });
+
+ // Append processed token with balance and fiat amount
+ const sharedFields = {
+ balance,
+ tokenFiatAmount,
+ chainId,
+ string: String(balance),
+ };
+ if (token.isNative) {
+ tokensWithBalance.push({
+ ...sharedFields,
+ type: AssetType.native,
+ image:
+ CHAIN_ID_TOKEN_IMAGE_MAP[
+ chainId as keyof typeof CHAIN_ID_TOKEN_IMAGE_MAP
+ ],
+ symbol:
+ CHAIN_ID_TO_CURRENCY_SYMBOL_MAP[
+ chainId as keyof typeof CHAIN_ID_TO_CURRENCY_SYMBOL_MAP
+ ],
+ decimals: token.decimals,
+ });
+ } else {
+ tokensWithBalance.push({
+ ...token,
+ ...sharedFields,
+ image:
+ token.image ||
+ erc20TokensByChain?.[chainId]?.data?.[
+ token.address.toLowerCase()
+ ]?.iconUrl,
+ address: token.address,
+ type: AssetType.token,
+ });
+ }
+ });
+ },
+ );
+
+ return tokensWithBalance.sort(
+ (a, b) => (b.tokenFiatAmount ?? 0) - (a.tokenFiatAmount ?? 0),
+ );
+ }, [JSON.stringify(selectedAccountTokensChains)]);
+
+ const balanceByChainId = useMemo(() => {
+ return assetsWithBalance.reduce(
+ (acc: Record<`0x${string}`, number>, { chainId, tokenFiatAmount }) => {
+ if (!acc[chainId]) {
+ acc[chainId] = 0;
+ }
+ if (tokenFiatAmount) {
+ acc[chainId] += tokenFiatAmount;
+ }
+ return acc;
+ },
+ {},
+ );
+ }, [assetsWithBalance]);
+
+ return { assetsWithBalance, balanceByChainId };
+};
diff --git a/ui/hooks/useTokensWithFiltering.test.ts b/ui/hooks/useTokensWithFiltering.test.ts
index f5ea05e02b8d..0a523b69bd74 100644
--- a/ui/hooks/useTokensWithFiltering.test.ts
+++ b/ui/hooks/useTokensWithFiltering.test.ts
@@ -61,6 +61,7 @@ describe('useTokensWithFiltering should return token list generator', () => {
name: 'Ether',
primaryLabel: 'ETH',
rawFiat: '',
+ chainId: '0x1',
rightPrimaryLabel: undefined,
rightSecondaryLabel: '',
secondaryLabel: 'Ether',
@@ -74,6 +75,7 @@ describe('useTokensWithFiltering should return token list generator', () => {
decimals: 18,
erc20: true,
erc721: false,
+ chainId: '0x1',
iconUrl:
'https://static.cx.metamask.io/api/v1/tokenIcons/1/0x6b3595068778dd592e39a122f4f5a5cf09c90fe2.png',
identiconAddress: null,
@@ -121,6 +123,7 @@ describe('useTokensWithFiltering should return token list generator', () => {
identiconAddress: null,
image: './images/eth_logo.svg',
name: 'Ether',
+ chainId: '0x1',
primaryLabel: 'ETH',
rawFiat: '0',
rightPrimaryLabel: '0 ETH',
@@ -141,6 +144,7 @@ describe('useTokensWithFiltering should return token list generator', () => {
identiconAddress: null,
image: 'images/contract/usdt.svg',
name: 'Tether USD',
+ chainId: '0x1',
primaryLabel: 'USDT',
rawFiat: '',
rightPrimaryLabel: undefined,
diff --git a/ui/hooks/useTokensWithFiltering.ts b/ui/hooks/useTokensWithFiltering.ts
index d729ce3c1fdc..ef155eb9ca1c 100644
--- a/ui/hooks/useTokensWithFiltering.ts
+++ b/ui/hooks/useTokensWithFiltering.ts
@@ -75,14 +75,20 @@ export const useTokensWithFiltering = (
);
const filteredTokenListGenerator = useCallback(
- (shouldAddToken: (symbol: string, address?: string) => boolean) => {
+ (
+ shouldAddToken: (
+ symbol: string,
+ address?: string,
+ tokenChainId?: string,
+ ) => boolean,
+ ) => {
const buildTokenData = (
token: SwapsTokenObject,
):
| AssetWithDisplayData
| AssetWithDisplayData
| undefined => {
- if (chainId && shouldAddToken(token.symbol, token.address)) {
+ if (chainId && shouldAddToken(token.symbol, token.address, chainId)) {
return getRenderableTokenData(
{
...token,
@@ -90,6 +96,7 @@ export const useTokensWithFiltering = (
? AssetType.native
: AssetType.token,
image: token.iconUrl,
+ chainId,
},
tokenConversionRates,
conversionRate,
@@ -114,6 +121,7 @@ export const useTokensWithFiltering = (
numberOfDecimals: 4,
toDenomination: EtherDenomination.ETH,
}),
+ chainId,
}
: {};
const nativeToken = buildTokenData({
diff --git a/ui/pages/asset/components/__snapshots__/asset-page.test.tsx.snap b/ui/pages/asset/components/__snapshots__/asset-page.test.tsx.snap
index d6e4c675f8e1..dc3cf4c408de 100644
--- a/ui/pages/asset/components/__snapshots__/asset-page.test.tsx.snap
+++ b/ui/pages/asset/components/__snapshots__/asset-page.test.tsx.snap
@@ -177,13 +177,14 @@ exports[`AssetPage should render a native asset 1`] = `
Your balance
@@ -502,13 +496,14 @@ exports[`AssetPage should render an ERC20 asset without prices 1`] = `
Your balance
@@ -1013,13 +1001,14 @@ exports[`AssetPage should render an ERC20 token with prices 1`] = `
Your balance
diff --git a/ui/pages/bridge/prepare/__snapshots__/prepare-bridge-page.test.tsx.snap b/ui/pages/bridge/prepare/__snapshots__/prepare-bridge-page.test.tsx.snap
index 8c73ba3c82b2..b4873c7e1c89 100644
--- a/ui/pages/bridge/prepare/__snapshots__/prepare-bridge-page.test.tsx.snap
+++ b/ui/pages/bridge/prepare/__snapshots__/prepare-bridge-page.test.tsx.snap
@@ -142,11 +142,43 @@ exports[`PrepareBridgePage should render the component, with initial state 1`] =
-
- Select token
-
+
+
+
+ ?
+
+
+
+
+
+
+
+
+
+
- $0.00
+ $5,805.77
diff --git a/ui/pages/bridge/prepare/bridge-input-group.tsx b/ui/pages/bridge/prepare/bridge-input-group.tsx
index 2627b9e04102..2f8ea8fda1c9 100644
--- a/ui/pages/bridge/prepare/bridge-input-group.tsx
+++ b/ui/pages/bridge/prepare/bridge-input-group.tsx
@@ -44,6 +44,7 @@ const generateAssetFromToken = (
image: tokenDetails.iconUrl,
symbol: tokenDetails.symbol,
address: tokenDetails.address,
+ chainId,
};
}
@@ -57,6 +58,7 @@ const generateAssetFromToken = (
CHAIN_ID_TO_CURRENCY_SYMBOL_MAP[
chainId as keyof typeof CHAIN_ID_TO_CURRENCY_SYMBOL_MAP
],
+ chainId,
};
};
@@ -68,6 +70,7 @@ export const BridgeInputGroup = ({
onAmountChange,
networkProps,
customTokenListGenerator,
+ isMultiselectEnabled,
amountFieldProps = {},
}: {
className: string;
@@ -79,7 +82,11 @@ export const BridgeInputGroup = ({
>;
} & Pick<
React.ComponentProps,
- 'networkProps' | 'header' | 'customTokenListGenerator' | 'onAssetChange'
+ | 'networkProps'
+ | 'header'
+ | 'customTokenListGenerator'
+ | 'onAssetChange'
+ | 'isMultiselectEnabled'
>) => {
const t = useI18nContext();
@@ -123,6 +130,7 @@ export const BridgeInputGroup = ({
onAssetChange={onAssetChange}
networkProps={networkProps}
customTokenListGenerator={customTokenListGenerator}
+ isMultiselectEnabled={isMultiselectEnabled}
/>
{
expect(container).toMatchSnapshot();
expect(getByRole('button', { name: /ETH/u })).toBeInTheDocument();
- expect(getByRole('button', { name: /Select token/u })).toBeInTheDocument();
expect(getByTestId('from-amount')).toBeInTheDocument();
expect(getByTestId('from-amount').closest('input')).not.toBeDisabled();
@@ -167,6 +166,7 @@ describe('PrepareBridgePage', () => {
iconUrl: 'http://url',
symbol: 'UNI',
address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984',
+ decimals: 6,
},
toChainId: 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 2cf97891189f..1533fc1a9c20 100644
--- a/ui/pages/bridge/prepare/prepare-bridge-page.tsx
+++ b/ui/pages/bridge/prepare/prepare-bridge-page.tsx
@@ -50,6 +50,7 @@ import {
} from '../../../hooks/bridge/useCrossChainSwapsEventTracker';
import { useRequestProperties } from '../../../hooks/bridge/events/useRequestProperties';
import { MetaMetricsEventName } from '../../../../shared/constants/metametrics';
+import { isNetworkAdded } from '../../../ducks/bridge/utils';
import { BridgeInputGroup } from './bridge-input-group';
const PrepareBridgePage = () => {
@@ -194,7 +195,7 @@ const PrepareBridgePage = () => {
{
dispatch(setFromTokenInputValue(e));
@@ -219,17 +220,20 @@ const PrepareBridgePage = () => {
if (networkConfig.chainId === toChain?.chainId) {
dispatch(setToChainId(null));
}
- dispatch(
- setActiveNetwork(
- networkConfig.rpcEndpoints[
- networkConfig.defaultRpcEndpointIndex
- ].networkClientId,
- ),
- );
+ if (isNetworkAdded(networkConfig)) {
+ dispatch(
+ setActiveNetwork(
+ networkConfig.rpcEndpoints[
+ networkConfig.defaultRpcEndpointIndex
+ ].networkClientId,
+ ),
+ );
+ }
dispatch(setFromChain(networkConfig.chainId));
dispatch(setFromToken(null));
dispatch(setFromTokenInputValue(null));
},
+ header: t('bridgeFrom'),
}}
customTokenListGenerator={
fromTokens && fromTopAssets ? fromTokenListGenerator : undefined
@@ -239,6 +243,7 @@ const PrepareBridgePage = () => {
autoFocus: true,
value: fromAmount || undefined,
}}
+ isMultiselectEnabled={true}
/>
@@ -279,7 +284,7 @@ const PrepareBridgePage = () => {
{
token?.address &&
@@ -300,6 +305,7 @@ const PrepareBridgePage = () => {
dispatch(setToChainId(networkConfig.chainId));
dispatch(setToChain(networkConfig.chainId));
},
+ header: t('bridgeTo'),
}}
customTokenListGenerator={
toChain && toTokens && toTopAssets
diff --git a/ui/pages/confirmations/components/confirm-page-container/confirm-page-container-header/__snapshots__/confirm-page-container-header.component.test.js.snap b/ui/pages/confirmations/components/confirm-page-container/confirm-page-container-header/__snapshots__/confirm-page-container-header.component.test.js.snap
index 98fe98ec4138..78aa89d2bba3 100644
--- a/ui/pages/confirmations/components/confirm-page-container/confirm-page-container-header/__snapshots__/confirm-page-container-header.component.test.js.snap
+++ b/ui/pages/confirmations/components/confirm-page-container/confirm-page-container-header/__snapshots__/confirm-page-container-header.component.test.js.snap
@@ -32,12 +32,12 @@ exports[`Confirm Detail Row Component should match snapshot 1`] = `
>
G
-
Goerli
-
+
G
-
Goerli
-
+
G
-
Goerli
-
+
G
-
Goerli
-
+