diff --git a/app/core/redux/slices/bridge/index.test.ts b/app/core/redux/slices/bridge/index.test.ts index 5745a235206..1166148e533 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, @@ -531,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); }); }); @@ -597,6 +601,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 +697,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', () => { @@ -671,19 +805,24 @@ 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; - // @ts-expect-error - Mock state has correct structure at runtime + 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 // 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); }); @@ -697,13 +836,24 @@ describe('bridge slice', () => { expect(result).toBeUndefined(); }); - it('returns false when support flag is disabled', () => { - const mockState = cloneDeep(mockRootState) as unknown as RootState; - // @ts-expect-error - Mock state has correct structure at runtime + 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 // 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); }); 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), ); }, );