From 557c0e3094b8f190607d5e3aa8c18615f2ac8504 Mon Sep 17 00:00:00 2001 From: Bryan Fullam Date: Fri, 6 Feb 2026 22:31:55 +0100 Subject: [PATCH 1/3] fix: check chainRanking against ALLOWED_BRIDGE_CHAIN_IDS (#25788) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When new networks are added to the chainRanking remote feature flag in LaunchDarkly, older app versions that don't support those networks would still surface them in the UI (destination network pills, source chain checks). This creates a forward-compatibility gap where users could see unsupported networks. This change adds client-side filtering of chainRanking against ALLOWED_BRIDGE_CHAIN_IDS — the hardcoded allowlist in @metamask/bridge-controller that defines which chains this version of the client actually supports. This ensures that chains added to the remote flag in the future are silently ignored by older app versions that lack support for them. CHANGELOG entry: null Fixes: ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Low Risk** > Narrow change to selector filtering logic with comprehensive unit test coverage; main risk is unintentionally hiding a chain if allowlist/CAIP formatting is incorrect. > > **Overview** > Prevents *forward-incompatible* networks from being surfaced when the remote `bridgeConfigV2.chainRanking` feature flag adds new chains that older clients don’t support. > > Adds a shared `isAllowedBridgeChainId` guard and applies it to `selectSourceChainRanking`, `selectDestChainRanking`, and `selectIsBridgeEnabledSourceFactory` so only allowlisted EVM/non-EVM CAIP chain IDs are considered. Updates/adds unit tests to cover allowlist filtering for source/dest ranking and source-enabled checks. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 93e136728f5b35c4efb2efb547bbab9e3690fa6a. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- app/core/redux/slices/bridge/index.test.ts | 135 ++++++++++++++++++++- app/core/redux/slices/bridge/index.ts | 39 +++++- 2 files changed, 166 insertions(+), 8 deletions(-) diff --git a/app/core/redux/slices/bridge/index.test.ts b/app/core/redux/slices/bridge/index.test.ts index 5745a235206..3134363c111 100644 --- a/app/core/redux/slices/bridge/index.test.ts +++ b/app/core/redux/slices/bridge/index.test.ts @@ -17,6 +17,7 @@ import reducer, { selectIsBridgeEnabledDest, selectIsSwapsLive, selectDestChainRanking, + selectSourceChainRanking, } from '.'; import { BridgeToken, @@ -597,6 +598,77 @@ describe('bridge slice', () => { }); }); + describe('selectSourceChainRanking', () => { + it('returns only supported and user-configured chains', () => { + const result = selectSourceChainRanking( + mockRootState as unknown as RootState, + ); + + // Should return chains that are both in ALLOWED_BRIDGE_CHAIN_IDS + // and in the user's configured networks + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBeGreaterThan(0); + + // Ethereum (0x1) is allowed and configured in the mock state + expect(result.some((chain) => chain.chainId === 'eip155:1')).toBe(true); + }); + + it('filters out unsupported EVM chains from chainRanking', () => { + const mockState = cloneDeep(mockRootState); + mockState.engine.backgroundState.RemoteFeatureFlagController.remoteFeatureFlags.bridgeConfigV2.chainRanking = + [ + ...mockState.engine.backgroundState.RemoteFeatureFlagController + .remoteFeatureFlags.bridgeConfigV2.chainRanking, + { chainId: 'eip155:99999', name: 'Unsupported EVM Chain' }, + ]; + + const result = selectSourceChainRanking( + mockState as unknown as RootState, + ); + + expect(result.some((chain) => chain.chainId === 'eip155:99999')).toBe( + false, + ); + }); + + it('filters out unsupported non-EVM chains from chainRanking', () => { + const mockState = cloneDeep(mockRootState); + mockState.engine.backgroundState.RemoteFeatureFlagController.remoteFeatureFlags.bridgeConfigV2.chainRanking = + [ + ...mockState.engine.backgroundState.RemoteFeatureFlagController + .remoteFeatureFlags.bridgeConfigV2.chainRanking, + { chainId: 'cosmos:cosmoshub-4', name: 'Unsupported Cosmos Chain' }, + ]; + + const result = selectSourceChainRanking( + mockState as unknown as RootState, + ); + + expect( + result.some((chain) => chain.chainId === 'cosmos:cosmoshub-4'), + ).toBe(false); + }); + + it('filters out chains not in user-configured networks', () => { + const result = selectSourceChainRanking( + mockRootState as unknown as RootState, + ); + + // Optimism (0xa) is in chainRanking and ALLOWED_BRIDGE_CHAIN_IDS + // AND in the mock user's configured networks + const hasOptimism = result.some((chain) => chain.chainId === 'eip155:10'); + expect(hasOptimism).toBe(true); + + // Verify no chains appear that aren't in the user's configured networks + // The user only has Ethereum (0x1) and Optimism (0xa) configured as EVM networks + result.forEach((chain) => { + if (chain.chainId.startsWith('eip155:')) { + expect(['eip155:1', 'eip155:10']).toContain(chain.chainId); + } + }); + }); + }); + describe('selectDestChainRanking', () => { it('returns chainRanking from feature flags', () => { const result = selectDestChainRanking( @@ -622,16 +694,75 @@ describe('bridge slice', () => { expect(hasEthereum).toBe(true); }); - it('returns all chains without filtering (unlike selectSourceChainRanking)', () => { + it('returns all supported chains without filtering by user-configured networks', () => { const result = selectDestChainRanking( mockRootState as unknown as RootState, ); - // selectDestChainRanking should return all chains from feature flags + // selectDestChainRanking should return all supported chains from feature flags // This is the key difference from selectSourceChainRanking which filters // by user-configured networks expect(result.length).toBeGreaterThan(0); }); + + it('filters out unsupported EVM chains not in ALLOWED_BRIDGE_CHAIN_IDS', () => { + const mockState = cloneDeep(mockRootState); + mockState.engine.backgroundState.RemoteFeatureFlagController.remoteFeatureFlags.bridgeConfigV2.chainRanking = + [ + ...mockState.engine.backgroundState.RemoteFeatureFlagController + .remoteFeatureFlags.bridgeConfigV2.chainRanking, + { chainId: 'eip155:99999', name: 'Unsupported Future Chain' }, + ]; + + const result = selectDestChainRanking(mockState as unknown as RootState); + + expect(result.some((chain) => chain.chainId === 'eip155:99999')).toBe( + false, + ); + expect(result.some((chain) => chain.chainId === 'eip155:1')).toBe(true); + }); + + it('filters out unsupported non-EVM chains not in ALLOWED_BRIDGE_CHAIN_IDS', () => { + const mockState = cloneDeep(mockRootState); + mockState.engine.backgroundState.RemoteFeatureFlagController.remoteFeatureFlags.bridgeConfigV2.chainRanking = + [ + ...mockState.engine.backgroundState.RemoteFeatureFlagController + .remoteFeatureFlags.bridgeConfigV2.chainRanking, + { chainId: 'cosmos:cosmoshub-4', name: 'Unsupported Cosmos Chain' }, + ]; + + const result = selectDestChainRanking(mockState as unknown as RootState); + + expect( + result.some((chain) => chain.chainId === 'cosmos:cosmoshub-4'), + ).toBe(false); + expect( + result.some( + (chain) => + chain.chainId === 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + ), + ).toBe(true); + }); + }); + + describe('selectIsBridgeEnabledSource - ALLOWED_BRIDGE_CHAIN_IDS filtering', () => { + it('returns false for a chain in chainRanking but not in ALLOWED_BRIDGE_CHAIN_IDS', () => { + const mockState = cloneDeep(mockRootState); + // Add an unsupported chain to chainRanking + mockState.engine.backgroundState.RemoteFeatureFlagController.remoteFeatureFlags.bridgeConfigV2.chainRanking = + [ + ...mockState.engine.backgroundState.RemoteFeatureFlagController + .remoteFeatureFlags.bridgeConfigV2.chainRanking, + { chainId: 'eip155:99999', name: 'Unsupported Future Chain' }, + ]; + + const result = selectIsBridgeEnabledSource( + mockState as unknown as RootState, + '0x1869F' as Hex, // hex for 99999 + ); + + expect(result).toBe(false); + }); }); describe('selectIsSwapsLive', () => { diff --git a/app/core/redux/slices/bridge/index.ts b/app/core/redux/slices/bridge/index.ts index 992bedd3fcc..44b3c8eab29 100644 --- a/app/core/redux/slices/bridge/index.ts +++ b/app/core/redux/slices/bridge/index.ts @@ -279,7 +279,26 @@ export const selectBridgeFeatureFlags = createSelector( ); /** - * Selector that returns the chainRanking from feature flags filtered by user-configured networks. + * Checks whether a CAIP chain ID from chainRanking is supported by this version of the client. + * This ensures that chains added to the remote chainRanking flag in the future + * won't be surfaced by older app versions that lack support for them. + */ +const isAllowedBridgeChainId = (caipChainId: string): boolean => { + if (caipChainId.startsWith('eip155:')) { + const hexChainId = formatChainIdToHex(caipChainId); + return ALLOWED_BRIDGE_CHAIN_IDS.includes( + hexChainId as AllowedBridgeChainIds, + ); + } + return ALLOWED_BRIDGE_CHAIN_IDS.includes( + caipChainId as AllowedBridgeChainIds, + ); +}; + +/** + * Selector that returns the chainRanking from feature flags filtered by: + * 1. Chains supported by this version of the client + * 2. User-configured networks * Used by NetworkPills in SOURCE mode to show all networks the user has added. */ export const selectSourceChainRanking = createSelector( @@ -297,6 +316,11 @@ export const selectSourceChainRanking = createSelector( return chainRanking.filter((chain) => { const { chainId } = chain; + // First, ensure this chain is supported by the current client version + if (!isAllowedBridgeChainId(chainId)) { + return false; + } + // For EVM chains (eip155:*), extract the hex chain ID and check if enabled if (chainId.startsWith('eip155:')) { const hexChainId = formatChainIdToHex(chainId); @@ -310,14 +334,17 @@ export const selectSourceChainRanking = createSelector( ); /** - * Selector that returns all chains from chainRanking (all bridge-supported networks). + * Selector that returns all chains from chainRanking that are supported by this + * version of the client. * Used by NetworkPills in DEST mode to show all available destination networks. */ export const selectDestChainRanking = createSelector( selectBridgeFeatureFlags, (bridgeFeatureFlags) => { const { chainRanking } = bridgeFeatureFlags; - return chainRanking ?? []; + return (chainRanking ?? []).filter((chain) => + isAllowedBridgeChainId(chain.chainId), + ); }, ); @@ -333,9 +360,9 @@ export const selectIsBridgeEnabledSourceFactory = createSelector( (bridgeFeatureFlags) => (chainId: Hex | CaipChainId) => { const caipChainId = formatChainIdToCaip(chainId); - return ( - bridgeFeatureFlags.support && - bridgeFeatureFlags.chains[caipChainId]?.isActiveSrc + return bridgeFeatureFlags.chainRanking?.some( + (chain) => + chain.chainId === caipChainId && isAllowedBridgeChainId(chain.chainId), ); }, ); From d22ac77f493f5df6501972bc06a8676c03509a05 Mon Sep 17 00:00:00 2001 From: Bryan Fullam Date: Mon, 9 Feb 2026 11:25:53 +0100 Subject: [PATCH 2/3] test: fix tests --- app/core/redux/slices/bridge/index.test.ts | 63 ++++++++++++++-------- 1 file changed, 42 insertions(+), 21 deletions(-) diff --git a/app/core/redux/slices/bridge/index.test.ts b/app/core/redux/slices/bridge/index.test.ts index 3134363c111..c3a88f50258 100644 --- a/app/core/redux/slices/bridge/index.test.ts +++ b/app/core/redux/slices/bridge/index.test.ts @@ -532,25 +532,28 @@ describe('bridge slice', () => { }); it('returns false when bridge is not enabled as source for the chain', () => { - const mockState = cloneDeep(mockRootState) as unknown as RootState; - // @ts-expect-error - Mock state has correct structure at runtime - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - mockState.engine.backgroundState.RemoteFeatureFlagController.remoteFeatureFlags.bridgeConfigV2!.chains[ - 'eip155:1' - ].isActiveSrc = false; + const mockState = cloneDeep(mockRootState); + // Remove chain from chainRanking to disable it (chainRanking presence = enabled) + mockState.engine.backgroundState.RemoteFeatureFlagController.remoteFeatureFlags.bridgeConfigV2.chainRanking = + mockState.engine.backgroundState.RemoteFeatureFlagController.remoteFeatureFlags.bridgeConfigV2.chainRanking.filter( + (chain) => chain.chainId !== 'eip155:1', + ); - const result = selectIsBridgeEnabledSource(mockState, '0x1'); + const result = selectIsBridgeEnabledSource( + mockState as unknown as RootState, + '0x1', + ); expect(result).toBe(false); }); - it('returns undefined when chain is not in bridge config', () => { + it('returns false when chain is not in bridge config', () => { const result = selectIsBridgeEnabledSource( mockRootState as unknown as RootState, '0x999' as Hex, ); - expect(result).toBeUndefined(); + expect(result).toBe(false); }); }); @@ -802,19 +805,25 @@ describe('bridge slice', () => { }); it('returns false when bridge is disabled for both source and destination', () => { - const mockState = cloneDeep(mockRootState) as unknown as RootState; - // @ts-expect-error - Mock state has correct structure at runtime - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - mockState.engine.backgroundState.RemoteFeatureFlagController.remoteFeatureFlags.bridgeConfigV2!.chains[ - 'eip155:1' - ].isActiveSrc = false; + const mockState = cloneDeep(mockRootState); + // Remove chain from chainRanking to disable source (chainRanking presence = enabled) + mockState.engine.backgroundState.RemoteFeatureFlagController.remoteFeatureFlags.bridgeConfigV2.chainRanking = + mockState.engine.backgroundState.RemoteFeatureFlagController.remoteFeatureFlags.bridgeConfigV2.chainRanking.filter( + (chain) => chain.chainId !== 'eip155:1', + ); + // Disable destination via chains config // @ts-expect-error - Mock state has correct structure at runtime // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - mockState.engine.backgroundState.RemoteFeatureFlagController.remoteFeatureFlags.bridgeConfigV2!.chains[ + ( + mockState as any + ).engine.backgroundState.RemoteFeatureFlagController.remoteFeatureFlags.bridgeConfigV2!.chains[ 'eip155:1' ].isActiveDest = false; - const result = selectIsSwapsLive(mockState, '0x1'); + const result = selectIsSwapsLive( + mockState as unknown as RootState, + '0x1', + ); expect(result).toBe(false); }); @@ -828,13 +837,25 @@ describe('bridge slice', () => { expect(result).toBeUndefined(); }); - it('returns false when support flag is disabled', () => { - const mockState = cloneDeep(mockRootState) as unknown as RootState; + it('returns false when support flag is disabled and source is not in chainRanking', () => { + const mockState = cloneDeep(mockRootState); + // Remove chain from chainRanking to disable source + mockState.engine.backgroundState.RemoteFeatureFlagController.remoteFeatureFlags.bridgeConfigV2.chainRanking = + mockState.engine.backgroundState.RemoteFeatureFlagController.remoteFeatureFlags.bridgeConfigV2.chainRanking.filter( + (chain) => chain.chainId !== 'eip155:1', + ); + // Disable destination via support flag // @ts-expect-error - Mock state has correct structure at runtime // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - mockState.engine.backgroundState.RemoteFeatureFlagController.remoteFeatureFlags.bridgeConfigV2!.support = false; + ( + mockState as any + ).engine.backgroundState.RemoteFeatureFlagController.remoteFeatureFlags.bridgeConfigV2!.support = + false; - const result = selectIsSwapsLive(mockState, '0x1'); + const result = selectIsSwapsLive( + mockState as unknown as RootState, + '0x1', + ); expect(result).toBe(false); }); From 3518d94ddfaa0e8a3fecdb9328c5c0e4e080a983 Mon Sep 17 00:00:00 2001 From: Bryan Fullam Date: Mon, 9 Feb 2026 11:38:48 +0100 Subject: [PATCH 3/3] chore: lint --- app/core/redux/slices/bridge/index.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/core/redux/slices/bridge/index.test.ts b/app/core/redux/slices/bridge/index.test.ts index c3a88f50258..1166148e533 100644 --- a/app/core/redux/slices/bridge/index.test.ts +++ b/app/core/redux/slices/bridge/index.test.ts @@ -812,7 +812,6 @@ describe('bridge slice', () => { (chain) => chain.chainId !== 'eip155:1', ); // Disable destination via chains config - // @ts-expect-error - Mock state has correct structure at runtime // eslint-disable-next-line @typescript-eslint/no-non-null-assertion ( mockState as any @@ -845,7 +844,6 @@ describe('bridge slice', () => { (chain) => chain.chainId !== 'eip155:1', ); // Disable destination via support flag - // @ts-expect-error - Mock state has correct structure at runtime // eslint-disable-next-line @typescript-eslint/no-non-null-assertion ( mockState as any