Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
200 changes: 175 additions & 25 deletions app/core/redux/slices/bridge/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import reducer, {
selectIsBridgeEnabledDest,
selectIsSwapsLive,
selectDestChainRanking,
selectSourceChainRanking,
} from '.';
import {
BridgeToken,
Expand Down Expand Up @@ -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);
});
});

Expand Down Expand Up @@ -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(
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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);
});
Expand All @@ -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);
});
Expand Down
39 changes: 33 additions & 6 deletions app/core/redux/slices/bridge/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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);
Expand All @@ -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),
);
},
);

Expand All @@ -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),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Source bridge check drops support flag and isActiveSrc

High Severity

selectIsBridgeEnabledSourceFactory was rewritten to only check chainRanking presence and isAllowedBridgeChainId, but it no longer checks bridgeFeatureFlags.support (the global kill-switch) or bridgeFeatureFlags.chains[caipChainId]?.isActiveSrc (the per-chain source activation flag). This means setting support = false or isActiveSrc = false no longer disables bridge-as-source. Since selectIsSwapsLive uses isEnabledSource || isEnabledDest, the global kill-switch is effectively bypassed — swaps/bridge will appear live even when the backend has disabled them. Note that selectIsBridgeEnabledDest still correctly checks both support and isActiveDest, creating an asymmetry.

Additional Locations (1)

Fix in Cursor Fix in Web

);
},
);
Expand Down
Loading