diff --git a/app/scripts/controllers/bridge/bridge-controller.test.ts b/app/scripts/controllers/bridge/bridge-controller.test.ts index 7ccad836e2b6..9c9036b87f7b 100644 --- a/app/scripts/controllers/bridge/bridge-controller.test.ts +++ b/app/scripts/controllers/bridge/bridge-controller.test.ts @@ -1,5 +1,6 @@ import nock from 'nock'; import { BRIDGE_API_BASE_URL } from '../../../../shared/constants/bridge'; +import { CHAIN_IDS } from '../../../../shared/constants/network'; import BridgeController from './bridge-controller'; import { BridgeControllerMessenger } from './types'; import { DEFAULT_BRIDGE_CONTROLLER_STATE } from './constants'; @@ -22,21 +23,32 @@ describe('BridgeController', function () { bridgeController = new BridgeController({ messenger: messengerMock }); }); + beforeEach(() => { + jest.clearAllMocks(); + nock(BRIDGE_API_BASE_URL) + .get('/getAllFeatureFlags') + .reply(200, { + 'extension-support': true, + 'src-network-allowlist': [10, 534352], + 'dest-network-allowlist': [137, 42161], + }); + }); + it('constructor should setup correctly', function () { expect(bridgeController.state).toStrictEqual(EMPTY_INIT_STATE); }); it('setBridgeFeatureFlags should fetch and set the bridge feature flags', async function () { - nock(BRIDGE_API_BASE_URL).get('/getAllFeatureFlags').reply(200, { - 'extension-support': true, - }); - expect(bridgeController.state.bridgeState.bridgeFeatureFlags).toStrictEqual( - { extensionSupport: false }, - ); + const expectedFeatureFlagsResponse = { + extensionSupport: true, + destNetworkAllowlist: [CHAIN_IDS.POLYGON, CHAIN_IDS.ARBITRUM], + srcNetworkAllowlist: [CHAIN_IDS.OPTIMISM, CHAIN_IDS.SCROLL], + }; + expect(bridgeController.state).toStrictEqual(EMPTY_INIT_STATE); await bridgeController.setBridgeFeatureFlags(); expect(bridgeController.state.bridgeState.bridgeFeatureFlags).toStrictEqual( - { extensionSupport: true }, + expectedFeatureFlagsResponse, ); }); }); diff --git a/app/scripts/controllers/bridge/constants.ts b/app/scripts/controllers/bridge/constants.ts index b69f529bd339..f2932120f98d 100644 --- a/app/scripts/controllers/bridge/constants.ts +++ b/app/scripts/controllers/bridge/constants.ts @@ -5,5 +5,7 @@ export const BRIDGE_CONTROLLER_NAME = 'BridgeController'; export const DEFAULT_BRIDGE_CONTROLLER_STATE: BridgeControllerState = { bridgeFeatureFlags: { [BridgeFeatureFlagsKey.EXTENSION_SUPPORT]: false, + [BridgeFeatureFlagsKey.NETWORK_SRC_ALLOWLIST]: [], + [BridgeFeatureFlagsKey.NETWORK_DEST_ALLOWLIST]: [], }, }; diff --git a/app/scripts/controllers/bridge/types.ts b/app/scripts/controllers/bridge/types.ts index 1ab02b07f7a7..aa92a6597c69 100644 --- a/app/scripts/controllers/bridge/types.ts +++ b/app/scripts/controllers/bridge/types.ts @@ -2,20 +2,26 @@ import { ControllerStateChangeEvent, RestrictedControllerMessenger, } from '@metamask/base-controller'; +import { Hex } from '@metamask/utils'; import BridgeController from './bridge-controller'; import { BRIDGE_CONTROLLER_NAME } from './constants'; export enum BridgeFeatureFlagsKey { EXTENSION_SUPPORT = 'extensionSupport', + NETWORK_SRC_ALLOWLIST = 'srcNetworkAllowlist', + NETWORK_DEST_ALLOWLIST = 'destNetworkAllowlist', } export type BridgeFeatureFlags = { [BridgeFeatureFlagsKey.EXTENSION_SUPPORT]: boolean; + [BridgeFeatureFlagsKey.NETWORK_SRC_ALLOWLIST]: Hex[]; + [BridgeFeatureFlagsKey.NETWORK_DEST_ALLOWLIST]: Hex[]; }; export type BridgeControllerState = { bridgeFeatureFlags: BridgeFeatureFlags; }; + export enum BridgeBackgroundAction { SET_FEATURE_FLAGS = 'setBridgeFeatureFlags', } diff --git a/app/scripts/lib/setupSentry.js b/app/scripts/lib/setupSentry.js index 9432f8c4e7af..4b17169a6f91 100644 --- a/app/scripts/lib/setupSentry.js +++ b/app/scripts/lib/setupSentry.js @@ -124,6 +124,8 @@ export const SENTRY_BACKGROUND_STATE = { bridgeState: { bridgeFeatureFlags: { extensionSupport: false, + destNetworkAllowlist: [], + srcNetworkAllowlist: [], }, }, }, diff --git a/test/e2e/default-fixture.js b/test/e2e/default-fixture.js index b760c114301f..833335ed67bb 100644 --- a/test/e2e/default-fixture.js +++ b/test/e2e/default-fixture.js @@ -120,7 +120,9 @@ function defaultFixture(inputChainId = CHAIN_IDS.LOCALHOST) { BridgeController: { bridgeState: { bridgeFeatureFlags: { + destNetworkAllowlist: [], extensionSupport: false, + srcNetworkAllowlist: [], }, }, }, diff --git a/test/e2e/mock-e2e.js b/test/e2e/mock-e2e.js index 3e3719eed432..fec1439465d6 100644 --- a/test/e2e/mock-e2e.js +++ b/test/e2e/mock-e2e.js @@ -1,11 +1,17 @@ const fs = require('fs'); -const { BRIDGE_API_BASE_URL } = require('../../shared/constants/bridge'); +const { + BRIDGE_DEV_API_BASE_URL, + BRIDGE_PROD_API_BASE_URL, +} = require('../../shared/constants/bridge'); const { GAS_API_BASE_URL, SWAPS_API_V2_BASE_URL, TOKEN_API_BASE_URL, } = require('../../shared/constants/swaps'); +const { + DEFAULT_FEATURE_FLAGS_RESPONSE: BRIDGE_DEFAULT_FEATURE_FLAGS_RESPONSE, +} = require('./tests/bridge/constants'); const CDN_CONFIG_PATH = 'test/e2e/mock-cdn/cdn-config.txt'; const CDN_STALE_DIFF_PATH = 'test/e2e/mock-cdn/cdn-stale-diff.txt'; @@ -294,16 +300,18 @@ async function setupMocking( }; }); - await server - .forGet(`${BRIDGE_API_BASE_URL}/getAllFeatureFlags`) - .thenCallback(() => { - return { - statusCode: 200, - json: { - 'extension-support': false, - }, - }; - }); + [ + `${BRIDGE_DEV_API_BASE_URL}/getAllFeatureFlags`, + `${BRIDGE_PROD_API_BASE_URL}/getAllFeatureFlags`, + ].forEach( + async (url) => + await server.forGet(url).thenCallback(() => { + return { + statusCode: 200, + json: BRIDGE_DEFAULT_FEATURE_FLAGS_RESPONSE, + }; + }), + ); await server .forGet(`https://token.api.cx.metamask.io/tokens/${chainId}`) diff --git a/test/e2e/tests/bridge/constants.ts b/test/e2e/tests/bridge/constants.ts index f5d90afcea0e..e79a1dfe4553 100644 --- a/test/e2e/tests/bridge/constants.ts +++ b/test/e2e/tests/bridge/constants.ts @@ -2,6 +2,8 @@ import { FeatureFlagResponse } from '../../../../ui/pages/bridge/bridge.util'; export const DEFAULT_FEATURE_FLAGS_RESPONSE: FeatureFlagResponse = { 'extension-support': false, + 'src-network-allowlist': [1, 42161, 59144], + 'dest-network-allowlist': [1, 42161, 59144], }; export const LOCATOR = { diff --git a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json index 391224338a39..bcfbeedc31ae 100644 --- a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json +++ b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json @@ -60,7 +60,21 @@ }, "AuthenticationController": { "isSignedIn": "boolean" }, "BridgeController": { - "bridgeState": { "bridgeFeatureFlags": { "extensionSupport": "boolean" } } + "bridgeState": { + "bridgeFeatureFlags": { + "extensionSupport": "boolean", + "srcNetworkAllowlist": { + "0": "string", + "1": "string", + "2": "string" + }, + "destNetworkAllowlist": { + "0": "string", + "1": "string", + "2": "string" + } + } + } }, "CronjobController": { "jobs": "object" }, "CurrencyController": { diff --git a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json index 4d3f7779e456..b4e29a987a04 100644 --- a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json +++ b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json @@ -253,7 +253,21 @@ "swapsStxMaxFeeMultiplier": 2, "swapsFeatureFlags": {} }, - "bridgeState": { "bridgeFeatureFlags": { "extensionSupport": "boolean" } }, + "bridgeState": { + "bridgeFeatureFlags": { + "extensionSupport": "boolean", + "srcNetworkAllowlist": { + "0": "string", + "1": "string", + "2": "string" + }, + "destNetworkAllowlist": { + "0": "string", + "1": "string", + "2": "string" + } + } + }, "ensEntries": "object", "ensResolutionsByAddress": "object", "pendingApprovals": "object", diff --git a/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-background-state.json b/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-background-state.json index 0261a3c16c90..f01b14638f6d 100644 --- a/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-background-state.json +++ b/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-background-state.json @@ -145,7 +145,9 @@ "BridgeController": { "bridgeState": { "bridgeFeatureFlags": { - "extensionSupport": "boolean" + "destNetworkAllowlist": {}, + "extensionSupport": "boolean", + "srcNetworkAllowlist": {} } } }, diff --git a/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-ui-state.json b/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-ui-state.json index fa2d96e37c5c..de12d7d04f5d 100644 --- a/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-ui-state.json +++ b/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-ui-state.json @@ -154,7 +154,9 @@ "BridgeController": { "bridgeState": { "bridgeFeatureFlags": { - "extensionSupport": "boolean" + "extensionSupport": "boolean", + "srcNetworkAllowlist": {}, + "destNetworkAllowlist": {} } } }, diff --git a/test/jest/mock-store.js b/test/jest/mock-store.js index d031a5930cfc..491df74bdfc3 100644 --- a/test/jest/mock-store.js +++ b/test/jest/mock-store.js @@ -668,12 +668,16 @@ export const createSwapsMockStore = () => { }; }; -export const createBridgeMockStore = (featureFlagOverrides = {}) => { +export const createBridgeMockStore = ( + featureFlagOverrides = {}, + bridgeSliceOverrides = {}, +) => { const swapsStore = createSwapsMockStore(); return { ...swapsStore, bridge: { toChain: null, + ...bridgeSliceOverrides, }, metamask: { ...swapsStore.metamask, @@ -681,6 +685,8 @@ export const createBridgeMockStore = (featureFlagOverrides = {}) => { ...(swapsStore.metamask.bridgeState ?? {}), bridgeFeatureFlags: { extensionSupport: false, + srcNetworkAllowlist: [], + destNetworkAllowlist: [], ...featureFlagOverrides, }, }, diff --git a/ui/ducks/bridge/bridge.ts b/ui/ducks/bridge/bridge.ts index 2f6b4aabd775..422e99573041 100644 --- a/ui/ducks/bridge/bridge.ts +++ b/ui/ducks/bridge/bridge.ts @@ -1,9 +1,11 @@ import { createSlice } from '@reduxjs/toolkit'; +import { ProviderConfig } from '@metamask/network-controller'; + import { swapsSlice } from '../swaps/swaps'; // Only states that are not in swaps slice -type BridgeState = { - toChain: string | null; +export type BridgeState = { + toChain: ProviderConfig | null; }; const initialState: BridgeState = { diff --git a/ui/ducks/bridge/selectors.test.ts b/ui/ducks/bridge/selectors.test.ts new file mode 100644 index 000000000000..e8e37748b2cf --- /dev/null +++ b/ui/ducks/bridge/selectors.test.ts @@ -0,0 +1,279 @@ +import { ProviderConfig } from '@metamask/network-controller'; +import { createBridgeMockStore } from '../../../test/jest/mock-store'; +import { CHAIN_IDS, FEATURED_RPCS } from '../../../shared/constants/network'; +import { ALLOWED_BRIDGE_CHAIN_IDS } from '../../../shared/constants/bridge'; +import { getCurrentNetwork } from '../../selectors'; +import { + getAllBridgeableNetworks, + getFromChain, + getFromChains, + getIsBridgeTx, + getToChain, + getToChains, +} from './selectors'; + +describe('Bridge selectors', () => { + describe('getFromChain', () => { + it('returns the fromChain from the state', () => { + const state = { + metamask: { + providerConfig: { chainId: '0x1', type: 'test-id' }, + networkConfigurations: [{ chainId: '0x1', id: 'test-id' }], + }, + }; + + const result = getFromChain(state as never); + expect(result).toStrictEqual({ + blockExplorerUrl: undefined, + chainId: '0x1', + id: 'test-id', + removable: true, + rpcPrefs: { + imageUrl: './images/eth_logo.svg', + }, + }); + + const providerConfig = getCurrentNetwork(state); + expect(result).toStrictEqual(providerConfig); + }); + }); + + describe('getToChain', () => { + it('returns the toChain from the state', () => { + const state = { + bridge: { + toChain: { chainId: '0x1' } as unknown as ProviderConfig, + }, + }; + + const result = getToChain(state as never); + + expect(result).toStrictEqual({ chainId: '0x1' }); + }); + }); + + describe('getAllBridgeableNetworks', () => { + it('returns list of ALLOWED_BRIDGE_CHAIN_IDS networks', () => { + const state = createBridgeMockStore(); + const result = getAllBridgeableNetworks(state as never); + + expect(result).toHaveLength(9); + expect(result[0]).toStrictEqual( + expect.objectContaining({ chainId: CHAIN_IDS.MAINNET }), + ); + expect(result[1]).toStrictEqual( + expect.objectContaining({ chainId: CHAIN_IDS.LINEA_MAINNET }), + ); + expect(result.slice(2)).toStrictEqual(FEATURED_RPCS); + result.forEach(({ chainId }) => { + expect(ALLOWED_BRIDGE_CHAIN_IDS).toContain(chainId); + }); + ALLOWED_BRIDGE_CHAIN_IDS.forEach((allowedChainId) => { + expect( + result.findIndex(({ chainId }) => chainId === allowedChainId), + ).toBeGreaterThan(-1); + }); + }); + + it('uses config from allNetworks if network is in both FEATURED_RPCS and allNetworks', () => { + const addedFeaturedNetwork = { + ...FEATURED_RPCS[FEATURED_RPCS.length - 1], + id: 'testid', + }; + const state = { + ...createBridgeMockStore(), + metamask: { + networkConfigurations: [addedFeaturedNetwork], + }, + }; + const result = getAllBridgeableNetworks(state as never); + + expect(result).toHaveLength(9); + expect(result[0]).toStrictEqual( + expect.objectContaining({ chainId: CHAIN_IDS.MAINNET }), + ); + expect(result[1]).toStrictEqual( + expect.objectContaining({ chainId: CHAIN_IDS.LINEA_MAINNET }), + ); + expect(result[2]).toStrictEqual({ + ...addedFeaturedNetwork, + removable: true, + blockExplorerUrl: 'https://basescan.org', + }); + expect(result.slice(3)).toStrictEqual(FEATURED_RPCS.slice(0, -1)); + }); + + it('returns network if included in ALLOWED_BRIDGE_CHAIN_IDS', () => { + const addedFeaturedNetwork = { + chainId: '0x11212131241523151', + nickname: 'scroll', + rpcUrl: 'https://a', + ticker: 'ETH', + rpcPrefs: { + blockExplorerUrl: 'https://a', + imageUrl: 'https://a', + }, + }; + const state = { + ...createBridgeMockStore(), + metamask: { + networkConfigurations: [addedFeaturedNetwork], + }, + }; + const result = getAllBridgeableNetworks(state as never); + + expect(result).toHaveLength(9); + expect(result[0]).toStrictEqual( + expect.objectContaining({ chainId: CHAIN_IDS.MAINNET }), + ); + expect(result[1]).toStrictEqual( + expect.objectContaining({ chainId: CHAIN_IDS.LINEA_MAINNET }), + ); + expect(result.slice(2)).toStrictEqual(FEATURED_RPCS); + }); + }); + + describe('getFromChains', () => { + it('excludes selected toChain and disabled chains from options', () => { + const state = createBridgeMockStore( + { + srcNetworkAllowlist: [ + CHAIN_IDS.MAINNET, + CHAIN_IDS.OPTIMISM, + CHAIN_IDS.POLYGON, + ], + }, + { toChain: { chainId: CHAIN_IDS.MAINNET } }, + ); + const result = getFromChains(state as never); + + expect(result).toHaveLength(3); + expect(result[0]).toStrictEqual( + expect.objectContaining({ chainId: CHAIN_IDS.MAINNET }), + ); + expect(result[1]).toStrictEqual( + expect.objectContaining({ chainId: CHAIN_IDS.OPTIMISM }), + ); + expect(result[2]).toStrictEqual( + expect.objectContaining({ chainId: CHAIN_IDS.POLYGON }), + ); + }); + + it('returns empty list when bridgeFeatureFlags are not set', () => { + const state = createBridgeMockStore(); + const result = getFromChains(state as never); + + expect(result).toHaveLength(0); + }); + }); + + describe('getToChains', () => { + it('excludes selected providerConfig and disabled chains from options', () => { + const state = createBridgeMockStore({ + destNetworkAllowlist: [ + CHAIN_IDS.MAINNET, + CHAIN_IDS.OPTIMISM, + CHAIN_IDS.POLYGON, + ], + }); + const result = getToChains(state as never); + + expect(result).toHaveLength(3); + expect(result[0]).toStrictEqual( + expect.objectContaining({ chainId: CHAIN_IDS.MAINNET }), + ); + expect(result[1]).toStrictEqual( + expect.objectContaining({ chainId: CHAIN_IDS.OPTIMISM }), + ); + expect(result[2]).toStrictEqual( + expect.objectContaining({ chainId: CHAIN_IDS.POLYGON }), + ); + }); + + it('returns empty list when bridgeFeatureFlags are not set', () => { + const state = createBridgeMockStore(); + const result = getToChains(state as never); + + expect(result).toHaveLength(0); + }); + }); + + describe('getIsBridgeTx', () => { + it('returns false if bridge is not enabled', () => { + const state = { + metamask: { + providerConfig: { chainId: '0x1' }, + useExternalServices: true, + bridgeState: { bridgeFeatureFlags: { extensionSupport: false } }, + }, + bridge: { toChain: { chainId: '0x38' } as unknown as ProviderConfig }, + }; + + const result = getIsBridgeTx(state as never); + + expect(result).toBe(false); + }); + + it('returns false if toChain is null', () => { + const state = { + metamask: { + providerConfig: { chainId: '0x1' }, + useExternalServices: true, + bridgeState: { bridgeFeatureFlags: { extensionSupport: true } }, + }, + bridge: { toChain: null }, + }; + + const result = getIsBridgeTx(state as never); + + expect(result).toBe(false); + }); + + it('returns false if fromChain and toChain have the same chainId', () => { + const state = { + metamask: { + providerConfig: { chainId: '0x1', id: 'test-id', type: 'rpc' }, + useExternalServices: true, + bridgeState: { bridgeFeatureFlags: { extensionSupport: true } }, + networkConfigurations: [{ chainId: '0x1', id: 'test-id' }], + }, + bridge: { toChain: { chainId: '0x1' } }, + }; + + const result = getIsBridgeTx(state as never); + + expect(result).toBe(false); + }); + + it('returns false if useExternalServices is not enabled', () => { + const state = { + metamask: { + providerConfig: { chainId: '0x1' }, + useExternalServices: false, + bridgeState: { bridgeFeatureFlags: { extensionSupport: true } }, + }, + bridge: { toChain: { chainId: '0x38' } }, + }; + + const result = getIsBridgeTx(state as never); + + expect(result).toBe(false); + }); + + it('returns true if bridge is enabled and fromChain and toChain have different chainIds', () => { + const state = { + metamask: { + providerConfig: { chainId: '0x1', id: 'test-id', type: 'rpc' }, + useExternalServices: true, + bridgeState: { bridgeFeatureFlags: { extensionSupport: true } }, + networkConfigurations: [{ chainId: '0x1', id: 'test-id' }], + }, + bridge: { toChain: { chainId: '0x38' } }, + }; + + const result = getIsBridgeTx(state as never); + + expect(result).toBe(true); + }); + }); +}); diff --git a/ui/ducks/bridge/selectors.ts b/ui/ducks/bridge/selectors.ts new file mode 100644 index 000000000000..a275d08ac789 --- /dev/null +++ b/ui/ducks/bridge/selectors.ts @@ -0,0 +1,80 @@ +import { NetworkState, ProviderConfig } from '@metamask/network-controller'; +import { uniqBy } from 'lodash'; +import { + getAllNetworks, + getCurrentNetwork, + getIsBridgeEnabled, +} from '../../selectors'; +import { ALLOWED_BRIDGE_CHAIN_IDS } from '../../../shared/constants/bridge'; +import { + BridgeControllerState, + BridgeFeatureFlagsKey, +} from '../../../app/scripts/controllers/bridge/types'; +import { + FEATURED_RPCS, + RPCDefinition, +} from '../../../shared/constants/network'; +import { createDeepEqualSelector } from '../../selectors/util'; +import { BridgeState } from './bridge'; + +// TODO add swaps state +type BridgeAppState = { + metamask: NetworkState & { bridgeState: BridgeControllerState } & { + useExternalServices: boolean; + }; + bridge: BridgeState; +}; + +export const getFromChain = (state: BridgeAppState): ProviderConfig => + getCurrentNetwork(state); +export const getToChain = (state: BridgeAppState): ProviderConfig | null => + state.bridge.toChain; + +export const getAllBridgeableNetworks = createDeepEqualSelector( + (state: BridgeAppState) => + // includes networks user has added + getAllNetworks({ + metamask: { networkConfigurations: state.metamask.networkConfigurations }, + }), + (allNetworks): (ProviderConfig | RPCDefinition)[] => { + return uniqBy([...allNetworks, ...FEATURED_RPCS], 'chainId').filter( + ({ chainId }) => ALLOWED_BRIDGE_CHAIN_IDS.includes(chainId), + ); + }, +); +export const getFromChains = createDeepEqualSelector( + getAllBridgeableNetworks, + (state: BridgeAppState) => state.metamask.bridgeState?.bridgeFeatureFlags, + ( + allBridgeableNetworks, + bridgeFeatureFlags, + ): (ProviderConfig | RPCDefinition)[] => + allBridgeableNetworks.filter(({ chainId }) => + bridgeFeatureFlags[BridgeFeatureFlagsKey.NETWORK_SRC_ALLOWLIST].includes( + chainId, + ), + ), +); +export const getToChains = createDeepEqualSelector( + getAllBridgeableNetworks, + (state: BridgeAppState) => state.metamask.bridgeState?.bridgeFeatureFlags, + ( + allBridgeableNetworks, + bridgeFeatureFlags, + ): (ProviderConfig | RPCDefinition)[] => + allBridgeableNetworks.filter(({ chainId }) => + bridgeFeatureFlags[BridgeFeatureFlagsKey.NETWORK_DEST_ALLOWLIST].includes( + chainId, + ), + ), +); + +export const getIsBridgeTx = createDeepEqualSelector( + getFromChain, + getToChain, + (state: BridgeAppState) => getIsBridgeEnabled(state), + (fromChain, toChain, isBridgeEnabled: boolean) => + isBridgeEnabled && + toChain !== null && + fromChain.chainId !== toChain.chainId, +); diff --git a/ui/pages/bridge/bridge.util.test.ts b/ui/pages/bridge/bridge.util.test.ts index d693f92dc956..da2637b1f5f4 100644 --- a/ui/pages/bridge/bridge.util.test.ts +++ b/ui/pages/bridge/bridge.util.test.ts @@ -1,59 +1,82 @@ import fetchWithCache from '../../../shared/lib/fetch-with-cache'; +import { CHAIN_IDS } from '../../../shared/constants/network'; import { fetchBridgeFeatureFlags } from './bridge.util'; jest.mock('../../../shared/lib/fetch-with-cache'); describe('Bridge utils', () => { - it('should fetch bridge feature flags successfully', async () => { - const mockResponse = { - 'extension-support': true, - }; - - (fetchWithCache as jest.Mock).mockResolvedValue(mockResponse); - - const result = await fetchBridgeFeatureFlags(); - - expect(fetchWithCache).toHaveBeenCalledWith({ - url: 'https://bridge.api.cx.metamask.io/getAllFeatureFlags', - fetchOptions: { - method: 'GET', - headers: { 'X-Client-Id': 'extension' }, - }, - cacheOptions: { cacheRefreshTime: 600000 }, - functionName: 'fetchBridgeFeatureFlags', + describe('fetchBridgeFeatureFlags', () => { + it('should fetch bridge feature flags successfully', async () => { + const mockResponse = { + 'extension-support': true, + 'src-network-allowlist': [1, 10, 59144, 120], + 'dest-network-allowlist': [1, 137, 59144, 11111], + }; + + (fetchWithCache as jest.Mock).mockResolvedValue(mockResponse); + + const result = await fetchBridgeFeatureFlags(); + + expect(fetchWithCache).toHaveBeenCalledWith({ + url: 'https://bridge.api.cx.metamask.io/getAllFeatureFlags', + fetchOptions: { + method: 'GET', + headers: { 'X-Client-Id': 'extension' }, + }, + cacheOptions: { cacheRefreshTime: 600000 }, + functionName: 'fetchBridgeFeatureFlags', + }); + + expect(result).toStrictEqual({ + extensionSupport: true, + srcNetworkAllowlist: [ + CHAIN_IDS.MAINNET, + CHAIN_IDS.OPTIMISM, + CHAIN_IDS.LINEA_MAINNET, + '0x78', + ], + destNetworkAllowlist: [ + CHAIN_IDS.MAINNET, + CHAIN_IDS.POLYGON, + CHAIN_IDS.LINEA_MAINNET, + '0x2b67', + ], + }); }); - expect(result).toEqual({ extensionSupport: true }); - }); + it('should use fallback bridge feature flags if response is unexpected', async () => { + const mockResponse = { + flag1: true, + flag2: false, + }; - it('should use fallback bridge feature flags if response is unexpected', async () => { - const mockResponse = { - flag1: true, - flag2: false, - }; + (fetchWithCache as jest.Mock).mockResolvedValue(mockResponse); - (fetchWithCache as jest.Mock).mockResolvedValue(mockResponse); + const result = await fetchBridgeFeatureFlags(); - const result = await fetchBridgeFeatureFlags(); + expect(fetchWithCache).toHaveBeenCalledWith({ + url: 'https://bridge.api.cx.metamask.io/getAllFeatureFlags', + fetchOptions: { + method: 'GET', + headers: { 'X-Client-Id': 'extension' }, + }, + cacheOptions: { cacheRefreshTime: 600000 }, + functionName: 'fetchBridgeFeatureFlags', + }); - expect(fetchWithCache).toHaveBeenCalledWith({ - url: 'https://bridge.api.cx.metamask.io/getAllFeatureFlags', - fetchOptions: { - method: 'GET', - headers: { 'X-Client-Id': 'extension' }, - }, - cacheOptions: { cacheRefreshTime: 600000 }, - functionName: 'fetchBridgeFeatureFlags', + expect(result).toStrictEqual({ + extensionSupport: false, + srcNetworkAllowlist: [], + destNetworkAllowlist: [], + }); }); - expect(result).toEqual({ extensionSupport: false }); - }); - - it('should handle fetch error', async () => { - const mockError = new Error('Failed to fetch'); + it('should handle fetch error', async () => { + const mockError = new Error('Failed to fetch'); - (fetchWithCache as jest.Mock).mockRejectedValue(mockError); + (fetchWithCache as jest.Mock).mockRejectedValue(mockError); - await expect(fetchBridgeFeatureFlags()).rejects.toThrowError(mockError); + await expect(fetchBridgeFeatureFlags()).rejects.toThrowError(mockError); + }); }); }); diff --git a/ui/pages/bridge/bridge.util.ts b/ui/pages/bridge/bridge.util.ts index 0c3e54d1dc79..e7506ede2066 100644 --- a/ui/pages/bridge/bridge.util.ts +++ b/ui/pages/bridge/bridge.util.ts @@ -1,3 +1,4 @@ +import { add0x } from '@metamask/utils'; import { BridgeFeatureFlagsKey, BridgeFeatureFlags, @@ -9,6 +10,7 @@ import { import { MINUTE } from '../../../shared/constants/time'; import fetchWithCache from '../../../shared/lib/fetch-with-cache'; import { validateData } from '../../../shared/lib/swaps-utils'; +import { decimalToHex } from '../../../shared/modules/conversion.utils'; const CLIENT_ID_HEADER = { 'X-Client-Id': BRIDGE_CLIENT_ID }; const CACHE_REFRESH_TEN_MINUTES = 10 * MINUTE; @@ -16,10 +18,14 @@ const CACHE_REFRESH_TEN_MINUTES = 10 * MINUTE; // Types copied from Metabridge API enum BridgeFlag { EXTENSION_SUPPORT = 'extension-support', + NETWORK_SRC_ALLOWLIST = 'src-network-allowlist', + NETWORK_DEST_ALLOWLIST = 'dest-network-allowlist', } export type FeatureFlagResponse = { [BridgeFlag.EXTENSION_SUPPORT]: boolean; + [BridgeFlag.NETWORK_SRC_ALLOWLIST]: number[]; + [BridgeFlag.NETWORK_DEST_ALLOWLIST]: number[]; }; // End of copied types @@ -54,6 +60,22 @@ export async function fetchBridgeFeatureFlags(): Promise { type: 'boolean', validator: (v) => typeof v === 'boolean', }, + { + property: BridgeFlag.NETWORK_SRC_ALLOWLIST, + type: 'object', + validator: (v): v is number[] => + Object.values(v as { [s: string]: unknown }).every( + (i) => typeof i === 'number', + ), + }, + { + property: BridgeFlag.NETWORK_DEST_ALLOWLIST, + type: 'object', + validator: (v): v is number[] => + Object.values(v as { [s: string]: unknown }).every( + (i) => typeof i === 'number', + ), + }, ], rawFeatureFlags, url, @@ -62,11 +84,21 @@ export async function fetchBridgeFeatureFlags(): Promise { return { [BridgeFeatureFlagsKey.EXTENSION_SUPPORT]: rawFeatureFlags[BridgeFlag.EXTENSION_SUPPORT], + [BridgeFeatureFlagsKey.NETWORK_SRC_ALLOWLIST]: rawFeatureFlags[ + BridgeFlag.NETWORK_SRC_ALLOWLIST + ].map((chainIdDec) => add0x(decimalToHex(chainIdDec))), + [BridgeFeatureFlagsKey.NETWORK_DEST_ALLOWLIST]: rawFeatureFlags[ + BridgeFlag.NETWORK_DEST_ALLOWLIST + ].map((chainIdDec) => add0x(decimalToHex(chainIdDec))), }; } return { // TODO set default to true once bridging is live [BridgeFeatureFlagsKey.EXTENSION_SUPPORT]: false, + // TODO set default to ALLOWED_BRIDGE_CHAIN_IDS once bridging is live + [BridgeFeatureFlagsKey.NETWORK_SRC_ALLOWLIST]: [], + // TODO set default to ALLOWED_BRIDGE_CHAIN_IDS once bridging is live + [BridgeFeatureFlagsKey.NETWORK_DEST_ALLOWLIST]: [], }; }